From 17406eebeed72184edf98604438888f694648bee Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 14:25:08 -0400 Subject: [PATCH 01/67] docs(spec): add M5a Lidarr connection + search/add design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M5a is the foundation slice of M5 (Lidarr integration + quarantine). Decomposed into M5a/M5b/M5c during brainstorming on 2026-04-29: - M5a (this spec) — Lidarr connection + search/add + admin shell - M5b — Quarantine workflow (per-user soft-hide, admin resolution) - M5c — Radio suggested-additions (out-of-library MBIDs surfaced) Each ships as its own PR with its own brainstorm/spec/plan cycle. Key decisions captured: - Permissions: search-all, add-admin via request queue - Config: DB-only, Settings UI is the only entry point (no YAML) - Settings shape: dedicated /admin/* route group, hard route gate - Search UX: standalone /discover route, all three granularities - Lifecycle: library scan as source of truth + 5-min reconciler worker - Quality profile/root folder: default + per-add admin override - UI lands at FabledSword design system bar (forest-teal accent; Moss/Bronze/Oxblood action buttons; per project_design_system memory) Co-Authored-By: Claude Opus 4.7 --- .../specs/2026-04-29-m5a-lidarr-design.md | 345 ++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-29-m5a-lidarr-design.md diff --git a/docs/superpowers/specs/2026-04-29-m5a-lidarr-design.md b/docs/superpowers/specs/2026-04-29-m5a-lidarr-design.md new file mode 100644 index 00000000..2cd8ffc7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-29-m5a-lidarr-design.md @@ -0,0 +1,345 @@ +# 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: "", message: ""}` 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//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: `` 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) + +- `` — 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. From d77f2d44591b456150326bb6752efad502c7d48e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 15:01:20 -0400 Subject: [PATCH 02/67] docs(plan): add M5a Lidarr connection + search/add implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 21-task TDD plan implementing the M5a spec (committed in 17406ee). Tasks 1-4 are step-level (write test → run → implement → run → commit); Tasks 5-21 are paragraph-level with template references back to 1-4 to keep the plan navigable while preserving full coverage. Coverage: - Task 1: migration 0010 + sqlc queries - Task 2: internal/lidarr typed client - Task 3: internal/lidarrconfig singleton wrapper - Task 4: internal/lidarrrequests Service - Task 5: internal/lidarrrequests Reconciler worker - Task 6: RequireAdmin middleware - Tasks 7-10: API handlers (search, requests CRUD, admin lidarr, admin requests) - Task 11: cmd/minstrel main wires reconciler - Tasks 12-13: web design tokens + API clients - Tasks 14-15: DiscoverResultCard + StatusPill components - Tasks 16-20: /discover, /requests, /admin/* routes - Task 21: verification + branch finish Self-review checklist confirms every spec section maps to a task. Co-Authored-By: Claude Opus 4.7 --- .../plans/2026-04-29-m5a-lidarr.md | 1979 +++++++++++++++++ 1 file changed, 1979 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-29-m5a-lidarr.md diff --git a/docs/superpowers/plans/2026-04-29-m5a-lidarr.md b/docs/superpowers/plans/2026-04-29-m5a-lidarr.md new file mode 100644 index 00000000..f14e7cf0 --- /dev/null +++ b/docs/superpowers/plans/2026-04-29-m5a-lidarr.md @@ -0,0 +1,1979 @@ +# M5a — Lidarr connection + search/add + admin shell — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire Minstrel to a household Lidarr instance — search at `/discover`, request from any user, admin moderation queue at `/admin/requests`, automatic reconciliation when downloaded tracks land in the library. + +**Architecture:** New `internal/lidarr` HTTP client (typed, mirrors `internal/scrobble/listenbrainz`); `internal/lidarrconfig` singleton service backed by a CHECK-constrained DB row; `internal/lidarrrequests` with synchronous `Service` (Approve calls Lidarr, fires scan) plus async `Reconciler` worker (5-min tick, joins approved requests against new tracks by MBID); new `RequireAdmin` middleware on a dedicated `/api/admin/*` route group; SPA gets `/discover`, `/requests`, `/admin/integrations`, `/admin/requests` with hard route-level role gate, all surfaces drawn against the FabledSword design system. + +**Tech Stack:** Go 1.23 · chi router · pgx/v5 + sqlc · Postgres + golang-migrate · SvelteKit 2 / Svelte 5 (runes) · TanStack Query · Vitest · golangci-lint · FabledSword design tokens (Obsidian/Iron surfaces, Moss/Bronze/Oxblood actions, forest-teal #4A6B5C accent, Fraunces ≥18px / Inter / JetBrains Mono). + +**Spec:** [`docs/superpowers/specs/2026-04-29-m5a-lidarr-design.md`](../specs/2026-04-29-m5a-lidarr-design.md). Read it before starting — every decision is explained there. + +**Memory dependencies:** `project_design_system.md` (token palette + voice rules), `project_product_not_project.md` (no YAML for feature config), `project_ui_quality.md` (no scaffolding-feel), `project_subsonic_legacy.md` (`/api/*` is primary), `project_no_github.md` (Forgejo MCP for PR ops, not gh CLI). + +--- + +## File map + +### Backend — create + +- `internal/db/migrations/0010_lidarr.up.sql` · `0010_lidarr.down.sql` — schema +- `internal/db/queries/lidarr_config.sql` — sqlc queries for the singleton +- `internal/db/queries/lidarr_requests.sql` — sqlc queries for requests +- `internal/lidarr/client.go` — Lidarr HTTP client (`Client` struct + methods) +- `internal/lidarr/types.go` — typed request/response structs +- `internal/lidarr/errors.go` — typed sentinel errors +- `internal/lidarr/client_test.go` — unit tests with `httptest` stubs + JSON fixtures +- `internal/lidarr/testdata/*.json` — captured Lidarr responses +- `internal/lidarrconfig/service.go` — singleton config wrapper +- `internal/lidarrconfig/service_test.go` — integration test against `MINSTREL_TEST_DATABASE_URL` +- `internal/lidarrrequests/service.go` — request lifecycle service +- `internal/lidarrrequests/service_test.go` — integration test +- `internal/lidarrrequests/reconciler.go` — background worker +- `internal/lidarrrequests/reconciler_integration_test.go` — integration test +- `internal/auth/admin.go` — `RequireAdmin` middleware +- `internal/auth/admin_test.go` — middleware tests +- `internal/api/lidarr.go` — `GET /api/lidarr/search` proxy +- `internal/api/lidarr_test.go` +- `internal/api/requests.go` — `/api/requests` user-facing CRUD +- `internal/api/requests_test.go` +- `internal/api/admin_lidarr.go` — `/api/admin/lidarr/*` (config CRUD + test + profiles + folders) +- `internal/api/admin_lidarr_test.go` +- `internal/api/admin_requests.go` — `/api/admin/requests/*` approval queue +- `internal/api/admin_requests_test.go` + +### Backend — modify + +- `internal/api/api.go` — register routes, mount `/api/admin` group +- `internal/api/auth_test.go` — extend `testHandlers` to inject Lidarr client + lidarrrequests service +- `cmd/minstrel/main.go` — wire `lidarrrequests.Reconciler` worker +- `internal/db/dbq/*` — regenerated by `sqlc generate` + +### Frontend — create + +- `web/src/lib/styles/fabledsword-tokens.css` — `:root` CSS custom properties for all FS tokens +- `web/tailwind.config.js` — extend theme to alias semantic Tailwind utilities to FS tokens (modify, not create — but this slice may need to drop the existing alias defaults) +- `web/src/lib/api/lidarr.ts` — search client +- `web/src/lib/api/requests.ts` — request CRUD client +- `web/src/lib/api/admin.ts` — admin endpoints client +- `web/src/lib/components/DiscoverResultCard.svelte` — card with reserved badge slot + anchored button +- `web/src/lib/components/DiscoverResultCard.test.ts` +- `web/src/lib/components/StatusPill.svelte` — semantic status pill (Pending/Approved/Completed/Rejected) +- `web/src/lib/components/StatusPill.test.ts` +- `web/src/lib/components/AdminSidebar.svelte` — admin nav rail +- `web/src/routes/discover/+page.svelte` +- `web/src/routes/discover/discover.test.ts` +- `web/src/routes/requests/+page.svelte` +- `web/src/routes/requests/requests.test.ts` +- `web/src/routes/admin/+layout.svelte` — admin shell + sidebar +- `web/src/routes/admin/+layout.ts` — role gate (load function) +- `web/src/routes/admin/+page.svelte` — overview landing +- `web/src/routes/admin/integrations/+page.svelte` +- `web/src/routes/admin/integrations/integrations.test.ts` +- `web/src/routes/admin/requests/+page.svelte` +- `web/src/routes/admin/requests/requests.test.ts` + +### Frontend — modify + +- `web/src/lib/components/Shell.svelte` — add `/discover` to nav, conditional `/admin` link for admins +- `web/src/app.css` (or equivalent) — import the tokens file +- `web/src/app.html` — `` Google Fonts (Fraunces / Inter / JetBrains Mono) + +--- + +## Task list + +### Task 1 — Migration 0010 + sqlc queries + +**Files:** +- Create: `internal/db/migrations/0010_lidarr.up.sql` +- Create: `internal/db/migrations/0010_lidarr.down.sql` +- Create: `internal/db/queries/lidarr_config.sql` +- Create: `internal/db/queries/lidarr_requests.sql` +- Modify: `internal/db/dbq/*` (regenerated by `sqlc generate`) + +- [ ] **Step 1.1: Write the up migration** + +`internal/db/migrations/0010_lidarr.up.sql`: + +```sql +-- M5a: Lidarr integration foundation. Two tables: +-- +-- lidarr_config — singleton (CHECK id=1) holding the operator's Lidarr +-- connection. enabled=false is the unconfigured state. +-- +-- lidarr_requests — per-request lifecycle row created by users at +-- /discover, transitioned by admin at /admin/requests, and matched +-- back to library tracks by the reconciler worker. Three matched_*_id +-- FKs (one per kind) instead of polymorphic — clean SQL, ON DELETE +-- SET NULL preserves audit even if the matched track is later removed. + +CREATE TABLE lidarr_config ( + id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1), + enabled boolean NOT NULL DEFAULT false, + base_url text, + api_key text, + default_quality_profile_id int, + default_root_folder_path text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO lidarr_config (id, enabled) VALUES (1, false); + +CREATE TYPE lidarr_request_status AS ENUM ( + 'pending', 'approved', 'rejected', 'completed', 'failed' +); +CREATE TYPE lidarr_request_kind AS ENUM ('artist', 'album', 'track'); + +CREATE TABLE lidarr_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status lidarr_request_status NOT NULL DEFAULT 'pending', + kind lidarr_request_kind NOT NULL, + + lidarr_artist_mbid text NOT NULL, + lidarr_album_mbid text, + lidarr_track_mbid text, + artist_name text NOT NULL, + album_title text, + track_title text, + + quality_profile_id int, + root_folder_path text, + + decided_at timestamptz, + decided_by uuid REFERENCES users(id) ON DELETE SET NULL, + notes text, + + completed_at timestamptz, + matched_track_id uuid REFERENCES tracks(id) ON DELETE SET NULL, + matched_album_id uuid REFERENCES albums(id) ON DELETE SET NULL, + matched_artist_id uuid REFERENCES artists(id) ON DELETE SET NULL, + + requested_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX lidarr_requests_user_id_idx ON lidarr_requests (user_id); +CREATE INDEX lidarr_requests_status_idx ON lidarr_requests (status); +CREATE INDEX lidarr_requests_artist_mbid_idx ON lidarr_requests (lidarr_artist_mbid); +CREATE INDEX lidarr_requests_album_mbid_idx ON lidarr_requests (lidarr_album_mbid) + WHERE lidarr_album_mbid IS NOT NULL; +``` + +- [ ] **Step 1.2: Write the down migration** + +`internal/db/migrations/0010_lidarr.down.sql`: + +```sql +DROP INDEX IF EXISTS lidarr_requests_album_mbid_idx; +DROP INDEX IF EXISTS lidarr_requests_artist_mbid_idx; +DROP INDEX IF EXISTS lidarr_requests_status_idx; +DROP INDEX IF EXISTS lidarr_requests_user_id_idx; +DROP TABLE IF EXISTS lidarr_requests; +DROP TYPE IF EXISTS lidarr_request_kind; +DROP TYPE IF EXISTS lidarr_request_status; +DROP TABLE IF EXISTS lidarr_config; +``` + +- [ ] **Step 1.3: Apply migration locally to confirm it runs** + +```bash +docker compose exec -T postgres psql -U minstrel -d minstrel -c "DROP TABLE IF EXISTS lidarr_requests; DROP TYPE IF EXISTS lidarr_request_kind; DROP TYPE IF EXISTS lidarr_request_status; DROP TABLE IF EXISTS lidarr_config;" +go run ./cmd/minstrel/migrate.go 2>/dev/null || go run ./cmd/minstrel up +docker compose exec -T postgres psql -U minstrel -d minstrel -c "\d lidarr_requests" +``` + +Expected: `\d lidarr_requests` shows the table with all columns and the four indexes. + +If your project doesn't have a standalone migrate command, the migration applies on server start via `db.Migrate(...)` — restart the minstrel container instead. + +- [ ] **Step 1.4: Write `lidarr_config.sql` queries** + +`internal/db/queries/lidarr_config.sql`: + +```sql +-- name: GetLidarrConfig :one +SELECT id, enabled, base_url, api_key, default_quality_profile_id, + default_root_folder_path, created_at, updated_at +FROM lidarr_config +WHERE id = 1; + +-- name: UpdateLidarrConfig :one +UPDATE lidarr_config + SET enabled = $1, + base_url = $2, + api_key = $3, + default_quality_profile_id = $4, + default_root_folder_path = $5, + updated_at = now() + WHERE id = 1 + RETURNING id, enabled, base_url, api_key, default_quality_profile_id, + default_root_folder_path, created_at, updated_at; +``` + +- [ ] **Step 1.5: Write `lidarr_requests.sql` queries** + +`internal/db/queries/lidarr_requests.sql`: + +```sql +-- name: CreateLidarrRequest :one +INSERT INTO lidarr_requests ( + user_id, kind, + lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, + artist_name, album_title, track_title +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING *; + +-- name: GetLidarrRequestByID :one +SELECT * FROM lidarr_requests WHERE id = $1; + +-- name: ListLidarrRequestsForUser :many +SELECT * FROM lidarr_requests +WHERE user_id = $1 +ORDER BY requested_at DESC +LIMIT $2; + +-- name: ListLidarrRequestsByStatus :many +SELECT * FROM lidarr_requests +WHERE status = $1 +ORDER BY requested_at DESC +LIMIT $2; + +-- name: ListApprovedLidarrRequestsForReconcile :many +SELECT * FROM lidarr_requests +WHERE status = 'approved' +ORDER BY decided_at ASC +LIMIT $1; + +-- name: ApproveLidarrRequest :one +UPDATE lidarr_requests + SET status = 'approved', + quality_profile_id = $2, + root_folder_path = $3, + decided_at = now(), + decided_by = $4, + updated_at = now() + WHERE id = $1 AND status = 'pending' + RETURNING *; + +-- name: RejectLidarrRequest :one +UPDATE lidarr_requests + SET status = 'rejected', + notes = $2, + decided_at = now(), + decided_by = $3, + updated_at = now() + WHERE id = $1 AND status = 'pending' + RETURNING *; + +-- name: CancelLidarrRequest :one +UPDATE lidarr_requests + SET status = 'rejected', + notes = 'cancelled by user', + decided_at = now(), + decided_by = $2, + updated_at = now() + WHERE id = $1 AND user_id = $2 AND status = 'pending' + RETURNING *; + +-- name: CompleteLidarrRequest :one +-- Reconciler transitions an approved request to completed when its +-- target track/album/artist has appeared in the library. +UPDATE lidarr_requests + SET status = 'completed', + matched_track_id = $2, + matched_album_id = $3, + matched_artist_id = $4, + completed_at = now(), + updated_at = now() + WHERE id = $1 AND status = 'approved' + RETURNING *; + +-- name: HasNonTerminalRequestForMBID :one +-- Returns true if any user has a pending/approved/completed request +-- whose MBID matches at the given level. Used to set the `requested` +-- flag on /api/lidarr/search responses. Terminal-status (rejected, +-- failed) rows do not count. +SELECT EXISTS ( + SELECT 1 FROM lidarr_requests + WHERE status IN ('pending', 'approved', 'completed') + AND ((kind = 'artist' AND lidarr_artist_mbid = $1) + OR (kind = 'album' AND lidarr_album_mbid = $1) + OR (kind = 'track' AND lidarr_track_mbid = $1)) +); +``` + +- [ ] **Step 1.6: Run sqlc generate** + +```bash +cd internal/db && sqlc generate && cd - +go build ./... +``` + +Expected: `internal/db/dbq/lidarr_config.sql.go` and `internal/db/dbq/lidarr_requests.sql.go` are created. Build succeeds. + +- [ ] **Step 1.7: Commit** + +```bash +git add internal/db/migrations/0010_lidarr.up.sql \ + internal/db/migrations/0010_lidarr.down.sql \ + internal/db/queries/lidarr_config.sql \ + internal/db/queries/lidarr_requests.sql \ + internal/db/dbq/ +git commit -m "feat(db): add lidarr_config + lidarr_requests schema (migration 0010)" +``` + +--- + +### Task 2 — Lidarr HTTP client + +**Files:** +- Create: `internal/lidarr/types.go` +- Create: `internal/lidarr/errors.go` +- Create: `internal/lidarr/client.go` +- Create: `internal/lidarr/client_test.go` +- Create: `internal/lidarr/testdata/lookup_artist.json` +- Create: `internal/lidarr/testdata/quality_profiles.json` +- Create: `internal/lidarr/testdata/root_folders.json` + +- [ ] **Step 2.1: Write the typed structs** + +`internal/lidarr/types.go`: + +```go +// Package lidarr is a typed HTTP client for Lidarr's v1 API. It is the +// only place in the codebase that knows about Lidarr's wire format. +// Callers receive value structs, never raw JSON. +package lidarr + +// LookupResult is the normalized shape returned by Lookup{Artist,Album,Track}. +// It is what we store on the request row (via the user's request) and +// what /api/lidarr/search returns to the SPA. +type LookupResult struct { + MBID string // foreignArtistId / foreignAlbumId / foreignTrackId + Name string // artist name; album/track returns Title here too + Secondary string // genre + album count for artist; year for album; album for track + ImageURL string // cover-art URL Lidarr surfaced (may be empty) +} + +// QualityProfile is the dropdown choice in /admin/integrations. +type QualityProfile struct { + ID int + Name string +} + +// RootFolder is the dropdown choice in /admin/integrations. +type RootFolder struct { + Path string + Accessible bool + FreeSpace int64 +} + +// AddArtistParams are the fields Lidarr requires on POST /api/v1/artist. +type AddArtistParams struct { + ForeignArtistID string + QualityProfileID int + RootFolderPath string + MonitorAll bool // true => Monitored="all"; false => "future" +} + +// AddAlbumParams are the fields Lidarr requires on POST /api/v1/album. +type AddAlbumParams struct { + ForeignAlbumID string + ForeignArtistID string // Lidarr requires the artist's foreign id too + QualityProfileID int + RootFolderPath string +} + +// PingResult is the response shape from GET /api/v1/system/status. +type PingResult struct { + Version string +} +``` + +- [ ] **Step 2.2: Write the typed errors** + +`internal/lidarr/errors.go`: + +```go +package lidarr + +import "errors" + +// Sentinel errors. Callers branch on these via errors.Is, not on +// HTTP status codes — the client maps codes to errors. +var ( + ErrUnreachable = errors.New("lidarr: unreachable") + ErrAuthFailed = errors.New("lidarr: auth failed") // 401 / 403 + ErrLookupFailed = errors.New("lidarr: lookup failed") // 4xx other than 401/403 + ErrServerError = errors.New("lidarr: server error") // 5xx + ErrInvalidPayload = errors.New("lidarr: invalid payload") +) +``` + +- [ ] **Step 2.3: Write the client skeleton + LookupArtist** + +`internal/lidarr/client.go`: + +```go +package lidarr + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" +) + +// Client wraps Lidarr's v1 HTTP API. BaseURL is the Lidarr instance +// (e.g. http://lidarr.lan:8686), APIKey comes from Lidarr's settings. +type Client struct { + BaseURL string + APIKey string + HTTP *http.Client +} + +func (c *Client) get(ctx context.Context, path string, q url.Values) (*http.Response, error) { + u, err := url.Parse(c.BaseURL) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) + } + u.Path = u.Path + path + if q != nil { + u.RawQuery = q.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Api-Key", c.APIKey) + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) + } + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + _ = resp.Body.Close() + return nil, ErrAuthFailed + } + if resp.StatusCode >= 500 { + _ = resp.Body.Close() + return nil, ErrServerError + } + if resp.StatusCode >= 400 { + _ = resp.Body.Close() + return nil, ErrLookupFailed + } + return resp, nil +} + +// LookupArtist hits Lidarr GET /api/v1/artist/lookup?term=. Returns +// normalized LookupResults from the response. +func (c *Client) LookupArtist(ctx context.Context, term string) ([]LookupResult, error) { + resp, err := c.get(ctx, "/api/v1/artist/lookup", url.Values{"term": {term}}) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + var raw []struct { + ForeignArtistID string `json:"foreignArtistId"` + ArtistName string `json:"artistName"` + Genres []string `json:"genres"` + AlbumCount int `json:"albumCount"` + Images []struct { + CoverType string `json:"coverType"` + RemoteURL string `json:"remoteUrl"` + URL string `json:"url"` + } `json:"images"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + out := make([]LookupResult, 0, len(raw)) + for _, r := range raw { + secondary := "" + if len(r.Genres) > 0 { + secondary = r.Genres[0] + } + if r.AlbumCount > 0 { + if secondary != "" { + secondary += " · " + } + secondary += strconv.Itoa(r.AlbumCount) + " albums" + } + out = append(out, LookupResult{ + MBID: r.ForeignArtistID, + Name: r.ArtistName, + Secondary: secondary, + ImageURL: pickPosterImage(r.Images), + }) + } + return out, nil +} + +func pickPosterImage(imgs []struct { + CoverType string `json:"coverType"` + RemoteURL string `json:"remoteUrl"` + URL string `json:"url"` +}) string { + for _, img := range imgs { + if img.CoverType == "poster" { + if img.RemoteURL != "" { + return img.RemoteURL + } + return img.URL + } + } + return "" +} + +// Ensure interface implementations stay consistent. +var _ = errors.Is +``` + +- [ ] **Step 2.4: Add a captured Lidarr lookup response to testdata** + +`internal/lidarr/testdata/lookup_artist.json`: + +```json +[ + { + "foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", + "artistName": "Boards of Canada", + "genres": ["Electronic", "IDM"], + "albumCount": 18, + "images": [ + {"coverType": "poster", "remoteUrl": "https://example.invalid/boc.jpg"}, + {"coverType": "banner", "remoteUrl": "https://example.invalid/boc-banner.jpg"} + ] + }, + { + "foreignArtistId": "f54ba20c-7da3-4b8a-9b12-22f09b9e2c1c", + "artistName": "Bored of Education", + "genres": [], + "albumCount": 0, + "images": [] + } +] +``` + +- [ ] **Step 2.5: Write the LookupArtist test** + +`internal/lidarr/client_test.go`: + +```go +package lidarr + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func TestLookupArtist_HappyPath(t *testing.T) { + body, err := os.ReadFile("testdata/lookup_artist.json") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Api-Key"); got != "key123" { + t.Errorf("api key = %q, want key123", got) + } + if r.URL.Path != "/api/v1/artist/lookup" { + t.Errorf("path = %q", r.URL.Path) + } + if r.URL.Query().Get("term") != "boards" { + t.Errorf("term = %q", r.URL.Query().Get("term")) + } + _, _ = w.Write(body) + })) + defer srv.Close() + c := &Client{BaseURL: srv.URL, APIKey: "key123", HTTP: srv.Client()} + got, err := c.LookupArtist(context.Background(), "boards") + if err != nil { + t.Fatalf("LookupArtist: %v", err) + } + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0].Name != "Boards of Canada" { + t.Errorf("name = %q", got[0].Name) + } + if got[0].Secondary != "Electronic · 18 albums" { + t.Errorf("secondary = %q", got[0].Secondary) + } + if got[0].ImageURL != "https://example.invalid/boc.jpg" { + t.Errorf("image = %q", got[0].ImageURL) + } + if got[1].Secondary != "" { + t.Errorf("expected empty secondary for empty genres + 0 albums; got %q", got[1].Secondary) + } +} + +func TestLookupArtist_AuthFailed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + c := &Client{BaseURL: srv.URL, APIKey: "x", HTTP: srv.Client()} + _, err := c.LookupArtist(context.Background(), "boards") + if !errors.Is(err, ErrAuthFailed) { + t.Fatalf("err = %v, want ErrAuthFailed", err) + } +} + +func TestLookupArtist_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + c := &Client{BaseURL: srv.URL, APIKey: "x", HTTP: srv.Client()} + _, err := c.LookupArtist(context.Background(), "boards") + if !errors.Is(err, ErrServerError) { + t.Fatalf("err = %v, want ErrServerError", err) + } +} + +func TestLookupArtist_Unreachable(t *testing.T) { + c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "x", HTTP: &http.Client{}} + _, err := c.LookupArtist(context.Background(), "boards") + if !errors.Is(err, ErrUnreachable) { + t.Fatalf("err = %v, want ErrUnreachable", err) + } +} +``` + +- [ ] **Step 2.6: Run the tests, fix until green** + +```bash +go test -race -v ./internal/lidarr/... +``` + +Expected: 4 tests pass. + +- [ ] **Step 2.7: Add LookupAlbum and LookupTrack methods** + +Append to `internal/lidarr/client.go`: + +```go +// LookupAlbum hits GET /api/v1/album/lookup?term=. Returns normalized +// LookupResults; Secondary is "year · trackcount". +func (c *Client) LookupAlbum(ctx context.Context, term string) ([]LookupResult, error) { + resp, err := c.get(ctx, "/api/v1/album/lookup", url.Values{"term": {term}}) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + var raw []struct { + ForeignAlbumID string `json:"foreignAlbumId"` + ForeignArtistID string `json:"foreignArtistId"` + Title string `json:"title"` + ArtistName string `json:"artistName"` + ReleaseDate string `json:"releaseDate"` + TrackCount int `json:"trackCount"` + Images []struct { + CoverType string `json:"coverType"` + RemoteURL string `json:"remoteUrl"` + URL string `json:"url"` + } `json:"images"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + out := make([]LookupResult, 0, len(raw)) + for _, r := range raw { + year := "" + if len(r.ReleaseDate) >= 4 { + year = r.ReleaseDate[:4] + } + secondary := r.ArtistName + if year != "" { + secondary += " · " + year + } + if r.TrackCount > 0 { + secondary += " · " + strconv.Itoa(r.TrackCount) + " tracks" + } + out = append(out, LookupResult{ + MBID: r.ForeignAlbumID, + Name: r.Title, + Secondary: secondary, + ImageURL: pickPosterImage(r.Images), + }) + } + return out, nil +} + +// LookupTrack hits GET /api/v1/track/lookup?term=. Lidarr's track +// lookup is per-album under the hood — Secondary is "album · artist". +func (c *Client) LookupTrack(ctx context.Context, term string) ([]LookupResult, error) { + resp, err := c.get(ctx, "/api/v1/track/lookup", url.Values{"term": {term}}) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + var raw []struct { + ForeignTrackID string `json:"foreignTrackId"` + ForeignAlbumID string `json:"foreignAlbumId"` + ForeignArtistID string `json:"foreignArtistId"` + Title string `json:"title"` + AlbumTitle string `json:"albumTitle"` + ArtistName string `json:"artistName"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + out := make([]LookupResult, 0, len(raw)) + for _, r := range raw { + secondary := r.AlbumTitle + if r.ArtistName != "" { + if secondary != "" { + secondary += " · " + } + secondary += r.ArtistName + } + out = append(out, LookupResult{ + MBID: r.ForeignTrackID, + Name: r.Title, + Secondary: secondary, + }) + } + return out, nil +} +``` + +- [ ] **Step 2.8: Add tests for LookupAlbum and LookupTrack** + +Use the same `httptest`+fixture pattern. Capture two more JSON files (`testdata/lookup_album.json`, `testdata/lookup_track.json`) with at least 2 results each, then add `TestLookupAlbum_HappyPath` and `TestLookupTrack_HappyPath` mirroring `TestLookupArtist_HappyPath`. Auth/server-error variants are unchanged so don't duplicate them — one parametric helper test suffices. + +- [ ] **Step 2.9: Add AddArtist, AddAlbum, ListQualityProfiles, ListRootFolders, Ping** + +Append to `internal/lidarr/client.go`: + +```go +// post is the shared POST helper. Body is marshaled JSON. +func (c *Client) post(ctx context.Context, path string, body []byte) (*http.Response, error) { + u, err := url.Parse(c.BaseURL) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) + } + u.Path = u.Path + path + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("X-Api-Key", c.APIKey) + req.Header.Set("Content-Type", "application/json") + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) + } + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + _ = resp.Body.Close() + return nil, ErrAuthFailed + } + if resp.StatusCode >= 500 { + _ = resp.Body.Close() + return nil, ErrServerError + } + if resp.StatusCode >= 400 { + _ = resp.Body.Close() + return nil, ErrLookupFailed + } + return resp, nil +} + +func (c *Client) AddArtist(ctx context.Context, p AddArtistParams) error { + monitor := "future" + if p.MonitorAll { + monitor = "all" + } + body, _ := json.Marshal(map[string]any{ + "foreignArtistId": p.ForeignArtistID, + "qualityProfileId": p.QualityProfileID, + "rootFolderPath": p.RootFolderPath, + "monitored": true, + "monitor": monitor, + "addOptions": map[string]any{"searchForMissingAlbums": true}, + }) + resp, err := c.post(ctx, "/api/v1/artist", body) + if err != nil { + return err + } + _ = resp.Body.Close() + return nil +} + +func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error { + body, _ := json.Marshal(map[string]any{ + "foreignAlbumId": p.ForeignAlbumID, + "foreignArtistId": p.ForeignArtistID, + "qualityProfileId": p.QualityProfileID, + "rootFolderPath": p.RootFolderPath, + "monitored": true, + "addOptions": map[string]any{"searchForNewAlbum": true}, + }) + resp, err := c.post(ctx, "/api/v1/album", body) + if err != nil { + return err + } + _ = resp.Body.Close() + return nil +} + +func (c *Client) ListQualityProfiles(ctx context.Context) ([]QualityProfile, error) { + resp, err := c.get(ctx, "/api/v1/qualityprofile", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + var raw []struct { + ID int `json:"id"` + Name string `json:"name"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + out := make([]QualityProfile, len(raw)) + for i, r := range raw { + out[i] = QualityProfile{ID: r.ID, Name: r.Name} + } + return out, nil +} + +func (c *Client) ListRootFolders(ctx context.Context) ([]RootFolder, error) { + resp, err := c.get(ctx, "/api/v1/rootfolder", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + var raw []struct { + Path string `json:"path"` + Accessible bool `json:"accessible"` + FreeSpace int64 `json:"freeSpace"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + out := make([]RootFolder, len(raw)) + for i, r := range raw { + out[i] = RootFolder{Path: r.Path, Accessible: r.Accessible, FreeSpace: r.FreeSpace} + } + return out, nil +} + +func (c *Client) Ping(ctx context.Context) (PingResult, error) { + resp, err := c.get(ctx, "/api/v1/system/status", nil) + if err != nil { + return PingResult{}, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + var raw struct { + Version string `json:"version"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + return PingResult{Version: raw.Version}, nil +} +``` + +Add `"bytes"` to the imports. + +- [ ] **Step 2.10: Add tests for the remaining methods** + +For each: capture or hand-write a fixture, add `TestAddArtist_PostsCorrectBody`, `TestAddAlbum_PostsCorrectBody`, `TestListQualityProfiles_HappyPath`, `TestListRootFolders_HappyPath`, `TestPing_ReturnsVersion`. The Add* tests should assert on the parsed POST body — read `r.Body`, decode JSON, check the field shape. + +- [ ] **Step 2.11: Run all client tests** + +```bash +go test -race -cover ./internal/lidarr/... +``` + +Expected: all tests pass; coverage ≥ 80%. + +- [ ] **Step 2.12: Commit** + +```bash +git add internal/lidarr/ +git commit -m "feat(lidarr): typed HTTP client for v1 API (lookup, add, profiles, ping)" +``` + +--- + +### Task 3 — `lidarrconfig` singleton service + +**Files:** +- Create: `internal/lidarrconfig/service.go` +- Create: `internal/lidarrconfig/service_test.go` + +- [ ] **Step 3.1: Write the service** + +`internal/lidarrconfig/service.go`: + +```go +// Package lidarrconfig is a thin wrapper over the singleton lidarr_config +// row. Get returns a typed Config; Save updates it. Callers branch on +// Config.Enabled — never on raw NULL fields. +package lidarrconfig + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Config is the typed projection of lidarr_config (no NULL fields exposed +// to callers — empty strings / zero ints carry the "unset" meaning). +type Config struct { + Enabled bool + BaseURL string + APIKey string + DefaultQualityProfileID int + DefaultRootFolderPath string +} + +// Service reads and writes the singleton. +type Service struct { + pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Service { return &Service{pool: pool} } + +func (s *Service) Get(ctx context.Context) (Config, error) { + row, err := dbq.New(s.pool).GetLidarrConfig(ctx) + if err != nil { + return Config{}, fmt.Errorf("lidarrconfig: %w", err) + } + cfg := Config{Enabled: row.Enabled} + if row.BaseUrl != nil { + cfg.BaseURL = *row.BaseUrl + } + if row.ApiKey != nil { + cfg.APIKey = *row.ApiKey + } + if row.DefaultQualityProfileID != nil { + cfg.DefaultQualityProfileID = int(*row.DefaultQualityProfileID) + } + if row.DefaultRootFolderPath != nil { + cfg.DefaultRootFolderPath = *row.DefaultRootFolderPath + } + return cfg, nil +} + +// Save writes the entire row. Callers pass the full Config they want +// stored — this is not a partial update. +func (s *Service) Save(ctx context.Context, cfg Config) error { + var ( + baseURL *string = strPtr(cfg.BaseURL) + apiKey *string = strPtr(cfg.APIKey) + qpID *int32 = int32Ptr(cfg.DefaultQualityProfileID) + rootPath *string = strPtr(cfg.DefaultRootFolderPath) + ) + _, err := dbq.New(s.pool).UpdateLidarrConfig(ctx, dbq.UpdateLidarrConfigParams{ + Enabled: cfg.Enabled, + BaseUrl: baseURL, + ApiKey: apiKey, + DefaultQualityProfileID: qpID, + DefaultRootFolderPath: rootPath, + }) + if err != nil { + return fmt.Errorf("lidarrconfig: %w", err) + } + return nil +} + +func strPtr(s string) *string { + if s == "" { + return nil + } + return &s +} + +func int32Ptr(i int) *int32 { + if i == 0 { + return nil + } + v := int32(i) + return &v +} +``` + +Note: the exact field names on `dbq.UpdateLidarrConfigParams` depend on sqlc's generation. After `sqlc generate` ran in Task 1, you'll see them — adjust the literal field names here to match. Pointer-vs-value also depends on how sqlc treats nullable text columns. + +- [ ] **Step 3.2: Write the integration test** + +`internal/lidarrconfig/service_test.go`: + +```go +package lidarrconfig + +import ( + "context" + "io" + "log/slog" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db" +) + +func newTestPool(t *testing.T) *pgxpool.Pool { + t.Helper() + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + // Reset singleton to default state before each test by replacing + // the row contents via UPDATE (TRUNCATE would violate the CHECK). + if _, err := pool.Exec(context.Background(), + "UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL, default_quality_profile_id=NULL, default_root_folder_path=NULL WHERE id=1", + ); err != nil { + t.Fatalf("reset: %v", err) + } + return pool +} + +func TestGet_DefaultRowReturnsZeroValueConfig(t *testing.T) { + pool := newTestPool(t) + cfg, err := New(pool).Get(context.Background()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if cfg.Enabled || cfg.BaseURL != "" || cfg.APIKey != "" { + t.Errorf("expected zero-value Config, got %+v", cfg) + } +} + +func TestSaveThenGet_RoundTrip(t *testing.T) { + pool := newTestPool(t) + s := New(pool) + want := Config{ + Enabled: true, + BaseURL: "http://lidarr.lan:8686", + APIKey: "secret", + DefaultQualityProfileID: 4, + DefaultRootFolderPath: "/music", + } + if err := s.Save(context.Background(), want); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := s.Get(context.Background()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got != want { + t.Errorf("round-trip mismatch:\n got = %+v\nwant = %+v", got, want) + } +} + +func TestSave_EmptyValuesPersistAsNULL(t *testing.T) { + pool := newTestPool(t) + s := New(pool) + if err := s.Save(context.Background(), Config{Enabled: false}); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := s.Get(context.Background()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.BaseURL != "" || got.APIKey != "" || got.DefaultRootFolderPath != "" { + t.Errorf("expected empty strings on round-trip; got %+v", got) + } +} +``` + +- [ ] **Step 3.3: Run the tests inside the docker network** + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test -race ./internal/lidarrconfig/... +``` + +Expected: 3 tests pass. + +- [ ] **Step 3.4: Commit** + +```bash +git add internal/lidarrconfig/ +git commit -m "feat(lidarrconfig): typed singleton config wrapper" +``` + +--- + +### Task 4 — `lidarrrequests` Service (lifecycle, no reconciler) + +**Files:** +- Create: `internal/lidarrrequests/service.go` +- Create: `internal/lidarrrequests/service_test.go` + +- [ ] **Step 4.1: Write the Service** + +`internal/lidarrrequests/service.go`: + +```go +// Package lidarrrequests owns the lifecycle of user requests to add +// music via Lidarr. The synchronous Service handles Create/List/ +// Approve/Reject/Cancel; the async Reconciler (separate file) closes +// approved requests once their target track lands in the library. +package lidarrrequests + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" +) + +// Public errors. Handlers map these to API error codes. +var ( + ErrInvalidKindFields = errors.New("lidarrrequests: missing required fields for kind") + ErrNotPending = errors.New("lidarrrequests: request is not pending") + ErrNotFound = errors.New("lidarrrequests: request not found") + ErrLidarrDisabled = errors.New("lidarrrequests: lidarr not configured") +) + +// CreateParams is the input for a new request from a user. +type CreateParams struct { + Kind string // "artist", "album", or "track" + LidarrArtistMBID string + LidarrAlbumMBID string // required for kind=album/track + LidarrTrackMBID string // required for kind=track + ArtistName string + AlbumTitle string // required for kind=album/track + TrackTitle string // required for kind=track +} + +// ApproveOverrides lets the admin override the snapshot defaults for one +// approval. Zero values mean "use config default." +type ApproveOverrides struct { + QualityProfileID int + RootFolderPath string +} + +type Service struct { + pool *pgxpool.Pool + lidarrCfg *lidarrconfig.Service + client *lidarr.Client + scanFn func() // injected; called after Approve to trigger a library scan +} + +func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, client *lidarr.Client, scanFn func()) *Service { + if scanFn == nil { + scanFn = func() {} + } + return &Service{pool: pool, lidarrCfg: cfg, client: client, scanFn: scanFn} +} + +// Create validates the kind→required-fields invariant and inserts a +// pending row. +func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.LidarrRequest, error) { + if err := validateKindFields(p); err != nil { + return dbq.LidarrRequest{}, err + } + q := dbq.New(s.pool) + row, err := q.CreateLidarrRequest(ctx, dbq.CreateLidarrRequestParams{ + UserID: userID, + Kind: dbq.LidarrRequestKind(p.Kind), + LidarrArtistMbid: p.LidarrArtistMBID, + LidarrAlbumMbid: strPtr(p.LidarrAlbumMBID), + LidarrTrackMbid: strPtr(p.LidarrTrackMBID), + ArtistName: p.ArtistName, + AlbumTitle: strPtr(p.AlbumTitle), + TrackTitle: strPtr(p.TrackTitle), + }) + if err != nil { + return dbq.LidarrRequest{}, fmt.Errorf("create: %w", err) + } + return row, nil +} + +func validateKindFields(p CreateParams) error { + if p.LidarrArtistMBID == "" || p.ArtistName == "" { + return fmt.Errorf("%w: artist_mbid and artist_name are always required", ErrInvalidKindFields) + } + switch p.Kind { + case "artist": + // fine + case "album": + if p.LidarrAlbumMBID == "" || p.AlbumTitle == "" { + return fmt.Errorf("%w: album kind requires album_mbid and album_title", ErrInvalidKindFields) + } + case "track": + if p.LidarrAlbumMBID == "" || p.AlbumTitle == "" { + return fmt.Errorf("%w: track kind requires album_mbid and album_title (track will be promoted)", ErrInvalidKindFields) + } + if p.LidarrTrackMBID == "" || p.TrackTitle == "" { + return fmt.Errorf("%w: track kind requires track_mbid and track_title", ErrInvalidKindFields) + } + default: + return fmt.Errorf("%w: unknown kind %q", ErrInvalidKindFields, p.Kind) + } + return nil +} + +func (s *Service) ListPending(ctx context.Context, limit int32) ([]dbq.LidarrRequest, error) { + return dbq.New(s.pool).ListLidarrRequestsByStatus(ctx, dbq.ListLidarrRequestsByStatusParams{ + Status: dbq.LidarrRequestStatusPending, Limit: limit, + }) +} + +func (s *Service) ListByStatus(ctx context.Context, status string, limit int32) ([]dbq.LidarrRequest, error) { + return dbq.New(s.pool).ListLidarrRequestsByStatus(ctx, dbq.ListLidarrRequestsByStatusParams{ + Status: dbq.LidarrRequestStatus(status), Limit: limit, + }) +} + +func (s *Service) ListForUser(ctx context.Context, userID pgtype.UUID, limit int32) ([]dbq.LidarrRequest, error) { + return dbq.New(s.pool).ListLidarrRequestsForUser(ctx, dbq.ListLidarrRequestsForUserParams{ + UserID: userID, Limit: limit, + }) +} + +// Approve transitions a pending request to approved, snapshotting the +// chosen quality profile + root folder, then calls Lidarr to actually +// add the artist/album, then triggers a library scan. If Lidarr returns +// an error, the request stays pending — the admin sees the error and +// can retry without losing the request. +func (s *Service) Approve(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, ov ApproveOverrides) (dbq.LidarrRequest, error) { + cfg, err := s.lidarrCfg.Get(ctx) + if err != nil { + return dbq.LidarrRequest{}, fmt.Errorf("approve: load config: %w", err) + } + if !cfg.Enabled || s.client == nil { + return dbq.LidarrRequest{}, ErrLidarrDisabled + } + row, err := dbq.New(s.pool).GetLidarrRequestByID(ctx, requestID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return dbq.LidarrRequest{}, ErrNotFound + } + return dbq.LidarrRequest{}, fmt.Errorf("approve: get: %w", err) + } + if row.Status != dbq.LidarrRequestStatusPending { + return dbq.LidarrRequest{}, ErrNotPending + } + + qp := cfg.DefaultQualityProfileID + if ov.QualityProfileID != 0 { + qp = ov.QualityProfileID + } + rf := cfg.DefaultRootFolderPath + if ov.RootFolderPath != "" { + rf = ov.RootFolderPath + } + + switch row.Kind { + case dbq.LidarrRequestKindArtist: + err = s.client.AddArtist(ctx, lidarr.AddArtistParams{ + ForeignArtistID: row.LidarrArtistMbid, QualityProfileID: qp, RootFolderPath: rf, MonitorAll: true, + }) + case dbq.LidarrRequestKindAlbum, dbq.LidarrRequestKindTrack: + // Track-kind requests promote to album-add; the spec is explicit. + albumMBID := "" + if row.LidarrAlbumMbid != nil { + albumMBID = *row.LidarrAlbumMbid + } + err = s.client.AddAlbum(ctx, lidarr.AddAlbumParams{ + ForeignAlbumID: albumMBID, ForeignArtistID: row.LidarrArtistMbid, + QualityProfileID: qp, RootFolderPath: rf, + }) + } + if err != nil { + return dbq.LidarrRequest{}, fmt.Errorf("approve: lidarr add: %w", err) + } + + approved, err := dbq.New(s.pool).ApproveLidarrRequest(ctx, dbq.ApproveLidarrRequestParams{ + ID: requestID, + QualityProfileID: int32Ptr(qp), + RootFolderPath: strPtr(rf), + DecidedBy: uuidPtr(adminID), + }) + if err != nil { + // Lidarr accepted but our DB update failed; admin should retry. + return dbq.LidarrRequest{}, fmt.Errorf("approve: persist: %w", err) + } + s.scanFn() + return approved, nil +} + +func (s *Service) Reject(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, notes string) (dbq.LidarrRequest, error) { + row, err := dbq.New(s.pool).RejectLidarrRequest(ctx, dbq.RejectLidarrRequestParams{ + ID: requestID, Notes: strPtr(notes), DecidedBy: uuidPtr(adminID), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + // Either not found OR not pending — caller can't distinguish from + // the SQL alone, so check after. + cur, gerr := dbq.New(s.pool).GetLidarrRequestByID(ctx, requestID) + if gerr != nil { + return dbq.LidarrRequest{}, ErrNotFound + } + if cur.Status != dbq.LidarrRequestStatusPending { + return dbq.LidarrRequest{}, ErrNotPending + } + return dbq.LidarrRequest{}, ErrNotFound + } + return dbq.LidarrRequest{}, fmt.Errorf("reject: %w", err) + } + return row, nil +} + +func (s *Service) Cancel(ctx context.Context, requestID pgtype.UUID, userID pgtype.UUID) (dbq.LidarrRequest, error) { + row, err := dbq.New(s.pool).CancelLidarrRequest(ctx, dbq.CancelLidarrRequestParams{ + ID: requestID, UserID: userID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return dbq.LidarrRequest{}, ErrNotPending + } + return dbq.LidarrRequest{}, fmt.Errorf("cancel: %w", err) + } + return row, nil +} + +func strPtr(s string) *string { + if s == "" { + return nil + } + return &s +} +func int32Ptr(i int) *int32 { + if i == 0 { + return nil + } + v := int32(i) + return &v +} +func uuidPtr(u pgtype.UUID) pgtype.UUID { return u } +``` + +(Field names like `dbq.LidarrRequestStatusPending`, `dbq.LidarrRequestKindArtist` come from sqlc — verify after `sqlc generate`. Pointer-vs-value for nullable columns also from sqlc.) + +- [ ] **Step 4.2: Write the integration test** + +`internal/lidarrrequests/service_test.go`: + +```go +package lidarrrequests + +import ( + "context" + "errors" + "io" + "log/slog" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" +) + +func newPool(t *testing.T) *pgxpool.Pool { + t.Helper() + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + dbtest.ResetDB(t, pool) + if _, err := pool.Exec(context.Background(), + "DELETE FROM lidarr_requests; UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL WHERE id=1", + ); err != nil { + t.Fatalf("reset lidarr tables: %v", err) + } + return pool +} + +func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID { + t.Helper() + u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ + Username: dbtest.TestUserPrefix + "rqtester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, + }) + if err != nil { + t.Fatalf("seed user: %v", err) + } + return u.ID +} + +func TestCreate_HappyPath_Artist(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool) + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + r, err := svc.Create(context.Background(), user, CreateParams{ + Kind: "artist", + LidarrArtistMBID: "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", + ArtistName: "Boards of Canada", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if r.Status != dbq.LidarrRequestStatusPending { + t.Errorf("status = %v", r.Status) + } +} + +func TestCreate_TrackKindRequiresAlbumMBID(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool) + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + _, err := svc.Create(context.Background(), user, CreateParams{ + Kind: "track", + LidarrArtistMBID: "a-mbid", ArtistName: "X", + LidarrTrackMBID: "t-mbid", TrackTitle: "Y", + // missing album fields + }) + if !errors.Is(err, ErrInvalidKindFields) { + t.Fatalf("err = %v, want ErrInvalidKindFields", err) + } +} + +func TestApprove_NotConfigured(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool) + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + r, _ := svc.Create(context.Background(), user, CreateParams{ + Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", + }) + _, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{}) + if !errors.Is(err, ErrLidarrDisabled) { + t.Fatalf("err = %v, want ErrLidarrDisabled", err) + } +} + +func TestReject_TransitionsToRejected(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool) + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + r, _ := svc.Create(context.Background(), user, CreateParams{ + Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", + }) + rejected, err := svc.Reject(context.Background(), r.ID, user, "low quality") + if err != nil { + t.Fatalf("Reject: %v", err) + } + if rejected.Status != dbq.LidarrRequestStatusRejected { + t.Errorf("status = %v", rejected.Status) + } +} + +func TestReject_AlreadyRejectedReturnsErrNotPending(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool) + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + r, _ := svc.Create(context.Background(), user, CreateParams{ + Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", + }) + _, _ = svc.Reject(context.Background(), r.ID, user, "first") + _, err := svc.Reject(context.Background(), r.ID, user, "second") + if !errors.Is(err, ErrNotPending) { + t.Fatalf("err = %v, want ErrNotPending", err) + } +} + +func TestCancel_OwnPendingOnly(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool) + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + r, _ := svc.Create(context.Background(), user, CreateParams{ + Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", + }) + if _, err := svc.Cancel(context.Background(), r.ID, user); err != nil { + t.Fatalf("Cancel: %v", err) + } + // Second cancel hits "not pending" because we just rejected it. + if _, err := svc.Cancel(context.Background(), r.ID, user); !errors.Is(err, ErrNotPending) { + t.Errorf("err = %v, want ErrNotPending", err) + } +} +``` + +- [ ] **Step 4.3: Run the tests** + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test -race ./internal/lidarrrequests/... +``` + +Expected: 6 tests pass. + +- [ ] **Step 4.4: Commit** + +```bash +git add internal/lidarrrequests/service.go internal/lidarrrequests/service_test.go +git commit -m "feat(lidarrrequests): request lifecycle service (create/list/approve/reject/cancel)" +``` + +--- + +This plan continues in the same shape for the remaining tasks. Subsequent tasks are sketched below at one-paragraph-per-task density to keep the plan navigable; expand each into the same step-level TDD detail (write test → run → implement → run → commit) when you reach it. Each task references the spec sections that drive it. + +--- + +### Task 5 — `lidarrrequests` Reconciler worker + +**Files:** `internal/lidarrrequests/reconciler.go`, `internal/lidarrrequests/reconciler_integration_test.go`, plus a sqlc query `MatchTrackForRequest` in `internal/db/queries/lidarr_requests.sql`. + +The reconciler mirrors `internal/similarity.Worker`: a `Run(ctx)` loop that calls `tickOnce(ctx)` every 5 minutes. `tickOnce`: +1. `ListApprovedLidarrRequestsForReconcile(limit=50)`. +2. For each row, look up the matching local row via MBID: + - `kind=artist` → `SELECT id FROM artists WHERE mbid = $1` + - `kind=album` → `SELECT id FROM albums WHERE mbid = $1` + - `kind=track` → `SELECT id FROM tracks WHERE album_id = (SELECT id FROM albums WHERE mbid = $1) LIMIT 1` (track-kind matched by parent album per spec §3 reconciler note) +3. If a match is found, call `CompleteLidarrRequest` with the matched IDs. + +Reconciler short-circuits to no-op when `lidarrconfig.Get(...).Enabled == false`. Errors logged at WARN, never propagated. + +**Tests** (per spec §8 — five integration scenarios): +- `TestReconciler_MatchesArtistByMBID` — seed artist, seed approved artist-kind request with same MBID, run `tickOnce`, expect status=completed and `matched_artist_id` set. +- `TestReconciler_MatchesAlbumByMBID` — same shape for album. +- `TestReconciler_MatchesTrackViaAlbumMBID` — track-kind request matches when ANY track of the parent album appears. +- `TestReconciler_NoMatchLeavesPending` — approved request with MBID not in library → row unchanged after `tickOnce`. +- `TestReconciler_AlreadyCompletedRowNotReprocessed` — pre-set status=completed, ensure `tickOnce` doesn't touch it. +- `TestReconciler_DisabledIsNoOp` — `lidarr_config.enabled=false` → `tickOnce` short-circuits even with approved rows present. + +Commit: `feat(lidarrrequests): add Reconciler worker matching approved requests to library`. + +--- + +### Task 6 — `RequireAdmin` middleware + +**Files:** `internal/auth/admin.go`, `internal/auth/admin_test.go`. + +Mirror `RequireUser`'s shape. After `RequireUser` puts the user in context, `RequireAdmin` reads the user from context, returns 403 with `{"error":"not_authorized"}` JSON envelope if `IsAdmin == false`. Test cases: admin passes through; non-admin returns 403; missing context (programmer error) returns 500. + +Commit: `feat(auth): add RequireAdmin middleware for /api/admin/* routes`. + +--- + +### Task 7 — `/api/lidarr/search` proxy handler + +**Files:** `internal/api/lidarr.go`, `internal/api/lidarr_test.go`. Modify: `internal/api/api.go` to inject the Lidarr client + lidarrconfig service into `handlers`. + +Handler reads `q` and `kind` from query params, validates `kind ∈ {artist, album, track}`, checks `lidarrconfig.Get().Enabled` — if false, returns `503 {"error":"lidarr_disabled"}`. Calls the matching `client.Lookup*`, then per-result enriches with: +- `in_library` — by joining against `artists.mbid` / `albums.mbid` / `tracks.mbid`. Add a small `IsMBIDInLibrary` sqlc query for each kind. +- `requested` — via `HasNonTerminalRequestForMBID` (already in queries from Task 1). + +Maps `lidarr.ErrUnreachable`/`ErrAuthFailed` to `503 lidarr_unreachable` / `503 lidarr_auth_failed`. Other errors → `500`. + +**Tests:** +- `TestHandleLidarrSearch_HappyPath` — stubs the Client to return one in-library + one requestable + one already-requested, asserts the JSON shape. +- `TestHandleLidarrSearch_DisabledReturns503` — `lidarr_config.enabled=false`. +- `TestHandleLidarrSearch_LidarrUnreachable` — stubbed Client returns `ErrUnreachable`. +- `TestHandleLidarrSearch_BadKind400`. +- `TestHandleLidarrSearch_RequiresAuth` — anonymous request rejected. + +Commit: `feat(api): add /api/lidarr/search proxy with library/request enrichment`. + +--- + +### Task 8 — `/api/requests` user-facing CRUD handlers + +**Files:** `internal/api/requests.go`, `internal/api/requests_test.go`. Modify: `internal/api/api.go` to register routes inside the `RequireUser` group. + +Five handlers: `POST /api/requests` (Create), `GET /api/requests` (ListForUser), `GET /api/requests/:id`, `DELETE /api/requests/:id` (Cancel). + +Each handler delegates to `lidarrrequests.Service`. Map `ErrInvalidKindFields → 400 mbid_required`, `ErrNotPending → 409 request_not_pending`, `ErrNotFound → 404 request_not_found`. `GET /:id` returns 404 if the row isn't the caller's own AND caller isn't admin. + +**Tests** (extend `testHandlers` to inject `lidarrrequests.Service`): +- Create with valid artist/album/track payloads → 201. +- Create with each invalid kind→fields combination → 400. +- List returns only caller's rows; cross-user scoped out. +- Get-own returns row; get-other-user 404; get-other-user-as-admin 200. +- Cancel pending → 200 with status=rejected; cancel non-pending → 409. + +Commit: `feat(api): add /api/requests user-facing CRUD`. + +--- + +### Task 9 — `/api/admin/lidarr/*` config + profiles + folders + test + +**Files:** `internal/api/admin_lidarr.go`, `internal/api/admin_lidarr_test.go`. Modify: `internal/api/api.go` to mount a `RequireAdmin` group under `/api/admin`. + +Handlers: `GET /api/admin/lidarr/config` (mask api_key), `PUT /api/admin/lidarr/config`, `POST /api/admin/lidarr/test`, `GET /api/admin/lidarr/quality-profiles`, `GET /api/admin/lidarr/root-folders`. + +PUT logic: if request `api_key` is empty string → preserve saved value; if non-empty → update. `enabled=true` requires `base_url` and `api_key` to be non-empty (validate at handler). + +Test endpoint: per-field fallback to saved values when absent or empty; always returns 200 with `{ok, version?, error?}`. + +**Tests:** +- GET config masks api_key when set. +- PUT empty api_key preserves saved value. +- PUT enabled=true with empty base_url → 400. +- POST test happy path returns `{ok:true, version}`. +- POST test with stubbed-unreachable client returns `{ok:false, error}`. +- Quality-profiles + root-folders proxy through to client. +- All endpoints return 403 for non-admin tokens. + +Commit: `feat(api): add /api/admin/lidarr/* config + profiles + folders + test`. + +--- + +### Task 10 — `/api/admin/requests/*` approval queue handlers + +**Files:** `internal/api/admin_requests.go`, `internal/api/admin_requests_test.go`. Modify: `internal/api/api.go` to register inside the `RequireAdmin` group. + +Three handlers: `GET /api/admin/requests?status=&limit=` (default `status=pending`), `POST /api/admin/requests/:id/approve` (body: optional override), `POST /api/admin/requests/:id/reject` (body: optional notes). + +Approve handler delegates to `Service.Approve`; surfaces `ErrLidarrDisabled` / `lidarr.ErrUnreachable` / `ErrNotPending` / `ErrNotFound` per error code table. + +**Tests:** +- List with status=pending returns pending rows only. +- Approve happy path: stubbed Client receives correct AddArtist/AddAlbum payload, row transitions to approved with snapshot fields, scan trigger called. +- Approve with override snapshots override values, not config defaults. +- Approve when Lidarr returns ErrUnreachable → 503; row stays pending. +- Reject with notes records notes; reject without notes works with NULL notes. +- All endpoints return 403 for non-admin tokens. + +Commit: `feat(api): add /api/admin/requests approval queue`. + +--- + +### Task 11 — Wire the Reconciler in `cmd/minstrel/main.go` + +**Files:** Modify `cmd/minstrel/main.go`. + +Mirror the existing scrobble/similarity worker spin-up. Construct `lidarrconfig.Service`, the `lidarr.Client` (BaseURL+APIKey loaded from the singleton on demand), `lidarrrequests.Reconciler`, and start its `Run(ctx)` in a goroutine alongside the others. + +Subtle: the Lidarr client's `BaseURL` and `APIKey` change at runtime when admin updates config. Two ways to handle — (a) construct a new Client per request inside the Service from the latest config, or (b) wrap a `*atomic.Pointer[lidarr.Client]` that the config-save handler swaps. Pick (a) — simpler, no atomic dance, the cost of constructing an `http.Client` per request is negligible. Refactor `Service` to hold a `func() *lidarr.Client` factory instead of a `*Client` so it always reads fresh config. + +(Update Task 4's `Service` shape to use the factory accordingly. This is a foreseeable refactor — better to absorb it now than fight stale clients in production.) + +Commit: `feat(cmd): start Lidarr reconciler worker alongside HTTP server`. + +--- + +### Task 12 — Frontend: FabledSword design tokens + fonts + +**Files:** Create `web/src/lib/styles/fabledsword-tokens.css`. Modify `web/src/app.css`. Modify `web/src/app.html` to load Google Fonts. Modify `web/tailwind.config.js` to alias semantic Tailwind utilities (e.g. `bg-surface`, `text-text-primary`, `border-border`) to FS tokens. + +Token file content: every variable from `project_design_system.md` `:root` block — surfaces, text, action, semantic, accent, font families, radii. Plus the per-app data-attribute hook that sets `--fs-accent` to forest-teal `#4A6B5C` for Minstrel. + +In Tailwind config, replace existing palette aliases: +- `surface`, `surface-hover` → Iron, Slate +- `background` → Obsidian +- `text-primary`, `text-secondary`, `text-muted` → Parchment, Vellum, Ash +- `border` → Pewter +- Add new utility classes for action (`bg-action-primary` → Moss, `bg-action-secondary` → Bronze, `bg-action-destructive` → Oxblood) and `accent` → forest teal. + +This is the slice that converts the rest of the app to the design system implicitly — by aliasing existing utility names. Verify by visiting the dev server and confirming the existing pages now read in the new palette without any per-page changes (a sign the alias mapping is correct). + +Commit: `feat(web): introduce FabledSword design system tokens + Tailwind aliases`. + +--- + +### Task 13 — Frontend: Lidarr + requests + admin API client modules + +**Files:** Create `web/src/lib/api/lidarr.ts`, `web/src/lib/api/requests.ts`, `web/src/lib/api/admin.ts`. + +Mirror existing client modules (e.g. `web/src/lib/api/likes.ts`). Each file exports typed async functions backed by the existing `api.get/post/put/delete` helper. + +Types match the API surface in spec §5. Vitest tests live alongside (e.g. `lidarr.test.ts`) using the existing fetch mocking setup — verify URL construction, query params, error mapping. + +Commit: `feat(web): add API client modules for Lidarr, requests, admin`. + +--- + +### Task 14 — Frontend: `` component + +**Files:** Create `web/src/lib/components/DiscoverResultCard.svelte`, `DiscoverResultCard.test.ts`. + +Component props: `{ kind: 'artist'|'album'|'track', title: string, subtitle?: string, imageUrl?: string, state: 'requestable'|'kept'|'requested', onRequest?: () => void }`. + +Layout discipline (per spec §6 + brainstorm): +- Outer `.card` is flex column with reserved `.text` block (`min-height` covers title + meta + badge row); `.actions` block uses `margin-top: auto`. +- Badge slot is always rendered as a 22px-min-height div; "Kept" pill (accent at 15% bg + accent text) appears only when `state==='kept'`. +- Three states render different actions: + - `requestable`: `bg-action-primary` button with plus icon, label "Request" + - `kept`: disabled ghost button "In library" + "Kept" pill in badge slot + - `requested`: disabled ghost button "Requested" +- Cover art: render `` when `imageUrl`; otherwise render Lucide fallback glyph (`Disc3` for artist, `Album` for album, `Music2` for track) inside the Slate-bg art square. + +**Tests:** +- Renders all three states with correct button text. +- Calls `onRequest` only in `requestable` state. +- Computes badge slot height with `min-height: 22px` even when no badge content (assert via `getComputedStyle`). +- Button is anchored to bottom of card body (assert `margin-top` === `auto` on `.actions`). + +Commit: `feat(web): add DiscoverResultCard with reserved badge slot + anchored button`. + +--- + +### Task 15 — Frontend: `` component + +**Files:** Create `web/src/lib/components/StatusPill.svelte`, `StatusPill.test.ts`. + +Single prop: `status: 'pending'|'approved'|'completed'|'rejected'|'failed'`. Renders a pill with semantic color (Warning / Info / Moss / Error / Error) per spec §6 and the design-system memory. Voice-rule labels: "Awaiting review" / "Approved · downloading" / "Kept" / "Set aside" / "Couldn't add." + +Tests: each status renders with the correct text and the correct semantic CSS class (use `bg-warning-tint`, etc., aliases). + +Commit: `feat(web): add StatusPill semantic-color status indicator`. + +--- + +### Task 16 — Frontend: `/discover` route + +**Files:** Create `web/src/routes/discover/+page.svelte`, `discover.test.ts`. Modify `web/src/lib/components/Shell.svelte` to add `/discover` to the main nav. + +Page elements: +- H2 "Add music to the library" (Fraunces 24/500), Vellum subtitle +- Search input (Obsidian inset, focus ring forest-teal) +- Tabs (Artists / Albums / Tracks) — active tab gets 2px forest-teal bottom border +- Card grid using `` +- Track-kind confirm modal: opens on Request click with a track-state result; "Requesting *Track X* will add the album *Album Y*. Continue?" — Confirm = Moss, Cancel = Bronze. Modal dismissed = no-op. + +Debounce search input by 250ms before querying. + +**Tests:** +- Debounced query fires correct API call with kind selector. +- Tab switch refetches with new `kind`. +- Track-kind result triggers modal; confirm triggers API call; cancel does not. +- Requestable card with `onRequest` flips to `requested` state on success. +- Empty results state shows "Nothing to add for that search yet." (voice-rule copy). + +Commit: `feat(web): add /discover route with search + request flow`. + +--- + +### Task 17 — Frontend: `/requests` user request history + +**Files:** Create `web/src/routes/requests/+page.svelte`, `requests.test.ts`. Modify `Shell.svelte` to add `/requests` link in the main nav (visible to all authed users). + +Page renders the caller's requests as rows (mirrors the mockup at `.superpowers/brainstorm/.../user-requests.html`). Each row: +- 56px album-art square (Slate fallback) +- Kind pill + StatusPill +- Title + meta line +- Per-status actions: Cancel button on pending; "Listen" link (forest-teal text) on completed (navigates to `/tracks/` if set, else fallthrough to album/artist) + +**Tests:** +- Renders one row per request from the API. +- Pending row exposes Cancel; Cancel calls API and removes row. +- Completed row renders "Listen" link with correct href. +- Rejected row renders admin notes if present, hides "Cancel" / "Listen." +- Empty list shows "Nothing requested yet." (voice-rule copy). + +Commit: `feat(web): add /requests user-facing request history`. + +--- + +### Task 18 — Frontend: `/admin/*` layout + role gate + +**Files:** Create `web/src/routes/admin/+layout.svelte`, `web/src/routes/admin/+layout.ts`, `web/src/routes/admin/+page.svelte` (Overview landing). + +`+layout.ts` exports a `load` function that checks `currentUser.is_admin`; if false, throws a SvelteKit `redirect(302, '/')`. Redirect happens before layout/child renders — exactly the hard route gate the operator specified. + +`+layout.svelte` renders the admin shell: +- Page header: "Admin" wordmark in Fraunces, the FabledSword small mark in Oxblood at top-left +- 220px sidebar `` component (separate file `web/src/lib/components/AdminSidebar.svelte`) with nav items: Overview / Integrations / **Requests** / Quarantine (placeholder, dimmed) / Users (placeholder, dimmed) / Library (placeholder, dimmed) +- Active nav item: 12% accent-tinted bg + 2px forest-teal left strip +- Main content area: `` for children + +`+page.svelte` (Overview): plain landing with two callout cards — "Pending requests: N" and "Lidarr: connected/unset" — each linking to its sub-page. Functional, not decorative. + +**Tests** (browser-mode, since SvelteKit `load` requires it): +- Non-admin user redirected to `/` before layout renders. +- Admin user lands on `/admin` and sees sidebar with Overview active. + +Commit: `feat(web): add /admin layout with role-gated load + sidebar`. + +--- + +### Task 19 — Frontend: `/admin/integrations` Lidarr panel + +**Files:** Create `web/src/routes/admin/integrations/+page.svelte`, `integrations.test.ts`. + +Page elements (matches mockup `admin-integrations.html`): +- Page header with status pill ("Lidarr · connected" Moss-tinted; "unset" Pewter ghost when not configured) +- Form section "Lidarr" with rows: + - Base URL — text input (Obsidian inset, JetBrains Mono for the URL value) + - API key — password input (masked) + - Default quality profile — `` populated from `GET /api/admin/lidarr/root-folders` +- Action row: Save changes (Moss + check icon), Test connection (Pewter ghost + refresh icon), Disconnect (Oxblood + trash icon, right-aligned) +- Disconnect requires a typed-confirm modal ("Type DISCONNECT to remove the Lidarr connection") because it sets `enabled=false` and clears `api_key`. + +Disabled section "MusicBrainz overrides" with `unset` foreshadows future integrations. Visually present, not implemented. + +**Tests:** +- Save changes calls PUT with form values. +- Empty api_key field on Save preserves saved value (sends empty string per spec). +- Test connection populates Lidarr's reported version on success. +- Disconnect requires modal confirmation; cancelling modal does not clear config. +- Quality-profile / root-folder dropdowns populated from API. + +Commit: `feat(web): add /admin/integrations Lidarr connection panel`. + +--- + +### Task 20 — Frontend: `/admin/requests` approval queue + override modal + +**Files:** Create `web/src/routes/admin/requests/+page.svelte`, `requests.test.ts`. Reuse ``. + +Page elements (matches mockup `admin-requests.html`): +- Tabs: Pending (default) / Approved / Completed / Rejected — each shows count from API as accent-tinted pill +- Request rows with action cluster: Override (Pewter ghost), Approve (Moss + check icon), Reject (Bronze + ✕ icon) +- Track-kind row's meta line spells out "Approving will add the album *X*" +- Override modal: collapsed-by-default form with Quality profile dropdown (populated via the admin endpoint) + Root folder dropdown; "Use defaults" leaves both empty (server uses snapshot defaults) + +**Tests:** +- Tab switch refetches with `?status=`. +- Approve fires POST with optional override values. +- Reject opens a notes input (textarea) above a Confirm button; Confirm sends notes. +- Approve with override modal returns chosen values to handler. +- Toast on Lidarr-unreachable error. + +Commit: `feat(web): add /admin/requests approval queue with override modal`. + +--- + +### Task 21 — Final verification + branch finish + +- [ ] **Step 21.1: Full Go test sweep** + +```bash +go test -short -race ./... +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test -race -p 1 ./... +``` + +Expected: short suite + integration suite both green. + +- [ ] **Step 21.2: Lint clean** + +```bash +golangci-lint run ./... +``` + +- [ ] **Step 21.3: Coverage check on new packages** + +```bash +go test -race -coverprofile=/tmp/cov.out ./internal/lidarr/... ./internal/lidarrconfig/... ./internal/lidarrrequests/... +go tool cover -func=/tmp/cov.out | tail -1 +``` + +Expected: combined ≥ 80% per spec §8. + +- [ ] **Step 21.4: Frontend full check** + +```bash +cd web && npm run check && npm test && npm run build +``` + +Expected: 0 errors, all vitest tests pass, build succeeds. + +- [ ] **Step 21.5: Manual smoke** + +- Set Lidarr config in `/admin/integrations` (use real Lidarr or stub). +- Search at `/discover`, request an artist. +- Approve from `/admin/requests`. +- Verify request shows up at `/requests` as Approved → wait for next library scan → status flips to Kept. +- Cancel a pending request from `/requests`. +- Verify non-admin is redirected when navigating to `/admin/*`. + +- [ ] **Step 21.6: Use `superpowers:finishing-a-development-branch`** + +Verify tests are still green, then run the skill to present finish options (merge / PR / keep / discard). Default for this slice is "create a PR to main" matching the established cadence. + +--- + +## Self-review checklist (run before declaring the plan ready) + +**Spec coverage** — every spec section maps to a task: +- §3 Architecture: Tasks 2 (client), 3 (config), 4 (service), 5 (reconciler), 6 (middleware), 11 (wiring) +- §4 Schema: Task 1 +- §5 API surface: Tasks 7 (search), 8 (requests CRUD), 9 (admin lidarr), 10 (admin requests) +- §6 UI surfaces: Tasks 12 (tokens), 13 (api), 14 (DiscoverResultCard), 15 (StatusPill), 16 (/discover), 17 (/requests), 18 (/admin layout), 19 (/admin/integrations), 20 (/admin/requests) +- §7 Error handling: distributed across Tasks 7-10 (each handler maps Service errors to API codes) +- §8 Testing: every Task includes tests; Task 21 verifies coverage targets +- §9 Decisions ledger: not directly implemented but referenced in commit messages +- §10 Out of scope: explicitly excluded — no quarantine, no suggested-additions, no webhook +- §11 Open questions: cover-art proxy + debounce/cache deferred to plan time → debounce at 250ms in Task 16; cover-art direct fetch (no proxy) for v1 + +**Placeholder scan:** the per-task detail level drops after Task 4 (each becomes one paragraph) — this is intentional for plan navigability, not a placeholder. When a subagent picks up Task 5+ they expand the paragraph into the same step-level TDD detail using Tasks 1-4 as templates, and reference the spec for any ambiguity. No "TBD" or "TODO" remains. + +**Type consistency:** +- Method names match across plan: `Service.Create/ListPending/ListByStatus/ListForUser/Approve/Reject/Cancel`, `Reconciler.Run/tickOnce`, `Client.LookupArtist/LookupAlbum/LookupTrack/AddArtist/AddAlbum/ListQualityProfiles/ListRootFolders/Ping` +- API paths match spec §5 +- Component names: ``, ``, `` — used consistently +- DB field names: `lidarr_artist_mbid`, `lidarr_album_mbid`, `lidarr_track_mbid`, `quality_profile_id`, `root_folder_path`, `matched_track_id`, etc. — consistent + +Plan is complete. From 9ceac5c63954a7abda7ebdd5a3ea765768ea2b78 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 15:10:01 -0400 Subject: [PATCH 03/67] feat(db): add lidarr_config + lidarr_requests schema (migration 0010) Co-Authored-By: Claude Sonnet 4.6 --- internal/db/dbq/albums.sql.go | 2 +- internal/db/dbq/artists.sql.go | 2 +- internal/db/dbq/contextual_likes.sql.go | 2 +- internal/db/dbq/db.go | 2 +- internal/db/dbq/events.sql.go | 2 +- internal/db/dbq/lidarr_config.sql.go | 76 ++++ internal/db/dbq/lidarr_requests.sql.go | 480 ++++++++++++++++++++ internal/db/dbq/likes.sql.go | 2 +- internal/db/dbq/models.go | 128 +++++- internal/db/dbq/recommendation.sql.go | 2 +- internal/db/dbq/scrobble.sql.go | 2 +- internal/db/dbq/sessions.sql.go | 2 +- internal/db/dbq/similarity.sql.go | 2 +- internal/db/dbq/tracks.sql.go | 2 +- internal/db/dbq/users.sql.go | 2 +- internal/db/migrations/0010_lidarr.down.sql | 8 + internal/db/migrations/0010_lidarr.up.sql | 63 +++ internal/db/queries/lidarr_config.sql | 17 + internal/db/queries/lidarr_requests.sql | 85 ++++ 19 files changed, 868 insertions(+), 13 deletions(-) create mode 100644 internal/db/dbq/lidarr_config.sql.go create mode 100644 internal/db/dbq/lidarr_requests.sql.go create mode 100644 internal/db/migrations/0010_lidarr.down.sql create mode 100644 internal/db/migrations/0010_lidarr.up.sql create mode 100644 internal/db/queries/lidarr_config.sql create mode 100644 internal/db/queries/lidarr_requests.sql diff --git a/internal/db/dbq/albums.sql.go b/internal/db/dbq/albums.sql.go index f94956b6..acbdeb5b 100644 --- a/internal/db/dbq/albums.sql.go +++ b/internal/db/dbq/albums.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: albums.sql package dbq diff --git a/internal/db/dbq/artists.sql.go b/internal/db/dbq/artists.sql.go index 259efbc4..cafeb6b5 100644 --- a/internal/db/dbq/artists.sql.go +++ b/internal/db/dbq/artists.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: artists.sql package dbq diff --git a/internal/db/dbq/contextual_likes.sql.go b/internal/db/dbq/contextual_likes.sql.go index 6b932e5e..32bdb7a6 100644 --- a/internal/db/dbq/contextual_likes.sql.go +++ b/internal/db/dbq/contextual_likes.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: contextual_likes.sql package dbq diff --git a/internal/db/dbq/db.go b/internal/db/dbq/db.go index 8650b40f..1890abd0 100644 --- a/internal/db/dbq/db.go +++ b/internal/db/dbq/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 package dbq diff --git a/internal/db/dbq/events.sql.go b/internal/db/dbq/events.sql.go index 7d3624b9..83a04dee 100644 --- a/internal/db/dbq/events.sql.go +++ b/internal/db/dbq/events.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: events.sql package dbq diff --git a/internal/db/dbq/lidarr_config.sql.go b/internal/db/dbq/lidarr_config.sql.go new file mode 100644 index 00000000..a6fa50b3 --- /dev/null +++ b/internal/db/dbq/lidarr_config.sql.go @@ -0,0 +1,76 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: lidarr_config.sql + +package dbq + +import ( + "context" +) + +const getLidarrConfig = `-- name: GetLidarrConfig :one +SELECT id, enabled, base_url, api_key, default_quality_profile_id, + default_root_folder_path, created_at, updated_at +FROM lidarr_config +WHERE id = 1 +` + +func (q *Queries) GetLidarrConfig(ctx context.Context) (LidarrConfig, error) { + row := q.db.QueryRow(ctx, getLidarrConfig) + var i LidarrConfig + err := row.Scan( + &i.ID, + &i.Enabled, + &i.BaseUrl, + &i.ApiKey, + &i.DefaultQualityProfileID, + &i.DefaultRootFolderPath, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateLidarrConfig = `-- name: UpdateLidarrConfig :one +UPDATE lidarr_config + SET enabled = $1, + base_url = $2, + api_key = $3, + default_quality_profile_id = $4, + default_root_folder_path = $5, + updated_at = now() + WHERE id = 1 + RETURNING id, enabled, base_url, api_key, default_quality_profile_id, + default_root_folder_path, created_at, updated_at +` + +type UpdateLidarrConfigParams struct { + Enabled bool + BaseUrl *string + ApiKey *string + DefaultQualityProfileID *int32 + DefaultRootFolderPath *string +} + +func (q *Queries) UpdateLidarrConfig(ctx context.Context, arg UpdateLidarrConfigParams) (LidarrConfig, error) { + row := q.db.QueryRow(ctx, updateLidarrConfig, + arg.Enabled, + arg.BaseUrl, + arg.ApiKey, + arg.DefaultQualityProfileID, + arg.DefaultRootFolderPath, + ) + var i LidarrConfig + err := row.Scan( + &i.ID, + &i.Enabled, + &i.BaseUrl, + &i.ApiKey, + &i.DefaultQualityProfileID, + &i.DefaultRootFolderPath, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/db/dbq/lidarr_requests.sql.go b/internal/db/dbq/lidarr_requests.sql.go new file mode 100644 index 00000000..70676607 --- /dev/null +++ b/internal/db/dbq/lidarr_requests.sql.go @@ -0,0 +1,480 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: lidarr_requests.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const approveLidarrRequest = `-- name: ApproveLidarrRequest :one +UPDATE lidarr_requests + SET status = 'approved', + quality_profile_id = $2, + root_folder_path = $3, + decided_at = now(), + decided_by = $4, + updated_at = now() + WHERE id = $1 AND status = 'pending' + RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at +` + +type ApproveLidarrRequestParams struct { + ID pgtype.UUID + QualityProfileID *int32 + RootFolderPath *string + DecidedBy pgtype.UUID +} + +func (q *Queries) ApproveLidarrRequest(ctx context.Context, arg ApproveLidarrRequestParams) (LidarrRequest, error) { + row := q.db.QueryRow(ctx, approveLidarrRequest, + arg.ID, + arg.QualityProfileID, + arg.RootFolderPath, + arg.DecidedBy, + ) + var i LidarrRequest + err := row.Scan( + &i.ID, + &i.UserID, + &i.Status, + &i.Kind, + &i.LidarrArtistMbid, + &i.LidarrAlbumMbid, + &i.LidarrTrackMbid, + &i.ArtistName, + &i.AlbumTitle, + &i.TrackTitle, + &i.QualityProfileID, + &i.RootFolderPath, + &i.DecidedAt, + &i.DecidedBy, + &i.Notes, + &i.CompletedAt, + &i.MatchedTrackID, + &i.MatchedAlbumID, + &i.MatchedArtistID, + &i.RequestedAt, + &i.UpdatedAt, + ) + return i, err +} + +const cancelLidarrRequest = `-- name: CancelLidarrRequest :one +UPDATE lidarr_requests + SET status = 'rejected', + notes = 'cancelled by user', + decided_at = now(), + decided_by = $2, + updated_at = now() + WHERE id = $1 AND user_id = $2 AND status = 'pending' + RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at +` + +type CancelLidarrRequestParams struct { + ID pgtype.UUID + DecidedBy pgtype.UUID +} + +func (q *Queries) CancelLidarrRequest(ctx context.Context, arg CancelLidarrRequestParams) (LidarrRequest, error) { + row := q.db.QueryRow(ctx, cancelLidarrRequest, arg.ID, arg.DecidedBy) + var i LidarrRequest + err := row.Scan( + &i.ID, + &i.UserID, + &i.Status, + &i.Kind, + &i.LidarrArtistMbid, + &i.LidarrAlbumMbid, + &i.LidarrTrackMbid, + &i.ArtistName, + &i.AlbumTitle, + &i.TrackTitle, + &i.QualityProfileID, + &i.RootFolderPath, + &i.DecidedAt, + &i.DecidedBy, + &i.Notes, + &i.CompletedAt, + &i.MatchedTrackID, + &i.MatchedAlbumID, + &i.MatchedArtistID, + &i.RequestedAt, + &i.UpdatedAt, + ) + return i, err +} + +const completeLidarrRequest = `-- name: CompleteLidarrRequest :one +UPDATE lidarr_requests + SET status = 'completed', + matched_track_id = $2, + matched_album_id = $3, + matched_artist_id = $4, + completed_at = now(), + updated_at = now() + WHERE id = $1 AND status = 'approved' + RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at +` + +type CompleteLidarrRequestParams struct { + ID pgtype.UUID + MatchedTrackID pgtype.UUID + MatchedAlbumID pgtype.UUID + MatchedArtistID pgtype.UUID +} + +// Reconciler transitions an approved request to completed when its +// target track/album/artist has appeared in the library. +func (q *Queries) CompleteLidarrRequest(ctx context.Context, arg CompleteLidarrRequestParams) (LidarrRequest, error) { + row := q.db.QueryRow(ctx, completeLidarrRequest, + arg.ID, + arg.MatchedTrackID, + arg.MatchedAlbumID, + arg.MatchedArtistID, + ) + var i LidarrRequest + err := row.Scan( + &i.ID, + &i.UserID, + &i.Status, + &i.Kind, + &i.LidarrArtistMbid, + &i.LidarrAlbumMbid, + &i.LidarrTrackMbid, + &i.ArtistName, + &i.AlbumTitle, + &i.TrackTitle, + &i.QualityProfileID, + &i.RootFolderPath, + &i.DecidedAt, + &i.DecidedBy, + &i.Notes, + &i.CompletedAt, + &i.MatchedTrackID, + &i.MatchedAlbumID, + &i.MatchedArtistID, + &i.RequestedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createLidarrRequest = `-- name: CreateLidarrRequest :one +INSERT INTO lidarr_requests ( + user_id, kind, + lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, + artist_name, album_title, track_title +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at +` + +type CreateLidarrRequestParams struct { + UserID pgtype.UUID + Kind LidarrRequestKind + LidarrArtistMbid string + LidarrAlbumMbid *string + LidarrTrackMbid *string + ArtistName string + AlbumTitle *string + TrackTitle *string +} + +func (q *Queries) CreateLidarrRequest(ctx context.Context, arg CreateLidarrRequestParams) (LidarrRequest, error) { + row := q.db.QueryRow(ctx, createLidarrRequest, + arg.UserID, + arg.Kind, + arg.LidarrArtistMbid, + arg.LidarrAlbumMbid, + arg.LidarrTrackMbid, + arg.ArtistName, + arg.AlbumTitle, + arg.TrackTitle, + ) + var i LidarrRequest + err := row.Scan( + &i.ID, + &i.UserID, + &i.Status, + &i.Kind, + &i.LidarrArtistMbid, + &i.LidarrAlbumMbid, + &i.LidarrTrackMbid, + &i.ArtistName, + &i.AlbumTitle, + &i.TrackTitle, + &i.QualityProfileID, + &i.RootFolderPath, + &i.DecidedAt, + &i.DecidedBy, + &i.Notes, + &i.CompletedAt, + &i.MatchedTrackID, + &i.MatchedAlbumID, + &i.MatchedArtistID, + &i.RequestedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getLidarrRequestByID = `-- name: GetLidarrRequestByID :one +SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM lidarr_requests WHERE id = $1 +` + +func (q *Queries) GetLidarrRequestByID(ctx context.Context, id pgtype.UUID) (LidarrRequest, error) { + row := q.db.QueryRow(ctx, getLidarrRequestByID, id) + var i LidarrRequest + err := row.Scan( + &i.ID, + &i.UserID, + &i.Status, + &i.Kind, + &i.LidarrArtistMbid, + &i.LidarrAlbumMbid, + &i.LidarrTrackMbid, + &i.ArtistName, + &i.AlbumTitle, + &i.TrackTitle, + &i.QualityProfileID, + &i.RootFolderPath, + &i.DecidedAt, + &i.DecidedBy, + &i.Notes, + &i.CompletedAt, + &i.MatchedTrackID, + &i.MatchedAlbumID, + &i.MatchedArtistID, + &i.RequestedAt, + &i.UpdatedAt, + ) + return i, err +} + +const hasNonTerminalRequestForMBID = `-- name: HasNonTerminalRequestForMBID :one +SELECT EXISTS ( + SELECT 1 FROM lidarr_requests + WHERE status IN ('pending', 'approved', 'completed') + AND ((kind = 'artist' AND lidarr_artist_mbid = $1) + OR (kind = 'album' AND lidarr_album_mbid = $1) + OR (kind = 'track' AND lidarr_track_mbid = $1)) +) +` + +// Returns true if any user has a pending/approved/completed request +// whose MBID matches at the given level. Used to set the `requested` +// flag on /api/lidarr/search responses. Terminal-status (rejected, +// failed) rows do not count. +func (q *Queries) HasNonTerminalRequestForMBID(ctx context.Context, lidarrArtistMbid string) (bool, error) { + row := q.db.QueryRow(ctx, hasNonTerminalRequestForMBID, lidarrArtistMbid) + var exists bool + err := row.Scan(&exists) + return exists, err +} + +const listApprovedLidarrRequestsForReconcile = `-- name: ListApprovedLidarrRequestsForReconcile :many +SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM lidarr_requests +WHERE status = 'approved' +ORDER BY decided_at ASC +LIMIT $1 +` + +func (q *Queries) ListApprovedLidarrRequestsForReconcile(ctx context.Context, limit int32) ([]LidarrRequest, error) { + rows, err := q.db.Query(ctx, listApprovedLidarrRequestsForReconcile, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []LidarrRequest + for rows.Next() { + var i LidarrRequest + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Status, + &i.Kind, + &i.LidarrArtistMbid, + &i.LidarrAlbumMbid, + &i.LidarrTrackMbid, + &i.ArtistName, + &i.AlbumTitle, + &i.TrackTitle, + &i.QualityProfileID, + &i.RootFolderPath, + &i.DecidedAt, + &i.DecidedBy, + &i.Notes, + &i.CompletedAt, + &i.MatchedTrackID, + &i.MatchedAlbumID, + &i.MatchedArtistID, + &i.RequestedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listLidarrRequestsByStatus = `-- name: ListLidarrRequestsByStatus :many +SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM lidarr_requests +WHERE status = $1 +ORDER BY requested_at DESC +LIMIT $2 +` + +type ListLidarrRequestsByStatusParams struct { + Status LidarrRequestStatus + Limit int32 +} + +func (q *Queries) ListLidarrRequestsByStatus(ctx context.Context, arg ListLidarrRequestsByStatusParams) ([]LidarrRequest, error) { + rows, err := q.db.Query(ctx, listLidarrRequestsByStatus, arg.Status, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []LidarrRequest + for rows.Next() { + var i LidarrRequest + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Status, + &i.Kind, + &i.LidarrArtistMbid, + &i.LidarrAlbumMbid, + &i.LidarrTrackMbid, + &i.ArtistName, + &i.AlbumTitle, + &i.TrackTitle, + &i.QualityProfileID, + &i.RootFolderPath, + &i.DecidedAt, + &i.DecidedBy, + &i.Notes, + &i.CompletedAt, + &i.MatchedTrackID, + &i.MatchedAlbumID, + &i.MatchedArtistID, + &i.RequestedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listLidarrRequestsForUser = `-- name: ListLidarrRequestsForUser :many +SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM lidarr_requests +WHERE user_id = $1 +ORDER BY requested_at DESC +LIMIT $2 +` + +type ListLidarrRequestsForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +func (q *Queries) ListLidarrRequestsForUser(ctx context.Context, arg ListLidarrRequestsForUserParams) ([]LidarrRequest, error) { + rows, err := q.db.Query(ctx, listLidarrRequestsForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []LidarrRequest + for rows.Next() { + var i LidarrRequest + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Status, + &i.Kind, + &i.LidarrArtistMbid, + &i.LidarrAlbumMbid, + &i.LidarrTrackMbid, + &i.ArtistName, + &i.AlbumTitle, + &i.TrackTitle, + &i.QualityProfileID, + &i.RootFolderPath, + &i.DecidedAt, + &i.DecidedBy, + &i.Notes, + &i.CompletedAt, + &i.MatchedTrackID, + &i.MatchedAlbumID, + &i.MatchedArtistID, + &i.RequestedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const rejectLidarrRequest = `-- name: RejectLidarrRequest :one +UPDATE lidarr_requests + SET status = 'rejected', + notes = $2, + decided_at = now(), + decided_by = $3, + updated_at = now() + WHERE id = $1 AND status = 'pending' + RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at +` + +type RejectLidarrRequestParams struct { + ID pgtype.UUID + Notes *string + DecidedBy pgtype.UUID +} + +func (q *Queries) RejectLidarrRequest(ctx context.Context, arg RejectLidarrRequestParams) (LidarrRequest, error) { + row := q.db.QueryRow(ctx, rejectLidarrRequest, arg.ID, arg.Notes, arg.DecidedBy) + var i LidarrRequest + err := row.Scan( + &i.ID, + &i.UserID, + &i.Status, + &i.Kind, + &i.LidarrArtistMbid, + &i.LidarrAlbumMbid, + &i.LidarrTrackMbid, + &i.ArtistName, + &i.AlbumTitle, + &i.TrackTitle, + &i.QualityProfileID, + &i.RootFolderPath, + &i.DecidedAt, + &i.DecidedBy, + &i.Notes, + &i.CompletedAt, + &i.MatchedTrackID, + &i.MatchedAlbumID, + &i.MatchedArtistID, + &i.RequestedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/db/dbq/likes.sql.go b/internal/db/dbq/likes.sql.go index 53533cc2..5510829b 100644 --- a/internal/db/dbq/likes.sql.go +++ b/internal/db/dbq/likes.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: likes.sql package dbq diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 5ce88a32..d0cbf1f2 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -1,13 +1,104 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 package dbq import ( + "database/sql/driver" + "fmt" + "github.com/jackc/pgx/v5/pgtype" ) +type LidarrRequestKind string + +const ( + LidarrRequestKindArtist LidarrRequestKind = "artist" + LidarrRequestKindAlbum LidarrRequestKind = "album" + LidarrRequestKindTrack LidarrRequestKind = "track" +) + +func (e *LidarrRequestKind) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = LidarrRequestKind(s) + case string: + *e = LidarrRequestKind(s) + default: + return fmt.Errorf("unsupported scan type for LidarrRequestKind: %T", src) + } + return nil +} + +type NullLidarrRequestKind struct { + LidarrRequestKind LidarrRequestKind + Valid bool // Valid is true if LidarrRequestKind is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullLidarrRequestKind) Scan(value interface{}) error { + if value == nil { + ns.LidarrRequestKind, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.LidarrRequestKind.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullLidarrRequestKind) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.LidarrRequestKind), nil +} + +type LidarrRequestStatus string + +const ( + LidarrRequestStatusPending LidarrRequestStatus = "pending" + LidarrRequestStatusApproved LidarrRequestStatus = "approved" + LidarrRequestStatusRejected LidarrRequestStatus = "rejected" + LidarrRequestStatusCompleted LidarrRequestStatus = "completed" + LidarrRequestStatusFailed LidarrRequestStatus = "failed" +) + +func (e *LidarrRequestStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = LidarrRequestStatus(s) + case string: + *e = LidarrRequestStatus(s) + default: + return fmt.Errorf("unsupported scan type for LidarrRequestStatus: %T", src) + } + return nil +} + +type NullLidarrRequestStatus struct { + LidarrRequestStatus LidarrRequestStatus + Valid bool // Valid is true if LidarrRequestStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullLidarrRequestStatus) Scan(value interface{}) error { + if value == nil { + ns.LidarrRequestStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.LidarrRequestStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullLidarrRequestStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.LidarrRequestStatus), nil +} + type Album struct { ID pgtype.UUID Title string @@ -65,6 +156,41 @@ type GeneralLikesArtist struct { LikedAt pgtype.Timestamptz } +type LidarrConfig struct { + ID int16 + Enabled bool + BaseUrl *string + ApiKey *string + DefaultQualityProfileID *int32 + DefaultRootFolderPath *string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type LidarrRequest struct { + ID pgtype.UUID + UserID pgtype.UUID + Status LidarrRequestStatus + Kind LidarrRequestKind + LidarrArtistMbid string + LidarrAlbumMbid *string + LidarrTrackMbid *string + ArtistName string + AlbumTitle *string + TrackTitle *string + QualityProfileID *int32 + RootFolderPath *string + DecidedAt pgtype.Timestamptz + DecidedBy pgtype.UUID + Notes *string + CompletedAt pgtype.Timestamptz + MatchedTrackID pgtype.UUID + MatchedAlbumID pgtype.UUID + MatchedArtistID pgtype.UUID + RequestedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + type PlayEvent struct { ID pgtype.UUID UserID pgtype.UUID diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index 7da85f13..97a620df 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: recommendation.sql package dbq diff --git a/internal/db/dbq/scrobble.sql.go b/internal/db/dbq/scrobble.sql.go index 8c8da74c..5a538e5b 100644 --- a/internal/db/dbq/scrobble.sql.go +++ b/internal/db/dbq/scrobble.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: scrobble.sql package dbq diff --git a/internal/db/dbq/sessions.sql.go b/internal/db/dbq/sessions.sql.go index 3ac9741c..bc34671b 100644 --- a/internal/db/dbq/sessions.sql.go +++ b/internal/db/dbq/sessions.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: sessions.sql package dbq diff --git a/internal/db/dbq/similarity.sql.go b/internal/db/dbq/similarity.sql.go index 5a92cecf..2673985c 100644 --- a/internal/db/dbq/similarity.sql.go +++ b/internal/db/dbq/similarity.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: similarity.sql package dbq diff --git a/internal/db/dbq/tracks.sql.go b/internal/db/dbq/tracks.sql.go index 5254bb50..d17447c9 100644 --- a/internal/db/dbq/tracks.sql.go +++ b/internal/db/dbq/tracks.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: tracks.sql package dbq diff --git a/internal/db/dbq/users.sql.go b/internal/db/dbq/users.sql.go index d15f691e..d2c97bbc 100644 --- a/internal/db/dbq/users.sql.go +++ b/internal/db/dbq/users.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: users.sql package dbq diff --git a/internal/db/migrations/0010_lidarr.down.sql b/internal/db/migrations/0010_lidarr.down.sql new file mode 100644 index 00000000..a6aabe02 --- /dev/null +++ b/internal/db/migrations/0010_lidarr.down.sql @@ -0,0 +1,8 @@ +DROP INDEX IF EXISTS lidarr_requests_album_mbid_idx; +DROP INDEX IF EXISTS lidarr_requests_artist_mbid_idx; +DROP INDEX IF EXISTS lidarr_requests_status_idx; +DROP INDEX IF EXISTS lidarr_requests_user_id_idx; +DROP TABLE IF EXISTS lidarr_requests; +DROP TYPE IF EXISTS lidarr_request_kind; +DROP TYPE IF EXISTS lidarr_request_status; +DROP TABLE IF EXISTS lidarr_config; diff --git a/internal/db/migrations/0010_lidarr.up.sql b/internal/db/migrations/0010_lidarr.up.sql new file mode 100644 index 00000000..8efb7b03 --- /dev/null +++ b/internal/db/migrations/0010_lidarr.up.sql @@ -0,0 +1,63 @@ +-- M5a: Lidarr integration foundation. Two tables: +-- +-- lidarr_config — singleton (CHECK id=1) holding the operator's Lidarr +-- connection. enabled=false is the unconfigured state. +-- +-- lidarr_requests — per-request lifecycle row created by users at +-- /discover, transitioned by admin at /admin/requests, and matched +-- back to library tracks by the reconciler worker. Three matched_*_id +-- FKs (one per kind) instead of polymorphic — clean SQL, ON DELETE +-- SET NULL preserves audit even if the matched track is later removed. + +CREATE TABLE lidarr_config ( + id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1), + enabled boolean NOT NULL DEFAULT false, + base_url text, + api_key text, + default_quality_profile_id int, + default_root_folder_path text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO lidarr_config (id, enabled) VALUES (1, false); + +CREATE TYPE lidarr_request_status AS ENUM ( + 'pending', 'approved', 'rejected', 'completed', 'failed' +); +CREATE TYPE lidarr_request_kind AS ENUM ('artist', 'album', 'track'); + +CREATE TABLE lidarr_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status lidarr_request_status NOT NULL DEFAULT 'pending', + kind lidarr_request_kind NOT NULL, + + lidarr_artist_mbid text NOT NULL, + lidarr_album_mbid text, + lidarr_track_mbid text, + artist_name text NOT NULL, + album_title text, + track_title text, + + quality_profile_id int, + root_folder_path text, + + decided_at timestamptz, + decided_by uuid REFERENCES users(id) ON DELETE SET NULL, + notes text, + + completed_at timestamptz, + matched_track_id uuid REFERENCES tracks(id) ON DELETE SET NULL, + matched_album_id uuid REFERENCES albums(id) ON DELETE SET NULL, + matched_artist_id uuid REFERENCES artists(id) ON DELETE SET NULL, + + requested_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX lidarr_requests_user_id_idx ON lidarr_requests (user_id); +CREATE INDEX lidarr_requests_status_idx ON lidarr_requests (status); +CREATE INDEX lidarr_requests_artist_mbid_idx ON lidarr_requests (lidarr_artist_mbid); +CREATE INDEX lidarr_requests_album_mbid_idx ON lidarr_requests (lidarr_album_mbid) + WHERE lidarr_album_mbid IS NOT NULL; diff --git a/internal/db/queries/lidarr_config.sql b/internal/db/queries/lidarr_config.sql new file mode 100644 index 00000000..29f93172 --- /dev/null +++ b/internal/db/queries/lidarr_config.sql @@ -0,0 +1,17 @@ +-- name: GetLidarrConfig :one +SELECT id, enabled, base_url, api_key, default_quality_profile_id, + default_root_folder_path, created_at, updated_at +FROM lidarr_config +WHERE id = 1; + +-- name: UpdateLidarrConfig :one +UPDATE lidarr_config + SET enabled = $1, + base_url = $2, + api_key = $3, + default_quality_profile_id = $4, + default_root_folder_path = $5, + updated_at = now() + WHERE id = 1 + RETURNING id, enabled, base_url, api_key, default_quality_profile_id, + default_root_folder_path, created_at, updated_at; diff --git a/internal/db/queries/lidarr_requests.sql b/internal/db/queries/lidarr_requests.sql new file mode 100644 index 00000000..9641581b --- /dev/null +++ b/internal/db/queries/lidarr_requests.sql @@ -0,0 +1,85 @@ +-- name: CreateLidarrRequest :one +INSERT INTO lidarr_requests ( + user_id, kind, + lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, + artist_name, album_title, track_title +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING *; + +-- name: GetLidarrRequestByID :one +SELECT * FROM lidarr_requests WHERE id = $1; + +-- name: ListLidarrRequestsForUser :many +SELECT * FROM lidarr_requests +WHERE user_id = $1 +ORDER BY requested_at DESC +LIMIT $2; + +-- name: ListLidarrRequestsByStatus :many +SELECT * FROM lidarr_requests +WHERE status = $1 +ORDER BY requested_at DESC +LIMIT $2; + +-- name: ListApprovedLidarrRequestsForReconcile :many +SELECT * FROM lidarr_requests +WHERE status = 'approved' +ORDER BY decided_at ASC +LIMIT $1; + +-- name: ApproveLidarrRequest :one +UPDATE lidarr_requests + SET status = 'approved', + quality_profile_id = $2, + root_folder_path = $3, + decided_at = now(), + decided_by = $4, + updated_at = now() + WHERE id = $1 AND status = 'pending' + RETURNING *; + +-- name: RejectLidarrRequest :one +UPDATE lidarr_requests + SET status = 'rejected', + notes = $2, + decided_at = now(), + decided_by = $3, + updated_at = now() + WHERE id = $1 AND status = 'pending' + RETURNING *; + +-- name: CancelLidarrRequest :one +UPDATE lidarr_requests + SET status = 'rejected', + notes = 'cancelled by user', + decided_at = now(), + decided_by = $2, + updated_at = now() + WHERE id = $1 AND user_id = $2 AND status = 'pending' + RETURNING *; + +-- name: CompleteLidarrRequest :one +-- Reconciler transitions an approved request to completed when its +-- target track/album/artist has appeared in the library. +UPDATE lidarr_requests + SET status = 'completed', + matched_track_id = $2, + matched_album_id = $3, + matched_artist_id = $4, + completed_at = now(), + updated_at = now() + WHERE id = $1 AND status = 'approved' + RETURNING *; + +-- name: HasNonTerminalRequestForMBID :one +-- Returns true if any user has a pending/approved/completed request +-- whose MBID matches at the given level. Used to set the `requested` +-- flag on /api/lidarr/search responses. Terminal-status (rejected, +-- failed) rows do not count. +SELECT EXISTS ( + SELECT 1 FROM lidarr_requests + WHERE status IN ('pending', 'approved', 'completed') + AND ((kind = 'artist' AND lidarr_artist_mbid = $1) + OR (kind = 'album' AND lidarr_album_mbid = $1) + OR (kind = 'track' AND lidarr_track_mbid = $1)) +); From a5bb79f2a8ad6e105b568087ec83745a8567382c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 15:19:54 -0400 Subject: [PATCH 04/67] fix(db): clarify lidarr_requests query semantics - HasNonTerminalRequestForMBID: use named parameter @mbid so the generated Go signature is `mbid string` instead of the misleading `lidarrArtistMbid string` (the value applies to all three MBID columns, not just the artist column) - CancelLidarrRequest: comment dual role of $2 (ownership guard + decided_by audit field) for future readers - CompleteLidarrRequest: document the per-kind one-of-three contract for matched_*_id parameters Co-Authored-By: Claude Sonnet 4.6 --- internal/db/dbq/lidarr_requests.sql.go | 18 ++++++++++++++---- internal/db/queries/lidarr_requests.sql | 20 +++++++++++++++----- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/internal/db/dbq/lidarr_requests.sql.go b/internal/db/dbq/lidarr_requests.sql.go index 70676607..ac9d796e 100644 --- a/internal/db/dbq/lidarr_requests.sql.go +++ b/internal/db/dbq/lidarr_requests.sql.go @@ -80,6 +80,11 @@ type CancelLidarrRequestParams struct { DecidedBy pgtype.UUID } +// $2 serves as both the ownership guard (user_id = $2) and the +// decided_by audit field. The generated parameter struct names it +// DecidedBy — callers must pass the cancelling user's UUID, and +// a wrong UUID will silently no-op (zero rows updated) rather than +// erroring at the SQL layer. func (q *Queries) CancelLidarrRequest(ctx context.Context, arg CancelLidarrRequestParams) (LidarrRequest, error) { row := q.db.QueryRow(ctx, cancelLidarrRequest, arg.ID, arg.DecidedBy) var i LidarrRequest @@ -129,7 +134,11 @@ type CompleteLidarrRequestParams struct { } // Reconciler transitions an approved request to completed when its -// target track/album/artist has appeared in the library. +// target track/album/artist has appeared in the library. Callers +// should set ONLY the matched_*_id corresponding to the request's +// kind and pass pgtype.UUID{} (Valid: false) for the others — +// per-kind one-of-three is the contract, NULL for the others +// preserves auditability if the matched row is later evicted. func (q *Queries) CompleteLidarrRequest(ctx context.Context, arg CompleteLidarrRequestParams) (LidarrRequest, error) { row := q.db.QueryRow(ctx, completeLidarrRequest, arg.ID, @@ -268,9 +277,10 @@ SELECT EXISTS ( // Returns true if any user has a pending/approved/completed request // whose MBID matches at the given level. Used to set the `requested` // flag on /api/lidarr/search responses. Terminal-status (rejected, -// failed) rows do not count. -func (q *Queries) HasNonTerminalRequestForMBID(ctx context.Context, lidarrArtistMbid string) (bool, error) { - row := q.db.QueryRow(ctx, hasNonTerminalRequestForMBID, lidarrArtistMbid) +// failed) rows do not count. The same MBID parameter is checked +// against artist/album/track columns based on kind. +func (q *Queries) HasNonTerminalRequestForMBID(ctx context.Context, mbid string) (bool, error) { + row := q.db.QueryRow(ctx, hasNonTerminalRequestForMBID, mbid) var exists bool err := row.Scan(&exists) return exists, err diff --git a/internal/db/queries/lidarr_requests.sql b/internal/db/queries/lidarr_requests.sql index 9641581b..d075a07a 100644 --- a/internal/db/queries/lidarr_requests.sql +++ b/internal/db/queries/lidarr_requests.sql @@ -49,6 +49,11 @@ UPDATE lidarr_requests RETURNING *; -- name: CancelLidarrRequest :one +-- $2 serves as both the ownership guard (user_id = $2) and the +-- decided_by audit field. The generated parameter struct names it +-- DecidedBy — callers must pass the cancelling user's UUID, and +-- a wrong UUID will silently no-op (zero rows updated) rather than +-- erroring at the SQL layer. UPDATE lidarr_requests SET status = 'rejected', notes = 'cancelled by user', @@ -60,7 +65,11 @@ UPDATE lidarr_requests -- name: CompleteLidarrRequest :one -- Reconciler transitions an approved request to completed when its --- target track/album/artist has appeared in the library. +-- target track/album/artist has appeared in the library. Callers +-- should set ONLY the matched_*_id corresponding to the request's +-- kind and pass pgtype.UUID{} (Valid: false) for the others — +-- per-kind one-of-three is the contract, NULL for the others +-- preserves auditability if the matched row is later evicted. UPDATE lidarr_requests SET status = 'completed', matched_track_id = $2, @@ -75,11 +84,12 @@ UPDATE lidarr_requests -- Returns true if any user has a pending/approved/completed request -- whose MBID matches at the given level. Used to set the `requested` -- flag on /api/lidarr/search responses. Terminal-status (rejected, --- failed) rows do not count. +-- failed) rows do not count. The same MBID parameter is checked +-- against artist/album/track columns based on kind. SELECT EXISTS ( SELECT 1 FROM lidarr_requests WHERE status IN ('pending', 'approved', 'completed') - AND ((kind = 'artist' AND lidarr_artist_mbid = $1) - OR (kind = 'album' AND lidarr_album_mbid = $1) - OR (kind = 'track' AND lidarr_track_mbid = $1)) + AND ((kind = 'artist' AND lidarr_artist_mbid = @mbid) + OR (kind = 'album' AND lidarr_album_mbid = @mbid) + OR (kind = 'track' AND lidarr_track_mbid = @mbid)) ); From a43fa09a04d3f89022428c2e777f07700d232caf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 15:25:10 -0400 Subject: [PATCH 05/67] feat(lidarr): typed HTTP client for v1 API (lookup, add, profiles, ping) Co-Authored-By: Claude Sonnet 4.6 --- internal/lidarr/client.go | 361 ++++++++++++ internal/lidarr/client_test.go | 550 ++++++++++++++++++ internal/lidarr/errors.go | 13 + internal/lidarr/testdata/lookup_album.json | 21 + internal/lidarr/testdata/lookup_artist.json | 19 + internal/lidarr/testdata/lookup_track.json | 14 + .../lidarr/testdata/quality_profiles.json | 5 + internal/lidarr/testdata/root_folders.json | 5 + internal/lidarr/types.go | 48 ++ 9 files changed, 1036 insertions(+) create mode 100644 internal/lidarr/client.go create mode 100644 internal/lidarr/client_test.go create mode 100644 internal/lidarr/errors.go create mode 100644 internal/lidarr/testdata/lookup_album.json create mode 100644 internal/lidarr/testdata/lookup_artist.json create mode 100644 internal/lidarr/testdata/lookup_track.json create mode 100644 internal/lidarr/testdata/quality_profiles.json create mode 100644 internal/lidarr/testdata/root_folders.json create mode 100644 internal/lidarr/types.go diff --git a/internal/lidarr/client.go b/internal/lidarr/client.go new file mode 100644 index 00000000..48af1d3d --- /dev/null +++ b/internal/lidarr/client.go @@ -0,0 +1,361 @@ +package lidarr + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" +) + +// Client wraps Lidarr's v1 HTTP API. BaseURL is the Lidarr instance +// (e.g. http://lidarr.lan:8686), APIKey comes from Lidarr's settings. +type Client struct { + BaseURL string + APIKey string + HTTP *http.Client +} + +// get executes an authenticated GET request and maps HTTP status codes to +// typed errors. The caller is responsible for closing the returned body. +func (c *Client) get(ctx context.Context, path string, q url.Values) (*http.Response, error) { + u, err := url.Parse(c.BaseURL) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) + } + u.Path = u.Path + path + if q != nil { + u.RawQuery = q.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Api-Key", c.APIKey) + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) + } + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + _ = resp.Body.Close() + return nil, ErrAuthFailed + } + if resp.StatusCode >= 500 { + _ = resp.Body.Close() + return nil, ErrServerError + } + if resp.StatusCode >= 400 { + _ = resp.Body.Close() + return nil, ErrLookupFailed + } + return resp, nil +} + +// post is the shared POST helper. body must be marshaled JSON. It maps HTTP +// status codes to typed errors; the caller is responsible for closing the +// returned body. +func (c *Client) post(ctx context.Context, path string, body []byte) (*http.Response, error) { + u, err := url.Parse(c.BaseURL) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) + } + u.Path = u.Path + path + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("X-Api-Key", c.APIKey) + req.Header.Set("Content-Type", "application/json") + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) + } + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + _ = resp.Body.Close() + return nil, ErrAuthFailed + } + if resp.StatusCode >= 500 { + _ = resp.Body.Close() + return nil, ErrServerError + } + if resp.StatusCode >= 400 { + _ = resp.Body.Close() + return nil, ErrLookupFailed + } + return resp, nil +} + +// LookupArtist hits GET /api/v1/artist/lookup?term=. Returns normalized +// LookupResults; Secondary is "{first genre} · {albumCount} albums" with +// missing parts skipped. +func (c *Client) LookupArtist(ctx context.Context, term string) ([]LookupResult, error) { + resp, err := c.get(ctx, "/api/v1/artist/lookup", url.Values{"term": {term}}) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + var raw []struct { + ForeignArtistID string `json:"foreignArtistId"` + ArtistName string `json:"artistName"` + Genres []string `json:"genres"` + AlbumCount int `json:"albumCount"` + Images []struct { + CoverType string `json:"coverType"` + RemoteURL string `json:"remoteUrl"` + URL string `json:"url"` + } `json:"images"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + out := make([]LookupResult, 0, len(raw)) + for _, r := range raw { + secondary := "" + if len(r.Genres) > 0 { + secondary = r.Genres[0] + } + if r.AlbumCount > 0 { + if secondary != "" { + secondary += " · " + } + secondary += strconv.Itoa(r.AlbumCount) + " albums" + } + out = append(out, LookupResult{ + MBID: r.ForeignArtistID, + Name: r.ArtistName, + Secondary: secondary, + ImageURL: pickPosterImage(r.Images), + }) + } + return out, nil +} + +// LookupAlbum hits GET /api/v1/album/lookup?term=. Returns normalized +// LookupResults; Secondary is "{artistName} · {year} · {trackCount} tracks". +func (c *Client) LookupAlbum(ctx context.Context, term string) ([]LookupResult, error) { + resp, err := c.get(ctx, "/api/v1/album/lookup", url.Values{"term": {term}}) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + var raw []struct { + ForeignAlbumID string `json:"foreignAlbumId"` + Title string `json:"title"` + ArtistName string `json:"artistName"` + ReleaseDate string `json:"releaseDate"` + TrackCount int `json:"trackCount"` + Images []struct { + CoverType string `json:"coverType"` + RemoteURL string `json:"remoteUrl"` + URL string `json:"url"` + } `json:"images"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + out := make([]LookupResult, 0, len(raw)) + for _, r := range raw { + year := "" + if len(r.ReleaseDate) >= 4 { + year = r.ReleaseDate[:4] + } + secondary := r.ArtistName + if year != "" { + secondary += " · " + year + } + if r.TrackCount > 0 { + secondary += " · " + strconv.Itoa(r.TrackCount) + " tracks" + } + out = append(out, LookupResult{ + MBID: r.ForeignAlbumID, + Name: r.Title, + Secondary: secondary, + ImageURL: pickPosterImage(r.Images), + }) + } + return out, nil +} + +// LookupTrack hits GET /api/v1/track/lookup?term=. Lidarr's track lookup is +// per-album under the hood. Secondary is "{albumTitle} · {artistName}". +func (c *Client) LookupTrack(ctx context.Context, term string) ([]LookupResult, error) { + resp, err := c.get(ctx, "/api/v1/track/lookup", url.Values{"term": {term}}) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + var raw []struct { + ForeignTrackID string `json:"foreignTrackId"` + Title string `json:"title"` + AlbumTitle string `json:"albumTitle"` + ArtistName string `json:"artistName"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + out := make([]LookupResult, 0, len(raw)) + for _, r := range raw { + secondary := r.AlbumTitle + if r.ArtistName != "" { + if secondary != "" { + secondary += " · " + } + secondary += r.ArtistName + } + out = append(out, LookupResult{ + MBID: r.ForeignTrackID, + Name: r.Title, + Secondary: secondary, + }) + } + return out, nil +} + +// AddArtist posts to POST /api/v1/artist. MonitorAll=true sends monitor="all"; +// false sends "future". Returns nil on 2xx; typed error otherwise. +func (c *Client) AddArtist(ctx context.Context, p AddArtistParams) error { + monitor := "future" + if p.MonitorAll { + monitor = "all" + } + body, _ := json.Marshal(map[string]any{ + "foreignArtistId": p.ForeignArtistID, + "qualityProfileId": p.QualityProfileID, + "rootFolderPath": p.RootFolderPath, + "monitored": true, + "monitor": monitor, + "addOptions": map[string]any{"searchForMissingAlbums": true}, + }) + resp, err := c.post(ctx, "/api/v1/artist", body) + if err != nil { + return err + } + _ = resp.Body.Close() + return nil +} + +// AddAlbum posts to POST /api/v1/album. Returns nil on 2xx; typed error +// otherwise. +func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error { + body, _ := json.Marshal(map[string]any{ + "foreignAlbumId": p.ForeignAlbumID, + "foreignArtistId": p.ForeignArtistID, + "qualityProfileId": p.QualityProfileID, + "rootFolderPath": p.RootFolderPath, + "monitored": true, + "addOptions": map[string]any{"searchForNewAlbum": true}, + }) + resp, err := c.post(ctx, "/api/v1/album", body) + if err != nil { + return err + } + _ = resp.Body.Close() + return nil +} + +// ListQualityProfiles hits GET /api/v1/qualityprofile and returns the full +// list of quality profiles configured in Lidarr. +func (c *Client) ListQualityProfiles(ctx context.Context) ([]QualityProfile, error) { + resp, err := c.get(ctx, "/api/v1/qualityprofile", nil) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + var raw []struct { + ID int `json:"id"` + Name string `json:"name"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + out := make([]QualityProfile, len(raw)) + for i, r := range raw { + out[i] = QualityProfile{ID: r.ID, Name: r.Name} + } + return out, nil +} + +// ListRootFolders hits GET /api/v1/rootfolder and returns the list of root +// folders configured in Lidarr. +func (c *Client) ListRootFolders(ctx context.Context) ([]RootFolder, error) { + resp, err := c.get(ctx, "/api/v1/rootfolder", nil) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + var raw []struct { + Path string `json:"path"` + Accessible bool `json:"accessible"` + FreeSpace int64 `json:"freeSpace"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + out := make([]RootFolder, len(raw)) + for i, r := range raw { + out[i] = RootFolder{Path: r.Path, Accessible: r.Accessible, FreeSpace: r.FreeSpace} + } + return out, nil +} + +// Ping hits GET /api/v1/system/status to verify connectivity and returns +// the Lidarr version string. +func (c *Client) Ping(ctx context.Context) (PingResult, error) { + resp, err := c.get(ctx, "/api/v1/system/status", nil) + if err != nil { + return PingResult{}, err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + var raw struct { + Version string `json:"version"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + return PingResult{Version: raw.Version}, nil +} + +// pickPosterImage returns the remote URL (preferred) or local URL for the +// first image with coverType=="poster". Returns "" when none match. +func pickPosterImage(imgs []struct { + CoverType string `json:"coverType"` + RemoteURL string `json:"remoteUrl"` + URL string `json:"url"` +}) string { + for _, img := range imgs { + if img.CoverType == "poster" { + if img.RemoteURL != "" { + return img.RemoteURL + } + return img.URL + } + } + return "" +} diff --git a/internal/lidarr/client_test.go b/internal/lidarr/client_test.go new file mode 100644 index 00000000..b8997e2e --- /dev/null +++ b/internal/lidarr/client_test.go @@ -0,0 +1,550 @@ +package lidarr + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +// newTestClient creates an httptest.Server and a Client wired to it. +func newTestClient(handler http.HandlerFunc) (*Client, *httptest.Server) { + srv := httptest.NewServer(handler) + return &Client{BaseURL: srv.URL, APIKey: "key123", HTTP: srv.Client()}, srv +} + +// mustReadFixture reads a file from testdata/ or fatals the test. +func mustReadFixture(t *testing.T, name string) []byte { + t.Helper() + b, err := os.ReadFile("testdata/" + name) + if err != nil { + t.Fatalf("read fixture %q: %v", name, err) + } + return b +} + +// --- LookupArtist --- + +func TestLookupArtist_HappyPath(t *testing.T) { + body := mustReadFixture(t, "lookup_artist.json") + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Api-Key"); got != "key123" { + t.Errorf("X-Api-Key = %q, want key123", got) + } + if r.URL.Path != "/api/v1/artist/lookup" { + t.Errorf("path = %q, want /api/v1/artist/lookup", r.URL.Path) + } + if got := r.URL.Query().Get("term"); got != "boards" { + t.Errorf("term = %q, want boards", got) + } + _, _ = w.Write(body) + }) + defer srv.Close() + + got, err := c.LookupArtist(context.Background(), "boards") + if err != nil { + t.Fatalf("LookupArtist: %v", err) + } + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + + // First result — rich data. + if got[0].MBID != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" { + t.Errorf("MBID = %q", got[0].MBID) + } + if got[0].Name != "Boards of Canada" { + t.Errorf("Name = %q", got[0].Name) + } + if got[0].Secondary != "Electronic · 18 albums" { + t.Errorf("Secondary = %q, want %q", got[0].Secondary, "Electronic · 18 albums") + } + if got[0].ImageURL != "https://example.invalid/boc.jpg" { + t.Errorf("ImageURL = %q", got[0].ImageURL) + } + + // Second result — empty genres and zero album count → empty secondary. + if got[1].Secondary != "" { + t.Errorf("expected empty secondary for no genres + 0 albums; got %q", got[1].Secondary) + } + if got[1].ImageURL != "" { + t.Errorf("expected empty ImageURL; got %q", got[1].ImageURL) + } +} + +func TestLookupArtist_AuthFailed(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + defer srv.Close() + _, err := c.LookupArtist(context.Background(), "boards") + if !errors.Is(err, ErrAuthFailed) { + t.Fatalf("err = %v, want ErrAuthFailed", err) + } +} + +func TestLookupArtist_Forbidden(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + }) + defer srv.Close() + _, err := c.LookupArtist(context.Background(), "boards") + if !errors.Is(err, ErrAuthFailed) { + t.Fatalf("err = %v, want ErrAuthFailed (403 must map to auth)", err) + } +} + +func TestLookupArtist_ServerError(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + defer srv.Close() + _, err := c.LookupArtist(context.Background(), "boards") + if !errors.Is(err, ErrServerError) { + t.Fatalf("err = %v, want ErrServerError", err) + } +} + +func TestLookupArtist_Unreachable(t *testing.T) { + c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "x", HTTP: &http.Client{}} + _, err := c.LookupArtist(context.Background(), "boards") + if !errors.Is(err, ErrUnreachable) { + t.Fatalf("err = %v, want ErrUnreachable", err) + } +} + +func TestLookupArtist_BadJSON(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`not json`)) + }) + defer srv.Close() + _, err := c.LookupArtist(context.Background(), "boards") + if !errors.Is(err, ErrInvalidPayload) { + t.Fatalf("err = %v, want ErrInvalidPayload", err) + } +} + +// --- LookupAlbum --- + +func TestLookupAlbum_HappyPath(t *testing.T) { + body := mustReadFixture(t, "lookup_album.json") + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/album/lookup" { + t.Errorf("path = %q, want /api/v1/album/lookup", r.URL.Path) + } + if got := r.URL.Query().Get("term"); got != "geogaddi" { + t.Errorf("term = %q, want geogaddi", got) + } + _, _ = w.Write(body) + }) + defer srv.Close() + + got, err := c.LookupAlbum(context.Background(), "geogaddi") + if err != nil { + t.Fatalf("LookupAlbum: %v", err) + } + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + + // First result: year from releaseDate, trackCount in secondary. + if got[0].MBID != "a1b2c3d4-e5f6-7890-abcd-ef1234567890" { + t.Errorf("MBID = %q", got[0].MBID) + } + if got[0].Name != "Music Has the Right to Children" { + t.Errorf("Name = %q", got[0].Name) + } + want0 := "Boards of Canada · 1998 · 18 tracks" + if got[0].Secondary != want0 { + t.Errorf("Secondary = %q, want %q", got[0].Secondary, want0) + } + // Poster image from second image entry. + if got[0].ImageURL != "https://example.invalid/mhtrtc-poster.jpg" { + t.Errorf("ImageURL = %q", got[0].ImageURL) + } + + // Second result. + want1 := "Boards of Canada · 2002 · 23 tracks" + if got[1].Secondary != want1 { + t.Errorf("Secondary = %q, want %q", got[1].Secondary, want1) + } + if got[1].ImageURL != "" { + t.Errorf("expected empty ImageURL; got %q", got[1].ImageURL) + } +} + +func TestLookupAlbum_BadJSON(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{}`)) + }) + defer srv.Close() + _, err := c.LookupAlbum(context.Background(), "x") + if !errors.Is(err, ErrInvalidPayload) { + t.Fatalf("err = %v, want ErrInvalidPayload (object, not array)", err) + } +} + +// --- LookupTrack --- + +func TestLookupTrack_HappyPath(t *testing.T) { + body := mustReadFixture(t, "lookup_track.json") + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/track/lookup" { + t.Errorf("path = %q, want /api/v1/track/lookup", r.URL.Path) + } + _, _ = w.Write(body) + }) + defer srv.Close() + + got, err := c.LookupTrack(context.Background(), "roygbiv") + if err != nil { + t.Fatalf("LookupTrack: %v", err) + } + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0].Name != "Roygbiv" { + t.Errorf("Name = %q", got[0].Name) + } + want := "Music Has the Right to Children · Boards of Canada" + if got[0].Secondary != want { + t.Errorf("Secondary = %q, want %q", got[0].Secondary, want) + } + if got[0].ImageURL != "" { + t.Errorf("tracks carry no images; ImageURL = %q", got[0].ImageURL) + } +} + +func TestLookupTrack_NoArtistName(t *testing.T) { + // Verify secondary degrades gracefully when artistName is absent. + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[{"foreignTrackId":"abc","title":"X","albumTitle":"AlbumY","artistName":""}]`)) + }) + defer srv.Close() + got, err := c.LookupTrack(context.Background(), "x") + if err != nil { + t.Fatalf("err: %v", err) + } + if got[0].Secondary != "AlbumY" { + t.Errorf("Secondary = %q, want %q", got[0].Secondary, "AlbumY") + } +} + +// --- AddArtist --- + +func TestAddArtist_PostsCorrectBody(t *testing.T) { + var decoded map[string]any + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %q, want POST", r.Method) + } + if r.URL.Path != "/api/v1/artist" { + t.Errorf("path = %q, want /api/v1/artist", r.URL.Path) + } + if ct := r.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + if err := json.NewDecoder(r.Body).Decode(&decoded); err != nil { + t.Errorf("decode body: %v", err) + } + w.WriteHeader(http.StatusCreated) + }) + defer srv.Close() + + err := c.AddArtist(context.Background(), AddArtistParams{ + ForeignArtistID: "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", + QualityProfileID: 2, + RootFolderPath: "/music", + MonitorAll: true, + }) + if err != nil { + t.Fatalf("AddArtist: %v", err) + } + + if decoded["foreignArtistId"] != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" { + t.Errorf("foreignArtistId = %v", decoded["foreignArtistId"]) + } + if decoded["qualityProfileId"] != float64(2) { + t.Errorf("qualityProfileId = %v", decoded["qualityProfileId"]) + } + if decoded["rootFolderPath"] != "/music" { + t.Errorf("rootFolderPath = %v", decoded["rootFolderPath"]) + } + if decoded["monitored"] != true { + t.Errorf("monitored = %v, want true", decoded["monitored"]) + } + if decoded["monitor"] != "all" { + t.Errorf("monitor = %v, want all (MonitorAll=true)", decoded["monitor"]) + } + opts, ok := decoded["addOptions"].(map[string]any) + if !ok { + t.Fatalf("addOptions missing or wrong type: %T", decoded["addOptions"]) + } + if opts["searchForMissingAlbums"] != true { + t.Errorf("addOptions.searchForMissingAlbums = %v, want true", opts["searchForMissingAlbums"]) + } +} + +func TestAddArtist_MonitorFuture(t *testing.T) { + var decoded map[string]any + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&decoded) + w.WriteHeader(http.StatusCreated) + }) + defer srv.Close() + + _ = c.AddArtist(context.Background(), AddArtistParams{MonitorAll: false}) + if decoded["monitor"] != "future" { + t.Errorf("monitor = %v, want future (MonitorAll=false)", decoded["monitor"]) + } +} + +func TestAddArtist_ServerError(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + defer srv.Close() + err := c.AddArtist(context.Background(), AddArtistParams{}) + if !errors.Is(err, ErrServerError) { + t.Fatalf("err = %v, want ErrServerError", err) + } +} + +// --- AddAlbum --- + +func TestAddAlbum_PostsCorrectBody(t *testing.T) { + var decoded map[string]any + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %q, want POST", r.Method) + } + if r.URL.Path != "/api/v1/album" { + t.Errorf("path = %q, want /api/v1/album", r.URL.Path) + } + _ = json.NewDecoder(r.Body).Decode(&decoded) + w.WriteHeader(http.StatusCreated) + }) + defer srv.Close() + + err := c.AddAlbum(context.Background(), AddAlbumParams{ + ForeignAlbumID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ForeignArtistID: "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", + QualityProfileID: 1, + RootFolderPath: "/music", + }) + if err != nil { + t.Fatalf("AddAlbum: %v", err) + } + + if decoded["foreignAlbumId"] != "a1b2c3d4-e5f6-7890-abcd-ef1234567890" { + t.Errorf("foreignAlbumId = %v", decoded["foreignAlbumId"]) + } + if decoded["foreignArtistId"] != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" { + t.Errorf("foreignArtistId = %v", decoded["foreignArtistId"]) + } + if decoded["monitored"] != true { + t.Errorf("monitored = %v, want true", decoded["monitored"]) + } + opts, ok := decoded["addOptions"].(map[string]any) + if !ok { + t.Fatalf("addOptions missing or wrong type: %T", decoded["addOptions"]) + } + if opts["searchForNewAlbum"] != true { + t.Errorf("addOptions.searchForNewAlbum = %v, want true", opts["searchForNewAlbum"]) + } +} + +func TestAddAlbum_AuthFailed(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + defer srv.Close() + err := c.AddAlbum(context.Background(), AddAlbumParams{}) + if !errors.Is(err, ErrAuthFailed) { + t.Fatalf("err = %v, want ErrAuthFailed", err) + } +} + +// --- ListQualityProfiles --- + +func TestListQualityProfiles_HappyPath(t *testing.T) { + body := mustReadFixture(t, "quality_profiles.json") + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/qualityprofile" { + t.Errorf("path = %q, want /api/v1/qualityprofile", r.URL.Path) + } + _, _ = w.Write(body) + }) + defer srv.Close() + + got, err := c.ListQualityProfiles(context.Background()) + if err != nil { + t.Fatalf("ListQualityProfiles: %v", err) + } + if len(got) != 3 { + t.Fatalf("len = %d, want 3", len(got)) + } + if got[0].ID != 1 || got[0].Name != "Any" { + t.Errorf("first profile = %+v, want {ID:1 Name:Any}", got[0]) + } + if got[1].ID != 2 || got[1].Name != "Lossless" { + t.Errorf("second profile = %+v", got[1]) + } +} + +func TestListQualityProfiles_BadJSON(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`not json`)) + }) + defer srv.Close() + _, err := c.ListQualityProfiles(context.Background()) + if !errors.Is(err, ErrInvalidPayload) { + t.Fatalf("err = %v, want ErrInvalidPayload", err) + } +} + +// --- ListRootFolders --- + +func TestListRootFolders_HappyPath(t *testing.T) { + body := mustReadFixture(t, "root_folders.json") + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/rootfolder" { + t.Errorf("path = %q, want /api/v1/rootfolder", r.URL.Path) + } + _, _ = w.Write(body) + }) + defer srv.Close() + + got, err := c.ListRootFolders(context.Background()) + if err != nil { + t.Fatalf("ListRootFolders: %v", err) + } + if len(got) != 3 { + t.Fatalf("len = %d, want 3", len(got)) + } + if got[0].Path != "/music" { + t.Errorf("Path = %q", got[0].Path) + } + if !got[0].Accessible { + t.Errorf("Accessible = false for /music, want true") + } + if got[0].FreeSpace != 107374182400 { + t.Errorf("FreeSpace = %d", got[0].FreeSpace) + } + if got[2].Accessible { + t.Errorf("expected /music-offline to be inaccessible") + } +} + +func TestListRootFolders_BadJSON(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`null`)) + }) + defer srv.Close() + // null decodes to a nil slice, no error — but len check should yield 0. + got, err := c.ListRootFolders(context.Background()) + if err != nil { + t.Fatalf("unexpected error on null body: %v", err) + } + if len(got) != 0 { + t.Errorf("expected empty slice from null body, got %d", len(got)) + } +} + +// --- Ping --- + +func TestPing_ReturnsVersion(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/system/status" { + t.Errorf("path = %q, want /api/v1/system/status", r.URL.Path) + } + _, _ = w.Write([]byte(`{"version":"2.4.3.3795","buildTime":"2024-01-15T00:00:00Z"}`)) + }) + defer srv.Close() + + res, err := c.Ping(context.Background()) + if err != nil { + t.Fatalf("Ping: %v", err) + } + if res.Version != "2.4.3.3795" { + t.Errorf("Version = %q, want 2.4.3.3795", res.Version) + } +} + +func TestPing_AuthFailed(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + defer srv.Close() + _, err := c.Ping(context.Background()) + if !errors.Is(err, ErrAuthFailed) { + t.Fatalf("err = %v, want ErrAuthFailed", err) + } +} + +func TestPing_Unreachable(t *testing.T) { + c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "x", HTTP: &http.Client{}} + _, err := c.Ping(context.Background()) + if !errors.Is(err, ErrUnreachable) { + t.Fatalf("err = %v, want ErrUnreachable", err) + } +} + +func TestPing_BadJSON(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{bad`)) + }) + defer srv.Close() + _, err := c.Ping(context.Background()) + if !errors.Is(err, ErrInvalidPayload) { + t.Fatalf("err = %v, want ErrInvalidPayload", err) + } +} + +// --- pickPosterImage --- + +func TestPickPosterImage_PreferRemoteURL(t *testing.T) { + imgs := []struct { + CoverType string `json:"coverType"` + RemoteURL string `json:"remoteUrl"` + URL string `json:"url"` + }{ + {CoverType: "poster", RemoteURL: "https://remote.example/img.jpg", URL: "https://local.example/img.jpg"}, + } + got := pickPosterImage(imgs) + if got != "https://remote.example/img.jpg" { + t.Errorf("got %q, want remote URL", got) + } +} + +func TestPickPosterImage_FallsBackToLocalURL(t *testing.T) { + imgs := []struct { + CoverType string `json:"coverType"` + RemoteURL string `json:"remoteUrl"` + URL string `json:"url"` + }{ + {CoverType: "poster", RemoteURL: "", URL: "https://local.example/img.jpg"}, + } + got := pickPosterImage(imgs) + if got != "https://local.example/img.jpg" { + t.Errorf("got %q, want local URL", got) + } +} + +func TestPickPosterImage_SkipsNonPoster(t *testing.T) { + imgs := []struct { + CoverType string `json:"coverType"` + RemoteURL string `json:"remoteUrl"` + URL string `json:"url"` + }{ + {CoverType: "banner", RemoteURL: "https://banner.example/img.jpg"}, + {CoverType: "cover", RemoteURL: "https://cover.example/img.jpg"}, + } + got := pickPosterImage(imgs) + if got != "" { + t.Errorf("got %q, want empty (no poster)", got) + } +} diff --git a/internal/lidarr/errors.go b/internal/lidarr/errors.go new file mode 100644 index 00000000..0cdf98e2 --- /dev/null +++ b/internal/lidarr/errors.go @@ -0,0 +1,13 @@ +package lidarr + +import "errors" + +// Sentinel errors. Callers branch on these via errors.Is, not on +// HTTP status codes — the client maps codes to errors. +var ( + ErrUnreachable = errors.New("lidarr: unreachable") + ErrAuthFailed = errors.New("lidarr: auth failed") // 401 / 403 + ErrLookupFailed = errors.New("lidarr: lookup failed") // 4xx other than 401/403 + ErrServerError = errors.New("lidarr: server error") // 5xx + ErrInvalidPayload = errors.New("lidarr: invalid payload") +) diff --git a/internal/lidarr/testdata/lookup_album.json b/internal/lidarr/testdata/lookup_album.json new file mode 100644 index 00000000..c8901a50 --- /dev/null +++ b/internal/lidarr/testdata/lookup_album.json @@ -0,0 +1,21 @@ +[ + { + "foreignAlbumId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "title": "Music Has the Right to Children", + "artistName": "Boards of Canada", + "releaseDate": "1998-04-27", + "trackCount": 18, + "images": [ + {"coverType": "cover", "remoteUrl": "https://example.invalid/mhtrtc.jpg", "url": ""}, + {"coverType": "poster", "remoteUrl": "https://example.invalid/mhtrtc-poster.jpg", "url": ""} + ] + }, + { + "foreignAlbumId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "title": "Geogaddi", + "artistName": "Boards of Canada", + "releaseDate": "2002-02-11", + "trackCount": 23, + "images": [] + } +] diff --git a/internal/lidarr/testdata/lookup_artist.json b/internal/lidarr/testdata/lookup_artist.json new file mode 100644 index 00000000..d3caab26 --- /dev/null +++ b/internal/lidarr/testdata/lookup_artist.json @@ -0,0 +1,19 @@ +[ + { + "foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", + "artistName": "Boards of Canada", + "genres": ["Electronic", "IDM"], + "albumCount": 18, + "images": [ + {"coverType": "poster", "remoteUrl": "https://example.invalid/boc.jpg", "url": ""}, + {"coverType": "banner", "remoteUrl": "https://example.invalid/boc-banner.jpg", "url": ""} + ] + }, + { + "foreignArtistId": "f54ba20c-7da3-4b8a-9b12-22f09b9e2c1c", + "artistName": "Bored of Education", + "genres": [], + "albumCount": 0, + "images": [] + } +] diff --git a/internal/lidarr/testdata/lookup_track.json b/internal/lidarr/testdata/lookup_track.json new file mode 100644 index 00000000..15230230 --- /dev/null +++ b/internal/lidarr/testdata/lookup_track.json @@ -0,0 +1,14 @@ +[ + { + "foreignTrackId": "t1a2b3c4-d5e6-7890-abcd-ef1234567890", + "title": "Roygbiv", + "albumTitle": "Music Has the Right to Children", + "artistName": "Boards of Canada" + }, + { + "foreignTrackId": "t2b3c4d5-e6f7-8901-bcde-f12345678901", + "title": "Aquarius", + "albumTitle": "Music Has the Right to Children", + "artistName": "Boards of Canada" + } +] diff --git a/internal/lidarr/testdata/quality_profiles.json b/internal/lidarr/testdata/quality_profiles.json new file mode 100644 index 00000000..b645820e --- /dev/null +++ b/internal/lidarr/testdata/quality_profiles.json @@ -0,0 +1,5 @@ +[ + {"id": 1, "name": "Any"}, + {"id": 2, "name": "Lossless"}, + {"id": 3, "name": "Standard"} +] diff --git a/internal/lidarr/testdata/root_folders.json b/internal/lidarr/testdata/root_folders.json new file mode 100644 index 00000000..b75eef9e --- /dev/null +++ b/internal/lidarr/testdata/root_folders.json @@ -0,0 +1,5 @@ +[ + {"path": "/music", "accessible": true, "freeSpace": 107374182400}, + {"path": "/music-lossy", "accessible": true, "freeSpace": 53687091200}, + {"path": "/music-offline", "accessible": false, "freeSpace": 0} +] diff --git a/internal/lidarr/types.go b/internal/lidarr/types.go new file mode 100644 index 00000000..0c4441ba --- /dev/null +++ b/internal/lidarr/types.go @@ -0,0 +1,48 @@ +// Package lidarr is a typed HTTP client for Lidarr's v1 API. It is the +// only place in the codebase that knows about Lidarr's wire format. +// Callers receive value structs, never raw JSON. +package lidarr + +// LookupResult is the normalized shape returned by Lookup{Artist,Album,Track}. +// It is what we store on the request row (via the user's request) and +// what /api/lidarr/search returns to the SPA. +type LookupResult struct { + MBID string // foreignArtistId / foreignAlbumId / foreignTrackId + Name string // artist name; album/track returns Title here too + Secondary string // genre + album count for artist; year for album; album for track + ImageURL string // cover-art URL Lidarr surfaced (may be empty) +} + +// QualityProfile is the dropdown choice in /admin/integrations. +type QualityProfile struct { + ID int + Name string +} + +// RootFolder is the dropdown choice in /admin/integrations. +type RootFolder struct { + Path string + Accessible bool + FreeSpace int64 +} + +// AddArtistParams are the fields Lidarr requires on POST /api/v1/artist. +type AddArtistParams struct { + ForeignArtistID string + QualityProfileID int + RootFolderPath string + MonitorAll bool // true => monitor="all"; false => "future" +} + +// AddAlbumParams are the fields Lidarr requires on POST /api/v1/album. +type AddAlbumParams struct { + ForeignAlbumID string + ForeignArtistID string // Lidarr requires the artist's foreign id too + QualityProfileID int + RootFolderPath string +} + +// PingResult is the response shape from GET /api/v1/system/status. +type PingResult struct { + Version string +} From ebff8f69a0e5fbe0c170197d4459cb3d2a0b6c7c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 16:26:23 -0400 Subject: [PATCH 06/67] fix(lidarr): address review findings on HTTP client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ArtistMBID/AlbumMBID to LookupResult so handlers can build the full lidarr_requests row from album/track lookups (parent MBID is needed for the AddAlbum API call and for the schema's NOT NULL lidarr_artist_mbid) - Trim trailing slash on BaseURL before joining paths to avoid double-slash URLs when operators enter "http://lidarr.lan/" - Check json.Marshal errors in AddArtist/AddAlbum - Extract lidarrImage as a named unexported type instead of repeating the anonymous struct three times - Add NewClient(baseURL, apiKey) constructor with 30s default timeout - Add ErrLookupFailed test coverage for the 4xx-non-auth branch on both get and post helpers - Rename TestListRootFolders_BadJSON -> _NullBody (it tests null, not malformed); add a real malformed-JSON test - Defensive separator guard on LookupAlbum.Secondary so an empty ArtistName doesn't produce a leading " · " Co-Authored-By: Claude Sonnet 4.6 --- internal/lidarr/client.go | 106 +++++++++++++-------- internal/lidarr/client_test.go | 89 +++++++++++++---- internal/lidarr/testdata/lookup_album.json | 2 + internal/lidarr/testdata/lookup_track.json | 4 + internal/lidarr/types.go | 24 +++-- 5 files changed, 161 insertions(+), 64 deletions(-) diff --git a/internal/lidarr/client.go b/internal/lidarr/client.go index 48af1d3d..da531e97 100644 --- a/internal/lidarr/client.go +++ b/internal/lidarr/client.go @@ -9,6 +9,8 @@ import ( "net/http" "net/url" "strconv" + "strings" + "time" ) // Client wraps Lidarr's v1 HTTP API. BaseURL is the Lidarr instance @@ -19,6 +21,17 @@ type Client struct { HTTP *http.Client } +// NewClient builds a Client with sensible HTTP defaults (30s timeout, +// matching the pattern in internal/scrobble/listenbrainz). Call sites +// that need a custom client can construct the Client struct directly. +func NewClient(baseURL, apiKey string) *Client { + return &Client{ + BaseURL: baseURL, + APIKey: apiKey, + HTTP: &http.Client{Timeout: 30 * time.Second}, + } +} + // get executes an authenticated GET request and maps HTTP status codes to // typed errors. The caller is responsible for closing the returned body. func (c *Client) get(ctx context.Context, path string, q url.Values) (*http.Response, error) { @@ -26,7 +39,7 @@ func (c *Client) get(ctx context.Context, path string, q url.Values) (*http.Resp if err != nil { return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) } - u.Path = u.Path + path + u.Path = strings.TrimRight(u.Path, "/") + path if q != nil { u.RawQuery = q.Encode() } @@ -62,7 +75,7 @@ func (c *Client) post(ctx context.Context, path string, body []byte) (*http.Resp if err != nil { return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) } - u.Path = u.Path + path + u.Path = strings.TrimRight(u.Path, "/") + path req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body)) if err != nil { return nil, err @@ -102,15 +115,11 @@ func (c *Client) LookupArtist(ctx context.Context, term string) ([]LookupResult, return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) } var raw []struct { - ForeignArtistID string `json:"foreignArtistId"` - ArtistName string `json:"artistName"` - Genres []string `json:"genres"` - AlbumCount int `json:"albumCount"` - Images []struct { - CoverType string `json:"coverType"` - RemoteURL string `json:"remoteUrl"` - URL string `json:"url"` - } `json:"images"` + ForeignArtistID string `json:"foreignArtistId"` + ArtistName string `json:"artistName"` + Genres []string `json:"genres"` + AlbumCount int `json:"albumCount"` + Images []lidarrImage `json:"images"` } if err := json.Unmarshal(body, &raw); err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) @@ -150,16 +159,13 @@ func (c *Client) LookupAlbum(ctx context.Context, term string) ([]LookupResult, return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) } var raw []struct { - ForeignAlbumID string `json:"foreignAlbumId"` - Title string `json:"title"` - ArtistName string `json:"artistName"` - ReleaseDate string `json:"releaseDate"` - TrackCount int `json:"trackCount"` - Images []struct { - CoverType string `json:"coverType"` - RemoteURL string `json:"remoteUrl"` - URL string `json:"url"` - } `json:"images"` + ForeignAlbumID string `json:"foreignAlbumId"` + ForeignArtistID string `json:"foreignArtistId"` + Title string `json:"title"` + ArtistName string `json:"artistName"` + ReleaseDate string `json:"releaseDate"` + TrackCount int `json:"trackCount"` + Images []lidarrImage `json:"images"` } if err := json.Unmarshal(body, &raw); err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) @@ -170,18 +176,28 @@ func (c *Client) LookupAlbum(ctx context.Context, term string) ([]LookupResult, if len(r.ReleaseDate) >= 4 { year = r.ReleaseDate[:4] } - secondary := r.ArtistName + secondary := "" + if r.ArtistName != "" { + secondary = r.ArtistName + } if year != "" { - secondary += " · " + year + if secondary != "" { + secondary += " · " + } + secondary += year } if r.TrackCount > 0 { - secondary += " · " + strconv.Itoa(r.TrackCount) + " tracks" + if secondary != "" { + secondary += " · " + } + secondary += strconv.Itoa(r.TrackCount) + " tracks" } out = append(out, LookupResult{ - MBID: r.ForeignAlbumID, - Name: r.Title, - Secondary: secondary, - ImageURL: pickPosterImage(r.Images), + MBID: r.ForeignAlbumID, + ArtistMBID: r.ForeignArtistID, + Name: r.Title, + Secondary: secondary, + ImageURL: pickPosterImage(r.Images), }) } return out, nil @@ -200,10 +216,12 @@ func (c *Client) LookupTrack(ctx context.Context, term string) ([]LookupResult, return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) } var raw []struct { - ForeignTrackID string `json:"foreignTrackId"` - Title string `json:"title"` - AlbumTitle string `json:"albumTitle"` - ArtistName string `json:"artistName"` + ForeignTrackID string `json:"foreignTrackId"` + ForeignAlbumID string `json:"foreignAlbumId"` + ForeignArtistID string `json:"foreignArtistId"` + Title string `json:"title"` + AlbumTitle string `json:"albumTitle"` + ArtistName string `json:"artistName"` } if err := json.Unmarshal(body, &raw); err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) @@ -218,9 +236,11 @@ func (c *Client) LookupTrack(ctx context.Context, term string) ([]LookupResult, secondary += r.ArtistName } out = append(out, LookupResult{ - MBID: r.ForeignTrackID, - Name: r.Title, - Secondary: secondary, + MBID: r.ForeignTrackID, + ArtistMBID: r.ForeignArtistID, + AlbumMBID: r.ForeignAlbumID, + Name: r.Title, + Secondary: secondary, }) } return out, nil @@ -233,7 +253,7 @@ func (c *Client) AddArtist(ctx context.Context, p AddArtistParams) error { if p.MonitorAll { monitor = "all" } - body, _ := json.Marshal(map[string]any{ + body, err := json.Marshal(map[string]any{ "foreignArtistId": p.ForeignArtistID, "qualityProfileId": p.QualityProfileID, "rootFolderPath": p.RootFolderPath, @@ -241,6 +261,9 @@ func (c *Client) AddArtist(ctx context.Context, p AddArtistParams) error { "monitor": monitor, "addOptions": map[string]any{"searchForMissingAlbums": true}, }) + if err != nil { + return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err) + } resp, err := c.post(ctx, "/api/v1/artist", body) if err != nil { return err @@ -252,7 +275,7 @@ func (c *Client) AddArtist(ctx context.Context, p AddArtistParams) error { // AddAlbum posts to POST /api/v1/album. Returns nil on 2xx; typed error // otherwise. func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error { - body, _ := json.Marshal(map[string]any{ + body, err := json.Marshal(map[string]any{ "foreignAlbumId": p.ForeignAlbumID, "foreignArtistId": p.ForeignArtistID, "qualityProfileId": p.QualityProfileID, @@ -260,6 +283,9 @@ func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error { "monitored": true, "addOptions": map[string]any{"searchForNewAlbum": true}, }) + if err != nil { + return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err) + } resp, err := c.post(ctx, "/api/v1/album", body) if err != nil { return err @@ -344,11 +370,7 @@ func (c *Client) Ping(ctx context.Context) (PingResult, error) { // pickPosterImage returns the remote URL (preferred) or local URL for the // first image with coverType=="poster". Returns "" when none match. -func pickPosterImage(imgs []struct { - CoverType string `json:"coverType"` - RemoteURL string `json:"remoteUrl"` - URL string `json:"url"` -}) string { +func pickPosterImage(imgs []lidarrImage) string { for _, img := range imgs { if img.CoverType == "poster" { if img.RemoteURL != "" { diff --git a/internal/lidarr/client_test.go b/internal/lidarr/client_test.go index b8997e2e..5f56f0bf 100644 --- a/internal/lidarr/client_test.go +++ b/internal/lidarr/client_test.go @@ -154,6 +154,12 @@ func TestLookupAlbum_HappyPath(t *testing.T) { if got[0].MBID != "a1b2c3d4-e5f6-7890-abcd-ef1234567890" { t.Errorf("MBID = %q", got[0].MBID) } + if got[0].ArtistMBID != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" { + t.Errorf("ArtistMBID = %q, want 069b64b6-7884-4f6a-94cc-e4c1d6c87a01", got[0].ArtistMBID) + } + if got[0].AlbumMBID != "" { + t.Errorf("AlbumMBID = %q, want empty on album result", got[0].AlbumMBID) + } if got[0].Name != "Music Has the Right to Children" { t.Errorf("Name = %q", got[0].Name) } @@ -213,6 +219,12 @@ func TestLookupTrack_HappyPath(t *testing.T) { if got[0].Secondary != want { t.Errorf("Secondary = %q, want %q", got[0].Secondary, want) } + if got[0].ArtistMBID != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" { + t.Errorf("ArtistMBID = %q, want 069b64b6-7884-4f6a-94cc-e4c1d6c87a01", got[0].ArtistMBID) + } + if got[0].AlbumMBID != "a1b2c3d4-e5f6-7890-abcd-ef1234567890" { + t.Errorf("AlbumMBID = %q, want a1b2c3d4-e5f6-7890-abcd-ef1234567890", got[0].AlbumMBID) + } if got[0].ImageURL != "" { t.Errorf("tracks carry no images; ImageURL = %q", got[0].ImageURL) } @@ -406,6 +418,51 @@ func TestListQualityProfiles_BadJSON(t *testing.T) { } } +// --- LookupArtist (additional) --- + +func TestLookupArtist_LookupFailed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + c := &Client{BaseURL: srv.URL, APIKey: "x", HTTP: srv.Client()} + _, err := c.LookupArtist(context.Background(), "boards") + if !errors.Is(err, ErrLookupFailed) { + t.Fatalf("err = %v, want ErrLookupFailed", err) + } +} + +func TestGet_BaseURLWithTrailingSlash(t *testing.T) { + body, _ := os.ReadFile("testdata/lookup_artist.json") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify path is /api/v1/artist/lookup, NOT //api/v1/artist/lookup + if r.URL.Path != "/api/v1/artist/lookup" { + t.Errorf("got path %q; trailing-slash BaseURL produced double-slash", r.URL.Path) + } + _, _ = w.Write(body) + })) + defer srv.Close() + c := &Client{BaseURL: srv.URL + "/", APIKey: "k", HTTP: srv.Client()} + _, err := c.LookupArtist(context.Background(), "boards") + if err != nil { + t.Fatalf("LookupArtist: %v", err) + } +} + +// --- AddArtist (additional) --- + +func TestAddArtist_PostFailed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + })) + defer srv.Close() + c := &Client{BaseURL: srv.URL, APIKey: "x", HTTP: srv.Client()} + err := c.AddArtist(context.Background(), AddArtistParams{ForeignArtistID: "x", QualityProfileID: 1, RootFolderPath: "/m"}) + if !errors.Is(err, ErrLookupFailed) { + t.Fatalf("err = %v, want ErrLookupFailed", err) + } +} + // --- ListRootFolders --- func TestListRootFolders_HappyPath(t *testing.T) { @@ -439,7 +496,7 @@ func TestListRootFolders_HappyPath(t *testing.T) { } } -func TestListRootFolders_BadJSON(t *testing.T) { +func TestListRootFolders_NullBody(t *testing.T) { c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`null`)) }) @@ -454,6 +511,18 @@ func TestListRootFolders_BadJSON(t *testing.T) { } } +func TestListRootFolders_BadJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("{not valid json")) + })) + defer srv.Close() + c := &Client{BaseURL: srv.URL, APIKey: "x", HTTP: srv.Client()} + _, err := c.ListRootFolders(context.Background()) + if !errors.Is(err, ErrInvalidPayload) { + t.Fatalf("err = %v, want ErrInvalidPayload", err) + } +} + // --- Ping --- func TestPing_ReturnsVersion(t *testing.T) { @@ -507,11 +576,7 @@ func TestPing_BadJSON(t *testing.T) { // --- pickPosterImage --- func TestPickPosterImage_PreferRemoteURL(t *testing.T) { - imgs := []struct { - CoverType string `json:"coverType"` - RemoteURL string `json:"remoteUrl"` - URL string `json:"url"` - }{ + imgs := []lidarrImage{ {CoverType: "poster", RemoteURL: "https://remote.example/img.jpg", URL: "https://local.example/img.jpg"}, } got := pickPosterImage(imgs) @@ -521,11 +586,7 @@ func TestPickPosterImage_PreferRemoteURL(t *testing.T) { } func TestPickPosterImage_FallsBackToLocalURL(t *testing.T) { - imgs := []struct { - CoverType string `json:"coverType"` - RemoteURL string `json:"remoteUrl"` - URL string `json:"url"` - }{ + imgs := []lidarrImage{ {CoverType: "poster", RemoteURL: "", URL: "https://local.example/img.jpg"}, } got := pickPosterImage(imgs) @@ -535,11 +596,7 @@ func TestPickPosterImage_FallsBackToLocalURL(t *testing.T) { } func TestPickPosterImage_SkipsNonPoster(t *testing.T) { - imgs := []struct { - CoverType string `json:"coverType"` - RemoteURL string `json:"remoteUrl"` - URL string `json:"url"` - }{ + imgs := []lidarrImage{ {CoverType: "banner", RemoteURL: "https://banner.example/img.jpg"}, {CoverType: "cover", RemoteURL: "https://cover.example/img.jpg"}, } diff --git a/internal/lidarr/testdata/lookup_album.json b/internal/lidarr/testdata/lookup_album.json index c8901a50..b1bb6e35 100644 --- a/internal/lidarr/testdata/lookup_album.json +++ b/internal/lidarr/testdata/lookup_album.json @@ -1,6 +1,7 @@ [ { "foreignAlbumId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", "title": "Music Has the Right to Children", "artistName": "Boards of Canada", "releaseDate": "1998-04-27", @@ -12,6 +13,7 @@ }, { "foreignAlbumId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", "title": "Geogaddi", "artistName": "Boards of Canada", "releaseDate": "2002-02-11", diff --git a/internal/lidarr/testdata/lookup_track.json b/internal/lidarr/testdata/lookup_track.json index 15230230..10751762 100644 --- a/internal/lidarr/testdata/lookup_track.json +++ b/internal/lidarr/testdata/lookup_track.json @@ -1,12 +1,16 @@ [ { "foreignTrackId": "t1a2b3c4-d5e6-7890-abcd-ef1234567890", + "foreignAlbumId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", "title": "Roygbiv", "albumTitle": "Music Has the Right to Children", "artistName": "Boards of Canada" }, { "foreignTrackId": "t2b3c4d5-e6f7-8901-bcde-f12345678901", + "foreignAlbumId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", "title": "Aquarius", "albumTitle": "Music Has the Right to Children", "artistName": "Boards of Canada" diff --git a/internal/lidarr/types.go b/internal/lidarr/types.go index 0c4441ba..d1c8ec05 100644 --- a/internal/lidarr/types.go +++ b/internal/lidarr/types.go @@ -4,13 +4,25 @@ package lidarr // LookupResult is the normalized shape returned by Lookup{Artist,Album,Track}. -// It is what we store on the request row (via the user's request) and -// what /api/lidarr/search returns to the SPA. +// MBID is the kind's primary identifier (foreignArtistId / foreignAlbumId / +// foreignTrackId from Lidarr). ArtistMBID and AlbumMBID carry parent +// identifiers when the kind has them — request creation and admin +// approval need them to construct the lidarr_requests row and the +// AddArtist/AddAlbum API calls. type LookupResult struct { - MBID string // foreignArtistId / foreignAlbumId / foreignTrackId - Name string // artist name; album/track returns Title here too - Secondary string // genre + album count for artist; year for album; album for track - ImageURL string // cover-art URL Lidarr surfaced (may be empty) + MBID string // foreignArtistId / foreignAlbumId / foreignTrackId + ArtistMBID string // parent artist's foreignArtistId — set on album/track results, empty on artist results + AlbumMBID string // parent album's foreignAlbumId — set on track results only + Name string // artist name; album/track returns Title here too + Secondary string // genre + album count for artist; year for album; album for track + ImageURL string // cover-art URL Lidarr surfaced (may be empty) +} + +// lidarrImage is the wire shape for image entries in Lidarr's API responses. +type lidarrImage struct { + CoverType string `json:"coverType"` + RemoteURL string `json:"remoteUrl"` + URL string `json:"url"` } // QualityProfile is the dropdown choice in /admin/integrations. From 429b75131b5b3cadf692ae2893b2de437efc8e42 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 16:29:22 -0400 Subject: [PATCH 07/67] feat(lidarrconfig): typed singleton config wrapper Co-Authored-By: Claude Sonnet 4.6 --- internal/lidarrconfig/service.go | 88 +++++++++++++++++++++++++++ internal/lidarrconfig/service_test.go | 88 +++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 internal/lidarrconfig/service.go create mode 100644 internal/lidarrconfig/service_test.go diff --git a/internal/lidarrconfig/service.go b/internal/lidarrconfig/service.go new file mode 100644 index 00000000..fc047e1c --- /dev/null +++ b/internal/lidarrconfig/service.go @@ -0,0 +1,88 @@ +// Package lidarrconfig is a thin wrapper over the singleton lidarr_config +// row. Get returns a typed Config; Save updates it. Callers branch on +// Config.Enabled — never on raw NULL fields. +package lidarrconfig + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Config is the typed projection of lidarr_config (no NULL fields exposed +// to callers — empty strings / zero ints carry the "unset" meaning). +type Config struct { + Enabled bool + BaseURL string + APIKey string + DefaultQualityProfileID int + DefaultRootFolderPath string +} + +// Service reads and writes the singleton. +type Service struct { + pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Service { return &Service{pool: pool} } + +func (s *Service) Get(ctx context.Context) (Config, error) { + row, err := dbq.New(s.pool).GetLidarrConfig(ctx) + if err != nil { + return Config{}, fmt.Errorf("lidarrconfig: %w", err) + } + cfg := Config{Enabled: row.Enabled} + if row.BaseUrl != nil { + cfg.BaseURL = *row.BaseUrl + } + if row.ApiKey != nil { + cfg.APIKey = *row.ApiKey + } + if row.DefaultQualityProfileID != nil { + cfg.DefaultQualityProfileID = int(*row.DefaultQualityProfileID) + } + if row.DefaultRootFolderPath != nil { + cfg.DefaultRootFolderPath = *row.DefaultRootFolderPath + } + return cfg, nil +} + +// Save writes the entire row. Callers pass the full Config they want +// stored — this is not a partial update. +func (s *Service) Save(ctx context.Context, cfg Config) error { + var ( + baseURL = strPtr(cfg.BaseURL) + apiKey = strPtr(cfg.APIKey) + qpID = int32Ptr(cfg.DefaultQualityProfileID) + rootPath = strPtr(cfg.DefaultRootFolderPath) + ) + _, err := dbq.New(s.pool).UpdateLidarrConfig(ctx, dbq.UpdateLidarrConfigParams{ + Enabled: cfg.Enabled, + BaseUrl: baseURL, + ApiKey: apiKey, + DefaultQualityProfileID: qpID, + DefaultRootFolderPath: rootPath, + }) + if err != nil { + return fmt.Errorf("lidarrconfig: %w", err) + } + return nil +} + +func strPtr(s string) *string { + if s == "" { + return nil + } + return &s +} + +func int32Ptr(i int) *int32 { + if i == 0 { + return nil + } + v := int32(i) + return &v +} diff --git a/internal/lidarrconfig/service_test.go b/internal/lidarrconfig/service_test.go new file mode 100644 index 00000000..627e4be1 --- /dev/null +++ b/internal/lidarrconfig/service_test.go @@ -0,0 +1,88 @@ +package lidarrconfig + +import ( + "context" + "io" + "log/slog" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db" +) + +func newTestPool(t *testing.T) *pgxpool.Pool { + t.Helper() + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + // Reset singleton to default state before each test by replacing + // the row contents via UPDATE (TRUNCATE would violate the CHECK). + if _, err := pool.Exec(context.Background(), + "UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL, default_quality_profile_id=NULL, default_root_folder_path=NULL WHERE id=1", + ); err != nil { + t.Fatalf("reset: %v", err) + } + return pool +} + +func TestGet_DefaultRowReturnsZeroValueConfig(t *testing.T) { + pool := newTestPool(t) + cfg, err := New(pool).Get(context.Background()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if cfg.Enabled || cfg.BaseURL != "" || cfg.APIKey != "" { + t.Errorf("expected zero-value Config, got %+v", cfg) + } +} + +func TestSaveThenGet_RoundTrip(t *testing.T) { + pool := newTestPool(t) + s := New(pool) + want := Config{ + Enabled: true, + BaseURL: "http://lidarr.lan:8686", + APIKey: "secret", + DefaultQualityProfileID: 4, + DefaultRootFolderPath: "/music", + } + if err := s.Save(context.Background(), want); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := s.Get(context.Background()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got != want { + t.Errorf("round-trip mismatch:\n got = %+v\nwant = %+v", got, want) + } +} + +func TestSave_EmptyValuesPersistAsNULL(t *testing.T) { + pool := newTestPool(t) + s := New(pool) + if err := s.Save(context.Background(), Config{Enabled: false}); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := s.Get(context.Background()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.BaseURL != "" || got.APIKey != "" || got.DefaultRootFolderPath != "" { + t.Errorf("expected empty strings on round-trip; got %+v", got) + } +} From b11fc117e7eaf4e5974c8831a7d11999e85bec61 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 17:12:03 -0400 Subject: [PATCH 08/67] feat(lidarrrequests): request lifecycle service (create/list/approve/reject/cancel) Co-Authored-By: Claude Sonnet 4.6 --- internal/lidarrrequests/service.go | 260 ++++++++++++++++++++++++ internal/lidarrrequests/service_test.go | 146 +++++++++++++ 2 files changed, 406 insertions(+) create mode 100644 internal/lidarrrequests/service.go create mode 100644 internal/lidarrrequests/service_test.go diff --git a/internal/lidarrrequests/service.go b/internal/lidarrrequests/service.go new file mode 100644 index 00000000..7a9cc0be --- /dev/null +++ b/internal/lidarrrequests/service.go @@ -0,0 +1,260 @@ +// Package lidarrrequests owns the lifecycle of user requests to add +// music via Lidarr. The synchronous Service handles Create/List/ +// Approve/Reject/Cancel; the async Reconciler (separate file) closes +// approved requests once their target track lands in the library. +package lidarrrequests + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" +) + +// Public errors. Handlers map these to API error codes. +var ( + ErrInvalidKindFields = errors.New("lidarrrequests: missing required fields for kind") + ErrNotPending = errors.New("lidarrrequests: request is not pending") + ErrNotFound = errors.New("lidarrrequests: request not found") + ErrLidarrDisabled = errors.New("lidarrrequests: lidarr not configured") +) + +// CreateParams is the input for a new request from a user. +type CreateParams struct { + Kind string // "artist", "album", or "track" + LidarrArtistMBID string + LidarrAlbumMBID string // required for kind=album/track + LidarrTrackMBID string // required for kind=track + ArtistName string + AlbumTitle string // required for kind=album/track + TrackTitle string // required for kind=track +} + +// ApproveOverrides lets the admin override the snapshot defaults for one +// approval. Zero values mean "use config default." +type ApproveOverrides struct { + QualityProfileID int + RootFolderPath string +} + +// Service owns the request lifecycle. clientFn is a factory called per +// Approve so that BaseURL/APIKey changes in lidarrconfig take effect +// immediately without restarting. scanFn is called after a successful +// Approve to trigger a library scan. +type Service struct { + pool *pgxpool.Pool + lidarrCfg *lidarrconfig.Service + clientFn func() *lidarr.Client // factory, called per Approve + scanFn func() +} + +// NewService constructs a Service. Pass nil for clientFn to disable Lidarr +// (Approve returns ErrLidarrDisabled). Pass nil for scanFn to no-op. +func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client, scanFn func()) *Service { + if scanFn == nil { + scanFn = func() {} + } + if clientFn == nil { + clientFn = func() *lidarr.Client { return nil } + } + return &Service{pool: pool, lidarrCfg: cfg, clientFn: clientFn, scanFn: scanFn} +} + +// Create validates the kind→required-fields invariant and inserts a +// pending row. +func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.LidarrRequest, error) { + if err := validateKindFields(p); err != nil { + return dbq.LidarrRequest{}, err + } + q := dbq.New(s.pool) + row, err := q.CreateLidarrRequest(ctx, dbq.CreateLidarrRequestParams{ + UserID: userID, + Kind: dbq.LidarrRequestKind(p.Kind), + LidarrArtistMbid: p.LidarrArtistMBID, + LidarrAlbumMbid: strPtr(p.LidarrAlbumMBID), + LidarrTrackMbid: strPtr(p.LidarrTrackMBID), + ArtistName: p.ArtistName, + AlbumTitle: strPtr(p.AlbumTitle), + TrackTitle: strPtr(p.TrackTitle), + }) + if err != nil { + return dbq.LidarrRequest{}, fmt.Errorf("create: %w", err) + } + return row, nil +} + +func validateKindFields(p CreateParams) error { + if p.LidarrArtistMBID == "" || p.ArtistName == "" { + return fmt.Errorf("%w: artist_mbid and artist_name are always required", ErrInvalidKindFields) + } + switch p.Kind { + case "artist": + // fine + case "album": + if p.LidarrAlbumMBID == "" || p.AlbumTitle == "" { + return fmt.Errorf("%w: album kind requires album_mbid and album_title", ErrInvalidKindFields) + } + case "track": + if p.LidarrAlbumMBID == "" || p.AlbumTitle == "" { + return fmt.Errorf("%w: track kind requires album_mbid and album_title (track will be promoted)", ErrInvalidKindFields) + } + if p.LidarrTrackMBID == "" || p.TrackTitle == "" { + return fmt.Errorf("%w: track kind requires track_mbid and track_title", ErrInvalidKindFields) + } + default: + return fmt.Errorf("%w: unknown kind %q", ErrInvalidKindFields, p.Kind) + } + return nil +} + +// ListPending returns pending requests ordered by requested_at DESC. +func (s *Service) ListPending(ctx context.Context, limit int32) ([]dbq.LidarrRequest, error) { + return dbq.New(s.pool).ListLidarrRequestsByStatus(ctx, dbq.ListLidarrRequestsByStatusParams{ + Status: dbq.LidarrRequestStatusPending, Limit: limit, + }) +} + +// ListByStatus returns requests with the given status, ordered by requested_at DESC. +func (s *Service) ListByStatus(ctx context.Context, status string, limit int32) ([]dbq.LidarrRequest, error) { + return dbq.New(s.pool).ListLidarrRequestsByStatus(ctx, dbq.ListLidarrRequestsByStatusParams{ + Status: dbq.LidarrRequestStatus(status), Limit: limit, + }) +} + +// ListForUser returns all requests by a user, ordered by requested_at DESC. +func (s *Service) ListForUser(ctx context.Context, userID pgtype.UUID, limit int32) ([]dbq.LidarrRequest, error) { + return dbq.New(s.pool).ListLidarrRequestsForUser(ctx, dbq.ListLidarrRequestsForUserParams{ + UserID: userID, Limit: limit, + }) +} + +// Approve transitions a pending request to approved, snapshotting the +// chosen quality profile + root folder, then calls Lidarr to actually +// add the artist/album, then triggers a library scan. If Lidarr returns +// an error, the request stays pending — the admin sees the error and +// can retry without losing the request. +func (s *Service) Approve(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, ov ApproveOverrides) (dbq.LidarrRequest, error) { + cfg, err := s.lidarrCfg.Get(ctx) + if err != nil { + return dbq.LidarrRequest{}, fmt.Errorf("approve: load config: %w", err) + } + client := s.clientFn() + if !cfg.Enabled || client == nil { + return dbq.LidarrRequest{}, ErrLidarrDisabled + } + row, err := dbq.New(s.pool).GetLidarrRequestByID(ctx, requestID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return dbq.LidarrRequest{}, ErrNotFound + } + return dbq.LidarrRequest{}, fmt.Errorf("approve: get: %w", err) + } + if row.Status != dbq.LidarrRequestStatusPending { + return dbq.LidarrRequest{}, ErrNotPending + } + + qp := cfg.DefaultQualityProfileID + if ov.QualityProfileID != 0 { + qp = ov.QualityProfileID + } + rf := cfg.DefaultRootFolderPath + if ov.RootFolderPath != "" { + rf = ov.RootFolderPath + } + + switch row.Kind { + case dbq.LidarrRequestKindArtist: + err = client.AddArtist(ctx, lidarr.AddArtistParams{ + ForeignArtistID: row.LidarrArtistMbid, QualityProfileID: qp, RootFolderPath: rf, MonitorAll: true, + }) + case dbq.LidarrRequestKindAlbum, dbq.LidarrRequestKindTrack: + // Track-kind requests promote to album-add; the spec is explicit. + albumMBID := "" + if row.LidarrAlbumMbid != nil { + albumMBID = *row.LidarrAlbumMbid + } + err = client.AddAlbum(ctx, lidarr.AddAlbumParams{ + ForeignAlbumID: albumMBID, ForeignArtistID: row.LidarrArtistMbid, + QualityProfileID: qp, RootFolderPath: rf, + }) + } + if err != nil { + return dbq.LidarrRequest{}, fmt.Errorf("approve: lidarr add: %w", err) + } + + approved, err := dbq.New(s.pool).ApproveLidarrRequest(ctx, dbq.ApproveLidarrRequestParams{ + ID: requestID, + QualityProfileID: int32Ptr(qp), + RootFolderPath: strPtr(rf), + DecidedBy: adminID, + }) + if err != nil { + // Lidarr accepted but our DB update failed; admin should retry. + return dbq.LidarrRequest{}, fmt.Errorf("approve: persist: %w", err) + } + s.scanFn() + return approved, nil +} + +// Reject transitions a pending request to rejected, recording notes and +// who decided. Returns ErrNotPending if the request is not pending, +// ErrNotFound if no such request exists. +func (s *Service) Reject(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, notes string) (dbq.LidarrRequest, error) { + row, err := dbq.New(s.pool).RejectLidarrRequest(ctx, dbq.RejectLidarrRequestParams{ + ID: requestID, Notes: strPtr(notes), DecidedBy: adminID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + // Either not found OR not pending — check which. + cur, gerr := dbq.New(s.pool).GetLidarrRequestByID(ctx, requestID) + if gerr != nil { + return dbq.LidarrRequest{}, ErrNotFound + } + if cur.Status != dbq.LidarrRequestStatusPending { + return dbq.LidarrRequest{}, ErrNotPending + } + return dbq.LidarrRequest{}, ErrNotFound + } + return dbq.LidarrRequest{}, fmt.Errorf("reject: %w", err) + } + return row, nil +} + +// Cancel lets a user withdraw their own pending request. The SQL WHERE +// clause enforces both ownership (user_id = $2) and pending status +// (status = 'pending'). A zero-rows result always means ErrNotPending +// from the caller's perspective. +func (s *Service) Cancel(ctx context.Context, requestID pgtype.UUID, userID pgtype.UUID) (dbq.LidarrRequest, error) { + row, err := dbq.New(s.pool).CancelLidarrRequest(ctx, dbq.CancelLidarrRequestParams{ + ID: requestID, DecidedBy: userID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return dbq.LidarrRequest{}, ErrNotPending + } + return dbq.LidarrRequest{}, fmt.Errorf("cancel: %w", err) + } + return row, nil +} + +func strPtr(s string) *string { + if s == "" { + return nil + } + return &s +} + +func int32Ptr(i int) *int32 { + if i == 0 { + return nil + } + v := int32(i) + return &v +} diff --git a/internal/lidarrrequests/service_test.go b/internal/lidarrrequests/service_test.go new file mode 100644 index 00000000..18d7aed0 --- /dev/null +++ b/internal/lidarrrequests/service_test.go @@ -0,0 +1,146 @@ +package lidarrrequests + +import ( + "context" + "errors" + "io" + "log/slog" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" +) + +func newPool(t *testing.T) *pgxpool.Pool { + t.Helper() + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + dbtest.ResetDB(t, pool) + if _, err := pool.Exec(context.Background(), + "DELETE FROM lidarr_requests; UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL WHERE id=1", + ); err != nil { + t.Fatalf("reset lidarr tables: %v", err) + } + return pool +} + +func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID { + t.Helper() + u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ + Username: dbtest.TestUserPrefix + "rqtester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, + }) + if err != nil { + t.Fatalf("seed user: %v", err) + } + return u.ID +} + +func TestCreate_HappyPath_Artist(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool) + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + r, err := svc.Create(context.Background(), user, CreateParams{ + Kind: "artist", + LidarrArtistMBID: "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", + ArtistName: "Boards of Canada", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if r.Status != dbq.LidarrRequestStatusPending { + t.Errorf("status = %v", r.Status) + } +} + +func TestCreate_TrackKindRequiresAlbumMBID(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool) + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + _, err := svc.Create(context.Background(), user, CreateParams{ + Kind: "track", + LidarrArtistMBID: "a-mbid", ArtistName: "X", + LidarrTrackMBID: "t-mbid", TrackTitle: "Y", + // missing album fields + }) + if !errors.Is(err, ErrInvalidKindFields) { + t.Fatalf("err = %v, want ErrInvalidKindFields", err) + } +} + +func TestApprove_NotConfigured(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool) + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + r, _ := svc.Create(context.Background(), user, CreateParams{ + Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", + }) + _, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{}) + if !errors.Is(err, ErrLidarrDisabled) { + t.Fatalf("err = %v, want ErrLidarrDisabled", err) + } +} + +func TestReject_TransitionsToRejected(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool) + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + r, _ := svc.Create(context.Background(), user, CreateParams{ + Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", + }) + rejected, err := svc.Reject(context.Background(), r.ID, user, "low quality") + if err != nil { + t.Fatalf("Reject: %v", err) + } + if rejected.Status != dbq.LidarrRequestStatusRejected { + t.Errorf("status = %v", rejected.Status) + } +} + +func TestReject_AlreadyRejectedReturnsErrNotPending(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool) + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + r, _ := svc.Create(context.Background(), user, CreateParams{ + Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", + }) + _, _ = svc.Reject(context.Background(), r.ID, user, "first") + _, err := svc.Reject(context.Background(), r.ID, user, "second") + if !errors.Is(err, ErrNotPending) { + t.Fatalf("err = %v, want ErrNotPending", err) + } +} + +func TestCancel_OwnPendingOnly(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool) + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + r, _ := svc.Create(context.Background(), user, CreateParams{ + Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", + }) + if _, err := svc.Cancel(context.Background(), r.ID, user); err != nil { + t.Fatalf("Cancel: %v", err) + } + // Second cancel hits "not pending" because we just rejected it. + if _, err := svc.Cancel(context.Background(), r.ID, user); !errors.Is(err, ErrNotPending) { + t.Errorf("err = %v, want ErrNotPending", err) + } +} From f73a5ccef54bc43b1916f3032ceb3a0358551b02 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 17:17:32 -0400 Subject: [PATCH 09/67] feat(lidarrrequests): add Reconciler worker matching approved requests to library Co-Authored-By: Claude Sonnet 4.6 --- internal/lidarrrequests/reconciler.go | 192 ++++++++++ .../reconciler_integration_test.go | 327 ++++++++++++++++++ 2 files changed, 519 insertions(+) create mode 100644 internal/lidarrrequests/reconciler.go create mode 100644 internal/lidarrrequests/reconciler_integration_test.go diff --git a/internal/lidarrrequests/reconciler.go b/internal/lidarrrequests/reconciler.go new file mode 100644 index 00000000..9b4ba12e --- /dev/null +++ b/internal/lidarrrequests/reconciler.go @@ -0,0 +1,192 @@ +package lidarrrequests + +import ( + "context" + "log/slog" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" +) + +// Reconciler is a background worker that periodically scans approved +// lidarr_requests and transitions them to completed when their target +// track/album/artist has appeared in the local library (matched by MBID). +// Errors per-row are logged at WARN and do not abort the tick. +type Reconciler struct { + pool *pgxpool.Pool + lidarrCfg *lidarrconfig.Service + logger *slog.Logger + tick time.Duration + batch int32 +} + +// NewReconciler constructs a Reconciler with production defaults: +// 5-minute tick, batch size 50. +func NewReconciler(pool *pgxpool.Pool, cfg *lidarrconfig.Service, logger *slog.Logger) *Reconciler { + return &Reconciler{ + pool: pool, + lidarrCfg: cfg, + logger: logger, + tick: 5 * time.Minute, + batch: 50, + } +} + +// Run blocks until ctx is cancelled, ticking every r.tick. Errors from +// tickOnce are logged at WARN and never propagated. +func (r *Reconciler) Run(ctx context.Context) { + t := time.NewTicker(r.tick) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := r.tickOnce(ctx); err != nil { + r.logger.Warn("lidarrrequests: reconciler tick failed", "err", err) + } + } + } +} + +// tickOnce loads approved requests (oldest first, up to r.batch) and +// transitions any whose target MBID is now present in the local library. +// It is exported-for-tests via the lowercase name (package-internal). +func (r *Reconciler) tickOnce(ctx context.Context) error { + cfg, err := r.lidarrCfg.Get(ctx) + if err != nil { + return err + } + if !cfg.Enabled { + r.logger.Debug("lidarrrequests: reconciler skipping tick — lidarr disabled") + return nil + } + + q := dbq.New(r.pool) + rows, err := q.ListApprovedLidarrRequestsForReconcile(ctx, r.batch) + if err != nil { + return err + } + + for _, row := range rows { + if err := r.reconcileRow(ctx, q, row); err != nil { + r.logger.Warn("lidarrrequests: reconciler row failed", + "request_id", row.ID, + "kind", row.Kind, + "err", err, + ) + } + } + return nil +} + +// reconcileRow checks the library for a match and, if found, calls +// CompleteLidarrRequest. It returns an error only for unexpected DB +// failures; a no-match is not an error. +func (r *Reconciler) reconcileRow(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error { + switch row.Kind { + case dbq.LidarrRequestKindArtist: + return r.reconcileArtist(ctx, q, row) + case dbq.LidarrRequestKindAlbum: + return r.reconcileAlbum(ctx, q, row) + case dbq.LidarrRequestKindTrack: + return r.reconcileTrack(ctx, q, row) + default: + r.logger.Warn("lidarrrequests: reconciler unknown kind", "kind", row.Kind) + return nil + } +} + +func (r *Reconciler) reconcileArtist(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error { + var artistID pgtype.UUID + err := r.pool.QueryRow(ctx, + "SELECT id FROM artists WHERE mbid = $1", + row.LidarrArtistMbid, + ).Scan(&artistID) + if err != nil { + if isNoRows(err) { + return nil // not in library yet + } + return err + } + _, err = q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{ + ID: row.ID, + MatchedArtistID: artistID, + MatchedAlbumID: pgtype.UUID{}, + MatchedTrackID: pgtype.UUID{}, + }) + return err +} + +func (r *Reconciler) reconcileAlbum(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error { + if row.LidarrAlbumMbid == nil { + return nil + } + var albumID pgtype.UUID + err := r.pool.QueryRow(ctx, + "SELECT id FROM albums WHERE mbid = $1", + *row.LidarrAlbumMbid, + ).Scan(&albumID) + if err != nil { + if isNoRows(err) { + return nil + } + return err + } + _, err = q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{ + ID: row.ID, + MatchedAlbumID: albumID, + MatchedArtistID: pgtype.UUID{}, + MatchedTrackID: pgtype.UUID{}, + }) + return err +} + +func (r *Reconciler) reconcileTrack(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error { + if row.LidarrAlbumMbid == nil { + return nil + } + // Track-kind requests match via their parent album's MBID, not track.mbid. + var albumID pgtype.UUID + err := r.pool.QueryRow(ctx, + "SELECT id FROM albums WHERE mbid = $1", + *row.LidarrAlbumMbid, + ).Scan(&albumID) + if err != nil { + if isNoRows(err) { + return nil + } + return err + } + + // Load any track from that album to set matched_track_id. + var trackID pgtype.UUID + err = r.pool.QueryRow(ctx, + "SELECT id FROM tracks WHERE album_id = $1 ORDER BY id LIMIT 1", + albumID, + ).Scan(&trackID) + if err != nil { + if isNoRows(err) { + // Album is in the library but no tracks ingested yet — try again next tick. + return nil + } + return err + } + + _, err = q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{ + ID: row.ID, + MatchedAlbumID: albumID, + MatchedTrackID: trackID, + MatchedArtistID: pgtype.UUID{}, + }) + return err +} + +func isNoRows(err error) bool { + return err == pgx.ErrNoRows +} diff --git a/internal/lidarrrequests/reconciler_integration_test.go b/internal/lidarrrequests/reconciler_integration_test.go new file mode 100644 index 00000000..2c2c8c96 --- /dev/null +++ b/internal/lidarrrequests/reconciler_integration_test.go @@ -0,0 +1,327 @@ +package lidarrrequests + +import ( + "context" + "io" + "log/slog" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" +) + +// seedApprovedRequestDirect inserts a pending request and immediately +// approves it via ApproveLidarrRequest, bypassing the Lidarr API call. +func seedApprovedRequestDirect(t *testing.T, q *dbq.Queries, userID pgtype.UUID, p CreateParams) dbq.LidarrRequest { + t.Helper() + ctx := context.Background() + row, err := q.CreateLidarrRequest(ctx, dbq.CreateLidarrRequestParams{ + UserID: userID, + Kind: dbq.LidarrRequestKind(p.Kind), + LidarrArtistMbid: p.LidarrArtistMBID, + LidarrAlbumMbid: nilableStr(p.LidarrAlbumMBID), + LidarrTrackMbid: nilableStr(p.LidarrTrackMBID), + ArtistName: p.ArtistName, + AlbumTitle: nilableStr(p.AlbumTitle), + TrackTitle: nilableStr(p.TrackTitle), + }) + if err != nil { + t.Fatalf("seedApprovedRequestDirect create: %v", err) + } + approved, err := q.ApproveLidarrRequest(ctx, dbq.ApproveLidarrRequestParams{ + ID: row.ID, + QualityProfileID: nil, + RootFolderPath: nil, + DecidedBy: userID, + }) + if err != nil { + t.Fatalf("seedApprovedRequestDirect approve: %v", err) + } + return approved +} + +func nilableStr(s string) *string { + if s == "" { + return nil + } + return &s +} + +func seedArtist(t *testing.T, q *dbq.Queries, name, mbid string) dbq.Artist { + t.Helper() + a, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: name, SortName: name, Mbid: &mbid, + }) + if err != nil { + t.Fatalf("seedArtist: %v", err) + } + return a +} + +func seedAlbum(t *testing.T, q *dbq.Queries, artistID pgtype.UUID, title, mbid string) dbq.Album { + t.Helper() + a, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: title, SortTitle: title, + ArtistID: artistID, + Mbid: &mbid, + }) + if err != nil { + t.Fatalf("seedAlbum: %v", err) + } + return a +} + +func seedTrack(t *testing.T, q *dbq.Queries, albumID, artistID pgtype.UUID, title, filePath string) dbq.Track { + t.Helper() + tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: title, AlbumID: albumID, ArtistID: artistID, + DurationMs: 180000, FilePath: filePath, + FileSize: 1024, FileFormat: "flac", + }) + if err != nil { + t.Fatalf("seedTrack: %v", err) + } + return tr +} + +func enableLidarrForPool(t *testing.T, pool *pgxpool.Pool) { + t.Helper() + if err := lidarrconfig.New(pool).Save(context.Background(), lidarrconfig.Config{ + Enabled: true, BaseURL: "http://lidarr:8686", APIKey: "test", + }); err != nil { + t.Fatalf("enableLidarrForPool: %v", err) + } +} + +func newTestLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +// TestReconciler_MatchesArtistByMBID: an approved artist-kind request whose +// MBID is in the artists table gets completed with matched_artist_id set. +func TestReconciler_MatchesArtistByMBID(t *testing.T) { + pool := newPool(t) + q := dbq.New(pool) + ctx := context.Background() + + enableLidarrForPool(t, pool) + user := seedUser(t, pool) + artistMBID := "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" + artist := seedArtist(t, q, "Boards of Canada", artistMBID) + + req := seedApprovedRequestDirect(t, q, user, CreateParams{ + Kind: "artist", LidarrArtistMBID: artistMBID, ArtistName: "Boards of Canada", + }) + + rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger()) + if err := rec.tickOnce(ctx); err != nil { + t.Fatalf("tickOnce: %v", err) + } + + got, err := q.GetLidarrRequestByID(ctx, req.ID) + if err != nil { + t.Fatalf("GetLidarrRequestByID: %v", err) + } + if got.Status != dbq.LidarrRequestStatusCompleted { + t.Errorf("status = %v, want completed", got.Status) + } + if got.MatchedArtistID != artist.ID { + t.Errorf("matched_artist_id = %v, want %v", got.MatchedArtistID, artist.ID) + } +} + +// TestReconciler_MatchesAlbumByMBID: an approved album-kind request whose +// album MBID is in the albums table gets completed with matched_album_id set. +func TestReconciler_MatchesAlbumByMBID(t *testing.T) { + pool := newPool(t) + q := dbq.New(pool) + ctx := context.Background() + + enableLidarrForPool(t, pool) + user := seedUser(t, pool) + artistMBID := "a-artist-mbid-album-test" + albumMBID := "b-album-mbid-album-test" + + artist := seedArtist(t, q, "Test Artist", artistMBID) + album := seedAlbum(t, q, artist.ID, "Test Album", albumMBID) + + req := seedApprovedRequestDirect(t, q, user, CreateParams{ + Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "Test Artist", + LidarrAlbumMBID: albumMBID, AlbumTitle: "Test Album", + }) + + rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger()) + if err := rec.tickOnce(ctx); err != nil { + t.Fatalf("tickOnce: %v", err) + } + + got, err := q.GetLidarrRequestByID(ctx, req.ID) + if err != nil { + t.Fatalf("GetLidarrRequestByID: %v", err) + } + if got.Status != dbq.LidarrRequestStatusCompleted { + t.Errorf("status = %v, want completed", got.Status) + } + if got.MatchedAlbumID != album.ID { + t.Errorf("matched_album_id = %v, want %v", got.MatchedAlbumID, album.ID) + } +} + +// TestReconciler_MatchesTrackViaAlbumMBID: an approved track-kind request +// matches when the parent album appears in the library. matched_track_id is +// also set to a track from that album. The track MBID on the request does +// not need to match any tracks.mbid — matching is via the parent album. +func TestReconciler_MatchesTrackViaAlbumMBID(t *testing.T) { + pool := newPool(t) + q := dbq.New(pool) + ctx := context.Background() + + enableLidarrForPool(t, pool) + user := seedUser(t, pool) + artistMBID := "a-artist-mbid-track-test" + albumMBID := "b-album-mbid-track-test" + trackMBID := "c-track-mbid-does-not-exist-in-tracks-table" + + artist := seedArtist(t, q, "Track Test Artist", artistMBID) + album := seedAlbum(t, q, artist.ID, "Track Test Album", albumMBID) + track := seedTrack(t, q, album.ID, artist.ID, "Track One", "/music/track-test/01.flac") + + req := seedApprovedRequestDirect(t, q, user, CreateParams{ + Kind: "track", LidarrArtistMBID: artistMBID, ArtistName: "Track Test Artist", + LidarrAlbumMBID: albumMBID, AlbumTitle: "Track Test Album", + LidarrTrackMBID: trackMBID, TrackTitle: "Track One", + }) + + rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger()) + if err := rec.tickOnce(ctx); err != nil { + t.Fatalf("tickOnce: %v", err) + } + + got, err := q.GetLidarrRequestByID(ctx, req.ID) + if err != nil { + t.Fatalf("GetLidarrRequestByID: %v", err) + } + if got.Status != dbq.LidarrRequestStatusCompleted { + t.Errorf("status = %v, want completed", got.Status) + } + if got.MatchedAlbumID != album.ID { + t.Errorf("matched_album_id = %v, want %v", got.MatchedAlbumID, album.ID) + } + if got.MatchedTrackID != track.ID { + t.Errorf("matched_track_id = %v, want %v", got.MatchedTrackID, track.ID) + } +} + +// TestReconciler_NoMatchLeavesPending: an approved request whose MBID is not +// in any library table is left unchanged after tickOnce. +func TestReconciler_NoMatchLeavesPending(t *testing.T) { + pool := newPool(t) + q := dbq.New(pool) + ctx := context.Background() + + enableLidarrForPool(t, pool) + user := seedUser(t, pool) + + req := seedApprovedRequestDirect(t, q, user, CreateParams{ + Kind: "artist", LidarrArtistMBID: "nonexistent-mbid-xyz", ArtistName: "Ghost Artist", + }) + + rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger()) + if err := rec.tickOnce(ctx); err != nil { + t.Fatalf("tickOnce: %v", err) + } + + got, err := q.GetLidarrRequestByID(ctx, req.ID) + if err != nil { + t.Fatalf("GetLidarrRequestByID: %v", err) + } + if got.Status != dbq.LidarrRequestStatusApproved { + t.Errorf("status = %v, want approved (unchanged)", got.Status) + } + if got.MatchedArtistID.Valid { + t.Errorf("matched_artist_id should be NULL, got %v", got.MatchedArtistID) + } +} + +// TestReconciler_AlreadyCompletedRowNotReprocessed: a completed row is not +// touched by the reconciler because ListApprovedLidarrRequestsForReconcile +// filters on status='approved'. +func TestReconciler_AlreadyCompletedRowNotReprocessed(t *testing.T) { + pool := newPool(t) + q := dbq.New(pool) + ctx := context.Background() + + enableLidarrForPool(t, pool) + user := seedUser(t, pool) + artistMBID := "already-done-artist-mbid" + artist := seedArtist(t, q, "Already Done Artist", artistMBID) + + // Seed approved, then complete it before running tickOnce. + req := seedApprovedRequestDirect(t, q, user, CreateParams{ + Kind: "artist", LidarrArtistMBID: artistMBID, ArtistName: "Already Done Artist", + }) + completed, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{ + ID: req.ID, + MatchedArtistID: artist.ID, + MatchedAlbumID: pgtype.UUID{}, + MatchedTrackID: pgtype.UUID{}, + }) + if err != nil { + t.Fatalf("CompleteLidarrRequest setup: %v", err) + } + + rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger()) + if err := rec.tickOnce(ctx); err != nil { + t.Fatalf("tickOnce: %v", err) + } + + got, err := q.GetLidarrRequestByID(ctx, req.ID) + if err != nil { + t.Fatalf("GetLidarrRequestByID: %v", err) + } + if got.Status != dbq.LidarrRequestStatusCompleted { + t.Errorf("status = %v, want completed (unchanged)", got.Status) + } + if !got.UpdatedAt.Time.Equal(completed.UpdatedAt.Time) { + t.Errorf("updated_at moved: was %v, now %v — reconciler should not have touched completed row", + completed.UpdatedAt.Time, got.UpdatedAt.Time) + } +} + +// TestReconciler_DisabledIsNoOp: when lidarr_config.enabled=false the +// reconciler skips the entire tick, leaving approved requests untouched. +func TestReconciler_DisabledIsNoOp(t *testing.T) { + pool := newPool(t) + q := dbq.New(pool) + ctx := context.Background() + + // lidarr_config is left disabled (newPool sets it to false). + user := seedUser(t, pool) + artistMBID := "disabled-noop-mbid" + // Seed the matching artist so it WOULD be found if lidarr were enabled. + seedArtist(t, q, "No-Op Artist", artistMBID) + + req := seedApprovedRequestDirect(t, q, user, CreateParams{ + Kind: "artist", LidarrArtistMBID: artistMBID, ArtistName: "No-Op Artist", + }) + + rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger()) + if err := rec.tickOnce(ctx); err != nil { + t.Fatalf("tickOnce: %v", err) + } + + got, err := q.GetLidarrRequestByID(ctx, req.ID) + if err != nil { + t.Fatalf("GetLidarrRequestByID: %v", err) + } + if got.Status != dbq.LidarrRequestStatusApproved { + t.Errorf("status = %v, want approved — reconciler should no-op when disabled", got.Status) + } +} From 44928263544e0a9e771bf62dd7a0575cbb8c20d2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 17:27:47 -0400 Subject: [PATCH 10/67] feat(auth): add RequireAdmin middleware for /api/admin/* routes Replaces the old X-API-Token-based RequireAdmin in middleware.go with a context-aware RequireAdmin() that runs after RequireUser, checks user.IsAdmin, and returns 403 {"error":"not_authorized"} for non-admins or 500 {"error":"internal_error"} if RequireUser was bypassed. Updates server.go to mount RequireUser then RequireAdmin on the /api/admin group. Co-Authored-By: Claude Sonnet 4.6 --- internal/auth/admin.go | 45 ++++++++++++++++++ internal/auth/admin_test.go | 94 +++++++++++++++++++++++++++++++++++++ internal/auth/middleware.go | 41 ++-------------- internal/server/server.go | 3 +- 4 files changed, 144 insertions(+), 39 deletions(-) create mode 100644 internal/auth/admin.go create mode 100644 internal/auth/admin_test.go diff --git a/internal/auth/admin.go b/internal/auth/admin.go new file mode 100644 index 00000000..7cd9b75c --- /dev/null +++ b/internal/auth/admin.go @@ -0,0 +1,45 @@ +package auth + +import ( + "encoding/json" + "net/http" +) + +// RequireAdmin is a middleware that MUST run after RequireUser. It reads the +// authenticated user from request context and rejects non-admin callers with +// 403. If no user is in context (RequireUser was bypassed), it returns 500 — +// that is a programmer error in the routing setup, not a client error. +// +// Mount this on /api/admin/* after auth.RequireUser: +// +// r.Route("/api/admin", func(admin chi.Router) { +// admin.Use(auth.RequireUser(pool)) +// admin.Use(auth.RequireAdmin()) +// ... +// }) +func RequireAdmin() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, ok := UserFromContext(r.Context()) + if !ok { + // Programmer error: RequireUser was not mounted before RequireAdmin. + writeAdminErr(w, http.StatusInternalServerError, "internal_error") + return + } + if !user.IsAdmin { + writeAdminErr(w, http.StatusForbidden, "not_authorized") + return + } + next.ServeHTTP(w, r) + }) + } +} + +// writeAdminErr writes a flat JSON error envelope {"error":""} and sets +// Content-Type. Uses a flat envelope (not the nested api.errorBody shape) +// because the spec for /api/admin/* errors defines {"error":""} directly. +func writeAdminErr(w http.ResponseWriter, status int, code string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": code}) +} diff --git a/internal/auth/admin_test.go b/internal/auth/admin_test.go new file mode 100644 index 00000000..393738b8 --- /dev/null +++ b/internal/auth/admin_test.go @@ -0,0 +1,94 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// injectUser returns a copy of r with the given user stored under userCtxKey. +// This mirrors what RequireUser does at runtime. +func injectUser(r *http.Request, u dbq.User) *http.Request { + ctx := context.WithValue(r.Context(), userCtxKey, u) + return r.WithContext(ctx) +} + +func TestRequireAdmin_AdminPasses(t *testing.T) { + called := false + stub := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + + h := RequireAdmin()(stub) + + req := injectUser( + httptest.NewRequest(http.MethodGet, "/api/admin/test", nil), + dbq.User{IsAdmin: true}, + ) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + if !called { + t.Error("stub handler was not called for admin user") + } +} + +func TestRequireAdmin_NonAdminReturns403(t *testing.T) { + stub := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("stub handler must not be called for non-admin user") + }) + + h := RequireAdmin()(stub) + + req := injectUser( + httptest.NewRequest(http.MethodGet, "/api/admin/test", nil), + dbq.User{IsAdmin: false}, + ) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, "not_authorized") { + t.Errorf("body %q does not contain %q", body, "not_authorized") + } + ct := w.Header().Get("Content-Type") + if !strings.HasPrefix(ct, "application/json") { + t.Errorf("Content-Type = %q, want application/json", ct) + } +} + +func TestRequireAdmin_NoUserContextReturns500(t *testing.T) { + stub := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("stub handler must not be called when no user is in context") + }) + + h := RequireAdmin()(stub) + + // Do NOT inject a user — simulate RequireUser being bypassed. + req := httptest.NewRequest(http.MethodGet, "/api/admin/test", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want 500", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, "internal_error") { + t.Errorf("body %q does not contain %q", body, "internal_error") + } + ct := w.Header().Get("Content-Type") + if !strings.HasPrefix(ct, "application/json") { + t.Errorf("Content-Type = %q, want application/json", ct) + } +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index 0f847480..1438c418 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -2,11 +2,6 @@ package auth import ( "context" - "errors" - "net/http" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) @@ -15,39 +10,9 @@ type ctxKey int const userCtxKey ctxKey = 1 -// RequireAdmin gates a handler on X-API-Token matching an admin user. This is -// the Minstrel-native token path (`/api/*`); Subsonic-compatible auth under -// `/rest/*` lands with the Subsonic server. -func RequireAdmin(pool *pgxpool.Pool) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token := r.Header.Get("X-API-Token") - if token == "" { - http.Error(w, "missing X-API-Token", http.StatusUnauthorized) - return - } - q := dbq.New(pool) - user, err := q.GetUserByAPIToken(r.Context(), token) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - http.Error(w, "invalid token", http.StatusUnauthorized) - return - } - http.Error(w, "auth lookup failed", http.StatusInternalServerError) - return - } - if !user.IsAdmin { - http.Error(w, "admin required", http.StatusForbidden) - return - } - ctx := context.WithValue(r.Context(), userCtxKey, user) - next.ServeHTTP(w, r.WithContext(ctx)) - }) - } -} - -// UserFromContext returns the authenticated user when the request passed -// through RequireAdmin (or a future RequireUser middleware). +// UserFromContext returns the authenticated user placed in context by +// RequireUser. Returns false when RequireUser has not run (e.g. in tests that +// bypass the middleware, or programmer-error routing). func UserFromContext(ctx context.Context) (dbq.User, bool) { u, ok := ctx.Value(userCtxKey).(dbq.User) return u, ok diff --git a/internal/server/server.go b/internal/server/server.go index 3e449693..9358e8d2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -57,7 +57,8 @@ func (s *Server) Router() http.Handler { ) api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg) r.Route("/api/admin", func(admin chi.Router) { - admin.Use(auth.RequireAdmin(s.Pool)) + admin.Use(auth.RequireUser(s.Pool)) + admin.Use(auth.RequireAdmin()) if s.Scanner != nil { admin.Post("/scan", s.handleAdminScan) } From 905e27a9884257b4fa682ae8c03bddff1ec8f0aa Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 17:35:00 -0400 Subject: [PATCH 11/67] feat(api): add /api/lidarr/search proxy with library/request enrichment Implements GET /api/lidarr/search?q=&kind=artist|album|track. Validates kind and q, loads lidarr_config per-request, calls the matching Lidarr Lookup* method, enriches each result with in_library (EXISTS by MBID against artists/albums/tracks) and requested (HasNonTerminalRequestForMBID), and returns a normalized JSON array. Adds lidarrCfg field to handlers struct and threads lidarrconfig.Service through Mount and server.Router. Co-Authored-By: Claude Sonnet 4.6 --- internal/api/api.go | 18 +- internal/api/auth_test.go | 3 +- internal/api/library_test.go | 2 +- internal/api/lidarr.go | 138 +++++++++++++++ internal/api/lidarr_test.go | 327 +++++++++++++++++++++++++++++++++++ internal/server/server.go | 3 +- 6 files changed, 481 insertions(+), 10 deletions(-) create mode 100644 internal/api/lidarr.go create mode 100644 internal/api/lidarr_test.go diff --git a/internal/api/api.go b/internal/api/api.go index 31463b2a..2f5281bf 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -13,15 +13,16 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/config" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" ) // Mount attaches /api/* handlers to r. Public endpoints (login) are outside // RequireUser; everything else is gated by the middleware. The events writer // is shared with the Subsonic mount so /rest/scrobble feeds the same store. -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig) { +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service) { rng := rand.New(rand.NewSource(rand.Int63())) - h := &handlers{pool: pool, logger: logger, events: events, recCfg: recCfg, rng: rng.Float64} + h := &handlers{pool: pool, logger: logger, events: events, recCfg: recCfg, rng: rng.Float64, lidarrCfg: lidarrCfg} r.Route("/api", func(api chi.Router) { api.Post("/auth/login", h.handleLogin) @@ -51,14 +52,17 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Get("/likes/albums", h.handleListLikedAlbums) authed.Get("/likes/artists", h.handleListLikedArtists) authed.Get("/likes/ids", h.handleGetLikedIDs) + + authed.Get("/lidarr/search", h.handleLidarrSearch) }) }) } type handlers struct { - pool *pgxpool.Pool - logger *slog.Logger - events *playevents.Writer - recCfg config.RecommendationConfig - rng func() float64 + pool *pgxpool.Pool + logger *slog.Logger + events *playevents.Writer + recCfg config.RecommendationConfig + rng func() float64 + lidarrCfg *lidarrconfig.Service } diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index b13b455e..87e24cc8 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -22,6 +22,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" ) @@ -54,7 +55,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { ContextWeight: 2.0, SimilarityWeight: 2.0, RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, } - h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }} + h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrconfig.New(pool)} return h, pool } diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 9c8e7714..6a76a0ae 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -441,7 +441,7 @@ func TestRoutesRegisteredInMount(t *testing.T) { r := chi.NewRouter() w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), 30*time.Minute, 0.5, 30000) - Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}) + Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg) paths := []string{ "/api/artists", diff --git a/internal/api/lidarr.go b/internal/api/lidarr.go new file mode 100644 index 00000000..b7b3218b --- /dev/null +++ b/internal/api/lidarr.go @@ -0,0 +1,138 @@ +package api + +import ( + "context" + "errors" + "net/http" + "strings" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" +) + +// lidarrSearchResult is the JSON shape returned by GET /api/lidarr/search. +// Field names follow the lowercase snake_case convention used by all /api/* +// responses. artist_mbid and album_mbid are the parent identifiers surfaced +// by LookupAlbum / LookupTrack; they are empty strings on artist results. +type lidarrSearchResult struct { + MBID string `json:"mbid"` + Name string `json:"name"` + SecondaryText string `json:"secondary_text"` + ImageURL string `json:"image_url"` + ArtistMBID string `json:"artist_mbid"` + AlbumMBID string `json:"album_mbid"` + InLibrary bool `json:"in_library"` + Requested bool `json:"requested"` +} + +// handleLidarrSearch implements GET /api/lidarr/search?q=&kind=artist|album|track. +// +// Steps: +// 1. Validate kind and q params. +// 2. Load lidarr config; 503 when disabled. +// 3. Call the matching Lidarr lookup. +// 4. For each result, compute in_library (EXISTS by MBID) and requested +// (HasNonTerminalRequestForMBID). +// 5. Return normalized JSON array. +func (h *handlers) handleLidarrSearch(w http.ResponseWriter, r *http.Request) { + kind := strings.TrimSpace(r.URL.Query().Get("kind")) + switch kind { + case "artist", "album", "track": + // valid + default: + writeErr(w, http.StatusBadRequest, "bad_kind", "kind must be artist, album, or track") + return + } + + q := strings.TrimSpace(r.URL.Query().Get("q")) + if q == "" { + writeErr(w, http.StatusBadRequest, "missing_query", "q is required") + return + } + + cfg, err := h.lidarrCfg.Get(r.Context()) + if err != nil { + h.logger.Error("api: lidarr search: load config", "err", err) + writeErr(w, http.StatusInternalServerError, "lidarr_lookup_failed", "failed to load lidarr config") + return + } + if !cfg.Enabled { + writeErr(w, http.StatusServiceUnavailable, "lidarr_disabled", "Lidarr integration is not enabled") + return + } + + client := lidarr.NewClient(cfg.BaseURL, cfg.APIKey) + + var results []lidarr.LookupResult + switch kind { + case "artist": + results, err = client.LookupArtist(r.Context(), q) + case "album": + results, err = client.LookupAlbum(r.Context(), q) + case "track": + results, err = client.LookupTrack(r.Context(), q) + } + if err != nil { + switch { + case errors.Is(err, lidarr.ErrUnreachable): + writeErr(w, http.StatusServiceUnavailable, "lidarr_unreachable", "Lidarr is unreachable") + case errors.Is(err, lidarr.ErrAuthFailed): + writeErr(w, http.StatusServiceUnavailable, "lidarr_auth_failed", "Lidarr authentication failed") + default: + h.logger.Error("api: lidarr search: lookup failed", "kind", kind, "err", err) + writeErr(w, http.StatusInternalServerError, "lidarr_lookup_failed", "Lidarr lookup failed") + } + return + } + + dbQ := dbq.New(h.pool) + out := make([]lidarrSearchResult, 0, len(results)) + for _, res := range results { + inLib, libErr := h.lidarrInLibrary(r.Context(), kind, res.MBID) + if libErr != nil { + h.logger.Error("api: lidarr search: in_library check failed", "err", libErr) + writeErr(w, http.StatusInternalServerError, "lidarr_lookup_failed", "library check failed") + return + } + requested, reqErr := dbQ.HasNonTerminalRequestForMBID(r.Context(), res.MBID) + if reqErr != nil { + h.logger.Error("api: lidarr search: requested check failed", "err", reqErr) + writeErr(w, http.StatusInternalServerError, "lidarr_lookup_failed", "request check failed") + return + } + out = append(out, lidarrSearchResult{ + MBID: res.MBID, + Name: res.Name, + SecondaryText: res.Secondary, + ImageURL: res.ImageURL, + ArtistMBID: res.ArtistMBID, + AlbumMBID: res.AlbumMBID, + InLibrary: inLib, + Requested: requested, + }) + } + writeJSON(w, http.StatusOK, out) +} + +// lidarrInLibrary checks whether the given MBID exists in the corresponding +// local library table based on kind. Runs a direct EXISTS query since +// these per-kind MBID lookups are not in the sqlc-generated query set. +func (h *handlers) lidarrInLibrary(ctx context.Context, kind, mbid string) (bool, error) { + var query string + switch kind { + case "artist": + query = `SELECT EXISTS(SELECT 1 FROM artists WHERE mbid = $1)` + case "album": + query = `SELECT EXISTS(SELECT 1 FROM albums WHERE mbid = $1)` + case "track": + query = `SELECT EXISTS(SELECT 1 FROM tracks WHERE mbid = $1)` + default: + return false, nil + } + var exists bool + if err := h.pool.QueryRow(ctx, query, mbid).Scan(&exists); err != nil { + return false, err + } + return exists, nil +} + diff --git a/internal/api/lidarr_test.go b/internal/api/lidarr_test.go new file mode 100644 index 00000000..591cf578 --- /dev/null +++ b/internal/api/lidarr_test.go @@ -0,0 +1,327 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" +) + +// resetLidarrState clears lidarr_requests rows and resets lidarr_config +// to the factory-default state (enabled=false, nulls) between tests. +func resetLidarrState(t *testing.T, h *handlers) { + t.Helper() + ctx := context.Background() + if _, err := h.pool.Exec(ctx, + "DELETE FROM lidarr_requests; UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL WHERE id=1", + ); err != nil { + t.Fatalf("resetLidarrState: %v", err) + } +} + +// saveLidarrConfig writes a test lidarr_config row pointing at the given stub URL. +func saveLidarrConfig(t *testing.T, h *handlers, baseURL string, enabled bool) { + t.Helper() + svc := lidarrconfig.New(h.pool) + err := svc.Save(context.Background(), lidarrconfig.Config{ + Enabled: enabled, + BaseURL: baseURL, + APIKey: "test-key", + DefaultQualityProfileID: 1, + DefaultRootFolderPath: "/music", + }) + if err != nil { + t.Fatalf("saveLidarrConfig: %v", err) + } +} + +// lidarrArtistStubBody returns a minimal Lidarr artist lookup JSON payload. +func lidarrArtistStubBody(mbid, name string) string { + return fmt.Sprintf(`[{"foreignArtistId":%q,"artistName":%q,"genres":["Rock"],"albumCount":5,"images":[]}]`, mbid, name) +} + +// newLidarrStub creates an httptest.Server that always returns the given body +// with 200 OK for any /api/v1/* path. +func newLidarrStub(t *testing.T, body string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv +} + +// seedArtistWithMBID inserts an artist row with a specific MBID for in_library testing. +func seedArtistWithMBID(t *testing.T, h *handlers, name, mbid string) dbq.Artist { + t.Helper() + a, err := dbq.New(h.pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: name, + SortName: name, + Mbid: &mbid, + }) + if err != nil { + t.Fatalf("seedArtistWithMBID: %v", err) + } + return a +} + +// seedRequestForMBID inserts a pending lidarr_requests row for the given MBID. +func seedRequestForMBID(t *testing.T, h *handlers, userID pgtype.UUID, kind dbq.LidarrRequestKind, artistMBID, mbid string) { + t.Helper() + var albumMBID, trackMBID *string + switch kind { + case dbq.LidarrRequestKindAlbum: + albumMBID = &mbid + case dbq.LidarrRequestKindTrack: + trackMBID = &mbid + } + _, err := dbq.New(h.pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{ + UserID: userID, + Kind: kind, + LidarrArtistMbid: artistMBID, + LidarrAlbumMbid: albumMBID, + LidarrTrackMbid: trackMBID, + ArtistName: "Test Artist", + AlbumTitle: nil, + TrackTitle: nil, + }) + if err != nil { + t.Fatalf("seedRequestForMBID: %v", err) + } +} + +// doLidarrSearch fires a GET /api/lidarr/search request directly at the handler. +// It simulates the RequireUser middleware by injecting a dummy user context. +func doLidarrSearch(t *testing.T, h *handlers, q, kind string) *httptest.ResponseRecorder { + t.Helper() + url := fmt.Sprintf("/api/lidarr/search?q=%s&kind=%s", q, kind) + req := httptest.NewRequest(http.MethodGet, url, nil) + // Inject a minimal user context the same way other handler tests do it. + // The lidarr search handler doesn't use the user from context, so any + // non-nil user value keeps the handler from panicking. + w := httptest.NewRecorder() + h.handleLidarrSearch(w, req) + return w +} + +// TestHandleLidarrSearch_ValidationErrors covers bad kind and missing query +// in a table-driven test. +func TestHandleLidarrSearch_ValidationErrors(t *testing.T) { + h, _ := testHandlers(t) + + cases := []struct { + name string + q string + kind string + wantStatus int + wantCode string + }{ + { + name: "bad kind playlist", + q: "Beatles", + kind: "playlist", + wantStatus: http.StatusBadRequest, + wantCode: "bad_kind", + }, + { + name: "missing query", + q: "", + kind: "artist", + wantStatus: http.StatusBadRequest, + wantCode: "missing_query", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + url := fmt.Sprintf("/api/lidarr/search?q=%s&kind=%s", tc.q, tc.kind) + req := httptest.NewRequest(http.MethodGet, url, nil) + w := httptest.NewRecorder() + h.handleLidarrSearch(w, req) + + if w.Code != tc.wantStatus { + t.Fatalf("status = %d, want %d; body = %s", w.Code, tc.wantStatus, w.Body.String()) + } + var resp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode body: %v; body=%s", err, w.Body.String()) + } + if resp.Error.Code != tc.wantCode { + t.Errorf("error.code = %q, want %q", resp.Error.Code, tc.wantCode) + } + }) + } +} + +// TestHandleLidarrSearch_DisabledReturns503 verifies that a disabled Lidarr +// config causes the handler to return 503 lidarr_disabled. +func TestHandleLidarrSearch_DisabledReturns503(t *testing.T) { + h, _ := testHandlers(t) + resetLidarrState(t, h) + + // Config is already disabled after resetLidarrState; ensure it's explicit. + saveLidarrConfig(t, h, "http://127.0.0.1:1", false) + + req := httptest.NewRequest(http.MethodGet, "/api/lidarr/search?q=Beatles&kind=artist", nil) + w := httptest.NewRecorder() + h.handleLidarrSearch(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503; body = %s", w.Code, w.Body.String()) + } + var resp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode body: %v; body=%s", err, w.Body.String()) + } + if resp.Error.Code != "lidarr_disabled" { + t.Errorf("error.code = %q, want lidarr_disabled", resp.Error.Code) + } +} + +// TestHandleLidarrSearch_HappyPath_RequestableResult checks a result that is +// neither in the library nor already requested. +func TestHandleLidarrSearch_HappyPath_RequestableResult(t *testing.T) { + h, _ := testHandlers(t) + resetLidarrState(t, h) + + const mbid = "artist-mbid-abc123" + stub := newLidarrStub(t, lidarrArtistStubBody(mbid, "The Beatles")) + saveLidarrConfig(t, h, stub.URL, true) + + w := doLidarrSearch(t, h, "Beatles", "artist") + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var results []lidarrSearchResult + if err := json.Unmarshal(w.Body.Bytes(), &results); err != nil { + t.Fatalf("decode: %v; body=%s", err, w.Body.String()) + } + if len(results) != 1 { + t.Fatalf("len(results) = %d, want 1", len(results)) + } + r := results[0] + if r.MBID != mbid { + t.Errorf("mbid = %q, want %q", r.MBID, mbid) + } + if r.InLibrary { + t.Errorf("in_library = true, want false") + } + if r.Requested { + t.Errorf("requested = true, want false") + } +} + +// TestHandleLidarrSearch_HappyPath_InLibraryResult checks that a result whose +// MBID matches an artist in the local DB has in_library=true. +func TestHandleLidarrSearch_HappyPath_InLibraryResult(t *testing.T) { + h, _ := testHandlers(t) + resetLidarrState(t, h) + + const mbid = "artist-mbid-inlib" + stub := newLidarrStub(t, lidarrArtistStubBody(mbid, "Library Artist")) + saveLidarrConfig(t, h, stub.URL, true) + + // Seed the artist with the matching MBID so in_library becomes true. + seedArtistWithMBID(t, h, dbtest.TestUserPrefix+"LibraryArtist", mbid) + + w := doLidarrSearch(t, h, "Library+Artist", "artist") + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var results []lidarrSearchResult + if err := json.Unmarshal(w.Body.Bytes(), &results); err != nil { + t.Fatalf("decode: %v; body=%s", err, w.Body.String()) + } + if len(results) != 1 { + t.Fatalf("len(results) = %d, want 1", len(results)) + } + if !results[0].InLibrary { + t.Errorf("in_library = false, want true") + } +} + +// TestHandleLidarrSearch_HappyPath_AlreadyRequested verifies that a result +// whose MBID matches a pending lidarr_requests row has requested=true. +func TestHandleLidarrSearch_HappyPath_AlreadyRequested(t *testing.T) { + h, _ := testHandlers(t) + resetLidarrState(t, h) + + const mbid = "artist-mbid-requested" + stub := newLidarrStub(t, lidarrArtistStubBody(mbid, "Requested Artist")) + saveLidarrConfig(t, h, stub.URL, true) + + // Seed a user and a pending request for the artist MBID. + user := seedUser(t, h.pool, "requester", "pw", false) + seedRequestForMBID(t, h, user.ID, dbq.LidarrRequestKindArtist, mbid, mbid) + + w := doLidarrSearch(t, h, "Requested+Artist", "artist") + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var results []lidarrSearchResult + if err := json.Unmarshal(w.Body.Bytes(), &results); err != nil { + t.Fatalf("decode: %v; body=%s", err, w.Body.String()) + } + if len(results) != 1 { + t.Fatalf("len(results) = %d, want 1", len(results)) + } + if !results[0].Requested { + t.Errorf("requested = false, want true") + } +} + +// TestHandleLidarrSearch_LidarrUnreachableReturns503 verifies that a +// connection-refused Lidarr base URL returns 503 lidarr_unreachable. +func TestHandleLidarrSearch_LidarrUnreachableReturns503(t *testing.T) { + h, _ := testHandlers(t) + resetLidarrState(t, h) + + // Port 1 is reserved and will refuse connections immediately. + saveLidarrConfig(t, h, "http://127.0.0.1:1", true) + + req := httptest.NewRequest(http.MethodGet, "/api/lidarr/search?q=Beatles&kind=artist", nil) + // Use a short-deadline context so the test doesn't hang waiting for TCP. + ctx, cancel := context.WithTimeout(req.Context(), 3*time.Second) + defer cancel() + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.handleLidarrSearch(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503; body = %s", w.Code, w.Body.String()) + } + var resp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body=%s", err, w.Body.String()) + } + if resp.Error.Code != "lidarr_unreachable" { + t.Errorf("error.code = %q, want lidarr_unreachable", resp.Error.Code) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 9358e8d2..bbe77125 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -17,6 +17,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/library" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic" "git.fabledsword.com/bvandeusen/minstrel/web" @@ -55,7 +56,7 @@ func (s *Server) Router() http.Handler { s.EventsCfg.SkipMaxCompletionRatio, s.EventsCfg.SkipMaxDurationPlayedMs, ) - api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg) + api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrconfig.New(s.Pool)) r.Route("/api/admin", func(admin chi.Router) { admin.Use(auth.RequireUser(s.Pool)) admin.Use(auth.RequireAdmin()) From 9192eb9f0a211a91c4ebf778b54478df08ac9087 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 17:35:45 -0400 Subject: [PATCH 12/67] fix(api): gofmt + revive cleanup on lidarr search handler --- internal/api/lidarr.go | 1 - internal/api/lidarr_test.go | 14 +++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/internal/api/lidarr.go b/internal/api/lidarr.go index b7b3218b..b502120b 100644 --- a/internal/api/lidarr.go +++ b/internal/api/lidarr.go @@ -135,4 +135,3 @@ func (h *handlers) lidarrInLibrary(ctx context.Context, kind, mbid string) (bool } return exists, nil } - diff --git a/internal/api/lidarr_test.go b/internal/api/lidarr_test.go index 591cf578..d800292e 100644 --- a/internal/api/lidarr_test.go +++ b/internal/api/lidarr_test.go @@ -53,7 +53,7 @@ func lidarrArtistStubBody(mbid, name string) string { // with 200 OK for any /api/v1/* path. func newLidarrStub(t *testing.T, body string) *httptest.Server { t.Helper() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(body)) @@ -128,16 +128,16 @@ func TestHandleLidarrSearch_ValidationErrors(t *testing.T) { wantCode string }{ { - name: "bad kind playlist", - q: "Beatles", - kind: "playlist", + name: "bad kind playlist", + q: "Beatles", + kind: "playlist", wantStatus: http.StatusBadRequest, wantCode: "bad_kind", }, { - name: "missing query", - q: "", - kind: "artist", + name: "missing query", + q: "", + kind: "artist", wantStatus: http.StatusBadRequest, wantCode: "missing_query", }, From 03cbd4d0c157de3096418e9b96a577033df3f360 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 17:40:49 -0400 Subject: [PATCH 13/67] feat(api): add /api/requests user-facing CRUD Implements POST/GET/GET:id/DELETE /api/requests handlers delegating to lidarrrequests.Service; wires routes in RequireUser group and threads the service through Mount and server.go. Co-Authored-By: Claude Sonnet 4.6 --- internal/api/api.go | 23 ++- internal/api/auth_test.go | 5 +- internal/api/library_test.go | 2 +- internal/api/requests.go | 203 +++++++++++++++++++ internal/api/requests_test.go | 356 ++++++++++++++++++++++++++++++++++ internal/server/server.go | 5 +- 6 files changed, 583 insertions(+), 11 deletions(-) create mode 100644 internal/api/requests.go create mode 100644 internal/api/requests_test.go diff --git a/internal/api/api.go b/internal/api/api.go index 2f5281bf..2354c5d8 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -14,15 +14,16 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" ) // Mount attaches /api/* handlers to r. Public endpoints (login) are outside // RequireUser; everything else is gated by the middleware. The events writer // is shared with the Subsonic mount so /rest/scrobble feeds the same store. -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service) { +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service) { rng := rand.New(rand.NewSource(rand.Int63())) - h := &handlers{pool: pool, logger: logger, events: events, recCfg: recCfg, rng: rng.Float64, lidarrCfg: lidarrCfg} + h := &handlers{pool: pool, logger: logger, events: events, recCfg: recCfg, rng: rng.Float64, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs} r.Route("/api", func(api chi.Router) { api.Post("/auth/login", h.handleLogin) @@ -54,15 +55,21 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Get("/likes/ids", h.handleGetLikedIDs) authed.Get("/lidarr/search", h.handleLidarrSearch) + + authed.Post("/requests", h.handleCreateRequest) + authed.Get("/requests", h.handleListRequests) + authed.Get("/requests/{id}", h.handleGetRequest) + authed.Delete("/requests/{id}", h.handleCancelRequest) }) }) } type handlers struct { - pool *pgxpool.Pool - logger *slog.Logger - events *playevents.Writer - recCfg config.RecommendationConfig - rng func() float64 - lidarrCfg *lidarrconfig.Service + pool *pgxpool.Pool + logger *slog.Logger + events *playevents.Writer + recCfg config.RecommendationConfig + rng func() float64 + lidarrCfg *lidarrconfig.Service + lidarrRequests *lidarrrequests.Service } diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 87e24cc8..b2644ea8 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -23,6 +23,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" ) @@ -55,7 +56,9 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { ContextWeight: 2.0, SimilarityWeight: 2.0, RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, } - h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrconfig.New(pool)} + lidarrCfg := lidarrconfig.New(pool) + lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil) + h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs} return h, pool } diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 6a76a0ae..63f01f2b 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -441,7 +441,7 @@ func TestRoutesRegisteredInMount(t *testing.T) { r := chi.NewRouter() w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), 30*time.Minute, 0.5, 30000) - Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg) + Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests) paths := []string{ "/api/artists", diff --git a/internal/api/requests.go b/internal/api/requests.go new file mode 100644 index 00000000..09d25e50 --- /dev/null +++ b/internal/api/requests.go @@ -0,0 +1,203 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" +) + +// requestView is the JSON shape returned by all /api/requests handlers. +// Field names follow the lowercase snake_case convention for /api/* responses. +type requestView struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + Status string `json:"status"` + Kind string `json:"kind"` + LidarrArtistMBID string `json:"lidarr_artist_mbid"` + LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"` + LidarrTrackMBID *string `json:"lidarr_track_mbid,omitempty"` + ArtistName string `json:"artist_name"` + AlbumTitle *string `json:"album_title,omitempty"` + TrackTitle *string `json:"track_title,omitempty"` + QualityProfileID *int32 `json:"quality_profile_id,omitempty"` + RootFolderPath *string `json:"root_folder_path,omitempty"` + DecidedAt pgtype.Timestamptz `json:"decided_at,omitempty"` + DecidedBy pgtype.UUID `json:"decided_by,omitempty"` + Notes *string `json:"notes,omitempty"` + CompletedAt pgtype.Timestamptz `json:"completed_at,omitempty"` + MatchedTrackID pgtype.UUID `json:"matched_track_id,omitempty"` + MatchedAlbumID pgtype.UUID `json:"matched_album_id,omitempty"` + MatchedArtistID pgtype.UUID `json:"matched_artist_id,omitempty"` + RequestedAt pgtype.Timestamptz `json:"requested_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func requestViewFrom(row dbq.LidarrRequest) requestView { + return requestView{ + ID: row.ID, + UserID: row.UserID, + Status: string(row.Status), + Kind: string(row.Kind), + LidarrArtistMBID: row.LidarrArtistMbid, + LidarrAlbumMBID: row.LidarrAlbumMbid, + LidarrTrackMBID: row.LidarrTrackMbid, + ArtistName: row.ArtistName, + AlbumTitle: row.AlbumTitle, + TrackTitle: row.TrackTitle, + QualityProfileID: row.QualityProfileID, + RootFolderPath: row.RootFolderPath, + DecidedAt: row.DecidedAt, + DecidedBy: row.DecidedBy, + Notes: row.Notes, + CompletedAt: row.CompletedAt, + MatchedTrackID: row.MatchedTrackID, + MatchedAlbumID: row.MatchedAlbumID, + MatchedArtistID: row.MatchedArtistID, + RequestedAt: row.RequestedAt, + UpdatedAt: row.UpdatedAt, + } +} + +// createRequestBody is the decoded JSON for POST /api/requests. +type createRequestBody struct { + Kind string `json:"kind"` + LidarrArtistMBID string `json:"lidarr_artist_mbid"` + LidarrAlbumMBID string `json:"lidarr_album_mbid"` + LidarrTrackMBID string `json:"lidarr_track_mbid"` + ArtistName string `json:"artist_name"` + AlbumTitle string `json:"album_title"` + TrackTitle string `json:"track_title"` +} + +// handleCreateRequest implements POST /api/requests. +func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + + var body createRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") + return + } + + row, err := h.lidarrRequests.Create(r.Context(), user.ID, lidarrrequests.CreateParams{ + Kind: body.Kind, + LidarrArtistMBID: body.LidarrArtistMBID, + LidarrAlbumMBID: body.LidarrAlbumMBID, + LidarrTrackMBID: body.LidarrTrackMBID, + ArtistName: body.ArtistName, + AlbumTitle: body.AlbumTitle, + TrackTitle: body.TrackTitle, + }) + if err != nil { + if errors.Is(err, lidarrrequests.ErrInvalidKindFields) { + writeErr(w, http.StatusBadRequest, "mbid_required", err.Error()) + return + } + h.logger.Error("api: create request", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "create failed") + return + } + + writeJSON(w, http.StatusCreated, requestViewFrom(row)) +} + +// handleListRequests implements GET /api/requests — returns caller's own requests. +func (h *handlers) handleListRequests(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + + rows, err := h.lidarrRequests.ListForUser(r.Context(), user.ID, 100) + if err != nil { + h.logger.Error("api: list requests", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "list failed") + return + } + + out := make([]requestView, 0, len(rows)) + for _, row := range rows { + out = append(out, requestViewFrom(row)) + } + writeJSON(w, http.StatusOK, out) +} + +// handleGetRequest implements GET /api/requests/:id. +// The caller must own the request, or be an admin. +func (h *handlers) handleGetRequest(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + + id, ok := parseUUID(chi.URLParam(r, "id")) + if !ok { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid request id") + return + } + + row, err := dbq.New(h.pool).GetLidarrRequestByID(r.Context(), id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + writeErr(w, http.StatusNotFound, "request_not_found", "request not found") + return + } + h.logger.Error("api: get request", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "get failed") + return + } + + // Allow if caller owns the request or is an admin. + if row.UserID != user.ID && !user.IsAdmin { + writeErr(w, http.StatusNotFound, "request_not_found", "request not found") + return + } + + writeJSON(w, http.StatusOK, requestViewFrom(row)) +} + +// handleCancelRequest implements DELETE /api/requests/:id. +// Cancels the caller's own pending request. +func (h *handlers) handleCancelRequest(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + + id, ok := parseUUID(chi.URLParam(r, "id")) + if !ok { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid request id") + return + } + + row, err := h.lidarrRequests.Cancel(r.Context(), id, user.ID) + if err != nil { + switch { + case errors.Is(err, lidarrrequests.ErrNotPending): + writeErr(w, http.StatusConflict, "request_not_pending", "request is not pending") + case errors.Is(err, lidarrrequests.ErrNotFound): + writeErr(w, http.StatusNotFound, "request_not_found", "request not found") + default: + h.logger.Error("api: cancel request", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "cancel failed") + } + return + } + + writeJSON(w, http.StatusOK, requestViewFrom(row)) +} diff --git a/internal/api/requests_test.go b/internal/api/requests_test.go new file mode 100644 index 00000000..b71ac9d5 --- /dev/null +++ b/internal/api/requests_test.go @@ -0,0 +1,356 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// newRequestsRouter builds a test chi router wiring the /api/requests handlers +// with chi URL params but without RequireUser middleware. Tests inject a user +// into the context manually. +func newRequestsRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Post("/api/requests", h.handleCreateRequest) + r.Get("/api/requests", h.handleListRequests) + r.Get("/api/requests/{id}", h.handleGetRequest) + r.Delete("/api/requests/{id}", h.handleCancelRequest) + return r +} + +// withUser injects a user into the request context the same way RequireUser does. +func withUser(req *http.Request, user dbq.User) *http.Request { + return req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user)) +} + +// doCreateRequest fires POST /api/requests with the given JSON body as the given user. +func doCreateRequest(h *handlers, user dbq.User, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/requests", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req = withUser(req, user) + w := httptest.NewRecorder() + newRequestsRouter(h).ServeHTTP(w, req) + return w +} + +// doListRequests fires GET /api/requests as the given user. +func doListRequests(h *handlers, user dbq.User) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/api/requests", nil) + req = withUser(req, user) + w := httptest.NewRecorder() + newRequestsRouter(h).ServeHTTP(w, req) + return w +} + +// doGetRequest fires GET /api/requests/:id as the given user. +func doGetRequest(h *handlers, user dbq.User, id pgtype.UUID) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/api/requests/"+uuidToString(id), nil) + req = withUser(req, user) + w := httptest.NewRecorder() + newRequestsRouter(h).ServeHTTP(w, req) + return w +} + +// doCancelRequest fires DELETE /api/requests/:id as the given user. +func doCancelRequest(h *handlers, user dbq.User, id pgtype.UUID) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodDelete, "/api/requests/"+uuidToString(id), nil) + req = withUser(req, user) + w := httptest.NewRecorder() + newRequestsRouter(h).ServeHTTP(w, req) + return w +} + +// createArtistRequest is a convenience wrapper that seeds a valid artist request +// via the handler and returns the parsed response. +func createArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, artistName string) requestView { + t.Helper() + body := fmt.Sprintf(`{"kind":"artist","lidarr_artist_mbid":%q,"artist_name":%q}`, artistMBID, artistName) + w := doCreateRequest(h, user, body) + if w.Code != http.StatusCreated { + t.Fatalf("createArtistRequest: status = %d, want 201; body = %s", w.Code, w.Body.String()) + } + var rv requestView + if err := json.Unmarshal(w.Body.Bytes(), &rv); err != nil { + t.Fatalf("createArtistRequest: decode: %v; body = %s", err, w.Body.String()) + } + return rv +} + +// TestHandleCreateRequest_Artist_HappyPath verifies a valid artist request +// returns 201 with kind=artist and status=pending. +func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) { + h, pool := testHandlers(t) + resetLidarrState(t, h) + + alice := seedUser(t, pool, "alice", "pw", false) + body := `{"kind":"artist","lidarr_artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}` + w := doCreateRequest(h, alice, body) + + if w.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body = %s", w.Code, w.Body.String()) + } + var rv requestView + if err := json.Unmarshal(w.Body.Bytes(), &rv); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if string(rv.Kind) != "artist" { + t.Errorf("kind = %q, want artist", rv.Kind) + } + if rv.Status != "pending" { + t.Errorf("status = %q, want pending", rv.Status) + } + if rv.LidarrArtistMBID != "artist-mbid-1" { + t.Errorf("lidarr_artist_mbid = %q", rv.LidarrArtistMBID) + } + if rv.ArtistName != "The Beatles" { + t.Errorf("artist_name = %q", rv.ArtistName) + } + if !rv.ID.Valid { + t.Error("id not set") + } +} + +// TestHandleCreateRequest_AlbumWithoutAlbumMBID_400 verifies that sending +// kind=album without lidarr_album_mbid returns 400 with mbid_required. +func TestHandleCreateRequest_AlbumWithoutAlbumMBID_400(t *testing.T) { + h, pool := testHandlers(t) + resetLidarrState(t, h) + + alice := seedUser(t, pool, "alice", "pw", false) + // Missing lidarr_album_mbid and album_title. + body := `{"kind":"album","lidarr_artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}` + w := doCreateRequest(h, alice, body) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String()) + } + var resp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if resp.Error.Code != "mbid_required" { + t.Errorf("error.code = %q, want mbid_required", resp.Error.Code) + } +} + +// TestHandleCreateRequest_TrackWithoutTrackFields_400 verifies that kind=track +// with missing track_mbid/track_title returns 400 with mbid_required. +func TestHandleCreateRequest_TrackWithoutTrackFields_400(t *testing.T) { + h, pool := testHandlers(t) + resetLidarrState(t, h) + + alice := seedUser(t, pool, "alice", "pw", false) + // Provides album fields but misses track_mbid and track_title. + body := `{"kind":"track","lidarr_artist_mbid":"artist-mbid-1","artist_name":"Beatles","lidarr_album_mbid":"album-mbid-1","album_title":"Abbey Road"}` + w := doCreateRequest(h, alice, body) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String()) + } + var resp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if resp.Error.Code != "mbid_required" { + t.Errorf("error.code = %q, want mbid_required", resp.Error.Code) + } +} + +// TestHandleListRequests_OnlyOwnRequests verifies alice sees only her own +// requests, not bob's. +func TestHandleListRequests_OnlyOwnRequests(t *testing.T) { + h, pool := testHandlers(t) + resetLidarrState(t, h) + + alice := seedUser(t, pool, "alice", "pw", false) + bob := seedUser(t, pool, "bob", "pw", false) + + createArtistRequest(t, h, alice, "alice-mbid-1", "Alice Artist 1") + createArtistRequest(t, h, alice, "alice-mbid-2", "Alice Artist 2") + createArtistRequest(t, h, bob, "bob-mbid-1", "Bob Artist 1") + + w := doListRequests(h, alice) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var results []requestView + if err := json.Unmarshal(w.Body.Bytes(), &results); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if len(results) != 2 { + t.Fatalf("len(results) = %d, want 2", len(results)) + } + for _, rv := range results { + if rv.UserID != alice.ID { + t.Errorf("result belongs to wrong user: %v", rv.UserID) + } + } +} + +// TestHandleGetRequest_OwnReturns200 verifies a caller can fetch their own request. +func TestHandleGetRequest_OwnReturns200(t *testing.T) { + h, pool := testHandlers(t) + resetLidarrState(t, h) + + alice := seedUser(t, pool, "alice", "pw", false) + rv := createArtistRequest(t, h, alice, "artist-mbid-get", "Get Artist") + + w := doGetRequest(h, alice, rv.ID) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var got requestView + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if got.ID != rv.ID { + t.Errorf("id mismatch") + } +} + +// TestHandleGetRequest_OtherUserNotAdmin_404 verifies a non-admin caller +// cannot see another user's request. +func TestHandleGetRequest_OtherUserNotAdmin_404(t *testing.T) { + h, pool := testHandlers(t) + resetLidarrState(t, h) + + bob := seedUser(t, pool, "bob", "pw", false) + charlie := seedUser(t, pool, "charlie", "pw", false) + + rv := createArtistRequest(t, h, bob, "bob-artist-mbid", "Bob's Artist") + + // Charlie is not an admin, tries to fetch Bob's request. + w := doGetRequest(h, charlie, rv.ID) + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String()) + } + var resp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if resp.Error.Code != "request_not_found" { + t.Errorf("error.code = %q, want request_not_found", resp.Error.Code) + } +} + +// TestHandleGetRequest_OtherUserAsAdmin_200 verifies an admin can fetch any +// user's request. +func TestHandleGetRequest_OtherUserAsAdmin_200(t *testing.T) { + h, pool := testHandlers(t) + resetLidarrState(t, h) + + bob := seedUser(t, pool, "bob", "pw", false) + admin := seedUser(t, pool, "admin", "pw", true) + + rv := createArtistRequest(t, h, bob, "bob-admin-mbid", "Bob's Artist For Admin") + + // Admin fetches Bob's request — should succeed. + w := doGetRequest(h, admin, rv.ID) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var got requestView + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if got.ID != rv.ID { + t.Errorf("id mismatch") + } +} + +// TestHandleCancelRequest_PendingHappyPath verifies that cancelling a pending +// request returns 200 with status=rejected. +func TestHandleCancelRequest_PendingHappyPath(t *testing.T) { + h, pool := testHandlers(t) + resetLidarrState(t, h) + + alice := seedUser(t, pool, "alice", "pw", false) + rv := createArtistRequest(t, h, alice, "cancel-mbid-1", "Cancel Me") + + w := doCancelRequest(h, alice, rv.ID) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var got requestView + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if got.Status != "rejected" { + t.Errorf("status = %q, want rejected", got.Status) + } +} + +// TestHandleCancelRequest_NonPending_409 verifies that cancelling an already +// rejected request returns 409 with request_not_pending. +func TestHandleCancelRequest_NonPending_409(t *testing.T) { + h, pool := testHandlers(t) + resetLidarrState(t, h) + + alice := seedUser(t, pool, "alice", "pw", false) + rv := createArtistRequest(t, h, alice, "cancel-mbid-2", "Cancel Twice") + + // First cancel transitions to rejected. + w1 := doCancelRequest(h, alice, rv.ID) + if w1.Code != http.StatusOK { + t.Fatalf("first cancel: status = %d, want 200; body = %s", w1.Code, w1.Body.String()) + } + + // Second cancel on a now-rejected row → ErrNotPending → 409. + // The cancel SQL checks user_id = $2 AND status = 'pending'; zero rows → ErrNotPending. + // For this test we use dbq directly to reject via admin so Cancel returns ErrNotPending + // rather than the ownership miss. We already did first cancel so row is rejected. + w2 := doCancelRequest(h, alice, rv.ID) + if w2.Code != http.StatusConflict { + t.Fatalf("second cancel: status = %d, want 409; body = %s", w2.Code, w2.Body.String()) + } + var resp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(w2.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body = %s", err, w2.Body.String()) + } + if resp.Error.Code != "request_not_pending" { + t.Errorf("error.code = %q, want request_not_pending", resp.Error.Code) + } +} + +// TestHandleCancelRequest_WrongUser_409 verifies that attempting to cancel +// another user's request returns 409 (not_pending from the ownership miss). +func TestHandleCancelRequest_WrongUser_409(t *testing.T) { + h, pool := testHandlers(t) + resetLidarrState(t, h) + + alice := seedUser(t, pool, "alice-cancel-owner", "pw", false) + bob := seedUser(t, pool, "bob-cancel-other", "pw", false) + + rv := createArtistRequest(t, h, alice, "cancel-wrong-user-mbid", "Alice's Artist") + + // Bob tries to cancel Alice's request: SQL WHERE user_id=bob AND status=pending → 0 rows. + w := doCancelRequest(h, bob, rv.ID) + if w.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409; body = %s", w.Code, w.Body.String()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index bbe77125..77c87add 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -18,6 +18,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/config" "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/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic" "git.fabledsword.com/bvandeusen/minstrel/web" @@ -56,7 +57,9 @@ func (s *Server) Router() http.Handler { s.EventsCfg.SkipMaxCompletionRatio, s.EventsCfg.SkipMaxDurationPlayedMs, ) - api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrconfig.New(s.Pool)) + lidarrCfg := lidarrconfig.New(s.Pool) + lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, nil, nil) + api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs) r.Route("/api/admin", func(admin chi.Router) { admin.Use(auth.RequireUser(s.Pool)) admin.Use(auth.RequireAdmin()) From 6fcae8dee4d6e338325990bfdba555a689612a2c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 17:44:54 -0400 Subject: [PATCH 14/67] feat(api): add /api/admin/lidarr/* config + profiles + folders + test Five admin-only handlers under RequireAdmin middleware: GET/PUT config (api_key masked, empty key on PUT preserves saved), POST test (always 200, maps Lidarr errors to stable codes), GET quality-profiles, GET root-folders. 10 HTTP integration tests, all green. Co-Authored-By: Claude Sonnet 4.6 --- internal/api/admin_lidarr.go | 249 ++++++++++++++++++++ internal/api/admin_lidarr_test.go | 379 ++++++++++++++++++++++++++++++ internal/api/api.go | 9 + 3 files changed, 637 insertions(+) create mode 100644 internal/api/admin_lidarr.go create mode 100644 internal/api/admin_lidarr_test.go diff --git a/internal/api/admin_lidarr.go b/internal/api/admin_lidarr.go new file mode 100644 index 00000000..adf080f2 --- /dev/null +++ b/internal/api/admin_lidarr.go @@ -0,0 +1,249 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "net/url" + + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" +) + +// lidarrConfigView is the JSON shape returned by GET /api/admin/lidarr/config +// and PUT /api/admin/lidarr/config. api_key is always masked as "***" when set. +type lidarrConfigView struct { + Enabled bool `json:"enabled"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + DefaultQualityProfileID int `json:"default_quality_profile_id"` + DefaultRootFolderPath string `json:"default_root_folder_path"` +} + +// maskAPIKey converts an api_key for external response: non-empty keys become +// "***"; empty (never set) keys remain "". +func maskAPIKey(key string) string { + if key == "" { + return "" + } + return "***" +} + +// configToView converts a lidarrconfig.Config to a lidarrConfigView with the +// api_key masked. +func configToView(cfg lidarrconfig.Config) lidarrConfigView { + return lidarrConfigView{ + Enabled: cfg.Enabled, + BaseURL: cfg.BaseURL, + APIKey: maskAPIKey(cfg.APIKey), + DefaultQualityProfileID: cfg.DefaultQualityProfileID, + DefaultRootFolderPath: cfg.DefaultRootFolderPath, + } +} + +// handleGetLidarrConfig implements GET /api/admin/lidarr/config. +// Returns the current Lidarr config with api_key masked. +func (h *handlers) handleGetLidarrConfig(w http.ResponseWriter, r *http.Request) { + cfg, err := h.lidarrCfg.Get(r.Context()) + if err != nil { + h.logger.Error("admin: get lidarr config", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error") + return + } + writeJSON(w, http.StatusOK, configToView(cfg)) +} + +// putLidarrConfigBody is the decoded JSON body for PUT /api/admin/lidarr/config. +type putLidarrConfigBody struct { + Enabled bool `json:"enabled"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + DefaultQualityProfileID int `json:"default_quality_profile_id"` + DefaultRootFolderPath string `json:"default_root_folder_path"` +} + +// handlePutLidarrConfig implements PUT /api/admin/lidarr/config. +// Empty api_key in the body preserves the currently saved api_key. +func (h *handlers) handlePutLidarrConfig(w http.ResponseWriter, r *http.Request) { + var body putLidarrConfigBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeAdminJSONErr(w, http.StatusBadRequest, "bad_request") + return + } + + // Resolve the effective api_key: empty in body = preserve saved value. + apiKey := body.APIKey + if apiKey == "" { + saved, err := h.lidarrCfg.Get(r.Context()) + if err != nil { + h.logger.Error("admin: put lidarr config: load saved", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error") + return + } + apiKey = saved.APIKey + } + + // Validate: enabled=true requires base_url AND api_key. + if body.Enabled { + if body.BaseURL == "" || apiKey == "" { + writeAdminJSONErr(w, http.StatusBadRequest, "missing_required_field") + return + } + // Validate that the URL parses. + if _, err := url.ParseRequestURI(body.BaseURL); err != nil { + writeAdminJSONErr(w, http.StatusBadRequest, "invalid_url") + return + } + } + + cfg := lidarrconfig.Config{ + Enabled: body.Enabled, + BaseURL: body.BaseURL, + APIKey: apiKey, + DefaultQualityProfileID: body.DefaultQualityProfileID, + DefaultRootFolderPath: body.DefaultRootFolderPath, + } + if err := h.lidarrCfg.Save(r.Context(), cfg); err != nil { + h.logger.Error("admin: put lidarr config: save", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error") + return + } + writeJSON(w, http.StatusOK, configToView(cfg)) +} + +// testLidarrBody is the optional JSON body for POST /api/admin/lidarr/test. +type testLidarrBody struct { + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` +} + +// handleTestLidarrConnection implements POST /api/admin/lidarr/test. +// Always returns 200; the ok/error fields in the response body indicate +// connection success or failure. +func (h *handlers) handleTestLidarrConnection(w http.ResponseWriter, r *http.Request) { + var body testLidarrBody + // Ignore decode errors — an empty body is valid (all fields optional). + _ = json.NewDecoder(r.Body).Decode(&body) + + // Fall back to saved config for any empty field. + baseURL := body.BaseURL + apiKey := body.APIKey + if baseURL == "" || apiKey == "" { + saved, err := h.lidarrCfg.Get(r.Context()) + if err != nil { + h.logger.Error("admin: test lidarr: load config", "err", err) + writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "internal_error"}) + return + } + if baseURL == "" { + baseURL = saved.BaseURL + } + if apiKey == "" { + apiKey = saved.APIKey + } + } + + client := lidarr.NewClient(baseURL, apiKey) + result, err := client.Ping(r.Context()) + if err != nil { + errCode := lidarrErrCode(err) + writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": errCode}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": result.Version}) +} + +// lidarrErrCode maps a lidarr client error to the stable string code used in +// admin API responses. +func lidarrErrCode(err error) string { + switch { + case errors.Is(err, lidarr.ErrUnreachable): + return "lidarr_unreachable" + case errors.Is(err, lidarr.ErrAuthFailed): + return "lidarr_auth_failed" + case errors.Is(err, lidarr.ErrLookupFailed): + return "lidarr_lookup_failed" + default: + return "internal_error" + } +} + +// qualityProfileView is the JSON shape for a single quality profile in the +// GET /api/admin/lidarr/quality-profiles response. +type qualityProfileView struct { + ID int `json:"id"` + Name string `json:"name"` +} + +// handleListQualityProfiles implements GET /api/admin/lidarr/quality-profiles. +func (h *handlers) handleListQualityProfiles(w http.ResponseWriter, r *http.Request) { + _, client, ok := h.lidarrClientFromConfig(w, r) + if !ok { + return + } + + profiles, err := client.ListQualityProfiles(r.Context()) + if err != nil { + writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err)) + return + } + out := make([]qualityProfileView, len(profiles)) + for i, p := range profiles { + out[i] = qualityProfileView{ID: p.ID, Name: p.Name} + } + writeJSON(w, http.StatusOK, out) +} + +// rootFolderView is the JSON shape for a single root folder in the +// GET /api/admin/lidarr/root-folders response. +type rootFolderView struct { + Path string `json:"path"` + Accessible bool `json:"accessible"` + FreeSpace int64 `json:"free_space"` +} + +// handleListRootFolders implements GET /api/admin/lidarr/root-folders. +func (h *handlers) handleListRootFolders(w http.ResponseWriter, r *http.Request) { + _, client, ok := h.lidarrClientFromConfig(w, r) + if !ok { + return + } + + folders, err := client.ListRootFolders(r.Context()) + if err != nil { + writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err)) + return + } + out := make([]rootFolderView, len(folders)) + for i, f := range folders { + out[i] = rootFolderView{Path: f.Path, Accessible: f.Accessible, FreeSpace: f.FreeSpace} + } + writeJSON(w, http.StatusOK, out) +} + +// lidarrClientFromConfig is a shared helper for handlers that need to proxy a +// request to Lidarr. It loads the saved config, returns 503 when disabled, and +// constructs a Client. Returns (cfg, client, true) on success; (_, nil, false) +// when a response has already been written. +func (h *handlers) lidarrClientFromConfig(w http.ResponseWriter, r *http.Request) (lidarrconfig.Config, *lidarr.Client, bool) { + cfg, err := h.lidarrCfg.Get(r.Context()) + if err != nil { + h.logger.Error("admin: lidarr proxy: load config", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error") + return lidarrconfig.Config{}, nil, false + } + if !cfg.Enabled { + writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled") + return lidarrconfig.Config{}, nil, false + } + return cfg, lidarr.NewClient(cfg.BaseURL, cfg.APIKey), true +} + +// writeAdminJSONErr writes a flat {"error":""} JSON response. This +// mirrors the shape the RequireAdmin middleware uses and that the spec +// defines for /api/admin/* errors. +func writeAdminJSONErr(w http.ResponseWriter, status int, code string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": code}) +} diff --git a/internal/api/admin_lidarr_test.go b/internal/api/admin_lidarr_test.go new file mode 100644 index 00000000..535419de --- /dev/null +++ b/internal/api/admin_lidarr_test.go @@ -0,0 +1,379 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" +) + +// newAdminLidarrRouter builds a test chi router with the five admin/lidarr +// endpoints. RequireAdmin middleware is applied. Tests inject a user into +// context directly (skipping RequireUser). +func newAdminLidarrRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Route("/api/admin", func(admin chi.Router) { + admin.Use(auth.RequireAdmin()) + admin.Get("/lidarr/config", h.handleGetLidarrConfig) + admin.Put("/lidarr/config", h.handlePutLidarrConfig) + admin.Post("/lidarr/test", h.handleTestLidarrConnection) + admin.Get("/lidarr/quality-profiles", h.handleListQualityProfiles) + admin.Get("/lidarr/root-folders", h.handleListRootFolders) + }) + return r +} + +// seedAdminUser creates a test user with is_admin=true. +func seedAdminUser(t *testing.T, h *handlers) dbq.User { + t.Helper() + return seedUser(t, h.pool, "admin", "pw", true) +} + +// doAdminReq fires an HTTP request at the admin router with the given user +// injected into context (bypassing RequireUser). +func doAdminReq(t *testing.T, h *handlers, method, path string, body []byte, user dbq.User) *httptest.ResponseRecorder { + t.Helper() + var reqBody *bytes.Buffer + if body != nil { + reqBody = bytes.NewBuffer(body) + } else { + reqBody = bytes.NewBuffer(nil) + } + req := httptest.NewRequest(method, path, reqBody) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user)) + w := httptest.NewRecorder() + newAdminLidarrRouter(h).ServeHTTP(w, req) + return w +} + +// saveLidarrConfigFull writes a lidarr config with the given key directly +// (for admin tests that need precise api_key control). +func saveLidarrConfigFull(t *testing.T, h *handlers, cfg lidarrconfig.Config) { + t.Helper() + svc := lidarrconfig.New(h.pool) + if err := svc.Save(context.Background(), cfg); err != nil { + t.Fatalf("saveLidarrConfigFull: %v", err) + } +} + +// TestHandleGetLidarrConfig_MasksAPIKey verifies that a set api_key is +// returned as "***" and never in plaintext. +func TestHandleGetLidarrConfig_MasksAPIKey(t *testing.T) { + h, _ := testHandlers(t) + resetLidarrState(t, h) + admin := seedAdminUser(t, h) + + saveLidarrConfigFull(t, h, lidarrconfig.Config{ + Enabled: true, + BaseURL: "http://lidarr.lan:8686", + APIKey: "secret123", + }) + + w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/config", nil, admin) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var resp lidarrConfigView + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if resp.APIKey == "secret123" { + t.Error("api_key leaked in response; want ***") + } + if resp.APIKey != "***" { + t.Errorf("api_key = %q, want ***", resp.APIKey) + } +} + +// TestHandleGetLidarrConfig_EmptyKeyReturnsEmpty verifies that when api_key is +// unset in the DB, GET returns api_key="" (not "***"). +func TestHandleGetLidarrConfig_EmptyKeyReturnsEmpty(t *testing.T) { + h, _ := testHandlers(t) + resetLidarrState(t, h) + admin := seedAdminUser(t, h) + + // Save config with empty api_key (null in DB via lidarrconfig.Service). + saveLidarrConfigFull(t, h, lidarrconfig.Config{ + Enabled: false, + BaseURL: "", + APIKey: "", + }) + + w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/config", nil, admin) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var resp lidarrConfigView + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if resp.APIKey != "" { + t.Errorf("api_key = %q, want empty string", resp.APIKey) + } +} + +// TestHandlePutLidarrConfig_EmptyKeyPreservesSaved verifies that a PUT with +// api_key="" does not overwrite the saved api_key. +func TestHandlePutLidarrConfig_EmptyKeyPreservesSaved(t *testing.T) { + h, _ := testHandlers(t) + resetLidarrState(t, h) + admin := seedAdminUser(t, h) + + // Seed a config with a known api_key. + saveLidarrConfigFull(t, h, lidarrconfig.Config{ + Enabled: true, + BaseURL: "http://lidarr.lan:8686", + APIKey: "originalkey", + }) + + // PUT with empty api_key — should preserve "originalkey". + body := []byte(`{"enabled":true,"base_url":"http://lidarr.lan:8686","api_key":""}`) + w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + + // Confirm the saved api_key is still "originalkey" by reading raw config. + svc := lidarrconfig.New(h.pool) + saved, err := svc.Get(context.Background()) + if err != nil { + t.Fatalf("get saved config: %v", err) + } + if saved.APIKey != "originalkey" { + t.Errorf("saved api_key = %q, want originalkey", saved.APIKey) + } +} + +// TestHandlePutLidarrConfig_EnabledRequiresBaseURL_400 verifies that enabling +// Lidarr without a base_url returns 400 with missing_required_field. +func TestHandlePutLidarrConfig_EnabledRequiresBaseURL_400(t *testing.T) { + h, _ := testHandlers(t) + resetLidarrState(t, h) + admin := seedAdminUser(t, h) + + body := []byte(`{"enabled":true,"base_url":"","api_key":"somekey"}`) + w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String()) + } + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if resp["error"] != "missing_required_field" { + t.Errorf("error = %q, want missing_required_field", resp["error"]) + } +} + +// TestHandlePutLidarrConfig_HappyPath verifies a valid PUT returns 200 with +// the updated config and the api_key masked. +func TestHandlePutLidarrConfig_HappyPath(t *testing.T) { + h, _ := testHandlers(t) + resetLidarrState(t, h) + admin := seedAdminUser(t, h) + + body := []byte(`{"enabled":true,"base_url":"http://lidarr.lan:8686","api_key":"newkey","default_quality_profile_id":2,"default_root_folder_path":"/music"}`) + w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var resp lidarrConfigView + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if !resp.Enabled { + t.Error("enabled = false, want true") + } + if resp.BaseURL != "http://lidarr.lan:8686" { + t.Errorf("base_url = %q, want http://lidarr.lan:8686", resp.BaseURL) + } + if resp.APIKey != "***" { + t.Errorf("api_key = %q, want ***", resp.APIKey) + } + if resp.DefaultQualityProfileID != 2 { + t.Errorf("default_quality_profile_id = %d, want 2", resp.DefaultQualityProfileID) + } + if resp.DefaultRootFolderPath != "/music" { + t.Errorf("default_root_folder_path = %q, want /music", resp.DefaultRootFolderPath) + } +} + +// TestHandleTestLidarrConnection_HappyPath verifies that POST /test against a +// stub returning valid status JSON responds 200 with ok=true and the version. +func TestHandleTestLidarrConnection_HappyPath(t *testing.T) { + h, _ := testHandlers(t) + resetLidarrState(t, h) + admin := seedAdminUser(t, h) + + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"2.0.5"}`)) + })) + t.Cleanup(stub.Close) + + saveLidarrConfigFull(t, h, lidarrconfig.Config{ + Enabled: true, + BaseURL: stub.URL, + APIKey: "test-key", + }) + + // POST with empty body — falls back to saved config. + w := doAdminReq(t, h, http.MethodPost, "/api/admin/lidarr/test", []byte(`{}`), admin) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if resp["ok"] != true { + t.Errorf("ok = %v, want true", resp["ok"]) + } + if resp["version"] != "2.0.5" { + t.Errorf("version = %v, want 2.0.5", resp["version"]) + } +} + +// TestHandleTestLidarrConnection_Unreachable verifies that POST /test against +// an unreachable URL returns 200 with ok=false and lidarr_unreachable. +func TestHandleTestLidarrConnection_Unreachable(t *testing.T) { + h, _ := testHandlers(t) + resetLidarrState(t, h) + admin := seedAdminUser(t, h) + + // Point at a URL that will immediately refuse connections. + body := []byte(`{"base_url":"http://127.0.0.1:1","api_key":"k"}`) + w := doAdminReq(t, h, http.MethodPost, "/api/admin/lidarr/test", body, admin) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if resp["ok"] != false { + t.Errorf("ok = %v, want false", resp["ok"]) + } + if resp["error"] != "lidarr_unreachable" { + t.Errorf("error = %v, want lidarr_unreachable", resp["error"]) + } +} + +// TestHandleListQualityProfiles_HappyPath verifies that GET /quality-profiles +// proxies and normalizes the Lidarr response. +func TestHandleListQualityProfiles_HappyPath(t *testing.T) { + h, _ := testHandlers(t) + resetLidarrState(t, h) + admin := seedAdminUser(t, h) + + stub := newLidarrStub(t, `[{"id":1,"name":"Lossless"},{"id":2,"name":"Standard"}]`) + saveLidarrConfigFull(t, h, lidarrconfig.Config{ + Enabled: true, + BaseURL: stub.URL, + APIKey: "test-key", + }) + + w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/quality-profiles", nil, admin) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var profiles []qualityProfileView + if err := json.Unmarshal(w.Body.Bytes(), &profiles); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if len(profiles) != 2 { + t.Fatalf("len(profiles) = %d, want 2", len(profiles)) + } + if profiles[0].ID != 1 || profiles[0].Name != "Lossless" { + t.Errorf("profiles[0] = %+v, want {1 Lossless}", profiles[0]) + } + if profiles[1].ID != 2 || profiles[1].Name != "Standard" { + t.Errorf("profiles[1] = %+v, want {2 Standard}", profiles[1]) + } +} + +// TestHandleListRootFolders_HappyPath verifies that GET /root-folders proxies +// and normalizes the Lidarr response. +func TestHandleListRootFolders_HappyPath(t *testing.T) { + h, _ := testHandlers(t) + resetLidarrState(t, h) + admin := seedAdminUser(t, h) + + stub := newLidarrStub(t, `[{"path":"/music","accessible":true,"freeSpace":1234567890}]`) + saveLidarrConfigFull(t, h, lidarrconfig.Config{ + Enabled: true, + BaseURL: stub.URL, + APIKey: "test-key", + }) + + w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/root-folders", nil, admin) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var folders []rootFolderView + if err := json.Unmarshal(w.Body.Bytes(), &folders); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if len(folders) != 1 { + t.Fatalf("len(folders) = %d, want 1", len(folders)) + } + f := folders[0] + if f.Path != "/music" { + t.Errorf("path = %q, want /music", f.Path) + } + if !f.Accessible { + t.Error("accessible = false, want true") + } + if f.FreeSpace != 1234567890 { + t.Errorf("free_space = %d, want 1234567890", f.FreeSpace) + } +} + +// TestAdminEndpoints_NonAdmin403 is a table-driven test verifying that every +// admin endpoint returns 403 with not_authorized for a non-admin user. +func TestAdminEndpoints_NonAdmin403(t *testing.T) { + h, pool := testHandlers(t) + resetLidarrState(t, h) + + nonAdmin := seedUser(t, pool, "regular", "pw", false) + + cases := []struct { + method string + path string + body []byte + }{ + {http.MethodGet, "/api/admin/lidarr/config", nil}, + {http.MethodPut, "/api/admin/lidarr/config", []byte(`{}`)}, + {http.MethodPost, "/api/admin/lidarr/test", []byte(`{}`)}, + {http.MethodGet, "/api/admin/lidarr/quality-profiles", nil}, + {http.MethodGet, "/api/admin/lidarr/root-folders", nil}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + w := doAdminReq(t, h, tc.method, tc.path, tc.body, nonAdmin) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String()) + } + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if resp["error"] != "not_authorized" { + t.Errorf("error = %q, want not_authorized", resp["error"]) + } + }) + } +} diff --git a/internal/api/api.go b/internal/api/api.go index 2354c5d8..9fa9bd93 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -60,6 +60,15 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Get("/requests", h.handleListRequests) authed.Get("/requests/{id}", h.handleGetRequest) authed.Delete("/requests/{id}", h.handleCancelRequest) + + authed.Route("/admin", func(admin chi.Router) { + admin.Use(auth.RequireAdmin()) + admin.Get("/lidarr/config", h.handleGetLidarrConfig) + admin.Put("/lidarr/config", h.handlePutLidarrConfig) + admin.Post("/lidarr/test", h.handleTestLidarrConnection) + admin.Get("/lidarr/quality-profiles", h.handleListQualityProfiles) + admin.Get("/lidarr/root-folders", h.handleListRootFolders) + }) }) }) } From 82ffb12f68644a595e6b12d2276837cdd98576b3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 17:56:25 -0400 Subject: [PATCH 15/67] feat(api): add /api/admin/requests approval queue Three admin-gated handlers: GET /admin/requests (list by status), POST /admin/requests/:id/approve (with optional overrides), and POST /admin/requests/:id/reject. testHandlers updated to inject a real clientFn so Approve exercises Lidarr in integration tests. Co-Authored-By: Claude Sonnet 4.6 --- internal/api/admin_requests.go | 152 ++++++++++ internal/api/admin_requests_test.go | 446 ++++++++++++++++++++++++++++ internal/api/api.go | 3 + 3 files changed, 601 insertions(+) create mode 100644 internal/api/admin_requests.go create mode 100644 internal/api/admin_requests_test.go diff --git a/internal/api/admin_requests.go b/internal/api/admin_requests.go new file mode 100644 index 00000000..63bf97ab --- /dev/null +++ b/internal/api/admin_requests.go @@ -0,0 +1,152 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" +) + +// validRequestStatuses is the set of allowed values for the ?status= param. +var validRequestStatuses = map[string]bool{ + "pending": true, + "approved": true, + "rejected": true, + "completed": true, + "failed": true, +} + +// handleListAdminRequests implements GET /api/admin/requests. +// ?status= defaults to "pending"; ?limit= defaults to 50, max 200. +func (h *handlers) handleListAdminRequests(w http.ResponseWriter, r *http.Request) { + status := r.URL.Query().Get("status") + if status == "" { + status = "pending" + } + if !validRequestStatuses[status] { + writeAdminJSONErr(w, http.StatusBadRequest, "invalid_status") + return + } + + limitStr := r.URL.Query().Get("limit") + limit := 50 + if limitStr != "" { + v, err := strconv.Atoi(limitStr) + if err != nil || v <= 0 { + writeAdminJSONErr(w, http.StatusBadRequest, "invalid_limit") + return + } + if v > 200 { + v = 200 + } + limit = v + } + + rows, err := h.lidarrRequests.ListByStatus(r.Context(), status, int32(limit)) + if err != nil { + h.logger.Error("admin: list requests", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") + return + } + + out := make([]requestView, 0, len(rows)) + for _, row := range rows { + out = append(out, requestViewFrom(row)) + } + writeJSON(w, http.StatusOK, out) +} + +// approveRequestBody is the optional JSON body for POST /api/admin/requests/:id/approve. +type approveRequestBody struct { + QualityProfileID int `json:"quality_profile_id"` + RootFolderPath string `json:"root_folder_path"` +} + +// handleApproveRequest implements POST /api/admin/requests/:id/approve. +func (h *handlers) handleApproveRequest(w http.ResponseWriter, r *http.Request) { + admin, ok := auth.UserFromContext(r.Context()) + if !ok { + writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized") + return + } + + id, ok := parseUUID(chi.URLParam(r, "id")) + if !ok { + writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") + return + } + + var body approveRequestBody + // Body is entirely optional; ignore decode errors. + _ = json.NewDecoder(r.Body).Decode(&body) + + row, err := h.lidarrRequests.Approve(r.Context(), id, admin.ID, lidarrrequests.ApproveOverrides{ + QualityProfileID: body.QualityProfileID, + RootFolderPath: body.RootFolderPath, + }) + if err != nil { + switch { + case errors.Is(err, lidarrrequests.ErrLidarrDisabled): + writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled") + case errors.Is(err, lidarrrequests.ErrNotFound): + writeAdminJSONErr(w, http.StatusNotFound, "request_not_found") + case errors.Is(err, lidarrrequests.ErrNotPending): + writeAdminJSONErr(w, http.StatusConflict, "request_not_pending") + case errors.Is(err, lidarr.ErrUnreachable): + writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unreachable") + case errors.Is(err, lidarr.ErrAuthFailed): + writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_auth_failed") + default: + h.logger.Error("admin: approve request", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") + } + return + } + + writeJSON(w, http.StatusOK, requestViewFrom(row)) +} + +// rejectRequestBody is the optional JSON body for POST /api/admin/requests/:id/reject. +type rejectRequestBody struct { + Notes string `json:"notes"` +} + +// handleRejectRequest implements POST /api/admin/requests/:id/reject. +func (h *handlers) handleRejectRequest(w http.ResponseWriter, r *http.Request) { + admin, ok := auth.UserFromContext(r.Context()) + if !ok { + writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized") + return + } + + id, ok := parseUUID(chi.URLParam(r, "id")) + if !ok { + writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") + return + } + + var body rejectRequestBody + _ = json.NewDecoder(r.Body).Decode(&body) + + row, err := h.lidarrRequests.Reject(r.Context(), id, admin.ID, body.Notes) + if err != nil { + switch { + case errors.Is(err, lidarrrequests.ErrNotFound): + writeAdminJSONErr(w, http.StatusNotFound, "request_not_found") + case errors.Is(err, lidarrrequests.ErrNotPending): + writeAdminJSONErr(w, http.StatusConflict, "request_not_pending") + default: + h.logger.Error("admin: reject request", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") + } + return + } + + writeJSON(w, http.StatusOK, requestViewFrom(row)) +} diff --git a/internal/api/admin_requests_test.go b/internal/api/admin_requests_test.go new file mode 100644 index 00000000..490e055c --- /dev/null +++ b/internal/api/admin_requests_test.go @@ -0,0 +1,446 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" +) + +// newAdminRequestsRouter builds a test chi router with the three admin/requests +// endpoints. RequireAdmin middleware is applied. Tests inject a user into +// context directly (skipping RequireUser). +func newAdminRequestsRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Route("/api/admin", func(admin chi.Router) { + admin.Use(auth.RequireAdmin()) + admin.Get("/requests", h.handleListAdminRequests) + admin.Post("/requests/{id}/approve", h.handleApproveRequest) + admin.Post("/requests/{id}/reject", h.handleRejectRequest) + }) + return r +} + +// doAdminRequestReq fires an HTTP request at the admin requests router with +// the given user injected into context (bypassing RequireUser). +func doAdminRequestReq(t *testing.T, h *handlers, method, path string, body []byte, user dbq.User) *httptest.ResponseRecorder { + t.Helper() + var reqBody *bytes.Buffer + if body != nil { + reqBody = bytes.NewBuffer(body) + } else { + reqBody = bytes.NewBuffer(nil) + } + req := httptest.NewRequest(method, path, reqBody) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user)) + w := httptest.NewRecorder() + newAdminRequestsRouter(h).ServeHTTP(w, req) + return w +} + +// testHandlersWithClientFn creates a handlers instance with a real clientFn +// so Service.Approve actually calls Lidarr during tests. +func testHandlersWithClientFn(t *testing.T) (*handlers, *lidarrconfig.Service) { + t.Helper() + h, _ := testHandlers(t) + lidarrCfg := lidarrconfig.New(h.pool) + clientFn := func() *lidarr.Client { + cfg, err := lidarrCfg.Get(context.Background()) + if err != nil || !cfg.Enabled || cfg.BaseURL == "" { + return nil + } + return lidarr.NewClient(cfg.BaseURL, cfg.APIKey) + } + h.lidarrRequests = lidarrrequests.NewService(h.pool, lidarrCfg, clientFn, nil) + return h, lidarrCfg +} + +// seedPendingArtistRequest creates a pending artist-kind lidarr request row +// for the given user and returns its parsed requestView. +func seedPendingArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, artistName string) requestView { + t.Helper() + return createArtistRequest(t, h, user, artistMBID, artistName) +} + +// TestHandleListAdminRequests_DefaultStatus_Pending seeds 2 pending + 1 rejected +// and verifies that GET without ?status returns only the 2 pending rows. +func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) { + h, _ := testHandlersWithClientFn(t) + resetLidarrState(t, h) + + alice := seedUser(t, h.pool, "alice", "pw", false) + admin := seedUser(t, h.pool, "admin", "pw", true) + + createArtistRequest(t, h, alice, "mbid-pending-1", "Pending Artist 1") + createArtistRequest(t, h, alice, "mbid-pending-2", "Pending Artist 2") + + // Reject one request directly via service. + rv3 := createArtistRequest(t, h, alice, "mbid-reject-1", "Reject Me") + _, err := h.lidarrRequests.Reject(context.Background(), rv3.ID, admin.ID, "test") + if err != nil { + t.Fatalf("reject seed: %v", err) + } + + w := doAdminRequestReq(t, h, http.MethodGet, "/api/admin/requests", nil, admin) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + + var results []requestView + if err := json.Unmarshal(w.Body.Bytes(), &results); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if len(results) != 2 { + t.Fatalf("len(results) = %d, want 2 (pending only)", len(results)) + } + for _, rv := range results { + if rv.Status != "pending" { + t.Errorf("result status = %q, want pending", rv.Status) + } + } +} + +// TestHandleListAdminRequests_StatusFilter verifies GET ?status=rejected returns +// only rejected rows. +func TestHandleListAdminRequests_StatusFilter(t *testing.T) { + h, _ := testHandlersWithClientFn(t) + resetLidarrState(t, h) + + alice := seedUser(t, h.pool, "alice", "pw", false) + admin := seedUser(t, h.pool, "admin", "pw", true) + + createArtistRequest(t, h, alice, "mbid-pending-f1", "Pending Filter 1") + + rv2 := createArtistRequest(t, h, alice, "mbid-rejected-f1", "Rejected Filter 1") + _, err := h.lidarrRequests.Reject(context.Background(), rv2.ID, admin.ID, "") + if err != nil { + t.Fatalf("reject seed: %v", err) + } + + w := doAdminRequestReq(t, h, http.MethodGet, "/api/admin/requests?status=rejected", nil, admin) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + + var results []requestView + if err := json.Unmarshal(w.Body.Bytes(), &results); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if len(results) != 1 { + t.Fatalf("len(results) = %d, want 1 rejected row", len(results)) + } + if results[0].Status != "rejected" { + t.Errorf("status = %q, want rejected", results[0].Status) + } +} + +// TestHandleListAdminRequests_BadStatus_400 verifies GET with an invalid +// ?status returns 400. +func TestHandleListAdminRequests_BadStatus_400(t *testing.T) { + h, _ := testHandlersWithClientFn(t) + resetLidarrState(t, h) + + admin := seedUser(t, h.pool, "admin", "pw", true) + + w := doAdminRequestReq(t, h, http.MethodGet, "/api/admin/requests?status=garbage", nil, admin) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String()) + } + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if resp["error"] != "invalid_status" { + t.Errorf("error = %q, want invalid_status", resp["error"]) + } +} + +// TestHandleApproveRequest_HappyPath seeds a Lidarr stub, seeds a pending +// artist request, and verifies POST /approve → 200 with row status=approved. +func TestHandleApproveRequest_HappyPath(t *testing.T) { + h, _ := testHandlersWithClientFn(t) + resetLidarrState(t, h) + + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":1}`)) + })) + t.Cleanup(stub.Close) + + saveLidarrConfig(t, h, stub.URL, true) + + alice := seedUser(t, h.pool, "alice", "pw", false) + admin := seedUser(t, h.pool, "admin", "pw", true) + + rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-approve", "Approve Me") + + w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", nil, admin) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + + var got requestView + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if got.Status != "approved" { + t.Errorf("status = %q, want approved", got.Status) + } + if got.ID != rv.ID { + t.Errorf("id mismatch") + } +} + +// TestHandleApproveRequest_OverrideUsed seeds config with default qp=1/root="/m", +// approves with override qp=5/root="/other", verifies the stub received those +// values and the row reflects the overrides. +func TestHandleApproveRequest_OverrideUsed(t *testing.T) { + h, _ := testHandlersWithClientFn(t) + resetLidarrState(t, h) + + var capturedBody []byte + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + buf := make([]byte, 4096) + n, _ := r.Body.Read(buf) + capturedBody = buf[:n] + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":1}`)) + })) + t.Cleanup(stub.Close) + + // Save config with default qp=1, root="/m". + if err := lidarrconfig.New(h.pool).Save(context.Background(), lidarrconfig.Config{ + Enabled: true, + BaseURL: stub.URL, + APIKey: "test-key", + DefaultQualityProfileID: 1, + DefaultRootFolderPath: "/m", + }); err != nil { + t.Fatalf("save config: %v", err) + } + + alice := seedUser(t, h.pool, "alice", "pw", false) + admin := seedUser(t, h.pool, "admin", "pw", true) + + rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-override", "Override Me") + + body := []byte(`{"quality_profile_id":5,"root_folder_path":"/other"}`) + w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", body, admin) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + + // Verify stub received the override values. + var stubPayload map[string]any + if err := json.Unmarshal(capturedBody, &stubPayload); err != nil { + t.Fatalf("decode stub payload: %v; body = %s", err, capturedBody) + } + if qp, _ := stubPayload["qualityProfileId"].(float64); int(qp) != 5 { + t.Errorf("stub qualityProfileId = %v, want 5", stubPayload["qualityProfileId"]) + } + if rf, _ := stubPayload["rootFolderPath"].(string); rf != "/other" { + t.Errorf("stub rootFolderPath = %q, want /other", rf) + } + + // Verify the returned row reflects the overrides. + var got requestView + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode response: %v; body = %s", err, w.Body.String()) + } + if got.QualityProfileID == nil || *got.QualityProfileID != 5 { + t.Errorf("quality_profile_id = %v, want 5", got.QualityProfileID) + } + if got.RootFolderPath == nil || *got.RootFolderPath != "/other" { + t.Errorf("root_folder_path = %v, want /other", got.RootFolderPath) + } +} + +// TestHandleApproveRequest_LidarrUnreachable_503 points config at an unreachable +// port and verifies POST /approve → 503 lidarr_unreachable, row stays pending. +func TestHandleApproveRequest_LidarrUnreachable_503(t *testing.T) { + h, _ := testHandlersWithClientFn(t) + resetLidarrState(t, h) + + saveLidarrConfig(t, h, "http://127.0.0.1:1", true) + + alice := seedUser(t, h.pool, "alice", "pw", false) + admin := seedUser(t, h.pool, "admin", "pw", true) + + rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-unreachable", "Unreachable") + + w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", nil, admin) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503; body = %s", w.Code, w.Body.String()) + } + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if resp["error"] != "lidarr_unreachable" { + t.Errorf("error = %q, want lidarr_unreachable", resp["error"]) + } + + // Row must still be pending. + rows, err := h.lidarrRequests.ListByStatus(context.Background(), "pending", 10) + if err != nil { + t.Fatalf("list pending: %v", err) + } + found := false + for _, r := range rows { + if r.ID == rv.ID { + found = true + break + } + } + if !found { + t.Error("row is no longer pending after failed approve") + } +} + +// TestHandleApproveRequest_NotPending_409 pre-rejects a row and verifies +// POST /approve → 409 request_not_pending. +func TestHandleApproveRequest_NotPending_409(t *testing.T) { + h, _ := testHandlersWithClientFn(t) + resetLidarrState(t, h) + + // Need an enabled Lidarr config so Approve reaches the not-pending check. + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":1}`)) + })) + t.Cleanup(stub.Close) + saveLidarrConfig(t, h, stub.URL, true) + + alice := seedUser(t, h.pool, "alice", "pw", false) + admin := seedUser(t, h.pool, "admin", "pw", true) + + rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-notpending", "Not Pending") + + // Pre-reject the row. + _, err := h.lidarrRequests.Reject(context.Background(), rv.ID, admin.ID, "pre-rejected") + if err != nil { + t.Fatalf("pre-reject: %v", err) + } + + w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", nil, admin) + if w.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409; body = %s", w.Code, w.Body.String()) + } + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if resp["error"] != "request_not_pending" { + t.Errorf("error = %q, want request_not_pending", resp["error"]) + } +} + +// TestHandleRejectRequest_HappyPath verifies POST /reject with notes returns +// 200 and the row transitions to rejected with notes saved. +func TestHandleRejectRequest_HappyPath(t *testing.T) { + h, _ := testHandlersWithClientFn(t) + resetLidarrState(t, h) + + alice := seedUser(t, h.pool, "alice", "pw", false) + admin := seedUser(t, h.pool, "admin", "pw", true) + + rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-reject", "Reject Me") + + body := []byte(`{"notes":"low-quality release; will look for the remaster"}`) + w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/reject", body, admin) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + + var got requestView + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if got.Status != "rejected" { + t.Errorf("status = %q, want rejected", got.Status) + } + if got.Notes == nil || *got.Notes != "low-quality release; will look for the remaster" { + t.Errorf("notes = %v, want expected string", got.Notes) + } +} + +// TestHandleRejectRequest_NoNotes_OK verifies POST /reject with empty body +// returns 200 (notes are optional). +func TestHandleRejectRequest_NoNotes_OK(t *testing.T) { + h, _ := testHandlersWithClientFn(t) + resetLidarrState(t, h) + + alice := seedUser(t, h.pool, "alice", "pw", false) + admin := seedUser(t, h.pool, "admin", "pw", true) + + rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-reject-nonotes", "Reject No Notes") + + w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/reject", nil, admin) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + + var got requestView + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if got.Status != "rejected" { + t.Errorf("status = %q, want rejected", got.Status) + } +} + +// TestAdminRequestsEndpoints_NonAdmin403 is a table-driven test verifying that +// all 3 admin request endpoints reject non-admin users with 403. +func TestAdminRequestsEndpoints_NonAdmin403(t *testing.T) { + h, _ := testHandlersWithClientFn(t) + resetLidarrState(t, h) + + nonAdmin := seedUser(t, h.pool, "regular", "pw", false) + + // Use a placeholder UUID for the ID endpoints. + fakeID := "00000000-0000-0000-0000-000000000001" + + cases := []struct { + method string + path string + body []byte + }{ + {http.MethodGet, "/api/admin/requests", nil}, + {http.MethodPost, "/api/admin/requests/" + fakeID + "/approve", nil}, + {http.MethodPost, "/api/admin/requests/" + fakeID + "/reject", nil}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + w := doAdminRequestReq(t, h, tc.method, tc.path, tc.body, nonAdmin) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String()) + } + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if resp["error"] != "not_authorized" { + t.Errorf("error = %q, want not_authorized", resp["error"]) + } + }) + } +} diff --git a/internal/api/api.go b/internal/api/api.go index 9fa9bd93..e654fa2f 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -68,6 +68,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Post("/lidarr/test", h.handleTestLidarrConnection) admin.Get("/lidarr/quality-profiles", h.handleListQualityProfiles) admin.Get("/lidarr/root-folders", h.handleListRootFolders) + admin.Get("/requests", h.handleListAdminRequests) + admin.Post("/requests/{id}/approve", h.handleApproveRequest) + admin.Post("/requests/{id}/reject", h.handleRejectRequest) }) }) }) From 1bde1787b910b917eb17e8beded4ef4f37663bf7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 17:57:42 -0400 Subject: [PATCH 16/67] feat(cmd): start Lidarr reconciler worker alongside HTTP server Co-Authored-By: Claude Sonnet 4.6 --- cmd/minstrel/main.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index 9b000abe..e35670e8 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -15,6 +15,8 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/config" "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/scrobble" "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" @@ -88,6 +90,13 @@ func run() error { similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity")) go similarityWorker.Run(ctx) + // Start the Lidarr reconciler worker. Per spec §M5a, polls pending Lidarr + // import requests and reconciles them against the library. Short-circuits + // to no-op when lidarr_config.enabled = false. + lidarrCfg := lidarrconfig.New(pool) + lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, logger.With("component", "lidarr")) + go lidarrReconciler.Run(ctx) + srv := server.New(logger, pool, scanner, subsonic.Config{ AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword, }, cfg.Events, cfg.Recommendation) From d1c1853d49667591cbbc5a93e8eaa07c914fe00e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 17:58:32 -0400 Subject: [PATCH 17/67] fix(auth): silence revive unused-parameter on stub test handlers --- internal/auth/admin_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/auth/admin_test.go b/internal/auth/admin_test.go index 393738b8..70ef99a2 100644 --- a/internal/auth/admin_test.go +++ b/internal/auth/admin_test.go @@ -19,7 +19,7 @@ func injectUser(r *http.Request, u dbq.User) *http.Request { func TestRequireAdmin_AdminPasses(t *testing.T) { called := false - stub := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + stub := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { called = true w.WriteHeader(http.StatusOK) }) @@ -42,7 +42,7 @@ func TestRequireAdmin_AdminPasses(t *testing.T) { } func TestRequireAdmin_NonAdminReturns403(t *testing.T) { - stub := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + stub := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { t.Fatal("stub handler must not be called for non-admin user") }) @@ -69,7 +69,7 @@ func TestRequireAdmin_NonAdminReturns403(t *testing.T) { } func TestRequireAdmin_NoUserContextReturns500(t *testing.T) { - stub := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + stub := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { t.Fatal("stub handler must not be called when no user is in context") }) From a7090c37685e214eef15ac865ef19d05b5b2c805 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 18:48:20 -0400 Subject: [PATCH 18/67] feat(web): introduce FabledSword design system tokens + Tailwind aliases Add the canonical FS token palette (surfaces, text, action, semantic, accent, radii, fonts) as CSS custom properties under web/src/lib/styles/ fabledsword-tokens.css, with a [data-fs-app="minstrel"] hook reserved for future per-app accent overrides. Wire the tokens through Tailwind by aliasing semantic colour utilities (bg-background, bg-surface, bg-surface-hover, text-text-primary/secondary/muted, border-border, bg-action-primary/secondary/destructive, accent, warning/error/info) to the new variables, plus rounded-sm..xl and font-display/sans/mono. Load Fraunces, Inter, and JetBrains Mono (400/500 only) via Google Fonts and default the body to Inter. Existing components inherit the new palette without per-page changes. Co-Authored-By: Claude Opus 4.7 --- web/src/app.css | 6 +++ web/src/app.html | 8 +++- web/src/lib/styles/fabledsword-tokens.css | 52 +++++++++++++++++++++++ web/tailwind.config.js | 36 +++++++++++++--- 4 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 web/src/lib/styles/fabledsword-tokens.css diff --git a/web/src/app.css b/web/src/app.css index 38870e67..7f0649f2 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -1,3 +1,5 @@ +@import './lib/styles/fabledsword-tokens.css'; + @tailwind base; @tailwind components; @tailwind utilities; @@ -11,3 +13,7 @@ body { height: 100%; margin: 0; } + +body { + font-family: var(--fs-font-body); +} diff --git a/web/src/app.html b/web/src/app.html index cc07c859..44ad21cf 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -5,9 +5,15 @@ Minstrel + + + %sveltekit.head% - +
%sveltekit.body%
diff --git a/web/src/lib/styles/fabledsword-tokens.css b/web/src/lib/styles/fabledsword-tokens.css new file mode 100644 index 00000000..ab166374 --- /dev/null +++ b/web/src/lib/styles/fabledsword-tokens.css @@ -0,0 +1,52 @@ +/* + * FabledSword design system tokens + * + * Shared palette across FabledSword apps (Minstrel, Scribe, Forge, ...). + * Per-app accent colour is overridable via the `[data-fs-app=""]` + * attribute hook. The `:root` default is forest-teal for Minstrel, the + * only consumer of this file today. + */ + +:root { + /* Surfaces */ + --fs-obsidian: #14171A; + --fs-iron: #1E2228; + --fs-slate: #2C313A; + --fs-pewter: #3F4651; + + /* Text */ + --fs-parchment: #E8E4D8; + --fs-vellum: #C2BFB4; + --fs-ash: #9C9A92; + + /* Action */ + --fs-moss: #4A5D3F; + --fs-bronze: #8B7355; + --fs-oxblood: #6B2118; + + /* Semantic */ + --fs-warning: #8B6F1E; + --fs-error: #C04A1F; + --fs-info: #3D5A6E; + + /* Accent (per-app; default = Minstrel forest-teal) */ + --fs-accent: #4A6B5C; + + /* Radii */ + --fs-radius-sm: 4px; + --fs-radius-md: 8px; + --fs-radius-lg: 12px; + --fs-radius-xl: 16px; + + /* Fonts */ + --fs-font-display: 'Fraunces', Georgia, serif; + --fs-font-body: 'Inter', system-ui, sans-serif; + --fs-font-mono: 'JetBrains Mono', ui-monospace, monospace; +} + +/* Per-app accent overrides. Set `data-fs-app="minstrel"` on or any + * ancestor to scope; the :root default already matches Minstrel, so this + * block is documentation / future override-point for other FS apps. */ +[data-fs-app="minstrel"] { + --fs-accent: #4A6B5C; +} diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 0b37e59d..cbdfe7f4 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -4,15 +4,39 @@ export default { theme: { extend: { colors: { + background: 'var(--fs-obsidian)', surface: { - 900: '#14161a', - 800: '#1a1d22', - 700: '#2a2f36' + DEFAULT: 'var(--fs-iron)', + hover: 'var(--fs-slate)' + }, + border: { + DEFAULT: 'var(--fs-pewter)' }, text: { - primary: '#e8ecf2', - secondary: '#cdd3db' - } + primary: 'var(--fs-parchment)', + secondary: 'var(--fs-vellum)', + muted: 'var(--fs-ash)' + }, + action: { + primary: 'var(--fs-moss)', + secondary: 'var(--fs-bronze)', + destructive: 'var(--fs-oxblood)' + }, + accent: 'var(--fs-accent)', + warning: 'var(--fs-warning)', + error: 'var(--fs-error)', + info: 'var(--fs-info)' + }, + borderRadius: { + sm: 'var(--fs-radius-sm)', + md: 'var(--fs-radius-md)', + lg: 'var(--fs-radius-lg)', + xl: 'var(--fs-radius-xl)' + }, + fontFamily: { + display: ['Fraunces', 'Georgia', 'serif'], + sans: ['Inter', 'system-ui', 'sans-serif'], + mono: ['JetBrains Mono', 'ui-monospace', 'monospace'] } } }, From 29968ae8da1773102861230346d99e74f2f49622 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 19:42:53 -0400 Subject: [PATCH 19/67] feat(web): wire Tailwind fontFamily to FS font tokens Removes duplication between fabledsword-tokens.css and tailwind.config.js; font stacks now live in one place so per-app overrides reach Tailwind utilities. --- web/tailwind.config.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/tailwind.config.js b/web/tailwind.config.js index cbdfe7f4..8833cac6 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -34,9 +34,9 @@ export default { xl: 'var(--fs-radius-xl)' }, fontFamily: { - display: ['Fraunces', 'Georgia', 'serif'], - sans: ['Inter', 'system-ui', 'sans-serif'], - mono: ['JetBrains Mono', 'ui-monospace', 'monospace'] + display: ['var(--fs-font-display)'], + sans: ['var(--fs-font-body)'], + mono: ['var(--fs-font-mono)'] } } }, From d7eaa189e25d1200cb9ba6632fdd81e269538d85 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 19:53:04 -0400 Subject: [PATCH 20/67] feat(web): add API client modules for Lidarr, requests, admin T13 of the M5a Lidarr plan. Three new client modules wrap api.get/post/put/del helpers and the existing TanStack Query qk namespace: lidarr.ts - searchLidarr() + createLidarrSearchQuery() requests.ts - createRequest, listMyRequests, getRequest, cancelRequest; createMyRequestsQuery(); cancel goes through apiFetch directly because the backend returns the cancelled row body (api.del's return type is fixed to null). admin.ts - getLidarrConfig, putLidarrConfig, testLidarrConnection, listQualityProfiles, listRootFolders, listAdminRequests, approveRequest, rejectRequest; query factories for each read; quality profiles + root folders take an enabled prop so the call site decides when Lidarr is configured. Shared LidarrRequestStatus / LidarrRequestKind enums and request/config/ search-result shapes added to types.ts. Per-module helpers (CreateRequestParams) stay in their module files. testLidarrConnection returns a discriminated union ({ok:true,version} | {ok:false,error}) and never throws on ok:false so the SPA can render either branch. qk extended with lidarrSearch, myRequests, lidarrConfig, lidarrQualityProfiles, lidarrRootFolders, adminRequests. Tests mirror likes.test.ts (vi.mock('./client')) and cover URL construction, query-param encoding, body shapes, the not-ok testLidarrConnection branch, and qk additions. 32 new tests, 217 total passing. Co-Authored-By: Claude Opus 4.7 --- web/src/lib/api/admin.test.ts | 227 +++++++++++++++++++++++++++++++ web/src/lib/api/admin.ts | 104 ++++++++++++++ web/src/lib/api/lidarr.test.ts | 73 ++++++++++ web/src/lib/api/lidarr.ts | 28 ++++ web/src/lib/api/queries.ts | 9 ++ web/src/lib/api/requests.test.ts | 155 +++++++++++++++++++++ web/src/lib/api/requests.ts | 67 +++++++++ web/src/lib/api/types.ts | 76 +++++++++++ 8 files changed, 739 insertions(+) create mode 100644 web/src/lib/api/admin.test.ts create mode 100644 web/src/lib/api/admin.ts create mode 100644 web/src/lib/api/lidarr.test.ts create mode 100644 web/src/lib/api/lidarr.ts create mode 100644 web/src/lib/api/requests.test.ts create mode 100644 web/src/lib/api/requests.ts diff --git a/web/src/lib/api/admin.test.ts b/web/src/lib/api/admin.test.ts new file mode 100644 index 00000000..90e76954 --- /dev/null +++ b/web/src/lib/api/admin.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; + +vi.mock('./client', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + del: vi.fn() + } +})); + +import { + getLidarrConfig, + putLidarrConfig, + testLidarrConnection, + listQualityProfiles, + listRootFolders, + listAdminRequests, + approveRequest, + rejectRequest +} from './admin'; +import { api } from './client'; +import { qk } from './queries'; +import type { + LidarrConfig, + LidarrQualityProfile, + LidarrRequest, + LidarrRootFolder +} from './types'; + +const baseConfig: LidarrConfig = { + enabled: true, + base_url: 'http://lidarr.lan:8686', + api_key: '***', + default_quality_profile_id: 1, + default_root_folder_path: '/music' +}; + +const baseRow: LidarrRequest = { + id: 'r1', + user_id: 'u1', + status: 'pending', + kind: 'album', + lidarr_artist_mbid: 'art-mbid', + lidarr_album_mbid: 'alb-mbid', + lidarr_track_mbid: null, + artist_name: 'Aphex Twin', + album_title: 'Drukqs', + track_title: null, + quality_profile_id: null, + root_folder_path: null, + decided_at: null, + decided_by: null, + notes: null, + completed_at: null, + matched_track_id: null, + matched_album_id: null, + matched_artist_id: null, + requested_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('getLidarrConfig', () => { + test('GETs /api/admin/lidarr/config', async () => { + (api.get as ReturnType).mockResolvedValueOnce(baseConfig); + const out = await getLidarrConfig(); + expect(api.get).toHaveBeenCalledWith('/api/admin/lidarr/config'); + expect(out).toBe(baseConfig); + }); +}); + +describe('putLidarrConfig', () => { + test('PUTs /api/admin/lidarr/config with the full config body', async () => { + const next: LidarrConfig = { ...baseConfig, api_key: 'plain-key' }; + (api.put as ReturnType).mockResolvedValueOnce(next); + const out = await putLidarrConfig(next); + expect(api.put).toHaveBeenCalledWith('/api/admin/lidarr/config', next); + expect(out).toBe(next); + }); +}); + +describe('testLidarrConnection', () => { + test('POSTs /api/admin/lidarr/test with empty body when no overrides', async () => { + (api.post as ReturnType).mockResolvedValueOnce({ + ok: true, + version: '2.0.0' + }); + const out = await testLidarrConnection(); + expect(api.post).toHaveBeenCalledWith('/api/admin/lidarr/test', {}); + expect(out).toEqual({ ok: true, version: '2.0.0' }); + }); + + test('forwards override base_url + api_key body', async () => { + (api.post as ReturnType).mockResolvedValueOnce({ + ok: true, + version: '2.0.0' + }); + await testLidarrConnection({ + base_url: 'http://other:8686', + api_key: 'try-this' + }); + expect(api.post).toHaveBeenCalledWith('/api/admin/lidarr/test', { + base_url: 'http://other:8686', + api_key: 'try-this' + }); + }); + + test('returns ok:false branch without throwing', async () => { + (api.post as ReturnType).mockResolvedValueOnce({ + ok: false, + error: 'auth failed' + }); + const out = await testLidarrConnection(); + expect(out).toEqual({ ok: false, error: 'auth failed' }); + }); +}); + +describe('listQualityProfiles', () => { + test('GETs /api/admin/lidarr/quality-profiles', async () => { + const profiles: LidarrQualityProfile[] = [{ id: 1, name: 'Lossless' }]; + (api.get as ReturnType).mockResolvedValueOnce(profiles); + const out = await listQualityProfiles(); + expect(api.get).toHaveBeenCalledWith('/api/admin/lidarr/quality-profiles'); + expect(out).toBe(profiles); + }); +}); + +describe('listRootFolders', () => { + test('GETs /api/admin/lidarr/root-folders', async () => { + const folders: LidarrRootFolder[] = [ + { path: '/music', accessible: true, free_space: 100 } + ]; + (api.get as ReturnType).mockResolvedValueOnce(folders); + const out = await listRootFolders(); + expect(api.get).toHaveBeenCalledWith('/api/admin/lidarr/root-folders'); + expect(out).toBe(folders); + }); +}); + +describe('listAdminRequests', () => { + test('no args -> /api/admin/requests with no query string', async () => { + (api.get as ReturnType).mockResolvedValueOnce([baseRow]); + await listAdminRequests(); + expect(api.get).toHaveBeenCalledWith('/api/admin/requests'); + }); + + test('status only -> ?status=', async () => { + (api.get as ReturnType).mockResolvedValueOnce([baseRow]); + await listAdminRequests('approved'); + expect(api.get).toHaveBeenCalledWith('/api/admin/requests?status=approved'); + }); + + test('status + limit -> ?status=&limit=', async () => { + (api.get as ReturnType).mockResolvedValueOnce([baseRow]); + await listAdminRequests('pending', 25); + expect(api.get).toHaveBeenCalledWith( + '/api/admin/requests?status=pending&limit=25' + ); + }); + + test('limit only -> ?limit=', async () => { + (api.get as ReturnType).mockResolvedValueOnce([baseRow]); + await listAdminRequests(undefined, 10); + expect(api.get).toHaveBeenCalledWith('/api/admin/requests?limit=10'); + }); +}); + +describe('approveRequest', () => { + test('POSTs to /api/admin/requests/:id/approve with empty body by default', async () => { + (api.post as ReturnType).mockResolvedValueOnce(baseRow); + await approveRequest('r1'); + expect(api.post).toHaveBeenCalledWith('/api/admin/requests/r1/approve', {}); + }); + + test('forwards quality_profile_id + root_folder_path overrides', async () => { + (api.post as ReturnType).mockResolvedValueOnce(baseRow); + await approveRequest('r1', { + quality_profile_id: 2, + root_folder_path: '/music' + }); + expect(api.post).toHaveBeenCalledWith('/api/admin/requests/r1/approve', { + quality_profile_id: 2, + root_folder_path: '/music' + }); + }); +}); + +describe('rejectRequest', () => { + test('POSTs to /api/admin/requests/:id/reject with empty body when no notes', async () => { + (api.post as ReturnType).mockResolvedValueOnce(baseRow); + await rejectRequest('r1'); + expect(api.post).toHaveBeenCalledWith('/api/admin/requests/r1/reject', {}); + }); + + test('includes notes when provided', async () => { + (api.post as ReturnType).mockResolvedValueOnce(baseRow); + await rejectRequest('r1', 'duplicate of r0'); + expect(api.post).toHaveBeenCalledWith('/api/admin/requests/r1/reject', { + notes: 'duplicate of r0' + }); + }); +}); + +describe('qk admin keys', () => { + test('lidarrConfig key', () => { + expect(qk.lidarrConfig()).toEqual(['lidarrConfig']); + }); + test('lidarrQualityProfiles key', () => { + expect(qk.lidarrQualityProfiles()).toEqual(['lidarrQualityProfiles']); + }); + test('lidarrRootFolders key', () => { + expect(qk.lidarrRootFolders()).toEqual(['lidarrRootFolders']); + }); + test('adminRequests key defaults to pending when status omitted', () => { + expect(qk.adminRequests()).toEqual(['adminRequests', { status: 'pending' }]); + }); + test('adminRequests key includes the status filter', () => { + expect(qk.adminRequests('approved')).toEqual([ + 'adminRequests', + { status: 'approved' } + ]); + }); +}); diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts new file mode 100644 index 00000000..bcf228f0 --- /dev/null +++ b/web/src/lib/api/admin.ts @@ -0,0 +1,104 @@ +import { createQuery } from '@tanstack/svelte-query'; +import { api } from './client'; +import { qk } from './queries'; +import type { + LidarrConfig, + LidarrQualityProfile, + LidarrRequest, + LidarrRequestStatus, + LidarrRootFolder, + LidarrTestResult +} from './types'; + +// Admin Lidarr config ----------------------------------------------------- + +export async function getLidarrConfig(): Promise { + return api.get('/api/admin/lidarr/config'); +} + +export async function putLidarrConfig(cfg: LidarrConfig): Promise { + return api.put('/api/admin/lidarr/config', cfg); +} + +// testLidarrConnection always returns 200 with a discriminated-union body — +// callers branch on `result.ok`. We do NOT throw on `ok:false`; the SPA wants +// to render either branch (e.g. "connected to Lidarr X.Y.Z" vs "auth failed"). +export async function testLidarrConnection( + body: { base_url?: string; api_key?: string } = {} +): Promise { + return api.post('/api/admin/lidarr/test', body); +} + +export async function listQualityProfiles(): Promise { + return api.get('/api/admin/lidarr/quality-profiles'); +} + +export async function listRootFolders(): Promise { + return api.get('/api/admin/lidarr/root-folders'); +} + +// Admin request queue ----------------------------------------------------- + +export async function listAdminRequests( + status?: LidarrRequestStatus, + limit?: number +): Promise { + const params = new URLSearchParams(); + if (status) params.set('status', status); + if (limit !== undefined) params.set('limit', String(limit)); + const qs = params.toString(); + return api.get( + qs ? `/api/admin/requests?${qs}` : '/api/admin/requests' + ); +} + +export async function approveRequest( + id: string, + overrides: { quality_profile_id?: number; root_folder_path?: string } = {} +): Promise { + return api.post(`/api/admin/requests/${id}/approve`, overrides); +} + +export async function rejectRequest( + id: string, + notes?: string +): Promise { + const body = notes !== undefined ? { notes } : {}; + return api.post(`/api/admin/requests/${id}/reject`, body); +} + +// Query factories --------------------------------------------------------- + +export function createLidarrConfigQuery() { + return createQuery({ + queryKey: qk.lidarrConfig(), + queryFn: getLidarrConfig + }); +} + +// `enabled` is passed in by the caller — typically derived from +// LidarrConfig.enabled — so the query only fires once Lidarr is configured. +// Keeping it as a prop (vs. reading config inside this factory) preserves +// purity and lets the caller choose its own gating logic. +export function createQualityProfilesQuery(enabled: boolean = true) { + return createQuery({ + queryKey: qk.lidarrQualityProfiles(), + queryFn: listQualityProfiles, + enabled + }); +} + +export function createRootFoldersQuery(enabled: boolean = true) { + return createQuery({ + queryKey: qk.lidarrRootFolders(), + queryFn: listRootFolders, + enabled + }); +} + +export function createAdminRequestsQuery(status?: LidarrRequestStatus) { + return createQuery({ + queryKey: qk.adminRequests(status), + queryFn: () => listAdminRequests(status) + }); +} diff --git a/web/src/lib/api/lidarr.test.ts b/web/src/lib/api/lidarr.test.ts new file mode 100644 index 00000000..31e26c90 --- /dev/null +++ b/web/src/lib/api/lidarr.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; + +vi.mock('./client', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + del: vi.fn() + } +})); + +import { searchLidarr } from './lidarr'; +import { api } from './client'; +import { qk } from './queries'; +import type { LidarrSearchResult } from './types'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('searchLidarr', () => { + test('builds URL with q and kind query params', async () => { + (api.get as ReturnType).mockResolvedValueOnce([]); + await searchLidarr('aphex twin', 'artist'); + expect(api.get).toHaveBeenCalledWith('/api/lidarr/search?q=aphex+twin&kind=artist'); + }); + + test('encodes special characters in q (quotes, ampersands, plus)', async () => { + (api.get as ReturnType).mockResolvedValueOnce([]); + await searchLidarr('boards of canada & friends', 'album'); + const calledWith = (api.get as ReturnType).mock.calls[0][0] as string; + // URLSearchParams encodes space as '+' and '&' as '%26'. + expect(calledWith).toBe( + '/api/lidarr/search?q=boards+of+canada+%26+friends&kind=album' + ); + }); + + test('passes through results unchanged', async () => { + const fixture: LidarrSearchResult[] = [ + { + mbid: 'mbid-1', + name: 'Aphex Twin', + secondary_text: 'Electronic', + image_url: 'https://example.test/img.jpg', + artist_mbid: '', + album_mbid: '', + in_library: false, + requested: false + } + ]; + (api.get as ReturnType).mockResolvedValueOnce(fixture); + const out = await searchLidarr('aphex', 'artist'); + expect(out).toBe(fixture); + }); + + test('propagates errors from api.get', async () => { + const err = { code: 'lidarr_unavailable', message: 'down', status: 503 }; + (api.get as ReturnType).mockRejectedValueOnce(err); + await expect(searchLidarr('x', 'track')).rejects.toMatchObject({ + code: 'lidarr_unavailable', + status: 503 + }); + }); +}); + +describe('qk.lidarrSearch', () => { + test('keys include q + kind', () => { + expect(qk.lidarrSearch('foo', 'artist')).toEqual([ + 'lidarrSearch', + { q: 'foo', kind: 'artist' } + ]); + }); +}); diff --git a/web/src/lib/api/lidarr.ts b/web/src/lib/api/lidarr.ts new file mode 100644 index 00000000..e3ddd332 --- /dev/null +++ b/web/src/lib/api/lidarr.ts @@ -0,0 +1,28 @@ +import { createQuery } from '@tanstack/svelte-query'; +import { api } from './client'; +import { qk } from './queries'; +import type { LidarrSearchResult, LidarrRequestKind } from './types'; + +// Search-only. Admin-side Lidarr config + connection probing lives in admin.ts. + +// searchLidarr proxies the user-facing Lidarr search endpoint. URLSearchParams +// handles encoding (spaces, quotes, ampersands) so callers can pass q raw. +export async function searchLidarr( + q: string, + kind: LidarrRequestKind +): Promise { + const params = new URLSearchParams({ q, kind }); + return api.get(`/api/lidarr/search?${params.toString()}`); +} + +// staleTime mirrors the spec §11 60s server-side LRU cache: re-issuing the +// same query within a minute hits cache on the server, so there's no point +// in marking it stale client-side any sooner. +export function createLidarrSearchQuery(q: string, kind: LidarrRequestKind) { + return createQuery({ + queryKey: qk.lidarrSearch(q, kind), + queryFn: () => searchLidarr(q, kind), + enabled: q.length > 0, + staleTime: 60_000 + }); +} diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 11c8b2a4..973bd51a 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -19,6 +19,15 @@ export const qk = { likedTracks: () => ['likedTracks'] as const, likedAlbums: () => ['likedAlbums'] as const, likedArtists: () => ['likedArtists'] as const, + // Lidarr / requests / admin. + lidarrSearch: (q: string, kind: string) => + ['lidarrSearch', { q, kind }] as const, + myRequests: () => ['myRequests'] as const, + lidarrConfig: () => ['lidarrConfig'] as const, + lidarrQualityProfiles: () => ['lidarrQualityProfiles'] as const, + lidarrRootFolders: () => ['lidarrRootFolders'] as const, + adminRequests: (status?: string) => + ['adminRequests', { status: status ?? 'pending' }] as const, }; export function createArtistsQuery(sort: ArtistSort) { diff --git a/web/src/lib/api/requests.test.ts b/web/src/lib/api/requests.test.ts new file mode 100644 index 00000000..b63fb5c9 --- /dev/null +++ b/web/src/lib/api/requests.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('./client', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + del: vi.fn() + }, + apiFetch: vi.fn() +})); + +import { + createRequest, + listMyRequests, + getRequest, + cancelRequest +} from './requests'; +import { api, apiFetch } from './client'; +import { qk } from './queries'; +import type { LidarrRequest } from './types'; + +const mockRow: LidarrRequest = { + id: 'r1', + user_id: 'u1', + status: 'pending', + kind: 'album', + lidarr_artist_mbid: 'art-mbid', + lidarr_album_mbid: 'alb-mbid', + lidarr_track_mbid: null, + artist_name: 'Aphex Twin', + album_title: 'Selected Ambient Works', + track_title: null, + quality_profile_id: null, + root_folder_path: null, + decided_at: null, + decided_by: null, + notes: null, + completed_at: null, + matched_track_id: null, + matched_album_id: null, + matched_artist_id: null, + requested_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('createRequest', () => { + test('POSTs /api/requests with full body for an album request', async () => { + (api.post as ReturnType).mockResolvedValueOnce(mockRow); + const out = await createRequest({ + kind: 'album', + lidarr_artist_mbid: 'art-mbid', + lidarr_album_mbid: 'alb-mbid', + artist_name: 'Aphex Twin', + album_title: 'Selected Ambient Works' + }); + expect(api.post).toHaveBeenCalledWith('/api/requests', { + kind: 'album', + lidarr_artist_mbid: 'art-mbid', + artist_name: 'Aphex Twin', + lidarr_album_mbid: 'alb-mbid', + album_title: 'Selected Ambient Works' + }); + expect(out).toBe(mockRow); + }); + + test('omits empty / undefined optional MBID + title fields from wire body', async () => { + (api.post as ReturnType).mockResolvedValueOnce(mockRow); + await createRequest({ + kind: 'artist', + lidarr_artist_mbid: 'art-mbid', + artist_name: 'Aphex Twin', + lidarr_album_mbid: '', + lidarr_track_mbid: '' + }); + const body = (api.post as ReturnType).mock.calls[0][1] as Record< + string, + unknown + >; + expect(body).toEqual({ + kind: 'artist', + lidarr_artist_mbid: 'art-mbid', + artist_name: 'Aphex Twin' + }); + expect(body).not.toHaveProperty('lidarr_album_mbid'); + expect(body).not.toHaveProperty('lidarr_track_mbid'); + expect(body).not.toHaveProperty('album_title'); + expect(body).not.toHaveProperty('track_title'); + }); + + test('includes track fields for a track-kind request', async () => { + (api.post as ReturnType).mockResolvedValueOnce(mockRow); + await createRequest({ + kind: 'track', + lidarr_artist_mbid: 'art-mbid', + lidarr_album_mbid: 'alb-mbid', + lidarr_track_mbid: 'trk-mbid', + artist_name: 'Aphex Twin', + album_title: 'Drukqs', + track_title: 'Avril 14th' + }); + expect(api.post).toHaveBeenCalledWith('/api/requests', { + kind: 'track', + lidarr_artist_mbid: 'art-mbid', + artist_name: 'Aphex Twin', + lidarr_album_mbid: 'alb-mbid', + lidarr_track_mbid: 'trk-mbid', + album_title: 'Drukqs', + track_title: 'Avril 14th' + }); + }); +}); + +describe('listMyRequests', () => { + test('GETs /api/requests', async () => { + (api.get as ReturnType).mockResolvedValueOnce([mockRow]); + const out = await listMyRequests(); + expect(api.get).toHaveBeenCalledWith('/api/requests'); + expect(out).toEqual([mockRow]); + }); +}); + +describe('getRequest', () => { + test('GETs /api/requests/:id', async () => { + (api.get as ReturnType).mockResolvedValueOnce(mockRow); + const out = await getRequest('r1'); + expect(api.get).toHaveBeenCalledWith('/api/requests/r1'); + expect(out).toBe(mockRow); + }); +}); + +describe('cancelRequest', () => { + test('DELETEs /api/requests/:id and returns the cancelled row', async () => { + const cancelled = { ...mockRow, status: 'rejected' as const }; + (apiFetch as ReturnType).mockResolvedValueOnce(cancelled); + const out = await cancelRequest('r1'); + expect(apiFetch).toHaveBeenCalledWith('/api/requests/r1', { method: 'DELETE' }); + expect(out).toBe(cancelled); + expect(out.status).toBe('rejected'); + }); +}); + +describe('qk.myRequests', () => { + test('returns the expected key tuple', () => { + expect(qk.myRequests()).toEqual(['myRequests']); + }); +}); diff --git a/web/src/lib/api/requests.ts b/web/src/lib/api/requests.ts new file mode 100644 index 00000000..ad01ecd5 --- /dev/null +++ b/web/src/lib/api/requests.ts @@ -0,0 +1,67 @@ +import { createQuery } from '@tanstack/svelte-query'; +import { api, apiFetch } from './client'; +import { qk } from './queries'; +import type { LidarrRequest, LidarrRequestKind } from './types'; + +// CreateRequestParams is the shape callers pass; unused MBID fields are +// optional, and we omit them from the wire body rather than sending empty +// strings (the backend tolerates either, but this keeps test expectations +// crisp and matches the spec §5 wire shape). +export type CreateRequestParams = { + kind: LidarrRequestKind; + lidarr_artist_mbid: string; + lidarr_album_mbid?: string; + lidarr_track_mbid?: string; + artist_name: string; + album_title?: string; + track_title?: string; +}; + +type CreateRequestBody = { + kind: LidarrRequestKind; + lidarr_artist_mbid: string; + artist_name: string; + lidarr_album_mbid?: string; + lidarr_track_mbid?: string; + album_title?: string; + track_title?: string; +}; + +function buildCreateBody(params: CreateRequestParams): CreateRequestBody { + const body: CreateRequestBody = { + kind: params.kind, + lidarr_artist_mbid: params.lidarr_artist_mbid, + artist_name: params.artist_name + }; + if (params.lidarr_album_mbid) body.lidarr_album_mbid = params.lidarr_album_mbid; + if (params.lidarr_track_mbid) body.lidarr_track_mbid = params.lidarr_track_mbid; + if (params.album_title) body.album_title = params.album_title; + if (params.track_title) body.track_title = params.track_title; + return body; +} + +export async function createRequest(params: CreateRequestParams): Promise { + return api.post('/api/requests', buildCreateBody(params)); +} + +export async function listMyRequests(): Promise { + return api.get('/api/requests'); +} + +export async function getRequest(id: string): Promise { + return api.get(`/api/requests/${id}`); +} + +// Server returns the cancelled row body (not 204) so callers can patch the +// cache without a refetch. api.del's return type is fixed to null, so we +// drop down to apiFetch here to keep typing honest. +export async function cancelRequest(id: string): Promise { + return apiFetch(`/api/requests/${id}`, { method: 'DELETE' }) as Promise; +} + +export function createMyRequestsQuery() { + return createQuery({ + queryKey: qk.myRequests(), + queryFn: listMyRequests + }); +} diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 2d666faa..0eb5ed43 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -70,3 +70,79 @@ export type EventRequest = export type PlayStartedResponse = { play_event_id: string; session_id: string }; export type EventOkResponse = { ok: true }; + +// Lidarr / requests shared enums + shapes. +// Status + Kind are used across lidarr.ts, requests.ts, and admin.ts so they +// live here. Per-module helpers (CreateRequestParams, etc.) stay in their +// respective modules. +export type LidarrRequestStatus = + | 'pending' + | 'approved' + | 'rejected' + | 'completed' + | 'failed'; + +export type LidarrRequestKind = 'artist' | 'album' | 'track'; + +export type LidarrSearchResult = { + mbid: string; + name: string; + secondary_text: string; + image_url: string; + artist_mbid: string; + album_mbid: string; + in_library: boolean; + requested: boolean; +}; + +// pgtype.UUID and pgtype.Timestamptz JSON-marshal as a native string when +// Valid, and as `null` (or omitted, with omitempty) otherwise. We unify both +// "absent" cases as `string | null` so callers branch on a single check. +export type LidarrRequest = { + id: string; + user_id: string; + status: LidarrRequestStatus; + kind: LidarrRequestKind; + lidarr_artist_mbid: string; + lidarr_album_mbid?: string | null; + lidarr_track_mbid?: string | null; + artist_name: string; + album_title?: string | null; + track_title?: string | null; + quality_profile_id?: number | null; + root_folder_path?: string | null; + decided_at?: string | null; + decided_by?: string | null; + notes?: string | null; + completed_at?: string | null; + matched_track_id?: string | null; + matched_album_id?: string | null; + matched_artist_id?: string | null; + requested_at: string; + updated_at: string; +}; + +export type LidarrConfig = { + enabled: boolean; + // api_key is "" when unset, "***" when masked on GET, plain text on PUT. + base_url: string; + api_key: string; + default_quality_profile_id: number; + default_root_folder_path: string; +}; + +export type LidarrQualityProfile = { + id: number; + name: string; +}; + +export type LidarrRootFolder = { + path: string; + accessible: boolean; + free_space: number; +}; + +// testLidarrConnection always returns 200; callers branch on `.ok`. +export type LidarrTestResult = + | { ok: true; version: string } + | { ok: false; error: string }; From ce7424219c4b5817388d89ff2dc1c2cbed0a2b2b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 20:04:51 -0400 Subject: [PATCH 21/67] feat(web): adminRequests cache key + staleTime polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - qk.adminRequests defaults to status='all' when no filter is set; the unfiltered endpoint returns every status, so caching it as 'pending' was a latent mismatch. - 60s staleTime on lidarrConfig + myRequests — both are session-stable enough that refetching on every consumer-page mount produced needless flicker. --- web/src/lib/api/admin.test.ts | 7 +++++-- web/src/lib/api/admin.ts | 3 ++- web/src/lib/api/queries.ts | 2 +- web/src/lib/api/requests.ts | 3 ++- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/web/src/lib/api/admin.test.ts b/web/src/lib/api/admin.test.ts index 90e76954..5ccd6329 100644 --- a/web/src/lib/api/admin.test.ts +++ b/web/src/lib/api/admin.test.ts @@ -215,8 +215,11 @@ describe('qk admin keys', () => { test('lidarrRootFolders key', () => { expect(qk.lidarrRootFolders()).toEqual(['lidarrRootFolders']); }); - test('adminRequests key defaults to pending when status omitted', () => { - expect(qk.adminRequests()).toEqual(['adminRequests', { status: 'pending' }]); + test('adminRequests key uses "all" when status omitted', () => { + // listAdminRequests sends no status param when called without one (returns + // every status), so the cache key must distinguish that from any specific + // status — otherwise an unfiltered call would falsely cache as `pending`. + expect(qk.adminRequests()).toEqual(['adminRequests', { status: 'all' }]); }); test('adminRequests key includes the status filter', () => { expect(qk.adminRequests('approved')).toEqual([ diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index bcf228f0..9ec0fc0e 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -72,7 +72,8 @@ export async function rejectRequest( export function createLidarrConfigQuery() { return createQuery({ queryKey: qk.lidarrConfig(), - queryFn: getLidarrConfig + queryFn: getLidarrConfig, + staleTime: 60_000 }); } diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 973bd51a..3efeb35c 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -27,7 +27,7 @@ export const qk = { lidarrQualityProfiles: () => ['lidarrQualityProfiles'] as const, lidarrRootFolders: () => ['lidarrRootFolders'] as const, adminRequests: (status?: string) => - ['adminRequests', { status: status ?? 'pending' }] as const, + ['adminRequests', { status: status ?? 'all' }] as const, }; export function createArtistsQuery(sort: ArtistSort) { diff --git a/web/src/lib/api/requests.ts b/web/src/lib/api/requests.ts index ad01ecd5..0e601753 100644 --- a/web/src/lib/api/requests.ts +++ b/web/src/lib/api/requests.ts @@ -62,6 +62,7 @@ export async function cancelRequest(id: string): Promise { export function createMyRequestsQuery() { return createQuery({ queryKey: qk.myRequests(), - queryFn: listMyRequests + queryFn: listMyRequests, + staleTime: 60_000 }); } From c8bd19d6f1462d7a73ba046349405bd74bd3a561 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 20:27:58 -0400 Subject: [PATCH 22/67] feat(web): add DiscoverResultCard with reserved badge slot + anchored button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T14 of the M5a Lidarr plan. The Discover grid in M5a renders mixed requestable / kept / requested cards from Lidarr search. Without layout discipline the cards in a row land their titles at different Y coordinates whenever the badge presence varies, which reads as visual noise. DiscoverResultCard enforces: - Flex column with `margin-top: auto` on `.actions`, so the action button is anchored to the bottom of the card body regardless of title/subtitle wrap differences. - `.badge-row` always rendered with `min-height: 22px`, so the title baseline holds even when no Kept pill is present. Both load-bearing CSS values use inline style attributes — jsdom's getComputedStyle does not resolve scoped diff --git a/web/src/lib/components/DiscoverResultCard.test.ts b/web/src/lib/components/DiscoverResultCard.test.ts new file mode 100644 index 00000000..6672349c --- /dev/null +++ b/web/src/lib/components/DiscoverResultCard.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import DiscoverResultCard from './DiscoverResultCard.svelte'; + +afterEach(() => vi.clearAllMocks()); + +describe('DiscoverResultCard', () => { + test('requestable state renders Request button and calls onRequest on click', async () => { + const onRequest = vi.fn(); + render(DiscoverResultCard, { + props: { + kind: 'artist', + title: 'Boards of Canada', + state: 'requestable', + onRequest, + }, + }); + const btn = screen.getByRole('button', { name: /request/i }); + expect(btn).not.toBeDisabled(); + await fireEvent.click(btn); + expect(onRequest).toHaveBeenCalledOnce(); + }); + + test('kept state renders disabled "In library" button + Kept pill', () => { + render(DiscoverResultCard, { + props: { + kind: 'album', + title: 'Music Has The Right To Children', + state: 'kept', + }, + }); + expect(screen.getByRole('button', { name: /in library/i })).toBeDisabled(); + expect(screen.getByText(/kept/i)).toBeInTheDocument(); + }); + + test('requested state renders disabled "Requested" button', () => { + render(DiscoverResultCard, { + props: { + kind: 'track', + title: 'Roygbiv', + state: 'requested', + }, + }); + expect(screen.getByRole('button', { name: /requested/i })).toBeDisabled(); + }); + + test('does not call onRequest when state is not requestable', async () => { + const onRequest = vi.fn(); + render(DiscoverResultCard, { + props: { + kind: 'album', + title: 'Geogaddi', + state: 'kept', + onRequest, + }, + }); + // Button is disabled — the test still verifies onRequest isn't called even + // if a click slips through (jsdom does not enforce :disabled at fireEvent level). + const btn = screen.getByRole('button'); + await fireEvent.click(btn); + expect(onRequest).not.toHaveBeenCalled(); + }); + + test('badge row reserves min-height: 22px even when empty', () => { + render(DiscoverResultCard, { + props: { + kind: 'artist', + title: 'Aphex Twin', + state: 'requestable', + }, + }); + const row = screen.getByTestId('badge-row'); + const cs = getComputedStyle(row); + expect(cs.minHeight).toBe('22px'); + }); + + test('actions block is anchored to bottom (margin-top: auto)', () => { + render(DiscoverResultCard, { + props: { + kind: 'artist', + title: 'Aphex Twin', + state: 'requestable', + }, + }); + const actions = screen.getByTestId('actions'); + expect(getComputedStyle(actions).marginTop).toBe('auto'); + }); + + test('renders when imageUrl is set', () => { + const { container } = render(DiscoverResultCard, { + props: { + kind: 'artist', + title: 'X', + state: 'requestable', + imageUrl: 'https://example.com/x.jpg', + }, + }); + const img = container.querySelector('img') as HTMLImageElement; + expect(img).toBeInTheDocument(); + expect(img.src).toBe('https://example.com/x.jpg'); + }); + + test('renders fallback Lucide glyph (no ) when imageUrl is absent', () => { + const { container } = render(DiscoverResultCard, { + props: { + kind: 'album', + title: 'X', + state: 'requestable', + }, + }); + expect(container.querySelector('img')).not.toBeInTheDocument(); + // Lucide renders an inline ; verify its presence as a proxy for "fallback rendered" + expect(container.querySelector('svg')).toBeInTheDocument(); + }); +}); From dcb49e96871c28eeea762630d6855e506f9cd0a9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 21:05:27 -0400 Subject: [PATCH 23/67] fix(web): a11y on DiscoverResultCard + drop test-only inline styles aria-labels now include the card title on all three button variants so SR users navigating a grid can tell which card a button belongs to: - "Request " / " is already in library" / "<title> already requested" The Kept pill gets role="status" so SR users hear the badge when it appears (without aria-live's announce-on-mount noise). The reserved-slot CSS (.badge-row { min-height: 22px } and .actions { margin-top: auto }) was already in the scoped <style> block; we drop the duplicate inline style="" attributes that existed purely to satisfy jsdom's getComputedStyle. Tried stylesheet introspection (document.styleSheets) as a replacement assertion, but vitest's @testing-library/svelte renderer doesn't inject the scoped <style> tag into jsdom (styleSheets.length === 0), so the two CSS assertions are dropped with an explanatory comment. The layout discipline is enforced visually at the consumer page rather than as a "CSS exists in CSS" unit test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- .../lib/components/DiscoverResultCard.svelte | 13 +++--- .../lib/components/DiscoverResultCard.test.ts | 46 ++++++++----------- 2 files changed, 25 insertions(+), 34 deletions(-) diff --git a/web/src/lib/components/DiscoverResultCard.svelte b/web/src/lib/components/DiscoverResultCard.svelte index e6ae477d..345e316f 100644 --- a/web/src/lib/components/DiscoverResultCard.svelte +++ b/web/src/lib/components/DiscoverResultCard.svelte @@ -55,21 +55,18 @@ {#if subtitle} <div class="subtitle text-sm text-text-secondary">{subtitle}</div> {/if} - <div - class="badge-row" - data-testid="badge-row" - style="min-height: 22px;" - > + <div class="badge-row" data-testid="badge-row"> {#if state === 'kept'} - <span class="kept-pill">Kept</span> + <span class="kept-pill" role="status">Kept</span> {/if} </div> </div> - <div class="actions pt-3" data-testid="actions" style="margin-top: auto;"> + <div class="actions pt-3" data-testid="actions"> {#if state === 'requestable'} <button type="button" + aria-label={`Request ${title}`} class="flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-text-primary" onclick={handleRequest} > @@ -79,6 +76,7 @@ <button type="button" disabled + aria-label={`${title} is already in library`} onclick={handleRequest} class="rounded-md border border-border px-3 py-1.5 text-sm text-text-muted" > @@ -88,6 +86,7 @@ <button type="button" disabled + aria-label={`${title} already requested`} onclick={handleRequest} class="rounded-md border border-border px-3 py-1.5 text-sm text-text-muted" > diff --git a/web/src/lib/components/DiscoverResultCard.test.ts b/web/src/lib/components/DiscoverResultCard.test.ts index 6672349c..acffc80f 100644 --- a/web/src/lib/components/DiscoverResultCard.test.ts +++ b/web/src/lib/components/DiscoverResultCard.test.ts @@ -15,13 +15,17 @@ describe('DiscoverResultCard', () => { onRequest, }, }); - const btn = screen.getByRole('button', { name: /request/i }); + // aria-label includes the title, so the accessible name is + // "Request Boards of Canada". + const btn = screen.getByRole('button', { + name: /request boards of canada/i, + }); expect(btn).not.toBeDisabled(); await fireEvent.click(btn); expect(onRequest).toHaveBeenCalledOnce(); }); - test('kept state renders disabled "In library" button + Kept pill', () => { + test('kept state renders disabled "In library" button + Kept pill with role=status', () => { render(DiscoverResultCard, { props: { kind: 'album', @@ -30,7 +34,10 @@ describe('DiscoverResultCard', () => { }, }); expect(screen.getByRole('button', { name: /in library/i })).toBeDisabled(); - expect(screen.getByText(/kept/i)).toBeInTheDocument(); + // Kept pill is exposed as a status region so SR users hear it when it appears. + const status = screen.getByRole('status'); + expect(status).toBeInTheDocument(); + expect(status.textContent).toMatch(/kept/i); }); test('requested state renders disabled "Requested" button', () => { @@ -61,30 +68,15 @@ describe('DiscoverResultCard', () => { expect(onRequest).not.toHaveBeenCalled(); }); - test('badge row reserves min-height: 22px even when empty', () => { - render(DiscoverResultCard, { - props: { - kind: 'artist', - title: 'Aphex Twin', - state: 'requestable', - }, - }); - const row = screen.getByTestId('badge-row'); - const cs = getComputedStyle(row); - expect(cs.minHeight).toBe('22px'); - }); - - test('actions block is anchored to bottom (margin-top: auto)', () => { - render(DiscoverResultCard, { - props: { - kind: 'artist', - title: 'Aphex Twin', - state: 'requestable', - }, - }); - const actions = screen.getByTestId('actions'); - expect(getComputedStyle(actions).marginTop).toBe('auto'); - }); + // NOTE: previous versions of this file asserted .badge-row { min-height: 22px } + // and .actions { margin-top: auto } via getComputedStyle, which only worked + // because the component shipped inline style="" attributes — a layering + // violation. Stylesheet introspection (document.styleSheets) was tried as a + // replacement, but vitest's @testing-library/svelte renderer does not inject + // the component's scoped <style> block into jsdom (styleSheets.length === 0). + // Asserting "CSS exists in CSS" has weak value anyway, so the layout + // discipline (reserved badge slot + bottom-anchored actions) is enforced + // visually at the consumer page rather than as a unit test here. test('renders <img> when imageUrl is set', () => { const { container } = render(DiscoverResultCard, { From d27b381250fb7ad71db7a23f47ad55ac2ee0c5c1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Wed, 29 Apr 2026 22:08:09 -0400 Subject: [PATCH 24/67] feat(web): add StatusPill semantic-color status indicator Single-prop component mapping LidarrRequestStatus to voice-rule labels and semantic tones (warning/info/success/error). Used by /requests and /admin/requests in subsequent tasks. --- web/src/lib/components/StatusPill.svelte | 54 +++++++++++++++++++++++ web/src/lib/components/StatusPill.test.ts | 25 +++++++++++ 2 files changed, 79 insertions(+) create mode 100644 web/src/lib/components/StatusPill.svelte create mode 100644 web/src/lib/components/StatusPill.test.ts diff --git a/web/src/lib/components/StatusPill.svelte b/web/src/lib/components/StatusPill.svelte new file mode 100644 index 00000000..cc6907b8 --- /dev/null +++ b/web/src/lib/components/StatusPill.svelte @@ -0,0 +1,54 @@ +<script lang="ts" module> + import type { LidarrRequestStatus } from '$lib/api/types'; + + type Tone = 'warning' | 'info' | 'success' | 'error'; + + type StatusEntry = { label: string; tone: Tone }; + + // Voice-rule labels per spec §6 (and project_design_system.md). Pending requests + // are an "awaiting" moment, completed requests are "kept" (Minstrel-voice for + // success), rejected is "set aside", failed is the error register. + const STATUS_TABLE: Record<LidarrRequestStatus, StatusEntry> = { + pending: { label: 'Awaiting review', tone: 'warning' }, + approved: { label: 'Approved · downloading', tone: 'info' }, + completed: { label: 'Kept', tone: 'success' }, + rejected: { label: 'Set aside', tone: 'error' }, + failed: { label: "Couldn't add", tone: 'error' }, + }; +</script> + +<script lang="ts"> + let { status }: { status: LidarrRequestStatus } = $props(); + + const entry = $derived(STATUS_TABLE[status]); +</script> + +<span class="pill" data-tone={entry.tone} data-status={status}>{entry.label}</span> + +<style> + .pill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + line-height: 14px; + font-weight: 500; + } + .pill[data-tone='warning'] { + background: color-mix(in srgb, var(--fs-warning) 15%, transparent); + color: var(--fs-warning); + } + .pill[data-tone='info'] { + background: color-mix(in srgb, var(--fs-info) 15%, transparent); + color: var(--fs-info); + } + .pill[data-tone='success'] { + background: color-mix(in srgb, var(--fs-moss) 15%, transparent); + color: var(--fs-moss); + } + .pill[data-tone='error'] { + background: color-mix(in srgb, var(--fs-error) 15%, transparent); + color: var(--fs-error); + } +</style> diff --git a/web/src/lib/components/StatusPill.test.ts b/web/src/lib/components/StatusPill.test.ts new file mode 100644 index 00000000..6d8d1e08 --- /dev/null +++ b/web/src/lib/components/StatusPill.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import StatusPill from './StatusPill.svelte'; + +// Voice-rule labels and semantic tones per spec §6 / project_design_system.md. +// Each row is the contract: input status -> rendered label + data-tone attr. +const CASES = [ + { status: 'pending', label: 'Awaiting review', tone: 'warning' }, + { status: 'approved', label: 'Approved · downloading', tone: 'info' }, + { status: 'completed', label: 'Kept', tone: 'success' }, + { status: 'rejected', label: 'Set aside', tone: 'error' }, + { status: 'failed', label: "Couldn't add", tone: 'error' }, +] as const; + +describe('StatusPill', () => { + for (const { status, label, tone } of CASES) { + test(`${status} renders "${label}" with tone=${tone}`, () => { + render(StatusPill, { props: { status } }); + const pill = screen.getByText(label); + expect(pill).toBeInTheDocument(); + expect(pill.getAttribute('data-tone')).toBe(tone); + expect(pill.getAttribute('data-status')).toBe(status); + }); + } +}); From 8a12b8c5716237cbf908b11b03e735f9fbdf026d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Wed, 29 Apr 2026 22:15:52 -0400 Subject: [PATCH 25/67] feat(web): add /discover route with search + request flow Local-input + 250ms debounce drives Lidarr search; tab switch refetches with new kind. Track-kind Request opens a confirm modal explaining the album that will be added; Confirm fires createRequest, Cancel is a no-op. Successful requests flip the card to 'requested' optimistically. Shell nav now exposes /discover between Search and Playlists. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- web/src/lib/components/Shell.svelte | 1 + web/src/lib/components/Shell.test.ts | 3 +- web/src/routes/discover/+page.svelte | 222 +++++++++++++++++++++ web/src/routes/discover/discover.test.ts | 242 +++++++++++++++++++++++ 4 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 web/src/routes/discover/+page.svelte create mode 100644 web/src/routes/discover/discover.test.ts diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index fb9a0a90..b25c5f31 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -25,6 +25,7 @@ { href: '/', label: 'Library' }, { href: '/library/liked', label: 'Liked' }, { href: '/search', label: 'Search' }, + { href: '/discover', label: 'Discover' }, { href: '/playlists', label: 'Playlists' }, { href: '/settings', label: 'Settings' } ]; diff --git a/web/src/lib/components/Shell.test.ts b/web/src/lib/components/Shell.test.ts index 1ad5a48d..cae5eed2 100644 --- a/web/src/lib/components/Shell.test.ts +++ b/web/src/lib/components/Shell.test.ts @@ -28,10 +28,11 @@ describe('Shell', () => { expect(screen.getByText('alice')).toBeInTheDocument(); }); - test('renders three nav items: Library, Search, Playlists', () => { + test('renders nav items including Discover between Search and Playlists', () => { render(Shell); expect(screen.getByRole('link', { name: 'Library' })).toHaveAttribute('href', '/'); expect(screen.getByRole('link', { name: 'Search' })).toHaveAttribute('href', '/search'); + expect(screen.getByRole('link', { name: 'Discover' })).toHaveAttribute('href', '/discover'); expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists'); }); diff --git a/web/src/routes/discover/+page.svelte b/web/src/routes/discover/+page.svelte new file mode 100644 index 00000000..4ec63f06 --- /dev/null +++ b/web/src/routes/discover/+page.svelte @@ -0,0 +1,222 @@ +<script lang="ts"> + import { createLidarrSearchQuery } from '$lib/api/lidarr'; + import { createRequest } from '$lib/api/requests'; + import DiscoverResultCard from '$lib/components/DiscoverResultCard.svelte'; + import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte'; + import type { + LidarrRequestKind, + LidarrSearchResult + } from '$lib/api/types'; + + // Local input is the source of truth for the discover query — Shell's global + // SearchInput targets /search, not /discover. We debounce by 250ms before + // pushing into `debouncedQ`, which is what the query factory observes. + let inputValue = $state(''); + let debouncedQ = $state(''); + let activeKind: LidarrRequestKind = $state('artist'); + + let debounceTimer: ReturnType<typeof setTimeout> | null = null; + $effect(() => { + const v = inputValue; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debouncedQ = v.trim(); + }, 250); + return () => { + if (debounceTimer) clearTimeout(debounceTimer); + }; + }); + + const queryStore = $derived(createLidarrSearchQuery(debouncedQ, activeKind)); + const query = $derived($queryStore); + const results = $derived((query.data ?? []) as LidarrSearchResult[]); + + // Track-kind confirm modal: nulled out = closed. + let modalResult: LidarrSearchResult | null = $state(null); + + // In-session optimistic flip — once a Request goes through, we flip the + // card to 'requested' immediately. Cleared only on full page reload, which + // is fine for a short-lived discover session. + let optimisticRequested: Set<string> = $state(new Set()); + + function cardState(r: LidarrSearchResult) { + if (r.in_library) return 'kept' as const; + if (r.requested || optimisticRequested.has(r.mbid)) return 'requested' as const; + return 'requestable' as const; + } + + // Backend validates kind→required-MBID-fields, not the human strings, so + // imperfect derivations of artist_name from secondary_text won't block the + // request. We pass the data we have and let the server take care of the rest. + function buildRequestParams(r: LidarrSearchResult, kind: LidarrRequestKind) { + if (kind === 'artist') { + return { + kind, + lidarr_artist_mbid: r.artist_mbid || r.mbid, + artist_name: r.name + }; + } + if (kind === 'album') { + return { + kind, + lidarr_artist_mbid: r.artist_mbid, + lidarr_album_mbid: r.album_mbid || r.mbid, + artist_name: r.secondary_text, + album_title: r.name + }; + } + // track + return { + kind, + lidarr_artist_mbid: r.artist_mbid, + lidarr_album_mbid: r.album_mbid, + lidarr_track_mbid: r.mbid, + artist_name: r.secondary_text, + album_title: r.secondary_text, + track_title: r.name + }; + } + + async function submitRequest(r: LidarrSearchResult) { + try { + await createRequest(buildRequestParams(r, activeKind)); + const next = new Set(optimisticRequested); + next.add(r.mbid); + optimisticRequested = next; + } catch { + // Swallow for v1: error toasts land in a later UX pass. The card stays + // in 'requestable' so the user can retry. + } + } + + function handleRequestClick(r: LidarrSearchResult) { + if (activeKind === 'track') { + modalResult = r; + } else { + submitRequest(r); + } + } + + function confirmModal() { + if (modalResult) submitRequest(modalResult); + modalResult = null; + } + + function cancelModal() { + modalResult = null; + } + + const tabs: { kind: LidarrRequestKind; label: string }[] = [ + { kind: 'artist', label: 'Artists' }, + { kind: 'album', label: 'Albums' }, + { kind: 'track', label: 'Tracks' } + ]; +</script> + +<div class="space-y-6"> + <header class="space-y-1"> + <h2 class="font-display text-2xl font-medium text-text-primary"> + Add music to the library + </h2> + <p class="text-text-secondary"> + Search Lidarr to add new artists, albums, or tracks. + </p> + </header> + + <input + type="search" + aria-label="Search Lidarr" + placeholder="Search artists, albums, or tracks" + bind:value={inputValue} + class="w-full rounded-md border border-border bg-background px-3 py-2 text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" + /> + + <nav aria-label="Result kind" class="border-b border-border"> + <ul class="flex gap-2"> + {#each tabs as tab (tab.kind)} + <li> + <button + type="button" + aria-pressed={activeKind === tab.kind} + class="border-b-2 px-3 py-2 text-sm {activeKind === tab.kind + ? 'border-accent text-text-primary' + : 'border-transparent text-text-secondary hover:text-text-primary'}" + onclick={() => (activeKind = tab.kind)} + > + {tab.label} + </button> + </li> + {/each} + </ul> + </nav> + + {#if !debouncedQ} + <p class="text-text-secondary"> + Search Lidarr for music to add to the library. + </p> + {:else if query.isError} + <ApiErrorBanner error={query.error} onRetry={query.refetch} /> + {:else if query.isPending} + <p class="text-text-secondary">Searching…</p> + {:else if results.length === 0} + <p class="text-text-secondary">Nothing to add for that search yet.</p> + {:else} + <div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> + {#each results as r (r.mbid)} + <DiscoverResultCard + kind={activeKind} + title={r.name} + subtitle={r.secondary_text} + imageUrl={r.image_url || undefined} + state={cardState(r)} + onRequest={() => handleRequestClick(r)} + /> + {/each} + </div> + {/if} +</div> + +{#if modalResult} + <!-- svelte-ignore a11y_click_events_have_key_events --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + class="fixed inset-0 z-50 flex items-center justify-center" + style="background: rgba(0,0,0,0.5);" + onclick={cancelModal} + > + <div + role="dialog" + aria-modal="true" + aria-labelledby="track-confirm-title" + class="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-lg" + onclick={(e) => e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + tabindex="-1" + > + <h3 id="track-confirm-title" class="font-display text-lg font-medium text-text-primary"> + Add the album? + </h3> + <p class="mt-2 text-text-secondary"> + Requesting <em class="font-medium text-text-primary">{modalResult.name}</em> + will add <em class="font-medium text-text-primary">{modalResult.secondary_text}</em>. + Continue? + </p> + <div class="mt-5 flex justify-end gap-2"> + <button + type="button" + class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary" + onclick={cancelModal} + > + Cancel + </button> + <button + type="button" + class="rounded-md bg-action-primary px-3 py-1.5 text-sm text-text-primary" + onclick={confirmModal} + > + Add the album + </button> + </div> + </div> + </div> +{/if} diff --git a/web/src/routes/discover/discover.test.ts b/web/src/routes/discover/discover.test.ts new file mode 100644 index 00000000..60e29e67 --- /dev/null +++ b/web/src/routes/discover/discover.test.ts @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import { mockQuery } from '../../test-utils/query'; +import type { LidarrSearchResult } from '$lib/api/types'; + +// Lidarr search query factory and createRequest are mocked at the module +// level so each test can shape what the page sees without standing up a +// real QueryClient + network. +vi.mock('$lib/api/lidarr', () => ({ + createLidarrSearchQuery: vi.fn() +})); + +vi.mock('$lib/api/requests', () => ({ + createRequest: vi.fn().mockResolvedValue({ id: 'r1' }) +})); + +vi.mock('$lib/api/client', () => ({ + api: { get: vi.fn(), post: vi.fn(), put: vi.fn(), del: vi.fn() } +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record<string, unknown>; + return { ...actual, useQueryClient: () => ({}) }; +}); + +import DiscoverPage from './+page.svelte'; +import { createLidarrSearchQuery } from '$lib/api/lidarr'; +import { createRequest } from '$lib/api/requests'; + +const mockedCreateQuery = createLidarrSearchQuery as ReturnType<typeof vi.fn>; +const mockedCreateRequest = createRequest as ReturnType<typeof vi.fn>; + +function result(over: Partial<LidarrSearchResult> = {}): LidarrSearchResult { + return { + mbid: 'mbid-1', + name: 'Boards of Canada', + secondary_text: 'Electronic', + image_url: '', + artist_mbid: 'art-1', + album_mbid: '', + in_library: false, + requested: false, + ...over + }; +} + +beforeEach(() => { + // Default: empty results, non-pending. Tests override per-case. + mockedCreateQuery.mockReturnValue(mockQuery<LidarrSearchResult[]>({ data: [] })); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('Discover page', () => { + test('initial state (no query) shows the search prompt copy', () => { + render(DiscoverPage); + expect( + screen.getByText(/search lidarr for music to add/i) + ).toBeInTheDocument(); + }); + + test('debounced input fires query factory with typed value after 250ms', async () => { + vi.useFakeTimers(); + render(DiscoverPage); + const input = screen.getByLabelText(/search lidarr/i); + await fireEvent.input(input, { target: { value: 'boards' } }); + // Before the timer elapses, the factory was called only with the + // initial empty string — never with 'boards'. + expect(mockedCreateQuery).not.toHaveBeenCalledWith('boards', 'artist'); + await vi.advanceTimersByTimeAsync(250); + expect(mockedCreateQuery).toHaveBeenLastCalledWith('boards', 'artist'); + vi.useRealTimers(); + }); + + test('switching tabs refetches with the new kind', async () => { + vi.useFakeTimers(); + render(DiscoverPage); + const input = screen.getByLabelText(/search lidarr/i); + await fireEvent.input(input, { target: { value: 'boards' } }); + await vi.advanceTimersByTimeAsync(250); + // Switch to Albums. + await fireEvent.click(screen.getByRole('button', { name: 'Albums' })); + expect(mockedCreateQuery).toHaveBeenLastCalledWith('boards', 'album'); + // Then to Tracks. + await fireEvent.click(screen.getByRole('button', { name: 'Tracks' })); + expect(mockedCreateQuery).toHaveBeenLastCalledWith('boards', 'track'); + vi.useRealTimers(); + }); + + test('empty results state renders the voice-rule copy', async () => { + vi.useFakeTimers(); + mockedCreateQuery.mockReturnValue(mockQuery<LidarrSearchResult[]>({ data: [] })); + render(DiscoverPage); + const input = screen.getByLabelText(/search lidarr/i); + await fireEvent.input(input, { target: { value: 'zzz' } }); + await vi.advanceTimersByTimeAsync(250); + await waitFor(() => + expect( + screen.getByText(/nothing to add for that search yet\./i) + ).toBeInTheDocument() + ); + vi.useRealTimers(); + }); + + test('artist-kind Request click calls createRequest immediately (no modal)', async () => { + vi.useFakeTimers(); + const r = result({ + mbid: 'art-mbid', + artist_mbid: 'art-mbid', + name: 'Boards of Canada' + }); + mockedCreateQuery.mockReturnValue(mockQuery<LidarrSearchResult[]>({ data: [r] })); + render(DiscoverPage); + const input = screen.getByLabelText(/search lidarr/i); + await fireEvent.input(input, { target: { value: 'boards' } }); + await vi.advanceTimersByTimeAsync(250); + vi.useRealTimers(); + + const requestBtn = await screen.findByRole('button', { + name: /request boards of canada/i + }); + await fireEvent.click(requestBtn); + expect(mockedCreateRequest).toHaveBeenCalledTimes(1); + expect(mockedCreateRequest).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'artist' }) + ); + // Modal must not be present for non-track kinds. + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + test('requestable card flips to "Requested" after a successful request', async () => { + vi.useFakeTimers(); + const r = result({ + mbid: 'art-mbid', + artist_mbid: 'art-mbid', + name: 'Boards of Canada' + }); + mockedCreateQuery.mockReturnValue(mockQuery<LidarrSearchResult[]>({ data: [r] })); + render(DiscoverPage); + const input = screen.getByLabelText(/search lidarr/i); + await fireEvent.input(input, { target: { value: 'boards' } }); + await vi.advanceTimersByTimeAsync(250); + vi.useRealTimers(); + + const requestBtn = await screen.findByRole('button', { + name: /request boards of canada/i + }); + await fireEvent.click(requestBtn); + await waitFor(() => { + const flipped = screen.getByRole('button', { name: /already requested/i }); + expect(flipped).toBeDisabled(); + }); + }); + + test('track-kind Request click opens confirm modal; Confirm calls createRequest', async () => { + vi.useFakeTimers(); + const r = result({ + mbid: 'tr-mbid', + artist_mbid: 'art-mbid', + album_mbid: 'al-mbid', + name: 'Roygbiv', + secondary_text: 'Music Has The Right To Children · Boards of Canada' + }); + mockedCreateQuery.mockReturnValue(mockQuery<LidarrSearchResult[]>({ data: [r] })); + render(DiscoverPage); + // Switch to track kind first. + await fireEvent.click(screen.getByRole('button', { name: 'Tracks' })); + const input = screen.getByLabelText(/search lidarr/i); + await fireEvent.input(input, { target: { value: 'roy' } }); + await vi.advanceTimersByTimeAsync(250); + vi.useRealTimers(); + + const requestBtn = await screen.findByRole('button', { + name: /request roygbiv/i + }); + await fireEvent.click(requestBtn); + // Modal opens with the explanation copy. + const dialog = await screen.findByRole('dialog'); + expect(dialog).toBeInTheDocument(); + expect(dialog.textContent).toMatch(/continue/i); + // Confirm fires createRequest with kind=track. + await fireEvent.click(screen.getByRole('button', { name: /add the album/i })); + expect(mockedCreateRequest).toHaveBeenCalledTimes(1); + expect(mockedCreateRequest).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'track', + lidarr_track_mbid: 'tr-mbid', + lidarr_album_mbid: 'al-mbid', + lidarr_artist_mbid: 'art-mbid' + }) + ); + }); + + test('track-kind modal Cancel does not call createRequest', async () => { + vi.useFakeTimers(); + const r = result({ + mbid: 'tr-mbid', + artist_mbid: 'art-mbid', + album_mbid: 'al-mbid', + name: 'Roygbiv', + secondary_text: 'Music Has The Right To Children · Boards of Canada' + }); + mockedCreateQuery.mockReturnValue(mockQuery<LidarrSearchResult[]>({ data: [r] })); + render(DiscoverPage); + await fireEvent.click(screen.getByRole('button', { name: 'Tracks' })); + const input = screen.getByLabelText(/search lidarr/i); + await fireEvent.input(input, { target: { value: 'roy' } }); + await vi.advanceTimersByTimeAsync(250); + vi.useRealTimers(); + + const requestBtn = await screen.findByRole('button', { + name: /request roygbiv/i + }); + await fireEvent.click(requestBtn); + await screen.findByRole('dialog'); + await fireEvent.click(screen.getByRole('button', { name: /^cancel$/i })); + expect(mockedCreateRequest).not.toHaveBeenCalled(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + test('in_library result renders kept state regardless of requested', async () => { + vi.useFakeTimers(); + const r = result({ + mbid: 'kept-1', + name: 'Kind of Blue', + in_library: true, + requested: true + }); + mockedCreateQuery.mockReturnValue(mockQuery<LidarrSearchResult[]>({ data: [r] })); + render(DiscoverPage); + const input = screen.getByLabelText(/search lidarr/i); + await fireEvent.input(input, { target: { value: 'kind' } }); + await vi.advanceTimersByTimeAsync(250); + vi.useRealTimers(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /in library/i })).toBeDisabled(); + }); + }); +}); From a7506d941317dfa2f43554c0580bd2549814ce2d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Wed, 29 Apr 2026 22:20:35 -0400 Subject: [PATCH 26/67] fix(web): drop dead onkeydown stopPropagation on discover modal --- web/src/routes/discover/+page.svelte | 1 - 1 file changed, 1 deletion(-) diff --git a/web/src/routes/discover/+page.svelte b/web/src/routes/discover/+page.svelte index 4ec63f06..e75f71de 100644 --- a/web/src/routes/discover/+page.svelte +++ b/web/src/routes/discover/+page.svelte @@ -190,7 +190,6 @@ aria-labelledby="track-confirm-title" class="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-lg" onclick={(e) => e.stopPropagation()} - onkeydown={(e) => e.stopPropagation()} tabindex="-1" > <h3 id="track-confirm-title" class="font-display text-lg font-medium text-text-primary"> From ad904afaf665ed75e5329e6cca47485d40ff6f60 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Wed, 29 Apr 2026 22:24:17 -0400 Subject: [PATCH 27/67] feat(web): add /requests user-facing request history Renders the caller's Lidarr requests as rows with kind pill, StatusPill, and per-status actions: Cancel on pending (which calls cancelRequest then invalidates qk.myRequests()), Listen link on completed (deepest match wins: track > album > artist), admin notes on rejected. Empty state uses the voice-rule "Nothing requested yet." copy. Shell nav gains /requests between /discover and /playlists, visible to all authed users since the view is per-user. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- web/src/lib/components/Shell.svelte | 1 + web/src/lib/components/Shell.test.ts | 3 +- web/src/routes/requests/+page.svelte | 140 ++++++++++++++++++++ web/src/routes/requests/requests.test.ts | 159 +++++++++++++++++++++++ 4 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 web/src/routes/requests/+page.svelte create mode 100644 web/src/routes/requests/requests.test.ts diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index b25c5f31..9c83f655 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -26,6 +26,7 @@ { href: '/library/liked', label: 'Liked' }, { href: '/search', label: 'Search' }, { href: '/discover', label: 'Discover' }, + { href: '/requests', label: 'Requests' }, { href: '/playlists', label: 'Playlists' }, { href: '/settings', label: 'Settings' } ]; diff --git a/web/src/lib/components/Shell.test.ts b/web/src/lib/components/Shell.test.ts index cae5eed2..b4489f46 100644 --- a/web/src/lib/components/Shell.test.ts +++ b/web/src/lib/components/Shell.test.ts @@ -28,11 +28,12 @@ describe('Shell', () => { expect(screen.getByText('alice')).toBeInTheDocument(); }); - test('renders nav items including Discover between Search and Playlists', () => { + test('renders nav items including Discover and Requests between Search and Playlists', () => { render(Shell); expect(screen.getByRole('link', { name: 'Library' })).toHaveAttribute('href', '/'); expect(screen.getByRole('link', { name: 'Search' })).toHaveAttribute('href', '/search'); expect(screen.getByRole('link', { name: 'Discover' })).toHaveAttribute('href', '/discover'); + expect(screen.getByRole('link', { name: 'Requests' })).toHaveAttribute('href', '/requests'); expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists'); }); diff --git a/web/src/routes/requests/+page.svelte b/web/src/routes/requests/+page.svelte new file mode 100644 index 00000000..72c0d6b9 --- /dev/null +++ b/web/src/routes/requests/+page.svelte @@ -0,0 +1,140 @@ +<script lang="ts"> + import { Disc3, Album, Music2, X } from 'lucide-svelte'; + import { useQueryClient } from '@tanstack/svelte-query'; + import { createMyRequestsQuery, cancelRequest } from '$lib/api/requests'; + import { qk } from '$lib/api/queries'; + import StatusPill from '$lib/components/StatusPill.svelte'; + import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte'; + import type { LidarrRequest, LidarrRequestKind } from '$lib/api/types'; + + const queryStore = createMyRequestsQuery(); + const query = $derived($queryStore); + const requests = $derived((query.data ?? []) as LidarrRequest[]); + + const client = useQueryClient(); + + async function onCancel(id: string) { + try { + await cancelRequest(id); + await client.invalidateQueries({ queryKey: qk.myRequests() }); + } catch { + // Swallow for v1; toast surface lands later. The row stays put so the + // user can retry — failed cancel doesn't lie about success. + } + } + + function fallbackIcon(kind: LidarrRequestKind) { + if (kind === 'artist') return Disc3; + if (kind === 'album') return Album; + return Music2; + } + + function rowTitle(r: LidarrRequest): string { + if (r.kind === 'artist') return r.artist_name; + if (r.kind === 'album') return r.album_title ?? '—'; + return r.track_title ?? '—'; + } + + // Keep the meta line short and readable. We always lead with the artist, + // then a relative-ish date — locale formatting is good enough for v1 + // and keeps tests deterministic without pinning Intl.RelativeTimeFormat. + function rowMeta(r: LidarrRequest): string { + const when = new Date(r.requested_at).toLocaleDateString(); + if (r.kind === 'artist') return `Requested ${when}`; + return `by ${r.artist_name} · ${when}`; + } + + function listenHref(r: LidarrRequest): string | null { + if (r.matched_track_id) return `/tracks/${r.matched_track_id}`; + if (r.matched_album_id) return `/albums/${r.matched_album_id}`; + if (r.matched_artist_id) return `/artists/${r.matched_artist_id}`; + return null; + } +</script> + +<div class="space-y-6"> + <header class="space-y-1"> + <h2 class="font-display text-2xl font-medium text-text-primary"> + Your requests + </h2> + <p class="text-text-secondary"> + What you've asked Minstrel to add to the library. + </p> + </header> + + {#if query.isError} + <ApiErrorBanner error={query.error} onRetry={query.refetch} /> + {:else if query.isPending} + <p class="text-text-secondary">Reading the ledger…</p> + {:else if requests.length === 0} + <p class="text-text-secondary">Nothing requested yet.</p> + {:else} + <ul class="divide-y divide-border rounded-lg border border-border bg-surface"> + {#each requests as r (r.id)} + {@const Icon = fallbackIcon(r.kind)} + {@const href = listenHref(r)} + <li class="flex items-center gap-4 p-3" data-testid="request-row" data-status={r.status}> + <div + class="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-surface-hover" + aria-hidden="true" + > + <Icon size={24} strokeWidth={1} class="text-text-muted" /> + </div> + + <div class="min-w-0 flex-1 space-y-1"> + <div class="flex flex-wrap items-center gap-2"> + <span class="kind-pill">{r.kind}</span> + <StatusPill status={r.status} /> + </div> + <div class="truncate text-base font-medium text-text-primary"> + {rowTitle(r)} + </div> + <div class="truncate text-sm text-text-secondary"> + {rowMeta(r)} + </div> + {#if r.status === 'rejected' && r.notes} + <div class="text-sm text-text-secondary" data-testid="rejection-notes"> + {r.notes} + </div> + {/if} + </div> + + <div class="flex shrink-0 items-center gap-2"> + {#if r.status === 'pending'} + <button + type="button" + aria-label={`Cancel request for ${rowTitle(r)}`} + class="inline-flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover" + onclick={() => onCancel(r.id)} + > + <X size={16} strokeWidth={1} /> Cancel + </button> + {:else if r.status === 'completed' && href} + <a + {href} + aria-label={`Listen to ${rowTitle(r)}`} + class="inline-flex items-center gap-1 text-sm text-accent hover:underline" + > + <Music2 size={16} strokeWidth={1} /> Listen + </a> + {/if} + </div> + </li> + {/each} + </ul> + {/if} +</div> + +<style> + .kind-pill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + line-height: 14px; + background: color-mix(in srgb, var(--fs-accent) 15%, transparent); + color: var(--fs-accent); + text-transform: capitalize; + } +</style> diff --git a/web/src/routes/requests/requests.test.ts b/web/src/routes/requests/requests.test.ts new file mode 100644 index 00000000..097fd9e9 --- /dev/null +++ b/web/src/routes/requests/requests.test.ts @@ -0,0 +1,159 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import { mockQuery } from '../../test-utils/query'; +import type { LidarrRequest } from '$lib/api/types'; + +// Capture invalidateQueries on the mocked QueryClient so the cancel test can +// assert against the same instance the page consumed. +const invalidateQueries = vi.fn().mockResolvedValue(undefined); + +vi.mock('$lib/api/requests', () => ({ + createMyRequestsQuery: vi.fn(), + cancelRequest: vi.fn().mockResolvedValue({ id: 'r1' }) +})); + +vi.mock('$lib/api/queries', () => ({ + qk: { + myRequests: () => ['myRequests'] + } +})); + +vi.mock('$lib/api/client', () => ({ + api: { get: vi.fn(), post: vi.fn(), put: vi.fn(), del: vi.fn() } +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record<string, unknown>; + return { + ...actual, + useQueryClient: () => ({ invalidateQueries }) + }; +}); + +import RequestsPage from './+page.svelte'; +import { createMyRequestsQuery, cancelRequest } from '$lib/api/requests'; +import { qk } from '$lib/api/queries'; + +const mockedCreateMyRequestsQuery = createMyRequestsQuery as ReturnType<typeof vi.fn>; +const mockedCancelRequest = cancelRequest as ReturnType<typeof vi.fn>; + +function req(over: Partial<LidarrRequest> = {}): LidarrRequest { + return { + id: 'r1', + user_id: 'u1', + status: 'pending', + kind: 'album', + lidarr_artist_mbid: 'art-mbid', + lidarr_album_mbid: 'al-mbid', + lidarr_track_mbid: null, + artist_name: 'Boards of Canada', + album_title: 'Music Has The Right To Children', + track_title: null, + quality_profile_id: null, + root_folder_path: null, + decided_at: null, + decided_by: null, + notes: null, + completed_at: null, + matched_track_id: null, + matched_album_id: null, + matched_artist_id: null, + requested_at: '2026-04-28T12:00:00Z', + updated_at: '2026-04-28T12:00:00Z', + ...over + }; +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('Requests page', () => { + test('renders one row per request from the API', () => { + const rows: LidarrRequest[] = [ + req({ id: 'r1', kind: 'artist', artist_name: 'Boards of Canada' }), + req({ id: 'r2', kind: 'album', album_title: 'Geogaddi', status: 'completed' }), + req({ id: 'r3', kind: 'track', track_title: 'Roygbiv', status: 'rejected', notes: 'Already in library' }) + ]; + mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: rows })); + render(RequestsPage); + expect(screen.getAllByTestId('request-row')).toHaveLength(3); + expect(screen.getByText('Boards of Canada')).toBeInTheDocument(); + expect(screen.getByText('Geogaddi')).toBeInTheDocument(); + expect(screen.getByText('Roygbiv')).toBeInTheDocument(); + }); + + test('pending row exposes Cancel; clicking calls cancelRequest and invalidates myRequests', async () => { + const rows: LidarrRequest[] = [ + req({ id: 'r-pending', status: 'pending', kind: 'album', album_title: 'Geogaddi' }) + ]; + mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: rows })); + render(RequestsPage); + const cancelBtn = screen.getByRole('button', { name: /cancel request for geogaddi/i }); + await fireEvent.click(cancelBtn); + await waitFor(() => { + expect(mockedCancelRequest).toHaveBeenCalledWith('r-pending'); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: qk.myRequests() }); + }); + }); + + test('completed row with matched_track_id renders Listen link to /tracks/<id>', () => { + const rows: LidarrRequest[] = [ + req({ + id: 'r-track', + status: 'completed', + kind: 'track', + track_title: 'Roygbiv', + matched_track_id: 'tr-9', + matched_album_id: 'al-9', + matched_artist_id: 'art-9' + }) + ]; + mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: rows })); + render(RequestsPage); + const link = screen.getByRole('link', { name: /listen to roygbiv/i }); + expect(link).toHaveAttribute('href', '/tracks/tr-9'); + }); + + test('completed row with only matched_album_id renders Listen link to /albums/<id>', () => { + const rows: LidarrRequest[] = [ + req({ + id: 'r-album', + status: 'completed', + kind: 'album', + album_title: 'Geogaddi', + matched_track_id: null, + matched_album_id: 'al-7', + matched_artist_id: 'art-7' + }) + ]; + mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: rows })); + render(RequestsPage); + const link = screen.getByRole('link', { name: /listen to geogaddi/i }); + expect(link).toHaveAttribute('href', '/albums/al-7'); + }); + + test('rejected row renders notes and hides Cancel and Listen', () => { + const rows: LidarrRequest[] = [ + req({ + id: 'r-rej', + status: 'rejected', + kind: 'album', + album_title: 'Geogaddi', + notes: 'Already kept in the library.' + }) + ]; + mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: rows })); + render(RequestsPage); + expect(screen.getByTestId('rejection-notes')).toHaveTextContent('Already kept in the library.'); + expect(screen.queryByRole('button', { name: /cancel request/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /listen to/i })).not.toBeInTheDocument(); + }); + + test('empty list shows the voice-rule "Nothing requested yet." copy', () => { + mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: [] })); + render(RequestsPage); + expect(screen.getByText(/nothing requested yet\./i)).toBeInTheDocument(); + expect(screen.queryAllByTestId('request-row')).toHaveLength(0); + }); +}); From dd86ffca9491f2d60e6a91e21e3f0223c92f05ae Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Wed, 29 Apr 2026 22:29:07 -0400 Subject: [PATCH 28/67] fix(web): a11y on /requests aria-labels when titles are absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Album- and track-kind requests can have null album_title/track_title (the type allows it). Previously aria-label rendered 'Cancel request for —' which is meaningless to a screen reader. Fall back to 'this album by <artist>' / 'this track by <artist>' so the cancel/listen buttons stay disambiguable. --- web/src/routes/requests/+page.svelte | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/web/src/routes/requests/+page.svelte b/web/src/routes/requests/+page.svelte index 72c0d6b9..3907cc8c 100644 --- a/web/src/routes/requests/+page.svelte +++ b/web/src/routes/requests/+page.svelte @@ -35,6 +35,14 @@ return r.track_title ?? '—'; } + // Used in aria-labels — '—' is meaningless to screen readers, so when a + // title is missing fall back to a generic-but-intelligible phrase. + function rowAccessibleName(r: LidarrRequest): string { + if (r.kind === 'artist') return r.artist_name; + if (r.kind === 'album') return r.album_title ?? `this album by ${r.artist_name}`; + return r.track_title ?? `this track by ${r.artist_name}`; + } + // Keep the meta line short and readable. We always lead with the artist, // then a relative-ish date — locale formatting is good enough for v1 // and keeps tests deterministic without pinning Intl.RelativeTimeFormat. @@ -103,7 +111,7 @@ {#if r.status === 'pending'} <button type="button" - aria-label={`Cancel request for ${rowTitle(r)}`} + aria-label={`Cancel request for ${rowAccessibleName(r)}`} class="inline-flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover" onclick={() => onCancel(r.id)} > @@ -112,7 +120,7 @@ {:else if r.status === 'completed' && href} <a {href} - aria-label={`Listen to ${rowTitle(r)}`} + aria-label={`Listen to ${rowAccessibleName(r)}`} class="inline-flex items-center gap-1 text-sm text-accent hover:underline" > <Music2 size={16} strokeWidth={1} /> Listen From bfd48f5a02216812d62a96f5f6699114e1d7aa14 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Wed, 29 Apr 2026 22:48:26 -0400 Subject: [PATCH 29/67] feat(web): add /admin layout with role-gated load + sidebar Hard route gate in admin/+layout.ts redirects non-admins to / before the layout (or any child) renders. AdminSidebar reads the active route from $app/state and applies a 12% accent-tinted bg + 2px forest-teal left strip on the active item. Overview landing shows pending-request count and Lidarr connected/unset, each linking to its sub-page. Shell nav exposes the Admin link only to is_admin users. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- web/src/lib/components/AdminSidebar.svelte | 68 +++++++++++++ web/src/lib/components/AdminSidebar.test.ts | 51 ++++++++++ web/src/lib/components/Shell.svelte | 9 +- web/src/lib/components/Shell.test.ts | 36 ++++++- web/src/routes/admin/+layout.svelte | 19 ++++ web/src/routes/admin/+layout.ts | 13 +++ web/src/routes/admin/+page.svelte | 37 +++++++ web/src/routes/admin/admin.test.ts | 102 ++++++++++++++++++++ 8 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 web/src/lib/components/AdminSidebar.svelte create mode 100644 web/src/lib/components/AdminSidebar.test.ts create mode 100644 web/src/routes/admin/+layout.svelte create mode 100644 web/src/routes/admin/+layout.ts create mode 100644 web/src/routes/admin/+page.svelte create mode 100644 web/src/routes/admin/admin.test.ts diff --git a/web/src/lib/components/AdminSidebar.svelte b/web/src/lib/components/AdminSidebar.svelte new file mode 100644 index 00000000..8097f1e9 --- /dev/null +++ b/web/src/lib/components/AdminSidebar.svelte @@ -0,0 +1,68 @@ +<script lang="ts"> + import { page } from '$app/state'; + import { LayoutGrid, Plug, ListChecks, ShieldX, Users, FolderTree } from 'lucide-svelte'; + + type Item = { + href: string; + label: string; + icon: typeof LayoutGrid; + placeholder?: boolean; + }; + + const items: Item[] = [ + { href: '/admin', label: 'Overview', icon: LayoutGrid }, + { href: '/admin/integrations', label: 'Integrations', icon: Plug }, + { href: '/admin/requests', label: 'Requests', icon: ListChecks }, + { href: '/admin/quarantine', label: 'Quarantine', icon: ShieldX, placeholder: true }, + { href: '/admin/users', label: 'Users', icon: Users, placeholder: true }, + { href: '/admin/library', label: 'Library', icon: FolderTree, placeholder: true } + ]; + + function isActive(href: string): boolean { + if (href === '/admin') return page.url.pathname === '/admin'; + return page.url.pathname.startsWith(href); + } +</script> + +<aside class="w-[220px] shrink-0 border-r border-border bg-surface"> + <nav aria-label="Admin sections"> + <ul class="space-y-1 p-2"> + {#each items as item (item.href)} + <li> + {#if item.placeholder} + <span + class="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-text-muted opacity-60" + aria-disabled="true" + title="Coming soon" + > + <item.icon size={16} strokeWidth={1} /> + {item.label} + </span> + {:else} + <a + href={item.href} + aria-current={isActive(item.href) ? 'page' : undefined} + class="flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors {isActive( + item.href + ) + ? 'bg-accent-tint text-text-primary border-l-2 border-accent pl-[10px]' + : 'text-text-secondary hover:text-text-primary hover:bg-surface-hover'}" + > + <item.icon size={16} strokeWidth={1} /> + {item.label} + </a> + {/if} + </li> + {/each} + </ul> + </nav> +</aside> + +<style> + /* "12% accent-tinted bg" — the design-system rule for active nav state. + :global is required because the class is composed inside a templated + class string the Svelte scoped-CSS pass can't see. */ + :global(.bg-accent-tint) { + background: color-mix(in srgb, var(--fs-accent) 12%, transparent); + } +</style> diff --git a/web/src/lib/components/AdminSidebar.test.ts b/web/src/lib/components/AdminSidebar.test.ts new file mode 100644 index 00000000..792913e7 --- /dev/null +++ b/web/src/lib/components/AdminSidebar.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; + +const state = vi.hoisted(() => ({ + pageUrl: new URL('http://localhost/admin') +})); + +vi.mock('$app/state', () => ({ + page: { get url() { return state.pageUrl; } } +})); + +import AdminSidebar from './AdminSidebar.svelte'; + +describe('AdminSidebar', () => { + test('Overview link is active when on /admin', () => { + state.pageUrl = new URL('http://localhost/admin'); + render(AdminSidebar); + const overview = screen.getByRole('link', { name: /overview/i }); + expect(overview).toHaveAttribute('aria-current', 'page'); + }); + + test('Integrations link is active when on /admin/integrations', () => { + state.pageUrl = new URL('http://localhost/admin/integrations'); + render(AdminSidebar); + expect(screen.getByRole('link', { name: /integrations/i })).toHaveAttribute( + 'aria-current', + 'page' + ); + expect(screen.getByRole('link', { name: /overview/i })).not.toHaveAttribute('aria-current'); + }); + + test('Requests link is active when on /admin/requests', () => { + state.pageUrl = new URL('http://localhost/admin/requests'); + render(AdminSidebar); + expect(screen.getByRole('link', { name: /requests/i })).toHaveAttribute( + 'aria-current', + 'page' + ); + }); + + test('placeholder items render as non-links with aria-disabled', () => { + state.pageUrl = new URL('http://localhost/admin'); + render(AdminSidebar); + expect(screen.queryByRole('link', { name: /quarantine/i })).not.toBeInTheDocument(); + const quar = screen.getByText(/quarantine/i); + expect(quar.closest('[aria-disabled="true"]')).toBeInTheDocument(); + // Users + Library are also placeholders today. + expect(screen.queryByRole('link', { name: /^users$/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /^library$/i })).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index 9c83f655..9fc18724 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -30,6 +30,13 @@ { href: '/playlists', label: 'Playlists' }, { href: '/settings', label: 'Settings' } ]; + + // Admin link sits between Playlists and Settings, only visible to admins. + const visibleNavItems = $derived( + user.value?.is_admin + ? [...navItems.slice(0, -1), { href: '/admin', label: 'Admin' }, navItems[navItems.length - 1]] + : navItems + ); </script> <svelte:window onclick={() => (menuOpen = false)} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} /> @@ -70,7 +77,7 @@ <nav class="hidden w-48 border-r border-border bg-surface md:block"> <ul class="p-2"> - {#each navItems as item} + {#each visibleNavItems as item} <li> <a href={item.href} diff --git a/web/src/lib/components/Shell.test.ts b/web/src/lib/components/Shell.test.ts index b4489f46..dc7b445a 100644 --- a/web/src/lib/components/Shell.test.ts +++ b/web/src/lib/components/Shell.test.ts @@ -9,8 +9,18 @@ vi.mock('$app/navigation', () => ({ goto: vi.fn() })); +// Mutable handle so individual tests can flip the user between admin / +// non-admin without re-importing the module. +const userState = vi.hoisted(() => ({ + current: { id: '1', username: 'alice', is_admin: false } as { + id: string; + username: string; + is_admin: boolean; + } | null +})); + vi.mock('$lib/auth/store.svelte', () => ({ - user: { value: { id: '1', username: 'alice', is_admin: false } }, + user: { get value() { return userState.current; } }, logout: vi.fn().mockResolvedValue(undefined) })); @@ -20,6 +30,7 @@ import { goto } from '$app/navigation'; afterEach(() => { vi.clearAllMocks(); + userState.current = { id: '1', username: 'alice', is_admin: false }; }); describe('Shell', () => { @@ -37,6 +48,29 @@ describe('Shell', () => { expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists'); }); + test('non-admin users do not see the Admin nav link', () => { + userState.current = { id: '1', username: 'alice', is_admin: false }; + render(Shell); + expect(screen.queryByRole('link', { name: 'Admin' })).not.toBeInTheDocument(); + }); + + test('admin users see the Admin nav link between Playlists and Settings', () => { + userState.current = { id: '1', username: 'alice', is_admin: true }; + render(Shell); + const link = screen.getByRole('link', { name: 'Admin' }); + expect(link).toHaveAttribute('href', '/admin'); + // Order check: Playlists then Admin then Settings. + const labels = screen + .getAllByRole('link') + .map((el) => el.textContent?.trim()) + .filter(Boolean); + const idxPlaylists = labels.indexOf('Playlists'); + const idxAdmin = labels.indexOf('Admin'); + const idxSettings = labels.indexOf('Settings'); + expect(idxPlaylists).toBeLessThan(idxAdmin); + expect(idxAdmin).toBeLessThan(idxSettings); + }); + test('user-menu "Log out" calls logout() and navigates to /login', async () => { render(Shell); await fireEvent.click(screen.getByRole('button', { name: /alice/i })); diff --git a/web/src/routes/admin/+layout.svelte b/web/src/routes/admin/+layout.svelte new file mode 100644 index 00000000..072dd601 --- /dev/null +++ b/web/src/routes/admin/+layout.svelte @@ -0,0 +1,19 @@ +<script lang="ts"> + import { Sword } from 'lucide-svelte'; + import AdminSidebar from '$lib/components/AdminSidebar.svelte'; + + let { children } = $props<{ children: import('svelte').Snippet }>(); +</script> + +<div class="min-h-screen bg-background text-text-primary"> + <header class="flex items-center gap-3 border-b border-border bg-surface px-4 py-3"> + <Sword size={20} strokeWidth={1.5} class="text-action-destructive" /> + <h1 class="font-display text-xl font-medium">Admin</h1> + </header> + <div class="flex"> + <AdminSidebar /> + <main class="flex-1 p-6"> + {@render children?.()} + </main> + </div> +</div> diff --git a/web/src/routes/admin/+layout.ts b/web/src/routes/admin/+layout.ts new file mode 100644 index 00000000..e3f246f6 --- /dev/null +++ b/web/src/routes/admin/+layout.ts @@ -0,0 +1,13 @@ +import { redirect } from '@sveltejs/kit'; +import { user } from '$lib/auth/store.svelte'; +import type { LayoutLoad } from './$types'; + +// Hard route gate: runs before the layout (and any child page) renders. +// Auth is guaranteed bootstrapped by the root +layout.ts, so user.value is +// either a settled User object or null. +export const load: LayoutLoad = () => { + if (!user.value || !user.value.is_admin) { + throw redirect(302, '/'); + } + return {}; +}; diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte new file mode 100644 index 00000000..40f1c6d6 --- /dev/null +++ b/web/src/routes/admin/+page.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import { createAdminRequestsQuery, createLidarrConfigQuery } from '$lib/api/admin'; + + const requestsStore = createAdminRequestsQuery('pending'); + const requests = $derived($requestsStore); + + const configStore = createLidarrConfigQuery(); + const config = $derived($configStore); + + const pendingCount = $derived(requests.data?.length ?? 0); + const lidarrConnected = $derived(config.data?.enabled === true); +</script> + +<div class="space-y-6"> + <header> + <h2 class="font-display text-2xl font-medium text-text-primary">Overview</h2> + </header> + + <div class="grid gap-4 sm:grid-cols-2"> + <a + href="/admin/requests" + class="block rounded-xl border border-border bg-surface p-5 transition-colors hover:bg-surface-hover" + > + <div class="text-sm text-text-secondary">Pending requests</div> + <div class="font-display mt-1 text-3xl font-medium text-text-primary">{pendingCount}</div> + </a> + <a + href="/admin/integrations" + class="block rounded-xl border border-border bg-surface p-5 transition-colors hover:bg-surface-hover" + > + <div class="text-sm text-text-secondary">Lidarr</div> + <div class="font-display mt-1 text-2xl font-medium text-text-primary"> + {lidarrConnected ? 'Connected' : 'Unset'} + </div> + </a> + </div> +</div> diff --git a/web/src/routes/admin/admin.test.ts b/web/src/routes/admin/admin.test.ts new file mode 100644 index 00000000..e1221f9b --- /dev/null +++ b/web/src/routes/admin/admin.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { mockQuery } from '../../test-utils/query'; + +// Mutable mock-user handle so individual tests can flip is_admin / null +// without re-importing the module under test. +const userState = vi.hoisted(() => ({ + current: null as { id: string; username: string; is_admin: boolean } | null +})); + +vi.mock('$lib/auth/store.svelte', () => ({ + user: { get value() { return userState.current; } } +})); + +vi.mock('$app/state', () => ({ + page: { url: new URL('http://localhost/admin') } +})); + +vi.mock('$lib/api/admin', () => ({ + createAdminRequestsQuery: vi.fn(), + createLidarrConfigQuery: vi.fn() +})); + +afterEach(() => { + vi.clearAllMocks(); + userState.current = null; +}); + +describe('admin/+layout.ts load gate', () => { + test('non-admin user throws redirect to /', async () => { + userState.current = { id: 'u1', username: 'alice', is_admin: false }; + const { load } = await import('./+layout'); + await expect(async () => + // The SvelteKit `load` signature wants a LoadEvent; we don't read any of + // it inside the function, so an empty cast is sufficient for this gate. + load({} as Parameters<typeof load>[0]) + ).rejects.toMatchObject({ status: 302, location: '/' }); + }); + + test('unauthenticated (null user) throws redirect to /', async () => { + userState.current = null; + const { load } = await import('./+layout'); + await expect(async () => + load({} as Parameters<typeof load>[0]) + ).rejects.toMatchObject({ status: 302, location: '/' }); + }); + + test('admin user passes the gate', async () => { + userState.current = { id: 'u1', username: 'root', is_admin: true }; + const { load } = await import('./+layout'); + // load() is synchronous in the happy path; it only throws on the gate. + expect(load({} as Parameters<typeof load>[0])).toEqual({}); + }); +}); + +describe('admin Overview page', () => { + test('renders pending requests count and Lidarr status (connected)', async () => { + const { createAdminRequestsQuery, createLidarrConfigQuery } = await import('$lib/api/admin'); + (createAdminRequestsQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: [{ id: '1' }, { id: '2' }] }) + ); + (createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: { enabled: true } }) + ); + const { default: OverviewPage } = await import('./+page.svelte'); + render(OverviewPage); + expect(screen.getByText('Pending requests')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('Lidarr')).toBeInTheDocument(); + expect(screen.getByText(/connected/i)).toBeInTheDocument(); + }); + + test('shows zero pending and "Unset" when nothing is configured', async () => { + const { createAdminRequestsQuery, createLidarrConfigQuery } = await import('$lib/api/admin'); + (createAdminRequestsQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: [] }) + ); + (createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: { enabled: false } }) + ); + const { default: OverviewPage } = await import('./+page.svelte'); + render(OverviewPage); + expect(screen.getByText('0')).toBeInTheDocument(); + expect(screen.getByText(/unset/i)).toBeInTheDocument(); + }); + + test('Overview cards link to their sub-pages', async () => { + const { createAdminRequestsQuery, createLidarrConfigQuery } = await import('$lib/api/admin'); + (createAdminRequestsQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: [] }) + ); + (createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: { enabled: false } }) + ); + const { default: OverviewPage } = await import('./+page.svelte'); + render(OverviewPage); + const reqLink = screen.getByRole('link', { name: /pending requests/i }); + expect(reqLink).toHaveAttribute('href', '/admin/requests'); + const lidarrLink = screen.getByRole('link', { name: /lidarr/i }); + expect(lidarrLink).toHaveAttribute('href', '/admin/integrations'); + }); +}); From 041e63744d3ee1c232624c56f80f69ecc1f8ccb8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Wed, 29 Apr 2026 22:54:30 -0400 Subject: [PATCH 30/67] fix(web): promote bg-accent-tint to a real Tailwind utility Replaces the :global scoped-style workaround in AdminSidebar with a proper accent.tint color tier in tailwind.config.js. Tailwind generates bg-accent-tint deterministically; the global class no longer leaks from a single component. Future consumers (e.g. /admin/requests tab counts) get the same utility. --- web/src/lib/components/AdminSidebar.svelte | 9 --------- web/tailwind.config.js | 5 ++++- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/web/src/lib/components/AdminSidebar.svelte b/web/src/lib/components/AdminSidebar.svelte index 8097f1e9..381395ab 100644 --- a/web/src/lib/components/AdminSidebar.svelte +++ b/web/src/lib/components/AdminSidebar.svelte @@ -57,12 +57,3 @@ </ul> </nav> </aside> - -<style> - /* "12% accent-tinted bg" — the design-system rule for active nav state. - :global is required because the class is composed inside a templated - class string the Svelte scoped-CSS pass can't see. */ - :global(.bg-accent-tint) { - background: color-mix(in srgb, var(--fs-accent) 12%, transparent); - } -</style> diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 8833cac6..a0baaa4d 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -22,7 +22,10 @@ export default { secondary: 'var(--fs-bronze)', destructive: 'var(--fs-oxblood)' }, - accent: 'var(--fs-accent)', + accent: { + DEFAULT: 'var(--fs-accent)', + tint: 'color-mix(in srgb, var(--fs-accent) 12%, transparent)' + }, warning: 'var(--fs-warning)', error: 'var(--fs-error)', info: 'var(--fs-info)' From c281c8b5ddc0a669de232537ddf4f16836b45be3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Wed, 29 Apr 2026 23:06:19 -0400 Subject: [PATCH 31/67] feat(web): add /admin/integrations Lidarr connection panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connection management for Lidarr — base URL, API key, default quality profile, default root folder. Empty api_key on save preserves the masked-saved key per backend semantics. Disconnect requires typed "DISCONNECT" confirmation. Includes a placeholder MusicBrainz overrides section for future integrations. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- .../routes/admin/integrations/+page.svelte | 311 ++++++++++++++++++ .../admin/integrations/integrations.test.ts | 183 +++++++++++ 2 files changed, 494 insertions(+) create mode 100644 web/src/routes/admin/integrations/+page.svelte create mode 100644 web/src/routes/admin/integrations/integrations.test.ts diff --git a/web/src/routes/admin/integrations/+page.svelte b/web/src/routes/admin/integrations/+page.svelte new file mode 100644 index 00000000..3e3a914c --- /dev/null +++ b/web/src/routes/admin/integrations/+page.svelte @@ -0,0 +1,311 @@ +<script lang="ts"> + import { Save, RefreshCw, Trash2 } from 'lucide-svelte'; + import { useQueryClient } from '@tanstack/svelte-query'; + import { + createLidarrConfigQuery, + createQualityProfilesQuery, + createRootFoldersQuery, + putLidarrConfig, + testLidarrConnection + } from '$lib/api/admin'; + import { qk } from '$lib/api/queries'; + import type { LidarrConfig, LidarrTestResult } from '$lib/api/types'; + + // Lidarr connection panel. The "saved api key" is masked as "***" on GET — + // never displayed in the input. The input always starts empty; sending an + // empty string on PUT tells the backend "preserve the saved key". This is + // the one field with that semantics; everything else is sent as-typed. + + const client = useQueryClient(); + + const configStore = createLidarrConfigQuery(); + const config = $derived($configStore); + + // Local form state — initialized once when config first loads. Subsequent + // refetches don't clobber whatever the operator is in the middle of editing. + let baseUrl = $state(''); + let apiKeyInput = $state(''); // intentionally never seeded with '***' + let qualityId = $state<number>(0); + let rootPath = $state<string>(''); + let initialized = false; + + $effect(() => { + const c = config.data; + if (c && !initialized) { + baseUrl = c.base_url; + qualityId = c.default_quality_profile_id; + rootPath = c.default_root_folder_path; + initialized = true; + } + }); + + // The dropdown queries only fire once Lidarr is configured; otherwise the + // backend has no client to call and would 4xx. + const profilesEnabled = $derived(!!config.data?.enabled); + + const profilesStore = $derived(createQualityProfilesQuery(profilesEnabled)); + const profiles = $derived($profilesStore); + + const foldersStore = $derived(createRootFoldersQuery(profilesEnabled)); + const folders = $derived($foldersStore); + + let testResult: LidarrTestResult | null = $state(null); + let saveError: string | null = $state(null); + let isSaving = $state(false); + let isTesting = $state(false); + + async function onSave() { + isSaving = true; + saveError = null; + try { + const cfg: LidarrConfig = { + enabled: true, + base_url: baseUrl, + api_key: apiKeyInput, // empty string tells backend "preserve saved key" + default_quality_profile_id: qualityId, + default_root_folder_path: rootPath + }; + await putLidarrConfig(cfg); + await client.invalidateQueries({ queryKey: qk.lidarrConfig() }); + apiKeyInput = ''; + } catch (e) { + saveError = (e as { code?: string }).code ?? 'save_failed'; + } finally { + isSaving = false; + } + } + + async function onTest() { + isTesting = true; + testResult = null; + try { + testResult = await testLidarrConnection({ + base_url: baseUrl, + api_key: apiKeyInput + }); + } finally { + isTesting = false; + } + } + + // Disconnect typed-confirm modal. Requires the literal "DISCONNECT" string + // to avoid muscle-memory clearing of an integration the operator depends on. + let modalOpen = $state(false); + let disconnectInput = $state(''); + const canDisconnect = $derived(disconnectInput === 'DISCONNECT'); + + async function onConfirmDisconnect() { + if (!canDisconnect) return; + await putLidarrConfig({ + enabled: false, + base_url: '', + api_key: '', + default_quality_profile_id: 0, + default_root_folder_path: '' + }); + await client.invalidateQueries({ queryKey: qk.lidarrConfig() }); + modalOpen = false; + disconnectInput = ''; + } + + function cancelDisconnect() { + modalOpen = false; + disconnectInput = ''; + } +</script> + +<div class="space-y-6"> + <header class="flex items-center justify-between"> + <h2 class="font-display text-2xl font-medium text-text-primary">Integrations</h2> + {#if config.data?.enabled} + <span + class="inline-flex items-center gap-2 rounded-md bg-accent-tint px-3 py-1 text-xs font-medium text-action-primary" + > + <span class="h-1.5 w-1.5 rounded-full bg-action-primary"></span> + Lidarr · connected + </span> + {:else} + <span + class="inline-flex items-center gap-2 rounded-md border border-border px-3 py-1 text-xs text-text-muted" + > + <span class="h-1.5 w-1.5 rounded-full bg-text-muted"></span> + Lidarr · unset + </span> + {/if} + </header> + + <!-- Lidarr panel --> + <section class="space-y-4 rounded-xl border border-border bg-surface p-5"> + <div> + <h3 class="font-display text-lg font-medium text-text-primary">Lidarr</h3> + <p class="mt-1 text-sm text-text-secondary"> + Search Lidarr from <span class="font-mono text-accent">/discover</span> and + route approved requests to it. + </p> + </div> + + <label class="block"> + <span class="block text-sm text-text-secondary">Base URL</span> + <input + type="text" + bind:value={baseUrl} + placeholder="http://lidarr.lan:8686" + class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" + /> + </label> + + <label class="block"> + <span class="block text-sm text-text-secondary">API key</span> + <input + type="password" + bind:value={apiKeyInput} + placeholder={config.data?.api_key === '***' + ? '••• (saved — leave empty to keep)' + : 'Paste API key'} + class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" + /> + </label> + + <label class="block"> + <span class="block text-sm text-text-secondary">Default quality profile</span> + <select + bind:value={qualityId} + disabled={!profilesEnabled || profiles.isPending} + class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-50" + > + {#each profiles.data ?? [] as p (p.id)} + <option value={p.id}>{p.name}</option> + {/each} + </select> + </label> + + <label class="block"> + <span class="block text-sm text-text-secondary">Default root folder</span> + <select + bind:value={rootPath} + disabled={!profilesEnabled || folders.isPending} + class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-50" + > + {#each folders.data ?? [] as f (f.path)} + <option value={f.path} + >{f.path}{f.accessible ? '' : ' (not accessible)'}</option + > + {/each} + </select> + </label> + + {#if testResult} + {#if testResult.ok} + <p class="text-sm text-action-primary"> + Connected — Lidarr {testResult.version} + </p> + {:else} + <p class="text-sm text-error">Connection failed — {testResult.error}</p> + {/if} + {/if} + {#if saveError} + <p class="text-sm text-error">Save failed — {saveError}</p> + {/if} + + <div class="flex items-center gap-2 pt-2"> + <button + type="button" + onclick={onSave} + disabled={isSaving} + class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-text-primary disabled:opacity-50" + > + <Save size={14} strokeWidth={2} /> + Save changes + </button> + <button + type="button" + onclick={onTest} + disabled={isTesting} + class="inline-flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50" + > + <RefreshCw size={14} strokeWidth={2} /> + Test connection + </button> + <button + type="button" + onclick={() => (modalOpen = true)} + class="ml-auto inline-flex items-center gap-2 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-text-primary" + > + <Trash2 size={14} strokeWidth={2} /> + Disconnect + </button> + </div> + </section> + + <!-- MusicBrainz overrides — visually present, not implemented. Foreshadows + future integrations so the page doesn't read like Lidarr-only. --> + <section class="space-y-2 rounded-xl border border-border bg-surface p-5 opacity-60"> + <div class="flex items-center justify-between"> + <h3 class="font-display text-lg font-medium text-text-primary"> + MusicBrainz overrides + </h3> + <span + class="inline-flex items-center gap-2 rounded-md border border-border px-3 py-1 text-xs text-text-muted" + > + <span class="h-1.5 w-1.5 rounded-full bg-text-muted"></span> + unset + </span> + </div> + <p class="text-sm text-text-secondary">Not yet configured.</p> + </section> +</div> + +{#if modalOpen} + <!-- svelte-ignore a11y_click_events_have_key_events --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + class="fixed inset-0 z-50 flex items-center justify-center" + style="background: rgba(0,0,0,0.5);" + onclick={cancelDisconnect} + > + <div + role="dialog" + aria-modal="true" + aria-labelledby="disconnect-title" + class="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-lg" + onclick={(e) => e.stopPropagation()} + tabindex="-1" + > + <h3 + id="disconnect-title" + class="font-display text-lg font-medium text-text-primary" + > + Disconnect Lidarr? + </h3> + <p class="mt-2 text-text-secondary"> + This clears the saved configuration. Type + <span class="font-mono text-text-primary">DISCONNECT</span> to remove the + Lidarr connection. + </p> + <input + type="text" + bind:value={disconnectInput} + placeholder="DISCONNECT" + aria-label="Type DISCONNECT to confirm" + class="mt-3 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" + /> + <div class="mt-5 flex justify-end gap-2"> + <button + type="button" + onclick={cancelDisconnect} + class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary" + > + Cancel + </button> + <button + type="button" + onclick={onConfirmDisconnect} + disabled={!canDisconnect} + class="rounded-md bg-action-destructive px-3 py-1.5 text-sm text-text-primary disabled:opacity-50" + > + Disconnect + </button> + </div> + </div> + </div> +{/if} diff --git a/web/src/routes/admin/integrations/integrations.test.ts b/web/src/routes/admin/integrations/integrations.test.ts new file mode 100644 index 00000000..120c6acc --- /dev/null +++ b/web/src/routes/admin/integrations/integrations.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import { mockQuery } from '../../../test-utils/query'; +import type { LidarrConfig } from '$lib/api/types'; + +// useQueryClient is wrapped so the page can call invalidateQueries() without +// a real QueryClient context. Everything else from svelte-query passes through. +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record<string, unknown>; + return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) }; +}); + +vi.mock('$lib/api/admin', () => ({ + createLidarrConfigQuery: vi.fn(), + createQualityProfilesQuery: vi.fn(), + createRootFoldersQuery: vi.fn(), + putLidarrConfig: vi.fn(), + testLidarrConnection: vi.fn() +})); + +import IntegrationsPage from './+page.svelte'; +import { + createLidarrConfigQuery, + createQualityProfilesQuery, + createRootFoldersQuery, + putLidarrConfig, + testLidarrConnection +} from '$lib/api/admin'; + +const cfgConnected: LidarrConfig = { + enabled: true, + base_url: 'http://lidarr.local', + api_key: '***', + default_quality_profile_id: 1, + default_root_folder_path: '/music' +}; + +const cfgUnset: LidarrConfig = { + enabled: false, + base_url: '', + api_key: '', + default_quality_profile_id: 0, + default_root_folder_path: '' +}; + +afterEach(() => { + vi.clearAllMocks(); +}); + +function setup(opts: { config?: LidarrConfig } = {}) { + (createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: opts.config ?? cfgConnected }) + ); + (createQualityProfilesQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ + data: [ + { id: 1, name: 'Standard' }, + { id: 2, name: 'Lossless' } + ] + }) + ); + (createRootFoldersQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: [{ path: '/music', accessible: true, free_space: 0 }] }) + ); + return render(IntegrationsPage); +} + +// Helper for finding the modal's Disconnect confirm button. Both the panel +// trigger and the modal confirm share the visible name "Disconnect"; the +// modal one is the descendant of role="dialog". +function modalConfirmButton(): HTMLButtonElement { + const matches = screen.getAllByRole('button', { name: /^disconnect$/i }); + const inDialog = matches.find((b) => b.closest('[role="dialog"]') !== null); + if (!inDialog) throw new Error('modal Disconnect button not found'); + return inDialog as HTMLButtonElement; +} + +describe('/admin/integrations', () => { + test('Save calls putLidarrConfig with empty api_key when input is blank', async () => { + setup(); + (putLidarrConfig as ReturnType<typeof vi.fn>).mockResolvedValueOnce(cfgConnected); + await fireEvent.click(screen.getByRole('button', { name: /save changes/i })); + expect(putLidarrConfig).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + base_url: 'http://lidarr.local', + api_key: '', + default_quality_profile_id: 1, + default_root_folder_path: '/music' + }) + ); + }); + + test('Save with new api_key sends the typed value', async () => { + setup(); + const apiInput = screen.getByPlaceholderText(/saved — leave empty to keep/i); + await fireEvent.input(apiInput, { target: { value: 'newkey' } }); + (putLidarrConfig as ReturnType<typeof vi.fn>).mockResolvedValueOnce(cfgConnected); + await fireEvent.click(screen.getByRole('button', { name: /save changes/i })); + expect(putLidarrConfig).toHaveBeenCalledWith( + expect.objectContaining({ api_key: 'newkey' }) + ); + }); + + test('Test connection renders Lidarr version on success', async () => { + setup(); + (testLidarrConnection as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ + ok: true, + version: '2.0.0' + }); + await fireEvent.click( + screen.getByRole('button', { name: /test connection/i }) + ); + await waitFor(() => + expect(screen.getByText(/lidarr 2\.0\.0/i)).toBeInTheDocument() + ); + }); + + test('Test connection renders error code on failure', async () => { + setup(); + (testLidarrConnection as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ + ok: false, + error: 'lidarr_unreachable' + }); + await fireEvent.click( + screen.getByRole('button', { name: /test connection/i }) + ); + await waitFor(() => + expect(screen.getByText(/lidarr_unreachable/i)).toBeInTheDocument() + ); + }); + + test('Disconnect requires typed confirm; cancelling does not call API', async () => { + setup(); + // Open modal via the panel-level Disconnect button. + await fireEvent.click( + screen.getByRole('button', { name: /^disconnect$/i }) + ); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + // Confirm button is disabled until "DISCONNECT" is typed. + expect(modalConfirmButton().disabled).toBe(true); + await fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(putLidarrConfig).not.toHaveBeenCalled(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + test('Disconnect with typed confirmation calls API with cleared config', async () => { + setup(); + await fireEvent.click( + screen.getByRole('button', { name: /^disconnect$/i }) + ); + const confirmInput = screen.getByPlaceholderText('DISCONNECT'); + await fireEvent.input(confirmInput, { target: { value: 'DISCONNECT' } }); + (putLidarrConfig as ReturnType<typeof vi.fn>).mockResolvedValueOnce(cfgUnset); + await fireEvent.click(modalConfirmButton()); + expect(putLidarrConfig).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + base_url: '', + api_key: '', + default_quality_profile_id: 0, + default_root_folder_path: '' + }) + ); + }); + + test('Quality profile and root folder dropdowns populate from API', () => { + setup(); + expect(screen.getByText('Standard')).toBeInTheDocument(); + expect(screen.getByText('Lossless')).toBeInTheDocument(); + expect(screen.getByText('/music')).toBeInTheDocument(); + }); + + test('header shows "Lidarr · connected" when configured', () => { + setup(); + expect(screen.getByText(/lidarr · connected/i)).toBeInTheDocument(); + }); + + test('header shows "Lidarr · unset" when not configured', () => { + setup({ config: cfgUnset }); + expect(screen.getByText(/lidarr · unset/i)).toBeInTheDocument(); + }); +}); From b1b50187b38b2caaf2b231af378551a02033ddc5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Wed, 29 Apr 2026 23:11:32 -0400 Subject: [PATCH 32/67] fix(web): integrations panel review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Trim whitespace before checking the typed-confirm value (paste-with-spaces was silently keeping the button disabled). - Wrap onConfirmDisconnect in try/catch and surface disconnect_failed inline in the modal — operators on flaky networks now see why nothing happened. - Invalidate quality-profile + root-folder caches alongside config on both save and disconnect, so a base_url change refetches the lists immediately. --- .../routes/admin/integrations/+page.svelte | 47 ++++++++++++++----- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/web/src/routes/admin/integrations/+page.svelte b/web/src/routes/admin/integrations/+page.svelte index 3e3a914c..8c83ab5e 100644 --- a/web/src/routes/admin/integrations/+page.svelte +++ b/web/src/routes/admin/integrations/+page.svelte @@ -66,7 +66,13 @@ default_root_folder_path: rootPath }; await putLidarrConfig(cfg); - await client.invalidateQueries({ queryKey: qk.lidarrConfig() }); + // Invalidate config + profile/folder lists. A new base_url means a + // different server, so the cached lists must refetch. + await Promise.all([ + client.invalidateQueries({ queryKey: qk.lidarrConfig() }), + client.invalidateQueries({ queryKey: qk.lidarrQualityProfiles() }), + client.invalidateQueries({ queryKey: qk.lidarrRootFolders() }) + ]); apiKeyInput = ''; } catch (e) { saveError = (e as { code?: string }).code ?? 'save_failed'; @@ -89,28 +95,40 @@ } // Disconnect typed-confirm modal. Requires the literal "DISCONNECT" string - // to avoid muscle-memory clearing of an integration the operator depends on. + // (whitespace-trimmed) to avoid muscle-memory clearing of an integration the + // operator depends on. let modalOpen = $state(false); let disconnectInput = $state(''); - const canDisconnect = $derived(disconnectInput === 'DISCONNECT'); + let disconnectError = $state<string | null>(null); + const canDisconnect = $derived(disconnectInput.trim() === 'DISCONNECT'); async function onConfirmDisconnect() { if (!canDisconnect) return; - await putLidarrConfig({ - enabled: false, - base_url: '', - api_key: '', - default_quality_profile_id: 0, - default_root_folder_path: '' - }); - await client.invalidateQueries({ queryKey: qk.lidarrConfig() }); - modalOpen = false; - disconnectInput = ''; + disconnectError = null; + try { + await putLidarrConfig({ + enabled: false, + base_url: '', + api_key: '', + default_quality_profile_id: 0, + default_root_folder_path: '' + }); + await Promise.all([ + client.invalidateQueries({ queryKey: qk.lidarrConfig() }), + client.invalidateQueries({ queryKey: qk.lidarrQualityProfiles() }), + client.invalidateQueries({ queryKey: qk.lidarrRootFolders() }) + ]); + modalOpen = false; + disconnectInput = ''; + } catch (e) { + disconnectError = (e as { code?: string }).code ?? 'disconnect_failed'; + } } function cancelDisconnect() { modalOpen = false; disconnectInput = ''; + disconnectError = null; } </script> @@ -289,6 +307,9 @@ aria-label="Type DISCONNECT to confirm" class="mt-3 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" /> + {#if disconnectError} + <p class="mt-2 text-sm text-error">Disconnect failed — {disconnectError}</p> + {/if} <div class="mt-5 flex justify-end gap-2"> <button type="button" From b895e9ef7e7522dd95c233f7eca75649da77af8e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Wed, 29 Apr 2026 23:24:34 -0400 Subject: [PATCH 33/67] feat(web): add /admin/requests approval queue with override modal Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- web/src/routes/admin/requests/+page.svelte | 444 ++++++++++++++++++ .../routes/admin/requests/requests.test.ts | 130 +++++ 2 files changed, 574 insertions(+) create mode 100644 web/src/routes/admin/requests/+page.svelte create mode 100644 web/src/routes/admin/requests/requests.test.ts diff --git a/web/src/routes/admin/requests/+page.svelte b/web/src/routes/admin/requests/+page.svelte new file mode 100644 index 00000000..f0eb75e3 --- /dev/null +++ b/web/src/routes/admin/requests/+page.svelte @@ -0,0 +1,444 @@ +<script lang="ts"> + import { Disc3, Album, Music2, Check, X, SlidersHorizontal } from 'lucide-svelte'; + import { useQueryClient } from '@tanstack/svelte-query'; + import { + createAdminRequestsQuery, + createQualityProfilesQuery, + createRootFoldersQuery, + approveRequest, + rejectRequest + } from '$lib/api/admin'; + import { qk } from '$lib/api/queries'; + import StatusPill from '$lib/components/StatusPill.svelte'; + import type { LidarrRequest, LidarrRequestKind, LidarrRequestStatus } from '$lib/api/types'; + + // Approval queue. Tabs filter by status; rows expose Override/Approve/Reject + // per spec. The override modal collapses by default — most requests get the + // snapshot defaults, so we don't make the operator stare at two dropdowns + // they almost never touch. + + const client = useQueryClient(); + + // The four tab filters. "Pending" is default — that's the actionable bucket; + // the rest are review-only views. + const tabs: { status: LidarrRequestStatus; label: string }[] = [ + { status: 'pending', label: 'Pending' }, + { status: 'approved', label: 'Approved' }, + { status: 'completed', label: 'Completed' }, + { status: 'rejected', label: 'Rejected' } + ]; + + let activeStatus = $state<LidarrRequestStatus>('pending'); + + // The query factory is re-created on every status flip so TanStack treats + // each status as its own cache key. We don't need a single shared store. + const queryStore = $derived(createAdminRequestsQuery(activeStatus)); + const query = $derived($queryStore); + const rows = $derived((query.data ?? []) as LidarrRequest[]); + + // Quality + root dropdowns for the override modal. Always enabled; if Lidarr + // is disabled the modal is unreachable from this page anyway (the queue + // would be empty), and on this page we'd rather see the dropdown options + // than block the modal on a separate gate. + const profilesStore = createQualityProfilesQuery(true); + const profiles = $derived($profilesStore); + const foldersStore = createRootFoldersQuery(true); + const folders = $derived($foldersStore); + + // Modal state. `overrideOpen` and `rejectOpen` carry the request id when + // open; null otherwise. The current row is looked up by id at render time. + let overrideOpen = $state<string | null>(null); + let qualityOverride = $state<number | ''>(''); + let rootOverride = $state<string>(''); + + let rejectOpen = $state<string | null>(null); + let rejectNotes = $state<string>(''); + + // Toast surface — single line, fixed bottom-right, auto-clears after 5s. + // No third-party lib for v1; this is enough to surface the lidarr-unreachable + // error per spec §7 without dragging in a toast framework. + let toast = $state<string | null>(null); + let toastTimer: ReturnType<typeof setTimeout> | null = null; + + function showToast(msg: string) { + if (toastTimer) clearTimeout(toastTimer); + toast = msg; + toastTimer = setTimeout(() => { + toast = null; + }, 5000); + } + + function errorCopy(code: string): string { + switch (code) { + case 'lidarr_unreachable': + return "Lidarr is unreachable right now. Try again, or check Settings → Integrations."; + case 'lidarr_disabled': + return 'Lidarr integration is not enabled.'; + case 'lidarr_auth_failed': + return 'Lidarr authentication failed.'; + case 'request_not_pending': + return 'This request is no longer pending.'; + default: + return code ? code : "Couldn't reach Lidarr."; + } + } + + function fallbackIcon(kind: LidarrRequestKind) { + if (kind === 'artist') return Disc3; + if (kind === 'album') return Album; + return Music2; + } + + function rowTitle(r: LidarrRequest): string { + if (r.kind === 'artist') return r.artist_name; + if (r.kind === 'album') return r.album_title ?? '—'; + return r.track_title ?? '—'; + } + + function rowAccessibleName(r: LidarrRequest): string { + if (r.kind === 'artist') return r.artist_name; + if (r.kind === 'album') return r.album_title ?? `this album by ${r.artist_name}`; + return r.track_title ?? `this track by ${r.artist_name}`; + } + + // The id is a UUID — first 8 chars is plenty to disambiguate without a real + // username, and matches what the polish pass will replace with the real + // username surface. + function rowMeta(r: LidarrRequest): string { + const when = new Date(r.requested_at).toLocaleDateString(); + const userBit = `by user ${r.user_id.slice(0, 8)}`; + return `${userBit} · ${when}`; + } + + async function invalidate() { + // Invalidate every status bucket so e.g. an approve flips a row from the + // pending list to the approved list once the operator switches tabs. + await client.invalidateQueries({ queryKey: ['adminRequests'] }); + } + + async function onApprove( + r: LidarrRequest, + overrides?: { quality_profile_id?: number; root_folder_path?: string } + ) { + try { + if (overrides && (overrides.quality_profile_id !== undefined || overrides.root_folder_path !== undefined)) { + await approveRequest(r.id, overrides); + } else { + await approveRequest(r.id); + } + await invalidate(); + } catch (e) { + const code = (e as { code?: string }).code ?? 'unknown'; + showToast(errorCopy(code)); + } + } + + function openOverride(r: LidarrRequest) { + overrideOpen = r.id; + // Reset to "Use defaults" each time — operators should consciously opt in + // to an override, not inherit the previous row's choice. + qualityOverride = ''; + rootOverride = ''; + } + + function cancelOverride() { + overrideOpen = null; + } + + async function confirmOverride(r: LidarrRequest) { + const overrides: { quality_profile_id?: number; root_folder_path?: string } = {}; + if (qualityOverride !== '') overrides.quality_profile_id = Number(qualityOverride); + if (rootOverride !== '') overrides.root_folder_path = rootOverride; + overrideOpen = null; + await onApprove(r, overrides); + } + + function openReject(r: LidarrRequest) { + rejectOpen = r.id; + rejectNotes = ''; + } + + function cancelReject() { + rejectOpen = null; + } + + async function confirmReject(r: LidarrRequest) { + const notes = rejectNotes.trim(); + rejectOpen = null; + try { + await rejectRequest(r.id, notes ? notes : undefined); + await invalidate(); + } catch (e) { + const code = (e as { code?: string }).code ?? 'unknown'; + showToast(errorCopy(code)); + } + } + + // Modal lookups: if the active tab has been refetched while a modal is + // open, the row may no longer be in `rows`. Guard against that by treating + // missing-row as closed. + const overrideRow = $derived( + overrideOpen ? rows.find((r) => r.id === overrideOpen) ?? null : null + ); + const rejectRow = $derived( + rejectOpen ? rows.find((r) => r.id === rejectOpen) ?? null : null + ); +</script> + +<div class="space-y-6"> + <header class="space-y-1"> + <h2 class="font-display text-2xl font-medium text-text-primary">Requests</h2> + <p class="text-text-secondary">Approve or set aside what users have asked for.</p> + </header> + + <nav aria-label="Request status filters" class="border-b border-border"> + <ul class="flex gap-2"> + {#each tabs as tab (tab.status)} + {@const isActive = activeStatus === tab.status} + <li> + <button + type="button" + role="tab" + aria-selected={isActive} + class="border-b-2 px-3 py-2 text-sm transition-colors {isActive + ? 'border-accent text-text-primary' + : 'border-transparent text-text-secondary hover:text-text-primary'}" + onclick={() => (activeStatus = tab.status)} + > + {tab.label} + {#if isActive} + <span + class="ml-1.5 inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent" + data-testid="active-tab-count" + > + {rows.length} + </span> + {/if} + </button> + </li> + {/each} + </ul> + </nav> + + {#if query.isPending} + <p class="text-text-secondary">Reading the queue…</p> + {:else if query.isError} + <p class="text-error">Couldn't load requests.</p> + {:else if rows.length === 0} + <p class="text-text-secondary">Nothing here.</p> + {:else} + <ul class="divide-y divide-border rounded-lg border border-border bg-surface"> + {#each rows as r (r.id)} + {@const Icon = fallbackIcon(r.kind)} + <li class="flex items-start gap-4 p-3" data-testid="admin-request-row" data-status={r.status}> + <div + class="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-surface-hover" + aria-hidden="true" + > + <Icon size={24} strokeWidth={1} class="text-text-muted" /> + </div> + + <div class="min-w-0 flex-1 space-y-1"> + <div class="flex flex-wrap items-center gap-2"> + <span class="kind-pill">{r.kind}</span> + <StatusPill status={r.status} /> + </div> + <div class="truncate text-base font-medium text-text-primary"> + {rowTitle(r)} + </div> + <div class="truncate text-sm text-text-secondary"> + {rowMeta(r)} + </div> + {#if r.kind === 'track' && r.album_title} + <div class="text-sm text-text-secondary" data-testid="track-disclosure"> + Approving will add the album <em class="font-medium text-text-primary">{r.album_title}</em>. + </div> + {/if} + {#if r.notes} + <div class="text-sm text-text-secondary">{r.notes}</div> + {/if} + </div> + + {#if r.status === 'pending'} + <div class="flex shrink-0 items-center gap-2"> + <button + type="button" + aria-label={`Override ${rowAccessibleName(r)}`} + class="inline-flex items-center gap-1 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-hover" + onclick={() => openOverride(r)} + > + <SlidersHorizontal size={14} strokeWidth={1.5} /> + Override + </button> + <button + type="button" + aria-label={`Approve ${rowAccessibleName(r)}`} + class="inline-flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-text-primary" + onclick={() => onApprove(r)} + > + <Check size={14} strokeWidth={2} /> + Approve + </button> + <button + type="button" + aria-label={`Reject ${rowAccessibleName(r)}`} + class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary" + onclick={() => openReject(r)} + > + <X size={14} strokeWidth={2} /> + Reject + </button> + </div> + {/if} + </li> + {/each} + </ul> + {/if} +</div> + +{#if overrideRow} + <!-- svelte-ignore a11y_click_events_have_key_events --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + class="fixed inset-0 z-50 flex items-center justify-center" + style="background: rgba(0,0,0,0.5);" + onclick={cancelOverride} + > + <div + role="dialog" + aria-modal="true" + aria-labelledby="override-title" + class="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-lg" + onclick={(e) => e.stopPropagation()} + tabindex="-1" + > + <h3 id="override-title" class="font-display text-lg font-medium text-text-primary"> + Approve with override + </h3> + <p class="mt-2 text-sm text-text-secondary"> + Leave fields blank to use the saved defaults. + </p> + + <label class="mt-4 block"> + <span class="block text-sm text-text-secondary">Quality profile</span> + <select + bind:value={qualityOverride} + class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent" + > + <option value="">Use default</option> + {#each profiles.data ?? [] as p (p.id)} + <option value={p.id}>{p.name}</option> + {/each} + </select> + </label> + + <label class="mt-3 block"> + <span class="block text-sm text-text-secondary">Root folder</span> + <select + bind:value={rootOverride} + class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent" + > + <option value="">Use default</option> + {#each folders.data ?? [] as f (f.path)} + <option value={f.path}>{f.path}{f.accessible ? '' : ' (not accessible)'}</option> + {/each} + </select> + </label> + + <div class="mt-5 flex justify-end gap-2"> + <button + type="button" + class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary" + onclick={cancelOverride} + > + Cancel + </button> + <button + type="button" + class="inline-flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-text-primary" + onclick={() => confirmOverride(overrideRow!)} + > + <Check size={14} strokeWidth={2} /> + Approve with override + </button> + </div> + </div> + </div> +{/if} + +{#if rejectRow} + <!-- svelte-ignore a11y_click_events_have_key_events --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + class="fixed inset-0 z-50 flex items-center justify-center" + style="background: rgba(0,0,0,0.5);" + onclick={cancelReject} + > + <div + role="dialog" + aria-modal="true" + aria-labelledby="reject-title" + class="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-lg" + onclick={(e) => e.stopPropagation()} + tabindex="-1" + > + <h3 id="reject-title" class="font-display text-lg font-medium text-text-primary"> + Reject request? + </h3> + <p class="mt-2 text-sm text-text-secondary"> + Notes are shown to the requester. + </p> + <label class="mt-3 block"> + <span class="block text-sm text-text-secondary">Notes</span> + <textarea + bind:value={rejectNotes} + rows="3" + placeholder="Optional — why this is being set aside" + class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" + ></textarea> + </label> + <div class="mt-5 flex justify-end gap-2"> + <button + type="button" + class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary" + onclick={cancelReject} + > + Cancel + </button> + <button + type="button" + class="inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-text-primary" + onclick={() => confirmReject(rejectRow!)} + > + <X size={14} strokeWidth={2} /> + Confirm reject + </button> + </div> + </div> + </div> +{/if} + +{#if toast} + <div + role="status" + aria-live="polite" + class="fixed bottom-4 right-4 z-50 max-w-sm rounded-md border border-border bg-surface px-4 py-3 text-sm text-text-primary shadow-lg" + data-testid="toast" + > + {toast} + </div> +{/if} + +<style> + .kind-pill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + line-height: 14px; + background: color-mix(in srgb, var(--fs-accent) 15%, transparent); + color: var(--fs-accent); + text-transform: capitalize; + } +</style> diff --git a/web/src/routes/admin/requests/requests.test.ts b/web/src/routes/admin/requests/requests.test.ts new file mode 100644 index 00000000..799d1b0c --- /dev/null +++ b/web/src/routes/admin/requests/requests.test.ts @@ -0,0 +1,130 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import { mockQuery } from '../../../test-utils/query'; +import type { LidarrRequest } from '$lib/api/types'; + +// Wrap useQueryClient so the page can call invalidateQueries() without a +// real QueryClient context. Everything else from svelte-query passes through. +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record<string, unknown>; + return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) }; +}); + +vi.mock('$lib/api/admin', () => ({ + createAdminRequestsQuery: vi.fn(), + createQualityProfilesQuery: vi.fn(), + createRootFoldersQuery: vi.fn(), + approveRequest: vi.fn(), + rejectRequest: vi.fn() +})); + +import AdminRequestsPage from './+page.svelte'; +import { + createAdminRequestsQuery, + createQualityProfilesQuery, + createRootFoldersQuery, + approveRequest, + rejectRequest +} from '$lib/api/admin'; + +const baseRow: LidarrRequest = { + id: 'r1', + user_id: 'u1234567-aaaa-bbbb-cccc-deadbeef0001', + status: 'pending', + kind: 'album', + lidarr_artist_mbid: 'a-mbid', + lidarr_album_mbid: 'al-mbid', + artist_name: 'Boards of Canada', + album_title: 'Geogaddi', + requested_at: '2026-04-29T10:00:00Z', + updated_at: '2026-04-29T10:00:00Z' +}; + +afterEach(() => vi.clearAllMocks()); + +function setup(rows: LidarrRequest[] = [baseRow]) { + (createAdminRequestsQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: rows }) + ); + (createQualityProfilesQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: [{ id: 1, name: 'Standard' }] }) + ); + (createRootFoldersQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: [{ path: '/music', accessible: true, free_space: 0 }] }) + ); + return render(AdminRequestsPage); +} + +describe('/admin/requests', () => { + test('Tab switch refetches with new status filter', async () => { + setup(); + await fireEvent.click(screen.getByRole('tab', { name: /approved/i })); + expect(createAdminRequestsQuery).toHaveBeenCalledWith('approved'); + }); + + test('Approve without override fires POST with no override body', async () => { + setup(); + (approveRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseRow); + await fireEvent.click(screen.getByRole('button', { name: /approve geogaddi/i })); + expect(approveRequest).toHaveBeenCalledWith('r1'); + }); + + test('Override modal Confirm sends chosen values', async () => { + setup(); + await fireEvent.click(screen.getByRole('button', { name: /override geogaddi/i })); + await fireEvent.change(screen.getByLabelText(/quality profile/i), { + target: { value: '1' } + }); + await fireEvent.change(screen.getByLabelText(/root folder/i), { + target: { value: '/music' } + }); + (approveRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseRow); + await fireEvent.click( + screen.getByRole('button', { name: /approve with override/i }) + ); + expect(approveRequest).toHaveBeenCalledWith('r1', { + quality_profile_id: 1, + root_folder_path: '/music' + }); + }); + + test('Reject opens notes textarea; confirm sends notes', async () => { + setup(); + await fireEvent.click(screen.getByRole('button', { name: /reject geogaddi/i })); + const textarea = screen.getByLabelText(/notes/i) as HTMLTextAreaElement; + await fireEvent.input(textarea, { target: { value: 'duplicate' } }); + (rejectRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ + ...baseRow, + status: 'rejected' + }); + await fireEvent.click( + screen.getByRole('button', { name: /confirm reject/i }) + ); + expect(rejectRequest).toHaveBeenCalledWith('r1', 'duplicate'); + }); + + test('Lidarr-unreachable error shows toast', async () => { + setup(); + (approveRequest as ReturnType<typeof vi.fn>).mockRejectedValueOnce({ + code: 'lidarr_unreachable', + message: 'unreachable', + status: 503 + }); + await fireEvent.click(screen.getByRole('button', { name: /approve geogaddi/i })); + await waitFor(() => + expect(screen.getByText(/lidarr is unreachable/i)).toBeInTheDocument() + ); + }); + + test('Track-kind row renders disclosure copy about adding the album', () => { + setup([ + { + ...baseRow, + kind: 'track', + track_title: 'Roygbiv', + album_title: 'Geogaddi' + } + ]); + expect(screen.getByText(/will add the album/i)).toBeInTheDocument(); + }); +}); From ab9be40bb242fdb675372db583d71acfbe538ace Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Wed, 29 Apr 2026 23:31:18 -0400 Subject: [PATCH 34/67] feat(web): add /admin/requests approval queue with override modal Pending/Approved/Completed/Rejected tabs (active count only). Override modal collapsed by default; reject modal with notes textarea. Lidarr-unreachable toast surfaces sync-Approve failures. Cancel buttons in both modals are Pewter ghost; Reject/Confirm reject use Bronze (the design system reserves Oxblood for irreversible actions like Disconnect). --- web/src/routes/admin/requests/+page.svelte | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/web/src/routes/admin/requests/+page.svelte b/web/src/routes/admin/requests/+page.svelte index f0eb75e3..a97e94be 100644 --- a/web/src/routes/admin/requests/+page.svelte +++ b/web/src/routes/admin/requests/+page.svelte @@ -8,7 +8,6 @@ approveRequest, rejectRequest } from '$lib/api/admin'; - import { qk } from '$lib/api/queries'; import StatusPill from '$lib/components/StatusPill.svelte'; import type { LidarrRequest, LidarrRequestKind, LidarrRequestStatus } from '$lib/api/types'; @@ -348,7 +347,7 @@ <div class="mt-5 flex justify-end gap-2"> <button type="button" - class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary" + class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary" onclick={cancelOverride} > Cancel @@ -400,14 +399,14 @@ <div class="mt-5 flex justify-end gap-2"> <button type="button" - class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary" + class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary" onclick={cancelReject} > Cancel </button> <button type="button" - class="inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-text-primary" + class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary" onclick={() => confirmReject(rejectRow!)} > <X size={14} strokeWidth={2} /> From db37deb6f876fd1c0e866f9eb7cce0070760a3aa Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 10:58:21 -0400 Subject: [PATCH 35/67] test(lidarrrequests): expand coverage on Service.Approve + Reconciler - Adds Service tests for ListPending/ListByStatus/ListForUser, the album- and unknown-kind validation branches, and an Approve happy path with a stub Lidarr server that exercises both default-snapshot and override- snapshot persistence. - Adds Reconciler tests for Run (short tick + cancel), the album-not-yet- in-library no-op branch, and the track-kind 'parent album present but no tracks yet' branch. Combined coverage on internal/lidarr*, lidarrconfig, lidarrrequests now 86.5% (lidarrrequests 81.5%), meeting the per-package >=80% target in spec section 8. --- .../reconciler_integration_test.go | 116 +++++++++++ internal/lidarrrequests/service_test.go | 186 ++++++++++++++++++ 2 files changed, 302 insertions(+) diff --git a/internal/lidarrrequests/reconciler_integration_test.go b/internal/lidarrrequests/reconciler_integration_test.go index 2c2c8c96..ff13471f 100644 --- a/internal/lidarrrequests/reconciler_integration_test.go +++ b/internal/lidarrrequests/reconciler_integration_test.go @@ -5,6 +5,7 @@ import ( "io" "log/slog" "testing" + "time" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" @@ -325,3 +326,118 @@ func TestReconciler_DisabledIsNoOp(t *testing.T) { t.Errorf("status = %v, want approved — reconciler should no-op when disabled", got.Status) } } + +// TestReconciler_RunRunsAtLeastOneTick exercises Run's goroutine loop with a +// short tick, verifies it picks up an approved row and transitions it to +// completed before the test cancels the context. +func TestReconciler_RunRunsAtLeastOneTick(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + pool := newPool(t) + enableLidarrForPool(t, pool) + q := dbq.New(pool) + user := seedUser(t, pool) + + const mbid = "run-mbid" + seedArtist(t, q, "Run Artist", mbid) + req := seedApprovedRequestDirect(t, q, user, CreateParams{ + Kind: "artist", LidarrArtistMBID: mbid, ArtistName: "Run Artist", + }) + + rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger()) + rec.tick = 25 * time.Millisecond + + done := make(chan struct{}) + go func() { + rec.Run(ctx) + close(done) + }() + + // Poll the row up to ~2s for the reconciler to flip it. Beats sleeping + // a fixed amount on slow CI. + deadline := time.Now().Add(2 * time.Second) + for { + got, err := q.GetLidarrRequestByID(ctx, req.ID) + if err == nil && got.Status == dbq.LidarrRequestStatusCompleted { + break + } + if time.Now().After(deadline) { + t.Fatalf("status not completed within 2s; last err=%v", err) + } + time.Sleep(10 * time.Millisecond) + } + + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("Run did not exit within 1s of cancel") + } +} + +// TestReconciler_TrackKindNoTracksInAlbumYet: the parent album exists in the +// library but no track rows have been ingested yet. Reconciler should leave +// the row approved so a subsequent tick can pick it up once tracks land. +func TestReconciler_TrackKindNoTracksInAlbumYet(t *testing.T) { + pool := newPool(t) + q := dbq.New(pool) + ctx := context.Background() + + enableLidarrForPool(t, pool) + user := seedUser(t, pool) + artistMBID := "ar-mbid-tracks-pending" + albumMBID := "al-mbid-tracks-pending" + + artist := seedArtist(t, q, "Pending Tracks Artist", artistMBID) + _ = seedAlbum(t, q, artist.ID, "Album Without Tracks", albumMBID) + // No tracks seeded. + + req := seedApprovedRequestDirect(t, q, user, CreateParams{ + Kind: "track", LidarrArtistMBID: artistMBID, ArtistName: "Pending Tracks Artist", + LidarrAlbumMBID: albumMBID, AlbumTitle: "Album Without Tracks", + LidarrTrackMBID: "t-mbid-not-yet", TrackTitle: "Some Track", + }) + + rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger()) + if err := rec.tickOnce(ctx); err != nil { + t.Fatalf("tickOnce: %v", err) + } + + got, err := q.GetLidarrRequestByID(ctx, req.ID) + if err != nil { + t.Fatalf("GetLidarrRequestByID: %v", err) + } + if got.Status != dbq.LidarrRequestStatusApproved { + t.Errorf("status = %v, want approved (album present, no tracks yet)", got.Status) + } +} + +// TestReconciler_AlbumKindNoMatchInAlbumsTable exercises the album branch's +// no-rows return path when the request's album_mbid isn't in the library yet. +func TestReconciler_AlbumKindNoMatchInAlbumsTable(t *testing.T) { + pool := newPool(t) + q := dbq.New(pool) + ctx := context.Background() + + enableLidarrForPool(t, pool) + user := seedUser(t, pool) + + req := seedApprovedRequestDirect(t, q, user, CreateParams{ + Kind: "album", LidarrArtistMBID: "ar-no-match", ArtistName: "Nope Artist", + LidarrAlbumMBID: "al-not-in-library", AlbumTitle: "Nope Album", + }) + + rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger()) + if err := rec.tickOnce(ctx); err != nil { + t.Fatalf("tickOnce: %v", err) + } + + got, err := q.GetLidarrRequestByID(ctx, req.ID) + if err != nil { + t.Fatalf("GetLidarrRequestByID: %v", err) + } + if got.Status != dbq.LidarrRequestStatusApproved { + t.Errorf("status = %v, want approved (album not yet in library)", got.Status) + } +} diff --git a/internal/lidarrrequests/service_test.go b/internal/lidarrrequests/service_test.go index 18d7aed0..ab9ba4e8 100644 --- a/internal/lidarrrequests/service_test.go +++ b/internal/lidarrrequests/service_test.go @@ -5,6 +5,8 @@ import ( "errors" "io" "log/slog" + "net/http" + "net/http/httptest" "os" "testing" @@ -14,6 +16,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" ) @@ -144,3 +147,186 @@ func TestCancel_OwnPendingOnly(t *testing.T) { t.Errorf("err = %v, want ErrNotPending", err) } } + +func TestCreate_AlbumKindRequiresAlbumMBID(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool) + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + _, err := svc.Create(context.Background(), user, CreateParams{ + Kind: "album", + LidarrArtistMBID: "a-mbid", ArtistName: "X", + // missing album_mbid + album_title + }) + if !errors.Is(err, ErrInvalidKindFields) { + t.Fatalf("err = %v, want ErrInvalidKindFields", err) + } +} + +func TestCreate_UnknownKindRejected(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool) + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + _, err := svc.Create(context.Background(), user, CreateParams{ + Kind: "playlist", LidarrArtistMBID: "a-mbid", ArtistName: "X", + }) + if !errors.Is(err, ErrInvalidKindFields) { + t.Fatalf("err = %v, want ErrInvalidKindFields", err) + } +} + +func TestListPending_AndListByStatus(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool) + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + for _, name := range []string{"A", "B"} { + if _, err := svc.Create(context.Background(), user, CreateParams{ + Kind: "artist", LidarrArtistMBID: name + "-mbid", ArtistName: name, + }); err != nil { + t.Fatalf("Create(%s): %v", name, err) + } + } + pending, err := svc.ListPending(context.Background(), 50) + if err != nil { + t.Fatalf("ListPending: %v", err) + } + if len(pending) != 2 { + t.Errorf("ListPending len = %d, want 2", len(pending)) + } + rejected, err := svc.ListByStatus(context.Background(), "rejected", 50) + if err != nil { + t.Fatalf("ListByStatus(rejected): %v", err) + } + if len(rejected) != 0 { + t.Errorf("ListByStatus(rejected) len = %d, want 0", len(rejected)) + } +} + +func TestListForUser_OnlyOwnRows(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool) + bob, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ + Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiToken: "x2", IsAdmin: false, + }) + if err != nil { + t.Fatalf("seed bob: %v", err) + } + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + if _, err := svc.Create(context.Background(), alice, CreateParams{ + Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "Alice's request", + }); err != nil { + t.Fatalf("Create(alice): %v", err) + } + if _, err := svc.Create(context.Background(), bob.ID, CreateParams{ + Kind: "artist", LidarrArtistMBID: "b-mbid", ArtistName: "Bob's request", + }); err != nil { + t.Fatalf("Create(bob): %v", err) + } + rows, err := svc.ListForUser(context.Background(), alice, 50) + if err != nil { + t.Fatalf("ListForUser: %v", err) + } + if len(rows) != 1 || rows[0].ArtistName != "Alice's request" { + t.Errorf("ListForUser = %+v, want only Alice's row", rows) + } +} + +// approveTestSetup wires Service against a stub Lidarr server. Returns the +// Service, an admin user id, and the captured Lidarr request body for the +// most recent call (handler updates it in-place). +func approveTestSetup(t *testing.T) (*Service, pgtype.UUID, *pgxpool.Pool, *httptest.Server) { + t.Helper() + pool := newPool(t) + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":1}`)) + })) + t.Cleanup(stub.Close) + + cfg := lidarrconfig.New(pool) + if err := cfg.Save(context.Background(), lidarrconfig.Config{ + Enabled: true, + BaseURL: stub.URL, + APIKey: "k", + DefaultQualityProfileID: 7, + DefaultRootFolderPath: "/music", + }); err != nil { + t.Fatalf("save config: %v", err) + } + clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") } + svc := NewService(pool, cfg, clientFn, nil) + user := seedUser(t, pool) + return svc, user, pool, stub +} + +func TestApprove_HappyPath_ArtistKind(t *testing.T) { + svc, user, _, _ := approveTestSetup(t) + r, err := svc.Create(context.Background(), user, CreateParams{ + Kind: "artist", LidarrArtistMBID: "ar-mbid", ArtistName: "Approvee", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + approved, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{}) + if err != nil { + t.Fatalf("Approve: %v", err) + } + if approved.Status != dbq.LidarrRequestStatusApproved { + t.Errorf("status = %v, want approved", approved.Status) + } + // Default snapshot from config. + if approved.QualityProfileID == nil || *approved.QualityProfileID != 7 { + t.Errorf("quality_profile_id = %v, want 7", approved.QualityProfileID) + } + if approved.RootFolderPath == nil || *approved.RootFolderPath != "/music" { + t.Errorf("root_folder_path = %v, want /music", approved.RootFolderPath) + } +} + +func TestApprove_HappyPath_AlbumKindWithOverride(t *testing.T) { + svc, user, _, _ := approveTestSetup(t) + r, err := svc.Create(context.Background(), user, CreateParams{ + Kind: "album", + LidarrArtistMBID: "ar-mbid", ArtistName: "X", + LidarrAlbumMBID: "al-mbid", AlbumTitle: "Y", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + approved, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{ + QualityProfileID: 99, + RootFolderPath: "/elsewhere", + }) + if err != nil { + t.Fatalf("Approve: %v", err) + } + if approved.QualityProfileID == nil || *approved.QualityProfileID != 99 { + t.Errorf("quality_profile_id = %v, want 99 (override)", approved.QualityProfileID) + } + if approved.RootFolderPath == nil || *approved.RootFolderPath != "/elsewhere" { + t.Errorf("root_folder_path = %v, want /elsewhere", approved.RootFolderPath) + } +} + +func TestApprove_AlreadyApprovedReturnsErrNotPending(t *testing.T) { + svc, user, _, _ := approveTestSetup(t) + r, _ := svc.Create(context.Background(), user, CreateParams{ + Kind: "artist", LidarrArtistMBID: "ar-mbid", ArtistName: "X", + }) + if _, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{}); err != nil { + t.Fatalf("first approve: %v", err) + } + if _, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{}); !errors.Is(err, ErrNotPending) { + t.Errorf("second approve err = %v, want ErrNotPending", err) + } +} + +func TestApprove_NotFound(t *testing.T) { + svc, user, _, _ := approveTestSetup(t) + var bogus pgtype.UUID + bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + bogus.Valid = true + if _, err := svc.Approve(context.Background(), bogus, user, ApproveOverrides{}); !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} From 0b3919e4a13b6c16734101f8a15bc41108c33d80 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 14:54:44 -0400 Subject: [PATCH 36/67] docs(spec): add M5b quarantine workflow design Per-user track-level soft-hide with reason taxonomy, aggregated admin queue at /admin/quarantine with Resolve / Delete file / Delete via Lidarr actions, audit log for admin actions, soft-hide honored by /api/* read endpoints (Subsonic /rest/* unchanged per legacy rule). Lidarr client extends with LookupAlbumByMBID + DeleteAlbum (deleteFiles + addImportListExclusion). --- .../specs/2026-04-30-m5b-quarantine-design.md | 389 ++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-30-m5b-quarantine-design.md diff --git a/docs/superpowers/specs/2026-04-30-m5b-quarantine-design.md b/docs/superpowers/specs/2026-04-30-m5b-quarantine-design.md new file mode 100644 index 00000000..850ccbcf --- /dev/null +++ b/docs/superpowers/specs/2026-04-30-m5b-quarantine-design.md @@ -0,0 +1,389 @@ +# 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. From 0806a37a42ab56aafc6a4f74860ec5a2aa5025a3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 15:15:53 -0400 Subject: [PATCH 37/67] docs(plan): add M5b quarantine workflow implementation plan 16 tasks covering migration 0011, Lidarr client extensions (LookupArtistByMBID, LookupAlbumByMBID, DeleteAlbum), library.DeleteTrackFile, lidarrquarantine.Service (Flag/Unflag/ListMine/ListAdminQueue + Resolve/DeleteFile/DeleteViaLidarr), soft-hide enforcement on user-context track-list reads, /api/quarantine + admin endpoints, and frontend (TrackMenu/FlagPopover, /library/hidden, /admin/quarantine, sidebar promotion). Mirrors the M5a plan cadence. --- .../plans/2026-04-30-m5b-quarantine.md | 2916 +++++++++++++++++ 1 file changed, 2916 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-30-m5b-quarantine.md diff --git a/docs/superpowers/plans/2026-04-30-m5b-quarantine.md b/docs/superpowers/plans/2026-04-30-m5b-quarantine.md new file mode 100644 index 00000000..358cf510 --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-m5b-quarantine.md @@ -0,0 +1,2916 @@ +# M5b — Quarantine workflow + admin resolution UI — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire per-user track-level quarantine into Minstrel — flag affordance via a kebab `<TrackMenu>` on every track row + the player, soft-hide enforcement on user-context `/api/*` reads, dedicated `/library/hidden` for the user, aggregated admin queue at `/admin/quarantine` with Resolve / Delete file / Delete via Lidarr actions, audit log for admin actions. + +**Architecture:** New `internal/lidarrquarantine` package (Service, no background worker) backed by two tables (`lidarr_quarantine` per-user complaints + `lidarr_quarantine_actions` audit log). Lidarr HTTP client gains `LookupArtistByMBID`, `LookupAlbumByMBID`, `DeleteAlbum` (always called with `deleteFiles=true` and `addImportListExclusion=true`). `internal/library` gains `DeleteTrackFile`. Existing read queries that return tracks in user-context get `*ForUser` variants that join against `lidarr_quarantine`; Subsonic queries are untouched. SPA gets a `<TrackMenu>` overflow component (mounted in `TrackRow` and `PlayerBar`) opening a `<FlagPopover>`, plus `/library/hidden` and `/admin/quarantine` routes. + +**Tech Stack:** Go 1.23 · chi router · pgx/v5 + sqlc · Postgres + golang-migrate · SvelteKit 2 / Svelte 5 (runes) · TanStack Query · Vitest · golangci-lint · FabledSword design tokens (existing M5a infrastructure). + +**Spec:** [`docs/superpowers/specs/2026-04-30-m5b-quarantine-design.md`](../specs/2026-04-30-m5b-quarantine-design.md). Read it before starting — every decision is explained there. + +**Memory dependencies:** `project_design_system.md` (FabledSword token palette + voice rules), `project_subsonic_legacy.md` (`/rest/*` does not honor quarantine), `project_no_github.md` (Forgejo MCP for PR ops, not gh CLI), `project_git_workflow.md` (commit on `dev`; PR to `main` separately). + +--- + +## File map + +### Backend — create + +- `internal/db/migrations/0011_lidarr_quarantine.up.sql` · `0011_lidarr_quarantine.down.sql` — schema +- `internal/db/queries/lidarr_quarantine.sql` — sqlc queries for both tables +- `internal/lidarrquarantine/service.go` — `Service` (Flag/Unflag/ListMine/ListAdminQueue/Resolve/DeleteFile/DeleteViaLidarr) +- `internal/lidarrquarantine/service_test.go` — integration tests +- `internal/lidarr/lookup_mbid.go` — `LookupArtistByMBID`, `LookupAlbumByMBID` (split from `client.go` to keep that file from growing) +- `internal/lidarr/delete.go` — `DeleteAlbum` HTTP method + `DELETE` helper +- `internal/lidarr/delete_test.go` — tests for the new methods +- `internal/lidarr/testdata/album_lookup_by_mbid.json`, `artist_lookup_by_mbid.json` — captured fixtures +- `internal/library/delete.go` — `DeleteTrackFile` +- `internal/library/delete_test.go` — tests +- `internal/api/quarantine.go` — `/api/quarantine/*` user-facing handlers +- `internal/api/quarantine_test.go` +- `internal/api/admin_quarantine.go` — `/api/admin/quarantine/*` admin handlers +- `internal/api/admin_quarantine_test.go` + +### Backend — modify + +- `internal/db/queries/tracks.sql` — add `ListTracksByAlbumForUser`, `SearchTracksForUser`, `CountTracksMatchingForUser` (filtered variants) +- `internal/db/queries/recommendation.sql` — extend `LoadRadioCandidates` and `LoadRadioCandidatesV2` to also exclude quarantined tracks +- `internal/api/api.go` — register routes, mount `/api/admin/quarantine` group, route the modified user-context endpoints to the `*ForUser` queries +- `internal/api/auth_test.go` — extend `testHandlers` to inject `lidarrquarantine.Service` +- `internal/api/albums.go` (or wherever album-detail composes its track list) — switch to `ListTracksByAlbumForUser` when user context is present +- `internal/api/search.go` — switch to `SearchTracksForUser` +- `internal/api/radio.go` (or wherever radio handlers live) — pass through the existing user_id parameter to the now-quarantine-aware query +- `cmd/minstrel/main.go` — construct `lidarrquarantine.Service` and inject +- `internal/db/dbq/*` — regenerated by `sqlc generate` + +### Frontend — create + +- `web/src/lib/api/quarantine.ts` — user-facing client (Flag/Unflag/ListMine) +- `web/src/lib/api/quarantine.test.ts` +- `web/src/lib/components/TrackMenu.svelte` — kebab overflow menu +- `web/src/lib/components/TrackMenu.test.ts` +- `web/src/lib/components/FlagPopover.svelte` — reason + notes form +- `web/src/lib/components/FlagPopover.test.ts` +- `web/src/lib/components/QuarantineRow.svelte` — shared row used by both `/library/hidden` and `/admin/quarantine` +- `web/src/lib/components/QuarantineRow.test.ts` +- `web/src/routes/library/hidden/+page.svelte` +- `web/src/routes/library/hidden/hidden.test.ts` +- `web/src/routes/admin/quarantine/+page.svelte` +- `web/src/routes/admin/quarantine/quarantine.test.ts` + +### Frontend — modify + +- `web/src/lib/api/admin.ts` — add `listAdminQuarantine`, `resolveQuarantine`, `deleteQuarantineFile`, `deleteQuarantineViaLidarr`, `listQuarantineActions` plus query factories +- `web/src/lib/api/queries.ts` — add `qk.myQuarantine`, `qk.adminQuarantine`, `qk.adminQuarantineActions` +- `web/src/lib/api/types.ts` — add `LidarrQuarantineReason`, `LidarrQuarantineRow`, `AdminQuarantineRow`, `LidarrQuarantineActionRow`, `LidarrQuarantineAction` enums +- `web/src/lib/components/Shell.svelte` — add `Hidden` to the main nav after `Liked` +- `web/src/lib/components/Shell.test.ts` — assert the new nav order +- `web/src/lib/components/AdminSidebar.svelte` — promote `Quarantine` from `placeholder: true` to a real link +- `web/src/lib/components/AdminSidebar.test.ts` — update tests; quarantine is now a link +- `web/src/lib/components/TrackRow.svelte` — mount `<TrackMenu>` next to `<LikeButton>` +- `web/src/lib/components/TrackRow.test.ts` — extend to cover the menu +- `web/src/lib/components/PlayerBar.svelte` — mount `<TrackMenu>` in the right cluster +- `web/src/lib/components/PlayerBar.test.ts` — extend to cover the menu + +--- + +## Task list + +### Task 1 — Migration 0011 + sqlc queries + +**Files:** +- Create: `internal/db/migrations/0011_lidarr_quarantine.up.sql` +- Create: `internal/db/migrations/0011_lidarr_quarantine.down.sql` +- Create: `internal/db/queries/lidarr_quarantine.sql` +- Modify: `internal/db/dbq/*` (regenerated by `sqlc generate`) + +- [ ] **Step 1.1: Write the up migration** + +`internal/db/migrations/0011_lidarr_quarantine.up.sql`: + +```sql +-- M5b: per-user track quarantines + admin action audit log. +-- +-- lidarr_quarantine — one row per (user, track) complaint. PK matches +-- the general_likes pattern. Re-flagging the same track upserts. Deleted +-- on user resolution (un-hide), admin Resolve, or any of the deletes. +-- +-- lidarr_quarantine_actions — audit log of admin destructive actions. +-- Snapshot text columns let the log stay readable after the underlying +-- track/album rows are gone. + +CREATE TYPE lidarr_quarantine_reason AS ENUM ( + 'bad_rip', 'wrong_file', 'wrong_tags', 'duplicate', 'other' +); + +CREATE TABLE lidarr_quarantine ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + reason lidarr_quarantine_reason NOT NULL, + notes text, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, track_id) +); + +CREATE INDEX lidarr_quarantine_track_idx ON lidarr_quarantine (track_id); +CREATE INDEX lidarr_quarantine_user_idx ON lidarr_quarantine (user_id, created_at DESC); + +CREATE TYPE lidarr_quarantine_action AS ENUM ( + 'resolved', 'deleted_file', 'deleted_via_lidarr' +); + +CREATE TABLE lidarr_quarantine_actions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + track_id uuid NOT NULL, + track_title text NOT NULL, + artist_name text NOT NULL, + album_title text, + action lidarr_quarantine_action NOT NULL, + admin_id uuid REFERENCES users(id) ON DELETE SET NULL, + lidarr_album_mbid text, + affected_users int NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX lidarr_quarantine_actions_track_idx ON lidarr_quarantine_actions (track_id); +CREATE INDEX lidarr_quarantine_actions_created_idx ON lidarr_quarantine_actions (created_at DESC); +``` + +- [ ] **Step 1.2: Write the down migration** + +`internal/db/migrations/0011_lidarr_quarantine.down.sql`: + +```sql +DROP INDEX IF EXISTS lidarr_quarantine_actions_created_idx; +DROP INDEX IF EXISTS lidarr_quarantine_actions_track_idx; +DROP TABLE IF EXISTS lidarr_quarantine_actions; +DROP TYPE IF EXISTS lidarr_quarantine_action; +DROP INDEX IF EXISTS lidarr_quarantine_user_idx; +DROP INDEX IF EXISTS lidarr_quarantine_track_idx; +DROP TABLE IF EXISTS lidarr_quarantine; +DROP TYPE IF EXISTS lidarr_quarantine_reason; +``` + +- [ ] **Step 1.3: Apply migration locally to confirm it runs** + +```bash +docker compose up -d postgres +docker compose exec -T postgres psql -U minstrel -d minstrel -c "DROP TABLE IF EXISTS lidarr_quarantine_actions; DROP TYPE IF EXISTS lidarr_quarantine_action; DROP TABLE IF EXISTS lidarr_quarantine; DROP TYPE IF EXISTS lidarr_quarantine_reason;" +go run ./cmd/minstrel up 2>/dev/null || true # apply via server start instead +docker compose exec -T postgres psql -U minstrel -d minstrel -c "\d lidarr_quarantine" +docker compose exec -T postgres psql -U minstrel -d minstrel -c "\d lidarr_quarantine_actions" +``` + +Expected: both `\d` commands print the table with columns and indexes. + +If the project doesn't have a standalone migrate command, the migration applies on server start via `db.Migrate(...)` — restart the minstrel container instead. + +- [ ] **Step 1.4: Write the queries** + +`internal/db/queries/lidarr_quarantine.sql`: + +```sql +-- name: UpsertQuarantine :one +-- Insert a new quarantine row, or update reason/notes if the user has +-- already flagged this track. +INSERT INTO lidarr_quarantine (user_id, track_id, reason, notes) +VALUES ($1, $2, $3, $4) +ON CONFLICT (user_id, track_id) DO UPDATE SET + reason = EXCLUDED.reason, + notes = EXCLUDED.notes, + created_at = now() +RETURNING user_id, track_id, reason, notes, created_at; + +-- name: DeleteQuarantine :one +-- Removes the caller's row. Returns the deleted row so the handler can +-- distinguish "no row existed" (zero rows -> ErrNoRows) from success. +DELETE FROM lidarr_quarantine + WHERE user_id = $1 AND track_id = $2 + RETURNING user_id, track_id, reason, notes, created_at; + +-- name: ListQuarantineForUser :many +-- Caller's own quarantines joined with track + album + artist for full +-- detail. Drives /library/hidden. +SELECT + sqlc.embed(q), + sqlc.embed(t), + sqlc.embed(al), + sqlc.embed(ar) +FROM lidarr_quarantine q +JOIN tracks t ON t.id = q.track_id +JOIN albums al ON al.id = t.album_id +JOIN artists ar ON ar.id = t.artist_id +WHERE q.user_id = $1 +ORDER BY q.created_at DESC; + +-- name: ListAdminQuarantineQueue :many +-- Aggregated admin queue. One row per track. The handler post-processes +-- the rows it gets from this query plus a per-track ListQuarantineReports +-- call to materialize reason_counts and the per-user reports list. +SELECT + t.id AS track_id, + t.title AS track_title, + ar.name AS artist_name, + al.title AS album_title, + al.id AS album_id, + al.mbid AS lidarr_album_mbid, + count(q.user_id)::int AS report_count, + max(q.created_at) AS latest_at +FROM lidarr_quarantine q +JOIN tracks t ON t.id = q.track_id +JOIN albums al ON al.id = t.album_id +JOIN artists ar ON ar.id = t.artist_id +GROUP BY t.id, ar.name, al.title, al.id, al.mbid +ORDER BY max(q.created_at) DESC; + +-- name: ListQuarantineReportsForTrack :many +-- Per-user reports for a single track. Returned by ListAdminQuarantineQueue +-- post-processing and exposed expandable in the SPA admin queue rows. +SELECT + q.user_id, + u.username, + q.reason, + q.notes, + q.created_at +FROM lidarr_quarantine q +JOIN users u ON u.id = q.user_id +WHERE q.track_id = $1 +ORDER BY q.created_at DESC; + +-- name: DeleteQuarantineForTrack :exec +-- Clears all per-user rows for a given track. Used by Resolve and the +-- two delete actions. Caller writes the audit row separately before +-- this fires (so we can capture the affected_users count). +DELETE FROM lidarr_quarantine WHERE track_id = $1; + +-- name: CountQuarantineForTrack :one +-- Reads affected_users for the audit row before the delete fires. +SELECT count(*)::int FROM lidarr_quarantine WHERE track_id = $1; + +-- name: WriteQuarantineAction :one +-- Audit row for an admin destructive action. +INSERT INTO lidarr_quarantine_actions ( + track_id, track_title, artist_name, album_title, + action, admin_id, lidarr_album_mbid, affected_users +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING *; + +-- name: ListQuarantineActions :many +SELECT * FROM lidarr_quarantine_actions +ORDER BY created_at DESC +LIMIT $1; +``` + +- [ ] **Step 1.5: Run sqlc generate** + +```bash +cd internal/db && sqlc generate && cd - +go build ./... +``` + +Expected: clean build. New types `LidarrQuarantine`, `LidarrQuarantineAction`, `ListAdminQuarantineQueueRow`, `ListQuarantineForUserRow`, `ListQuarantineReportsForTrackRow` etc. surface in `internal/db/dbq/`. + +- [ ] **Step 1.6: Commit** + +```bash +git add internal/db/migrations/0011_lidarr_quarantine.up.sql \ + internal/db/migrations/0011_lidarr_quarantine.down.sql \ + internal/db/queries/lidarr_quarantine.sql \ + internal/db/dbq/ +git commit -m "feat(db): add lidarr_quarantine + actions schema (migration 0011)" +``` + +--- + +### Task 2 — Lidarr HTTP client extensions + +**Files:** +- Create: `internal/lidarr/lookup_mbid.go` +- Create: `internal/lidarr/delete.go` +- Create: `internal/lidarr/delete_test.go` +- Create: `internal/lidarr/testdata/album_lookup_by_mbid.json` +- Create: `internal/lidarr/testdata/artist_lookup_by_mbid.json` +- Modify: `internal/lidarr/types.go` — add `LidarrArtist`, `LidarrAlbum` + +The existing M5a client lives in `internal/lidarr/client.go`. To keep it from sprawling, the M5b additions land in two new files: `lookup_mbid.go` for the GET-by-MBID methods and `delete.go` for the DELETE method + a tiny `del()` HTTP helper. + +- [ ] **Step 2.1: Add the typed structs** + +`internal/lidarr/types.go` (modify) — append the two structs at the bottom of the file: + +```go +// LidarrArtist is the subset of Lidarr's artist resource used by M5b +// admin actions. The "id" field is Lidarr's internal numeric ID — needed +// for DELETE /api/v1/artist/{id} calls. +type LidarrArtist struct { + ID int `json:"id"` + ForeignArtistID string `json:"foreignArtistId"` // MBID + ArtistName string `json:"artistName"` +} + +// LidarrAlbum is the subset of Lidarr's album resource used by M5b +// admin actions. +type LidarrAlbum struct { + ID int `json:"id"` + ForeignAlbumID string `json:"foreignAlbumId"` // MBID + Title string `json:"title"` + ArtistID int `json:"artistId"` +} +``` + +- [ ] **Step 2.2: Add a sentinel error for not-found** + +`internal/lidarr/errors.go` (modify) — append: + +```go +// ErrNotFound is returned by LookupArtistByMBID and LookupAlbumByMBID +// when Lidarr returns 200 with an empty array — i.e., the MBID isn't in +// Lidarr's monitored set. Distinguished from network/auth errors so admin +// handlers can surface it as `lidarr_album_lookup_failed` (502) instead +// of `lidarr_unreachable` (503). +var ErrNotFound = errors.New("lidarr: not found") +``` + +If `errors.go` doesn't already import `"errors"`, add it. + +- [ ] **Step 2.3: Write `lookup_mbid.go`** + +```go +package lidarr + +import ( + "context" + "encoding/json" + "fmt" + "net/url" +) + +// LookupArtistByMBID returns the artist Lidarr has indexed under that +// MBID. Returns ErrNotFound if Lidarr returns an empty array. +func (c *Client) LookupArtistByMBID(ctx context.Context, mbid string) (LidarrArtist, error) { + if mbid == "" { + return LidarrArtist{}, fmt.Errorf("lidarr: empty mbid") + } + q := url.Values{"mbId": []string{mbid}} + resp, err := c.get(ctx, "/api/v1/artist", q) + if err != nil { + return LidarrArtist{}, err + } + defer resp.Body.Close() + + var rows []LidarrArtist + if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil { + return LidarrArtist{}, fmt.Errorf("lidarr: decode artist: %w", err) + } + if len(rows) == 0 { + return LidarrArtist{}, ErrNotFound + } + return rows[0], nil +} + +// LookupAlbumByMBID returns the album Lidarr has indexed under that +// MBID. Returns ErrNotFound on empty result. +func (c *Client) LookupAlbumByMBID(ctx context.Context, mbid string) (LidarrAlbum, error) { + if mbid == "" { + return LidarrAlbum{}, fmt.Errorf("lidarr: empty mbid") + } + q := url.Values{"foreignAlbumId": []string{mbid}} + resp, err := c.get(ctx, "/api/v1/album", q) + if err != nil { + return LidarrAlbum{}, err + } + defer resp.Body.Close() + + var rows []LidarrAlbum + if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil { + return LidarrAlbum{}, fmt.Errorf("lidarr: decode album: %w", err) + } + if len(rows) == 0 { + return LidarrAlbum{}, ErrNotFound + } + return rows[0], nil +} +``` + +- [ ] **Step 2.4: Write `delete.go`** + +```go +package lidarr + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" +) + +// del issues a DELETE against the given path with optional query params. +// Mirrors the existing get/post helpers in client.go; consolidating the +// auth header + base-URL handling behavior in one place. +func (c *Client) del(ctx context.Context, path string, q url.Values) (*http.Response, error) { + u, err := c.url(path) + if err != nil { + return nil, err + } + if q != nil { + u.RawQuery = q.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("lidarr: build DELETE: %w", err) + } + req.Header.Set("X-Api-Key", c.APIKey) + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) + } + if resp.StatusCode == http.StatusUnauthorized { + resp.Body.Close() + return nil, ErrAuthFailed + } + if resp.StatusCode >= 400 { + resp.Body.Close() + return nil, fmt.Errorf("%w: status %d", ErrLookupFailed, resp.StatusCode) + } + return resp, nil +} + +// DeleteAlbum removes an album from Lidarr's library. +// - deleteFiles=true also removes the audio files from disk. +// - addImportListExclusion=true tells Lidarr to never re-add this album +// via import-list scans. +// +// M5b's admin "delete via Lidarr" action always passes both `true`. +func (c *Client) DeleteAlbum(ctx context.Context, lidarrAlbumID int, deleteFiles, addImportListExclusion bool) error { + if lidarrAlbumID == 0 { + return fmt.Errorf("lidarr: zero album id") + } + q := url.Values{ + "deleteFiles": []string{strconv.FormatBool(deleteFiles)}, + "addImportListExclusion": []string{strconv.FormatBool(addImportListExclusion)}, + } + resp, err := c.del(ctx, "/api/v1/album/"+strconv.Itoa(lidarrAlbumID), q) + if err != nil { + return err + } + resp.Body.Close() + return nil +} +``` + +If `client.go` doesn't already export a `url(path)` helper, look at how `get(ctx, path, q)` builds its URL and either factor out the helper or inline the logic here. (M5a's `client.go` has `c.url(path)` at the top of the file — check before duplicating.) + +- [ ] **Step 2.5: Capture fixtures** + +`internal/lidarr/testdata/album_lookup_by_mbid.json` — a single-element JSON array matching what Lidarr returns for `GET /api/v1/album?foreignAlbumId=<mbid>`: + +```json +[ + { + "id": 42, + "foreignAlbumId": "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d", + "title": "Music Has The Right To Children", + "artistId": 7 + } +] +``` + +`internal/lidarr/testdata/artist_lookup_by_mbid.json` — same shape: + +```json +[ + { + "id": 7, + "foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", + "artistName": "Boards of Canada" + } +] +``` + +- [ ] **Step 2.6: Write `delete_test.go` (covers all three new methods)** + +```go +package lidarr + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func TestLookupAlbumByMBID_HappyPath(t *testing.T) { + body, err := os.ReadFile("testdata/album_lookup_by_mbid.json") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/album" { + t.Errorf("path = %q, want /api/v1/album", r.URL.Path) + } + if got := r.URL.Query().Get("foreignAlbumId"); got != "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d" { + t.Errorf("foreignAlbumId = %q", got) + } + if got := r.Header.Get("X-Api-Key"); got != "test-key" { + t.Errorf("X-Api-Key = %q, want test-key", got) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + }) + defer srv.Close() + + got, err := c.LookupAlbumByMBID(context.Background(), "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d") + if err != nil { + t.Fatalf("LookupAlbumByMBID: %v", err) + } + if got.ID != 42 || got.Title != "Music Has The Right To Children" { + t.Errorf("got = %+v", got) + } +} + +func TestLookupAlbumByMBID_EmptyArrayReturnsErrNotFound(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("[]")) + }) + defer srv.Close() + + _, err := c.LookupAlbumByMBID(context.Background(), "x") + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestLookupAlbumByMBID_AuthFailed(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + defer srv.Close() + + _, err := c.LookupAlbumByMBID(context.Background(), "x") + if !errors.Is(err, ErrAuthFailed) { + t.Errorf("err = %v, want ErrAuthFailed", err) + } +} + +func TestLookupArtistByMBID_HappyPath(t *testing.T) { + body, err := os.ReadFile("testdata/artist_lookup_by_mbid.json") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/artist" { + t.Errorf("path = %q, want /api/v1/artist", r.URL.Path) + } + if got := r.URL.Query().Get("mbId"); got != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" { + t.Errorf("mbId = %q", got) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + }) + defer srv.Close() + + got, err := c.LookupArtistByMBID(context.Background(), "069b64b6-7884-4f6a-94cc-e4c1d6c87a01") + if err != nil { + t.Fatalf("LookupArtistByMBID: %v", err) + } + if got.ID != 7 || got.ArtistName != "Boards of Canada" { + t.Errorf("got = %+v", got) + } +} + +func TestDeleteAlbum_PassesBothFlags(t *testing.T) { + var captured *http.Request + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + captured = r + w.WriteHeader(http.StatusOK) + }) + defer srv.Close() + + if err := c.DeleteAlbum(context.Background(), 42, true, true); err != nil { + t.Fatalf("DeleteAlbum: %v", err) + } + if captured == nil || captured.Method != http.MethodDelete { + t.Fatalf("method = %v, want DELETE", captured) + } + if captured.URL.Path != "/api/v1/album/42" { + t.Errorf("path = %q", captured.URL.Path) + } + if got := captured.URL.Query().Get("deleteFiles"); got != "true" { + t.Errorf("deleteFiles = %q", got) + } + if got := captured.URL.Query().Get("addImportListExclusion"); got != "true" { + t.Errorf("addImportListExclusion = %q", got) + } +} + +func TestDeleteAlbum_5xxReturnsErrLookupFailed(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + defer srv.Close() + + err := c.DeleteAlbum(context.Background(), 42, true, true) + if !errors.Is(err, ErrLookupFailed) { + t.Errorf("err = %v, want ErrLookupFailed", err) + } +} + +func TestDeleteAlbum_NetworkErrorReturnsErrUnreachable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + srv.Close() // server is closed; client should fail to connect + c := NewClient(srv.URL, "test-key") + + err := c.DeleteAlbum(context.Background(), 42, true, true) + if !errors.Is(err, ErrUnreachable) { + t.Errorf("err = %v, want ErrUnreachable", err) + } +} +``` + +`newTestClient` is the existing helper in `client_test.go` (M5a). Reuse it. + +- [ ] **Step 2.7: Run tests + build** + +```bash +go test ./internal/lidarr/... -count=1 +go build ./... +``` + +Expected: all green. + +- [ ] **Step 2.8: Commit** + +```bash +git add internal/lidarr/ +git commit -m "feat(lidarr): LookupArtistByMBID, LookupAlbumByMBID, DeleteAlbum" +``` + +--- + +### Task 3 — `internal/library` `DeleteTrackFile` + +**Files:** +- Create: `internal/library/delete.go` +- Create: `internal/library/delete_test.go` + +The admin "Delete file" action removes the file from disk and the row from `tracks`. The album/artist rows stay. Other tracks may reference them; the admin only nuked one track. + +- [ ] **Step 3.1: Write `delete.go`** + +```go +package library + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// ErrTrackNotFound is returned when DeleteTrackFile is called with an id +// that has no row in tracks. +var ErrTrackNotFound = errors.New("library: track not found") + +// DeleteTrackFile removes a track file from disk and its row from the +// tracks table. Album and artist rows are left untouched. +// +// Steps: +// 1. Look up the track to get its file_path. +// 2. Remove the file from disk. fs.ErrNotExist is OK — already gone. +// 3. Delete the tracks row. +// +// Order matters: file first, then DB. If the file delete fails (permission, +// I/O error), we leave the DB row alone so the admin can retry. +func DeleteTrackFile(ctx context.Context, pool *pgxpool.Pool, trackID pgtype.UUID) error { + q := dbq.New(pool) + track, err := q.GetTrackByID(ctx, trackID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrTrackNotFound + } + return fmt.Errorf("get track: %w", err) + } + + if err := os.Remove(track.FilePath); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("remove file: %w", err) + } + + if _, err := pool.Exec(ctx, "DELETE FROM tracks WHERE id = $1", trackID); err != nil { + return fmt.Errorf("delete row: %w", err) + } + return nil +} +``` + +- [ ] **Step 3.2: Write `delete_test.go`** + +```go +package library + +import ( + "context" + "errors" + "io" + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func TestDeleteTrackFile_HappyPath(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + + if _, err := pool.Exec(context.Background(), + "TRUNCATE tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { + t.Fatalf("truncate: %v", err) + } + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "X", SortName: "X"}) + album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "A", SortTitle: "A", ArtistID: artist.ID}) + + // Create a real on-disk file the test can prove is removed. + dir := t.TempDir() + path := filepath.Join(dir, "track.mp3") + if err := os.WriteFile(path, []byte("payload"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "T", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: path, FileSize: 7, FileFormat: "mp3", + }) + if err != nil { + t.Fatalf("upsert: %v", err) + } + + if err := DeleteTrackFile(context.Background(), pool, track.ID); err != nil { + t.Fatalf("DeleteTrackFile: %v", err) + } + + // File gone. + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Errorf("file still exists: %v", err) + } + // Row gone. + if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { + t.Errorf("track row still exists") + } + // Album row preserved. + if _, err := q.GetAlbumByID(context.Background(), album.ID); err != nil { + t.Errorf("album row vanished: %v", err) + } +} + +func TestDeleteTrackFile_FileAlreadyGoneSucceeds(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, _ := pgxpool.New(context.Background(), dsn) + t.Cleanup(pool.Close) + if _, err := pool.Exec(context.Background(), + "TRUNCATE tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { + t.Fatalf("truncate: %v", err) + } + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "X", SortName: "X"}) + album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "A", SortTitle: "A", ArtistID: artist.ID}) + + track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "T", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: "/no/such/file/anywhere.mp3", FileSize: 0, FileFormat: "mp3", + }) + + if err := DeleteTrackFile(context.Background(), pool, track.ID); err != nil { + t.Fatalf("DeleteTrackFile with missing file: %v", err) + } + if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { + t.Errorf("track row still exists") + } +} + +func TestDeleteTrackFile_NotFoundReturnsErr(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, _ := pgxpool.New(context.Background(), dsn) + t.Cleanup(pool.Close) + + var bogus pgxUUID + bogus.Set([16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}) + err := DeleteTrackFile(context.Background(), pool, bogus.UUID) + if !errors.Is(err, ErrTrackNotFound) { + t.Errorf("err = %v, want ErrTrackNotFound", err) + } +} + +// pgxUUID is a tiny shim for the test — the existing scanner_test.go in +// this package uses raw byte arrays to build a synthetic pgtype.UUID. If +// the convention changes, mirror whatever helper that test uses. +type pgxUUID struct { + UUID interface { + // satisfied by pgtype.UUID + } +} + +func (u *pgxUUID) Set(b [16]byte) { + // Replace this body with whatever the existing tests use to construct + // a pgtype.UUID from raw bytes. If unsure, copy from + // internal/lidarrrequests/service_test.go's TestApprove_NotFound. + panic("replace with the project's pgtype.UUID construction helper") +} +``` + +The `pgxUUID` shim above is a placeholder — when implementing, look at `internal/lidarrrequests/service_test.go:TestApprove_NotFound` which constructs a synthetic UUID with `bogus.Bytes = [16]byte{...}; bogus.Valid = true`. Use that pattern instead. + +- [ ] **Step 3.3: Run tests** + +```bash +docker compose up -d postgres +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test -race ./internal/library/... -run TestDeleteTrackFile +``` + +Expected: all three subtests pass. + +- [ ] **Step 3.4: Commit** + +```bash +git add internal/library/delete.go internal/library/delete_test.go +git commit -m "feat(library): DeleteTrackFile (rm file + tracks row, album/artist preserved)" +``` + +--- + +### Task 4 — `lidarrquarantine.Service` — Flag/Unflag/ListMine/ListAdminQueue + +**Files:** +- Create: `internal/lidarrquarantine/service.go` +- Create: `internal/lidarrquarantine/service_test.go` + +Read the M5a `internal/lidarrrequests/service.go` first — it's the closest analog. Same shape (`Service` struct, factory function, integration tests gated on `MINSTREL_TEST_DATABASE_URL`, `dbtest.ResetDB` for isolation). Mirror it. + +- [ ] **Step 4.1: Write the package skeleton + read paths** + +`internal/lidarrquarantine/service.go`: + +```go +// Package lidarrquarantine owns the per-user track quarantine workflow. +// Users flag a track as broken (Flag/Unflag), the SPA hides the track +// from their views, and admins resolve the resulting reports via the +// Service's admin actions (Resolve / DeleteFile / DeleteViaLidarr). +package lidarrquarantine + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" + "git.fabledsword.com/bvandeusen/minstrel/internal/library" +) + +// Public errors. Handlers map these to API codes. +var ( + ErrBadReason = errors.New("lidarrquarantine: invalid reason") + ErrTrackNotFound = errors.New("lidarrquarantine: track not found") + ErrQuarantineNotFound = errors.New("lidarrquarantine: quarantine row not found") + ErrAlbumMBIDMissing = errors.New("lidarrquarantine: track has no parent album mbid") + ErrLidarrAlbumNotFound = errors.New("lidarrquarantine: lidarr has no album for that mbid") + ErrLidarrDisabled = errors.New("lidarrquarantine: lidarr is not configured") +) + +// Service is the lifecycle owner. clientFn is a per-call factory so config +// changes in lidarrconfig take effect immediately. clientFn returns nil +// when Lidarr is disabled. +type Service struct { + pool *pgxpool.Pool + lidarrCfg *lidarrconfig.Service + clientFn func() *lidarr.Client +} + +func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client) *Service { + if clientFn == nil { + clientFn = func() *lidarr.Client { return nil } + } + return &Service{pool: pool, lidarrCfg: cfg, clientFn: clientFn} +} + +// Flag inserts or updates a quarantine row for the caller. Re-flagging +// the same (user, track) overwrites reason+notes. +func (s *Service) Flag(ctx context.Context, userID, trackID pgtype.UUID, reason string, notes string) (dbq.LidarrQuarantine, error) { + if !validReason(reason) { + return dbq.LidarrQuarantine{}, ErrBadReason + } + var notesPtr *string + if notes != "" { + notesPtr = ¬es + } + row, err := dbq.New(s.pool).UpsertQuarantine(ctx, dbq.UpsertQuarantineParams{ + UserID: userID, + TrackID: trackID, + Reason: dbq.LidarrQuarantineReason(reason), + Notes: notesPtr, + }) + if err != nil { + // ON CONFLICT path can't trip ErrNoRows; only an FK violation does + // (track_id doesn't exist). Surface that as ErrTrackNotFound. + return dbq.LidarrQuarantine{}, fmt.Errorf("upsert: %w", err) + } + return row, nil +} + +// Unflag removes the caller's row. Returns ErrQuarantineNotFound if +// no row exists. +func (s *Service) Unflag(ctx context.Context, userID, trackID pgtype.UUID) error { + _, err := dbq.New(s.pool).DeleteQuarantine(ctx, dbq.DeleteQuarantineParams{ + UserID: userID, TrackID: trackID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrQuarantineNotFound + } + return fmt.Errorf("delete: %w", err) + } + return nil +} + +// ListMine returns the caller's quarantines with track/album/artist +// detail. Drives /library/hidden. +func (s *Service) ListMine(ctx context.Context, userID pgtype.UUID) ([]dbq.ListQuarantineForUserRow, error) { + return dbq.New(s.pool).ListQuarantineForUser(ctx, userID) +} + +// AdminQueueRow is the assembled aggregated row served by the admin +// queue endpoint. The handler post-processes the SQL results to attach +// reason_counts and per-user reports. +type AdminQueueRow struct { + TrackID pgtype.UUID + TrackTitle string + ArtistName string + AlbumTitle *string + AlbumID pgtype.UUID + LidarrAlbumMBID *string + ReportCount int32 + LatestAt pgtype.Timestamptz + ReasonCounts map[string]int + Reports []UserReport +} + +type UserReport struct { + UserID pgtype.UUID + Username string + Reason string + Notes *string + CreatedAt pgtype.Timestamptz +} + +// ListAdminQueue returns the aggregated admin queue. One row per track. +func (s *Service) ListAdminQueue(ctx context.Context) ([]AdminQueueRow, error) { + q := dbq.New(s.pool) + aggregated, err := q.ListAdminQuarantineQueue(ctx) + if err != nil { + return nil, fmt.Errorf("aggregate: %w", err) + } + out := make([]AdminQueueRow, 0, len(aggregated)) + for _, r := range aggregated { + reports, err := q.ListQuarantineReportsForTrack(ctx, r.TrackID) + if err != nil { + return nil, fmt.Errorf("reports for track %v: %w", r.TrackID, err) + } + rc := make(map[string]int, len(reports)) + userReports := make([]UserReport, 0, len(reports)) + for _, rep := range reports { + rc[string(rep.Reason)]++ + userReports = append(userReports, UserReport{ + UserID: rep.UserID, + Username: rep.Username, + Reason: string(rep.Reason), + Notes: rep.Notes, + CreatedAt: rep.CreatedAt, + }) + } + out = append(out, AdminQueueRow{ + TrackID: r.TrackID, + TrackTitle: r.TrackTitle, + ArtistName: r.ArtistName, + AlbumTitle: r.AlbumTitle, + AlbumID: r.AlbumID, + LidarrAlbumMBID: r.LidarrAlbumMbid, + ReportCount: r.ReportCount, + LatestAt: r.LatestAt, + ReasonCounts: rc, + Reports: userReports, + }) + } + return out, nil +} + +func validReason(r string) bool { + switch r { + case "bad_rip", "wrong_file", "wrong_tags", "duplicate", "other": + return true + } + return false +} +``` + +(Admin actions Resolve / DeleteFile / DeleteViaLidarr land in Task 5.) + +- [ ] **Step 4.2: Write the integration tests for the read paths** + +`internal/lidarrquarantine/service_test.go`: + +```go +package lidarrquarantine + +import ( + "context" + "errors" + "io" + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" +) + +func newPool(t *testing.T) *pgxpool.Pool { + t.Helper() + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + dbtest.ResetDB(t, pool) + if _, err := pool.Exec(context.Background(), + "DELETE FROM lidarr_quarantine; DELETE FROM lidarr_quarantine_actions;"); err != nil { + t.Fatalf("reset quarantine tables: %v", err) + } + return pool +} + +func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User { + t.Helper() + u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ + Username: dbtest.TestUserPrefix + name, PasswordHash: "x", + ApiToken: name + "-token", IsAdmin: false, + }) + if err != nil { + t.Fatalf("seed user %s: %v", name, err) + } + return u +} + +func seedTrack(t *testing.T, pool *pgxpool.Pool, title, mbid string) (dbq.Track, dbq.Album, dbq.Artist) { + t.Helper() + q := dbq.New(pool) + artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "Test Artist", SortName: "Test Artist", + }) + if err != nil { + t.Fatalf("artist: %v", err) + } + albumMBID := mbid + "-album" + album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Test Album", SortTitle: "Test Album", + ArtistID: artist.ID, Mbid: &albumMBID, + }) + if err != nil { + t.Fatalf("album: %v", err) + } + track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: title, AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: filepath.Join(t.TempDir(), title+".mp3"), + FileSize: 100, FileFormat: "mp3", + }) + if err != nil { + t.Fatalf("track: %v", err) + } + return track, album, artist +} + +func TestFlag_HappyPath(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + track, _, _ := seedTrack(t, pool, "Bad Track", "abc") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + row, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "crackly") + if err != nil { + t.Fatalf("Flag: %v", err) + } + if string(row.Reason) != "bad_rip" { + t.Errorf("reason = %v", row.Reason) + } + if row.Notes == nil || *row.Notes != "crackly" { + t.Errorf("notes = %v", row.Notes) + } +} + +func TestFlag_UpsertOnSecondFlag(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + track, _, _ := seedTrack(t, pool, "T", "x") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "first"); err != nil { + t.Fatalf("first flag: %v", err) + } + row, err := svc.Flag(context.Background(), user.ID, track.ID, "wrong_tags", "") + if err != nil { + t.Fatalf("second flag: %v", err) + } + if string(row.Reason) != "wrong_tags" { + t.Errorf("reason = %v", row.Reason) + } + if row.Notes != nil { + t.Errorf("notes = %v, want nil after empty notes upsert", row.Notes) + } +} + +func TestFlag_BadReasonRejected(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + track, _, _ := seedTrack(t, pool, "T", "x") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + _, err := svc.Flag(context.Background(), user.ID, track.ID, "garbage", "") + if !errors.Is(err, ErrBadReason) { + t.Errorf("err = %v, want ErrBadReason", err) + } +} + +func TestUnflag_DeletesRow(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + track, _, _ := seedTrack(t, pool, "T", "x") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + _, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "") + if err := svc.Unflag(context.Background(), user.ID, track.ID); err != nil { + t.Fatalf("Unflag: %v", err) + } + if err := svc.Unflag(context.Background(), user.ID, track.ID); !errors.Is(err, ErrQuarantineNotFound) { + t.Errorf("second Unflag err = %v, want ErrQuarantineNotFound", err) + } +} + +func TestListMine_OrderedNewestFirst(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + t1, _, _ := seedTrack(t, pool, "T1", "x") + t2, _, _ := seedTrack(t, pool, "T2", "y") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + _, _ = svc.Flag(context.Background(), user.ID, t1.ID, "bad_rip", "") + _, _ = svc.Flag(context.Background(), user.ID, t2.ID, "duplicate", "") + + rows, err := svc.ListMine(context.Background(), user.ID) + if err != nil { + t.Fatalf("ListMine: %v", err) + } + if len(rows) != 2 { + t.Fatalf("len = %d, want 2", len(rows)) + } + // T2 was flagged second — newest first. + if rows[0].LidarrQuarantine.TrackID != t2.ID { + t.Errorf("first row track = %v, want T2 (%v)", rows[0].LidarrQuarantine.TrackID, t2.ID) + } +} + +func TestListAdminQueue_AggregatesByTrackWithReasonCounts(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + bob := seedUser(t, pool, "bob") + carol := seedUser(t, pool, "carol") + track, _, _ := seedTrack(t, pool, "Hot Mess", "abc") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + _, _ = svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", "") + _, _ = svc.Flag(context.Background(), bob.ID, track.ID, "bad_rip", "") + _, _ = svc.Flag(context.Background(), carol.ID, track.ID, "wrong_tags", "") + + rows, err := svc.ListAdminQueue(context.Background()) + if err != nil { + t.Fatalf("ListAdminQueue: %v", err) + } + if len(rows) != 1 { + t.Fatalf("len = %d, want 1 aggregated row", len(rows)) + } + r := rows[0] + if r.ReportCount != 3 { + t.Errorf("report_count = %d, want 3", r.ReportCount) + } + if r.ReasonCounts["bad_rip"] != 2 || r.ReasonCounts["wrong_tags"] != 1 { + t.Errorf("reason_counts = %+v, want bad_rip=2 wrong_tags=1", r.ReasonCounts) + } + if len(r.Reports) != 3 { + t.Errorf("reports len = %d, want 3", len(r.Reports)) + } +} +``` + +- [ ] **Step 4.3: Run the tests** + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test -race ./internal/lidarrquarantine/... +``` + +Expected: all green. + +- [ ] **Step 4.4: Commit** + +```bash +git add internal/lidarrquarantine/ +git commit -m "feat(lidarrquarantine): Service Flag/Unflag/ListMine/ListAdminQueue" +``` + +--- + +### Task 5 — `lidarrquarantine.Service` admin actions + +**Files:** +- Modify: `internal/lidarrquarantine/service.go` — append Resolve / DeleteFile / DeleteViaLidarr +- Modify: `internal/lidarrquarantine/service_test.go` — append admin-action tests + +The three admin actions all follow the same shape: +1. Read the track (and parent album for DeleteViaLidarr) for snapshot fields. +2. Capture `affected_users` count via `CountQuarantineForTrack` *before* deleting. +3. For DeleteFile: call `library.DeleteTrackFile`; for DeleteViaLidarr: lookup album in Lidarr, call `Client.DeleteAlbum`, delete all Minstrel tracks in that album. +4. Delete `lidarr_quarantine` rows for the affected tracks. +5. Write a `lidarr_quarantine_actions` audit row. + +Order matters: Lidarr/file delete first, then DB writes. Failure of the external call leaves the per-user rows intact for retry. **No partial state.** + +- [ ] **Step 5.1: Append `Resolve` to `service.go`** + +```go +// Resolve clears all per-user quarantine rows for a track and writes an +// audit log row. Idempotent — a track with no rows still writes an audit +// entry with affected_users=0 (so admin can see "I clicked resolve on a +// track that already had no reports"). +func (s *Service) Resolve(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) { + q := dbq.New(s.pool) + track, err := q.GetTrackByID(ctx, trackID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return dbq.LidarrQuarantineAction{}, ErrTrackNotFound + } + return dbq.LidarrQuarantineAction{}, fmt.Errorf("get track: %w", err) + } + snap, err := s.snapshot(ctx, q, track) + if err != nil { + return dbq.LidarrQuarantineAction{}, err + } + + affected, err := q.CountQuarantineForTrack(ctx, trackID) + if err != nil { + return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err) + } + if err := q.DeleteQuarantineForTrack(ctx, trackID); err != nil { + return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete rows: %w", err) + } + return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ + TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, + AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionResolved, + AdminID: adminID, LidarrAlbumMbid: nil, AffectedUsers: affected, + }) +} + +// snapshot is shared scaffolding — pulls album/artist titles for the audit row. +type quarantineSnapshot struct { + TrackTitle string + ArtistName string + AlbumTitle *string + LidarrAlbumMBID *string +} + +func (s *Service) snapshot(ctx context.Context, q *dbq.Queries, track dbq.Track) (quarantineSnapshot, error) { + album, err := q.GetAlbumByID(ctx, track.AlbumID) + if err != nil { + return quarantineSnapshot{}, fmt.Errorf("get album: %w", err) + } + artist, err := q.GetArtistByID(ctx, track.ArtistID) + if err != nil { + return quarantineSnapshot{}, fmt.Errorf("get artist: %w", err) + } + return quarantineSnapshot{ + TrackTitle: track.Title, + ArtistName: artist.Name, + AlbumTitle: &album.Title, + LidarrAlbumMBID: album.Mbid, + }, nil +} +``` + +If `dbq.GetAlbumByID` / `dbq.GetArtistByID` don't exist as named queries, check the existing albums.sql / artists.sql files — they almost certainly do under different names (`AlbumByID`, `ArtistByID`, etc.) — and substitute the actual names. + +- [ ] **Step 5.2: Append `DeleteFile`** + +```go +// DeleteFile removes the track file from disk and the tracks row, then +// clears all per-user quarantine rows for that track and writes an audit +// row. If the file deletion fails, the per-user rows stay so admin can +// retry. No partial state. +func (s *Service) DeleteFile(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) { + q := dbq.New(s.pool) + track, err := q.GetTrackByID(ctx, trackID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return dbq.LidarrQuarantineAction{}, ErrTrackNotFound + } + return dbq.LidarrQuarantineAction{}, fmt.Errorf("get track: %w", err) + } + snap, err := s.snapshot(ctx, q, track) + if err != nil { + return dbq.LidarrQuarantineAction{}, err + } + + affected, err := q.CountQuarantineForTrack(ctx, trackID) + if err != nil { + return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err) + } + + if err := library.DeleteTrackFile(ctx, s.pool, trackID); err != nil { + return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete file: %w", err) + } + // tracks row is gone; the FK ON DELETE CASCADE on lidarr_quarantine + // already cleared the per-user rows. + return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ + TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, + AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionDeletedFile, + AdminID: adminID, LidarrAlbumMbid: nil, AffectedUsers: affected, + }) +} +``` + +Note the cascade comment: the schema has `ON DELETE CASCADE` on `lidarr_quarantine.track_id`, so when `library.DeleteTrackFile` runs `DELETE FROM tracks WHERE id = $1`, the per-user quarantine rows go too. We don't call `DeleteQuarantineForTrack` separately. **Verify this assumption holds when implementing** — re-check `0011_lidarr_quarantine.up.sql` and the existing `tracks` constraints. + +- [ ] **Step 5.3: Append `DeleteViaLidarr`** + +```go +// DeleteViaLidarr is the destructive admin path: tells Lidarr to remove +// the parent album with deleteFiles=true + addImportListExclusion=true, +// then removes Minstrel rows for all tracks of that album. The cascade +// on lidarr_quarantine clears per-user rows automatically. +// +// On Lidarr failure (unreachable, auth-failed, lookup-empty), nothing +// changes locally. Admin retries. +func (s *Service) DeleteViaLidarr(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, int, error) { + cfg, err := s.lidarrCfg.Get(ctx) + if err != nil { + return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("load config: %w", err) + } + client := s.clientFn() + if !cfg.Enabled || client == nil { + return dbq.LidarrQuarantineAction{}, 0, ErrLidarrDisabled + } + + q := dbq.New(s.pool) + track, err := q.GetTrackByID(ctx, trackID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return dbq.LidarrQuarantineAction{}, 0, ErrTrackNotFound + } + return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("get track: %w", err) + } + snap, err := s.snapshot(ctx, q, track) + if err != nil { + return dbq.LidarrQuarantineAction{}, 0, err + } + if snap.LidarrAlbumMBID == nil || *snap.LidarrAlbumMBID == "" { + return dbq.LidarrQuarantineAction{}, 0, ErrAlbumMBIDMissing + } + + affected, err := q.CountQuarantineForTrack(ctx, trackID) + if err != nil { + return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("count: %w", err) + } + + // Look up the album in Lidarr to translate MBID -> Lidarr internal ID. + album, err := client.LookupAlbumByMBID(ctx, *snap.LidarrAlbumMBID) + if err != nil { + if errors.Is(err, lidarr.ErrNotFound) { + return dbq.LidarrQuarantineAction{}, 0, ErrLidarrAlbumNotFound + } + return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("lidarr lookup: %w", err) + } + + // Lidarr DELETE — both flags true. + if err := client.DeleteAlbum(ctx, album.ID, true, true); err != nil { + return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("lidarr delete: %w", err) + } + + // Now remove the local rows. Cascade handles per-user quarantine + // rows via the FK on lidarr_quarantine.track_id. + res, err := s.pool.Exec(ctx, "DELETE FROM tracks WHERE album_id = $1", track.AlbumID) + if err != nil { + // We deleted in Lidarr but failed in our DB. Operator-recoverable + // by re-running. Audit row will reflect the eventual state. + return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("delete tracks: %w", err) + } + deletedCount := int(res.RowsAffected()) + + // Album/artist rows stay; if the operator wants those gone too they + // can be cleaned up by a future scan or a manual SQL pass. + + action, err := q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ + TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, + AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionDeletedViaLidarr, + AdminID: adminID, LidarrAlbumMbid: snap.LidarrAlbumMBID, + AffectedUsers: affected, + }) + return action, deletedCount, err +} +``` + +- [ ] **Step 5.4: Append admin-action tests** + +`internal/lidarrquarantine/service_test.go` — append: + +```go +import ( + // ... add to existing imports: + "net/http" + "net/http/httptest" + + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" +) + +func TestResolve_ClearsRowsAndWritesAudit(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + bob := seedUser(t, pool, "bob") + track, _, _ := seedTrack(t, pool, "T", "x") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + _, _ = svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", "") + _, _ = svc.Flag(context.Background(), bob.ID, track.ID, "wrong_tags", "") + + audit, err := svc.Resolve(context.Background(), track.ID, alice.ID) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if audit.AffectedUsers != 2 { + t.Errorf("affected_users = %d, want 2", audit.AffectedUsers) + } + if audit.Action != dbq.LidarrQuarantineActionResolved { + t.Errorf("action = %v, want resolved", audit.Action) + } + // No more rows for this track. + n, _ := dbq.New(pool).CountQuarantineForTrack(context.Background(), track.ID) + if n != 0 { + t.Errorf("rows after resolve = %d, want 0", n) + } +} + +func TestDeleteFile_RemovesFileAndAuditsAffected(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + // Real on-disk file: + dir := t.TempDir() + path := filepath.Join(dir, "track.mp3") + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"}) + album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "Al", SortTitle: "Al", ArtistID: artist.ID}) + track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "T", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3", + }) + + svc := NewService(pool, lidarrconfig.New(pool), nil) + _, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "") + + audit, err := svc.DeleteFile(context.Background(), track.ID, user.ID) + if err != nil { + t.Fatalf("DeleteFile: %v", err) + } + if audit.AffectedUsers != 1 { + t.Errorf("affected_users = %d, want 1", audit.AffectedUsers) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Errorf("file still exists: %v", err) + } +} + +func TestDeleteViaLidarr_FullCascade(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + + // Stub Lidarr server. + var captured []string + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = append(captured, r.Method+" "+r.URL.Path+"?"+r.URL.RawQuery) + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/v1/album" && r.Method == http.MethodGet { + _, _ = w.Write([]byte(`[{"id":42,"foreignAlbumId":"al-mbid","title":"Al","artistId":7}]`)) + return + } + // DELETE /api/v1/album/42 -> 200. + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(stub.Close) + + cfg := lidarrconfig.New(pool) + if err := cfg.Save(context.Background(), lidarrconfig.Config{ + Enabled: true, BaseURL: stub.URL, APIKey: "k", + }); err != nil { + t.Fatalf("save config: %v", err) + } + clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") } + svc := NewService(pool, cfg, clientFn) + + // Seed a track on an album whose mbid we'll match in the stub. + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"}) + albumMBID := "al-mbid" + album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Al", SortTitle: "Al", ArtistID: artist.ID, Mbid: &albumMBID, + }) + dir := t.TempDir() + path := filepath.Join(dir, "T.mp3") + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "T", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3", + }) + _, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "") + + audit, deleted, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID) + if err != nil { + t.Fatalf("DeleteViaLidarr: %v", err) + } + if deleted != 1 { + t.Errorf("deleted = %d, want 1 track removed", deleted) + } + if audit.Action != dbq.LidarrQuarantineActionDeletedViaLidarr { + t.Errorf("action = %v", audit.Action) + } + if audit.AffectedUsers != 1 { + t.Errorf("affected_users = %d, want 1", audit.AffectedUsers) + } + if audit.LidarrAlbumMbid == nil || *audit.LidarrAlbumMbid != "al-mbid" { + t.Errorf("lidarr_album_mbid = %v", audit.LidarrAlbumMbid) + } + // Track row is gone (and so is the per-user quarantine row, via cascade). + if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { + t.Errorf("track row still exists") + } + // Verify Lidarr was called with both flags true. + foundDelete := false + for _, c := range captured { + if c == "DELETE /api/v1/album/42?addImportListExclusion=true&deleteFiles=true" || + c == "DELETE /api/v1/album/42?deleteFiles=true&addImportListExclusion=true" { + foundDelete = true + } + } + if !foundDelete { + t.Errorf("Lidarr DELETE not called with both flags true; captured = %v", captured) + } +} + +func TestDeleteViaLidarr_LidarrDisabled(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + track, _, _ := seedTrack(t, pool, "T", "x") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + _, _, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID) + if !errors.Is(err, ErrLidarrDisabled) { + t.Errorf("err = %v, want ErrLidarrDisabled", err) + } +} +``` + +- [ ] **Step 5.5: Run + commit** + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test -race ./internal/lidarrquarantine/... +git add internal/lidarrquarantine/ +git commit -m "feat(lidarrquarantine): admin actions Resolve/DeleteFile/DeleteViaLidarr" +``` + +--- + +### Task 6 — Soft-hide query updates + +**Files:** +- Modify: `internal/db/queries/tracks.sql` — add `*ForUser` variants +- Modify: `internal/db/queries/recommendation.sql` — extend existing radio queries +- Regenerate: `internal/db/dbq/` + +The four affected queries are `ListTracksByAlbum`, `SearchTracks`, `CountTracksMatching`, and the radio loaders. The album/artist views are read through the existing handlers — adding `*ForUser` variants that take `user_id` lets the handlers route based on auth context. + +- [ ] **Step 6.1: Add `ListTracksByAlbumForUser` to `tracks.sql`** + +Append to `internal/db/queries/tracks.sql`: + +```sql +-- name: ListTracksByAlbumForUser :many +-- Same as ListTracksByAlbum but excludes tracks the user has quarantined. +SELECT * FROM tracks +WHERE album_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $2 AND q.track_id = tracks.id + ) +ORDER BY disc_number NULLS LAST, track_number NULLS LAST; + +-- name: SearchTracksForUser :many +SELECT * FROM tracks +WHERE title ILIKE '%' || $1 || '%' + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $2 AND q.track_id = tracks.id + ) +ORDER BY title +LIMIT $3 OFFSET $4; + +-- name: CountTracksMatchingForUser :one +SELECT COUNT(*) FROM tracks +WHERE title ILIKE '%' || $1::text || '%' + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $2 AND q.track_id = tracks.id + ); +``` + +- [ ] **Step 6.2: Extend the radio loaders** + +Modify `internal/db/queries/recommendation.sql`. For each `LoadRadioCandidates*` query, add a quarantine clause to the `WHERE` block. The user_id is already a parameter on these queries (`$1`); the additional clause is: + +```sql + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) +``` + +For `LoadRadioCandidates`: + +```sql +WHERE t.id <> $2 + AND NOT EXISTS ( + SELECT 1 FROM play_events + WHERE user_id = $1 AND track_id = t.id + AND started_at > now() - $3 * interval '1 hour' + ) + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ); +``` + +For `LoadRadioCandidatesV2`, add the same clause inside the final `WHERE` of the union output (look for the comment "5-way UNION" — add the clause to the outer WHERE that filters the unioned candidates by `excluded_ids` etc.). + +- [ ] **Step 6.3: Regenerate sqlc + build** + +```bash +cd internal/db && sqlc generate && cd - +go build ./... +``` + +Expected: clean build. New methods `ListTracksByAlbumForUser`, `SearchTracksForUser`, `CountTracksMatchingForUser` appear in `internal/db/dbq/tracks.sql.go`. + +- [ ] **Step 6.4: Commit** + +```bash +git add internal/db/queries/ internal/db/dbq/ +git commit -m "feat(db): add user-context track query variants honoring quarantine" +``` + +--- + +### Task 7 — Wire soft-hide into existing read handlers + +**Files:** Modify existing handlers under `internal/api/` to call the `*ForUser` queries when an authenticated user is in context. + +The pattern: where the handler currently calls (e.g.) `q.ListTracksByAlbum(ctx, albumID)`, switch to `q.ListTracksByAlbumForUser(ctx, dbq.ListTracksByAlbumForUserParams{AlbumID: albumID, UserID: user.ID})` when `user, ok := auth.UserFromContext(r.Context()); ok` is true. The `else` branch keeps the unfiltered query for any path without a user context (Subsonic, internal callers). + +- [ ] **Step 7.1: Identify call sites** + +Run: + +```bash +grep -rn "ListTracksByAlbum\|SearchTracks\|CountTracksMatching\|LoadRadioCandidates" \ + internal/api/ internal/subsonic/ +``` + +For every call in `internal/api/`, branch on `auth.UserFromContext`. For every call in `internal/subsonic/`, leave it alone (Subsonic is `/rest/*` and doesn't honor quarantine per the legacy memory). + +- [ ] **Step 7.2: Pattern to apply** + +Example for the album-detail handler: + +```go +func (h *handlers) handleAlbumDetail(w http.ResponseWriter, r *http.Request) { + // ... existing parse + lookup ... + var tracks []dbq.Track + if user, ok := auth.UserFromContext(r.Context()); ok { + tracks, err = q.ListTracksByAlbumForUser(r.Context(), dbq.ListTracksByAlbumForUserParams{ + AlbumID: albumID, UserID: user.ID, + }) + } else { + tracks, err = q.ListTracksByAlbum(r.Context(), albumID) + } + // ... existing error + response ... +} +``` + +Repeat for search and radio handlers. The radio handlers already take `user_id` for personalization — the schema change in Task 6 just extended their existing query, so those handlers don't need restructuring. + +- [ ] **Step 7.3: Regression-test the existing endpoints** + +```bash +go test ./internal/api/... -count=1 +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test -race -p 1 ./internal/api/... +``` + +Expected: existing tests still pass — they don't seed any quarantine rows, so the filter is a no-op. + +- [ ] **Step 7.4: Commit** + +```bash +git add internal/api/ +git commit -m "feat(api): route track-list reads through user-context quarantine filter" +``` + +--- + +### Task 8 — `/api/quarantine/*` user-facing handlers + +**Files:** +- Create: `internal/api/quarantine.go` +- Create: `internal/api/quarantine_test.go` +- Modify: `internal/api/api.go` to mount the new routes + +Three endpoints: `POST /api/quarantine`, `DELETE /api/quarantine/:track_id`, `GET /api/quarantine/mine`. Mirror M5a's `internal/api/requests.go` for the auth + writeJSON conventions. + +- [ ] **Step 8.1: Write `quarantine.go`** + +```go +package api + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" +) + +type quarantineView struct { + UserID pgtype.UUID `json:"user_id"` + TrackID pgtype.UUID `json:"track_id"` + Reason string `json:"reason"` + Notes *string `json:"notes,omitempty"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func quarantineViewFrom(row dbq.LidarrQuarantine) quarantineView { + return quarantineView{ + UserID: row.UserID, TrackID: row.TrackID, + Reason: string(row.Reason), Notes: row.Notes, CreatedAt: row.CreatedAt, + } +} + +type flagBody struct { + TrackID pgtype.UUID `json:"track_id"` + Reason string `json:"reason"` + Notes string `json:"notes"` +} + +func (h *handlers) handleFlag(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + var body flagBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") + return + } + row, err := h.lidarrQuarantine.Flag(r.Context(), user.ID, body.TrackID, body.Reason, body.Notes) + if err != nil { + switch { + case errors.Is(err, lidarrquarantine.ErrBadReason): + writeErr(w, http.StatusBadRequest, "bad_reason", err.Error()) + case errors.Is(err, lidarrquarantine.ErrTrackNotFound): + writeErr(w, http.StatusNotFound, "track_not_found", "track does not exist") + default: + h.logger.Error("api: flag", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "flag failed") + } + return + } + writeJSON(w, http.StatusCreated, quarantineViewFrom(row)) +} + +func (h *handlers) handleUnflag(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + id, ok := parseUUID(chi.URLParam(r, "track_id")) + if !ok { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id") + return + } + if err := h.lidarrQuarantine.Unflag(r.Context(), user.ID, id); err != nil { + if errors.Is(err, lidarrquarantine.ErrQuarantineNotFound) { + writeErr(w, http.StatusNotFound, "quarantine_not_found", "no quarantine for that track") + return + } + h.logger.Error("api: unflag", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "unflag failed") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// quarantineMineView wraps the joined row for /api/quarantine/mine. The +// SPA on /library/hidden needs the full track + album + artist payload. +type quarantineMineView struct { + quarantineView + Track dbq.Track `json:"track"` + Album dbq.Album `json:"album"` + Artist dbq.Artist `json:"artist"` +} + +func (h *handlers) handleListMyQuarantine(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + rows, err := h.lidarrQuarantine.ListMine(r.Context(), user.ID) + if err != nil { + h.logger.Error("api: list mine", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "list failed") + return + } + out := make([]quarantineMineView, 0, len(rows)) + for _, row := range rows { + out = append(out, quarantineMineView{ + quarantineView: quarantineViewFrom(row.LidarrQuarantine), + Track: row.Track, + Album: row.Album, + Artist: row.Artist, + }) + } + writeJSON(w, http.StatusOK, out) +} +``` + +- [ ] **Step 8.2: Mount routes in `api.go`** + +Inside the existing authenticated route group, add: + +```go +r.Post("/api/quarantine", h.handleFlag) +r.Delete("/api/quarantine/{track_id}", h.handleUnflag) +r.Get("/api/quarantine/mine", h.handleListMyQuarantine) +``` + +- [ ] **Step 8.3: Add `lidarrQuarantine` to the handlers struct** + +Find the `handlers` struct (in `internal/api/api.go` or wherever it lives) and add: + +```go +lidarrQuarantine *lidarrquarantine.Service +``` + +Then update the constructor / wiring to accept it. + +- [ ] **Step 8.4: Write tests** + +`internal/api/quarantine_test.go` mirrors the M5a `requests_test.go` shape: stub handlers, real DB via `MINSTREL_TEST_DATABASE_URL`, table-driven scenarios. Cover: +- Flag with valid reason → 201, row visible in `ListMine`. +- Flag with `bad_reason` → 400, `error.code === "bad_reason"`. +- Flag for a track that doesn't exist → 404 `track_not_found`. +- Unflag happy path → 204. +- Unflag for a row that doesn't exist → 404 `quarantine_not_found`. +- ListMine with two flags → returns two rows, newest first. +- Unauthenticated requests → 401 across the board (the existing `RequireUser` middleware handles this; one assertion is enough). + +- [ ] **Step 8.5: Commit** + +```bash +go test ./internal/api/... -run TestFlag -count=1 +git add internal/api/quarantine.go internal/api/quarantine_test.go internal/api/api.go +git commit -m "feat(api): /api/quarantine user-facing CRUD" +``` + +--- + +### Task 9 — `/api/admin/quarantine/*` admin handlers + +**Files:** +- Create: `internal/api/admin_quarantine.go` +- Create: `internal/api/admin_quarantine_test.go` +- Modify: `internal/api/api.go` to mount under the existing `/api/admin/*` route group + +Five endpoints: GET queue, GET actions, three POST resolution actions. Reuse the `RequireAdmin` middleware from M5a. + +- [ ] **Step 9.1: Write `admin_quarantine.go`** + +```go +package api + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" +) + +// adminQueueRowView is the wire shape returned by GET /api/admin/quarantine. +type adminQueueRowView struct { + TrackID string `json:"track_id"` + TrackTitle string `json:"track_title"` + ArtistName string `json:"artist_name"` + AlbumTitle *string `json:"album_title,omitempty"` + AlbumID string `json:"album_id"` + LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"` + ReportCount int32 `json:"report_count"` + LatestAt string `json:"latest_at"` + ReasonCounts map[string]int `json:"reason_counts"` + Reports []adminQueueReportView `json:"reports"` +} + +type adminQueueReportView struct { + UserID string `json:"user_id"` + Username string `json:"username"` + Reason string `json:"reason"` + Notes *string `json:"notes,omitempty"` + CreatedAt string `json:"created_at"` +} + +func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Request) { + rows, err := h.lidarrQuarantine.ListAdminQueue(r.Context()) + if err != nil { + h.logger.Error("admin: list quarantine", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") + return + } + out := make([]adminQueueRowView, 0, len(rows)) + for _, r := range rows { + reports := make([]adminQueueReportView, 0, len(r.Reports)) + for _, rep := range r.Reports { + reports = append(reports, adminQueueReportView{ + UserID: uuidToString(rep.UserID), + Username: rep.Username, + Reason: rep.Reason, + Notes: rep.Notes, + CreatedAt: rep.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), + }) + } + out = append(out, adminQueueRowView{ + TrackID: uuidToString(r.TrackID), TrackTitle: r.TrackTitle, + ArtistName: r.ArtistName, AlbumTitle: r.AlbumTitle, + AlbumID: uuidToString(r.AlbumID), LidarrAlbumMBID: r.LidarrAlbumMBID, + ReportCount: r.ReportCount, + LatestAt: r.LatestAt.Time.Format("2006-01-02T15:04:05Z07:00"), + ReasonCounts: r.ReasonCounts, Reports: reports, + }) + } + writeJSON(w, http.StatusOK, out) +} + +type actionResultView struct { + ActionID string `json:"action_id"` + AffectedUsers int32 `json:"affected_users"` + DeletedTrackCount *int `json:"deleted_track_count,omitempty"` +} + +func (h *handlers) handleResolveQuarantine(w http.ResponseWriter, r *http.Request) { + admin, _ := auth.UserFromContext(r.Context()) + id, ok := parseUUID(chi.URLParam(r, "track_id")) + if !ok { + writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") + return + } + action, err := h.lidarrQuarantine.Resolve(r.Context(), id, admin.ID) + if err != nil { + if errors.Is(err, lidarrquarantine.ErrTrackNotFound) { + writeAdminJSONErr(w, http.StatusNotFound, "track_not_found") + return + } + h.logger.Error("admin: resolve quarantine", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") + return + } + writeJSON(w, http.StatusOK, actionResultView{ + ActionID: uuidToString(action.ID), AffectedUsers: action.AffectedUsers, + }) +} + +func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Request) { + admin, _ := auth.UserFromContext(r.Context()) + id, ok := parseUUID(chi.URLParam(r, "track_id")) + if !ok { + writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") + return + } + action, err := h.lidarrQuarantine.DeleteFile(r.Context(), id, admin.ID) + if err != nil { + switch { + case errors.Is(err, lidarrquarantine.ErrTrackNotFound): + writeAdminJSONErr(w, http.StatusNotFound, "track_not_found") + default: + h.logger.Error("admin: delete file", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "file_delete_failed") + } + return + } + writeJSON(w, http.StatusOK, actionResultView{ + ActionID: uuidToString(action.ID), AffectedUsers: action.AffectedUsers, + }) +} + +func (h *handlers) handleDeleteQuarantineViaLidarr(w http.ResponseWriter, r *http.Request) { + admin, _ := auth.UserFromContext(r.Context()) + id, ok := parseUUID(chi.URLParam(r, "track_id")) + if !ok { + writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") + return + } + action, deleted, err := h.lidarrQuarantine.DeleteViaLidarr(r.Context(), id, admin.ID) + if err != nil { + switch { + case errors.Is(err, lidarrquarantine.ErrLidarrDisabled): + writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled") + case errors.Is(err, lidarrquarantine.ErrTrackNotFound): + writeAdminJSONErr(w, http.StatusNotFound, "track_not_found") + case errors.Is(err, lidarrquarantine.ErrAlbumMBIDMissing): + writeAdminJSONErr(w, http.StatusNotFound, "album_mbid_missing") + case errors.Is(err, lidarrquarantine.ErrLidarrAlbumNotFound): + writeAdminJSONErr(w, http.StatusBadGateway, "lidarr_album_lookup_failed") + case errors.Is(err, lidarr.ErrUnreachable): + writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unreachable") + case errors.Is(err, lidarr.ErrAuthFailed): + writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_auth_failed") + default: + h.logger.Error("admin: delete via lidarr", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") + } + return + } + writeJSON(w, http.StatusOK, actionResultView{ + ActionID: uuidToString(action.ID), AffectedUsers: action.AffectedUsers, + DeletedTrackCount: &deleted, + }) +} + +type actionLogView struct { + ID string `json:"id"` + TrackID string `json:"track_id"` + TrackTitle string `json:"track_title"` + ArtistName string `json:"artist_name"` + AlbumTitle *string `json:"album_title,omitempty"` + Action string `json:"action"` + AdminID *string `json:"admin_id,omitempty"` + LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"` + AffectedUsers int32 `json:"affected_users"` + CreatedAt string `json:"created_at"` +} + +func (h *handlers) handleListQuarantineActions(w http.ResponseWriter, r *http.Request) { + limitStr := r.URL.Query().Get("limit") + limit := int32(50) + if limitStr != "" { + if v, err := strconv.Atoi(limitStr); err == nil && v > 0 && v <= 200 { + limit = int32(v) + } + } + rows, err := dbq.New(h.pool).ListQuarantineActions(r.Context(), limit) + if err != nil { + h.logger.Error("admin: list actions", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") + return + } + out := make([]actionLogView, 0, len(rows)) + for _, row := range rows { + var adminID *string + if row.AdminID.Valid { + s := uuidToString(row.AdminID) + adminID = &s + } + out = append(out, actionLogView{ + ID: uuidToString(row.ID), TrackID: uuidToString(row.TrackID), + TrackTitle: row.TrackTitle, ArtistName: row.ArtistName, AlbumTitle: row.AlbumTitle, + Action: string(row.Action), AdminID: adminID, + LidarrAlbumMBID: row.LidarrAlbumMbid, AffectedUsers: row.AffectedUsers, + CreatedAt: row.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), + }) + } + writeJSON(w, http.StatusOK, out) +} +``` + +`uuidToString` and `parseUUID` are existing helpers in `internal/api/`. Reuse them. + +- [ ] **Step 9.2: Mount routes** + +Inside the `/api/admin` route group: + +```go +r.Get("/api/admin/quarantine", h.handleListAdminQuarantine) +r.Post("/api/admin/quarantine/{track_id}/resolve", h.handleResolveQuarantine) +r.Post("/api/admin/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile) +r.Post("/api/admin/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr) +r.Get("/api/admin/quarantine/actions", h.handleListQuarantineActions) +``` + +- [ ] **Step 9.3: Tests (`internal/api/admin_quarantine_test.go`)** + +Mirror M5a's `internal/api/admin_requests_test.go`. Cover: +- Aggregated queue shape: 3 users × 1 track yields 1 row with `report_count=3`, correct `reason_counts`. +- Resolve clears rows, returns 200 with `affected_users`. +- Delete file: file vanishes from disk, row gone, audit row written. +- Delete via Lidarr with stub server: lookup → DELETE → row removed; happy path 200 with `deleted_track_count`. +- Delete via Lidarr with `lidarr_disabled` config → 503 `lidarr_disabled`. +- Delete via Lidarr with stub returning empty array on lookup → 502 `lidarr_album_lookup_failed`. +- Delete via Lidarr on a track with no album MBID → 404 `album_mbid_missing`. +- Non-admin user → 403 across all admin endpoints. +- Action log GET returns rows ordered newest-first. + +- [ ] **Step 9.4: Commit** + +```bash +docker run --rm --network minstrel_minstrel -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm go test -race -p 1 ./internal/api/... -run AdminQuarantine +git add internal/api/admin_quarantine.go internal/api/admin_quarantine_test.go internal/api/api.go +git commit -m "feat(api): /api/admin/quarantine queue + resolve/delete-file/delete-via-lidarr" +``` + +--- + +### Task 10 — Wire `Service` into `cmd/minstrel/main.go` + +**Files:** Modify `cmd/minstrel/main.go`. + +The handlers struct (Task 8 step 8.3) now expects a `*lidarrquarantine.Service`. Construct it at startup and pass it through. + +- [ ] **Step 10.1: Add the construction** + +Find where the M5a `lidarrrequests.Service` is constructed in `main.go`. Right after it, add: + +```go +quarSvc := lidarrquarantine.NewService(pool, lidarrCfg, func() *lidarr.Client { + cfg, err := lidarrCfg.Get(ctx) + if err != nil || !cfg.Enabled { + return nil + } + return lidarr.NewClient(cfg.BaseURL, cfg.APIKey) +}) +``` + +If a similar `clientFn` already exists for `lidarrrequests`, reuse it instead of duplicating the closure. Pass `quarSvc` into the handlers constructor. + +- [ ] **Step 10.2: Build + smoke test** + +```bash +go build ./cmd/minstrel +go vet ./... +golangci-lint run ./... +``` + +Expected: clean build, no lint warnings. If `golangci-lint` isn't installed, skip. + +- [ ] **Step 10.3: Commit** + +```bash +git add cmd/minstrel/main.go internal/api/api.go +git commit -m "feat(cmd): wire lidarrquarantine.Service into the API handlers" +``` + +--- + +### Task 11 — Frontend: API client modules + types + +**Files:** Create `web/src/lib/api/quarantine.ts` + `quarantine.test.ts`. Modify `web/src/lib/api/types.ts`, `queries.ts`, `admin.ts`. Mirror the existing M5a pattern from `requests.ts` / `admin.ts`. + +- [ ] **Step 11.1: Add types to `types.ts`** + +```ts +export type LidarrQuarantineReason = 'bad_rip' | 'wrong_file' | 'wrong_tags' | 'duplicate' | 'other'; + +export type LidarrQuarantineRow = { + user_id: string; + track_id: string; + reason: LidarrQuarantineReason; + notes?: string | null; + created_at: string; +}; + +export type LidarrQuarantineMineRow = LidarrQuarantineRow & { + track: TrackRef; + album: AlbumRef; + artist: ArtistRef; +}; + +export type AdminQuarantineRow = { + track_id: string; + track_title: string; + artist_name: string; + album_title?: string | null; + album_id: string; + lidarr_album_mbid?: string | null; + report_count: number; + latest_at: string; + reason_counts: Record<LidarrQuarantineReason, number>; + reports: AdminQuarantineReport[]; +}; + +export type AdminQuarantineReport = { + user_id: string; + username: string; + reason: LidarrQuarantineReason; + notes?: string | null; + created_at: string; +}; + +export type LidarrQuarantineAction = 'resolved' | 'deleted_file' | 'deleted_via_lidarr'; + +export type LidarrQuarantineActionRow = { + id: string; + track_id: string; + track_title: string; + artist_name: string; + album_title?: string | null; + action: LidarrQuarantineAction; + admin_id?: string | null; + lidarr_album_mbid?: string | null; + affected_users: number; + created_at: string; +}; + +export type ActionResult = { + action_id: string; + affected_users: number; + deleted_track_count?: number; +}; +``` + +- [ ] **Step 11.2: Add query keys to `queries.ts`** + +```ts +qk.myQuarantine = () => ['myQuarantine'] as const; +qk.adminQuarantine = () => ['adminQuarantine'] as const; +qk.adminQuarantineActions = (limit?: number) => ['adminQuarantineActions', { limit: limit ?? 50 }] as const; +``` + +(Keep the existing `qk` object syntax — append the new functions.) + +- [ ] **Step 11.3: Write `quarantine.ts`** + +```ts +import { createQuery } from '@tanstack/svelte-query'; +import { api, apiFetch } from './client'; +import { qk } from './queries'; +import type { + LidarrQuarantineRow, + LidarrQuarantineMineRow, + LidarrQuarantineReason +} from './types'; + +export type FlagParams = { + track_id: string; + reason: LidarrQuarantineReason; + notes?: string; +}; + +export async function flagTrack(params: FlagParams): Promise<LidarrQuarantineRow> { + const body: FlagParams = { track_id: params.track_id, reason: params.reason }; + if (params.notes && params.notes.length > 0) body.notes = params.notes; + return api.post<LidarrQuarantineRow>('/api/quarantine', body); +} + +// Server returns 204 (no body) for DELETE; handle accordingly. +export async function unflagTrack(trackID: string): Promise<void> { + await apiFetch(`/api/quarantine/${trackID}`, { method: 'DELETE' }); +} + +export async function listMyQuarantine(): Promise<LidarrQuarantineMineRow[]> { + return api.get<LidarrQuarantineMineRow[]>('/api/quarantine/mine'); +} + +export function createMyQuarantineQuery() { + return createQuery({ + queryKey: qk.myQuarantine(), + queryFn: listMyQuarantine, + staleTime: 60_000 + }); +} +``` + +- [ ] **Step 11.4: Append admin endpoints to `admin.ts`** + +```ts +import type { AdminQuarantineRow, ActionResult, LidarrQuarantineActionRow } from './types'; + +export async function listAdminQuarantine(): Promise<AdminQuarantineRow[]> { + return api.get<AdminQuarantineRow[]>('/api/admin/quarantine'); +} + +export async function resolveQuarantine(trackID: string): Promise<ActionResult> { + return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/resolve`, {}); +} + +export async function deleteQuarantineFile(trackID: string): Promise<ActionResult> { + return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/delete-file`, {}); +} + +export async function deleteQuarantineViaLidarr(trackID: string): Promise<ActionResult> { + return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/delete-via-lidarr`, {}); +} + +export async function listQuarantineActions(limit = 50): Promise<LidarrQuarantineActionRow[]> { + return api.get<LidarrQuarantineActionRow[]>(`/api/admin/quarantine/actions?limit=${limit}`); +} + +export function createAdminQuarantineQuery() { + return createQuery({ + queryKey: qk.adminQuarantine(), + queryFn: listAdminQuarantine, + staleTime: 30_000 // queue should refresh more aggressively than my-history + }); +} + +export function createQuarantineActionsQuery(limit = 50) { + return createQuery({ + queryKey: qk.adminQuarantineActions(limit), + queryFn: () => listQuarantineActions(limit), + staleTime: 60_000 + }); +} +``` + +- [ ] **Step 11.5: Tests** + +`quarantine.test.ts` — mirror `requests.test.ts` shape: `vi.mock('./client')`, assert URL + body shapes, return-value flow-through. Cover flagTrack (with + without notes), unflagTrack, listMyQuarantine, query factory, qk shape. + +For `admin.ts`: extend the existing test file to cover the five new functions + their query factories. + +- [ ] **Step 11.6: Run + commit** + +```bash +cd web && npm run check && npm test -- --run && cd - +git add web/src/lib/api/ +git commit -m "feat(web): API client modules for quarantine + admin quarantine" +``` + +--- + +### Task 12 — Frontend: `<TrackMenu>` + `<FlagPopover>` components + +**Files:** Create `web/src/lib/components/TrackMenu.svelte`, `TrackMenu.test.ts`, `FlagPopover.svelte`, `FlagPopover.test.ts`. + +`<TrackMenu>` is a kebab button + dropdown menu. For M5b it has one item: "Flag this track…". Component is structured so future actions slot in alongside. + +`<FlagPopover>` is the reason form, opened from TrackMenu. Pre-fills if the user already has a quarantine on this track. + +- [ ] **Step 12.1: Write `TrackMenu.svelte`** + +```svelte +<script lang="ts"> + import { MoreVertical, Flag } from 'lucide-svelte'; + import FlagPopover from './FlagPopover.svelte'; + import type { TrackRef } from '$lib/api/types'; + + let { track }: { track: TrackRef } = $props(); + + let menuOpen = $state(false); + let popoverOpen = $state(false); + + function toggleMenu(e: MouseEvent) { + e.stopPropagation(); + menuOpen = !menuOpen; + } + + function openFlag() { + menuOpen = false; + popoverOpen = true; + } + + function closeAll() { + menuOpen = false; + popoverOpen = false; + } +</script> + +<svelte:window + onclick={() => (menuOpen = false)} + onkeydown={(e) => e.key === 'Escape' && closeAll()} +/> + +<div class="relative inline-block"> + <button + type="button" + aria-label={`Track actions for ${track.title}`} + aria-haspopup="menu" + aria-expanded={menuOpen} + onclick={toggleMenu} + class="rounded p-1 text-text-muted hover:text-text-primary" + > + <MoreVertical size={16} strokeWidth={1} /> + </button> + + {#if menuOpen} + <div + role="menu" + class="absolute right-0 z-20 mt-1 w-48 rounded-md border border-border bg-surface p-1 shadow-lg" + onclick={(e) => e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + > + <button + type="button" + role="menuitem" + onclick={openFlag} + class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm text-text-primary hover:bg-surface-hover" + > + <Flag size={14} strokeWidth={1} /> + Flag this track… + </button> + </div> + {/if} + + {#if popoverOpen} + <FlagPopover {track} onClose={closeAll} /> + {/if} +</div> +``` + +- [ ] **Step 12.2: Write `FlagPopover.svelte`** + +```svelte +<script lang="ts"> + import { Flag } from 'lucide-svelte'; + import { useQueryClient } from '@tanstack/svelte-query'; + import { flagTrack } from '$lib/api/quarantine'; + import { qk } from '$lib/api/queries'; + import type { TrackRef, LidarrQuarantineReason } from '$lib/api/types'; + + let { + track, + onClose, + initialReason, + initialNotes, + }: { + track: TrackRef; + onClose: () => void; + initialReason?: LidarrQuarantineReason; + initialNotes?: string; + } = $props(); + + let reason: LidarrQuarantineReason = $state(initialReason ?? 'bad_rip'); + let notes = $state(initialNotes ?? ''); + let submitting = $state(false); + let error = $state<string | null>(null); + + const isUpdate = !!initialReason; + const client = useQueryClient(); + + async function submit() { + submitting = true; + error = null; + try { + await flagTrack({ track_id: track.id, reason, notes: notes.trim() || undefined }); + await client.invalidateQueries({ queryKey: qk.myQuarantine() }); + onClose(); + } catch (e) { + error = (e as { code?: string }).code ?? 'flag_failed'; + } finally { + submitting = false; + } + } +</script> + +<!-- Anchored popover. Parent wrapper provides the relative-positioning context. --> +<div + role="dialog" + aria-modal="false" + aria-labelledby="flag-popover-title" + class="absolute right-0 z-30 mt-1 w-72 rounded-lg border border-border bg-surface p-3 shadow-xl" + onclick={(e) => e.stopPropagation()} + onkeydown={(e) => e.key === 'Escape' && onClose()} +> + <h4 id="flag-popover-title" class="text-sm font-medium text-text-primary"> + Flag this track as broken + </h4> + <label class="mt-2 block"> + <span class="block text-xs text-text-secondary">Reason</span> + <select + bind:value={reason} + class="mt-1 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent" + > + <option value="bad_rip">Bad rip</option> + <option value="wrong_file">Wrong file</option> + <option value="wrong_tags">Wrong tags</option> + <option value="duplicate">Duplicate</option> + <option value="other">Other</option> + </select> + </label> + <label class="mt-2 block"> + <span class="block text-xs text-text-secondary">Notes (optional)</span> + <textarea + bind:value={notes} + maxlength="200" + placeholder="What's wrong with it?" + class="mt-1 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" + rows="2" + ></textarea> + </label> + {#if error} + <p class="mt-2 text-xs text-error">Couldn't save flag — {error}</p> + {/if} + <div class="mt-3 flex justify-end gap-2"> + <button + type="button" + class="rounded-md border border-border px-2.5 py-1 text-sm text-text-secondary hover:text-text-primary" + onclick={onClose} + > + Cancel + </button> + <button + type="button" + onclick={submit} + disabled={submitting} + class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-2.5 py-1 text-sm text-text-primary disabled:opacity-50" + > + <Flag size={14} strokeWidth={1} /> + {isUpdate ? 'Update flag' : 'Flag'} + </button> + </div> +</div> +``` + +- [ ] **Step 12.3: Tests** + +`TrackMenu.test.ts`: +- Click kebab → menu visible. +- Click outside → menu closes. +- Escape → menu closes. +- Click "Flag this track…" → popover opens. + +`FlagPopover.test.ts`: +- Defaults to reason=`bad_rip` when no initialReason. +- Pre-fills when initialReason+initialNotes are provided; button reads "Update flag". +- Submit calls `flagTrack` (mocked) with the typed reason + non-empty notes; empty notes are NOT sent. +- Cancel calls onClose; does not call flagTrack. +- Submit calls `invalidateQueries` on success. + +For `flagTrack` mock pattern, copy from `web/src/routes/admin/integrations/integrations.test.ts` (the `vi.mock('$lib/api/admin', ...)` shape). + +- [ ] **Step 12.4: Run + commit** + +```bash +cd web && npm run check && npm test -- --run TrackMenu FlagPopover && cd - +git add web/src/lib/components/TrackMenu.svelte web/src/lib/components/TrackMenu.test.ts \ + web/src/lib/components/FlagPopover.svelte web/src/lib/components/FlagPopover.test.ts +git commit -m "feat(web): TrackMenu overflow + FlagPopover for the quarantine flow" +``` + +--- + +### Task 13 — Mount `<TrackMenu>` in `TrackRow` + `PlayerBar` + +**Files:** Modify `TrackRow.svelte`, `TrackRow.test.ts`, `PlayerBar.svelte`, `PlayerBar.test.ts`. + +- [ ] **Step 13.1: TrackRow** + +Add `<TrackMenu>` as a sibling to `<LikeButton>` in the row's right cluster: + +```svelte +<script lang="ts"> + // ... existing imports ... + import TrackMenu from './TrackMenu.svelte'; +</script> + +<!-- existing markup ... right cluster: --> +<LikeButton entityType="track" entityId={track.id} /> +<TrackMenu {track} /> +``` + +- [ ] **Step 13.2: PlayerBar** + +Same: add the `<TrackMenu>` to the right cluster, after the like button. The `track` prop is the currently-playing track from the player store (e.g. `player.current`). + +```svelte +{#if player.current} + <LikeButton entityType="track" entityId={player.current.id} /> + <TrackMenu track={player.current} /> +{/if} +``` + +- [ ] **Step 13.3: Update tests** + +Both `TrackRow.test.ts` and `PlayerBar.test.ts` — add an assertion that the track-actions kebab is rendered. Existing tests (like-button presence, play-on-click) should still pass. + +- [ ] **Step 13.4: Run + commit** + +```bash +cd web && npm test -- --run TrackRow PlayerBar && cd - +git add web/src/lib/components/TrackRow.svelte web/src/lib/components/TrackRow.test.ts \ + web/src/lib/components/PlayerBar.svelte web/src/lib/components/PlayerBar.test.ts +git commit -m "feat(web): mount TrackMenu in TrackRow + PlayerBar" +``` + +--- + +### Task 14 — Frontend: `/library/hidden` route + +**Files:** Create `web/src/routes/library/hidden/+page.svelte` + `hidden.test.ts`. Modify `Shell.svelte` to add `Hidden` to the main nav (between `Liked` and `Search`). + +- [ ] **Step 14.1: Write the page** + +Pattern matches `/requests` exactly — same row anatomy. Use `createMyQuarantineQuery()` for data, `unflagTrack(trackID)` + `invalidateQueries(qk.myQuarantine())` for the un-hide affordance. Empty state copy: "Nothing hidden yet." + +Each row (mirroring `/requests`): +- 56px album art with Lucide `Music2` fallback. +- Pills: kind ("Track", accent-tint), reason (`bad_rip` etc., accent-tint). +- Title (Parchment) + meta line "by `<artist>` · `<album>` · flagged 2d ago". +- Notes (Vellum, italic) — only when present. +- Action: Un-hide (Pewter ghost + Lucide `RotateCcw`). One click — no confirmation. Optimistic remove. + +Header: H2 "Hidden" (Fraunces 24/500) + subtitle "Tracks you've flagged as broken." + +- [ ] **Step 14.2: Update Shell** + +Add to `navItems`: + +```ts +{ href: '/library/hidden', label: 'Hidden' } +``` + +Position: after `Liked`, before `Search`. Update `Shell.test.ts` to assert the new link's order. + +- [ ] **Step 14.3: Tests** + +`hidden.test.ts`: +- Renders one row per quarantine. +- Un-hide click calls `unflagTrack` + invalidates query. +- Empty state shows "Nothing hidden yet." +- Notes render (italic) when present, absent otherwise. + +- [ ] **Step 14.4: Commit** + +```bash +cd web && npm run check && npm test -- --run hidden && cd - +git add web/src/routes/library/hidden/ web/src/lib/components/Shell.svelte web/src/lib/components/Shell.test.ts +git commit -m "feat(web): /library/hidden user-facing quarantine view" +``` + +--- + +### Task 15 — Frontend: `/admin/quarantine` route + sidebar promotion + +**Files:** Create `web/src/routes/admin/quarantine/+page.svelte` + `quarantine.test.ts`. Modify `AdminSidebar.svelte` (promote Quarantine from placeholder), `AdminSidebar.test.ts`. + +- [ ] **Step 15.1: Promote sidebar item** + +In `AdminSidebar.svelte`, change: + +```diff +-{ href: '/admin/quarantine', label: 'Quarantine', icon: ShieldX, placeholder: true } ++{ href: '/admin/quarantine', label: 'Quarantine', icon: ShieldX } +``` + +Update `AdminSidebar.test.ts`: +- Replace the placeholder-treatment assertion for Quarantine. +- Add an assertion that Quarantine renders as a real `<a>` link. +- Add an assertion that `/admin/quarantine` activates Quarantine in the sidebar. + +- [ ] **Step 15.2: Write the page** + +Page layout (matches the design-system spec from §6 of the spec): + +- Header: H2 "Quarantine" + accent-tint count pill when `report_count > 0`. +- Empty state: "Nothing to triage right now." +- Aggregated rows: 56px art · title + meta · reason-distribution pills · expandable per-user reports · inline play button (accent-colored — brand moment) · action cluster (Resolve / Delete file / Delete via Lidarr). +- Modal-confirm for Delete file. +- Typed-confirm modal for Delete via Lidarr (matches M5a Disconnect pattern; trim equality on "DELETE"). +- Dimmed Delete-via-Lidarr button when `lidarr_album_mbid` is null, with `title` attribute "Local-only track — no Lidarr album to remove." +- Lidarr-unreachable error → toast (reuse the `errorCopy` helper pattern from `/admin/requests`). + +Use `createAdminQuarantineQuery()` for data. On mutation success, `invalidateQueries({ queryKey: qk.adminQuarantine() })`. + +Use the existing player's `enqueueTrack` (or `playRadio` — pick the closest-fit existing action) for the inline Play button. + +- [ ] **Step 15.3: Tests (`quarantine.test.ts`)** + +Cover: +- Aggregated rows render with reason distribution. +- Expand caret reveals per-user reports. +- Resolve fires `resolveQuarantine` + invalidates. +- Delete file → modal-confirm → fires `deleteQuarantineFile`. +- Delete via Lidarr → typed-confirm modal "DELETE" → fires `deleteQuarantineViaLidarr`. +- Lidarr-unreachable error → toast renders with the spec copy ("Lidarr is unreachable…"). +- Dimmed Delete-via-Lidarr when MBID missing. +- Inline play button calls the player. +- Empty state copy. + +- [ ] **Step 15.4: Commit** + +```bash +cd web && npm run check && npm test -- --run admin/quarantine && cd - +git add web/src/routes/admin/quarantine/ web/src/lib/components/AdminSidebar.svelte web/src/lib/components/AdminSidebar.test.ts +git commit -m "feat(web): /admin/quarantine aggregated queue with resolution actions" +``` + +--- + +### Task 16 — Final verification + branch finish + +- [ ] **Step 16.1: Full Go test sweep** + +```bash +go test -short -race ./... +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test -race -p 1 ./... +``` + +Expected: short suite + integration suite both green. The pre-existing `internal/library/TestScanner_Integration` flake is documented and not blocked on (per `project_scanner_flake.md`). + +- [ ] **Step 16.2: Lint clean** + +```bash +golangci-lint run ./... +``` + +- [ ] **Step 16.3: Coverage check** + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + bash -c 'go test -race -coverprofile=/tmp/cov.out \ + ./internal/lidarr/... ./internal/lidarrquarantine/... \ + ./internal/library/... && go tool cover -func=/tmp/cov.out | tail -1' +``` + +Expected: combined ≥ 80%, per spec §8. Both `internal/lidarr/` and `internal/lidarrquarantine/` should individually clear 80%. + +- [ ] **Step 16.4: Frontend full check** + +```bash +cd web && npm run check && npm test -- --run && npm run build +``` + +Expected: 0 errors, all vitest tests pass, build succeeds. + +- [ ] **Step 16.5: Manual smoke** + +- Configure Lidarr in `/admin/integrations` (or use the M5a-saved config). +- Flag a track from the now-playing bar → confirm it disappears from the album page. +- Confirm `/library/hidden` shows the flagged track. +- Un-hide → track returns to album page. +- Re-flag from a different user (admin user A flags as `bad_rip`, then user B flags same track as `wrong_tags`). +- Open `/admin/quarantine` → aggregated row shows count=2, distribution `1× bad_rip, 1× wrong_tags`. +- Click Play → track plays in the player. +- Click Resolve → row clears for both users. +- Re-flag, then click Delete file → file gone from disk, row gone from queue. +- Re-flag a track on a Lidarr-managed album → click Delete via Lidarr → typed-confirm "DELETE" → confirm Lidarr received DELETE call (check Lidarr's UI/logs). +- Verify non-admin user redirected when navigating to `/admin/quarantine`. + +- [ ] **Step 16.6: Use `superpowers:finishing-a-development-branch`** + +Verify tests are still green, then run the skill to present finish options (merge / PR / keep / discard). Default for this slice is "create a PR to main" matching the established cadence (per `project_git_workflow` memory). + +--- + +## Self-review checklist (run before declaring the plan ready) + +**Spec coverage** — every spec section maps to a task: +- §3 Architecture: Tasks 2 (client extensions), 3 (library), 4-5 (Service), 7 (soft-hide enforcement), 10 (wiring) +- §4 Schema: Task 1 +- §5 API surface: Tasks 8 (user), 9 (admin) +- §6 UI surfaces: Tasks 12 (TrackMenu+FlagPopover), 13 (mount), 14 (/library/hidden), 15 (/admin/quarantine + sidebar) +- §7 Error handling: distributed across Tasks 8, 9 (handler error mapping); Service layer (Tasks 4, 5) defines the typed errors +- §8 Testing: every Task includes tests; Task 16 verifies coverage targets +- §9 Decisions ledger: not directly implemented but referenced in commit messages +- §10 Out of scope: explicitly excluded — no album/artist quarantine, no bulk operations, no auto-resolve, no Subsonic honoring +- §11 Open questions: position of `/library/hidden` in nav (Task 14, between Liked and Search per the spec); dimmed-with-tooltip for missing MBID (Task 15) + +**Placeholder scan:** the per-task detail level drops after Task 9 (frontend tasks become 1-2 paragraphs) — intentional for navigability. Tasks 14 and 15 reference established patterns from M5a (`/requests` page anatomy, M5a typed-confirm modal for Disconnect) without re-stating the full code. No "TBD" or "TODO" remains. + +**Type consistency:** +- Service method names: `Flag`, `Unflag`, `ListMine`, `ListAdminQueue`, `Resolve`, `DeleteFile`, `DeleteViaLidarr` — consistent across plan +- Error names: `ErrBadReason`, `ErrTrackNotFound`, `ErrQuarantineNotFound`, `ErrAlbumMBIDMissing`, `ErrLidarrAlbumNotFound`, `ErrLidarrDisabled` — consistent +- Lidarr client method names: `LookupArtistByMBID`, `LookupAlbumByMBID`, `DeleteAlbum` — consistent +- API paths match spec §5 +- Component names: `<TrackMenu>`, `<FlagPopover>`, `<QuarantineRow>` (mentioned in file map but not separately tasked — bake into Tasks 14 & 15 as inline JSX) +- DB column names: `lidarr_quarantine.{user_id,track_id,reason,notes,created_at}`, `lidarr_quarantine_actions.{id,track_id,track_title,artist_name,album_title,action,admin_id,lidarr_album_mbid,affected_users,created_at}` — consistent + +Plan is complete. From a8df73fe423a6e84212435ffbab3042761bb1ed0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 16:48:33 -0400 Subject: [PATCH 38/67] feat(db): add lidarr_quarantine + actions schema (migration 0011) --- internal/db/dbq/lidarr_quarantine.sql.go | 372 ++++++++++++++++++ internal/db/dbq/models.go | 109 +++++ .../0011_lidarr_quarantine.down.sql | 8 + .../migrations/0011_lidarr_quarantine.up.sql | 45 +++ internal/db/queries/lidarr_quarantine.sql | 89 +++++ 5 files changed, 623 insertions(+) create mode 100644 internal/db/dbq/lidarr_quarantine.sql.go create mode 100644 internal/db/migrations/0011_lidarr_quarantine.down.sql create mode 100644 internal/db/migrations/0011_lidarr_quarantine.up.sql create mode 100644 internal/db/queries/lidarr_quarantine.sql diff --git a/internal/db/dbq/lidarr_quarantine.sql.go b/internal/db/dbq/lidarr_quarantine.sql.go new file mode 100644 index 00000000..5942194f --- /dev/null +++ b/internal/db/dbq/lidarr_quarantine.sql.go @@ -0,0 +1,372 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: lidarr_quarantine.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const countQuarantineForTrack = `-- name: CountQuarantineForTrack :one +SELECT count(*)::int FROM lidarr_quarantine WHERE track_id = $1 +` + +// Reads affected_users for the audit row before the delete fires. +func (q *Queries) CountQuarantineForTrack(ctx context.Context, trackID pgtype.UUID) (int32, error) { + row := q.db.QueryRow(ctx, countQuarantineForTrack, trackID) + var column_1 int32 + err := row.Scan(&column_1) + return column_1, err +} + +const deleteQuarantine = `-- name: DeleteQuarantine :one +DELETE FROM lidarr_quarantine + WHERE user_id = $1 AND track_id = $2 + RETURNING user_id, track_id, reason, notes, created_at +` + +type DeleteQuarantineParams struct { + UserID pgtype.UUID + TrackID pgtype.UUID +} + +// Removes the caller's row. Returns the deleted row so the handler can +// distinguish "no row existed" (zero rows -> ErrNoRows) from success. +func (q *Queries) DeleteQuarantine(ctx context.Context, arg DeleteQuarantineParams) (LidarrQuarantine, error) { + row := q.db.QueryRow(ctx, deleteQuarantine, arg.UserID, arg.TrackID) + var i LidarrQuarantine + err := row.Scan( + &i.UserID, + &i.TrackID, + &i.Reason, + &i.Notes, + &i.CreatedAt, + ) + return i, err +} + +const deleteQuarantineForTrack = `-- name: DeleteQuarantineForTrack :exec +DELETE FROM lidarr_quarantine WHERE track_id = $1 +` + +// Clears all per-user rows for a given track. Used by Resolve and the +// two delete actions. Caller writes the audit row separately before +// this fires (so we can capture the affected_users count). +func (q *Queries) DeleteQuarantineForTrack(ctx context.Context, trackID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteQuarantineForTrack, trackID) + return err +} + +const listAdminQuarantineQueue = `-- name: ListAdminQuarantineQueue :many +SELECT + t.id AS track_id, + t.title AS track_title, + ar.name AS artist_name, + al.title AS album_title, + al.id AS album_id, + al.mbid AS lidarr_album_mbid, + count(q.user_id)::int AS report_count, + max(q.created_at) AS latest_at +FROM lidarr_quarantine q +JOIN tracks t ON t.id = q.track_id +JOIN albums al ON al.id = t.album_id +JOIN artists ar ON ar.id = t.artist_id +GROUP BY t.id, ar.name, al.title, al.id, al.mbid +ORDER BY max(q.created_at) DESC +` + +type ListAdminQuarantineQueueRow struct { + TrackID pgtype.UUID + TrackTitle string + ArtistName string + AlbumTitle string + AlbumID pgtype.UUID + LidarrAlbumMbid *string + ReportCount int32 + LatestAt interface{} +} + +// Aggregated admin queue. One row per track. The handler post-processes +// the rows it gets from this query plus a per-track ListQuarantineReports +// call to materialize reason_counts and the per-user reports list. +func (q *Queries) ListAdminQuarantineQueue(ctx context.Context) ([]ListAdminQuarantineQueueRow, error) { + rows, err := q.db.Query(ctx, listAdminQuarantineQueue) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAdminQuarantineQueueRow + for rows.Next() { + var i ListAdminQuarantineQueueRow + if err := rows.Scan( + &i.TrackID, + &i.TrackTitle, + &i.ArtistName, + &i.AlbumTitle, + &i.AlbumID, + &i.LidarrAlbumMbid, + &i.ReportCount, + &i.LatestAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listQuarantineActions = `-- name: ListQuarantineActions :many +SELECT id, track_id, track_title, artist_name, album_title, action, admin_id, lidarr_album_mbid, affected_users, created_at FROM lidarr_quarantine_actions +ORDER BY created_at DESC +LIMIT $1 +` + +func (q *Queries) ListQuarantineActions(ctx context.Context, limit int32) ([]LidarrQuarantineAction, error) { + rows, err := q.db.Query(ctx, listQuarantineActions, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []LidarrQuarantineAction + for rows.Next() { + var i LidarrQuarantineAction + if err := rows.Scan( + &i.ID, + &i.TrackID, + &i.TrackTitle, + &i.ArtistName, + &i.AlbumTitle, + &i.Action, + &i.AdminID, + &i.LidarrAlbumMbid, + &i.AffectedUsers, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listQuarantineForUser = `-- name: ListQuarantineForUser :many +SELECT + q.user_id, q.track_id, q.reason, q.notes, q.created_at, + t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, + al.id, al.title, al.sort_title, al.artist_id, al.release_date, al.mbid, al.cover_art_path, al.created_at, al.updated_at, + ar.id, ar.name, ar.sort_name, ar.mbid, ar.created_at, ar.updated_at +FROM lidarr_quarantine q +JOIN tracks t ON t.id = q.track_id +JOIN albums al ON al.id = t.album_id +JOIN artists ar ON ar.id = t.artist_id +WHERE q.user_id = $1 +ORDER BY q.created_at DESC +` + +type ListQuarantineForUserRow struct { + LidarrQuarantine LidarrQuarantine + Track Track + Album Album + Artist Artist +} + +// Caller's own quarantines joined with track + album + artist for full +// detail. Drives /library/hidden. +func (q *Queries) ListQuarantineForUser(ctx context.Context, userID pgtype.UUID) ([]ListQuarantineForUserRow, error) { + rows, err := q.db.Query(ctx, listQuarantineForUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListQuarantineForUserRow + for rows.Next() { + var i ListQuarantineForUserRow + if err := rows.Scan( + &i.LidarrQuarantine.UserID, + &i.LidarrQuarantine.TrackID, + &i.LidarrQuarantine.Reason, + &i.LidarrQuarantine.Notes, + &i.LidarrQuarantine.CreatedAt, + &i.Track.ID, + &i.Track.Title, + &i.Track.AlbumID, + &i.Track.ArtistID, + &i.Track.TrackNumber, + &i.Track.DiscNumber, + &i.Track.DurationMs, + &i.Track.FilePath, + &i.Track.FileSize, + &i.Track.FileFormat, + &i.Track.Bitrate, + &i.Track.Mbid, + &i.Track.Genre, + &i.Track.AddedAt, + &i.Track.UpdatedAt, + &i.Album.ID, + &i.Album.Title, + &i.Album.SortTitle, + &i.Album.ArtistID, + &i.Album.ReleaseDate, + &i.Album.Mbid, + &i.Album.CoverArtPath, + &i.Album.CreatedAt, + &i.Album.UpdatedAt, + &i.Artist.ID, + &i.Artist.Name, + &i.Artist.SortName, + &i.Artist.Mbid, + &i.Artist.CreatedAt, + &i.Artist.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listQuarantineReportsForTrack = `-- name: ListQuarantineReportsForTrack :many +SELECT + q.user_id, + u.username, + q.reason, + q.notes, + q.created_at +FROM lidarr_quarantine q +JOIN users u ON u.id = q.user_id +WHERE q.track_id = $1 +ORDER BY q.created_at DESC +` + +type ListQuarantineReportsForTrackRow struct { + UserID pgtype.UUID + Username string + Reason LidarrQuarantineReason + Notes *string + CreatedAt pgtype.Timestamptz +} + +// Per-user reports for a single track. Returned by ListAdminQuarantineQueue +// post-processing and exposed expandable in the SPA admin queue rows. +func (q *Queries) ListQuarantineReportsForTrack(ctx context.Context, trackID pgtype.UUID) ([]ListQuarantineReportsForTrackRow, error) { + rows, err := q.db.Query(ctx, listQuarantineReportsForTrack, trackID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListQuarantineReportsForTrackRow + for rows.Next() { + var i ListQuarantineReportsForTrackRow + if err := rows.Scan( + &i.UserID, + &i.Username, + &i.Reason, + &i.Notes, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertQuarantine = `-- name: UpsertQuarantine :one +INSERT INTO lidarr_quarantine (user_id, track_id, reason, notes) +VALUES ($1, $2, $3, $4) +ON CONFLICT (user_id, track_id) DO UPDATE SET + reason = EXCLUDED.reason, + notes = EXCLUDED.notes, + created_at = now() +RETURNING user_id, track_id, reason, notes, created_at +` + +type UpsertQuarantineParams struct { + UserID pgtype.UUID + TrackID pgtype.UUID + Reason LidarrQuarantineReason + Notes *string +} + +// Insert a new quarantine row, or update reason/notes if the user has +// already flagged this track. +func (q *Queries) UpsertQuarantine(ctx context.Context, arg UpsertQuarantineParams) (LidarrQuarantine, error) { + row := q.db.QueryRow(ctx, upsertQuarantine, + arg.UserID, + arg.TrackID, + arg.Reason, + arg.Notes, + ) + var i LidarrQuarantine + err := row.Scan( + &i.UserID, + &i.TrackID, + &i.Reason, + &i.Notes, + &i.CreatedAt, + ) + return i, err +} + +const writeQuarantineAction = `-- name: WriteQuarantineAction :one +INSERT INTO lidarr_quarantine_actions ( + track_id, track_title, artist_name, album_title, + action, admin_id, lidarr_album_mbid, affected_users +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, track_id, track_title, artist_name, album_title, action, admin_id, lidarr_album_mbid, affected_users, created_at +` + +type WriteQuarantineActionParams struct { + TrackID pgtype.UUID + TrackTitle string + ArtistName string + AlbumTitle *string + Action LidarrQuarantineActionKind + AdminID pgtype.UUID + LidarrAlbumMbid *string + AffectedUsers int32 +} + +// Audit row for an admin destructive action. +func (q *Queries) WriteQuarantineAction(ctx context.Context, arg WriteQuarantineActionParams) (LidarrQuarantineAction, error) { + row := q.db.QueryRow(ctx, writeQuarantineAction, + arg.TrackID, + arg.TrackTitle, + arg.ArtistName, + arg.AlbumTitle, + arg.Action, + arg.AdminID, + arg.LidarrAlbumMbid, + arg.AffectedUsers, + ) + var i LidarrQuarantineAction + err := row.Scan( + &i.ID, + &i.TrackID, + &i.TrackTitle, + &i.ArtistName, + &i.AlbumTitle, + &i.Action, + &i.AdminID, + &i.LidarrAlbumMbid, + &i.AffectedUsers, + &i.CreatedAt, + ) + return i, err +} diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index d0cbf1f2..052bf95f 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -11,6 +11,94 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type LidarrQuarantineActionKind string + +const ( + LidarrQuarantineActionKindResolved LidarrQuarantineActionKind = "resolved" + LidarrQuarantineActionKindDeletedFile LidarrQuarantineActionKind = "deleted_file" + LidarrQuarantineActionKindDeletedViaLidarr LidarrQuarantineActionKind = "deleted_via_lidarr" +) + +func (e *LidarrQuarantineActionKind) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = LidarrQuarantineActionKind(s) + case string: + *e = LidarrQuarantineActionKind(s) + default: + return fmt.Errorf("unsupported scan type for LidarrQuarantineActionKind: %T", src) + } + return nil +} + +type NullLidarrQuarantineActionKind struct { + LidarrQuarantineActionKind LidarrQuarantineActionKind + Valid bool // Valid is true if LidarrQuarantineActionKind is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullLidarrQuarantineActionKind) Scan(value interface{}) error { + if value == nil { + ns.LidarrQuarantineActionKind, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.LidarrQuarantineActionKind.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullLidarrQuarantineActionKind) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.LidarrQuarantineActionKind), nil +} + +type LidarrQuarantineReason string + +const ( + LidarrQuarantineReasonBadRip LidarrQuarantineReason = "bad_rip" + LidarrQuarantineReasonWrongFile LidarrQuarantineReason = "wrong_file" + LidarrQuarantineReasonWrongTags LidarrQuarantineReason = "wrong_tags" + LidarrQuarantineReasonDuplicate LidarrQuarantineReason = "duplicate" + LidarrQuarantineReasonOther LidarrQuarantineReason = "other" +) + +func (e *LidarrQuarantineReason) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = LidarrQuarantineReason(s) + case string: + *e = LidarrQuarantineReason(s) + default: + return fmt.Errorf("unsupported scan type for LidarrQuarantineReason: %T", src) + } + return nil +} + +type NullLidarrQuarantineReason struct { + LidarrQuarantineReason LidarrQuarantineReason + Valid bool // Valid is true if LidarrQuarantineReason is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullLidarrQuarantineReason) Scan(value interface{}) error { + if value == nil { + ns.LidarrQuarantineReason, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.LidarrQuarantineReason.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullLidarrQuarantineReason) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.LidarrQuarantineReason), nil +} + type LidarrRequestKind string const ( @@ -167,6 +255,27 @@ type LidarrConfig struct { UpdatedAt pgtype.Timestamptz } +type LidarrQuarantine struct { + UserID pgtype.UUID + TrackID pgtype.UUID + Reason LidarrQuarantineReason + Notes *string + CreatedAt pgtype.Timestamptz +} + +type LidarrQuarantineAction struct { + ID pgtype.UUID + TrackID pgtype.UUID + TrackTitle string + ArtistName string + AlbumTitle *string + Action LidarrQuarantineActionKind + AdminID pgtype.UUID + LidarrAlbumMbid *string + AffectedUsers int32 + CreatedAt pgtype.Timestamptz +} + type LidarrRequest struct { ID pgtype.UUID UserID pgtype.UUID diff --git a/internal/db/migrations/0011_lidarr_quarantine.down.sql b/internal/db/migrations/0011_lidarr_quarantine.down.sql new file mode 100644 index 00000000..beaf999f --- /dev/null +++ b/internal/db/migrations/0011_lidarr_quarantine.down.sql @@ -0,0 +1,8 @@ +DROP INDEX IF EXISTS lidarr_quarantine_actions_created_idx; +DROP INDEX IF EXISTS lidarr_quarantine_actions_track_idx; +DROP TABLE IF EXISTS lidarr_quarantine_actions; +DROP TYPE IF EXISTS lidarr_quarantine_action_kind; +DROP INDEX IF EXISTS lidarr_quarantine_user_idx; +DROP INDEX IF EXISTS lidarr_quarantine_track_idx; +DROP TABLE IF EXISTS lidarr_quarantine; +DROP TYPE IF EXISTS lidarr_quarantine_reason; diff --git a/internal/db/migrations/0011_lidarr_quarantine.up.sql b/internal/db/migrations/0011_lidarr_quarantine.up.sql new file mode 100644 index 00000000..ff4c177e --- /dev/null +++ b/internal/db/migrations/0011_lidarr_quarantine.up.sql @@ -0,0 +1,45 @@ +-- M5b: per-user track quarantines + admin action audit log. +-- +-- lidarr_quarantine — one row per (user, track) complaint. PK matches +-- the general_likes pattern. Re-flagging the same track upserts. Deleted +-- on user resolution (un-hide), admin Resolve, or any of the deletes. +-- +-- lidarr_quarantine_actions — audit log of admin destructive actions. +-- Snapshot text columns let the log stay readable after the underlying +-- track/album rows are gone. + +CREATE TYPE lidarr_quarantine_reason AS ENUM ( + 'bad_rip', 'wrong_file', 'wrong_tags', 'duplicate', 'other' +); + +CREATE TABLE lidarr_quarantine ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + reason lidarr_quarantine_reason NOT NULL, + notes text, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, track_id) +); + +CREATE INDEX lidarr_quarantine_track_idx ON lidarr_quarantine (track_id); +CREATE INDEX lidarr_quarantine_user_idx ON lidarr_quarantine (user_id, created_at DESC); + +CREATE TYPE lidarr_quarantine_action_kind AS ENUM ( + 'resolved', 'deleted_file', 'deleted_via_lidarr' +); + +CREATE TABLE lidarr_quarantine_actions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + track_id uuid NOT NULL, + track_title text NOT NULL, + artist_name text NOT NULL, + album_title text, + action lidarr_quarantine_action_kind NOT NULL, + admin_id uuid REFERENCES users(id) ON DELETE SET NULL, + lidarr_album_mbid text, + affected_users int NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX lidarr_quarantine_actions_track_idx ON lidarr_quarantine_actions (track_id); +CREATE INDEX lidarr_quarantine_actions_created_idx ON lidarr_quarantine_actions (created_at DESC); diff --git a/internal/db/queries/lidarr_quarantine.sql b/internal/db/queries/lidarr_quarantine.sql new file mode 100644 index 00000000..7e4c54ed --- /dev/null +++ b/internal/db/queries/lidarr_quarantine.sql @@ -0,0 +1,89 @@ +-- name: UpsertQuarantine :one +-- Insert a new quarantine row, or update reason/notes if the user has +-- already flagged this track. +INSERT INTO lidarr_quarantine (user_id, track_id, reason, notes) +VALUES ($1, $2, $3, $4) +ON CONFLICT (user_id, track_id) DO UPDATE SET + reason = EXCLUDED.reason, + notes = EXCLUDED.notes, + created_at = now() +RETURNING user_id, track_id, reason, notes, created_at; + +-- name: DeleteQuarantine :one +-- Removes the caller's row. Returns the deleted row so the handler can +-- distinguish "no row existed" (zero rows -> ErrNoRows) from success. +DELETE FROM lidarr_quarantine + WHERE user_id = $1 AND track_id = $2 + RETURNING user_id, track_id, reason, notes, created_at; + +-- name: ListQuarantineForUser :many +-- Caller's own quarantines joined with track + album + artist for full +-- detail. Drives /library/hidden. +SELECT + sqlc.embed(q), + sqlc.embed(t), + sqlc.embed(al), + sqlc.embed(ar) +FROM lidarr_quarantine q +JOIN tracks t ON t.id = q.track_id +JOIN albums al ON al.id = t.album_id +JOIN artists ar ON ar.id = t.artist_id +WHERE q.user_id = $1 +ORDER BY q.created_at DESC; + +-- name: ListAdminQuarantineQueue :many +-- Aggregated admin queue. One row per track. The handler post-processes +-- the rows it gets from this query plus a per-track ListQuarantineReports +-- call to materialize reason_counts and the per-user reports list. +SELECT + t.id AS track_id, + t.title AS track_title, + ar.name AS artist_name, + al.title AS album_title, + al.id AS album_id, + al.mbid AS lidarr_album_mbid, + count(q.user_id)::int AS report_count, + max(q.created_at) AS latest_at +FROM lidarr_quarantine q +JOIN tracks t ON t.id = q.track_id +JOIN albums al ON al.id = t.album_id +JOIN artists ar ON ar.id = t.artist_id +GROUP BY t.id, ar.name, al.title, al.id, al.mbid +ORDER BY max(q.created_at) DESC; + +-- name: ListQuarantineReportsForTrack :many +-- Per-user reports for a single track. Returned by ListAdminQuarantineQueue +-- post-processing and exposed expandable in the SPA admin queue rows. +SELECT + q.user_id, + u.username, + q.reason, + q.notes, + q.created_at +FROM lidarr_quarantine q +JOIN users u ON u.id = q.user_id +WHERE q.track_id = $1 +ORDER BY q.created_at DESC; + +-- name: DeleteQuarantineForTrack :exec +-- Clears all per-user rows for a given track. Used by Resolve and the +-- two delete actions. Caller writes the audit row separately before +-- this fires (so we can capture the affected_users count). +DELETE FROM lidarr_quarantine WHERE track_id = $1; + +-- name: CountQuarantineForTrack :one +-- Reads affected_users for the audit row before the delete fires. +SELECT count(*)::int FROM lidarr_quarantine WHERE track_id = $1; + +-- name: WriteQuarantineAction :one +-- Audit row for an admin destructive action. +INSERT INTO lidarr_quarantine_actions ( + track_id, track_title, artist_name, album_title, + action, admin_id, lidarr_album_mbid, affected_users +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING *; + +-- name: ListQuarantineActions :many +SELECT * FROM lidarr_quarantine_actions +ORDER BY created_at DESC +LIMIT $1; From 71a9bd8deef712d7dc247d61a68f8b3bb950c97f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 16:54:03 -0400 Subject: [PATCH 39/67] fix(db): drop redundant quarantine user index + tighten ListQuarantineForUser - Composite PK (user_id, track_id) already serves WHERE user_id queries; the secondary (user_id, created_at DESC) index just amplified writes. - ListQuarantineForUser now selects only the joined fields the SPA card actually renders (~10 columns) rather than embedding three full structs (~35 columns); halves wire/DB bandwidth before consumers exist. - max(q.created_at)::timestamptz cast emits pgtype.Timestamptz instead of interface{} so handlers can read latest_at without a type-assert. --- internal/db/dbq/lidarr_quarantine.sql.go | 74 ++++++++----------- .../0011_lidarr_quarantine.down.sql | 1 - .../migrations/0011_lidarr_quarantine.up.sql | 5 +- internal/db/queries/lidarr_quarantine.sql | 21 ++++-- 4 files changed, 50 insertions(+), 51 deletions(-) diff --git a/internal/db/dbq/lidarr_quarantine.sql.go b/internal/db/dbq/lidarr_quarantine.sql.go index 5942194f..eac2ac6d 100644 --- a/internal/db/dbq/lidarr_quarantine.sql.go +++ b/internal/db/dbq/lidarr_quarantine.sql.go @@ -69,8 +69,8 @@ SELECT al.title AS album_title, al.id AS album_id, al.mbid AS lidarr_album_mbid, - count(q.user_id)::int AS report_count, - max(q.created_at) AS latest_at + count(q.user_id)::int AS report_count, + max(q.created_at)::timestamptz AS latest_at FROM lidarr_quarantine q JOIN tracks t ON t.id = q.track_id JOIN albums al ON al.id = t.album_id @@ -87,7 +87,7 @@ type ListAdminQuarantineQueueRow struct { AlbumID pgtype.UUID LidarrAlbumMbid *string ReportCount int32 - LatestAt interface{} + LatestAt pgtype.Timestamptz } // Aggregated admin queue. One row per track. The handler post-processes @@ -162,9 +162,14 @@ func (q *Queries) ListQuarantineActions(ctx context.Context, limit int32) ([]Lid const listQuarantineForUser = `-- name: ListQuarantineForUser :many SELECT q.user_id, q.track_id, q.reason, q.notes, q.created_at, - t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, - al.id, al.title, al.sort_title, al.artist_id, al.release_date, al.mbid, al.cover_art_path, al.created_at, al.updated_at, - ar.id, ar.name, ar.sort_name, ar.mbid, ar.created_at, ar.updated_at + t.id AS track_id_join, + t.title AS track_title, + t.duration_ms AS track_duration_ms, + al.id AS album_id, + al.title AS album_title, + al.cover_art_path AS album_cover_art_path, + ar.id AS artist_id, + ar.name AS artist_name FROM lidarr_quarantine q JOIN tracks t ON t.id = q.track_id JOIN albums al ON al.id = t.album_id @@ -174,14 +179,21 @@ ORDER BY q.created_at DESC ` type ListQuarantineForUserRow struct { - LidarrQuarantine LidarrQuarantine - Track Track - Album Album - Artist Artist + LidarrQuarantine LidarrQuarantine + TrackIDJoin pgtype.UUID + TrackTitle string + TrackDurationMs int32 + AlbumID pgtype.UUID + AlbumTitle string + AlbumCoverArtPath *string + ArtistID pgtype.UUID + ArtistName string } -// Caller's own quarantines joined with track + album + artist for full -// detail. Drives /library/hidden. +// Caller's own quarantines plus just the joined fields the SPA's +// /library/hidden actually renders. Embedding the full tracks/albums/artists +// structs would drag ~28 columns of file_path / sort_* / mbid / etc. that +// the row card never uses. func (q *Queries) ListQuarantineForUser(ctx context.Context, userID pgtype.UUID) ([]ListQuarantineForUserRow, error) { rows, err := q.db.Query(ctx, listQuarantineForUser, userID) if err != nil { @@ -197,36 +209,14 @@ func (q *Queries) ListQuarantineForUser(ctx context.Context, userID pgtype.UUID) &i.LidarrQuarantine.Reason, &i.LidarrQuarantine.Notes, &i.LidarrQuarantine.CreatedAt, - &i.Track.ID, - &i.Track.Title, - &i.Track.AlbumID, - &i.Track.ArtistID, - &i.Track.TrackNumber, - &i.Track.DiscNumber, - &i.Track.DurationMs, - &i.Track.FilePath, - &i.Track.FileSize, - &i.Track.FileFormat, - &i.Track.Bitrate, - &i.Track.Mbid, - &i.Track.Genre, - &i.Track.AddedAt, - &i.Track.UpdatedAt, - &i.Album.ID, - &i.Album.Title, - &i.Album.SortTitle, - &i.Album.ArtistID, - &i.Album.ReleaseDate, - &i.Album.Mbid, - &i.Album.CoverArtPath, - &i.Album.CreatedAt, - &i.Album.UpdatedAt, - &i.Artist.ID, - &i.Artist.Name, - &i.Artist.SortName, - &i.Artist.Mbid, - &i.Artist.CreatedAt, - &i.Artist.UpdatedAt, + &i.TrackIDJoin, + &i.TrackTitle, + &i.TrackDurationMs, + &i.AlbumID, + &i.AlbumTitle, + &i.AlbumCoverArtPath, + &i.ArtistID, + &i.ArtistName, ); err != nil { return nil, err } diff --git a/internal/db/migrations/0011_lidarr_quarantine.down.sql b/internal/db/migrations/0011_lidarr_quarantine.down.sql index beaf999f..bd086c54 100644 --- a/internal/db/migrations/0011_lidarr_quarantine.down.sql +++ b/internal/db/migrations/0011_lidarr_quarantine.down.sql @@ -2,7 +2,6 @@ DROP INDEX IF EXISTS lidarr_quarantine_actions_created_idx; DROP INDEX IF EXISTS lidarr_quarantine_actions_track_idx; DROP TABLE IF EXISTS lidarr_quarantine_actions; DROP TYPE IF EXISTS lidarr_quarantine_action_kind; -DROP INDEX IF EXISTS lidarr_quarantine_user_idx; DROP INDEX IF EXISTS lidarr_quarantine_track_idx; DROP TABLE IF EXISTS lidarr_quarantine; DROP TYPE IF EXISTS lidarr_quarantine_reason; diff --git a/internal/db/migrations/0011_lidarr_quarantine.up.sql b/internal/db/migrations/0011_lidarr_quarantine.up.sql index ff4c177e..8ff73259 100644 --- a/internal/db/migrations/0011_lidarr_quarantine.up.sql +++ b/internal/db/migrations/0011_lidarr_quarantine.up.sql @@ -21,8 +21,11 @@ CREATE TABLE lidarr_quarantine ( PRIMARY KEY (user_id, track_id) ); +-- Only the track_id index is non-redundant: the composite PK +-- (user_id, track_id) already serves WHERE user_id = $1 lookups, so a +-- separate (user_id, created_at DESC) index would just amplify writes +-- without paying for itself at household-scale row counts. CREATE INDEX lidarr_quarantine_track_idx ON lidarr_quarantine (track_id); -CREATE INDEX lidarr_quarantine_user_idx ON lidarr_quarantine (user_id, created_at DESC); CREATE TYPE lidarr_quarantine_action_kind AS ENUM ( 'resolved', 'deleted_file', 'deleted_via_lidarr' diff --git a/internal/db/queries/lidarr_quarantine.sql b/internal/db/queries/lidarr_quarantine.sql index 7e4c54ed..8c43fc9c 100644 --- a/internal/db/queries/lidarr_quarantine.sql +++ b/internal/db/queries/lidarr_quarantine.sql @@ -17,13 +17,20 @@ DELETE FROM lidarr_quarantine RETURNING user_id, track_id, reason, notes, created_at; -- name: ListQuarantineForUser :many --- Caller's own quarantines joined with track + album + artist for full --- detail. Drives /library/hidden. +-- Caller's own quarantines plus just the joined fields the SPA's +-- /library/hidden actually renders. Embedding the full tracks/albums/artists +-- structs would drag ~28 columns of file_path / sort_* / mbid / etc. that +-- the row card never uses. SELECT sqlc.embed(q), - sqlc.embed(t), - sqlc.embed(al), - sqlc.embed(ar) + t.id AS track_id_join, + t.title AS track_title, + t.duration_ms AS track_duration_ms, + al.id AS album_id, + al.title AS album_title, + al.cover_art_path AS album_cover_art_path, + ar.id AS artist_id, + ar.name AS artist_name FROM lidarr_quarantine q JOIN tracks t ON t.id = q.track_id JOIN albums al ON al.id = t.album_id @@ -42,8 +49,8 @@ SELECT al.title AS album_title, al.id AS album_id, al.mbid AS lidarr_album_mbid, - count(q.user_id)::int AS report_count, - max(q.created_at) AS latest_at + count(q.user_id)::int AS report_count, + max(q.created_at)::timestamptz AS latest_at FROM lidarr_quarantine q JOIN tracks t ON t.id = q.track_id JOIN albums al ON al.id = t.album_id From f2cdd23fd59c8b8a8be1b7cfbd5ac769aef47f79 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 16:57:14 -0400 Subject: [PATCH 40/67] feat(lidarr): LookupArtistByMBID, LookupAlbumByMBID, DeleteAlbum --- internal/lidarr/delete.go | 69 +++++++ internal/lidarr/delete_test.go | 178 ++++++++++++++++++ internal/lidarr/errors.go | 6 + internal/lidarr/lookup_mbid.go | 54 ++++++ .../lidarr/testdata/album_lookup_by_mbid.json | 8 + .../testdata/artist_lookup_by_mbid.json | 7 + internal/lidarr/types.go | 18 ++ 7 files changed, 340 insertions(+) create mode 100644 internal/lidarr/delete.go create mode 100644 internal/lidarr/delete_test.go create mode 100644 internal/lidarr/lookup_mbid.go create mode 100644 internal/lidarr/testdata/album_lookup_by_mbid.json create mode 100644 internal/lidarr/testdata/artist_lookup_by_mbid.json diff --git a/internal/lidarr/delete.go b/internal/lidarr/delete.go new file mode 100644 index 00000000..2a060320 --- /dev/null +++ b/internal/lidarr/delete.go @@ -0,0 +1,69 @@ +package lidarr + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" +) + +// del is the shared DELETE helper. Mirrors get()/post() in client.go: builds +// the URL inline, sets the API key, maps status codes to typed errors. The +// caller is responsible for closing the returned body. +func (c *Client) del(ctx context.Context, path string, q url.Values) (*http.Response, error) { + u, err := url.Parse(c.BaseURL) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) + } + u.Path = strings.TrimRight(u.Path, "/") + path + if q != nil { + u.RawQuery = q.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Api-Key", c.APIKey) + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) + } + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + _ = resp.Body.Close() + return nil, ErrAuthFailed + } + if resp.StatusCode >= 500 { + _ = resp.Body.Close() + return nil, ErrServerError + } + if resp.StatusCode >= 400 { + _ = resp.Body.Close() + return nil, ErrLookupFailed + } + return resp, nil +} + +// DeleteAlbum removes an album from Lidarr's library. +// - deleteFiles=true also removes the audio files from disk. +// - addImportListExclusion=true tells Lidarr to never re-add this album +// via import-list scans. +// +// M5b's admin "delete via Lidarr" action always passes both `true`. +func (c *Client) DeleteAlbum(ctx context.Context, lidarrAlbumID int, deleteFiles, addImportListExclusion bool) error { + if lidarrAlbumID == 0 { + return fmt.Errorf("lidarr: zero album id") + } + q := url.Values{ + "deleteFiles": []string{strconv.FormatBool(deleteFiles)}, + "addImportListExclusion": []string{strconv.FormatBool(addImportListExclusion)}, + } + resp, err := c.del(ctx, "/api/v1/album/"+strconv.Itoa(lidarrAlbumID), q) + if err != nil { + return err + } + resp.Body.Close() + return nil +} diff --git a/internal/lidarr/delete_test.go b/internal/lidarr/delete_test.go new file mode 100644 index 00000000..78d6789c --- /dev/null +++ b/internal/lidarr/delete_test.go @@ -0,0 +1,178 @@ +package lidarr + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func TestLookupAlbumByMBID_HappyPath(t *testing.T) { + body, err := os.ReadFile("testdata/album_lookup_by_mbid.json") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/album" { + t.Errorf("path = %q, want /api/v1/album", r.URL.Path) + } + if got := r.URL.Query().Get("foreignAlbumId"); got != "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d" { + t.Errorf("foreignAlbumId = %q", got) + } + if got := r.Header.Get("X-Api-Key"); got != "key123" { + t.Errorf("X-Api-Key = %q, want key123", got) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + }) + defer srv.Close() + + got, err := c.LookupAlbumByMBID(context.Background(), "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d") + if err != nil { + t.Fatalf("LookupAlbumByMBID: %v", err) + } + if got.ID != 42 || got.Title != "Music Has The Right To Children" { + t.Errorf("got = %+v", got) + } +} + +func TestLookupAlbumByMBID_EmptyArrayReturnsErrNotFound(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("[]")) + }) + defer srv.Close() + + _, err := c.LookupAlbumByMBID(context.Background(), "x") + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestLookupAlbumByMBID_AuthFailed(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + defer srv.Close() + + _, err := c.LookupAlbumByMBID(context.Background(), "x") + if !errors.Is(err, ErrAuthFailed) { + t.Errorf("err = %v, want ErrAuthFailed", err) + } +} + +func TestLookupAlbumByMBID_5xxReturnsErrServerError(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + defer srv.Close() + + _, err := c.LookupAlbumByMBID(context.Background(), "x") + if !errors.Is(err, ErrServerError) { + t.Errorf("err = %v, want ErrServerError", err) + } +} + +func TestLookupAlbumByMBID_BadJSON(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not json")) + }) + defer srv.Close() + + _, err := c.LookupAlbumByMBID(context.Background(), "x") + if err == nil { + t.Fatal("err = nil, want decode error") + } +} + +func TestLookupArtistByMBID_HappyPath(t *testing.T) { + body, err := os.ReadFile("testdata/artist_lookup_by_mbid.json") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/artist" { + t.Errorf("path = %q, want /api/v1/artist", r.URL.Path) + } + if got := r.URL.Query().Get("mbId"); got != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" { + t.Errorf("mbId = %q", got) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + }) + defer srv.Close() + + got, err := c.LookupArtistByMBID(context.Background(), "069b64b6-7884-4f6a-94cc-e4c1d6c87a01") + if err != nil { + t.Fatalf("LookupArtistByMBID: %v", err) + } + if got.ID != 7 || got.ArtistName != "Boards of Canada" { + t.Errorf("got = %+v", got) + } +} + +func TestDeleteAlbum_PassesBothFlags(t *testing.T) { + var captured *http.Request + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + captured = r + w.WriteHeader(http.StatusOK) + }) + defer srv.Close() + + if err := c.DeleteAlbum(context.Background(), 42, true, true); err != nil { + t.Fatalf("DeleteAlbum: %v", err) + } + if captured == nil || captured.Method != http.MethodDelete { + t.Fatalf("method = %v, want DELETE", captured) + } + if captured.URL.Path != "/api/v1/album/42" { + t.Errorf("path = %q", captured.URL.Path) + } + if got := captured.URL.Query().Get("deleteFiles"); got != "true" { + t.Errorf("deleteFiles = %q", got) + } + if got := captured.URL.Query().Get("addImportListExclusion"); got != "true" { + t.Errorf("addImportListExclusion = %q", got) + } +} + +func TestDeleteAlbum_5xxReturnsErrServerError(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + defer srv.Close() + + err := c.DeleteAlbum(context.Background(), 42, true, true) + if !errors.Is(err, ErrServerError) { + t.Errorf("err = %v, want ErrServerError", err) + } +} + +func TestDeleteAlbum_NetworkErrorReturnsErrUnreachable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + srv.Close() // server is closed; client should fail to connect + c := NewClient(srv.URL, "test-key") + + err := c.DeleteAlbum(context.Background(), 42, true, true) + if !errors.Is(err, ErrUnreachable) { + t.Errorf("err = %v, want ErrUnreachable", err) + } +} + +func TestDeleteAlbum_ZeroIDRejected(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + t.Error("server should not be called with zero id") + }) + defer srv.Close() + + if err := c.DeleteAlbum(context.Background(), 0, true, true); err == nil { + t.Error("err = nil, want zero-id rejection") + } +} diff --git a/internal/lidarr/errors.go b/internal/lidarr/errors.go index 0cdf98e2..e852428f 100644 --- a/internal/lidarr/errors.go +++ b/internal/lidarr/errors.go @@ -10,4 +10,10 @@ var ( ErrLookupFailed = errors.New("lidarr: lookup failed") // 4xx other than 401/403 ErrServerError = errors.New("lidarr: server error") // 5xx ErrInvalidPayload = errors.New("lidarr: invalid payload") + // ErrNotFound is returned by LookupArtistByMBID and LookupAlbumByMBID + // when Lidarr returns 200 with an empty array — i.e., the MBID isn't in + // Lidarr's monitored set. Distinguished from network/auth errors so admin + // handlers can surface it as `lidarr_album_lookup_failed` (502) instead + // of `lidarr_unreachable` (503). + ErrNotFound = errors.New("lidarr: not found") ) diff --git a/internal/lidarr/lookup_mbid.go b/internal/lidarr/lookup_mbid.go new file mode 100644 index 00000000..89e839c8 --- /dev/null +++ b/internal/lidarr/lookup_mbid.go @@ -0,0 +1,54 @@ +package lidarr + +import ( + "context" + "encoding/json" + "fmt" + "net/url" +) + +// LookupArtistByMBID returns the artist Lidarr has indexed under that +// MBID. Returns ErrNotFound if Lidarr returns an empty array. +func (c *Client) LookupArtistByMBID(ctx context.Context, mbid string) (LidarrArtist, error) { + if mbid == "" { + return LidarrArtist{}, fmt.Errorf("lidarr: empty mbid") + } + q := url.Values{"mbId": []string{mbid}} + resp, err := c.get(ctx, "/api/v1/artist", q) + if err != nil { + return LidarrArtist{}, err + } + defer resp.Body.Close() + + var rows []LidarrArtist + if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil { + return LidarrArtist{}, fmt.Errorf("lidarr: decode artist: %w", err) + } + if len(rows) == 0 { + return LidarrArtist{}, ErrNotFound + } + return rows[0], nil +} + +// LookupAlbumByMBID returns the album Lidarr has indexed under that +// MBID. Returns ErrNotFound on empty result. +func (c *Client) LookupAlbumByMBID(ctx context.Context, mbid string) (LidarrAlbum, error) { + if mbid == "" { + return LidarrAlbum{}, fmt.Errorf("lidarr: empty mbid") + } + q := url.Values{"foreignAlbumId": []string{mbid}} + resp, err := c.get(ctx, "/api/v1/album", q) + if err != nil { + return LidarrAlbum{}, err + } + defer resp.Body.Close() + + var rows []LidarrAlbum + if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil { + return LidarrAlbum{}, fmt.Errorf("lidarr: decode album: %w", err) + } + if len(rows) == 0 { + return LidarrAlbum{}, ErrNotFound + } + return rows[0], nil +} diff --git a/internal/lidarr/testdata/album_lookup_by_mbid.json b/internal/lidarr/testdata/album_lookup_by_mbid.json new file mode 100644 index 00000000..90d38049 --- /dev/null +++ b/internal/lidarr/testdata/album_lookup_by_mbid.json @@ -0,0 +1,8 @@ +[ + { + "id": 42, + "foreignAlbumId": "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d", + "title": "Music Has The Right To Children", + "artistId": 7 + } +] diff --git a/internal/lidarr/testdata/artist_lookup_by_mbid.json b/internal/lidarr/testdata/artist_lookup_by_mbid.json new file mode 100644 index 00000000..74d78984 --- /dev/null +++ b/internal/lidarr/testdata/artist_lookup_by_mbid.json @@ -0,0 +1,7 @@ +[ + { + "id": 7, + "foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", + "artistName": "Boards of Canada" + } +] diff --git a/internal/lidarr/types.go b/internal/lidarr/types.go index d1c8ec05..8e4c8052 100644 --- a/internal/lidarr/types.go +++ b/internal/lidarr/types.go @@ -58,3 +58,21 @@ type AddAlbumParams struct { type PingResult struct { Version string } + +// LidarrArtist is the subset of Lidarr's artist resource used by M5b +// admin actions. The "id" field is Lidarr's internal numeric ID — needed +// for DELETE /api/v1/artist/{id} calls. +type LidarrArtist struct { + ID int `json:"id"` + ForeignArtistID string `json:"foreignArtistId"` // MBID + ArtistName string `json:"artistName"` +} + +// LidarrAlbum is the subset of Lidarr's album resource used by M5b +// admin actions. +type LidarrAlbum struct { + ID int `json:"id"` + ForeignAlbumID string `json:"foreignAlbumId"` // MBID + Title string `json:"title"` + ArtistID int `json:"artistId"` +} From dbe0e79f54a8f94350bc17050662d30da6cc6e6d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 17:01:50 -0400 Subject: [PATCH 41/67] fix(lidarr): wrap MBID-lookup decode errors with ErrInvalidPayload + fill test gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Decode errors in LookupArtist/AlbumByMBID now wrap ErrInvalidPayload so callers can errors.Is the same way they do against the M5a methods. - Tighten TestLookupAlbumByMBID_BadJSON to assert on ErrInvalidPayload. - Add empty-mbid rejection tests for both methods. - Add TestLookupArtistByMBID_EmptyArrayReturnsErrNotFound (only the album variant had the test before — symmetric coverage now). - Normalize the network-error test's api key to key123 for consistency with the rest of delete_test.go. --- internal/lidarr/delete_test.go | 42 +++++++++++++++++++++++++++++++--- internal/lidarr/lookup_mbid.go | 4 ++-- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/internal/lidarr/delete_test.go b/internal/lidarr/delete_test.go index 78d6789c..db8d3822 100644 --- a/internal/lidarr/delete_test.go +++ b/internal/lidarr/delete_test.go @@ -86,8 +86,44 @@ func TestLookupAlbumByMBID_BadJSON(t *testing.T) { defer srv.Close() _, err := c.LookupAlbumByMBID(context.Background(), "x") - if err == nil { - t.Fatal("err = nil, want decode error") + if !errors.Is(err, ErrInvalidPayload) { + t.Errorf("err = %v, want ErrInvalidPayload", err) + } +} + +func TestLookupAlbumByMBID_EmptyMBIDRejected(t *testing.T) { + c, srv := newTestClient(func(http.ResponseWriter, *http.Request) { + t.Error("server should not be called with empty mbid") + }) + defer srv.Close() + + if _, err := c.LookupAlbumByMBID(context.Background(), ""); err == nil { + t.Error("err = nil, want empty-mbid rejection") + } +} + +func TestLookupArtistByMBID_EmptyArrayReturnsErrNotFound(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("[]")) + }) + defer srv.Close() + + _, err := c.LookupArtistByMBID(context.Background(), "x") + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestLookupArtistByMBID_EmptyMBIDRejected(t *testing.T) { + c, srv := newTestClient(func(http.ResponseWriter, *http.Request) { + t.Error("server should not be called with empty mbid") + }) + defer srv.Close() + + if _, err := c.LookupArtistByMBID(context.Background(), ""); err == nil { + t.Error("err = nil, want empty-mbid rejection") } } @@ -158,7 +194,7 @@ func TestDeleteAlbum_5xxReturnsErrServerError(t *testing.T) { func TestDeleteAlbum_NetworkErrorReturnsErrUnreachable(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) srv.Close() // server is closed; client should fail to connect - c := NewClient(srv.URL, "test-key") + c := NewClient(srv.URL, "key123") err := c.DeleteAlbum(context.Background(), 42, true, true) if !errors.Is(err, ErrUnreachable) { diff --git a/internal/lidarr/lookup_mbid.go b/internal/lidarr/lookup_mbid.go index 89e839c8..ecfc0e08 100644 --- a/internal/lidarr/lookup_mbid.go +++ b/internal/lidarr/lookup_mbid.go @@ -22,7 +22,7 @@ func (c *Client) LookupArtistByMBID(ctx context.Context, mbid string) (LidarrArt var rows []LidarrArtist if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil { - return LidarrArtist{}, fmt.Errorf("lidarr: decode artist: %w", err) + return LidarrArtist{}, fmt.Errorf("%w: decode artist: %v", ErrInvalidPayload, err) } if len(rows) == 0 { return LidarrArtist{}, ErrNotFound @@ -45,7 +45,7 @@ func (c *Client) LookupAlbumByMBID(ctx context.Context, mbid string) (LidarrAlbu var rows []LidarrAlbum if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil { - return LidarrAlbum{}, fmt.Errorf("lidarr: decode album: %w", err) + return LidarrAlbum{}, fmt.Errorf("%w: decode album: %v", ErrInvalidPayload, err) } if len(rows) == 0 { return LidarrAlbum{}, ErrNotFound From d0523da520a97e757853b201d20896e38d74e849 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 17:04:21 -0400 Subject: [PATCH 42/67] feat(library): DeleteTrackFile (rm file + tracks row, album/artist preserved) --- internal/library/delete.go | 49 +++++++++++++ internal/library/delete_test.go | 119 ++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 internal/library/delete.go create mode 100644 internal/library/delete_test.go diff --git a/internal/library/delete.go b/internal/library/delete.go new file mode 100644 index 00000000..133f6496 --- /dev/null +++ b/internal/library/delete.go @@ -0,0 +1,49 @@ +package library + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// ErrTrackNotFound is returned when DeleteTrackFile is called with an id +// that has no row in tracks. +var ErrTrackNotFound = errors.New("library: track not found") + +// DeleteTrackFile removes a track file from disk and its row from the +// tracks table. Album and artist rows are left untouched. +// +// Steps: +// 1. Look up the track to get its file_path. +// 2. Remove the file from disk. fs.ErrNotExist is OK — already gone. +// 3. Delete the tracks row. +// +// Order matters: file first, then DB. If the file delete fails (permission, +// I/O error), we leave the DB row alone so the admin can retry. +func DeleteTrackFile(ctx context.Context, pool *pgxpool.Pool, trackID pgtype.UUID) error { + q := dbq.New(pool) + track, err := q.GetTrackByID(ctx, trackID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrTrackNotFound + } + return fmt.Errorf("get track: %w", err) + } + + if err := os.Remove(track.FilePath); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("remove file: %w", err) + } + + if _, err := pool.Exec(ctx, "DELETE FROM tracks WHERE id = $1", trackID); err != nil { + return fmt.Errorf("delete row: %w", err) + } + return nil +} diff --git a/internal/library/delete_test.go b/internal/library/delete_test.go new file mode 100644 index 00000000..9952bffd --- /dev/null +++ b/internal/library/delete_test.go @@ -0,0 +1,119 @@ +package library + +import ( + "context" + "errors" + "io" + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" +) + +func newPool(t *testing.T) *pgxpool.Pool { + t.Helper() + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + dbtest.ResetDB(t, pool) + return pool +} + +func seedTrack(t *testing.T, pool *pgxpool.Pool, filePath string) (dbq.Track, dbq.Album, dbq.Artist) { + t.Helper() + q := dbq.New(pool) + artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "Delete Test Artist", SortName: "Delete Test Artist", + }) + if err != nil { + t.Fatalf("artist: %v", err) + } + album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Delete Test Album", SortTitle: "Delete Test Album", + ArtistID: artist.ID, + }) + if err != nil { + t.Fatalf("album: %v", err) + } + track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "Delete Test Track", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: filePath, FileSize: 100, FileFormat: "mp3", + }) + if err != nil { + t.Fatalf("track: %v", err) + } + return track, album, artist +} + +func TestDeleteTrackFile_HappyPath(t *testing.T) { + pool := newPool(t) + q := dbq.New(pool) + + dir := t.TempDir() + path := filepath.Join(dir, "track.mp3") + if err := os.WriteFile(path, []byte("payload"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + track, album, _ := seedTrack(t, pool, path) + + if err := DeleteTrackFile(context.Background(), pool, track.ID); err != nil { + t.Fatalf("DeleteTrackFile: %v", err) + } + + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Errorf("file still exists: %v", err) + } + if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { + t.Errorf("track row still exists") + } + // Album row preserved (other tracks may reference it). + if _, err := q.GetAlbumByID(context.Background(), album.ID); err != nil { + t.Errorf("album row vanished: %v", err) + } +} + +func TestDeleteTrackFile_FileAlreadyGoneSucceeds(t *testing.T) { + pool := newPool(t) + q := dbq.New(pool) + + track, _, _ := seedTrack(t, pool, "/no/such/file/anywhere.mp3") + + if err := DeleteTrackFile(context.Background(), pool, track.ID); err != nil { + t.Fatalf("DeleteTrackFile with missing file: %v", err) + } + if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { + t.Errorf("track row still exists") + } +} + +func TestDeleteTrackFile_NotFoundReturnsErr(t *testing.T) { + pool := newPool(t) + + var bogus pgtype.UUID + bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + bogus.Valid = true + + err := DeleteTrackFile(context.Background(), pool, bogus) + if !errors.Is(err, ErrTrackNotFound) { + t.Errorf("err = %v, want ErrTrackNotFound", err) + } +} From e6e3f297d64b2cc33ee0b5b1c8dcb3072c3e8524 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 17:32:08 -0400 Subject: [PATCH 43/67] fix(dbtest,library): include quarantine tables in ResetDB; clarify DeleteTrackFile docstring - dbtest.ResetDB.dataTables now truncates lidarr_quarantine + lidarr_quarantine_actions alongside the other M2-M4 data tables. Without this, M5b service tests would inherit residual state across runs. - DeleteTrackFile godoc spells out the post-file/pre-DB failure window reconciles via the next library scan; the function is retry-safe by design, not atomic. --- internal/dbtest/reset.go | 2 ++ internal/library/delete.go | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/dbtest/reset.go b/internal/dbtest/reset.go index 4f821232..65c72225 100644 --- a/internal/dbtest/reset.go +++ b/internal/dbtest/reset.go @@ -49,6 +49,8 @@ var dataTables = []string{ "skip_events", "play_sessions", "sessions", + "lidarr_quarantine_actions", + "lidarr_quarantine", "tracks", "albums", "artists", diff --git a/internal/library/delete.go b/internal/library/delete.go index 133f6496..9f701a0d 100644 --- a/internal/library/delete.go +++ b/internal/library/delete.go @@ -27,7 +27,10 @@ var ErrTrackNotFound = errors.New("library: track not found") // 3. Delete the tracks row. // // Order matters: file first, then DB. If the file delete fails (permission, -// I/O error), we leave the DB row alone so the admin can retry. +// I/O error), we leave the DB row alone so the admin can retry. The reverse +// failure mode — file gone, DB row still present — is recoverable: the next +// library scan reconciles missing files by removing their tracks rows. So +// the function is retry-safe rather than atomic, by design. func DeleteTrackFile(ctx context.Context, pool *pgxpool.Pool, trackID pgtype.UUID) error { q := dbq.New(pool) track, err := q.GetTrackByID(ctx, trackID) From b4fe224a677c18e6dac5e3ff1dcd9e40cecae0ed Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 17:36:27 -0400 Subject: [PATCH 44/67] feat(lidarrquarantine): Service Flag/Unflag/ListMine/ListAdminQueue --- internal/lidarrquarantine/service.go | 179 ++++++++++++++++++ internal/lidarrquarantine/service_test.go | 211 ++++++++++++++++++++++ 2 files changed, 390 insertions(+) create mode 100644 internal/lidarrquarantine/service.go create mode 100644 internal/lidarrquarantine/service_test.go diff --git a/internal/lidarrquarantine/service.go b/internal/lidarrquarantine/service.go new file mode 100644 index 00000000..e69cb001 --- /dev/null +++ b/internal/lidarrquarantine/service.go @@ -0,0 +1,179 @@ +// Package lidarrquarantine owns the per-user track quarantine workflow. +// Users flag a track as broken (Flag/Unflag), the SPA hides the track +// from their views, and admins resolve the resulting reports via the +// Service's admin actions (Resolve / DeleteFile / DeleteViaLidarr — +// the admin action methods are added in a follow-up task). +package lidarrquarantine + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" +) + +// Public errors. Handlers map these to API codes. +var ( + ErrBadReason = errors.New("lidarrquarantine: invalid reason") + ErrTrackNotFound = errors.New("lidarrquarantine: track not found") + ErrQuarantineNotFound = errors.New("lidarrquarantine: quarantine row not found") + ErrAlbumMBIDMissing = errors.New("lidarrquarantine: track has no parent album mbid") + ErrLidarrAlbumNotFound = errors.New("lidarrquarantine: lidarr has no album for that mbid") + ErrLidarrDisabled = errors.New("lidarrquarantine: lidarr is not configured") +) + +// Service owns the request lifecycle. clientFn is a per-call factory so +// config changes in lidarrconfig take effect immediately. clientFn returns +// nil when Lidarr is disabled. +type Service struct { + pool *pgxpool.Pool + lidarrCfg *lidarrconfig.Service + clientFn func() *lidarr.Client +} + +// NewService constructs a Service. Pass nil for clientFn to disable the +// Lidarr-using methods (DeleteViaLidarr will return ErrLidarrDisabled). +func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client) *Service { + if clientFn == nil { + clientFn = func() *lidarr.Client { return nil } + } + return &Service{pool: pool, lidarrCfg: cfg, clientFn: clientFn} +} + +// Flag inserts or updates a quarantine row for the caller. Re-flagging +// the same (user, track) overwrites reason+notes (and bumps created_at). +func (s *Service) Flag(ctx context.Context, userID, trackID pgtype.UUID, reason string, notes string) (dbq.LidarrQuarantine, error) { + if !validReason(reason) { + return dbq.LidarrQuarantine{}, ErrBadReason + } + var notesPtr *string + if notes != "" { + notesPtr = ¬es + } + row, err := dbq.New(s.pool).UpsertQuarantine(ctx, dbq.UpsertQuarantineParams{ + UserID: userID, + TrackID: trackID, + Reason: dbq.LidarrQuarantineReason(reason), + Notes: notesPtr, + }) + if err != nil { + // FK violation on track_id surfaces here as a postgres error; + // callers can treat any error as "track doesn't exist or DB issue." + // Distinguishing FK violation specifically isn't worth the extra + // dependency on pgconn just for one branch. + return dbq.LidarrQuarantine{}, fmt.Errorf("upsert: %w", err) + } + return row, nil +} + +// Unflag removes the caller's row. Returns ErrQuarantineNotFound if no +// row exists for that (user, track). +func (s *Service) Unflag(ctx context.Context, userID, trackID pgtype.UUID) error { + _, err := dbq.New(s.pool).DeleteQuarantine(ctx, dbq.DeleteQuarantineParams{ + UserID: userID, TrackID: trackID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrQuarantineNotFound + } + return fmt.Errorf("delete: %w", err) + } + return nil +} + +// ListMine returns the caller's quarantines with track + album + artist +// detail. Drives /library/hidden. Ordered newest-first. +func (s *Service) ListMine(ctx context.Context, userID pgtype.UUID) ([]dbq.ListQuarantineForUserRow, error) { + return dbq.New(s.pool).ListQuarantineForUser(ctx, userID) +} + +// AdminQueueRow is the assembled aggregated row served by the admin +// queue endpoint. The handler post-processes the SQL result + a per-track +// ListQuarantineReports call to materialize reason_counts and the +// per-user reports list. +type AdminQueueRow struct { + TrackID pgtype.UUID + TrackTitle string + ArtistName string + AlbumTitle *string + AlbumID pgtype.UUID + LidarrAlbumMBID *string + ReportCount int32 + LatestAt pgtype.Timestamptz + ReasonCounts map[string]int + Reports []UserReport +} + +// UserReport is the per-user detail under an aggregated admin row. +type UserReport struct { + UserID pgtype.UUID + Username string + Reason string + Notes *string + CreatedAt pgtype.Timestamptz +} + +// ListAdminQueue returns the aggregated admin queue. One row per track. +// Performs an N+1 (one ListQuarantineReportsForTrack per aggregated row) +// because materializing reason_counts in pure SQL is awkward; the queue +// is small in practice (≲100 tracks at household scale). Revisit with +// json_object_agg if the queue grows. +func (s *Service) ListAdminQueue(ctx context.Context) ([]AdminQueueRow, error) { + q := dbq.New(s.pool) + aggregated, err := q.ListAdminQuarantineQueue(ctx) + if err != nil { + return nil, fmt.Errorf("aggregate: %w", err) + } + out := make([]AdminQueueRow, 0, len(aggregated)) + for _, r := range aggregated { + reports, err := q.ListQuarantineReportsForTrack(ctx, r.TrackID) + if err != nil { + return nil, fmt.Errorf("reports for track %v: %w", r.TrackID, err) + } + rc := make(map[string]int, len(reports)) + userReports := make([]UserReport, 0, len(reports)) + for _, rep := range reports { + rc[string(rep.Reason)]++ + userReports = append(userReports, UserReport{ + UserID: rep.UserID, + Username: rep.Username, + Reason: string(rep.Reason), + Notes: rep.Notes, + CreatedAt: rep.CreatedAt, + }) + } + var albumTitle *string + if r.AlbumTitle != "" { + t := r.AlbumTitle + albumTitle = &t + } + out = append(out, AdminQueueRow{ + TrackID: r.TrackID, + TrackTitle: r.TrackTitle, + ArtistName: r.ArtistName, + AlbumTitle: albumTitle, + AlbumID: r.AlbumID, + LidarrAlbumMBID: r.LidarrAlbumMbid, + ReportCount: r.ReportCount, + LatestAt: r.LatestAt, + ReasonCounts: rc, + Reports: userReports, + }) + } + return out, nil +} + +func validReason(r string) bool { + switch r { + case "bad_rip", "wrong_file", "wrong_tags", "duplicate", "other": + return true + } + return false +} diff --git a/internal/lidarrquarantine/service_test.go b/internal/lidarrquarantine/service_test.go new file mode 100644 index 00000000..57862d72 --- /dev/null +++ b/internal/lidarrquarantine/service_test.go @@ -0,0 +1,211 @@ +package lidarrquarantine + +import ( + "context" + "errors" + "io" + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" +) + +func newPool(t *testing.T) *pgxpool.Pool { + t.Helper() + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + dbtest.ResetDB(t, pool) + return pool +} + +func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User { + t.Helper() + u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ + Username: dbtest.TestUserPrefix + name, PasswordHash: "x", + ApiToken: name + "-token", IsAdmin: false, + }) + if err != nil { + t.Fatalf("seed user %s: %v", name, err) + } + return u +} + +func seedTrack(t *testing.T, pool *pgxpool.Pool, title, mbid string) (dbq.Track, dbq.Album, dbq.Artist) { + t.Helper() + q := dbq.New(pool) + artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "Test Artist", SortName: "Test Artist", + }) + if err != nil { + t.Fatalf("artist: %v", err) + } + albumMBID := mbid + "-album" + album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Test Album", SortTitle: "Test Album", + ArtistID: artist.ID, Mbid: &albumMBID, + }) + if err != nil { + t.Fatalf("album: %v", err) + } + track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: title, AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: filepath.Join(t.TempDir(), title+".mp3"), + FileSize: 100, FileFormat: "mp3", + }) + if err != nil { + t.Fatalf("track: %v", err) + } + return track, album, artist +} + +func TestFlag_HappyPath(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + track, _, _ := seedTrack(t, pool, "Bad Track", "abc") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + row, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "crackly") + if err != nil { + t.Fatalf("Flag: %v", err) + } + if string(row.Reason) != "bad_rip" { + t.Errorf("reason = %v", row.Reason) + } + if row.Notes == nil || *row.Notes != "crackly" { + t.Errorf("notes = %v", row.Notes) + } +} + +func TestFlag_UpsertOnSecondFlag(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + track, _, _ := seedTrack(t, pool, "T", "x") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "first"); err != nil { + t.Fatalf("first flag: %v", err) + } + row, err := svc.Flag(context.Background(), user.ID, track.ID, "wrong_tags", "") + if err != nil { + t.Fatalf("second flag: %v", err) + } + if string(row.Reason) != "wrong_tags" { + t.Errorf("reason = %v", row.Reason) + } + if row.Notes != nil { + t.Errorf("notes = %v, want nil after empty notes upsert", row.Notes) + } +} + +func TestFlag_BadReasonRejected(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + track, _, _ := seedTrack(t, pool, "T", "x") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + _, err := svc.Flag(context.Background(), user.ID, track.ID, "garbage", "") + if !errors.Is(err, ErrBadReason) { + t.Errorf("err = %v, want ErrBadReason", err) + } +} + +func TestUnflag_DeletesRow(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + track, _, _ := seedTrack(t, pool, "T", "x") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", ""); err != nil { + t.Fatalf("Flag: %v", err) + } + if err := svc.Unflag(context.Background(), user.ID, track.ID); err != nil { + t.Fatalf("Unflag: %v", err) + } + if err := svc.Unflag(context.Background(), user.ID, track.ID); !errors.Is(err, ErrQuarantineNotFound) { + t.Errorf("second Unflag err = %v, want ErrQuarantineNotFound", err) + } +} + +func TestListMine_OrderedNewestFirst(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + t1, _, _ := seedTrack(t, pool, "T1", "x") + t2, _, _ := seedTrack(t, pool, "T2", "y") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + if _, err := svc.Flag(context.Background(), user.ID, t1.ID, "bad_rip", ""); err != nil { + t.Fatalf("Flag t1: %v", err) + } + if _, err := svc.Flag(context.Background(), user.ID, t2.ID, "duplicate", ""); err != nil { + t.Fatalf("Flag t2: %v", err) + } + + rows, err := svc.ListMine(context.Background(), user.ID) + if err != nil { + t.Fatalf("ListMine: %v", err) + } + if len(rows) != 2 { + t.Fatalf("len = %d, want 2", len(rows)) + } + // T2 was flagged second — newest first. + if rows[0].LidarrQuarantine.TrackID != t2.ID { + t.Errorf("first row track = %v, want T2 (%v)", rows[0].LidarrQuarantine.TrackID, t2.ID) + } +} + +func TestListAdminQueue_AggregatesByTrackWithReasonCounts(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + bob := seedUser(t, pool, "bob") + carol := seedUser(t, pool, "carol") + track, _, _ := seedTrack(t, pool, "Hot Mess", "abc") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + if _, err := svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", ""); err != nil { + t.Fatalf("alice flag: %v", err) + } + if _, err := svc.Flag(context.Background(), bob.ID, track.ID, "bad_rip", ""); err != nil { + t.Fatalf("bob flag: %v", err) + } + if _, err := svc.Flag(context.Background(), carol.ID, track.ID, "wrong_tags", ""); err != nil { + t.Fatalf("carol flag: %v", err) + } + + rows, err := svc.ListAdminQueue(context.Background()) + if err != nil { + t.Fatalf("ListAdminQueue: %v", err) + } + if len(rows) != 1 { + t.Fatalf("len = %d, want 1 aggregated row", len(rows)) + } + r := rows[0] + if r.ReportCount != 3 { + t.Errorf("report_count = %d, want 3", r.ReportCount) + } + if r.ReasonCounts["bad_rip"] != 2 || r.ReasonCounts["wrong_tags"] != 1 { + t.Errorf("reason_counts = %+v, want bad_rip=2 wrong_tags=1", r.ReasonCounts) + } + if len(r.Reports) != 3 { + t.Errorf("reports len = %d, want 3", len(r.Reports)) + } +} From 7a0bc1f815a78c25314f82af9a72dc9cec06987e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 17:42:55 -0400 Subject: [PATCH 45/67] fix(lidarrquarantine): translate FK violation to ErrTrackNotFound + drop unneeded *string AlbumTitle - Flag now detects Postgres SQLSTATE 23503 (foreign_key_violation) on UpsertQuarantine and surfaces ErrTrackNotFound instead of a wrapped generic. T8's handler can now return 404 track_not_found cleanly. Constraint-name guard ('track' substring) keeps a future user_id FK from mis-mapping to the same error. - AdminQueueRow.AlbumTitle is now string (not *string). albums.title is NOT NULL upstream; the empty-string-to-nil dance was answering a nullability question that doesn't exist in the schema. - Add TestFlag_NonexistentTrackReturnsErrTrackNotFound covering the new branch. --- internal/lidarrquarantine/service.go | 24 ++++++++++++----------- internal/lidarrquarantine/service_test.go | 16 +++++++++++++++ 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/internal/lidarrquarantine/service.go b/internal/lidarrquarantine/service.go index e69cb001..b13343b6 100644 --- a/internal/lidarrquarantine/service.go +++ b/internal/lidarrquarantine/service.go @@ -9,8 +9,10 @@ import ( "context" "errors" "fmt" + "strings" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" @@ -64,10 +66,15 @@ func (s *Service) Flag(ctx context.Context, userID, trackID pgtype.UUID, reason Notes: notesPtr, }) if err != nil { - // FK violation on track_id surfaces here as a postgres error; - // callers can treat any error as "track doesn't exist or DB issue." - // Distinguishing FK violation specifically isn't worth the extra - // dependency on pgconn just for one branch. + // FK violation on track_id (no such track) → ErrTrackNotFound so the + // handler can return 404 instead of a generic 500. Code 23503 is the + // Postgres SQLSTATE for foreign_key_violation; the constraint check + // guards against future FKs (e.g. user_id) firing the same code. + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23503" && + (pgErr.ConstraintName == "" || strings.Contains(pgErr.ConstraintName, "track")) { + return dbq.LidarrQuarantine{}, ErrTrackNotFound + } return dbq.LidarrQuarantine{}, fmt.Errorf("upsert: %w", err) } return row, nil @@ -102,7 +109,7 @@ type AdminQueueRow struct { TrackID pgtype.UUID TrackTitle string ArtistName string - AlbumTitle *string + AlbumTitle string AlbumID pgtype.UUID LidarrAlbumMBID *string ReportCount int32 @@ -149,16 +156,11 @@ func (s *Service) ListAdminQueue(ctx context.Context) ([]AdminQueueRow, error) { CreatedAt: rep.CreatedAt, }) } - var albumTitle *string - if r.AlbumTitle != "" { - t := r.AlbumTitle - albumTitle = &t - } out = append(out, AdminQueueRow{ TrackID: r.TrackID, TrackTitle: r.TrackTitle, ArtistName: r.ArtistName, - AlbumTitle: albumTitle, + AlbumTitle: r.AlbumTitle, AlbumID: r.AlbumID, LidarrAlbumMBID: r.LidarrAlbumMbid, ReportCount: r.ReportCount, diff --git a/internal/lidarrquarantine/service_test.go b/internal/lidarrquarantine/service_test.go index 57862d72..8d6b4ba7 100644 --- a/internal/lidarrquarantine/service_test.go +++ b/internal/lidarrquarantine/service_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "testing" + "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db" @@ -117,6 +118,21 @@ func TestFlag_UpsertOnSecondFlag(t *testing.T) { } } +func TestFlag_NonexistentTrackReturnsErrTrackNotFound(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + + var bogus pgtype.UUID + bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + bogus.Valid = true + + svc := NewService(pool, lidarrconfig.New(pool), nil) + _, err := svc.Flag(context.Background(), user.ID, bogus, "bad_rip", "") + if !errors.Is(err, ErrTrackNotFound) { + t.Errorf("err = %v, want ErrTrackNotFound", err) + } +} + func TestFlag_BadReasonRejected(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "alice") From 782e72b5954502fff7c41088ae3d3cf2b6cc4984 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 19:37:40 -0400 Subject: [PATCH 46/67] feat(lidarrquarantine): admin actions Resolve/DeleteFile/DeleteViaLidarr --- internal/lidarrquarantine/service.go | 173 +++++++++++++++- internal/lidarrquarantine/service_test.go | 238 ++++++++++++++++++++++ 2 files changed, 409 insertions(+), 2 deletions(-) diff --git a/internal/lidarrquarantine/service.go b/internal/lidarrquarantine/service.go index b13343b6..571f6b62 100644 --- a/internal/lidarrquarantine/service.go +++ b/internal/lidarrquarantine/service.go @@ -1,8 +1,7 @@ // Package lidarrquarantine owns the per-user track quarantine workflow. // Users flag a track as broken (Flag/Unflag), the SPA hides the track // from their views, and admins resolve the resulting reports via the -// Service's admin actions (Resolve / DeleteFile / DeleteViaLidarr — -// the admin action methods are added in a follow-up task). +// Service's admin actions (Resolve / DeleteFile / DeleteViaLidarr). package lidarrquarantine import ( @@ -17,6 +16,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/library" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" ) @@ -179,3 +179,172 @@ func validReason(r string) bool { } return false } + +// Resolve clears all per-user quarantine rows for a track and writes an +// audit log row. Idempotent — a track with no rows still writes an audit +// entry with affected_users=0 (so admin can see "I clicked resolve on a +// track that already had no reports"). +func (s *Service) Resolve(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) { + q := dbq.New(s.pool) + track, err := q.GetTrackByID(ctx, trackID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return dbq.LidarrQuarantineAction{}, ErrTrackNotFound + } + return dbq.LidarrQuarantineAction{}, fmt.Errorf("get track: %w", err) + } + snap, err := s.snapshot(ctx, q, track) + if err != nil { + return dbq.LidarrQuarantineAction{}, err + } + + affected, err := q.CountQuarantineForTrack(ctx, trackID) + if err != nil { + return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err) + } + if err := q.DeleteQuarantineForTrack(ctx, trackID); err != nil { + return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete rows: %w", err) + } + return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ + TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, + AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionKindResolved, + AdminID: adminID, LidarrAlbumMbid: snap.LidarrAlbumMBID, AffectedUsers: affected, + }) +} + +// quarantineSnapshot is the audit-row-ready snapshot of track + album + +// artist text columns. Used by the three admin actions. +type quarantineSnapshot struct { + TrackTitle string + ArtistName string + AlbumTitle *string + LidarrAlbumMBID *string +} + +// snapshot pulls the audit-row text columns from track / album / artist. +// AlbumTitle is materialized as *string because WriteQuarantineActionParams +// expects nullable; we coerce the schema's NOT NULL value into a pointer. +func (s *Service) snapshot(ctx context.Context, q *dbq.Queries, track dbq.Track) (quarantineSnapshot, error) { + album, err := q.GetAlbumByID(ctx, track.AlbumID) + if err != nil { + return quarantineSnapshot{}, fmt.Errorf("get album: %w", err) + } + artist, err := q.GetArtistByID(ctx, track.ArtistID) + if err != nil { + return quarantineSnapshot{}, fmt.Errorf("get artist: %w", err) + } + titlePtr := album.Title + return quarantineSnapshot{ + TrackTitle: track.Title, + ArtistName: artist.Name, + AlbumTitle: &titlePtr, + LidarrAlbumMBID: album.Mbid, + }, nil +} + +// DeleteFile removes the track file from disk and the tracks row, then +// (via FK ON DELETE CASCADE) clears all per-user quarantine rows for that +// track and writes an audit row. If the file deletion fails, the per-user +// rows stay so admin can retry. No partial state. +func (s *Service) DeleteFile(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) { + q := dbq.New(s.pool) + track, err := q.GetTrackByID(ctx, trackID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return dbq.LidarrQuarantineAction{}, ErrTrackNotFound + } + return dbq.LidarrQuarantineAction{}, fmt.Errorf("get track: %w", err) + } + snap, err := s.snapshot(ctx, q, track) + if err != nil { + return dbq.LidarrQuarantineAction{}, err + } + + affected, err := q.CountQuarantineForTrack(ctx, trackID) + if err != nil { + return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err) + } + if err := library.DeleteTrackFile(ctx, s.pool, trackID); err != nil { + return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete file: %w", err) + } + // tracks row is gone; ON DELETE CASCADE on lidarr_quarantine.track_id + // already cleared the per-user rows. We don't call + // DeleteQuarantineForTrack separately. + return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ + TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, + AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionKindDeletedFile, + AdminID: adminID, LidarrAlbumMbid: snap.LidarrAlbumMBID, AffectedUsers: affected, + }) +} + +// DeleteViaLidarr is the destructive admin path: tells Lidarr to remove +// the parent album with deleteFiles=true + addImportListExclusion=true, +// then removes Minstrel rows for all tracks of that album. The cascade +// on lidarr_quarantine.track_id clears per-user rows automatically. +// +// On Lidarr failure (unreachable, auth-failed, lookup-empty), nothing +// changes locally. Admin retries. +// +// Returns the audit row + the count of tracks deleted from Minstrel. +func (s *Service) DeleteViaLidarr(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, int, error) { + cfg, err := s.lidarrCfg.Get(ctx) + if err != nil { + return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("load config: %w", err) + } + client := s.clientFn() + if !cfg.Enabled || client == nil { + return dbq.LidarrQuarantineAction{}, 0, ErrLidarrDisabled + } + + q := dbq.New(s.pool) + track, err := q.GetTrackByID(ctx, trackID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return dbq.LidarrQuarantineAction{}, 0, ErrTrackNotFound + } + return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("get track: %w", err) + } + snap, err := s.snapshot(ctx, q, track) + if err != nil { + return dbq.LidarrQuarantineAction{}, 0, err + } + if snap.LidarrAlbumMBID == nil || *snap.LidarrAlbumMBID == "" { + return dbq.LidarrQuarantineAction{}, 0, ErrAlbumMBIDMissing + } + + affected, err := q.CountQuarantineForTrack(ctx, trackID) + if err != nil { + return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("count: %w", err) + } + + // Lidarr lookup: MBID -> internal album ID. + album, err := client.LookupAlbumByMBID(ctx, *snap.LidarrAlbumMBID) + if err != nil { + if errors.Is(err, lidarr.ErrNotFound) { + return dbq.LidarrQuarantineAction{}, 0, ErrLidarrAlbumNotFound + } + return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("lidarr lookup: %w", err) + } + + // Lidarr DELETE — both flags true. + if err := client.DeleteAlbum(ctx, album.ID, true, true); err != nil { + return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("lidarr delete: %w", err) + } + + // Now remove the local rows. CASCADE on lidarr_quarantine.track_id + // handles per-user rows. + res, err := s.pool.Exec(ctx, "DELETE FROM tracks WHERE album_id = $1", track.AlbumID) + if err != nil { + // Lidarr is already done; we couldn't update locally. Operator- + // recoverable: re-running picks up where we left off. + return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("delete tracks: %w", err) + } + deletedCount := int(res.RowsAffected()) + + action, err := q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ + TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, + AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionKindDeletedViaLidarr, + AdminID: adminID, LidarrAlbumMbid: snap.LidarrAlbumMBID, AffectedUsers: affected, + }) + return action, deletedCount, err +} diff --git a/internal/lidarrquarantine/service_test.go b/internal/lidarrquarantine/service_test.go index 8d6b4ba7..903aac86 100644 --- a/internal/lidarrquarantine/service_test.go +++ b/internal/lidarrquarantine/service_test.go @@ -5,6 +5,8 @@ import ( "errors" "io" "log/slog" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -15,6 +17,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" ) @@ -225,3 +228,238 @@ func TestListAdminQueue_AggregatesByTrackWithReasonCounts(t *testing.T) { t.Errorf("reports len = %d, want 3", len(r.Reports)) } } + +func TestResolve_ClearsRowsAndWritesAudit(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + bob := seedUser(t, pool, "bob") + track, _, _ := seedTrack(t, pool, "T", "x") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + if _, err := svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", ""); err != nil { + t.Fatalf("alice flag: %v", err) + } + if _, err := svc.Flag(context.Background(), bob.ID, track.ID, "wrong_tags", ""); err != nil { + t.Fatalf("bob flag: %v", err) + } + + audit, err := svc.Resolve(context.Background(), track.ID, alice.ID) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if audit.AffectedUsers != 2 { + t.Errorf("affected_users = %d, want 2", audit.AffectedUsers) + } + if audit.Action != dbq.LidarrQuarantineActionKindResolved { + t.Errorf("action = %v, want resolved", audit.Action) + } + n, _ := dbq.New(pool).CountQuarantineForTrack(context.Background(), track.ID) + if n != 0 { + t.Errorf("rows after resolve = %d, want 0", n) + } +} + +func TestResolve_TrackNotFound(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + var bogus pgtype.UUID + bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + bogus.Valid = true + + svc := NewService(pool, lidarrconfig.New(pool), nil) + _, err := svc.Resolve(context.Background(), bogus, user.ID) + if !errors.Is(err, ErrTrackNotFound) { + t.Errorf("err = %v, want ErrTrackNotFound", err) + } +} + +func TestDeleteFile_RemovesFileAndAuditsAffected(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + q := dbq.New(pool) + + dir := t.TempDir() + path := filepath.Join(dir, "track.mp3") + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"}) + album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "Al", SortTitle: "Al", ArtistID: artist.ID}) + track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "T", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3", + }) + + svc := NewService(pool, lidarrconfig.New(pool), nil) + if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", ""); err != nil { + t.Fatalf("Flag: %v", err) + } + + audit, err := svc.DeleteFile(context.Background(), track.ID, user.ID) + if err != nil { + t.Fatalf("DeleteFile: %v", err) + } + if audit.AffectedUsers != 1 { + t.Errorf("affected_users = %d, want 1", audit.AffectedUsers) + } + if audit.Action != dbq.LidarrQuarantineActionKindDeletedFile { + t.Errorf("action = %v, want deleted_file", audit.Action) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Errorf("file still exists: %v", err) + } + // Track row gone; CASCADE cleared the quarantine row. + if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { + t.Errorf("track row still exists") + } +} + +func TestDeleteViaLidarr_FullCascade(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + + var captured []string + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = append(captured, r.Method+" "+r.URL.Path+"?"+r.URL.RawQuery) + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/v1/album" && r.Method == http.MethodGet { + _, _ = w.Write([]byte(`[{"id":42,"foreignAlbumId":"al-mbid","title":"Al","artistId":7}]`)) + return + } + // DELETE /api/v1/album/42 -> 200. + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(stub.Close) + + cfg := lidarrconfig.New(pool) + if err := cfg.Save(context.Background(), lidarrconfig.Config{ + Enabled: true, BaseURL: stub.URL, APIKey: "k", + }); err != nil { + t.Fatalf("save config: %v", err) + } + clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") } + svc := NewService(pool, cfg, clientFn) + + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"}) + albumMBID := "al-mbid" + album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Al", SortTitle: "Al", ArtistID: artist.ID, Mbid: &albumMBID, + }) + dir := t.TempDir() + path := filepath.Join(dir, "T.mp3") + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "T", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3", + }) + if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", ""); err != nil { + t.Fatalf("Flag: %v", err) + } + + audit, deleted, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID) + if err != nil { + t.Fatalf("DeleteViaLidarr: %v", err) + } + if deleted != 1 { + t.Errorf("deleted = %d, want 1 track removed", deleted) + } + if audit.Action != dbq.LidarrQuarantineActionKindDeletedViaLidarr { + t.Errorf("action = %v", audit.Action) + } + if audit.AffectedUsers != 1 { + t.Errorf("affected_users = %d, want 1", audit.AffectedUsers) + } + if audit.LidarrAlbumMbid == nil || *audit.LidarrAlbumMbid != "al-mbid" { + t.Errorf("lidarr_album_mbid = %v", audit.LidarrAlbumMbid) + } + if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { + t.Errorf("track row still exists") + } + // Verify Lidarr received DELETE with both flags true (query order may vary). + foundDelete := false + for _, c := range captured { + if c == "DELETE /api/v1/album/42?addImportListExclusion=true&deleteFiles=true" || + c == "DELETE /api/v1/album/42?deleteFiles=true&addImportListExclusion=true" { + foundDelete = true + } + } + if !foundDelete { + t.Errorf("Lidarr DELETE not called with both flags true; captured = %v", captured) + } +} + +func TestDeleteViaLidarr_LidarrDisabled(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + track, _, _ := seedTrack(t, pool, "T", "x") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + _, _, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID) + if !errors.Is(err, ErrLidarrDisabled) { + t.Errorf("err = %v, want ErrLidarrDisabled", err) + } +} + +func TestDeleteViaLidarr_AlbumMBIDMissing(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"}) + // Album with NO mbid set. + album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Al", SortTitle: "Al", ArtistID: artist.ID, // Mbid: nil + }) + dir := t.TempDir() + path := filepath.Join(dir, "T.mp3") + _ = os.WriteFile(path, []byte("x"), 0o644) + track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "T", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3", + }) + + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(stub.Close) + cfg := lidarrconfig.New(pool) + _ = cfg.Save(context.Background(), lidarrconfig.Config{Enabled: true, BaseURL: stub.URL, APIKey: "k"}) + clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") } + svc := NewService(pool, cfg, clientFn) + _, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "") + + _, _, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID) + if !errors.Is(err, ErrAlbumMBIDMissing) { + t.Errorf("err = %v, want ErrAlbumMBIDMissing", err) + } +} + +func TestDeleteViaLidarr_LidarrAlbumNotFound(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/v1/album" && r.Method == http.MethodGet { + _, _ = w.Write([]byte("[]")) // Lidarr returns empty -> ErrNotFound + return + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(stub.Close) + cfg := lidarrconfig.New(pool) + _ = cfg.Save(context.Background(), lidarrconfig.Config{Enabled: true, BaseURL: stub.URL, APIKey: "k"}) + clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") } + svc := NewService(pool, cfg, clientFn) + + track, _, _ := seedTrack(t, pool, "T", "x") + _, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "") + + _, _, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID) + if !errors.Is(err, ErrLidarrAlbumNotFound) { + t.Errorf("err = %v, want ErrLidarrAlbumNotFound", err) + } +} From ef3fffb3ea7bbf9e71f47ef587353fe5d1f5633f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 19:42:19 -0400 Subject: [PATCH 47/67] fix(lidarrquarantine): keep LidarrAlbumMbid nil on Resolve/DeleteFile audit rows + cover idempotent Resolve The audit log treats lidarr_album_mbid as a 'this row triggered Lidarr' marker. Setting it on Resolve/DeleteFile (where Lidarr is never contacted) muddied that semantic. Reverting to plan: only DeleteViaLidarr writes the mbid. Adds an idempotency test for Resolve over a track with zero rows (audit row written, affected_users=0, mbid nil). --- internal/lidarrquarantine/service.go | 10 +++++--- internal/lidarrquarantine/service_test.go | 28 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/internal/lidarrquarantine/service.go b/internal/lidarrquarantine/service.go index 571f6b62..3a402342 100644 --- a/internal/lidarrquarantine/service.go +++ b/internal/lidarrquarantine/service.go @@ -205,10 +205,13 @@ func (s *Service) Resolve(ctx context.Context, trackID, adminID pgtype.UUID) (db if err := q.DeleteQuarantineForTrack(ctx, trackID); err != nil { return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete rows: %w", err) } + // LidarrAlbumMbid stays nil here: Lidarr was not contacted. The audit + // log uses the field as a "this row triggered Lidarr" marker, so only + // DeleteViaLidarr sets it. return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionKindResolved, - AdminID: adminID, LidarrAlbumMbid: snap.LidarrAlbumMBID, AffectedUsers: affected, + AdminID: adminID, LidarrAlbumMbid: nil, AffectedUsers: affected, }) } @@ -269,11 +272,12 @@ func (s *Service) DeleteFile(ctx context.Context, trackID, adminID pgtype.UUID) } // tracks row is gone; ON DELETE CASCADE on lidarr_quarantine.track_id // already cleared the per-user rows. We don't call - // DeleteQuarantineForTrack separately. + // DeleteQuarantineForTrack separately. LidarrAlbumMbid stays nil — + // Lidarr was not contacted. return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionKindDeletedFile, - AdminID: adminID, LidarrAlbumMbid: snap.LidarrAlbumMBID, AffectedUsers: affected, + AdminID: adminID, LidarrAlbumMbid: nil, AffectedUsers: affected, }) } diff --git a/internal/lidarrquarantine/service_test.go b/internal/lidarrquarantine/service_test.go index 903aac86..a0708765 100644 --- a/internal/lidarrquarantine/service_test.go +++ b/internal/lidarrquarantine/service_test.go @@ -253,12 +253,37 @@ func TestResolve_ClearsRowsAndWritesAudit(t *testing.T) { if audit.Action != dbq.LidarrQuarantineActionKindResolved { t.Errorf("action = %v, want resolved", audit.Action) } + if audit.LidarrAlbumMbid != nil { + t.Errorf("lidarr_album_mbid = %v, want nil for resolve", audit.LidarrAlbumMbid) + } n, _ := dbq.New(pool).CountQuarantineForTrack(context.Background(), track.ID) if n != 0 { t.Errorf("rows after resolve = %d, want 0", n) } } +func TestResolve_NoExistingRowsStillWritesAudit(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + track, _, _ := seedTrack(t, pool, "T", "x") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + // No flags applied — resolve a track with zero quarantine rows. + audit, err := svc.Resolve(context.Background(), track.ID, user.ID) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if audit.AffectedUsers != 0 { + t.Errorf("affected_users = %d, want 0", audit.AffectedUsers) + } + if audit.Action != dbq.LidarrQuarantineActionKindResolved { + t.Errorf("action = %v, want resolved", audit.Action) + } + if audit.LidarrAlbumMbid != nil { + t.Errorf("lidarr_album_mbid = %v, want nil for resolve", audit.LidarrAlbumMbid) + } +} + func TestResolve_TrackNotFound(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "alice") @@ -305,6 +330,9 @@ func TestDeleteFile_RemovesFileAndAuditsAffected(t *testing.T) { if audit.Action != dbq.LidarrQuarantineActionKindDeletedFile { t.Errorf("action = %v, want deleted_file", audit.Action) } + if audit.LidarrAlbumMbid != nil { + t.Errorf("lidarr_album_mbid = %v, want nil for delete_file", audit.LidarrAlbumMbid) + } if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { t.Errorf("file still exists: %v", err) } From 559fb5dd2c62111480fe9f32f5f98fc7dbfae528 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 19:49:00 -0400 Subject: [PATCH 48/67] feat(db): add user-context track query variants honoring quarantine --- internal/db/dbq/recommendation.sql.go | 8 ++ internal/db/dbq/tracks.sql.go | 135 +++++++++++++++++++++++++ internal/db/queries/recommendation.sql | 8 ++ internal/db/queries/tracks.sql | 31 ++++++ 4 files changed, 182 insertions(+) diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index 97a620df..dd1dea47 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -34,6 +34,10 @@ WHERE t.id <> $2 WHERE user_id = $1 AND track_id = t.id AND started_at > now() - $3 * interval '1 hour' ) + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) ` type LoadRadioCandidatesParams struct { @@ -117,6 +121,10 @@ excluded_ids AS ( SELECT pe.track_id AS id FROM play_events pe WHERE pe.user_id = $1 AND pe.started_at > now() - $3 * interval '1 hour' + UNION ALL + SELECT q.track_id AS id + FROM lidarr_quarantine q + WHERE q.user_id = $1 ), lb_similar AS ( SELECT ts.track_b_id AS track_id, ts.score AS sim_score diff --git a/internal/db/dbq/tracks.sql.go b/internal/db/dbq/tracks.sql.go index d17447c9..65486ad8 100644 --- a/internal/db/dbq/tracks.sql.go +++ b/internal/db/dbq/tracks.sql.go @@ -33,6 +33,28 @@ func (q *Queries) CountTracksMatching(ctx context.Context, dollar_1 string) (int return count, err } +const countTracksMatchingForUser = `-- name: CountTracksMatchingForUser :one +SELECT COUNT(*) FROM tracks +WHERE title ILIKE '%' || $1::text || '%' + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $2 AND q.track_id = tracks.id + ) +` + +type CountTracksMatchingForUserParams struct { + Column1 string + UserID pgtype.UUID +} + +// $1 = title query, $2 = user_id. +func (q *Queries) CountTracksMatchingForUser(ctx context.Context, arg CountTracksMatchingForUserParams) (int64, error) { + row := q.db.QueryRow(ctx, countTracksMatchingForUser, arg.Column1, arg.UserID) + var count int64 + err := row.Scan(&count) + return count, err +} + const getTrackByID = `-- name: GetTrackByID :one SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE id = $1 ` @@ -127,6 +149,59 @@ func (q *Queries) ListTracksByAlbum(ctx context.Context, albumID pgtype.UUID) ([ return items, nil } +const listTracksByAlbumForUser = `-- name: ListTracksByAlbumForUser :many +SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks +WHERE album_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $2 AND q.track_id = tracks.id + ) +ORDER BY disc_number NULLS LAST, track_number NULLS LAST +` + +type ListTracksByAlbumForUserParams struct { + AlbumID pgtype.UUID + UserID pgtype.UUID +} + +// Same as ListTracksByAlbum but excludes tracks the user has quarantined. +// $1 = album_id, $2 = user_id. +func (q *Queries) ListTracksByAlbumForUser(ctx context.Context, arg ListTracksByAlbumForUserParams) ([]Track, error) { + rows, err := q.db.Query(ctx, listTracksByAlbumForUser, arg.AlbumID, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Track + for rows.Next() { + var i Track + if err := rows.Scan( + &i.ID, + &i.Title, + &i.AlbumID, + &i.ArtistID, + &i.TrackNumber, + &i.DiscNumber, + &i.DurationMs, + &i.FilePath, + &i.FileSize, + &i.FileFormat, + &i.Bitrate, + &i.Mbid, + &i.Genre, + &i.AddedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const searchTracks = `-- name: SearchTracks :many SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE title ILIKE '%' || $1 || '%' @@ -176,6 +251,66 @@ func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]T return items, nil } +const searchTracksForUser = `-- name: SearchTracksForUser :many +SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks +WHERE title ILIKE '%' || $1 || '%' + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $2 AND q.track_id = tracks.id + ) +ORDER BY title +LIMIT $3 OFFSET $4 +` + +type SearchTracksForUserParams struct { + Column1 *string + UserID pgtype.UUID + Limit int32 + Offset int32 +} + +// $1 = title query, $2 = user_id, $3 = limit, $4 = offset. +func (q *Queries) SearchTracksForUser(ctx context.Context, arg SearchTracksForUserParams) ([]Track, error) { + rows, err := q.db.Query(ctx, searchTracksForUser, + arg.Column1, + arg.UserID, + arg.Limit, + arg.Offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Track + for rows.Next() { + var i Track + if err := rows.Scan( + &i.ID, + &i.Title, + &i.AlbumID, + &i.ArtistID, + &i.TrackNumber, + &i.DiscNumber, + &i.DurationMs, + &i.FilePath, + &i.FileSize, + &i.FileFormat, + &i.Bitrate, + &i.Mbid, + &i.Genre, + &i.AddedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const upsertTrack = `-- name: UpsertTrack :one INSERT INTO tracks ( title, album_id, artist_id, track_number, disc_number, diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index 10e81601..f3dc081c 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -26,6 +26,10 @@ WHERE t.id <> $2 SELECT 1 FROM play_events WHERE user_id = $1 AND track_id = t.id AND started_at > now() - $3 * interval '1 hour' + ) + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id ); -- name: LoadRadioCandidatesV2 :many @@ -53,6 +57,10 @@ excluded_ids AS ( SELECT pe.track_id AS id FROM play_events pe WHERE pe.user_id = $1 AND pe.started_at > now() - $3 * interval '1 hour' + UNION ALL + SELECT q.track_id AS id + FROM lidarr_quarantine q + WHERE q.user_id = $1 ), lb_similar AS ( SELECT ts.track_b_id AS track_id, ts.score AS sim_score diff --git a/internal/db/queries/tracks.sql b/internal/db/queries/tracks.sql index 04bffc56..d9b06e1f 100644 --- a/internal/db/queries/tracks.sql +++ b/internal/db/queries/tracks.sql @@ -39,3 +39,34 @@ LIMIT $2 OFFSET $3; -- name: CountTracksMatching :one SELECT COUNT(*) FROM tracks WHERE title ILIKE '%' || $1::text || '%'; + +-- name: ListTracksByAlbumForUser :many +-- Same as ListTracksByAlbum but excludes tracks the user has quarantined. +-- $1 = album_id, $2 = user_id. +SELECT * FROM tracks +WHERE album_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $2 AND q.track_id = tracks.id + ) +ORDER BY disc_number NULLS LAST, track_number NULLS LAST; + +-- name: SearchTracksForUser :many +-- $1 = title query, $2 = user_id, $3 = limit, $4 = offset. +SELECT * FROM tracks +WHERE title ILIKE '%' || $1 || '%' + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $2 AND q.track_id = tracks.id + ) +ORDER BY title +LIMIT $3 OFFSET $4; + +-- name: CountTracksMatchingForUser :one +-- $1 = title query, $2 = user_id. +SELECT COUNT(*) FROM tracks +WHERE title ILIKE '%' || $1::text || '%' + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $2 AND q.track_id = tracks.id + ); From ba81e4f2acc560c72ee977d4523292155853d1c0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 20:09:04 -0400 Subject: [PATCH 49/67] feat(api): route track-list reads through user-context quarantine filter --- internal/api/library.go | 10 +++++++++- internal/api/search.go | 23 +++++++++++++++++++---- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/internal/api/library.go b/internal/api/library.go index 8ec44e59..da7331ac 100644 --- a/internal/api/library.go +++ b/internal/api/library.go @@ -7,6 +7,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) @@ -71,7 +72,14 @@ func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") return } - tracks, err := q.ListTracksByAlbum(r.Context(), id) + var tracks []dbq.Track + if user, ok := auth.UserFromContext(r.Context()); ok { + tracks, err = q.ListTracksByAlbumForUser(r.Context(), dbq.ListTracksByAlbumForUserParams{ + AlbumID: id, UserID: user.ID, + }) + } else { + tracks, err = q.ListTracksByAlbum(r.Context(), id) + } if err != nil { h.logger.Error("api: list tracks failed", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") diff --git a/internal/api/search.go b/internal/api/search.go index b077453e..9227bab0 100644 --- a/internal/api/search.go +++ b/internal/api/search.go @@ -7,6 +7,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) @@ -74,15 +75,29 @@ func (h *handlers) handleSearch(w http.ResponseWriter, r *http.Request) { return } - tracks, err := dbQ.SearchTracks(r.Context(), dbq.SearchTracksParams{ - Column1: needle, Limit: int32(limit), Offset: int32(offset), - }) + var tracks []dbq.Track + if user, ok := auth.UserFromContext(r.Context()); ok { + tracks, err = dbQ.SearchTracksForUser(r.Context(), dbq.SearchTracksForUserParams{ + Column1: needle, UserID: user.ID, Limit: int32(limit), Offset: int32(offset), + }) + } else { + tracks, err = dbQ.SearchTracks(r.Context(), dbq.SearchTracksParams{ + Column1: needle, Limit: int32(limit), Offset: int32(offset), + }) + } if err != nil { h.logger.Error("api: search tracks failed", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "search failed") return } - trackTotal, err := dbQ.CountTracksMatching(r.Context(), q) + var trackTotal int64 + if user, ok := auth.UserFromContext(r.Context()); ok { + trackTotal, err = dbQ.CountTracksMatchingForUser(r.Context(), dbq.CountTracksMatchingForUserParams{ + Column1: q, UserID: user.ID, + }) + } else { + trackTotal, err = dbQ.CountTracksMatching(r.Context(), q) + } if err != nil { h.logger.Error("api: count tracks matching failed", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "search failed") From 6fedd495bea548ee2ddb6cf69858ead75dd967f0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 20:19:39 -0400 Subject: [PATCH 50/67] feat(api): /api/quarantine user + admin endpoints + Service wiring User-facing /api/quarantine handlers (POST flag, DELETE unflag, GET mine) plus the five /api/admin/quarantine endpoints (queue, resolve, delete-file, delete-via-lidarr, actions). Mount() and the handlers struct now accept the lidarrquarantine.Service, constructed in server.go alongside the existing lidarrrequests wiring. --- internal/api/admin_quarantine.go | 243 +++++++++++++++ internal/api/admin_quarantine_test.go | 429 ++++++++++++++++++++++++++ internal/api/api.go | 36 ++- internal/api/auth_test.go | 4 +- internal/api/library_test.go | 2 +- internal/api/quarantine.go | 148 +++++++++ internal/api/quarantine_test.go | 305 ++++++++++++++++++ internal/server/server.go | 4 +- 8 files changed, 1159 insertions(+), 12 deletions(-) create mode 100644 internal/api/admin_quarantine.go create mode 100644 internal/api/admin_quarantine_test.go create mode 100644 internal/api/quarantine.go create mode 100644 internal/api/quarantine_test.go diff --git a/internal/api/admin_quarantine.go b/internal/api/admin_quarantine.go new file mode 100644 index 00000000..a6c2e06d --- /dev/null +++ b/internal/api/admin_quarantine.go @@ -0,0 +1,243 @@ +package api + +import ( + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" +) + +// adminQueueRowView is a single aggregated row in the admin queue response. +type adminQueueRowView struct { + TrackID string `json:"track_id"` + TrackTitle string `json:"track_title"` + ArtistName string `json:"artist_name"` + AlbumTitle string `json:"album_title"` + AlbumID string `json:"album_id"` + LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"` + ReportCount int32 `json:"report_count"` + LatestAt string `json:"latest_at"` + ReasonCounts map[string]int `json:"reason_counts"` + Reports []adminQueueReportView `json:"reports"` +} + +// adminQueueReportView is one user's report under an aggregated admin row. +type adminQueueReportView struct { + UserID string `json:"user_id"` + Username string `json:"username"` + Reason string `json:"reason"` + Notes *string `json:"notes,omitempty"` + CreatedAt string `json:"created_at"` +} + +// formatTimestamp renders a pgtype.Timestamptz as RFC3339 or empty string. +func formatTimestamp(ts pgtype.Timestamptz) string { + if !ts.Valid { + return "" + } + return ts.Time.Format("2006-01-02T15:04:05Z07:00") +} + +// handleListAdminQuarantine implements GET /api/admin/quarantine. +func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Request) { + rows, err := h.lidarrQuarantine.ListAdminQueue(r.Context()) + if err != nil { + h.logger.Error("admin: list quarantine", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") + return + } + out := make([]adminQueueRowView, 0, len(rows)) + for _, row := range rows { + reports := make([]adminQueueReportView, 0, len(row.Reports)) + for _, rep := range row.Reports { + reports = append(reports, adminQueueReportView{ + UserID: uuidToString(rep.UserID), + Username: rep.Username, + Reason: rep.Reason, + Notes: rep.Notes, + CreatedAt: formatTimestamp(rep.CreatedAt), + }) + } + out = append(out, adminQueueRowView{ + TrackID: uuidToString(row.TrackID), + TrackTitle: row.TrackTitle, + ArtistName: row.ArtistName, + AlbumTitle: row.AlbumTitle, + AlbumID: uuidToString(row.AlbumID), + LidarrAlbumMBID: row.LidarrAlbumMBID, + ReportCount: row.ReportCount, + LatestAt: formatTimestamp(row.LatestAt), + ReasonCounts: row.ReasonCounts, + Reports: reports, + }) + } + writeJSON(w, http.StatusOK, out) +} + +// actionResultView is the response shape for the three admin destructive +// actions (resolve, delete-file, delete-via-lidarr). +type actionResultView struct { + ActionID string `json:"action_id"` + AffectedUsers int32 `json:"affected_users"` + DeletedTrackCount *int `json:"deleted_track_count,omitempty"` +} + +// handleResolveQuarantine implements POST /api/admin/quarantine/{track_id}/resolve. +func (h *handlers) handleResolveQuarantine(w http.ResponseWriter, r *http.Request) { + admin, ok := auth.UserFromContext(r.Context()) + if !ok { + writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized") + return + } + id, ok := parseUUID(chi.URLParam(r, "track_id")) + if !ok { + writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") + return + } + action, err := h.lidarrQuarantine.Resolve(r.Context(), id, admin.ID) + if err != nil { + if errors.Is(err, lidarrquarantine.ErrTrackNotFound) { + writeAdminJSONErr(w, http.StatusNotFound, "track_not_found") + return + } + h.logger.Error("admin: resolve quarantine", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") + return + } + writeJSON(w, http.StatusOK, actionResultView{ + ActionID: uuidToString(action.ID), + AffectedUsers: action.AffectedUsers, + }) +} + +// handleDeleteQuarantineFile implements POST /api/admin/quarantine/{track_id}/delete-file. +func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Request) { + admin, ok := auth.UserFromContext(r.Context()) + if !ok { + writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized") + return + } + id, ok := parseUUID(chi.URLParam(r, "track_id")) + if !ok { + writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") + return + } + action, err := h.lidarrQuarantine.DeleteFile(r.Context(), id, admin.ID) + if err != nil { + switch { + case errors.Is(err, lidarrquarantine.ErrTrackNotFound): + writeAdminJSONErr(w, http.StatusNotFound, "track_not_found") + default: + h.logger.Error("admin: delete file", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "file_delete_failed") + } + return + } + writeJSON(w, http.StatusOK, actionResultView{ + ActionID: uuidToString(action.ID), + AffectedUsers: action.AffectedUsers, + }) +} + +// handleDeleteQuarantineViaLidarr implements POST /api/admin/quarantine/{track_id}/delete-via-lidarr. +func (h *handlers) handleDeleteQuarantineViaLidarr(w http.ResponseWriter, r *http.Request) { + admin, ok := auth.UserFromContext(r.Context()) + if !ok { + writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized") + return + } + id, ok := parseUUID(chi.URLParam(r, "track_id")) + if !ok { + writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") + return + } + action, deleted, err := h.lidarrQuarantine.DeleteViaLidarr(r.Context(), id, admin.ID) + if err != nil { + switch { + case errors.Is(err, lidarrquarantine.ErrLidarrDisabled): + writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled") + case errors.Is(err, lidarrquarantine.ErrTrackNotFound): + writeAdminJSONErr(w, http.StatusNotFound, "track_not_found") + case errors.Is(err, lidarrquarantine.ErrAlbumMBIDMissing): + writeAdminJSONErr(w, http.StatusNotFound, "album_mbid_missing") + case errors.Is(err, lidarrquarantine.ErrLidarrAlbumNotFound): + writeAdminJSONErr(w, http.StatusBadGateway, "lidarr_album_lookup_failed") + case errors.Is(err, lidarr.ErrUnreachable): + writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unreachable") + case errors.Is(err, lidarr.ErrAuthFailed): + writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_auth_failed") + default: + h.logger.Error("admin: delete via lidarr", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") + } + return + } + writeJSON(w, http.StatusOK, actionResultView{ + ActionID: uuidToString(action.ID), + AffectedUsers: action.AffectedUsers, + DeletedTrackCount: &deleted, + }) +} + +// actionLogView is the row shape returned by GET /api/admin/quarantine/actions. +type actionLogView struct { + ID string `json:"id"` + TrackID string `json:"track_id"` + TrackTitle string `json:"track_title"` + ArtistName string `json:"artist_name"` + AlbumTitle *string `json:"album_title,omitempty"` + Action string `json:"action"` + AdminID *string `json:"admin_id,omitempty"` + LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"` + AffectedUsers int32 `json:"affected_users"` + CreatedAt string `json:"created_at"` +} + +// handleListQuarantineActions implements GET /api/admin/quarantine/actions. +// ?limit= defaults to 50, capped at 200. +func (h *handlers) handleListQuarantineActions(w http.ResponseWriter, r *http.Request) { + limitStr := r.URL.Query().Get("limit") + limit := int32(50) + if limitStr != "" { + if v, err := strconv.Atoi(limitStr); err == nil && v > 0 { + if v > 200 { + v = 200 + } + limit = int32(v) + } + } + rows, err := dbq.New(h.pool).ListQuarantineActions(r.Context(), limit) + if err != nil { + h.logger.Error("admin: list actions", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") + return + } + out := make([]actionLogView, 0, len(rows)) + for _, row := range rows { + var adminID *string + if row.AdminID.Valid { + s := uuidToString(row.AdminID) + adminID = &s + } + out = append(out, actionLogView{ + ID: uuidToString(row.ID), + TrackID: uuidToString(row.TrackID), + TrackTitle: row.TrackTitle, + ArtistName: row.ArtistName, + AlbumTitle: row.AlbumTitle, + Action: string(row.Action), + AdminID: adminID, + LidarrAlbumMBID: row.LidarrAlbumMbid, + AffectedUsers: row.AffectedUsers, + CreatedAt: formatTimestamp(row.CreatedAt), + }) + } + writeJSON(w, http.StatusOK, out) +} diff --git a/internal/api/admin_quarantine_test.go b/internal/api/admin_quarantine_test.go new file mode 100644 index 00000000..d45e536e --- /dev/null +++ b/internal/api/admin_quarantine_test.go @@ -0,0 +1,429 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" +) + +// newAdminQuarantineRouter builds a test chi router with the five admin +// quarantine endpoints. RequireAdmin middleware is applied; tests inject +// the user into context manually (RequireUser is bypassed). +func newAdminQuarantineRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Route("/api/admin", func(admin chi.Router) { + admin.Use(auth.RequireAdmin()) + admin.Get("/quarantine", h.handleListAdminQuarantine) + admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine) + admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile) + admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr) + admin.Get("/quarantine/actions", h.handleListQuarantineActions) + }) + return r +} + +// doAdminQuarantineReq fires an HTTP request against the admin quarantine +// router with the given user in context. +func doAdminQuarantineReq(t *testing.T, h *handlers, method, path string, body []byte, user dbq.User) *httptest.ResponseRecorder { + t.Helper() + var buf *bytes.Buffer + if body != nil { + buf = bytes.NewBuffer(body) + } else { + buf = bytes.NewBuffer(nil) + } + req := httptest.NewRequest(method, path, buf) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user)) + w := httptest.NewRecorder() + newAdminQuarantineRouter(h).ServeHTTP(w, req) + return w +} + +// installQuarantineClientFn rewires h.lidarrQuarantine to a fresh Service +// with a real clientFn pointing at the (current) saved lidarr_config. This +// mirrors testHandlersWithClientFn for the requests test file. +func installQuarantineClientFn(t *testing.T, h *handlers) { + t.Helper() + cfg := lidarrconfig.New(h.pool) + clientFn := func() *lidarr.Client { + c, err := cfg.Get(context.Background()) + if err != nil || !c.Enabled || c.BaseURL == "" { + return nil + } + return lidarr.NewClient(c.BaseURL, c.APIKey) + } + h.lidarrQuarantine = lidarrquarantine.NewService(h.pool, cfg, clientFn) +} + +// flagDirect bypasses the HTTP handler to seed a quarantine row via the +// Service for setup. Used to construct admin queue scenarios. +func flagDirect(t *testing.T, h *handlers, userID, trackID pgtype.UUID, reason string) { + t.Helper() + if _, err := h.lidarrQuarantine.Flag(context.Background(), userID, trackID, reason, ""); err != nil { + t.Fatalf("flagDirect: %v", err) + } +} + +// seedTrackOnDisk creates a real file under a temp dir and an artist+album+ +// track row pointing at it. Used for delete-file tests that need the file +// to exist before the handler removes it. +func seedTrackOnDisk(t *testing.T, h *handlers, unique string) (dbq.Track, string) { + t.Helper() + artist := seedArtist(t, h.pool, "AdmQ Artist "+unique) + album := seedAlbum(t, h.pool, artist.ID, "AdmQ Album "+unique, 0) + dir := t.TempDir() + path := filepath.Join(dir, "track-"+unique+".mp3") + if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + one := int32(1) + tr, err := dbq.New(h.pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "AdmQ Track " + unique, + AlbumID: album.ID, + ArtistID: artist.ID, + TrackNumber: &one, + DurationMs: 30000, + FilePath: path, + FileSize: 5, + FileFormat: "mp3", + }) + if err != nil { + t.Fatalf("UpsertTrack: %v", err) + } + return tr, path +} + +// seedTrackWithAlbumMBID seeds an artist+album-with-mbid+track. The mbid is +// required for delete-via-lidarr scenarios. +func seedTrackWithAlbumMBID(t *testing.T, h *handlers, unique, albumMBID string) (dbq.Track, string) { + t.Helper() + artist := seedArtist(t, h.pool, "DvL Artist "+unique) + dir := t.TempDir() + path := filepath.Join(dir, "track-"+unique+".mp3") + if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + mbid := albumMBID + album, err := dbq.New(h.pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "DvL Album " + unique, SortTitle: "DvL Album " + unique, + ArtistID: artist.ID, Mbid: &mbid, + }) + if err != nil { + t.Fatalf("UpsertAlbum: %v", err) + } + one := int32(1) + tr, err := dbq.New(h.pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "DvL Track " + unique, + AlbumID: album.ID, + ArtistID: artist.ID, + TrackNumber: &one, + DurationMs: 30000, + FilePath: path, + FileSize: 5, + FileFormat: "mp3", + }) + if err != nil { + t.Fatalf("UpsertTrack: %v", err) + } + return tr, path +} + +// TestListAdminQuarantine_AggregatedShape seeds 3 users flagging the same +// track with mixed reasons. Verifies one aggregated row with report_count=3, +// reason_counts populated, and a Reports list of length 3. +func TestListAdminQuarantine_AggregatedShape(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + resetLidarrState(t, h) + installQuarantineClientFn(t, h) + + alice := seedUser(t, pool, "alice", "pw", false) + bob := seedUser(t, pool, "bob", "pw", false) + carol := seedUser(t, pool, "carol", "pw", false) + admin := seedUser(t, pool, "admin", "pw", true) + + track, _ := seedTrackOnDisk(t, h, "agg") + flagDirect(t, h, alice.ID, track.ID, "bad_rip") + flagDirect(t, h, bob.ID, track.ID, "bad_rip") + flagDirect(t, h, carol.ID, track.ID, "wrong_tags") + + w := doAdminQuarantineReq(t, h, http.MethodGet, "/api/admin/quarantine", nil, admin) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var rows []adminQueueRowView + if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if len(rows) != 1 { + t.Fatalf("len = %d, want 1 aggregated row", len(rows)) + } + r := rows[0] + if r.ReportCount != 3 { + t.Errorf("report_count = %d, want 3", r.ReportCount) + } + if r.ReasonCounts["bad_rip"] != 2 || r.ReasonCounts["wrong_tags"] != 1 { + t.Errorf("reason_counts = %+v", r.ReasonCounts) + } + if len(r.Reports) != 3 { + t.Errorf("reports len = %d, want 3", len(r.Reports)) + } +} + +// TestResolveQuarantine_HappyPath seeds two flags, resolves, verifies 200, +// and confirms an audit row was written. +func TestResolveQuarantine_HappyPath(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + resetLidarrState(t, h) + installQuarantineClientFn(t, h) + + alice := seedUser(t, pool, "alice", "pw", false) + bob := seedUser(t, pool, "bob", "pw", false) + admin := seedUser(t, pool, "admin", "pw", true) + + track, _ := seedTrackOnDisk(t, h, "resolve") + flagDirect(t, h, alice.ID, track.ID, "bad_rip") + flagDirect(t, h, bob.ID, track.ID, "wrong_tags") + + w := doAdminQuarantineReq(t, h, http.MethodPost, + "/api/admin/quarantine/"+uuidToString(track.ID)+"/resolve", nil, admin) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var got actionResultView + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if got.AffectedUsers != 2 { + t.Errorf("affected_users = %d, want 2", got.AffectedUsers) + } + if got.ActionID == "" { + t.Error("action_id empty") + } + + // Audit row must exist with action=resolved. + actions, err := dbq.New(pool).ListQuarantineActions(context.Background(), 50) + if err != nil { + t.Fatalf("list actions: %v", err) + } + if len(actions) != 1 { + t.Fatalf("audit rows = %d, want 1", len(actions)) + } + if actions[0].Action != dbq.LidarrQuarantineActionKindResolved { + t.Errorf("audit action = %v, want resolved", actions[0].Action) + } +} + +// TestDeleteQuarantineFile_HappyPath seeds a flag and verifies POST +// /delete-file removes the file and the track row. +func TestDeleteQuarantineFile_HappyPath(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + resetLidarrState(t, h) + installQuarantineClientFn(t, h) + + alice := seedUser(t, pool, "alice", "pw", false) + admin := seedUser(t, pool, "admin", "pw", true) + + track, path := seedTrackOnDisk(t, h, "delfile") + flagDirect(t, h, alice.ID, track.ID, "bad_rip") + + w := doAdminQuarantineReq(t, h, http.MethodPost, + "/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-file", nil, admin) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var got actionResultView + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if got.AffectedUsers != 1 { + t.Errorf("affected_users = %d, want 1", got.AffectedUsers) + } + + // File and track row should be gone. + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("file still exists: %v", err) + } + if _, err := dbq.New(pool).GetTrackByID(context.Background(), track.ID); err == nil { + t.Error("track row still exists") + } +} + +// TestDeleteQuarantineViaLidarr_HappyPath uses a stub Lidarr that returns +// a single album for the lookup and 200 for the delete. Verifies the +// audit row is written and the local track row is deleted. +func TestDeleteQuarantineViaLidarr_HappyPath(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + resetLidarrState(t, h) + installQuarantineClientFn(t, h) + + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/v1/album" && r.Method == http.MethodGet { + _, _ = w.Write([]byte(`[{"id":42,"foreignAlbumId":"al-mbid-dvl","title":"Al","artistId":7}]`)) + return + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(stub.Close) + saveLidarrConfig(t, h, stub.URL, true) + installQuarantineClientFn(t, h) // re-install after config save so clientFn picks it up + + alice := seedUser(t, pool, "alice", "pw", false) + admin := seedUser(t, pool, "admin", "pw", true) + + track, _ := seedTrackWithAlbumMBID(t, h, "dvl-happy", "al-mbid-dvl") + flagDirect(t, h, alice.ID, track.ID, "bad_rip") + + w := doAdminQuarantineReq(t, h, http.MethodPost, + "/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var got actionResultView + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if got.AffectedUsers != 1 { + t.Errorf("affected_users = %d, want 1", got.AffectedUsers) + } + if got.DeletedTrackCount == nil || *got.DeletedTrackCount != 1 { + t.Errorf("deleted_track_count = %v, want 1", got.DeletedTrackCount) + } + + // Track row should be gone. + if _, err := dbq.New(pool).GetTrackByID(context.Background(), track.ID); err == nil { + t.Error("track row still exists after delete-via-lidarr") + } + // Audit row written with the lidarr_album_mbid. + actions, err := dbq.New(pool).ListQuarantineActions(context.Background(), 50) + if err != nil { + t.Fatalf("list actions: %v", err) + } + if len(actions) != 1 { + t.Fatalf("audit rows = %d, want 1", len(actions)) + } + if actions[0].Action != dbq.LidarrQuarantineActionKindDeletedViaLidarr { + t.Errorf("audit action = %v, want deleted_via_lidarr", actions[0].Action) + } + if actions[0].LidarrAlbumMbid == nil || *actions[0].LidarrAlbumMbid != "al-mbid-dvl" { + t.Errorf("audit lidarr_album_mbid = %v", actions[0].LidarrAlbumMbid) + } +} + +// TestDeleteQuarantineViaLidarr_LidarrDisabled verifies 503 when Lidarr is +// not configured. +func TestDeleteQuarantineViaLidarr_LidarrDisabled(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + resetLidarrState(t, h) + installQuarantineClientFn(t, h) + + alice := seedUser(t, pool, "alice", "pw", false) + admin := seedUser(t, pool, "admin", "pw", true) + + track, _ := seedTrackWithAlbumMBID(t, h, "dvl-disabled", "al-mbid-disabled") + flagDirect(t, h, alice.ID, track.ID, "bad_rip") + + // Lidarr config is reset (disabled). + w := doAdminQuarantineReq(t, h, http.MethodPost, + "/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503; body = %s", w.Code, w.Body.String()) + } + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if resp["error"] != "lidarr_disabled" { + t.Errorf("error = %q, want lidarr_disabled", resp["error"]) + } +} + +// TestDeleteQuarantineViaLidarr_AlbumMBIDMissing verifies 404 album_mbid_missing +// when the parent album has no mbid. +func TestDeleteQuarantineViaLidarr_AlbumMBIDMissing(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + resetLidarrState(t, h) + installQuarantineClientFn(t, h) + + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(stub.Close) + saveLidarrConfig(t, h, stub.URL, true) + installQuarantineClientFn(t, h) + + alice := seedUser(t, pool, "alice", "pw", false) + admin := seedUser(t, pool, "admin", "pw", true) + + // Track whose album has NO mbid. + track, _ := seedTrackOnDisk(t, h, "no-mbid") + flagDirect(t, h, alice.ID, track.ID, "bad_rip") + + w := doAdminQuarantineReq(t, h, http.MethodPost, + "/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin) + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String()) + } + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if resp["error"] != "album_mbid_missing" { + t.Errorf("error = %q, want album_mbid_missing", resp["error"]) + } +} + +// TestQuarantineAdminEndpointsRequire403ForNonAdmin verifies all five admin +// endpoints reject non-admin callers with 403. +func TestQuarantineAdminEndpointsRequire403ForNonAdmin(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + resetLidarrState(t, h) + installQuarantineClientFn(t, h) + + nonAdmin := seedUser(t, pool, "regular", "pw", false) + fakeID := "00000000-0000-0000-0000-000000000001" + + cases := []struct { + method string + path string + }{ + {http.MethodGet, "/api/admin/quarantine"}, + {http.MethodPost, "/api/admin/quarantine/" + fakeID + "/resolve"}, + {http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-file"}, + {http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-via-lidarr"}, + {http.MethodGet, "/api/admin/quarantine/actions"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + w := doAdminQuarantineReq(t, h, tc.method, tc.path, nil, nonAdmin) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String()) + } + }) + } +} diff --git a/internal/api/api.go b/internal/api/api.go index e654fa2f..0ef8d086 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -14,6 +14,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" ) @@ -21,9 +22,15 @@ import ( // Mount attaches /api/* handlers to r. Public endpoints (login) are outside // RequireUser; everything else is gated by the middleware. The events writer // is shared with the Subsonic mount so /rest/scrobble feeds the same store. -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service) { +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service) { rng := rand.New(rand.NewSource(rand.Int63())) - h := &handlers{pool: pool, logger: logger, events: events, recCfg: recCfg, rng: rng.Float64, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs} + h := &handlers{ + pool: pool, logger: logger, events: events, recCfg: recCfg, + rng: rng.Float64, + lidarrCfg: lidarrCfg, + lidarrRequests: lidarrReqs, + lidarrQuarantine: lidarrQuar, + } r.Route("/api", func(api chi.Router) { api.Post("/auth/login", h.handleLogin) @@ -61,6 +68,10 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Get("/requests/{id}", h.handleGetRequest) authed.Delete("/requests/{id}", h.handleCancelRequest) + authed.Post("/quarantine", h.handleFlag) + authed.Delete("/quarantine/{track_id}", h.handleUnflag) + authed.Get("/quarantine/mine", h.handleListMyQuarantine) + authed.Route("/admin", func(admin chi.Router) { admin.Use(auth.RequireAdmin()) admin.Get("/lidarr/config", h.handleGetLidarrConfig) @@ -71,17 +82,24 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Get("/requests", h.handleListAdminRequests) admin.Post("/requests/{id}/approve", h.handleApproveRequest) admin.Post("/requests/{id}/reject", h.handleRejectRequest) + + admin.Get("/quarantine", h.handleListAdminQuarantine) + admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine) + admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile) + admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr) + admin.Get("/quarantine/actions", h.handleListQuarantineActions) }) }) }) } type handlers struct { - pool *pgxpool.Pool - logger *slog.Logger - events *playevents.Writer - recCfg config.RecommendationConfig - rng func() float64 - lidarrCfg *lidarrconfig.Service - lidarrRequests *lidarrrequests.Service + pool *pgxpool.Pool + logger *slog.Logger + events *playevents.Writer + recCfg config.RecommendationConfig + rng func() float64 + lidarrCfg *lidarrconfig.Service + lidarrRequests *lidarrrequests.Service + lidarrQuarantine *lidarrquarantine.Service } diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index b2644ea8..b38a8f72 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -23,6 +23,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" ) @@ -58,7 +59,8 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { } lidarrCfg := lidarrconfig.New(pool) lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil) - h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs} + lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil) + h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar} return h, pool } diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 63f01f2b..80e6e7ac 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -441,7 +441,7 @@ func TestRoutesRegisteredInMount(t *testing.T) { r := chi.NewRouter() w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), 30*time.Minute, 0.5, 30000) - Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests) + Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine) paths := []string{ "/api/artists", diff --git a/internal/api/quarantine.go b/internal/api/quarantine.go new file mode 100644 index 00000000..d3a61188 --- /dev/null +++ b/internal/api/quarantine.go @@ -0,0 +1,148 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" +) + +// quarantineView is the JSON shape returned by the user-facing endpoints +// that operate on a single quarantine row (POST /api/quarantine). +type quarantineView struct { + UserID string `json:"user_id"` + TrackID string `json:"track_id"` + Reason string `json:"reason"` + Notes *string `json:"notes,omitempty"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func quarantineViewFrom(row dbq.LidarrQuarantine) quarantineView { + return quarantineView{ + UserID: uuidToString(row.UserID), + TrackID: uuidToString(row.TrackID), + Reason: string(row.Reason), + Notes: row.Notes, + CreatedAt: row.CreatedAt, + } +} + +// flagBody is the JSON body for POST /api/quarantine. track_id is sent as +// the canonical hyphenated string the SPA already gets from /api/tracks/*. +type flagBody struct { + TrackID string `json:"track_id"` + Reason string `json:"reason"` + Notes string `json:"notes"` +} + +// handleFlag implements POST /api/quarantine. Upserts via Service.Flag. +func (h *handlers) handleFlag(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + var body flagBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") + return + } + trackID, ok := parseUUID(body.TrackID) + if !ok { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id") + return + } + row, err := h.lidarrQuarantine.Flag(r.Context(), user.ID, trackID, body.Reason, body.Notes) + if err != nil { + switch { + case errors.Is(err, lidarrquarantine.ErrBadReason): + writeErr(w, http.StatusBadRequest, "bad_reason", "invalid reason") + case errors.Is(err, lidarrquarantine.ErrTrackNotFound): + writeErr(w, http.StatusNotFound, "track_not_found", "track does not exist") + default: + h.logger.Error("api: flag", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "flag failed") + } + return + } + writeJSON(w, http.StatusCreated, quarantineViewFrom(row)) +} + +// handleUnflag implements DELETE /api/quarantine/{track_id}. +func (h *handlers) handleUnflag(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + id, ok := parseUUID(chi.URLParam(r, "track_id")) + if !ok { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id") + return + } + if err := h.lidarrQuarantine.Unflag(r.Context(), user.ID, id); err != nil { + if errors.Is(err, lidarrquarantine.ErrQuarantineNotFound) { + writeErr(w, http.StatusNotFound, "quarantine_not_found", "no quarantine for that track") + return + } + h.logger.Error("api: unflag", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "unflag failed") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// quarantineMineView is the per-row shape returned by GET /api/quarantine/mine, +// composed from the joined ListQuarantineForUserRow. +type quarantineMineView struct { + TrackID string `json:"track_id"` + Reason string `json:"reason"` + Notes *string `json:"notes,omitempty"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + TrackTitle string `json:"track_title"` + TrackDurationMs int32 `json:"track_duration_ms"` + AlbumID string `json:"album_id"` + AlbumTitle string `json:"album_title"` + AlbumCoverArtPath *string `json:"album_cover_art_path,omitempty"` + ArtistID string `json:"artist_id"` + ArtistName string `json:"artist_name"` +} + +// handleListMyQuarantine implements GET /api/quarantine/mine. Returns the +// caller's quarantines with track/album/artist detail. +func (h *handlers) handleListMyQuarantine(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + rows, err := h.lidarrQuarantine.ListMine(r.Context(), user.ID) + if err != nil { + h.logger.Error("api: list mine", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "list failed") + return + } + out := make([]quarantineMineView, 0, len(rows)) + for _, row := range rows { + out = append(out, quarantineMineView{ + TrackID: uuidToString(row.LidarrQuarantine.TrackID), + Reason: string(row.LidarrQuarantine.Reason), + Notes: row.LidarrQuarantine.Notes, + CreatedAt: row.LidarrQuarantine.CreatedAt, + TrackTitle: row.TrackTitle, + TrackDurationMs: row.TrackDurationMs, + AlbumID: uuidToString(row.AlbumID), + AlbumTitle: row.AlbumTitle, + AlbumCoverArtPath: row.AlbumCoverArtPath, + ArtistID: uuidToString(row.ArtistID), + ArtistName: row.ArtistName, + }) + } + writeJSON(w, http.StatusOK, out) +} diff --git a/internal/api/quarantine_test.go b/internal/api/quarantine_test.go new file mode 100644 index 00000000..83a425e6 --- /dev/null +++ b/internal/api/quarantine_test.go @@ -0,0 +1,305 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// newQuarantineRouter builds a test router for the user-facing /api/quarantine +// endpoints. Tests inject the user into context manually (RequireUser is +// applied in real Mount but skipped here so unit tests can target the handler +// path directly). +func newQuarantineRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Post("/api/quarantine", h.handleFlag) + r.Delete("/api/quarantine/{track_id}", h.handleUnflag) + r.Get("/api/quarantine/mine", h.handleListMyQuarantine) + return r +} + +// doFlag fires POST /api/quarantine with body as the given user. +func doFlag(h *handlers, user dbq.User, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/quarantine", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req = withUser(req, user) + w := httptest.NewRecorder() + newQuarantineRouter(h).ServeHTTP(w, req) + return w +} + +// doUnflag fires DELETE /api/quarantine/{track_id} as the given user. +func doUnflag(h *handlers, user dbq.User, trackID string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodDelete, "/api/quarantine/"+trackID, nil) + req = withUser(req, user) + w := httptest.NewRecorder() + newQuarantineRouter(h).ServeHTTP(w, req) + return w +} + +// doListMine fires GET /api/quarantine/mine as the given user. +func doListMine(h *handlers, user dbq.User) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/api/quarantine/mine", nil) + req = withUser(req, user) + w := httptest.NewRecorder() + newQuarantineRouter(h).ServeHTTP(w, req) + return w +} + +// seedQuarantineTrack creates a minimal artist+album+track for quarantine +// tests. Uniqueness is forced by appending unique into title fields. +func seedQuarantineTrack(t *testing.T, h *handlers, unique string) dbq.Track { + t.Helper() + artist := seedArtist(t, h.pool, "Q Artist "+unique) + album := seedAlbum(t, h.pool, artist.ID, "Q Album "+unique, 0) + return seedTrack(t, h.pool, album.ID, artist.ID, "Q Track "+unique, 1, 30000) +} + +// TestFlag_HappyPath verifies POST /api/quarantine returns 201 and the row +// shows up in ListMine. +func TestFlag_HappyPath(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + + alice := seedUser(t, pool, "alice", "pw", false) + track := seedQuarantineTrack(t, h, "happy") + + body := fmt.Sprintf(`{"track_id":%q,"reason":"bad_rip","notes":"crackly"}`, uuidToString(track.ID)) + w := doFlag(h, alice, body) + + if w.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body = %s", w.Code, w.Body.String()) + } + var got quarantineView + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if got.Reason != "bad_rip" { + t.Errorf("reason = %q, want bad_rip", got.Reason) + } + if got.Notes == nil || *got.Notes != "crackly" { + t.Errorf("notes = %v, want 'crackly'", got.Notes) + } + if got.TrackID != uuidToString(track.ID) { + t.Errorf("track_id = %q, want %q", got.TrackID, uuidToString(track.ID)) + } + + // Verify it shows up in ListMine. + wm := doListMine(h, alice) + if wm.Code != http.StatusOK { + t.Fatalf("ListMine status = %d, body = %s", wm.Code, wm.Body.String()) + } + var rows []quarantineMineView + if err := json.Unmarshal(wm.Body.Bytes(), &rows); err != nil { + t.Fatalf("decode mine: %v; body = %s", err, wm.Body.String()) + } + if len(rows) != 1 { + t.Fatalf("mine len = %d, want 1", len(rows)) + } + if rows[0].TrackID != uuidToString(track.ID) { + t.Errorf("mine track_id = %q, want %q", rows[0].TrackID, uuidToString(track.ID)) + } + if rows[0].TrackTitle != track.Title { + t.Errorf("mine track_title = %q, want %q", rows[0].TrackTitle, track.Title) + } +} + +// TestFlag_BadReason verifies an unknown reason returns 400 bad_reason. +func TestFlag_BadReason(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + + alice := seedUser(t, pool, "alice", "pw", false) + track := seedQuarantineTrack(t, h, "badreason") + + body := fmt.Sprintf(`{"track_id":%q,"reason":"garbage"}`, uuidToString(track.ID)) + w := doFlag(h, alice, body) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String()) + } + var resp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if resp.Error.Code != "bad_reason" { + t.Errorf("error.code = %q, want bad_reason", resp.Error.Code) + } +} + +// TestFlag_TrackNotFound verifies a nonexistent track id returns 404. +func TestFlag_TrackNotFound(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + + alice := seedUser(t, pool, "alice", "pw", false) + + // Made-up but well-formed UUID — track won't exist in the DB. + body := `{"track_id":"00000000-0000-0000-0000-000000000099","reason":"bad_rip"}` + w := doFlag(h, alice, body) + + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String()) + } + var resp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if resp.Error.Code != "track_not_found" { + t.Errorf("error.code = %q, want track_not_found", resp.Error.Code) + } +} + +// TestUnflag_HappyPath verifies DELETE returns 204 after a flag. +func TestUnflag_HappyPath(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + + alice := seedUser(t, pool, "alice", "pw", false) + track := seedQuarantineTrack(t, h, "unflag") + + body := fmt.Sprintf(`{"track_id":%q,"reason":"bad_rip"}`, uuidToString(track.ID)) + if w := doFlag(h, alice, body); w.Code != http.StatusCreated { + t.Fatalf("flag failed: %d, body = %s", w.Code, w.Body.String()) + } + + w := doUnflag(h, alice, uuidToString(track.ID)) + if w.Code != http.StatusNoContent { + t.Fatalf("unflag status = %d, want 204; body = %s", w.Code, w.Body.String()) + } + + // Confirm it's gone. + mine := doListMine(h, alice) + var rows []quarantineMineView + _ = json.Unmarshal(mine.Body.Bytes(), &rows) + if len(rows) != 0 { + t.Errorf("after unflag, mine len = %d, want 0", len(rows)) + } +} + +// TestUnflag_NotFound verifies DELETE on an un-flagged track returns 404 +// quarantine_not_found. +func TestUnflag_NotFound(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + + alice := seedUser(t, pool, "alice", "pw", false) + track := seedQuarantineTrack(t, h, "unflagnf") + + w := doUnflag(h, alice, uuidToString(track.ID)) + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String()) + } + var resp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if resp.Error.Code != "quarantine_not_found" { + t.Errorf("error.code = %q, want quarantine_not_found", resp.Error.Code) + } +} + +// TestListMine_OK seeds two flags on alice and one on bob, verifies alice +// only sees her own. +func TestListMine_OK(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + + alice := seedUser(t, pool, "alice", "pw", false) + bob := seedUser(t, pool, "bob", "pw", false) + + t1 := seedQuarantineTrack(t, h, "lm-1") + t2 := seedQuarantineTrack(t, h, "lm-2") + t3 := seedQuarantineTrack(t, h, "lm-3") + + for _, body := range []string{ + fmt.Sprintf(`{"track_id":%q,"reason":"bad_rip"}`, uuidToString(t1.ID)), + fmt.Sprintf(`{"track_id":%q,"reason":"wrong_tags"}`, uuidToString(t2.ID)), + } { + if w := doFlag(h, alice, body); w.Code != http.StatusCreated { + t.Fatalf("alice flag: %d, body = %s", w.Code, w.Body.String()) + } + } + bobBody := fmt.Sprintf(`{"track_id":%q,"reason":"duplicate"}`, uuidToString(t3.ID)) + if w := doFlag(h, bob, bobBody); w.Code != http.StatusCreated { + t.Fatalf("bob flag: %d, body = %s", w.Code, w.Body.String()) + } + + w := doListMine(h, alice) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var rows []quarantineMineView + if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if len(rows) != 2 { + t.Fatalf("len = %d, want 2 (alice's only)", len(rows)) + } + for _, r := range rows { + if r.TrackID == uuidToString(t3.ID) { + t.Errorf("alice can see bob's flag") + } + } +} + +// TestQuarantineEndpointsRequireAuth verifies the /api/quarantine endpoints +// return 401 when no user is in context (covers RequireUser failure mode +// uniformly across the three handlers). +func TestQuarantineEndpointsRequireAuth(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + + cases := []struct { + name string + method string + path string + body string + }{ + {"flag", http.MethodPost, "/api/quarantine", `{"track_id":"00000000-0000-0000-0000-000000000001","reason":"bad_rip"}`}, + {"unflag", http.MethodDelete, "/api/quarantine/00000000-0000-0000-0000-000000000001", ""}, + {"mine", http.MethodGet, "/api/quarantine/mine", ""}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + var reqBody *bytes.Buffer + if tc.body != "" { + reqBody = bytes.NewBufferString(tc.body) + } else { + reqBody = bytes.NewBuffer(nil) + } + req := httptest.NewRequest(tc.method, tc.path, reqBody) + if tc.body != "" { + req.Header.Set("Content-Type", "application/json") + } + // No user injected into context. + req = req.WithContext(context.Background()) + w := httptest.NewRecorder() + newQuarantineRouter(h).ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Errorf("%s: status = %d, want 401; body = %s", tc.name, w.Code, w.Body.String()) + } + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 77c87add..f491cbe7 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -18,6 +18,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/library" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic" @@ -59,7 +60,8 @@ func (s *Server) Router() http.Handler { ) lidarrCfg := lidarrconfig.New(s.Pool) lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, nil, nil) - api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs) + lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, nil) + api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar) r.Route("/api/admin", func(admin chi.Router) { admin.Use(auth.RequireUser(s.Pool)) admin.Use(auth.RequireAdmin()) From b7f813650296d2c426b84a7a9f46b63e6b8abf47 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 20:23:07 -0400 Subject: [PATCH 51/67] fix(server): wire real Lidarr clientFn for requests + quarantine services Both lidarrrequests.Approve and lidarrquarantine.DeleteViaLidarr need a working client factory; the previous nil clientFn made them always return ErrLidarrDisabled even when /admin/integrations had a valid Lidarr config saved. Per-call factory re-reads config so admin saves take effect without restart, matching the Service contract. --- internal/server/server.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index f491cbe7..a55eba19 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -17,6 +17,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/library" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" @@ -59,8 +60,19 @@ func (s *Server) Router() http.Handler { s.EventsCfg.SkipMaxDurationPlayedMs, ) lidarrCfg := lidarrconfig.New(s.Pool) - lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, nil, nil) - lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, nil) + // Per-call client factory: re-reads config so an admin save in + // /admin/integrations takes effect immediately without restart. + // Returns nil when Lidarr is disabled, which the Service-layer + // methods translate to ErrLidarrDisabled. + lidarrClientFn := func() *lidarr.Client { + cfg, err := lidarrCfg.Get(context.Background()) + if err != nil || !cfg.Enabled || cfg.BaseURL == "" || cfg.APIKey == "" { + return nil + } + return lidarr.NewClient(cfg.BaseURL, cfg.APIKey) + } + lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, lidarrClientFn, nil) + lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn) api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar) r.Route("/api/admin", func(admin chi.Router) { admin.Use(auth.RequireUser(s.Pool)) From b653e834784415588ae4520ead68ab936e6ca19b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 20:26:07 -0400 Subject: [PATCH 52/67] feat(web): API client modules for quarantine + admin quarantine --- web/src/lib/api/admin.test.ts | 119 ++++++++++++++++++++++++++++- web/src/lib/api/admin.ts | 45 +++++++++++ web/src/lib/api/quarantine.test.ts | 116 ++++++++++++++++++++++++++++ web/src/lib/api/quarantine.ts | 37 +++++++++ web/src/lib/api/queries.ts | 4 + web/src/lib/api/types.ts | 75 ++++++++++++++++++ 6 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/api/quarantine.test.ts create mode 100644 web/src/lib/api/quarantine.ts diff --git a/web/src/lib/api/admin.test.ts b/web/src/lib/api/admin.test.ts index 5ccd6329..dcbed704 100644 --- a/web/src/lib/api/admin.test.ts +++ b/web/src/lib/api/admin.test.ts @@ -17,13 +17,21 @@ import { listRootFolders, listAdminRequests, approveRequest, - rejectRequest + rejectRequest, + listAdminQuarantine, + resolveQuarantine, + deleteQuarantineFile, + deleteQuarantineViaLidarr, + listQuarantineActions } from './admin'; import { api } from './client'; import { qk } from './queries'; import type { + ActionResult, + AdminQuarantineRow, LidarrConfig, LidarrQualityProfile, + LidarrQuarantineActionRow, LidarrRequest, LidarrRootFolder } from './types'; @@ -228,3 +236,112 @@ describe('qk admin keys', () => { ]); }); }); + +const baseQuarantineRow: AdminQuarantineRow = { + track_id: 't1', + track_title: 'Avril 14th', + artist_name: 'Aphex Twin', + album_title: 'Drukqs', + album_id: 'alb1', + lidarr_album_mbid: null, + report_count: 1, + latest_at: '2026-01-01T00:00:00Z', + reason_counts: { bad_rip: 1 }, + reports: [] +}; + +const baseAction: ActionResult = { + action_id: 'a1', + affected_users: 1 +}; + +const baseActionRow: LidarrQuarantineActionRow = { + id: 'a1', + track_id: 't1', + track_title: 'Avril 14th', + artist_name: 'Aphex Twin', + album_title: 'Drukqs', + action: 'resolved', + admin_id: 'admin1', + lidarr_album_mbid: null, + affected_users: 1, + created_at: '2026-01-01T00:00:00Z' +}; + +describe('listAdminQuarantine', () => { + test('GETs /api/admin/quarantine', async () => { + (api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce([baseQuarantineRow]); + const out = await listAdminQuarantine(); + expect(api.get).toHaveBeenCalledWith('/api/admin/quarantine'); + expect(out).toEqual([baseQuarantineRow]); + }); +}); + +describe('resolveQuarantine', () => { + test('POSTs /api/admin/quarantine/:trackID/resolve with empty body', async () => { + (api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseAction); + const out = await resolveQuarantine('t1'); + expect(api.post).toHaveBeenCalledWith('/api/admin/quarantine/t1/resolve', {}); + expect(out).toBe(baseAction); + }); +}); + +describe('deleteQuarantineFile', () => { + test('POSTs /api/admin/quarantine/:trackID/delete-file with empty body', async () => { + (api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseAction); + const out = await deleteQuarantineFile('t1'); + expect(api.post).toHaveBeenCalledWith( + '/api/admin/quarantine/t1/delete-file', + {} + ); + expect(out).toBe(baseAction); + }); +}); + +describe('deleteQuarantineViaLidarr', () => { + test('POSTs /api/admin/quarantine/:trackID/delete-via-lidarr with empty body', async () => { + (api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseAction); + const out = await deleteQuarantineViaLidarr('t1'); + expect(api.post).toHaveBeenCalledWith( + '/api/admin/quarantine/t1/delete-via-lidarr', + {} + ); + expect(out).toBe(baseAction); + }); +}); + +describe('listQuarantineActions', () => { + test('defaults to limit=50 when no argument', async () => { + (api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce([baseActionRow]); + await listQuarantineActions(); + expect(api.get).toHaveBeenCalledWith( + '/api/admin/quarantine/actions?limit=50' + ); + }); + + test('forwards explicit limit', async () => { + (api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce([baseActionRow]); + await listQuarantineActions(10); + expect(api.get).toHaveBeenCalledWith( + '/api/admin/quarantine/actions?limit=10' + ); + }); +}); + +describe('qk admin quarantine keys', () => { + test('adminQuarantine key', () => { + expect(qk.adminQuarantine()).toEqual(['adminQuarantine']); + }); + test('adminQuarantineActions key defaults to limit 50', () => { + expect(qk.adminQuarantineActions()).toEqual([ + 'adminQuarantineActions', + { limit: 50 } + ]); + }); + test('adminQuarantineActions key reflects custom limit', () => { + expect(qk.adminQuarantineActions(25)).toEqual([ + 'adminQuarantineActions', + { limit: 25 } + ]); + }); +}); diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index 9ec0fc0e..fe6b7d83 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -2,8 +2,11 @@ import { createQuery } from '@tanstack/svelte-query'; import { api } from './client'; import { qk } from './queries'; import type { + ActionResult, + AdminQuarantineRow, LidarrConfig, LidarrQualityProfile, + LidarrQuarantineActionRow, LidarrRequest, LidarrRequestStatus, LidarrRootFolder, @@ -103,3 +106,45 @@ export function createAdminRequestsQuery(status?: LidarrRequestStatus) { queryFn: () => listAdminRequests(status) }); } + +// Admin quarantine -------------------------------------------------------- + +export async function listAdminQuarantine(): Promise<AdminQuarantineRow[]> { + return api.get<AdminQuarantineRow[]>('/api/admin/quarantine'); +} + +export async function resolveQuarantine(trackID: string): Promise<ActionResult> { + return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/resolve`, {}); +} + +export async function deleteQuarantineFile(trackID: string): Promise<ActionResult> { + return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/delete-file`, {}); +} + +export async function deleteQuarantineViaLidarr(trackID: string): Promise<ActionResult> { + return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/delete-via-lidarr`, {}); +} + +export async function listQuarantineActions( + limit: number = 50 +): Promise<LidarrQuarantineActionRow[]> { + return api.get<LidarrQuarantineActionRow[]>( + `/api/admin/quarantine/actions?limit=${limit}` + ); +} + +export function createAdminQuarantineQuery() { + return createQuery({ + queryKey: qk.adminQuarantine(), + queryFn: listAdminQuarantine, + staleTime: 30_000 + }); +} + +export function createQuarantineActionsQuery(limit: number = 50) { + return createQuery({ + queryKey: qk.adminQuarantineActions(limit), + queryFn: () => listQuarantineActions(limit), + staleTime: 60_000 + }); +} diff --git a/web/src/lib/api/quarantine.test.ts b/web/src/lib/api/quarantine.test.ts new file mode 100644 index 00000000..a8843f44 --- /dev/null +++ b/web/src/lib/api/quarantine.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('./client', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + del: vi.fn() + }, + apiFetch: vi.fn() +})); + +import { flagTrack, unflagTrack, listMyQuarantine } from './quarantine'; +import { api, apiFetch } from './client'; +import { qk } from './queries'; +import type { + LidarrQuarantineRow, + LidarrQuarantineMineRow +} from './types'; + +const mockRow: LidarrQuarantineRow = { + user_id: 'u1', + track_id: 't1', + reason: 'bad_rip', + notes: null, + created_at: '2026-01-01T00:00:00Z' +}; + +const mockMineRow: LidarrQuarantineMineRow = { + track_id: 't1', + reason: 'bad_rip', + notes: null, + created_at: '2026-01-01T00:00:00Z', + track_title: 'Avril 14th', + track_duration_ms: 120000, + album_id: 'alb1', + album_title: 'Drukqs', + album_cover_art_path: null, + artist_id: 'art1', + artist_name: 'Aphex Twin' +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('flagTrack', () => { + test('POSTs /api/quarantine without notes when none provided', async () => { + (api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockRow); + const out = await flagTrack({ track_id: 't1', reason: 'bad_rip' }); + expect(api.post).toHaveBeenCalledWith('/api/quarantine', { + track_id: 't1', + reason: 'bad_rip' + }); + const body = (api.post as ReturnType<typeof vi.fn>).mock.calls[0][1] as Record< + string, + unknown + >; + expect(body).not.toHaveProperty('notes'); + expect(out).toBe(mockRow); + }); + + test('omits empty-string notes from the body', async () => { + (api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockRow); + await flagTrack({ track_id: 't1', reason: 'wrong_tags', notes: '' }); + const body = (api.post as ReturnType<typeof vi.fn>).mock.calls[0][1] as Record< + string, + unknown + >; + expect(body).toEqual({ track_id: 't1', reason: 'wrong_tags' }); + expect(body).not.toHaveProperty('notes'); + }); + + test('includes notes when a non-empty string is provided', async () => { + (api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockRow); + await flagTrack({ + track_id: 't1', + reason: 'duplicate', + notes: 'same as t0' + }); + expect(api.post).toHaveBeenCalledWith('/api/quarantine', { + track_id: 't1', + reason: 'duplicate', + notes: 'same as t0' + }); + }); +}); + +describe('unflagTrack', () => { + test('DELETEs /api/quarantine/:trackID', async () => { + (apiFetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null); + await unflagTrack('t1'); + expect(apiFetch).toHaveBeenCalledWith('/api/quarantine/t1', { + method: 'DELETE' + }); + }); +}); + +describe('listMyQuarantine', () => { + test('GETs /api/quarantine/mine', async () => { + (api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce([mockMineRow]); + const out = await listMyQuarantine(); + expect(api.get).toHaveBeenCalledWith('/api/quarantine/mine'); + expect(out).toEqual([mockMineRow]); + }); +}); + +describe('qk.myQuarantine', () => { + test('returns the expected key tuple', () => { + expect(qk.myQuarantine()).toEqual(['myQuarantine']); + }); +}); diff --git a/web/src/lib/api/quarantine.ts b/web/src/lib/api/quarantine.ts new file mode 100644 index 00000000..db14dbec --- /dev/null +++ b/web/src/lib/api/quarantine.ts @@ -0,0 +1,37 @@ +import { createQuery } from '@tanstack/svelte-query'; +import { api, apiFetch } from './client'; +import { qk } from './queries'; +import type { + LidarrQuarantineRow, + LidarrQuarantineMineRow, + LidarrQuarantineReason +} from './types'; + +export type FlagParams = { + track_id: string; + reason: LidarrQuarantineReason; + notes?: string; +}; + +export async function flagTrack(params: FlagParams): Promise<LidarrQuarantineRow> { + const body: FlagParams = { track_id: params.track_id, reason: params.reason }; + if (params.notes && params.notes.length > 0) body.notes = params.notes; + return api.post<LidarrQuarantineRow>('/api/quarantine', body); +} + +// Server returns 204 (no body) for DELETE; apiFetch handles it. +export async function unflagTrack(trackID: string): Promise<void> { + await apiFetch(`/api/quarantine/${trackID}`, { method: 'DELETE' }); +} + +export async function listMyQuarantine(): Promise<LidarrQuarantineMineRow[]> { + return api.get<LidarrQuarantineMineRow[]>('/api/quarantine/mine'); +} + +export function createMyQuarantineQuery() { + return createQuery({ + queryKey: qk.myQuarantine(), + queryFn: listMyQuarantine, + staleTime: 60_000 + }); +} diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 3efeb35c..1d684959 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -28,6 +28,10 @@ export const qk = { lidarrRootFolders: () => ['lidarrRootFolders'] as const, adminRequests: (status?: string) => ['adminRequests', { status: status ?? 'all' }] as const, + myQuarantine: () => ['myQuarantine'] as const, + adminQuarantine: () => ['adminQuarantine'] as const, + adminQuarantineActions: (limit?: number) => + ['adminQuarantineActions', { limit: limit ?? 50 }] as const, }; export function createArtistsQuery(sort: ArtistSort) { diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 0eb5ed43..6b8ce1fc 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -146,3 +146,78 @@ export type LidarrRootFolder = { export type LidarrTestResult = | { ok: true; version: string } | { ok: false; error: string }; + +// Lidarr quarantine ------------------------------------------------------ + +export type LidarrQuarantineReason = + | 'bad_rip' + | 'wrong_file' + | 'wrong_tags' + | 'duplicate' + | 'other'; + +export type LidarrQuarantineRow = { + user_id: string; + track_id: string; + reason: LidarrQuarantineReason; + notes?: string | null; + created_at: string; +}; + +// What GET /api/quarantine/mine returns per row +export type LidarrQuarantineMineRow = { + track_id: string; + reason: LidarrQuarantineReason; + notes?: string | null; + created_at: string; + track_title: string; + track_duration_ms: number; + album_id: string; + album_title: string; + album_cover_art_path?: string | null; + artist_id: string; + artist_name: string; +}; + +// What GET /api/admin/quarantine returns per row +export type AdminQuarantineRow = { + track_id: string; + track_title: string; + artist_name: string; + album_title: string; + album_id: string; + lidarr_album_mbid?: string | null; + report_count: number; + latest_at: string; + reason_counts: Partial<Record<LidarrQuarantineReason, number>>; + reports: AdminQuarantineReport[]; +}; + +export type AdminQuarantineReport = { + user_id: string; + username: string; + reason: LidarrQuarantineReason; + notes?: string | null; + created_at: string; +}; + +export type LidarrQuarantineAction = 'resolved' | 'deleted_file' | 'deleted_via_lidarr'; + +export type LidarrQuarantineActionRow = { + id: string; + track_id: string; + track_title: string; + artist_name: string; + album_title?: string | null; + action: LidarrQuarantineAction; + admin_id?: string | null; + lidarr_album_mbid?: string | null; + affected_users: number; + created_at: string; +}; + +export type ActionResult = { + action_id: string; + affected_users: number; + deleted_track_count?: number; +}; From 8da4a3f9c056e71c7c387fb064b1e8a872aea466 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 20:29:07 -0400 Subject: [PATCH 53/67] feat(web): TrackMenu overflow + FlagPopover for the quarantine flow --- web/src/lib/components/FlagPopover.svelte | 97 ++++++++++++++++++++++ web/src/lib/components/FlagPopover.test.ts | 96 +++++++++++++++++++++ web/src/lib/components/TrackMenu.svelte | 65 +++++++++++++++ web/src/lib/components/TrackMenu.test.ts | 52 ++++++++++++ 4 files changed, 310 insertions(+) create mode 100644 web/src/lib/components/FlagPopover.svelte create mode 100644 web/src/lib/components/FlagPopover.test.ts create mode 100644 web/src/lib/components/TrackMenu.svelte create mode 100644 web/src/lib/components/TrackMenu.test.ts diff --git a/web/src/lib/components/FlagPopover.svelte b/web/src/lib/components/FlagPopover.svelte new file mode 100644 index 00000000..25dbcf75 --- /dev/null +++ b/web/src/lib/components/FlagPopover.svelte @@ -0,0 +1,97 @@ +<script lang="ts"> + import { Flag } from 'lucide-svelte'; + import { useQueryClient } from '@tanstack/svelte-query'; + import { flagTrack } from '$lib/api/quarantine'; + import { qk } from '$lib/api/queries'; + import type { TrackRef, LidarrQuarantineReason } from '$lib/api/types'; + + let { + track, + onClose, + initialReason, + initialNotes + }: { + track: TrackRef; + onClose: () => void; + initialReason?: LidarrQuarantineReason; + initialNotes?: string; + } = $props(); + + let reason: LidarrQuarantineReason = $state(initialReason ?? 'bad_rip'); + let notes = $state(initialNotes ?? ''); + let submitting = $state(false); + let error = $state<string | null>(null); + + const isUpdate = !!initialReason; + const client = useQueryClient(); + + async function submit() { + submitting = true; + error = null; + try { + await flagTrack({ track_id: track.id, reason, notes: notes.trim() || undefined }); + await client.invalidateQueries({ queryKey: qk.myQuarantine() }); + onClose(); + } catch (e) { + error = (e as { code?: string }).code ?? 'flag_failed'; + } finally { + submitting = false; + } + } +</script> + +<div + role="dialog" + aria-modal="false" + aria-labelledby="flag-popover-title" + class="absolute right-0 z-30 mt-1 w-72 rounded-lg border border-border bg-surface p-3 shadow-xl" + onclick={(e) => e.stopPropagation()} +> + <h4 id="flag-popover-title" class="text-sm font-medium text-text-primary"> + Flag this track as broken + </h4> + <label class="mt-2 block"> + <span class="block text-xs text-text-secondary">Reason</span> + <select + bind:value={reason} + class="mt-1 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent" + > + <option value="bad_rip">Bad rip</option> + <option value="wrong_file">Wrong file</option> + <option value="wrong_tags">Wrong tags</option> + <option value="duplicate">Duplicate</option> + <option value="other">Other</option> + </select> + </label> + <label class="mt-2 block"> + <span class="block text-xs text-text-secondary">Notes (optional)</span> + <textarea + bind:value={notes} + maxlength="200" + placeholder="What's wrong with it?" + class="mt-1 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" + rows="2" + ></textarea> + </label> + {#if error} + <p class="mt-2 text-xs text-error">Couldn't save flag — {error}</p> + {/if} + <div class="mt-3 flex justify-end gap-2"> + <button + type="button" + class="rounded-md border border-border px-2.5 py-1 text-sm text-text-secondary hover:text-text-primary" + onclick={onClose} + > + Cancel + </button> + <button + type="button" + onclick={submit} + disabled={submitting} + class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-2.5 py-1 text-sm text-text-primary disabled:opacity-50" + > + <Flag size={14} strokeWidth={1} /> + {isUpdate ? 'Update flag' : 'Flag'} + </button> + </div> +</div> diff --git a/web/src/lib/components/FlagPopover.test.ts b/web/src/lib/components/FlagPopover.test.ts new file mode 100644 index 00000000..0bd19363 --- /dev/null +++ b/web/src/lib/components/FlagPopover.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import FlagPopover from './FlagPopover.svelte'; +import type { TrackRef } from '$lib/api/types'; + +const invalidateMock = vi.fn(); +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record<string, unknown>; + return { + ...actual, + useQueryClient: () => ({ invalidateQueries: invalidateMock }) + }; +}); + +vi.mock('$lib/api/quarantine', () => ({ + flagTrack: vi.fn().mockResolvedValue({ track_id: 't1', reason: 'bad_rip' }) +})); + +import { flagTrack } from '$lib/api/quarantine'; + +const track: TrackRef = { + id: 't1', + title: 'Roygbiv', + album_id: 'a1', + album_title: 'Geogaddi', + artist_id: 'ar1', + artist_name: 'Boards of Canada', + duration_sec: 240, + stream_url: '/api/tracks/t1/stream' +}; + +afterEach(() => vi.clearAllMocks()); + +describe('FlagPopover', () => { + test('default reason is bad_rip; button reads "Flag" when not initialReason', () => { + render(FlagPopover, { props: { track, onClose: vi.fn() } }); + const select = screen.getByRole('combobox') as HTMLSelectElement; + expect(select.value).toBe('bad_rip'); + expect(screen.getByRole('button', { name: /^flag$/i })).toBeInTheDocument(); + }); + + test('pre-fills reason and notes when initial values are provided; button reads "Update flag"', () => { + render(FlagPopover, { + props: { + track, + onClose: vi.fn(), + initialReason: 'wrong_tags', + initialNotes: 'wrong artist' + } + }); + const select = screen.getByRole('combobox') as HTMLSelectElement; + expect(select.value).toBe('wrong_tags'); + const textarea = screen.getByPlaceholderText(/what's wrong/i) as HTMLTextAreaElement; + expect(textarea.value).toBe('wrong artist'); + expect(screen.getByRole('button', { name: /update flag/i })).toBeInTheDocument(); + }); + + test('submits with reason and notes; calls invalidateQueries on success', async () => { + const onClose = vi.fn(); + render(FlagPopover, { props: { track, onClose } }); + const select = screen.getByRole('combobox'); + await fireEvent.change(select, { target: { value: 'duplicate' } }); + const textarea = screen.getByPlaceholderText(/what's wrong/i); + await fireEvent.input(textarea, { target: { value: 'same recording' } }); + await fireEvent.click(screen.getByRole('button', { name: /^flag$/i })); + await Promise.resolve(); + await Promise.resolve(); + expect(flagTrack).toHaveBeenCalledWith({ + track_id: 't1', + reason: 'duplicate', + notes: 'same recording' + }); + expect(invalidateMock).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + + test('empty notes are not sent', async () => { + render(FlagPopover, { props: { track, onClose: vi.fn() } }); + await fireEvent.click(screen.getByRole('button', { name: /^flag$/i })); + await Promise.resolve(); + await Promise.resolve(); + expect(flagTrack).toHaveBeenCalledWith({ + track_id: 't1', + reason: 'bad_rip', + notes: undefined + }); + }); + + test('cancel button calls onClose without firing flagTrack', async () => { + const onClose = vi.fn(); + render(FlagPopover, { props: { track, onClose } }); + await fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(flagTrack).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/web/src/lib/components/TrackMenu.svelte b/web/src/lib/components/TrackMenu.svelte new file mode 100644 index 00000000..a57629bf --- /dev/null +++ b/web/src/lib/components/TrackMenu.svelte @@ -0,0 +1,65 @@ +<script lang="ts"> + import { MoreVertical, Flag } from 'lucide-svelte'; + import FlagPopover from './FlagPopover.svelte'; + import type { TrackRef } from '$lib/api/types'; + + let { track }: { track: TrackRef } = $props(); + + let menuOpen = $state(false); + let popoverOpen = $state(false); + + function toggleMenu(e: MouseEvent) { + e.stopPropagation(); + menuOpen = !menuOpen; + } + + function openFlag() { + menuOpen = false; + popoverOpen = true; + } + + function closeAll() { + menuOpen = false; + popoverOpen = false; + } +</script> + +<svelte:window + onclick={() => (menuOpen = false)} + onkeydown={(e) => e.key === 'Escape' && closeAll()} +/> + +<div class="relative inline-block"> + <button + type="button" + aria-label={`Track actions for ${track.title}`} + aria-haspopup="menu" + aria-expanded={menuOpen} + onclick={toggleMenu} + class="rounded p-1 text-text-muted hover:text-text-primary" + > + <MoreVertical size={16} strokeWidth={1} /> + </button> + + {#if menuOpen} + <div + role="menu" + class="absolute right-0 z-20 mt-1 w-48 rounded-md border border-border bg-surface p-1 shadow-lg" + onclick={(e) => e.stopPropagation()} + > + <button + type="button" + role="menuitem" + onclick={openFlag} + class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm text-text-primary hover:bg-surface-hover" + > + <Flag size={14} strokeWidth={1} /> + Flag this track… + </button> + </div> + {/if} + + {#if popoverOpen} + <FlagPopover {track} onClose={closeAll} /> + {/if} +</div> diff --git a/web/src/lib/components/TrackMenu.test.ts b/web/src/lib/components/TrackMenu.test.ts new file mode 100644 index 00000000..267908e9 --- /dev/null +++ b/web/src/lib/components/TrackMenu.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import TrackMenu from './TrackMenu.svelte'; +import type { TrackRef } from '$lib/api/types'; + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record<string, unknown>; + return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) }; +}); + +vi.mock('$lib/api/quarantine', () => ({ + flagTrack: vi.fn().mockResolvedValue({}) +})); + +const track: TrackRef = { + id: 't1', + title: 'Roygbiv', + album_id: 'a1', + album_title: 'Geogaddi', + artist_id: 'ar1', + artist_name: 'Boards of Canada', + duration_sec: 240, + stream_url: '/api/tracks/t1/stream' +}; + +afterEach(() => vi.clearAllMocks()); + +describe('TrackMenu', () => { + test('opens menu on kebab click', async () => { + render(TrackMenu, { props: { track } }); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + await fireEvent.click(screen.getByRole('button', { name: /track actions for roygbiv/i })); + expect(screen.getByRole('menu')).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /flag this track/i })).toBeInTheDocument(); + }); + + test('Escape key closes menu', async () => { + render(TrackMenu, { props: { track } }); + await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); + expect(screen.getByRole('menu')).toBeInTheDocument(); + await fireEvent.keyDown(window, { key: 'Escape' }); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + test('clicking "Flag this track…" opens FlagPopover and closes menu', async () => { + render(TrackMenu, { props: { track } }); + await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); + await fireEvent.click(screen.getByRole('menuitem', { name: /flag this track/i })); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(screen.getByRole('dialog', { name: /flag this track as broken/i })).toBeInTheDocument(); + }); +}); From 88ff997af780787244b5a256368bd405c0a645bf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 20:31:56 -0400 Subject: [PATCH 54/67] feat(web): mount TrackMenu in TrackRow + PlayerBar --- web/src/lib/components/PlayerBar.svelte | 2 ++ web/src/lib/components/PlayerBar.test.ts | 14 ++++++++++++++ web/src/lib/components/TrackRow.svelte | 4 +++- web/src/lib/components/TrackRow.test.ts | 22 ++++++++++++++++++---- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/web/src/lib/components/PlayerBar.svelte b/web/src/lib/components/PlayerBar.svelte index e2e593b3..f6f78e56 100644 --- a/web/src/lib/components/PlayerBar.svelte +++ b/web/src/lib/components/PlayerBar.svelte @@ -7,6 +7,7 @@ import { formatDuration } from '$lib/media/duration'; import { FALLBACK_COVER } from '$lib/media/covers'; import LikeButton from './LikeButton.svelte'; + import TrackMenu from './TrackMenu.svelte'; const current = $derived(player.current); @@ -59,6 +60,7 @@ </a> </div> <LikeButton entityType="track" entityId={current.id} /> + <TrackMenu track={current} /> </div> <!-- Center: seek row + transport row --> diff --git a/web/src/lib/components/PlayerBar.test.ts b/web/src/lib/components/PlayerBar.test.ts index 67c31e02..f36fbf5a 100644 --- a/web/src/lib/components/PlayerBar.test.ts +++ b/web/src/lib/components/PlayerBar.test.ts @@ -51,6 +51,13 @@ vi.mock('$lib/api/likes', () => ({ unlikeEntity: vi.fn() })); +vi.mock('$lib/api/quarantine', () => ({ + flagTrack: vi.fn(), + unflagTrack: vi.fn(), + listMyQuarantine: vi.fn().mockResolvedValue([]), + createMyQuarantineQuery: vi.fn() +})); + vi.mock('@tanstack/svelte-query', async (orig) => { const actual = (await orig()) as Record<string, unknown>; return { ...actual, useQueryClient: () => ({}) }; @@ -174,4 +181,11 @@ describe('PlayerBar', () => { expect(screen.getByText('1:05')).toBeInTheDocument(); expect(screen.getByText('4:05')).toBeInTheDocument(); }); + + test('renders the TrackMenu kebab button when a track is current', () => { + render(PlayerBar); + expect( + screen.getByRole('button', { name: /track actions for/i }) + ).toBeInTheDocument(); + }); }); diff --git a/web/src/lib/components/TrackRow.svelte b/web/src/lib/components/TrackRow.svelte index eeafee1e..8d558708 100644 --- a/web/src/lib/components/TrackRow.svelte +++ b/web/src/lib/components/TrackRow.svelte @@ -3,6 +3,7 @@ import { formatDuration } from '$lib/media/duration'; import { playQueue, enqueueTrack, playRadio } from '$lib/player/store.svelte'; import LikeButton from './LikeButton.svelte'; + import TrackMenu from './TrackMenu.svelte'; type PlayHandler = (tracks: TrackRef[], index: number) => void; @@ -47,7 +48,7 @@ aria-label={track.title} onclick={onRowClick} onkeydown={onRowKey} - class="grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent" + class="grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto_auto_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent" > <span class="text-right tabular-nums text-text-secondary"> {track.track_number ?? '—'} @@ -67,5 +68,6 @@ onclick={onAddClick} class="rounded p-1 text-text-secondary hover:text-text-primary" >+</button> + <TrackMenu {track} /> <span class="tabular-nums text-text-secondary">{formatDuration(track.duration_sec)}</span> </div> diff --git a/web/src/lib/components/TrackRow.test.ts b/web/src/lib/components/TrackRow.test.ts index b1693021..0b54c548 100644 --- a/web/src/lib/components/TrackRow.test.ts +++ b/web/src/lib/components/TrackRow.test.ts @@ -19,6 +19,13 @@ vi.mock('$lib/api/likes', () => ({ unlikeEntity: vi.fn() })); +vi.mock('$lib/api/quarantine', () => ({ + flagTrack: vi.fn(), + unflagTrack: vi.fn(), + listMyQuarantine: vi.fn().mockResolvedValue([]), + createMyQuarantineQuery: vi.fn() +})); + vi.mock('@tanstack/svelte-query', async (orig) => { const actual = (await orig()) as Record<string, unknown>; return { ...actual, useQueryClient: () => ({}) }; @@ -62,26 +69,26 @@ describe('TrackRow', () => { test('clicking the row calls playQueue(tracks, index)', async () => { render(TrackRow, { props: { tracks, index: 1 } }); - await fireEvent.click(screen.getByRole('button', { name: /Freddie Freeloader/ })); + await fireEvent.click(screen.getByRole('button', { name: 'Freddie Freeloader' })); expect(playQueue).toHaveBeenCalledWith(tracks, 1); }); test('Enter on the row activates play', async () => { render(TrackRow, { props: { tracks, index: 0 } }); - await fireEvent.keyDown(screen.getByRole('button', { name: /So What/ }), { key: 'Enter' }); + await fireEvent.keyDown(screen.getByRole('button', { name: 'So What' }), { key: 'Enter' }); expect(playQueue).toHaveBeenCalledWith(tracks, 0); }); test('Space on the row activates play', async () => { render(TrackRow, { props: { tracks, index: 0 } }); - await fireEvent.keyDown(screen.getByRole('button', { name: /So What/ }), { key: ' ' }); + await fireEvent.keyDown(screen.getByRole('button', { name: 'So What' }), { key: ' ' }); expect(playQueue).toHaveBeenCalledWith(tracks, 0); }); test('onPlay prop overrides default playQueue', async () => { const onPlay = vi.fn(); render(TrackRow, { props: { tracks, index: 1, onPlay } }); - await fireEvent.click(screen.getByRole('button', { name: /Freddie Freeloader/ })); + await fireEvent.click(screen.getByRole('button', { name: 'Freddie Freeloader' })); expect(onPlay).toHaveBeenCalledWith(tracks, 1); expect(playQueue).not.toHaveBeenCalled(); }); @@ -99,4 +106,11 @@ describe('TrackRow', () => { expect(playRadio).toHaveBeenCalledWith('t1'); expect(playQueue).not.toHaveBeenCalled(); }); + + test('renders the TrackMenu kebab button', () => { + render(TrackRow, { props: { tracks, index: 0 } }); + expect( + screen.getByRole('button', { name: /track actions for/i }) + ).toBeInTheDocument(); + }); }); From 0d7a65cffbb626c0205ba309f6af3c5997f4514b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 20:35:11 -0400 Subject: [PATCH 55/67] feat(web): /library/hidden user-facing quarantine view Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- web/src/lib/components/Shell.svelte | 15 +-- web/src/lib/components/Shell.test.ts | 15 +++ web/src/routes/library/hidden/+page.svelte | 111 +++++++++++++++++++ web/src/routes/library/hidden/hidden.test.ts | 64 +++++++++++ 4 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 web/src/routes/library/hidden/+page.svelte create mode 100644 web/src/routes/library/hidden/hidden.test.ts diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index 9fc18724..80d7af57 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -22,13 +22,14 @@ } const navItems = [ - { href: '/', label: 'Library' }, - { href: '/library/liked', label: 'Liked' }, - { href: '/search', label: 'Search' }, - { href: '/discover', label: 'Discover' }, - { href: '/requests', label: 'Requests' }, - { href: '/playlists', label: 'Playlists' }, - { href: '/settings', label: 'Settings' } + { href: '/', label: 'Library' }, + { href: '/library/liked', label: 'Liked' }, + { href: '/library/hidden', label: 'Hidden' }, + { href: '/search', label: 'Search' }, + { href: '/discover', label: 'Discover' }, + { href: '/requests', label: 'Requests' }, + { href: '/playlists', label: 'Playlists' }, + { href: '/settings', label: 'Settings' } ]; // Admin link sits between Playlists and Settings, only visible to admins. diff --git a/web/src/lib/components/Shell.test.ts b/web/src/lib/components/Shell.test.ts index dc7b445a..b3f24b90 100644 --- a/web/src/lib/components/Shell.test.ts +++ b/web/src/lib/components/Shell.test.ts @@ -48,6 +48,21 @@ describe('Shell', () => { expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists'); }); + test('Hidden nav link sits between Liked and Search', () => { + render(Shell); + const hidden = screen.getByRole('link', { name: 'Hidden' }); + expect(hidden).toHaveAttribute('href', '/library/hidden'); + const labels = screen + .getAllByRole('link') + .map((el) => el.textContent?.trim()) + .filter(Boolean); + const idxLiked = labels.indexOf('Liked'); + const idxHidden = labels.indexOf('Hidden'); + const idxSearch = labels.indexOf('Search'); + expect(idxLiked).toBeLessThan(idxHidden); + expect(idxHidden).toBeLessThan(idxSearch); + }); + test('non-admin users do not see the Admin nav link', () => { userState.current = { id: '1', username: 'alice', is_admin: false }; render(Shell); diff --git a/web/src/routes/library/hidden/+page.svelte b/web/src/routes/library/hidden/+page.svelte new file mode 100644 index 00000000..35c8e9f5 --- /dev/null +++ b/web/src/routes/library/hidden/+page.svelte @@ -0,0 +1,111 @@ +<script lang="ts"> + import { Music2, RotateCcw } from 'lucide-svelte'; + import { useQueryClient } from '@tanstack/svelte-query'; + import { createMyQuarantineQuery, unflagTrack } from '$lib/api/quarantine'; + import { qk } from '$lib/api/queries'; + import type { LidarrQuarantineMineRow, LidarrQuarantineReason } from '$lib/api/types'; + import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte'; + + const client = useQueryClient(); + const queryStore = createMyQuarantineQuery(); + const query = $derived($queryStore); + const rows = $derived((query.data ?? []) as LidarrQuarantineMineRow[]); + + const REASON_LABELS: Record<LidarrQuarantineReason, string> = { + bad_rip: 'Bad rip', + wrong_file: 'Wrong file', + wrong_tags: 'Wrong tags', + duplicate: 'Duplicate', + other: 'Other' + }; + + function relativeTime(iso: string): string { + const ms = Date.now() - new Date(iso).getTime(); + const days = Math.floor(ms / (24 * 3_600_000)); + if (days >= 1) return `${days}d ago`; + const hours = Math.floor(ms / 3_600_000); + if (hours >= 1) return `${hours}h ago`; + const minutes = Math.floor(ms / 60_000); + if (minutes >= 1) return `${minutes}m ago`; + return 'just now'; + } + + async function onUnhide(trackID: string) { + try { + await unflagTrack(trackID); + await client.invalidateQueries({ queryKey: qk.myQuarantine() }); + } catch { + // Silent in v1; the SPA will refetch on next mount. + } + } +</script> + +<div class="space-y-6"> + <header class="space-y-1"> + <h2 class="font-display text-2xl font-medium text-text-primary">Hidden</h2> + <p class="text-text-secondary">Tracks you've flagged as broken.</p> + </header> + + {#if query.isError} + <ApiErrorBanner error={query.error} onRetry={query.refetch} /> + {:else if !query.isPending && rows.length === 0} + <p class="text-text-secondary">Nothing hidden yet.</p> + {:else if rows.length > 0} + <ul class="space-y-2"> + {#each rows as row (row.track_id)} + <li class="flex items-start gap-3 rounded-md border border-border bg-surface p-3"> + <!-- Album art / fallback --> + <div class="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-surface-hover"> + {#if row.album_cover_art_path} + <img + src={`/api/albums/${row.album_id}/cover`} + alt="" + class="h-full w-full rounded-md object-cover" + loading="lazy" + /> + {:else} + <Music2 size={24} strokeWidth={1} class="text-text-muted" /> + {/if} + </div> + <!-- Body --> + <div class="flex flex-1 flex-col gap-1"> + <div class="flex items-center gap-2"> + <span class="kind-pill">Track</span> + <span class="kind-pill">{REASON_LABELS[row.reason]}</span> + </div> + <div class="text-sm text-text-primary">{row.track_title}</div> + <div class="text-xs text-text-secondary"> + by {row.artist_name} · {row.album_title} · flagged {relativeTime(row.created_at)} + </div> + {#if row.notes} + <div class="text-xs italic text-text-secondary">{row.notes}</div> + {/if} + </div> + <!-- Action --> + <button + type="button" + aria-label={`Un-hide ${row.track_title}`} + onclick={() => onUnhide(row.track_id)} + class="inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2.5 py-1.5 text-sm text-text-secondary hover:text-text-primary" + > + <RotateCcw size={14} strokeWidth={1} /> + Un-hide + </button> + </li> + {/each} + </ul> + {/if} +</div> + +<style> + .kind-pill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + line-height: 14px; + background: color-mix(in srgb, var(--fs-accent) 15%, transparent); + color: var(--fs-accent); + } +</style> diff --git a/web/src/routes/library/hidden/hidden.test.ts b/web/src/routes/library/hidden/hidden.test.ts new file mode 100644 index 00000000..1630d241 --- /dev/null +++ b/web/src/routes/library/hidden/hidden.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { mockQuery } from '../../../test-utils/query'; + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record<string, unknown>; + return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) }; +}); + +vi.mock('$lib/api/quarantine', () => ({ + createMyQuarantineQuery: vi.fn(), + unflagTrack: vi.fn().mockResolvedValue(undefined) +})); + +import HiddenPage from './+page.svelte'; +import { createMyQuarantineQuery, unflagTrack } from '$lib/api/quarantine'; +import type { LidarrQuarantineMineRow } from '$lib/api/types'; + +const baseRow: LidarrQuarantineMineRow = { + track_id: 't1', + reason: 'bad_rip', + notes: 'crackles', + created_at: new Date(Date.now() - 2 * 24 * 3_600_000).toISOString(), // 2 days ago + track_title: 'Roygbiv', + track_duration_ms: 200000, + album_id: 'a1', + album_title: 'Geogaddi', + album_cover_art_path: null, + artist_id: 'ar1', + artist_name: 'Boards of Canada' +}; + +afterEach(() => vi.clearAllMocks()); + +describe('/library/hidden', () => { + test('renders one row per quarantine with title + meta + reason pill + notes', () => { + (createMyQuarantineQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [baseRow] })); + render(HiddenPage); + expect(screen.getByText('Roygbiv')).toBeInTheDocument(); + expect(screen.getByText(/by boards of canada · geogaddi/i)).toBeInTheDocument(); + expect(screen.getByText('Bad rip')).toBeInTheDocument(); + expect(screen.getByText('crackles')).toBeInTheDocument(); + }); + + test('un-hide button calls unflagTrack', async () => { + (createMyQuarantineQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [baseRow] })); + render(HiddenPage); + await fireEvent.click(screen.getByRole('button', { name: /un-hide roygbiv/i })); + expect(unflagTrack).toHaveBeenCalledWith('t1'); + }); + + test('empty state shows "Nothing hidden yet."', () => { + (createMyQuarantineQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] })); + render(HiddenPage); + expect(screen.getByText(/nothing hidden yet/i)).toBeInTheDocument(); + }); + + test('notes are absent when row.notes is null', () => { + const row = { ...baseRow, notes: null }; + (createMyQuarantineQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [row] })); + render(HiddenPage); + expect(screen.queryByText('crackles')).not.toBeInTheDocument(); + }); +}); From 3bfec944c7506d42a82aa58e8c300d23ac03095d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 20:40:34 -0400 Subject: [PATCH 56/67] feat(web): /admin/quarantine aggregated queue with resolution actions Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- web/src/lib/components/AdminSidebar.svelte | 2 +- web/src/lib/components/AdminSidebar.test.ts | 23 +- web/src/routes/admin/quarantine/+page.svelte | 476 ++++++++++++++++++ .../admin/quarantine/quarantine.test.ts | 181 +++++++ 4 files changed, 677 insertions(+), 5 deletions(-) create mode 100644 web/src/routes/admin/quarantine/+page.svelte create mode 100644 web/src/routes/admin/quarantine/quarantine.test.ts diff --git a/web/src/lib/components/AdminSidebar.svelte b/web/src/lib/components/AdminSidebar.svelte index 381395ab..99114878 100644 --- a/web/src/lib/components/AdminSidebar.svelte +++ b/web/src/lib/components/AdminSidebar.svelte @@ -13,7 +13,7 @@ { href: '/admin', label: 'Overview', icon: LayoutGrid }, { href: '/admin/integrations', label: 'Integrations', icon: Plug }, { href: '/admin/requests', label: 'Requests', icon: ListChecks }, - { href: '/admin/quarantine', label: 'Quarantine', icon: ShieldX, placeholder: true }, + { href: '/admin/quarantine', label: 'Quarantine', icon: ShieldX }, { href: '/admin/users', label: 'Users', icon: Users, placeholder: true }, { href: '/admin/library', label: 'Library', icon: FolderTree, placeholder: true } ]; diff --git a/web/src/lib/components/AdminSidebar.test.ts b/web/src/lib/components/AdminSidebar.test.ts index 792913e7..8e73d0ab 100644 --- a/web/src/lib/components/AdminSidebar.test.ts +++ b/web/src/lib/components/AdminSidebar.test.ts @@ -38,14 +38,29 @@ describe('AdminSidebar', () => { ); }); + test('Quarantine link is active when on /admin/quarantine', () => { + state.pageUrl = new URL('http://localhost/admin/quarantine'); + render(AdminSidebar); + expect(screen.getByRole('link', { name: /quarantine/i })).toHaveAttribute( + 'aria-current', + 'page' + ); + }); + + test('Quarantine renders as a real link', () => { + state.pageUrl = new URL('http://localhost/admin'); + render(AdminSidebar); + const link = screen.getByRole('link', { name: /quarantine/i }); + expect(link).toHaveAttribute('href', '/admin/quarantine'); + }); + test('placeholder items render as non-links with aria-disabled', () => { state.pageUrl = new URL('http://localhost/admin'); render(AdminSidebar); - expect(screen.queryByRole('link', { name: /quarantine/i })).not.toBeInTheDocument(); - const quar = screen.getByText(/quarantine/i); - expect(quar.closest('[aria-disabled="true"]')).toBeInTheDocument(); - // Users + Library are also placeholders today. + // Users + Library are still placeholders today. expect(screen.queryByRole('link', { name: /^users$/i })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: /^library$/i })).not.toBeInTheDocument(); + const users = screen.getByText(/^users$/i); + expect(users.closest('[aria-disabled="true"]')).toBeInTheDocument(); }); }); diff --git a/web/src/routes/admin/quarantine/+page.svelte b/web/src/routes/admin/quarantine/+page.svelte new file mode 100644 index 00000000..15cf81b1 --- /dev/null +++ b/web/src/routes/admin/quarantine/+page.svelte @@ -0,0 +1,476 @@ +<script lang="ts"> + import { Music2, RotateCcw, Trash2, Cloud, Play, ChevronRight } from 'lucide-svelte'; + import { useQueryClient } from '@tanstack/svelte-query'; + import { + createAdminQuarantineQuery, + resolveQuarantine, + deleteQuarantineFile, + deleteQuarantineViaLidarr + } from '$lib/api/admin'; + import { qk } from '$lib/api/queries'; + import { playRadio } from '$lib/player/store.svelte'; + import type { AdminQuarantineRow, LidarrQuarantineReason } from '$lib/api/types'; + + // Aggregated triage queue. One row per track, with per-row resolution + // actions: Resolve (clears reports), Delete file (Bronze; modal-confirm), + // Delete via Lidarr (Oxblood; typed-confirm "DELETE"). Mirrors + // /admin/requests for shape — toast helper, errorCopy(), invalidateQueries. + + const client = useQueryClient(); + + const queryStore = createAdminQuarantineQuery(); + const query = $derived($queryStore); + const rows = $derived((query.data ?? []) as AdminQuarantineRow[]); + + // Sum of reports across all rows; rendered as the accent-tint pill in the + // header. Count of *reports*, not unique tracks — that's the spec. + const totalReports = $derived( + rows.reduce((sum, r) => sum + r.report_count, 0) + ); + + const REASON_LABELS: Record<LidarrQuarantineReason, string> = { + bad_rip: 'Bad rip', + wrong_file: 'Wrong file', + wrong_tags: 'Wrong tags', + duplicate: 'Duplicate', + other: 'Other' + }; + + function relativeTime(iso: string): string { + const ms = Date.now() - new Date(iso).getTime(); + const days = Math.floor(ms / (24 * 3_600_000)); + if (days >= 1) return `${days}d ago`; + const hours = Math.floor(ms / 3_600_000); + if (hours >= 1) return `${hours}h ago`; + const minutes = Math.floor(ms / 60_000); + if (minutes >= 1) return `${minutes}m ago`; + return 'just now'; + } + + // Row-level expand state: tracks which rows have their per-user report + // details revealed. Keyed by track_id. + let expanded = $state<Record<string, boolean>>({}); + + function toggleExpanded(trackID: string) { + expanded[trackID] = !expanded[trackID]; + } + + // Modal state. The modal-confirm (delete file) and the typed-confirm (delete + // via Lidarr) both store the active row's track_id; null when closed. + let deleteFileOpen = $state<string | null>(null); + + let deleteLidarrOpen = $state<string | null>(null); + let deleteLidarrInput = $state<string>(''); + // Inline error inside the typed-confirm modal — the modal stays open with + // feedback if Lidarr can't be reached or the album lookup fails. + let deleteLidarrError = $state<string | null>(null); + + // Toast surface — same pattern as /admin/requests for the lidarr-unreachable + // and other error codes from /resolve and /delete-file. + let toast = $state<string | null>(null); + let toastTimer: ReturnType<typeof setTimeout> | null = null; + + function showToast(msg: string) { + if (toastTimer) clearTimeout(toastTimer); + toast = msg; + toastTimer = setTimeout(() => { + toast = null; + }, 5000); + } + + function errorCopy(code: string): string { + switch (code) { + case 'lidarr_unreachable': + return "Lidarr is unreachable right now. Try again, or check Settings → Integrations."; + case 'lidarr_disabled': + return 'Lidarr integration is not enabled.'; + case 'lidarr_auth_failed': + return 'Lidarr authentication failed.'; + case 'lidarr_album_lookup_failed': + return "Lidarr doesn't recognize this album. Try Resolve or Delete file instead."; + case 'album_mbid_missing': + return 'This track has no Lidarr album to remove.'; + default: + return "Couldn't reach Lidarr."; + } + } + + async function invalidate() { + await client.invalidateQueries({ queryKey: qk.adminQuarantine() }); + } + + async function onResolve(r: AdminQuarantineRow) { + try { + await resolveQuarantine(r.track_id); + await invalidate(); + } catch (e) { + const code = (e as { code?: string }).code ?? 'unknown'; + showToast(errorCopy(code)); + } + } + + function openDeleteFile(r: AdminQuarantineRow) { + deleteFileOpen = r.track_id; + } + + function cancelDeleteFile() { + deleteFileOpen = null; + } + + async function confirmDeleteFile(r: AdminQuarantineRow) { + deleteFileOpen = null; + try { + await deleteQuarantineFile(r.track_id); + await invalidate(); + } catch (e) { + const code = (e as { code?: string }).code ?? 'unknown'; + showToast(errorCopy(code)); + } + } + + function openDeleteLidarr(r: AdminQuarantineRow) { + deleteLidarrOpen = r.track_id; + deleteLidarrInput = ''; + deleteLidarrError = null; + } + + function cancelDeleteLidarr() { + deleteLidarrOpen = null; + deleteLidarrInput = ''; + deleteLidarrError = null; + } + + async function confirmDeleteLidarr(r: AdminQuarantineRow) { + // Trimmed equality matches the M5a Disconnect typed-confirm pattern. + if (deleteLidarrInput.trim() !== 'DELETE') return; + try { + await deleteQuarantineViaLidarr(r.track_id); + deleteLidarrOpen = null; + deleteLidarrInput = ''; + deleteLidarrError = null; + await invalidate(); + } catch (e) { + const code = (e as { code?: string }).code ?? 'unknown'; + // Inline error keeps the modal open so the operator sees the failure + // alongside the album they were about to remove. + deleteLidarrError = errorCopy(code); + // Also surface as toast so it's visible after dismissing the modal. + showToast(errorCopy(code)); + } + } + + async function onPlay(r: AdminQuarantineRow) { + try { + await playRadio(r.track_id); + } catch { + // Silent on failure — the play button is a convenience, not a primary + // action. Toast surface is reserved for the destructive action errors. + } + } + + // Modal lookups guard against the row disappearing if the query refetches + // while the modal is open. + const deleteFileRow = $derived( + deleteFileOpen ? rows.find((r) => r.track_id === deleteFileOpen) ?? null : null + ); + const deleteLidarrRow = $derived( + deleteLidarrOpen ? rows.find((r) => r.track_id === deleteLidarrOpen) ?? null : null + ); +</script> + +<div class="space-y-6"> + <header class="space-y-1"> + <div class="flex items-center gap-2"> + <h2 class="font-display text-2xl font-medium text-text-primary">Quarantine</h2> + {#if totalReports > 0} + <span + class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent" + data-testid="report-count-pill" + > + {totalReports} + </span> + {/if} + </div> + <p class="text-text-secondary">User-reported broken tracks waiting for triage.</p> + </header> + + {#if query.isPending} + <p class="text-text-secondary">Reading the queue…</p> + {:else if query.isError} + <p class="text-error">Couldn't load the quarantine queue.</p> + {:else if rows.length === 0} + <p class="text-text-secondary">Nothing to triage right now.</p> + {:else} + <ul class="divide-y divide-border rounded-lg border border-border bg-surface"> + {#each rows as r (r.track_id)} + {@const isExpanded = !!expanded[r.track_id]} + {@const lidarrDisabled = !r.lidarr_album_mbid} + <li + class="flex items-start gap-4 p-3" + data-testid="admin-quarantine-row" + data-track-id={r.track_id} + > + <!-- 56px album art with Slate fallback. --> + <div + class="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-surface-hover" + aria-hidden="true" + > + {#if r.album_id} + <img + src={`/api/albums/${r.album_id}/cover`} + alt="" + class="h-full w-full rounded-md object-cover" + loading="lazy" + /> + {:else} + <Music2 size={24} strokeWidth={1} class="text-text-muted" /> + {/if} + </div> + + <div class="min-w-0 flex-1 space-y-1"> + <div class="truncate text-base font-medium text-text-primary"> + {r.track_title} + </div> + <div class="truncate text-sm text-text-secondary"> + {r.artist_name} · {r.album_title} · {r.report_count} reports — latest {relativeTime(r.latest_at)} + </div> + + <!-- Reason distribution pills --> + <div class="flex flex-wrap gap-1.5 pt-1"> + {#each Object.entries(r.reason_counts) as [reason, count] (reason)} + <span + class="reason-pill" + data-testid="reason-pill" + > + {count}× {REASON_LABELS[reason as LidarrQuarantineReason] ?? reason} + </span> + {/each} + </div> + + <!-- Reports details disclosure --> + <button + type="button" + class="mt-1 inline-flex items-center gap-1 text-xs text-text-secondary hover:text-text-primary" + aria-expanded={isExpanded} + aria-label={`${isExpanded ? 'Hide' : 'Show'} reports for ${r.track_title}`} + onclick={() => toggleExpanded(r.track_id)} + > + <ChevronRight + size={12} + strokeWidth={1.5} + class="transition-transform {isExpanded ? 'rotate-90' : ''}" + /> + {isExpanded ? 'Hide reports' : `Show ${r.reports.length} reports`} + </button> + + {#if isExpanded} + <ul + class="mt-2 space-y-1 rounded-md border border-border bg-background p-2 text-xs text-text-secondary" + data-testid="reports-details" + > + {#each r.reports as rep, idx (rep.user_id + ':' + rep.created_at + ':' + idx)} + <li> + <span class="text-text-primary">{rep.username}</span> + · {REASON_LABELS[rep.reason] ?? rep.reason} + {#if rep.notes} + · <span class="italic">{rep.notes}</span> + {/if} + · {relativeTime(rep.created_at)} + </li> + {/each} + </ul> + {/if} + </div> + + <!-- Action cluster --> + <div class="flex shrink-0 items-center gap-2"> + <button + type="button" + aria-label={`Play ${r.track_title}`} + class="inline-flex items-center justify-center rounded-md p-2 text-accent hover:bg-accent-tint" + onclick={() => onPlay(r)} + > + <Play size={16} strokeWidth={2} /> + </button> + + <button + type="button" + aria-label={`Resolve ${r.track_title}`} + class="inline-flex items-center gap-1 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-hover" + onclick={() => onResolve(r)} + > + <RotateCcw size={14} strokeWidth={1.5} /> + Resolve + </button> + + <button + type="button" + aria-label={`Delete file for ${r.track_title}`} + class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary" + onclick={() => openDeleteFile(r)} + > + <Trash2 size={14} strokeWidth={2} /> + Delete file + </button> + + {#if lidarrDisabled} + <span + aria-label={`Delete via Lidarr disabled for ${r.track_title}`} + title="Local-only track — no Lidarr album to remove." + class="inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-text-primary opacity-50 cursor-not-allowed" + data-testid="delete-lidarr-disabled" + > + <Trash2 size={14} strokeWidth={2} /> + <Cloud size={14} strokeWidth={2} /> + Delete via Lidarr + </span> + {:else} + <button + type="button" + aria-label={`Delete via Lidarr for ${r.track_title}`} + class="inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-text-primary" + onclick={() => openDeleteLidarr(r)} + > + <Trash2 size={14} strokeWidth={2} /> + <Cloud size={14} strokeWidth={2} /> + Delete via Lidarr + </button> + {/if} + </div> + </li> + {/each} + </ul> + {/if} +</div> + +{#if deleteFileRow} + <!-- svelte-ignore a11y_click_events_have_key_events --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + class="fixed inset-0 z-50 flex items-center justify-center" + style="background: rgba(0,0,0,0.5);" + onclick={cancelDeleteFile} + > + <div + role="dialog" + aria-modal="true" + aria-labelledby="delete-file-title" + class="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-lg" + onclick={(e) => e.stopPropagation()} + tabindex="-1" + > + <h3 id="delete-file-title" class="font-display text-lg font-medium text-text-primary"> + Delete file? + </h3> + <p class="mt-2 text-sm text-text-secondary"> + Remove <em class="font-medium text-text-primary">{deleteFileRow.track_title}</em> + from disk and clear {deleteFileRow.report_count} reports? Lidarr may auto-redownload depending on its monitor settings. + </p> + <div class="mt-5 flex justify-end gap-2"> + <button + type="button" + class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary" + onclick={cancelDeleteFile} + > + Cancel + </button> + <button + type="button" + aria-label="Confirm delete file" + class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary" + onclick={() => confirmDeleteFile(deleteFileRow!)} + > + <Trash2 size={14} strokeWidth={2} /> + Delete file + </button> + </div> + </div> + </div> +{/if} + +{#if deleteLidarrRow} + <!-- svelte-ignore a11y_click_events_have_key_events --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + class="fixed inset-0 z-50 flex items-center justify-center" + style="background: rgba(0,0,0,0.5);" + onclick={cancelDeleteLidarr} + > + <div + role="dialog" + aria-modal="true" + aria-labelledby="delete-lidarr-title" + class="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-lg" + onclick={(e) => e.stopPropagation()} + tabindex="-1" + > + <h3 id="delete-lidarr-title" class="font-display text-lg font-medium text-text-primary"> + Delete via Lidarr? + </h3> + <p class="mt-2 text-sm text-text-secondary"> + This will tell Lidarr to remove <em class="font-medium text-text-primary">{deleteLidarrRow.album_title}</em> + (artist <em class="font-medium text-text-primary">{deleteLidarrRow.artist_name}</em>) + and add it to the import-list exclusion. Affects all tracks on the album. Type <strong>DELETE</strong> to confirm. + </p> + <label class="mt-3 block"> + <span class="block text-sm text-text-secondary">Type DELETE to confirm</span> + <input + type="text" + bind:value={deleteLidarrInput} + placeholder="DELETE" + class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" + aria-label="Type DELETE to confirm" + /> + </label> + {#if deleteLidarrError} + <p class="mt-3 text-sm text-error" data-testid="delete-lidarr-error"> + {deleteLidarrError} + </p> + {/if} + <div class="mt-5 flex justify-end gap-2"> + <button + type="button" + class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary" + onclick={cancelDeleteLidarr} + > + Cancel + </button> + <button + type="button" + aria-label="Confirm delete via Lidarr" + disabled={deleteLidarrInput.trim() !== 'DELETE'} + class="inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-text-primary disabled:cursor-not-allowed disabled:opacity-50" + onclick={() => confirmDeleteLidarr(deleteLidarrRow!)} + > + <Trash2 size={14} strokeWidth={2} /> + <Cloud size={14} strokeWidth={2} /> + Delete via Lidarr + </button> + </div> + </div> + </div> +{/if} + +{#if toast} + <div + role="status" + aria-live="polite" + class="fixed bottom-4 right-4 z-50 max-w-sm rounded-md border border-border bg-surface px-4 py-3 text-sm text-text-primary shadow-lg" + data-testid="toast" + > + {toast} + </div> +{/if} + +<style> + .reason-pill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + line-height: 14px; + background: color-mix(in srgb, var(--fs-accent) 15%, transparent); + color: var(--fs-accent); + } +</style> diff --git a/web/src/routes/admin/quarantine/quarantine.test.ts b/web/src/routes/admin/quarantine/quarantine.test.ts new file mode 100644 index 00000000..68d7eedb --- /dev/null +++ b/web/src/routes/admin/quarantine/quarantine.test.ts @@ -0,0 +1,181 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import { mockQuery } from '../../../test-utils/query'; +import type { AdminQuarantineRow } from '$lib/api/types'; + +// Wrap useQueryClient so the page can call invalidateQueries() without a real +// QueryClient context. Everything else from svelte-query passes through. +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record<string, unknown>; + return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) }; +}); + +vi.mock('$lib/api/admin', () => ({ + createAdminQuarantineQuery: vi.fn(), + resolveQuarantine: vi.fn(), + deleteQuarantineFile: vi.fn(), + deleteQuarantineViaLidarr: vi.fn() +})); + +vi.mock('$lib/player/store.svelte', () => ({ + playRadio: vi.fn() +})); + +import AdminQuarantinePage from './+page.svelte'; +import { + createAdminQuarantineQuery, + resolveQuarantine, + deleteQuarantineFile, + deleteQuarantineViaLidarr +} from '$lib/api/admin'; +import { playRadio } from '$lib/player/store.svelte'; + +const baseRow: AdminQuarantineRow = { + track_id: 't-001', + track_title: 'Roygbiv', + artist_name: 'Boards of Canada', + album_title: 'Geogaddi', + album_id: 'al-001', + lidarr_album_mbid: 'al-mbid-1', + report_count: 3, + latest_at: new Date(Date.now() - 2 * 3_600_000).toISOString(), + reason_counts: { bad_rip: 2, wrong_tags: 1 }, + reports: [ + { + user_id: 'u1', + username: 'alice', + reason: 'bad_rip', + notes: 'crackles at 1:24', + created_at: new Date(Date.now() - 2 * 3_600_000).toISOString() + }, + { + user_id: 'u2', + username: 'bob', + reason: 'bad_rip', + notes: null, + created_at: new Date(Date.now() - 5 * 3_600_000).toISOString() + }, + { + user_id: 'u3', + username: 'carol', + reason: 'wrong_tags', + notes: 'wrong year', + created_at: new Date(Date.now() - 7 * 3_600_000).toISOString() + } + ] +}; + +afterEach(() => vi.clearAllMocks()); + +function setup(rows: AdminQuarantineRow[] = [baseRow]) { + (createAdminQuarantineQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: rows }) + ); + return render(AdminQuarantinePage); +} + +describe('/admin/quarantine', () => { + test('Empty state shows the spec copy', () => { + setup([]); + expect(screen.getByText('Nothing to triage right now.')).toBeInTheDocument(); + }); + + test('Aggregated row renders with reason distribution + report count', () => { + setup(); + expect(screen.getByText('Roygbiv')).toBeInTheDocument(); + expect( + screen.getByText(/Boards of Canada · Geogaddi · 3 reports — latest/i) + ).toBeInTheDocument(); + const pills = screen.getAllByTestId('reason-pill').map((el) => el.textContent?.trim()); + expect(pills).toContain('2× Bad rip'); + expect(pills).toContain('1× Wrong tags'); + expect(screen.getByTestId('report-count-pill')).toHaveTextContent('3'); + }); + + test('Resolve fires resolveQuarantine and invalidates the query', async () => { + setup(); + (resolveQuarantine as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ + action_id: 'a1', + affected_users: 3 + }); + await fireEvent.click(screen.getByRole('button', { name: /resolve roygbiv/i })); + expect(resolveQuarantine).toHaveBeenCalledWith('t-001'); + }); + + test('Delete file → modal-confirm → fires deleteQuarantineFile', async () => { + setup(); + (deleteQuarantineFile as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ + action_id: 'a2', + affected_users: 3 + }); + await fireEvent.click( + screen.getByRole('button', { name: /delete file for roygbiv/i }) + ); + const dialog = await screen.findByRole('dialog'); + expect(dialog).toHaveTextContent(/Remove/i); + expect(dialog).toHaveTextContent(/clear 3 reports/i); + await fireEvent.click( + screen.getByRole('button', { name: /confirm delete file/i }) + ); + expect(deleteQuarantineFile).toHaveBeenCalledWith('t-001'); + }); + + test('Delete via Lidarr → typed-confirm "DELETE" → fires deleteQuarantineViaLidarr', async () => { + setup(); + (deleteQuarantineViaLidarr as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ + action_id: 'a3', + affected_users: 3, + deleted_track_count: 12 + }); + await fireEvent.click( + screen.getByRole('button', { name: /delete via lidarr for roygbiv/i }) + ); + await screen.findByRole('dialog'); + const confirmBtn = screen.getByRole('button', { + name: /confirm delete via lidarr/i + }) as HTMLButtonElement; + expect(confirmBtn).toBeDisabled(); + const input = screen.getByLabelText(/type delete to confirm/i) as HTMLInputElement; + await fireEvent.input(input, { target: { value: 'DELETE' } }); + expect(confirmBtn).not.toBeDisabled(); + await fireEvent.click(confirmBtn); + expect(deleteQuarantineViaLidarr).toHaveBeenCalledWith('t-001'); + }); + + test('Lidarr-unreachable error shows toast with spec copy', async () => { + setup(); + (resolveQuarantine as ReturnType<typeof vi.fn>).mockRejectedValueOnce({ + code: 'lidarr_unreachable', + message: 'unreachable', + status: 503 + }); + await fireEvent.click(screen.getByRole('button', { name: /resolve roygbiv/i })); + await waitFor(() => + expect( + screen.getByText( + 'Lidarr is unreachable right now. Try again, or check Settings → Integrations.' + ) + ).toBeInTheDocument() + ); + }); + + test('Dimmed Delete-via-Lidarr when lidarr_album_mbid is null', () => { + setup([{ ...baseRow, lidarr_album_mbid: null }]); + const disabled = screen.getByTestId('delete-lidarr-disabled'); + expect(disabled).toHaveClass('cursor-not-allowed'); + expect(disabled).toHaveClass('opacity-50'); + expect(disabled).toHaveAttribute( + 'title', + 'Local-only track — no Lidarr album to remove.' + ); + expect( + screen.queryByRole('button', { name: /delete via lidarr for roygbiv/i }) + ).not.toBeInTheDocument(); + }); + + test('Inline play button calls playRadio with the track id', async () => { + setup(); + await fireEvent.click(screen.getByRole('button', { name: /play roygbiv/i })); + expect(playRadio).toHaveBeenCalledWith('t-001'); + }); +}); From 3e3ad89645610098a0ca7a0457d2c164b159071d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 20:49:11 -0400 Subject: [PATCH 57/67] fix: T16 verification cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - golangci-lint: errcheck on resp.Body.Close in Lidarr client + revive unused-parameter in delete_test.go - coverage: 2 error-branch tests added to lidarrquarantine.Service (DeleteFile + DeleteViaLidarr track-not-found paths) bring per-package coverage to 81.4% (target >=80%) - search/tracks.test.ts: same TrackMenu name-collision fix T13 applied to TrackRow.test.ts — exact-string button match instead of regex --- internal/lidarr/delete.go | 2 +- internal/lidarr/delete_test.go | 2 +- internal/lidarr/lookup_mbid.go | 4 +- internal/lidarrquarantine/service_test.go | 41 +++++++++++++++++++++ web/src/routes/search/tracks/tracks.test.ts | 11 +++++- 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/internal/lidarr/delete.go b/internal/lidarr/delete.go index 2a060320..9755c680 100644 --- a/internal/lidarr/delete.go +++ b/internal/lidarr/delete.go @@ -64,6 +64,6 @@ func (c *Client) DeleteAlbum(ctx context.Context, lidarrAlbumID int, deleteFiles if err != nil { return err } - resp.Body.Close() + _ = resp.Body.Close() return nil } diff --git a/internal/lidarr/delete_test.go b/internal/lidarr/delete_test.go index db8d3822..eb0edf0d 100644 --- a/internal/lidarr/delete_test.go +++ b/internal/lidarr/delete_test.go @@ -203,7 +203,7 @@ func TestDeleteAlbum_NetworkErrorReturnsErrUnreachable(t *testing.T) { } func TestDeleteAlbum_ZeroIDRejected(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + c, srv := newTestClient(func(_ http.ResponseWriter, _ *http.Request) { t.Error("server should not be called with zero id") }) defer srv.Close() diff --git a/internal/lidarr/lookup_mbid.go b/internal/lidarr/lookup_mbid.go index ecfc0e08..b6e17781 100644 --- a/internal/lidarr/lookup_mbid.go +++ b/internal/lidarr/lookup_mbid.go @@ -18,7 +18,7 @@ func (c *Client) LookupArtistByMBID(ctx context.Context, mbid string) (LidarrArt if err != nil { return LidarrArtist{}, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var rows []LidarrArtist if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil { @@ -41,7 +41,7 @@ func (c *Client) LookupAlbumByMBID(ctx context.Context, mbid string) (LidarrAlbu if err != nil { return LidarrAlbum{}, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var rows []LidarrAlbum if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil { diff --git a/internal/lidarrquarantine/service_test.go b/internal/lidarrquarantine/service_test.go index a0708765..cca8e26c 100644 --- a/internal/lidarrquarantine/service_test.go +++ b/internal/lidarrquarantine/service_test.go @@ -491,3 +491,44 @@ func TestDeleteViaLidarr_LidarrAlbumNotFound(t *testing.T) { t.Errorf("err = %v, want ErrLidarrAlbumNotFound", err) } } + +func TestDeleteFile_TrackNotFound(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + var bogus pgtype.UUID + bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + bogus.Valid = true + + svc := NewService(pool, lidarrconfig.New(pool), nil) + _, err := svc.DeleteFile(context.Background(), bogus, user.ID) + if !errors.Is(err, ErrTrackNotFound) { + t.Errorf("err = %v, want ErrTrackNotFound", err) + } +} + +func TestDeleteViaLidarr_TrackNotFound(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(stub.Close) + cfg := lidarrconfig.New(pool) + if err := cfg.Save(context.Background(), lidarrconfig.Config{ + Enabled: true, BaseURL: stub.URL, APIKey: "k", + }); err != nil { + t.Fatalf("save config: %v", err) + } + clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") } + svc := NewService(pool, cfg, clientFn) + + var bogus pgtype.UUID + bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + bogus.Valid = true + + _, _, err := svc.DeleteViaLidarr(context.Background(), bogus, user.ID) + if !errors.Is(err, ErrTrackNotFound) { + t.Errorf("err = %v, want ErrTrackNotFound", err) + } +} diff --git a/web/src/routes/search/tracks/tracks.test.ts b/web/src/routes/search/tracks/tracks.test.ts index 4d515483..f64a46fd 100644 --- a/web/src/routes/search/tracks/tracks.test.ts +++ b/web/src/routes/search/tracks/tracks.test.ts @@ -35,6 +35,13 @@ vi.mock('@tanstack/svelte-query', async (orig) => { return { ...actual, useQueryClient: () => ({}) }; }); +vi.mock('$lib/api/quarantine', () => ({ + flagTrack: vi.fn(), + unflagTrack: vi.fn(), + listMyQuarantine: vi.fn().mockResolvedValue([]), + createMyQuarantineQuery: vi.fn() +})); + import TracksOverflow from './+page.svelte'; import { createSearchTracksInfiniteQuery } from '$lib/api/queries'; import { playRadio } from '$lib/player/store.svelte'; @@ -62,7 +69,7 @@ describe('search tracks overflow', () => { mockInfiniteQuery({ pages: [page([track], 1)] }) ); render(TracksOverflow); - expect(screen.getByRole('button', { name: /So What/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'So What' })).toBeInTheDocument(); }); test('clicking a track row triggers playRadio with the track id', async () => { @@ -70,7 +77,7 @@ describe('search tracks overflow', () => { mockInfiniteQuery({ pages: [page([track], 1)] }) ); render(TracksOverflow); - await fireEvent.click(screen.getByRole('button', { name: /So What/ })); + await fireEvent.click(screen.getByRole('button', { name: 'So What' })); expect(playRadio).toHaveBeenCalledWith('t1'); }); From a0a9fb201b9d1ad14072a6fe5f7e5ff5703c83e1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 22:48:55 -0400 Subject: [PATCH 58/67] docs(spec): add M5c suggested-additions design Personalized artist suggestions on /discover (search-input-empty state). On-demand SQL ranks out-of-library MBIDs from artist_similarity_unmatched (new table, populated by extending the M4b similarity worker) by per-user signal: likes weighted 5x plus recency-decayed plays (exp(-age_days/30)). Top-12 with top-3 contributing seeds attributed per card. Reuses the M5a DiscoverResultCard + POST /api/requests artist-add path. --- ...26-04-30-m5c-suggested-additions-design.md | 360 ++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-30-m5c-suggested-additions-design.md diff --git a/docs/superpowers/specs/2026-04-30-m5c-suggested-additions-design.md b/docs/superpowers/specs/2026-04-30-m5c-suggested-additions-design.md new file mode 100644 index 00000000..641ff1a2 --- /dev/null +++ b/docs/superpowers/specs/2026-04-30-m5c-suggested-additions-design.md @@ -0,0 +1,360 @@ +# 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. From cf1b75ca12b3c0d37fbeabb29821bf49806e6586 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Thu, 30 Apr 2026 23:02:41 -0400 Subject: [PATCH 59/67] docs(plan): add M5c suggested-additions implementation plan 9 tasks: migration 0012 (artist_similarity_unmatched), ListenBrainz client extension (SimilarArtist.Name), similarity worker extension to persist unmatched MBIDs, recommendation.SuggestArtists service with single CTE, GET /api/discover/suggestions handler, frontend client + types, DiscoverResultCard attribution prop, SuggestionFeed component + /discover integration. --- .../2026-04-30-m5c-suggested-additions.md | 1838 +++++++++++++++++ 1 file changed, 1838 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-30-m5c-suggested-additions.md diff --git a/docs/superpowers/plans/2026-04-30-m5c-suggested-additions.md b/docs/superpowers/plans/2026-04-30-m5c-suggested-additions.md new file mode 100644 index 00000000..d73588c5 --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-m5c-suggested-additions.md @@ -0,0 +1,1838 @@ +# M5c — Suggested additions on `/discover` — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Personalized artist suggestions on `/discover` (search-input-empty state). Top-12 out-of-library artists ranked by per-user signal (likes ×5 + recency-decayed plays), with top-3 contributing seeds attributed per card. One-click add via the existing M5a Lidarr-request flow. + +**Architecture:** Extend the M4b similarity ingest worker to persist unmatched artist MBIDs to a new `artist_similarity_unmatched` table (mirrors `artist_similarity` shape). New `internal/recommendation` service runs a single CTE at request time that scores candidates from the user's likes + plays through the unmatched table. New `GET /api/discover/suggestions` handler. Frontend swaps `<DiscoverResultCard>` between the existing search results and a new suggestion feed when the search input is empty. + +**Tech Stack:** Go 1.23 · pgx/v5 + sqlc · Postgres + golang-migrate · SvelteKit 2 / Svelte 5 (runes) · TanStack Query · Vitest · existing FabledSword design tokens. + +**Spec:** [`docs/superpowers/specs/2026-04-30-m5c-suggested-additions-design.md`](../specs/2026-04-30-m5c-suggested-additions-design.md). Read it before starting — every decision is explained there. + +**Memory dependencies:** `project_design_system.md` (FabledSword tokens), `project_subsonic_legacy.md` (`/api/*` is primary), `project_no_github.md` (Forgejo MCP for PR ops), `project_git_workflow.md` (commit on `dev`; PR to `main` separately). + +--- + +## File map + +### Backend — create + +- `internal/db/migrations/0012_artist_similarity_unmatched.up.sql` · `.down.sql` +- `internal/recommendation/suggestions.go` — `SuggestArtists` service + types +- `internal/recommendation/suggestions_integration_test.go` +- `internal/api/suggestions.go` — `GET /api/discover/suggestions` handler +- `internal/api/suggestions_test.go` + +### Backend — modify + +- `internal/db/queries/similarity.sql` — add `UpsertArtistSimilarityUnmatched` +- `internal/db/queries/recommendation.sql` — add `SuggestArtistsForUser` +- `internal/db/dbq/*` — regenerated by `sqlc generate` +- `internal/scrobble/listenbrainz/client.go` — add `Name string \`json:"name"\`` to `SimilarArtist` +- `internal/similarity/worker.go` — extend `upsertArtistSimilar` to persist unmatched MBIDs +- `internal/similarity/worker_test.go` — extend with `TestUpsertArtistSimilar_PersistsUnmatchedToTable` +- `internal/api/api.go` — register `/api/discover/suggestions` route + +### Frontend — create + +- `web/src/lib/api/suggestions.ts` — client (`listSuggestions`, `createSuggestionsQuery`) +- `web/src/lib/api/suggestions.test.ts` +- `web/src/lib/components/SuggestionFeed.svelte` — subcomponent for the suggestion grid +- `web/src/lib/components/SuggestionFeed.test.ts` + +### Frontend — modify + +- `web/src/lib/api/types.ts` — add `ArtistSuggestion`, `SeedContribution` +- `web/src/lib/api/queries.ts` — add `qk.suggestions(limit)` +- `web/src/lib/components/DiscoverResultCard.svelte` — add optional `attribution?: string` prop +- `web/src/lib/components/DiscoverResultCard.test.ts` — assert attribution rendering +- `web/src/routes/discover/+page.svelte` — empty-input branches to `<SuggestionFeed>`; tabs hide when input is empty +- `web/src/routes/discover/discover.test.ts` — extend with suggestion-feed tests + +--- + +## Task list + +### Task 1 — Migration 0012 + worker upsert query + +**Files:** +- Create: `internal/db/migrations/0012_artist_similarity_unmatched.up.sql` +- Create: `internal/db/migrations/0012_artist_similarity_unmatched.down.sql` +- Modify: `internal/db/queries/similarity.sql` +- Regenerate: `internal/db/dbq/*` + +- [ ] **Step 1.1: Write the up migration** + +`internal/db/migrations/0012_artist_similarity_unmatched.up.sql`: + +```sql +-- M5c: persist unmatched-similar-artist MBIDs that the M4b worker would +-- otherwise discard. Mirrors artist_similarity shape: same composite PK +-- with source, same (seed_id, score DESC) index, same source enum check. +-- The candidate side is text + name (no FK) — that's the whole point. + +CREATE TABLE artist_similarity_unmatched ( + seed_artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE, + candidate_mbid text NOT NULL, + candidate_name text NOT NULL, + score DOUBLE PRECISION NOT NULL, + source text NOT NULL CHECK (source IN ('listenbrainz', 'musicbrainz_tag', 'user_cooccurrence')), + fetched_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (seed_artist_id, candidate_mbid, source) +); + +CREATE INDEX artist_similarity_unmatched_seed_score_idx + ON artist_similarity_unmatched (seed_artist_id, score DESC); +``` + +- [ ] **Step 1.2: Write the down migration** + +`internal/db/migrations/0012_artist_similarity_unmatched.down.sql`: + +```sql +DROP INDEX IF EXISTS artist_similarity_unmatched_seed_score_idx; +DROP TABLE IF EXISTS artist_similarity_unmatched; +``` + +- [ ] **Step 1.3: Append the worker upsert query** + +Append to `internal/db/queries/similarity.sql`: + +```sql +-- name: UpsertArtistSimilarityUnmatched :exec +-- Persists an out-of-library similar-artist MBID. Idempotent on +-- (seed_artist_id, candidate_mbid, source) — re-fetches refresh the +-- name/score and bump fetched_at. +INSERT INTO artist_similarity_unmatched ( + seed_artist_id, candidate_mbid, candidate_name, score, source +) VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (seed_artist_id, candidate_mbid, source) DO UPDATE SET + candidate_name = EXCLUDED.candidate_name, + score = EXCLUDED.score, + fetched_at = now(); +``` + +- [ ] **Step 1.4: Add a `dbtest.ResetDB` entry for the new table** + +Modify `internal/dbtest/reset.go` — append `"artist_similarity_unmatched"` to the `dataTables` slice between `track_similarity` and `scrobble_queue` so M5c integration tests don't inherit residual rows. + +```go +var dataTables = []string{ + "artist_similarity", + "track_similarity", + "artist_similarity_unmatched", // M5c + "scrobble_queue", + // ... rest unchanged ... +} +``` + +- [ ] **Step 1.5: Regenerate sqlc** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && sqlc generate +go build ./... +go vet ./... +``` + +Expected: clean build. New method `UpsertArtistSimilarityUnmatched` appears in `internal/db/dbq/similarity.sql.go`. + +- [ ] **Step 1.6: Apply migration to verify it runs** + +```bash +docker compose exec -T postgres psql -U minstrel -d minstrel -c "DROP TABLE IF EXISTS artist_similarity_unmatched;" +# Migration applies on next test run / server start. +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test -race ./internal/db/... -count=1 +docker compose exec -T postgres psql -U minstrel -d minstrel -c "\d artist_similarity_unmatched" +``` + +Expected: `\d` shows the table with all columns + the seed_score index. + +- [ ] **Step 1.7: Commit** + +```bash +git add internal/db/migrations/0012_artist_similarity_unmatched.up.sql \ + internal/db/migrations/0012_artist_similarity_unmatched.down.sql \ + internal/db/queries/similarity.sql \ + internal/db/dbq/ \ + internal/dbtest/reset.go +git commit -m "feat(db): add artist_similarity_unmatched schema (migration 0012)" +``` + +--- + +### Task 2 — Extend `SimilarArtist` with `Name` field + +**Files:** +- Modify: `internal/scrobble/listenbrainz/client.go` — add `Name` to the struct +- Modify: `internal/scrobble/listenbrainz/client_test.go` (if existing tests break) — already-passing tests should still pass since adding a field is additive + +The current struct at `internal/scrobble/listenbrainz/client.go:241-245`: + +```go +type SimilarArtist struct { + MBID string `json:"artist_mbid"` + Score float64 `json:"score"` +} +``` + +ListenBrainz's `/1/explore/similar-artists/{mbid}` endpoint returns each row with `artist_mbid`, `name`, `score`, plus other fields we don't care about. Adding `Name` is a purely additive struct change; existing JSON unmarshal still works for everything else. + +- [ ] **Step 2.1: Add the field** + +Modify `internal/scrobble/listenbrainz/client.go:241-245`: + +```go +type SimilarArtist struct { + MBID string `json:"artist_mbid"` + Name string `json:"name"` + Score float64 `json:"score"` +} +``` + +- [ ] **Step 2.2: Verify existing tests still pass** + +```bash +go test ./internal/scrobble/listenbrainz/... -count=1 +``` + +Expected: all green. The unmarshal already ignored the `name` field; capturing it doesn't break anything. + +- [ ] **Step 2.3: Commit** + +```bash +git add internal/scrobble/listenbrainz/client.go +git commit -m "feat(listenbrainz): expose Name on SimilarArtist for M5c suggestions" +``` + +--- + +### Task 3 — Extend similarity worker to persist unmatched MBIDs + +**Files:** +- Modify: `internal/similarity/worker.go` — extend `upsertArtistSimilar` +- Modify: `internal/similarity/worker_test.go` — add `TestUpsertArtistSimilar_PersistsUnmatchedToTable` + +The current `upsertArtistSimilar` filters returned MBIDs to those in `idByMBID` (in-library only) and discards the rest. M5c keeps the matched-path unchanged but adds a parallel unmatched-persist loop with the same top-K cap. + +- [ ] **Step 3.1: Read the current `upsertArtistSimilar` shape** + +Read `internal/similarity/worker.go:170-200` (function body) before editing. It mirrors `upsertTrackSimilar` exactly — same sort, same `idByMBID`, same top-K pattern. The matched-loop pattern is the template. + +- [ ] **Step 3.2: Extend `upsertArtistSimilar`** + +Replace the function body (`internal/similarity/worker.go:170` onwards). The matched loop stays exactly as it was; we add a second loop that walks the same sorted `results` and persists unmatched rows up to `w.topK`: + +```go +func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artistAID pgtype.UUID, results []listenbrainz.SimilarArtist) { + if len(results) == 0 { + return + } + sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score }) + + mbids := make([]string, 0, len(results)) + for _, r := range results { + mbids = append(mbids, r.MBID) + } + rows, err := q.GetArtistsByMBIDs(ctx, mbids) + if err != nil { + w.logger.Warn("similarity: GetArtistsByMBIDs", "err", err) + return + } + idByMBID := make(map[string]pgtype.UUID, len(rows)) + for _, r := range rows { + if r.Mbid != nil { + idByMBID[*r.Mbid] = r.ID + } + } + + // Matched: in-library similars → artist_similarity (existing path). + takenMatched := 0 + for _, r := range results { + if takenMatched >= w.topK { + break + } + localID, ok := idByMBID[r.MBID] + if !ok { + continue + } + if localID == artistAID { + continue // defensive — DB CHECK constraint also catches self-edges + } + if uerr := q.UpsertArtistSimilarity(ctx, dbq.UpsertArtistSimilarityParams{ + ArtistAID: artistAID, ArtistBID: localID, Score: r.Score, Source: "listenbrainz", + }); uerr != nil { + w.logger.Warn("similarity: UpsertArtistSimilarity", "err", uerr) + continue + } + takenMatched++ + } + + // Unmatched: out-of-library similars → artist_similarity_unmatched (M5c). + // Same top-K cap as the matched path; missing-name rows are skipped (we + // can't render a suggestion without an artist name). + takenUnmatched := 0 + for _, r := range results { + if takenUnmatched >= w.topK { + break + } + if _, inLib := idByMBID[r.MBID]; inLib { + continue + } + if r.Name == "" { + w.logger.Debug("similarity: skipping unmatched similar with empty name", "mbid", r.MBID) + continue + } + if uerr := q.UpsertArtistSimilarityUnmatched(ctx, dbq.UpsertArtistSimilarityUnmatchedParams{ + SeedArtistID: artistAID, + CandidateMbid: r.MBID, + CandidateName: r.Name, + Score: r.Score, + Source: "listenbrainz", + }); uerr != nil { + w.logger.Warn("similarity: UpsertArtistSimilarityUnmatched", "err", uerr) + continue + } + takenUnmatched++ + } +} +``` + +Note: the existing function used a single `taken` variable; the new version splits into `takenMatched` and `takenUnmatched` so each path is bounded independently. Verify the existing call to `UpsertArtistSimilarity` in your repo matches the signature you're substituting — sqlc may name the params struct differently. + +- [ ] **Step 3.3: Write `TestUpsertArtistSimilar_PersistsUnmatchedToTable`** + +Append to `internal/similarity/worker_test.go`: + +```go +func TestUpsertArtistSimilar_PersistsUnmatchedToTable(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + pool := newPool(t) // existing helper + q := dbq.New(pool) + ctx := context.Background() + + // Seed one in-library artist that will be the in-library match. + inLibMBID := "in-lib-mbid-123" + inLibArtist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{ + Name: "InLib Artist", SortName: "InLib Artist", Mbid: &inLibMBID, + }) + if err != nil { + t.Fatalf("seed in-lib artist: %v", err) + } + + // Seed a "seed" artist (the one whose similars we're processing). + seedMBID := "seed-artist-mbid" + seedArtist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{ + Name: "Seed Artist", SortName: "Seed Artist", Mbid: &seedMBID, + }) + if err != nil { + t.Fatalf("seed artist: %v", err) + } + + w := &Worker{pool: pool, logger: newTestLogger(), topK: 10} + + similars := []listenbrainz.SimilarArtist{ + {MBID: inLibMBID, Name: "InLib Artist", Score: 0.95}, + {MBID: "out-mbid-1", Name: "Outsider One", Score: 0.85}, + {MBID: "out-mbid-2", Name: "Outsider Two", Score: 0.80}, + {MBID: "out-mbid-3", Name: "Outsider Three", Score: 0.70}, + {MBID: "out-mbid-4", Name: "", Score: 0.60}, // missing name — should be skipped + } + w.upsertArtistSimilar(ctx, q, seedArtist.ID, similars) + + // Matched path: 1 row in artist_similarity. + var matchedCount int + if err := pool.QueryRow(ctx, + "SELECT count(*) FROM artist_similarity WHERE artist_a_id = $1", + seedArtist.ID, + ).Scan(&matchedCount); err != nil { + t.Fatalf("count matched: %v", err) + } + if matchedCount != 1 { + t.Errorf("artist_similarity rows = %d, want 1 (only the in-library match)", matchedCount) + } + + // Unmatched path: 3 rows (out-mbid-1/2/3); the empty-name row is skipped. + var unmatchedCount int + if err := pool.QueryRow(ctx, + "SELECT count(*) FROM artist_similarity_unmatched WHERE seed_artist_id = $1", + seedArtist.ID, + ).Scan(&unmatchedCount); err != nil { + t.Fatalf("count unmatched: %v", err) + } + if unmatchedCount != 3 { + t.Errorf("artist_similarity_unmatched rows = %d, want 3", unmatchedCount) + } + + // Verify a specific row's name + score round-tripped correctly. + var name string + var score float64 + if err := pool.QueryRow(ctx, + "SELECT candidate_name, score FROM artist_similarity_unmatched WHERE seed_artist_id = $1 AND candidate_mbid = $2", + seedArtist.ID, "out-mbid-1", + ).Scan(&name, &score); err != nil { + t.Fatalf("fetch out-mbid-1: %v", err) + } + if name != "Outsider One" || score != 0.85 { + t.Errorf("row = (%q, %v), want (Outsider One, 0.85)", name, score) + } + + // suppress unused warning if inLibArtist isn't otherwise referenced + _ = inLibArtist +} +``` + +If `internal/similarity/worker_test.go` doesn't already have a `newPool(t)` and `newTestLogger()` helper, mirror the pattern from `internal/lidarrquarantine/service_test.go` — those have working examples. + +- [ ] **Step 3.4: Run tests** + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test -race -count=1 ./internal/similarity/... +``` + +Expected: existing tests still pass + new test passes. + +- [ ] **Step 3.5: Commit** + +```bash +git add internal/similarity/worker.go internal/similarity/worker_test.go +git commit -m "feat(similarity): persist unmatched similar-artist MBIDs for M5c" +``` + +--- + +### Task 4 — `internal/recommendation` `SuggestArtists` service + +**Files:** +- Create: `internal/recommendation/suggestions.go` +- Create: `internal/recommendation/suggestions_integration_test.go` +- Modify: `internal/db/queries/recommendation.sql` — add the suggestion CTE query +- Regenerate: `internal/db/dbq/*` + +- [ ] **Step 4.1: Append the suggestion query** + +Append to `internal/db/queries/recommendation.sql`: + +```sql +-- name: SuggestArtistsForUser :many +-- M5c: per-user artist suggestions ranked by signal × similarity. The +-- seeds CTE collects the user's likes (×5) plus recency-decayed plays +-- (exp(-age_days / $2)). The contributions CTE joins those seeds against +-- artist_similarity_unmatched and filters out candidates already in +-- library or already in a non-terminal lidarr_request. The outer SELECT +-- aggregates per candidate, returning the top-3 contributing seeds for +-- attribution. $1=user_id, $2=half_life_days, $3=limit. +WITH seeds AS ( + SELECT a.id AS artist_id, + 5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END) + + COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2 * 86400.0))), 0) + AS signal, + (gla.artist_id IS NOT NULL) AS is_liked, + COUNT(pe.id) AS play_count + FROM artists a + LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1 + LEFT JOIN tracks t ON t.artist_id = a.id + LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 + WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL + GROUP BY a.id, gla.artist_id +), +contributions AS ( + SELECT u.candidate_mbid, + u.candidate_name, + seeds.artist_id AS seed_id, + seeds.is_liked, + seeds.play_count, + seeds.signal * u.score AS contribution + FROM artist_similarity_unmatched u + JOIN seeds ON seeds.artist_id = u.seed_artist_id + WHERE NOT EXISTS (SELECT 1 FROM artists WHERE mbid = u.candidate_mbid) + AND NOT EXISTS ( + SELECT 1 FROM lidarr_requests r + WHERE r.user_id = $1 + AND r.lidarr_artist_mbid = u.candidate_mbid + AND r.status NOT IN ('rejected', 'failed') + ) +) +SELECT candidate_mbid, + candidate_name, + SUM(contribution)::float8 AS total_score, + (array_agg(seed_id ORDER BY contribution DESC))[1:3] AS top_seed_ids, + (array_agg(contribution ORDER BY contribution DESC))[1:3] AS top_contributions, + (array_agg(is_liked ORDER BY contribution DESC))[1:3] AS top_is_liked, + (array_agg(play_count ORDER BY contribution DESC))[1:3] AS top_play_counts +FROM contributions +GROUP BY candidate_mbid, candidate_name +ORDER BY total_score DESC +LIMIT $3; +``` + +The extra arrays (`top_is_liked`, `top_play_counts`) let the SPA pick "liked"/"played" verbiage per attribution-line slot. + +- [ ] **Step 4.2: Regenerate sqlc** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && sqlc generate +go build ./... +``` + +Expected: clean. New method `SuggestArtistsForUser` in `internal/db/dbq/recommendation.sql.go`. Note the row type may have field names like `TopSeedIds` (plural-suffix suppressed by sqlc) — read the generated file before relying on names in the next step. + +- [ ] **Step 4.3: Write `internal/recommendation/suggestions.go`** + +```go +// suggestions.go is the M5c per-user artist-suggestion service. Reads +// the user's likes + plays, projects them through artist_similarity_unmatched +// via a single CTE, returns top-N candidates with top-3 attribution seeds +// resolved to artist names. +package recommendation + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// ArtistSuggestion is one ranked candidate with its top-3 attribution seeds. +type ArtistSuggestion struct { + MBID string + Name string + Score float64 + Attribution []SeedContribution +} + +// SeedContribution is one of the top-3 contributing seeds for a candidate. +type SeedContribution struct { + ArtistID pgtype.UUID + Name string + Contribution float64 + IsLiked bool + PlayCount int64 +} + +// SuggestArtists returns top-N artist suggestions for the user. limit is +// capped at 50; halfLifeDays is the recency-decay half-life for plays +// (default 30, operator-tunable). +func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, halfLifeDays float64, limit int) ([]ArtistSuggestion, error) { + if limit <= 0 || limit > 50 { + limit = 12 + } + if halfLifeDays <= 0 { + halfLifeDays = 30 + } + q := dbq.New(pool) + rows, err := q.SuggestArtistsForUser(ctx, dbq.SuggestArtistsForUserParams{ + UserID: userID, + Column2: halfLifeDays, // sqlc names unbound positional params Column2/3 — verify + Limit: int32(limit), + }) + if err != nil { + return nil, fmt.Errorf("suggest: query: %w", err) + } + if len(rows) == 0 { + return []ArtistSuggestion{}, nil + } + + // Collect the union of top-3 seed IDs across all rows for one batched + // name lookup. + seedSet := make(map[pgtype.UUID]struct{}, len(rows)*3) + for _, r := range rows { + for _, sid := range r.TopSeedIds { + seedSet[sid] = struct{}{} + } + } + seedIDs := make([]pgtype.UUID, 0, len(seedSet)) + for id := range seedSet { + seedIDs = append(seedIDs, id) + } + artists, err := q.GetArtistsByIDs(ctx, seedIDs) + if err != nil { + return nil, fmt.Errorf("suggest: resolve seeds: %w", err) + } + nameByID := make(map[pgtype.UUID]string, len(artists)) + for _, a := range artists { + nameByID[a.ID] = a.Name + } + + out := make([]ArtistSuggestion, 0, len(rows)) + for _, r := range rows { + attribution := make([]SeedContribution, 0, len(r.TopSeedIds)) + for i, sid := range r.TopSeedIds { + if i >= len(r.TopContributions) { + break + } + attribution = append(attribution, SeedContribution{ + ArtistID: sid, + Name: nameByID[sid], + Contribution: r.TopContributions[i], + IsLiked: r.TopIsLiked[i], + PlayCount: r.TopPlayCounts[i], + }) + } + out = append(out, ArtistSuggestion{ + MBID: r.CandidateMbid, + Name: r.CandidateName, + Score: r.TotalScore, + Attribution: attribution, + }) + } + return out, nil +} +``` + +Two things to verify against the actual generated code in `internal/db/dbq/recommendation.sql.go`: +1. The param struct may name `$2` something other than `Column2` (sqlc occasionally renames). Look at `SuggestArtistsForUserParams` and use whatever's there. +2. `GetArtistsByIDs` may not exist — check `internal/db/queries/artists.sql`. If absent, add a query in this same task: + +```sql +-- name: GetArtistsByIDs :many +SELECT * FROM artists WHERE id = ANY($1::uuid[]); +``` + +(If `GetArtistsByMBIDs` exists but not `GetArtistsByIDs`, add the IDs variant; sqlc-regenerate.) + +- [ ] **Step 4.4: Write integration tests** + +Create `internal/recommendation/suggestions_integration_test.go`: + +```go +package recommendation + +import ( + "context" + "io" + "log/slog" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" +) + +func newPool(t *testing.T) *pgxpool.Pool { + t.Helper() + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + dbtest.ResetDB(t, pool) + return pool +} + +func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User { + t.Helper() + u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ + Username: dbtest.TestUserPrefix + name, PasswordHash: "x", + ApiToken: name + "-token", IsAdmin: false, + }) + if err != nil { + t.Fatalf("seed user: %v", err) + } + return u +} + +func seedArtist(t *testing.T, pool *pgxpool.Pool, name, mbid string) dbq.Artist { + t.Helper() + var mbidPtr *string + if mbid != "" { + mbidPtr = &mbid + } + a, err := dbq.New(pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: name, SortName: name, Mbid: mbidPtr, + }) + if err != nil { + t.Fatalf("seed artist: %v", err) + } + return a +} + +func seedUnmatched(t *testing.T, pool *pgxpool.Pool, seedID pgtype.UUID, candMBID, candName string, score float64) { + t.Helper() + if err := dbq.New(pool).UpsertArtistSimilarityUnmatched(context.Background(), dbq.UpsertArtistSimilarityUnmatchedParams{ + SeedArtistID: seedID, + CandidateMbid: candMBID, + CandidateName: candName, + Score: score, + Source: "listenbrainz", + }); err != nil { + t.Fatalf("seed unmatched: %v", err) + } +} + +func TestSuggestArtists_LikesAndPlaysContributeToScore(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + seedA := seedArtist(t, pool, "Seed Liked", "") + seedB := seedArtist(t, pool, "Seed Played", "") + + // alice likes seedA. + if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ + UserID: user.ID, ArtistID: seedA.ID, + }); err != nil { + t.Fatalf("LikeArtist: %v", err) + } + // alice played seedB. (Need a play_event with the right artist via tracks.) + seedBAlbum := seedAlbumForArtist(t, pool, seedB.ID, "Album B") + seedBTrack := seedTrackOnAlbum(t, pool, seedBAlbum.ID, seedB.ID, "Track B") + insertPlayEvent(t, pool, user.ID, seedBTrack.ID, time.Now().Add(-1*time.Hour)) + + // Both seeds point at the same out-of-library candidate. + seedUnmatched(t, pool, seedA.ID, "out-mbid", "Outsider", 0.9) + seedUnmatched(t, pool, seedB.ID, "out-mbid", "Outsider", 0.5) + + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 1 { + t.Fatalf("len = %d, want 1", len(out)) + } + s := out[0] + if s.MBID != "out-mbid" || s.Name != "Outsider" { + t.Errorf("got = %+v", s) + } + if s.Score <= 0 { + t.Errorf("score = %v, want > 0", s.Score) + } + if len(s.Attribution) != 2 { + t.Errorf("attribution len = %d, want 2", len(s.Attribution)) + } +} + +func TestSuggestArtists_Top12Cap(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + seed := seedArtist(t, pool, "Seed", "") + if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ + UserID: user.ID, ArtistID: seed.ID, + }); err != nil { + t.Fatalf("LikeArtist: %v", err) + } + for i := 0; i < 30; i++ { + seedUnmatched(t, pool, seed.ID, fmt.Sprintf("mbid-%02d", i), fmt.Sprintf("Artist %02d", i), 0.99-float64(i)*0.01) + } + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 12 { + t.Errorf("len = %d, want 12", len(out)) + } + if out[0].MBID != "mbid-00" { + t.Errorf("first = %s, want mbid-00 (highest score)", out[0].MBID) + } +} + +func TestSuggestArtists_AttributionTopThree(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + // 5 seed artists all liked, all pointing at the same candidate but + // with descending similarity scores so contributions order is clean. + seeds := make([]dbq.Artist, 5) + for i := 0; i < 5; i++ { + seeds[i] = seedArtist(t, pool, fmt.Sprintf("Seed %d", i), "") + if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ + UserID: user.ID, ArtistID: seeds[i].ID, + }); err != nil { + t.Fatalf("LikeArtist: %v", err) + } + seedUnmatched(t, pool, seeds[i].ID, "shared-mbid", "Shared", 0.9-float64(i)*0.1) + } + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 1 { + t.Fatalf("len = %d, want 1 (shared candidate)", len(out)) + } + if got := len(out[0].Attribution); got != 3 { + t.Errorf("attribution len = %d, want 3", got) + } + // Verify ordering: highest contribution first (seed 0 with score 0.9). + if out[0].Attribution[0].Name != "Seed 0" { + t.Errorf("top attribution = %q, want Seed 0", out[0].Attribution[0].Name) + } +} + +func TestSuggestArtists_RecencyDecayDownweightsOldPlays(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + recentSeed := seedArtist(t, pool, "Recent", "") + oldSeed := seedArtist(t, pool, "Old", "") + + rAlbum := seedAlbumForArtist(t, pool, recentSeed.ID, "Recent Album") + rTrack := seedTrackOnAlbum(t, pool, rAlbum.ID, recentSeed.ID, "Recent Track") + insertPlayEvent(t, pool, user.ID, rTrack.ID, time.Now().Add(-1*24*time.Hour)) + + oAlbum := seedAlbumForArtist(t, pool, oldSeed.ID, "Old Album") + oTrack := seedTrackOnAlbum(t, pool, oAlbum.ID, oldSeed.ID, "Old Track") + insertPlayEvent(t, pool, user.ID, oTrack.ID, time.Now().Add(-90*24*time.Hour)) + + // Both seeds point at the same candidate with the same similarity score. + seedUnmatched(t, pool, recentSeed.ID, "cand", "Cand", 0.5) + seedUnmatched(t, pool, oldSeed.ID, "cand", "Cand", 0.5) + + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 1 { + t.Fatalf("len = %d, want 1", len(out)) + } + if len(out[0].Attribution) != 2 { + t.Fatalf("attribution len = %d, want 2", len(out[0].Attribution)) + } + // Recent seed contributes more than old seed. + if out[0].Attribution[0].Name != "Recent" { + t.Errorf("top attribution = %q, want Recent (1d-old play decays less than 90d)", out[0].Attribution[0].Name) + } + if out[0].Attribution[0].Contribution <= out[0].Attribution[1].Contribution { + t.Errorf("recent contribution (%v) should exceed old (%v)", + out[0].Attribution[0].Contribution, out[0].Attribution[1].Contribution) + } +} + +func TestSuggestArtists_FiltersInLibraryCandidates(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + seed := seedArtist(t, pool, "Seed", "") + if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ + UserID: user.ID, ArtistID: seed.ID, + }); err != nil { + t.Fatalf("LikeArtist: %v", err) + } + // Candidate that's already in library. + inLibMBID := "in-lib-mbid" + seedArtist(t, pool, "InLib", inLibMBID) + seedUnmatched(t, pool, seed.ID, inLibMBID, "InLib", 0.9) + + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 0 { + t.Errorf("len = %d, want 0 (in-library candidate should be filtered)", len(out)) + } +} + +func TestSuggestArtists_FiltersAlreadyRequested(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + seed := seedArtist(t, pool, "Seed", "") + if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ + UserID: user.ID, ArtistID: seed.ID, + }); err != nil { + t.Fatalf("LikeArtist: %v", err) + } + seedUnmatched(t, pool, seed.ID, "req-mbid", "Pending Request", 0.9) + if _, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{ + UserID: user.ID, + Kind: dbq.LidarrRequestKindArtist, + LidarrArtistMbid: "req-mbid", + ArtistName: "Pending Request", + }); err != nil { + t.Fatalf("CreateLidarrRequest: %v", err) + } + + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 0 { + t.Errorf("len = %d, want 0 (pending request should hide candidate)", len(out)) + } +} + +func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + seed := seedArtist(t, pool, "Seed", "") + if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ + UserID: user.ID, ArtistID: seed.ID, + }); err != nil { + t.Fatalf("LikeArtist: %v", err) + } + seedUnmatched(t, pool, seed.ID, "rej-mbid", "Rejected Once", 0.9) + req, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{ + UserID: user.ID, + Kind: dbq.LidarrRequestKindArtist, + LidarrArtistMbid: "rej-mbid", + ArtistName: "Rejected Once", + }) + if err != nil { + t.Fatalf("CreateLidarrRequest: %v", err) + } + rejNotes := "wrong artist" + if _, err := dbq.New(pool).RejectLidarrRequest(context.Background(), dbq.RejectLidarrRequestParams{ + ID: req.ID, Notes: &rejNotes, DecidedBy: user.ID, + }); err != nil { + t.Fatalf("RejectLidarrRequest: %v", err) + } + + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 1 { + t.Errorf("len = %d, want 1 (rejected requests don't hide the candidate)", len(out)) + } +} + +func TestSuggestArtists_EmptyForNewUser(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "newbie") + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 0 { + t.Errorf("len = %d, want 0 (new user has no signal)", len(out)) + } +} +``` + +Helper functions `seedAlbumForArtist`, `seedTrackOnAlbum`, `insertPlayEvent` are needed. Mirror the same helpers from `internal/lidarrquarantine/service_test.go`'s `seedTrack` (but parameterize artist): + +```go +func seedAlbumForArtist(t *testing.T, pool *pgxpool.Pool, artistID pgtype.UUID, title string) dbq.Album { + t.Helper() + a, err := dbq.New(pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: title, SortTitle: title, ArtistID: artistID, + }) + if err != nil { + t.Fatalf("seed album: %v", err) + } + return a +} + +func seedTrackOnAlbum(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string) dbq.Track { + t.Helper() + tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: title, AlbumID: albumID, ArtistID: artistID, + DurationMs: 1000, FilePath: "/tmp/m5c-" + title + ".mp3", + FileSize: 1, FileFormat: "mp3", + }) + if err != nil { + t.Fatalf("seed track: %v", err) + } + return tr +} + +func insertPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time) { + t.Helper() + if _, err := pool.Exec(context.Background(), + `INSERT INTO play_events (user_id, track_id, started_at, was_skipped) VALUES ($1, $2, $3, false)`, + userID, trackID, startedAt, + ); err != nil { + t.Fatalf("insert play_event: %v", err) + } +} +``` + +The exact `play_events` column set may differ from this minimal insert — read the migration `0005_events.up.sql` and add any required NOT-NULL columns (e.g. `client_id`, `session_id`) with sensible defaults if the insert fails. + +- [ ] **Step 4.5: Run tests** + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test -race -count=1 -p 1 ./internal/recommendation/... +``` + +Expected: 7 tests pass. + +- [ ] **Step 4.6: Commit** + +```bash +git add internal/recommendation/suggestions.go \ + internal/recommendation/suggestions_integration_test.go \ + internal/db/queries/recommendation.sql \ + internal/db/queries/artists.sql \ + internal/db/dbq/ +git commit -m "feat(recommendation): SuggestArtists service for M5c" +``` + +(Include `artists.sql` if you added `GetArtistsByIDs` to it in Step 4.3.) + +--- + +### Task 5 — `/api/discover/suggestions` handler + route mount + +**Files:** +- Create: `internal/api/suggestions.go` +- Create: `internal/api/suggestions_test.go` +- Modify: `internal/api/api.go` — add the route + +- [ ] **Step 5.1: Write the handler** + +Create `internal/api/suggestions.go`: + +```go +package api + +import ( + "net/http" + "strconv" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" +) + +// suggestionView is the wire shape returned by GET /api/discover/suggestions. +type suggestionView struct { + MBID string `json:"mbid"` + Name string `json:"name"` + Score float64 `json:"score"` + Attribution []seedContributionView `json:"attribution"` +} + +type seedContributionView struct { + ArtistID pgtype.UUID `json:"artist_id"` + Name string `json:"name"` + Contribution float64 `json:"contribution"` + IsLiked bool `json:"is_liked"` + PlayCount int64 `json:"play_count"` +} + +// handleListSuggestions implements GET /api/discover/suggestions. +// +// Query params: +// - limit (default 12, capped at 50) +// - half_life_days (default 30, no max) +// +// Returns 200 with a JSON array (possibly empty). Read-only; no admin gate. +func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + limit := 12 + if v := r.URL.Query().Get("limit"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid limit") + return + } + limit = n + } + halfLife := 30.0 + if v := r.URL.Query().Get("half_life_days"); v != "" { + f, err := strconv.ParseFloat(v, 64) + if err != nil || f <= 0 { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid half_life_days") + return + } + halfLife = f + } + + suggestions, err := recommendation.SuggestArtists(r.Context(), h.pool, user.ID, halfLife, limit) + if err != nil { + h.logger.Error("api: list suggestions", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "failed to load suggestions") + return + } + + out := make([]suggestionView, 0, len(suggestions)) + for _, s := range suggestions { + attr := make([]seedContributionView, 0, len(s.Attribution)) + for _, a := range s.Attribution { + attr = append(attr, seedContributionView{ + ArtistID: a.ArtistID, + Name: a.Name, + Contribution: a.Contribution, + IsLiked: a.IsLiked, + PlayCount: a.PlayCount, + }) + } + out = append(out, suggestionView{ + MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr, + }) + } + writeJSON(w, http.StatusOK, out) +} +``` + +- [ ] **Step 5.2: Mount the route** + +In `internal/api/api.go`, find the authed group (after `r.Get("/api/radio", ...)` line) and add: + +```go +authed.Get("/discover/suggestions", h.handleListSuggestions) +``` + +- [ ] **Step 5.3: Write handler tests** + +Create `internal/api/suggestions_test.go`: + +```go +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" +) + +func TestSuggestions_HappyPath(t *testing.T) { + h, _ := testHandlers(t) + user := seedUser(t, h.pool, "alice", "pw", false) + + // Seed: alice likes a seed artist; that seed has one out-of-library + // similar in artist_similarity_unmatched. + seedA, err := dbq.New(h.pool).UpsertArtist(t.Context(), dbq.UpsertArtistParams{ + Name: "Seed", SortName: "Seed", + }) + if err != nil { + t.Fatalf("UpsertArtist: %v", err) + } + if _, err := dbq.New(h.pool).LikeArtist(t.Context(), dbq.LikeArtistParams{ + UserID: user.ID, ArtistID: seedA.ID, + }); err != nil { + t.Fatalf("LikeArtist: %v", err) + } + if err := dbq.New(h.pool).UpsertArtistSimilarityUnmatched(t.Context(), dbq.UpsertArtistSimilarityUnmatchedParams{ + SeedArtistID: seedA.ID, + CandidateMbid: "out-mbid", + CandidateName: "Outsider", + Score: 0.9, + Source: "listenbrainz", + }); err != nil { + t.Fatalf("UpsertArtistSimilarityUnmatched: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/discover/suggestions", nil) + setUserCtx(req, user) + w := httptest.NewRecorder() + h.handleListSuggestions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var got []suggestionView + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 1 { + t.Fatalf("len = %d, want 1; body = %s", len(got), w.Body.String()) + } + if got[0].MBID != "out-mbid" || got[0].Name != "Outsider" { + t.Errorf("got = %+v", got[0]) + } + if len(got[0].Attribution) != 1 { + t.Errorf("attribution len = %d, want 1", len(got[0].Attribution)) + } + if got[0].Attribution[0].Name != "Seed" { + t.Errorf("attribution name = %q, want Seed", got[0].Attribution[0].Name) + } +} + +func TestSuggestions_EmptyForNewUser(t *testing.T) { + h, _ := testHandlers(t) + user := seedUser(t, h.pool, "newbie", "pw", false) + + req := httptest.NewRequest(http.MethodGet, "/api/discover/suggestions", nil) + setUserCtx(req, user) + w := httptest.NewRecorder() + h.handleListSuggestions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if got := w.Body.String(); got != "[]\n" && got != "[]" { + t.Errorf("body = %q, want []", got) + } +} + +func TestSuggestions_BadLimit(t *testing.T) { + h, _ := testHandlers(t) + user := seedUser(t, h.pool, "alice", "pw", false) + + req := httptest.NewRequest(http.MethodGet, "/api/discover/suggestions?limit=not-a-number", nil) + setUserCtx(req, user) + w := httptest.NewRecorder() + h.handleListSuggestions(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +// suppress unused imports in some test layouts +var _ = dbtest.TestUserPrefix +``` + +`testHandlers`, `seedUser`, `setUserCtx` are existing helpers in the test layout — see `internal/api/auth_test.go` and `internal/api/requests_test.go`. Use whatever pattern those tests use to seed a user and inject it into the request context. + +- [ ] **Step 5.4: Run tests** + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test -race -count=1 -p 1 ./internal/api/... -run Suggestions +``` + +Expected: 3 tests pass. + +- [ ] **Step 5.5: Commit** + +```bash +git add internal/api/suggestions.go internal/api/suggestions_test.go internal/api/api.go +git commit -m "feat(api): /api/discover/suggestions handler" +``` + +--- + +### Task 6 — Frontend API client + types + qk + +**Files:** +- Create: `web/src/lib/api/suggestions.ts` +- Create: `web/src/lib/api/suggestions.test.ts` +- Modify: `web/src/lib/api/types.ts` — add `ArtistSuggestion`, `SeedContribution` +- Modify: `web/src/lib/api/queries.ts` — add `qk.suggestions(limit)` + +- [ ] **Step 6.1: Add types** + +Append to `web/src/lib/api/types.ts`: + +```ts +export type SeedContribution = { + artist_id: string; + name: string; + contribution: number; + is_liked: boolean; + play_count: number; +}; + +export type ArtistSuggestion = { + mbid: string; + name: string; + score: number; + attribution: SeedContribution[]; // up to 3 entries, ordered by contribution DESC +}; +``` + +- [ ] **Step 6.2: Add query key** + +Append to the `qk` object in `web/src/lib/api/queries.ts`: + +```ts +suggestions: (limit?: number) => ['suggestions', { limit: limit ?? 12 }] as const, +``` + +- [ ] **Step 6.3: Write `suggestions.ts`** + +Create `web/src/lib/api/suggestions.ts`: + +```ts +import { createQuery } from '@tanstack/svelte-query'; +import { api } from './client'; +import { qk } from './queries'; +import type { ArtistSuggestion } from './types'; + +export async function listSuggestions(limit = 12): Promise<ArtistSuggestion[]> { + return api.get<ArtistSuggestion[]>(`/api/discover/suggestions?limit=${limit}`); +} + +export function createSuggestionsQuery(limit = 12) { + return createQuery({ + queryKey: qk.suggestions(limit), + queryFn: () => listSuggestions(limit), + staleTime: 5 * 60_000 // 5 minutes + }); +} +``` + +- [ ] **Step 6.4: Write tests** + +Create `web/src/lib/api/suggestions.test.ts`: + +```ts +import { describe, expect, test, vi, afterEach } from 'vitest'; + +vi.mock('./client', () => ({ + api: { get: vi.fn() } +})); + +import { listSuggestions } from './suggestions'; +import { qk } from './queries'; +import { api } from './client'; +import type { ArtistSuggestion } from './types'; + +afterEach(() => vi.clearAllMocks()); + +describe('suggestions client', () => { + test('listSuggestions hits the right URL with default limit', async () => { + const fixture: ArtistSuggestion[] = [ + { + mbid: 'm1', + name: 'Outsider', + score: 1.5, + attribution: [ + { artist_id: 'a1', name: 'Seed', contribution: 0.9, is_liked: true, play_count: 0 } + ] + } + ]; + (api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(fixture); + const got = await listSuggestions(); + expect(api.get).toHaveBeenCalledWith('/api/discover/suggestions?limit=12'); + expect(got).toEqual(fixture); + }); + + test('listSuggestions honors a custom limit', async () => { + (api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]); + await listSuggestions(20); + expect(api.get).toHaveBeenCalledWith('/api/discover/suggestions?limit=20'); + }); + + test('qk.suggestions key shape', () => { + expect(qk.suggestions()).toEqual(['suggestions', { limit: 12 }]); + expect(qk.suggestions(20)).toEqual(['suggestions', { limit: 20 }]); + }); +}); +``` + +- [ ] **Step 6.5: Verify** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web +npm run check +npm test -- --run suggestions +cd .. +``` + +Expected: 0 errors, 3 tests pass. + +- [ ] **Step 6.6: Commit** + +```bash +git add web/src/lib/api/suggestions.ts web/src/lib/api/suggestions.test.ts \ + web/src/lib/api/types.ts web/src/lib/api/queries.ts +git commit -m "feat(web): API client for /api/discover/suggestions" +``` + +--- + +### Task 7 — Extend `<DiscoverResultCard>` with `attribution` prop + +**Files:** +- Modify: `web/src/lib/components/DiscoverResultCard.svelte` +- Modify: `web/src/lib/components/DiscoverResultCard.test.ts` + +The card already has a `$props()` block accepting `kind`, `title`, `subtitle?`, `imageUrl?`, `state`, `onRequest?`. Add `attribution?: string` and render it in italic Vellum below the title (above the reserved badge slot). + +- [ ] **Step 7.1: Add the prop** + +In `DiscoverResultCard.svelte`, modify the `$props()` destructure: + +```ts +let { + kind, + title, + subtitle, + imageUrl, + state, + attribution, + onRequest, +}: { + kind: DiscoverCardKind; + title: string; + subtitle?: string; + imageUrl?: string; + state: DiscoverCardState; + attribution?: string; + onRequest?: () => void; +} = $props(); +``` + +- [ ] **Step 7.2: Render the attribution line** + +Find the section rendering the title + subtitle (search for `class="title"` and `class="subtitle"`). Add the attribution line between subtitle and `.badge-row`: + +```svelte +<div class="text mt-3"> + <div class="title text-base font-medium text-text-primary">{title}</div> + {#if subtitle} + <div class="subtitle text-sm text-text-secondary">{subtitle}</div> + {/if} + {#if attribution} + <div class="attribution text-xs italic text-text-secondary" data-testid="attribution"> + {attribution} + </div> + {/if} + <div class="badge-row" data-testid="badge-row"> + {#if state === 'kept'} + <span class="kept-pill" role="status">Kept</span> + {/if} + </div> +</div> +``` + +- [ ] **Step 7.3: Add tests** + +Append to `DiscoverResultCard.test.ts`: + +```ts +test('renders attribution line when prop is set', () => { + render(DiscoverResultCard, { + props: { + kind: 'artist', + title: 'Outsider', + state: 'requestable', + attribution: 'Because you liked Boards of Canada and played Aphex Twin.' + } + }); + expect(screen.getByTestId('attribution')).toHaveTextContent('Because you liked Boards of Canada and played Aphex Twin.'); +}); + +test('omits attribution line when prop is absent', () => { + render(DiscoverResultCard, { + props: { kind: 'artist', title: 'Outsider', state: 'requestable' } + }); + expect(screen.queryByTestId('attribution')).not.toBeInTheDocument(); +}); +``` + +- [ ] **Step 7.4: Verify + commit** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web +npm run check +npm test -- --run DiscoverResultCard +cd .. +git add web/src/lib/components/DiscoverResultCard.svelte web/src/lib/components/DiscoverResultCard.test.ts +git commit -m "feat(web): DiscoverResultCard attribution prop for M5c suggestions" +``` + +--- + +### Task 8 — `<SuggestionFeed>` subcomponent + `/discover` integration + +**Files:** +- Create: `web/src/lib/components/SuggestionFeed.svelte` +- Create: `web/src/lib/components/SuggestionFeed.test.ts` +- Modify: `web/src/routes/discover/+page.svelte` — branch to `<SuggestionFeed>` when search is empty +- Modify: `web/src/routes/discover/discover.test.ts` — add suggestion-feed scenarios + +- [ ] **Step 8.1: Write `<SuggestionFeed>`** + +Create `web/src/lib/components/SuggestionFeed.svelte`: + +```svelte +<script lang="ts"> + import { useQueryClient } from '@tanstack/svelte-query'; + import { createSuggestionsQuery } from '$lib/api/suggestions'; + import { createRequest } from '$lib/api/requests'; + import { qk } from '$lib/api/queries'; + import DiscoverResultCard from './DiscoverResultCard.svelte'; + import type { ArtistSuggestion, SeedContribution } from '$lib/api/types'; + + const client = useQueryClient(); + const queryStore = createSuggestionsQuery(); + const query = $derived($queryStore); + const suggestions = $derived((query.data ?? []) as ArtistSuggestion[]); + + // Track MBIDs the user just requested so the card flips immediately. + let optimisticRequested = $state(new Set<string>()); + + function visible(s: ArtistSuggestion): boolean { + return !optimisticRequested.has(s.mbid); + } + + function attributionText(attribution: SeedContribution[]): string { + if (attribution.length === 0) return ''; + const verb = (s: SeedContribution) => (s.is_liked ? 'liked' : 'played'); + const phrases = attribution.map((s) => `${verb(s)} ${s.name}`); + if (phrases.length === 1) { + return `Because you ${phrases[0]}.`; + } + if (phrases.length === 2) { + return `Because you ${phrases[0]} and ${phrases[1]}.`; + } + // 3 with Oxford comma + return `Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}.`; + } + + async function onRequest(s: ArtistSuggestion) { + try { + await createRequest({ + kind: 'artist', + lidarr_artist_mbid: s.mbid, + artist_name: s.name + }); + const next = new Set(optimisticRequested); + next.add(s.mbid); + optimisticRequested = next; + // Suggestions filter requested MBIDs server-side; refetch to drop the row. + await client.invalidateQueries({ queryKey: qk.suggestions() }); + } catch { + // Swallow for v1; the SPA will refetch on next mount and the card stays + // requestable so the user can retry. + } + } +</script> + +<div> + <header class="mb-4 space-y-1"> + <h2 class="font-display text-2xl font-medium text-text-primary">Suggested for you</h2> + <p class="text-text-secondary">Out-of-library artists drawn from what you've liked and played.</p> + </header> + + {#if !query.isPending && suggestions.length === 0} + <p class="text-text-secondary">Listen to something or like an artist to start getting suggestions.</p> + {:else if suggestions.length > 0} + <div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> + {#each suggestions.filter(visible) as s (s.mbid)} + <DiscoverResultCard + kind="artist" + title={s.name} + state="requestable" + attribution={attributionText(s.attribution)} + onRequest={() => onRequest(s)} + /> + {/each} + </div> + {/if} +</div> +``` + +- [ ] **Step 8.2: Write `<SuggestionFeed>` tests** + +Create `web/src/lib/components/SuggestionFeed.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { mockQuery } from '../../test-utils/query'; + +const invalidateMock = vi.fn(); +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record<string, unknown>; + return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) }; +}); + +vi.mock('$lib/api/suggestions', () => ({ + createSuggestionsQuery: vi.fn() +})); + +vi.mock('$lib/api/requests', () => ({ + createRequest: vi.fn().mockResolvedValue({}) +})); + +import SuggestionFeed from './SuggestionFeed.svelte'; +import { createSuggestionsQuery } from '$lib/api/suggestions'; +import { createRequest } from '$lib/api/requests'; +import type { ArtistSuggestion } from '$lib/api/types'; + +const oneSeed: ArtistSuggestion = { + mbid: 'mb1', name: 'Outsider', score: 1.0, + attribution: [{ artist_id: 'a1', name: 'Seed', contribution: 0.9, is_liked: true, play_count: 0 }] +}; + +const twoSeeds: ArtistSuggestion = { + mbid: 'mb2', name: 'Outsider Two', score: 2.0, + attribution: [ + { artist_id: 'a1', name: 'A', contribution: 0.8, is_liked: true, play_count: 0 }, + { artist_id: 'a2', name: 'B', contribution: 0.5, is_liked: false, play_count: 3 } + ] +}; + +const threeSeeds: ArtistSuggestion = { + mbid: 'mb3', name: 'Outsider Three', score: 3.0, + attribution: [ + { artist_id: 'a1', name: 'X', contribution: 0.9, is_liked: true, play_count: 0 }, + { artist_id: 'a2', name: 'Y', contribution: 0.6, is_liked: false, play_count: 5 }, + { artist_id: 'a3', name: 'Z', contribution: 0.3, is_liked: false, play_count: 1 } + ] +}; + +afterEach(() => vi.clearAllMocks()); + +describe('SuggestionFeed', () => { + test('renders one card per suggestion', () => { + (createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: [oneSeed, twoSeeds] }) + ); + render(SuggestionFeed); + expect(screen.getByText('Outsider')).toBeInTheDocument(); + expect(screen.getByText('Outsider Two')).toBeInTheDocument(); + }); + + test('attribution copy: 1 seed → "Because you liked X."', () => { + (createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: [oneSeed] }) + ); + render(SuggestionFeed); + expect(screen.getByText(/because you liked seed\./i)).toBeInTheDocument(); + }); + + test('attribution copy: 2 seeds → "Because you liked A and played B."', () => { + (createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: [twoSeeds] }) + ); + render(SuggestionFeed); + expect(screen.getByText(/because you liked a and played b\./i)).toBeInTheDocument(); + }); + + test('attribution copy: 3 seeds → Oxford comma', () => { + (createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: [threeSeeds] }) + ); + render(SuggestionFeed); + expect(screen.getByText(/because you liked x, played y, and played z\./i)).toBeInTheDocument(); + }); + + test('Request button calls createRequest with artist-kind body', async () => { + (createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: [oneSeed] }) + ); + render(SuggestionFeed); + await fireEvent.click(screen.getByRole('button', { name: /request outsider/i })); + expect(createRequest).toHaveBeenCalledWith({ + kind: 'artist', + lidarr_artist_mbid: 'mb1', + artist_name: 'Outsider' + }); + expect(invalidateMock).toHaveBeenCalled(); + }); + + test('empty state when data is []', () => { + (createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] })); + render(SuggestionFeed); + expect(screen.getByText(/listen to something or like an artist/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 8.3: Wire `<SuggestionFeed>` into `/discover`** + +Modify `web/src/routes/discover/+page.svelte`. Read it first — the existing structure runs the search query when `debouncedQ.length > 0`. Add the feed branch: + +```svelte +<script lang="ts"> + // ... existing imports ... + import SuggestionFeed from '$lib/components/SuggestionFeed.svelte'; +</script> + +<!-- existing markup ... before the kind tabs / search results: --> + +<div class="space-y-6"> + <!-- search input always visible --> + <input ... /> + + {#if debouncedQ === ''} + <SuggestionFeed /> + {:else} + <!-- existing: kind tabs + search-results grid + track-kind modal --> + {existing markup unchanged} + {/if} +</div> +``` + +The search-input element stays at the top level (visible in both branches). The header copy ("Add music to the library" vs "Suggested for you") is now owned by the respective branch — `<SuggestionFeed>` renders its own header; the search branch keeps the existing one. + +If the existing `+page.svelte` has its header above the input, you'll need to move it inside the search branch. Pattern: + +```svelte +<input bind:value={inputValue} ... /> + +{#if debouncedQ === ''} + <SuggestionFeed /> +{:else} + <header> + <h2>Add music to the library</h2> + <p>...</p> + </header> + <!-- kind tabs + grid + modal as before --> +{/if} +``` + +- [ ] **Step 8.4: Update `discover.test.ts`** + +The existing tests assume `inputValue === ''` shows the initial-copy state. Now it shows the suggestion feed. Update the relevant test and add new ones: + +Add a mock for `$lib/api/suggestions`: + +```ts +vi.mock('$lib/api/suggestions', () => ({ + createSuggestionsQuery: vi.fn() +})); +``` + +Update the existing "initial state shows search prompt copy" test (or add a replacement): + +```ts +test('empty input shows the suggestion feed', () => { + (createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: [] }) + ); + render(DiscoverPage); + expect(screen.getByText(/suggested for you/i)).toBeInTheDocument(); +}); + +test('typing replaces feed with search', async () => { + (createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: [] }) + ); + // mock the lidarr search query as before + // ... fire input event to trigger debounced search ... + // ... advance fake timers ... + expect(screen.queryByText(/suggested for you/i)).not.toBeInTheDocument(); + expect(screen.getByText(/add music to the library/i)).toBeInTheDocument(); +}); +``` + +The existing tests that drive the search flow stay — they always provide a non-empty query. The empty-input case becomes a suggestion-feed test. + +- [ ] **Step 8.5: Verify** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web +npm run check +npm test -- --run SuggestionFeed discover +npm run build +cd .. +``` + +Expected: 0 errors, all tests pass, build clean. + +- [ ] **Step 8.6: Commit** + +```bash +git add web/src/lib/components/SuggestionFeed.svelte \ + web/src/lib/components/SuggestionFeed.test.ts \ + web/src/routes/discover/+page.svelte \ + web/src/routes/discover/discover.test.ts +git commit -m "feat(web): suggestion feed on /discover (search-empty default)" +``` + +--- + +### Task 9 — Final verification + branch finish + +- [ ] **Step 9.1: Full Go test sweep** + +```bash +go test -short -race ./... +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test -race -p 1 ./... +``` + +Expected: short suite + integration suite both green. The pre-existing `internal/library/TestScanner_Integration` flake is documented (`project_scanner_flake.md`) and not blocking. + +- [ ] **Step 9.2: Lint clean** + +```bash +golangci-lint run ./... +``` + +Expected: no output. + +- [ ] **Step 9.3: Coverage check** + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + bash -c 'go test -race -p 1 -coverprofile=/tmp/cov.out \ + ./internal/recommendation/... ./internal/similarity/... ./internal/api/... && \ + go tool cover -func=/tmp/cov.out | tail -1' +``` + +Expected: combined ≥ 80% on the new code per spec §8. + +- [ ] **Step 9.4: Frontend full check** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web +npm run check +npm test -- --run +npm run build +cd .. +``` + +Expected: 0 errors, all tests pass, build succeeds. + +- [ ] **Step 9.5: Manual smoke** + +- Like an artist (or play a few tracks). +- Open `/discover` with the search input empty. +- Verify the "Suggested for you" header + grid renders. +- Verify each card has an attribution line that reads naturally. +- Click Request on a suggestion → confirm it disappears (optimistic) and a row appears at `/requests`. +- Type a search term → confirm the feed swaps out for search results. +- Clear the search input → confirm the feed comes back (cached, instant). +- For a fresh user with no likes/plays, the empty-state copy renders. + +- [ ] **Step 9.6: Use `superpowers:finishing-a-development-branch`** + +Verify tests are still green, then run the skill to present finish options (merge / PR / keep / discard). Default for this slice is "create a PR to main" matching the established cadence. + +--- + +## Self-review checklist + +**Spec coverage** — every spec section maps to a task: +- §3 Architecture: Tasks 1 (table), 2 (LB client), 3 (worker), 4 (service), 5 (handler), 6-8 (frontend) +- §4 Schema: Task 1 +- §5 API surface: Task 5 +- §6 UI surfaces: Tasks 7 (DiscoverResultCard), 8 (SuggestionFeed + /discover integration) +- §7 Error handling: distributed across Tasks 3 (worker WARN), 5 (handler 500), 8 (frontend silent-on-failure) +- §8 Testing: every Task includes tests; Task 9 verifies coverage targets +- §9 Decisions ledger: not directly implemented, referenced in commit messages +- §10 Out of scope: explicitly excluded — no album/track suggestions, no realtime invalidation, no cross-user CF, no pagination, no materialization +- §11 Open questions: Task 2 verifies the `Name` field on `SimilarArtist`; cold-start sparseness is documented behavior + +**Placeholder scan:** the per-task detail level drops after Task 5 (frontend tasks become standard SvelteKit page work) — intentional for navigability. No "TBD" or "TODO" remains. + +**Type consistency:** +- Service method: `SuggestArtists` consistent across plan +- Types: `ArtistSuggestion`, `SeedContribution` consistent across Go and TS +- API path: `/api/discover/suggestions` consistent +- Component name: `<SuggestionFeed>` consistent +- DB field names: `seed_artist_id`, `candidate_mbid`, `candidate_name`, `score`, `source`, `fetched_at` consistent across migration / queries / tests + +Plan is complete. From 2ca09749d90c7e2e1a2396e2d198030e5f05e899 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Fri, 1 May 2026 05:50:22 -0400 Subject: [PATCH 60/67] feat(db): add artist_similarity_unmatched schema (migration 0012) --- internal/db/dbq/models.go | 9 ++++++ internal/db/dbq/similarity.sql.go | 32 +++++++++++++++++++ .../0012_artist_similarity_unmatched.down.sql | 2 ++ .../0012_artist_similarity_unmatched.up.sql | 17 ++++++++++ internal/db/queries/similarity.sql | 12 +++++++ internal/dbtest/reset.go | 1 + 6 files changed, 73 insertions(+) create mode 100644 internal/db/migrations/0012_artist_similarity_unmatched.down.sql create mode 100644 internal/db/migrations/0012_artist_similarity_unmatched.up.sql diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 052bf95f..be014e20 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -216,6 +216,15 @@ type ArtistSimilarity struct { FetchedAt pgtype.Timestamptz } +type ArtistSimilarityUnmatched struct { + SeedArtistID pgtype.UUID + CandidateMbid string + CandidateName string + Score float64 + Source string + FetchedAt pgtype.Timestamptz +} + type ContextualLike struct { ID pgtype.UUID UserID pgtype.UUID diff --git a/internal/db/dbq/similarity.sql.go b/internal/db/dbq/similarity.sql.go index 2673985c..5fff165f 100644 --- a/internal/db/dbq/similarity.sql.go +++ b/internal/db/dbq/similarity.sql.go @@ -172,6 +172,38 @@ func (q *Queries) UpsertArtistSimilarity(ctx context.Context, arg UpsertArtistSi return err } +const upsertArtistSimilarityUnmatched = `-- 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() +` + +type UpsertArtistSimilarityUnmatchedParams struct { + SeedArtistID pgtype.UUID + CandidateMbid string + CandidateName string + Score float64 + Source string +} + +// Persists an out-of-library similar-artist MBID. Idempotent on +// (seed_artist_id, candidate_mbid, source) — re-fetches refresh the +// name/score and bump fetched_at. +func (q *Queries) UpsertArtistSimilarityUnmatched(ctx context.Context, arg UpsertArtistSimilarityUnmatchedParams) error { + _, err := q.db.Exec(ctx, upsertArtistSimilarityUnmatched, + arg.SeedArtistID, + arg.CandidateMbid, + arg.CandidateName, + arg.Score, + arg.Source, + ) + return err +} + const upsertTrackSimilarity = `-- name: UpsertTrackSimilarity :exec INSERT INTO track_similarity (track_a_id, track_b_id, score, source, fetched_at) VALUES ($1, $2, $3, 'listenbrainz', now()) diff --git a/internal/db/migrations/0012_artist_similarity_unmatched.down.sql b/internal/db/migrations/0012_artist_similarity_unmatched.down.sql new file mode 100644 index 00000000..29d52c9e --- /dev/null +++ b/internal/db/migrations/0012_artist_similarity_unmatched.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS artist_similarity_unmatched_seed_score_idx; +DROP TABLE IF EXISTS artist_similarity_unmatched; diff --git a/internal/db/migrations/0012_artist_similarity_unmatched.up.sql b/internal/db/migrations/0012_artist_similarity_unmatched.up.sql new file mode 100644 index 00000000..0d7cf1cb --- /dev/null +++ b/internal/db/migrations/0012_artist_similarity_unmatched.up.sql @@ -0,0 +1,17 @@ +-- 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); diff --git a/internal/db/queries/similarity.sql b/internal/db/queries/similarity.sql index 15a76b58..c9688c00 100644 --- a/internal/db/queries/similarity.sql +++ b/internal/db/queries/similarity.sql @@ -48,3 +48,15 @@ INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_ 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; + +-- name: UpsertArtistSimilarityUnmatched :exec +-- Persists an out-of-library similar-artist MBID. Idempotent on +-- (seed_artist_id, candidate_mbid, source) — re-fetches refresh the +-- name/score and bump fetched_at. +INSERT INTO artist_similarity_unmatched ( + seed_artist_id, candidate_mbid, candidate_name, score, source +) VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (seed_artist_id, candidate_mbid, source) DO UPDATE SET + candidate_name = EXCLUDED.candidate_name, + score = EXCLUDED.score, + fetched_at = now(); diff --git a/internal/dbtest/reset.go b/internal/dbtest/reset.go index 65c72225..3395d19a 100644 --- a/internal/dbtest/reset.go +++ b/internal/dbtest/reset.go @@ -40,6 +40,7 @@ const TestUserPrefix = "test-" var dataTables = []string{ "artist_similarity", "track_similarity", + "artist_similarity_unmatched", // M5c "scrobble_queue", "contextual_likes", "general_likes_albums", From be23ae488d28c101477db6c47af3ed1236768cf5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Fri, 1 May 2026 05:51:29 -0400 Subject: [PATCH 61/67] feat(listenbrainz): expose Name on SimilarArtist for M5c suggestions --- internal/scrobble/listenbrainz/client.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/scrobble/listenbrainz/client.go b/internal/scrobble/listenbrainz/client.go index dafac9ee..42d3dd7a 100644 --- a/internal/scrobble/listenbrainz/client.go +++ b/internal/scrobble/listenbrainz/client.go @@ -239,8 +239,11 @@ func (c *Client) SimilarRecordings(ctx context.Context, mbid string, limit int) const lbSimilarArtistsAlgorithm = "session_based_days_7500_session_30_contribution_5_threshold_15_limit_100_filter_True_skip_30" // SimilarArtist is one entry in the /explore/similar-artists response. +// Name is captured from the LB payload so M5c can render the artist's +// name on out-of-library suggestions without an extra MusicBrainz lookup. type SimilarArtist struct { MBID string `json:"artist_mbid"` + Name string `json:"name"` Score float64 `json:"score"` } From 5e73f590a987bdc097a7f83aef0618ec5edfb2d8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Fri, 1 May 2026 06:07:22 -0400 Subject: [PATCH 62/67] feat(similarity): persist unmatched similar-artist MBIDs for M5c MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit upsertArtistSimilar keeps the existing matched path (top-K rows into artist_similarity) and adds a parallel unmatched-persist loop with the same top-K cap. Empty-name rows are skipped — we can't render a suggestion card without a name. Logs unmatched-side errors at WARN without aborting the tick (mirrors the matched-path policy). --- internal/similarity/worker.go | 35 +++++++++- .../similarity/worker_integration_test.go | 70 +++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/internal/similarity/worker.go b/internal/similarity/worker.go index 6b017e3e..e37cf5be 100644 --- a/internal/similarity/worker.go +++ b/internal/similarity/worker.go @@ -189,9 +189,10 @@ func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artist } } - taken := 0 + // Matched: in-library similars → artist_similarity (existing path). + takenMatched := 0 for _, r := range results { - if taken >= w.topK { + if takenMatched >= w.topK { break } localID, ok := idByMBID[r.MBID] @@ -207,6 +208,34 @@ func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artist w.logger.Warn("similarity: UpsertArtistSimilarity", "err", uerr) continue } - taken++ + takenMatched++ + } + + // Unmatched: out-of-library similars → artist_similarity_unmatched (M5c). + // Same top-K cap as the matched path. Skip rows missing a name — we can't + // render a suggestion card without one. + takenUnmatched := 0 + for _, r := range results { + if takenUnmatched >= w.topK { + break + } + if _, inLib := idByMBID[r.MBID]; inLib { + continue + } + if r.Name == "" { + w.logger.Debug("similarity: skipping unmatched similar with empty name", "mbid", r.MBID) + continue + } + if uerr := q.UpsertArtistSimilarityUnmatched(ctx, dbq.UpsertArtistSimilarityUnmatchedParams{ + SeedArtistID: artistAID, + CandidateMbid: r.MBID, + CandidateName: r.Name, + Score: r.Score, + Source: "listenbrainz", + }); uerr != nil { + w.logger.Warn("similarity: UpsertArtistSimilarityUnmatched", "err", uerr) + continue + } + takenUnmatched++ } } diff --git a/internal/similarity/worker_integration_test.go b/internal/similarity/worker_integration_test.go index 87cf7732..e79863ca 100644 --- a/internal/similarity/worker_integration_test.go +++ b/internal/similarity/worker_integration_test.go @@ -394,3 +394,73 @@ func TestTickOnce_NoMBIDOnTrack_Skipped(t *testing.T) { t.Errorf("no-MBID track produced rows: %d", got) } } + +// TestUpsertArtistSimilar_PersistsUnmatchedToTable: M5c. Calls +// upsertArtistSimilar directly with a mix of in-library and out-of-library +// similar-artist payloads; verifies the matched path lands in +// artist_similarity (1 row), the unmatched path lands in +// artist_similarity_unmatched (3 rows), and rows with empty Name are +// skipped (we can't render a suggestion without a name). +func TestUpsertArtistSimilar_PersistsUnmatchedToTable(t *testing.T) { + f := newFixture(t) + ctx := context.Background() + + // Seed one additional in-library artist that will be the in-library match. + inLibMBID := "in-lib-mbid-123" + inLibArtist, err := f.q.UpsertArtist(ctx, dbq.UpsertArtistParams{ + Name: "InLib Artist", SortName: "InLib Artist", Mbid: &inLibMBID, + }) + if err != nil { + t.Fatalf("seed in-lib artist: %v", err) + } + + w := newTestWorker(f, "") + + similars := []listenbrainz.SimilarArtist{ + {MBID: inLibMBID, Name: "InLib Artist", Score: 0.95}, + {MBID: "out-mbid-1", Name: "Outsider One", Score: 0.85}, + {MBID: "out-mbid-2", Name: "Outsider Two", Score: 0.80}, + {MBID: "out-mbid-3", Name: "Outsider Three", Score: 0.70}, + {MBID: "out-mbid-4", Name: "", Score: 0.60}, // empty name — skipped + } + w.upsertArtistSimilar(ctx, f.q, f.artist.ID, similars) + + // Matched path: 1 row in artist_similarity for the in-library candidate. + var matchedCount int + if err := f.pool.QueryRow(ctx, + "SELECT count(*) FROM artist_similarity WHERE artist_a_id = $1", + f.artist.ID, + ).Scan(&matchedCount); err != nil { + t.Fatalf("count matched: %v", err) + } + if matchedCount != 1 { + t.Errorf("artist_similarity rows = %d, want 1 (only the in-library match)", matchedCount) + } + + // Unmatched path: 3 rows (out-mbid-1/2/3); the empty-name row is skipped. + var unmatchedCount int + if err := f.pool.QueryRow(ctx, + "SELECT count(*) FROM artist_similarity_unmatched WHERE seed_artist_id = $1", + f.artist.ID, + ).Scan(&unmatchedCount); err != nil { + t.Fatalf("count unmatched: %v", err) + } + if unmatchedCount != 3 { + t.Errorf("artist_similarity_unmatched rows = %d, want 3", unmatchedCount) + } + + // Verify a specific row's name + score round-tripped correctly. + var name string + var score float64 + if err := f.pool.QueryRow(ctx, + "SELECT candidate_name, score FROM artist_similarity_unmatched WHERE seed_artist_id = $1 AND candidate_mbid = $2", + f.artist.ID, "out-mbid-1", + ).Scan(&name, &score); err != nil { + t.Fatalf("fetch out-mbid-1: %v", err) + } + if name != "Outsider One" || score != 0.85 { + t.Errorf("row = (%q, %v), want (Outsider One, 0.85)", name, score) + } + + _ = inLibArtist +} From 277898a49a092702327c2dea64914fc338c1db5c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Fri, 1 May 2026 06:20:02 -0400 Subject: [PATCH 63/67] feat(recommendation): SuggestArtists service for M5c Add per-user artist-suggestion service ranking out-of-library MBIDs by signal x similarity. Single-CTE SQL collects user likes (5x weight) and recency-decayed plays, joins against artist_similarity_unmatched, and filters in-library candidates plus non-terminal lidarr_requests. The service resolves top-3 attribution seeds to artist names in a batched GetArtistsByIDs call so the UI can render "because you liked X" reasons. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- internal/db/dbq/artists.sql.go | 33 ++ internal/db/dbq/recommendation.sql.go | 96 +++++ internal/db/queries/artists.sql | 5 + internal/db/queries/recommendation.sql | 51 +++ internal/recommendation/suggestions.go | 102 ++++++ .../suggestions_integration_test.go | 332 ++++++++++++++++++ 6 files changed, 619 insertions(+) create mode 100644 internal/recommendation/suggestions.go create mode 100644 internal/recommendation/suggestions_integration_test.go diff --git a/internal/db/dbq/artists.sql.go b/internal/db/dbq/artists.sql.go index cafeb6b5..60f059e8 100644 --- a/internal/db/dbq/artists.sql.go +++ b/internal/db/dbq/artists.sql.go @@ -69,6 +69,39 @@ func (q *Queries) GetArtistByName(ctx context.Context, name string) (Artist, err return i, err } +const getArtistsByIDs = `-- name: GetArtistsByIDs :many +SELECT id, name, sort_name, mbid, created_at, updated_at FROM artists WHERE id = ANY($1::uuid[]) +` + +// Batched lookup used by M5c suggestion attribution to resolve top-3 +// contributing seed UUIDs back to artist names in one round-trip. +func (q *Queries) GetArtistsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Artist, error) { + rows, err := q.db.Query(ctx, getArtistsByIDs, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Artist + for rows.Next() { + var i Artist + if err := rows.Scan( + &i.ID, + &i.Name, + &i.SortName, + &i.Mbid, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listArtists = `-- name: ListArtists :many SELECT id, name, sort_name, mbid, created_at, updated_at FROM artists ORDER BY sort_name ` diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index dd1dea47..0ccf9a66 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -294,3 +294,99 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid } return items, nil } + +const suggestArtistsForUser = `-- 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::float8 * 86400.0))), 0) + AS signal, + (gla.artist_id IS NOT NULL) AS is_liked, + COUNT(pe.id)::bigint AS play_count + FROM artists a + LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1 + LEFT JOIN tracks t ON t.artist_id = a.id + LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 + WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL + GROUP BY a.id, gla.artist_id +), +contributions AS ( + SELECT u.candidate_mbid, + u.candidate_name, + seeds.artist_id AS seed_id, + seeds.is_liked, + seeds.play_count, + seeds.signal * u.score AS contribution + FROM artist_similarity_unmatched u + JOIN seeds ON seeds.artist_id = u.seed_artist_id + WHERE NOT EXISTS (SELECT 1 FROM artists WHERE mbid = u.candidate_mbid) + AND NOT EXISTS ( + SELECT 1 FROM lidarr_requests r + WHERE r.user_id = $1 + AND r.lidarr_artist_mbid = u.candidate_mbid + AND r.status NOT IN ('rejected', 'failed') + ) +) +SELECT candidate_mbid, + candidate_name, + SUM(contribution)::float8 AS total_score, + ((array_agg(seed_id ORDER BY contribution DESC))[1:3])::uuid[] AS top_seed_ids, + ((array_agg(contribution ORDER BY contribution DESC))[1:3])::float8[] AS top_contributions, + ((array_agg(is_liked ORDER BY contribution DESC))[1:3])::boolean[] AS top_is_liked, + ((array_agg(play_count ORDER BY contribution DESC))[1:3])::bigint[] AS top_play_counts +FROM contributions +GROUP BY candidate_mbid, candidate_name +ORDER BY total_score DESC +LIMIT $3 +` + +type SuggestArtistsForUserParams struct { + UserID pgtype.UUID + Column2 float64 + Limit int32 +} + +type SuggestArtistsForUserRow struct { + CandidateMbid string + CandidateName string + TotalScore float64 + TopSeedIds []pgtype.UUID + TopContributions []float64 + TopIsLiked []bool + TopPlayCounts []int64 +} + +// M5c: per-user artist suggestions ranked by signal x similarity. The +// seeds CTE collects the user's likes (x5) plus recency-decayed plays +// (exp(-age_days / $2)). The contributions CTE joins those seeds against +// artist_similarity_unmatched and filters out candidates already in +// library or already in a non-terminal lidarr_request. The outer SELECT +// aggregates per candidate, returning the top-3 contributing seeds for +// attribution. $1=user_id, $2=half_life_days, $3=limit. +func (q *Queries) SuggestArtistsForUser(ctx context.Context, arg SuggestArtistsForUserParams) ([]SuggestArtistsForUserRow, error) { + rows, err := q.db.Query(ctx, suggestArtistsForUser, arg.UserID, arg.Column2, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SuggestArtistsForUserRow + for rows.Next() { + var i SuggestArtistsForUserRow + if err := rows.Scan( + &i.CandidateMbid, + &i.CandidateName, + &i.TotalScore, + &i.TopSeedIds, + &i.TopContributions, + &i.TopIsLiked, + &i.TopPlayCounts, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/queries/artists.sql b/internal/db/queries/artists.sql index f089d46e..50ab2985 100644 --- a/internal/db/queries/artists.sql +++ b/internal/db/queries/artists.sql @@ -37,3 +37,8 @@ SELECT COUNT(*) FROM artists; -- name: CountArtistsMatching :one SELECT COUNT(*) FROM artists WHERE name ILIKE '%' || $1::text || '%'; + +-- name: GetArtistsByIDs :many +-- Batched lookup used by M5c suggestion attribution to resolve top-3 +-- contributing seed UUIDs back to artist names in one round-trip. +SELECT * FROM artists WHERE id = ANY($1::uuid[]); diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index f3dc081c..af4f525c 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -150,3 +150,54 @@ 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.added_at, t.updated_at, l.user_id, pe.last_played_at, pe.play_count, pe.skip_count; + +-- name: SuggestArtistsForUser :many +-- M5c: per-user artist suggestions ranked by signal x similarity. The +-- seeds CTE collects the user's likes (x5) plus recency-decayed plays +-- (exp(-age_days / $2)). The contributions CTE joins those seeds against +-- artist_similarity_unmatched and filters out candidates already in +-- library or already in a non-terminal lidarr_request. The outer SELECT +-- aggregates per candidate, returning the top-3 contributing seeds for +-- attribution. $1=user_id, $2=half_life_days, $3=limit. +WITH seeds AS ( + SELECT a.id AS artist_id, + 5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END) + + COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0) + AS signal, + (gla.artist_id IS NOT NULL) AS is_liked, + COUNT(pe.id)::bigint AS play_count + FROM artists a + LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1 + LEFT JOIN tracks t ON t.artist_id = a.id + LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 + WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL + GROUP BY a.id, gla.artist_id +), +contributions AS ( + SELECT u.candidate_mbid, + u.candidate_name, + seeds.artist_id AS seed_id, + seeds.is_liked, + seeds.play_count, + seeds.signal * u.score AS contribution + FROM artist_similarity_unmatched u + JOIN seeds ON seeds.artist_id = u.seed_artist_id + WHERE NOT EXISTS (SELECT 1 FROM artists WHERE mbid = u.candidate_mbid) + AND NOT EXISTS ( + SELECT 1 FROM lidarr_requests r + WHERE r.user_id = $1 + AND r.lidarr_artist_mbid = u.candidate_mbid + AND r.status NOT IN ('rejected', 'failed') + ) +) +SELECT candidate_mbid, + candidate_name, + SUM(contribution)::float8 AS total_score, + ((array_agg(seed_id ORDER BY contribution DESC))[1:3])::uuid[] AS top_seed_ids, + ((array_agg(contribution ORDER BY contribution DESC))[1:3])::float8[] AS top_contributions, + ((array_agg(is_liked ORDER BY contribution DESC))[1:3])::boolean[] AS top_is_liked, + ((array_agg(play_count ORDER BY contribution DESC))[1:3])::bigint[] AS top_play_counts +FROM contributions +GROUP BY candidate_mbid, candidate_name +ORDER BY total_score DESC +LIMIT $3; diff --git a/internal/recommendation/suggestions.go b/internal/recommendation/suggestions.go new file mode 100644 index 00000000..dfe970e3 --- /dev/null +++ b/internal/recommendation/suggestions.go @@ -0,0 +1,102 @@ +// suggestions.go is the M5c per-user artist-suggestion service. Reads +// the user's likes + plays, projects them through artist_similarity_unmatched +// via a single CTE, returns top-N candidates with top-3 attribution seeds +// resolved to artist names. +package recommendation + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// ArtistSuggestion is one ranked candidate with its top-3 attribution seeds. +type ArtistSuggestion struct { + MBID string + Name string + Score float64 + Attribution []SeedContribution +} + +// SeedContribution is one of the top-3 contributing seeds for a candidate. +type SeedContribution struct { + ArtistID pgtype.UUID + Name string + Contribution float64 + IsLiked bool + PlayCount int64 +} + +// SuggestArtists returns top-N artist suggestions for the user. limit is +// capped at 50 (default 12 when out of range); halfLifeDays is the +// recency-decay half-life for plays (default 30, operator-tunable). +func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, halfLifeDays float64, limit int) ([]ArtistSuggestion, error) { + if limit <= 0 || limit > 50 { + limit = 12 + } + if halfLifeDays <= 0 { + halfLifeDays = 30 + } + q := dbq.New(pool) + rows, err := q.SuggestArtistsForUser(ctx, dbq.SuggestArtistsForUserParams{ + UserID: userID, + Column2: halfLifeDays, + Limit: int32(limit), + }) + if err != nil { + return nil, fmt.Errorf("suggest: query: %w", err) + } + if len(rows) == 0 { + return []ArtistSuggestion{}, nil + } + + // Collect the union of top-3 seed IDs across all rows for one batched + // name lookup. pgtype.UUID is a comparable struct so it works as a map + // key directly. + seedSet := make(map[pgtype.UUID]struct{}, len(rows)*3) + for _, r := range rows { + for _, sid := range r.TopSeedIds { + seedSet[sid] = struct{}{} + } + } + seedIDs := make([]pgtype.UUID, 0, len(seedSet)) + for id := range seedSet { + seedIDs = append(seedIDs, id) + } + artists, err := q.GetArtistsByIDs(ctx, seedIDs) + if err != nil { + return nil, fmt.Errorf("suggest: resolve seeds: %w", err) + } + nameByID := make(map[pgtype.UUID]string, len(artists)) + for _, a := range artists { + nameByID[a.ID] = a.Name + } + + out := make([]ArtistSuggestion, 0, len(rows)) + for _, r := range rows { + attribution := make([]SeedContribution, 0, len(r.TopSeedIds)) + for i, sid := range r.TopSeedIds { + if i >= len(r.TopContributions) || i >= len(r.TopIsLiked) || i >= len(r.TopPlayCounts) { + break + } + attribution = append(attribution, SeedContribution{ + ArtistID: sid, + Name: nameByID[sid], + Contribution: r.TopContributions[i], + IsLiked: r.TopIsLiked[i], + PlayCount: r.TopPlayCounts[i], + }) + } + out = append(out, ArtistSuggestion{ + MBID: r.CandidateMbid, + Name: r.CandidateName, + Score: r.TotalScore, + Attribution: attribution, + }) + } + return out, nil +} diff --git a/internal/recommendation/suggestions_integration_test.go b/internal/recommendation/suggestions_integration_test.go new file mode 100644 index 00000000..9b5cf0c4 --- /dev/null +++ b/internal/recommendation/suggestions_integration_test.go @@ -0,0 +1,332 @@ +package recommendation + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" +) + +func newPool(t *testing.T) *pgxpool.Pool { + t.Helper() + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + dbtest.ResetDB(t, pool) + return pool +} + +func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User { + t.Helper() + u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ + Username: dbtest.TestUserPrefix + name, PasswordHash: "x", + ApiToken: name + "-token", IsAdmin: false, + }) + if err != nil { + t.Fatalf("seed user: %v", err) + } + return u +} + +func seedArtist(t *testing.T, pool *pgxpool.Pool, name, mbid string) dbq.Artist { + t.Helper() + var mbidPtr *string + if mbid != "" { + mbidPtr = &mbid + } + a, err := dbq.New(pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: name, SortName: name, Mbid: mbidPtr, + }) + if err != nil { + t.Fatalf("seed artist: %v", err) + } + return a +} + +func seedAlbumForArtist(t *testing.T, pool *pgxpool.Pool, artistID pgtype.UUID, title string) dbq.Album { + t.Helper() + a, err := dbq.New(pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: title, SortTitle: title, ArtistID: artistID, + }) + if err != nil { + t.Fatalf("seed album: %v", err) + } + return a +} + +func seedTrackOnAlbum(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string) dbq.Track { + t.Helper() + tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: title, AlbumID: albumID, ArtistID: artistID, + DurationMs: 1000, FilePath: "/tmp/m5c-" + title + ".mp3", + FileSize: 1, FileFormat: "mp3", + }) + if err != nil { + t.Fatalf("seed track: %v", err) + } + return tr +} + +func insertPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time) { + t.Helper() + ctx := context.Background() + var sessionID pgtype.UUID + if err := pool.QueryRow(ctx, + `INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id) + VALUES ($1, $2, $2, 'm5c-test') RETURNING id`, + userID, startedAt, + ).Scan(&sessionID); err != nil { + t.Fatalf("insert play_session: %v", err) + } + if _, err := pool.Exec(ctx, + `INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped) + VALUES ($1, $2, $3, $4, false)`, + userID, trackID, sessionID, startedAt, + ); err != nil { + t.Fatalf("insert play_event: %v", err) + } +} + +func likeArtist(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID) { + t.Helper() + if _, err := pool.Exec(context.Background(), + `INSERT INTO general_likes_artists (user_id, artist_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + userID, artistID, + ); err != nil { + t.Fatalf("like artist: %v", err) + } +} + +func seedUnmatched(t *testing.T, pool *pgxpool.Pool, seedID pgtype.UUID, candMBID, candName string, score float64) { + t.Helper() + if err := dbq.New(pool).UpsertArtistSimilarityUnmatched(context.Background(), dbq.UpsertArtistSimilarityUnmatchedParams{ + SeedArtistID: seedID, + CandidateMbid: candMBID, + CandidateName: candName, + Score: score, + Source: "listenbrainz", + }); err != nil { + t.Fatalf("seed unmatched: %v", err) + } +} + +func TestSuggestArtists_LikesAndPlaysContributeToScore(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + seedA := seedArtist(t, pool, "Seed Liked", "") + seedB := seedArtist(t, pool, "Seed Played", "") + + likeArtist(t, pool, user.ID, seedA.ID) + + seedBAlbum := seedAlbumForArtist(t, pool, seedB.ID, "Album B") + seedBTrack := seedTrackOnAlbum(t, pool, seedBAlbum.ID, seedB.ID, "Track B") + insertPlayEvent(t, pool, user.ID, seedBTrack.ID, time.Now().Add(-1*time.Hour)) + + seedUnmatched(t, pool, seedA.ID, "out-mbid", "Outsider", 0.9) + seedUnmatched(t, pool, seedB.ID, "out-mbid", "Outsider", 0.5) + + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 1 { + t.Fatalf("len = %d, want 1", len(out)) + } + s := out[0] + if s.MBID != "out-mbid" || s.Name != "Outsider" { + t.Errorf("got = %+v", s) + } + if s.Score <= 0 { + t.Errorf("score = %v, want > 0", s.Score) + } + if len(s.Attribution) != 2 { + t.Errorf("attribution len = %d, want 2", len(s.Attribution)) + } +} + +func TestSuggestArtists_Top12Cap(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + seed := seedArtist(t, pool, "Seed", "") + likeArtist(t, pool, user.ID, seed.ID) + for i := 0; i < 30; i++ { + seedUnmatched(t, pool, seed.ID, fmt.Sprintf("mbid-%02d", i), fmt.Sprintf("Artist %02d", i), 0.99-float64(i)*0.01) + } + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 12 { + t.Errorf("len = %d, want 12", len(out)) + } + if out[0].MBID != "mbid-00" { + t.Errorf("first = %s, want mbid-00 (highest score)", out[0].MBID) + } +} + +func TestSuggestArtists_AttributionTopThree(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + seeds := make([]dbq.Artist, 5) + for i := 0; i < 5; i++ { + seeds[i] = seedArtist(t, pool, fmt.Sprintf("Seed %d", i), "") + likeArtist(t, pool, user.ID, seeds[i].ID) + seedUnmatched(t, pool, seeds[i].ID, "shared-mbid", "Shared", 0.9-float64(i)*0.1) + } + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 1 { + t.Fatalf("len = %d, want 1 (shared candidate)", len(out)) + } + if got := len(out[0].Attribution); got != 3 { + t.Errorf("attribution len = %d, want 3", got) + } + if out[0].Attribution[0].Name != "Seed 0" { + t.Errorf("top attribution = %q, want Seed 0", out[0].Attribution[0].Name) + } +} + +func TestSuggestArtists_RecencyDecayDownweightsOldPlays(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + recentSeed := seedArtist(t, pool, "Recent", "") + oldSeed := seedArtist(t, pool, "Old", "") + + rAlbum := seedAlbumForArtist(t, pool, recentSeed.ID, "Recent Album") + rTrack := seedTrackOnAlbum(t, pool, rAlbum.ID, recentSeed.ID, "Recent Track") + insertPlayEvent(t, pool, user.ID, rTrack.ID, time.Now().Add(-1*24*time.Hour)) + + oAlbum := seedAlbumForArtist(t, pool, oldSeed.ID, "Old Album") + oTrack := seedTrackOnAlbum(t, pool, oAlbum.ID, oldSeed.ID, "Old Track") + insertPlayEvent(t, pool, user.ID, oTrack.ID, time.Now().Add(-90*24*time.Hour)) + + seedUnmatched(t, pool, recentSeed.ID, "cand", "Cand", 0.5) + seedUnmatched(t, pool, oldSeed.ID, "cand", "Cand", 0.5) + + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 1 { + t.Fatalf("len = %d, want 1", len(out)) + } + if len(out[0].Attribution) != 2 { + t.Fatalf("attribution len = %d, want 2", len(out[0].Attribution)) + } + if out[0].Attribution[0].Name != "Recent" { + t.Errorf("top attribution = %q, want Recent", out[0].Attribution[0].Name) + } + if out[0].Attribution[0].Contribution <= out[0].Attribution[1].Contribution { + t.Errorf("recent contribution (%v) should exceed old (%v)", + out[0].Attribution[0].Contribution, out[0].Attribution[1].Contribution) + } +} + +func TestSuggestArtists_FiltersInLibraryCandidates(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + seed := seedArtist(t, pool, "Seed", "") + likeArtist(t, pool, user.ID, seed.ID) + inLibMBID := "in-lib-mbid" + seedArtist(t, pool, "InLib", inLibMBID) + seedUnmatched(t, pool, seed.ID, inLibMBID, "InLib", 0.9) + + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 0 { + t.Errorf("len = %d, want 0 (in-library candidate should be filtered)", len(out)) + } +} + +func TestSuggestArtists_FiltersAlreadyRequested(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + seed := seedArtist(t, pool, "Seed", "") + likeArtist(t, pool, user.ID, seed.ID) + seedUnmatched(t, pool, seed.ID, "req-mbid", "Pending Request", 0.9) + if _, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{ + UserID: user.ID, + Kind: dbq.LidarrRequestKindArtist, + LidarrArtistMbid: "req-mbid", + ArtistName: "Pending Request", + }); err != nil { + t.Fatalf("CreateLidarrRequest: %v", err) + } + + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 0 { + t.Errorf("len = %d, want 0 (pending request should hide candidate)", len(out)) + } +} + +func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + seed := seedArtist(t, pool, "Seed", "") + likeArtist(t, pool, user.ID, seed.ID) + seedUnmatched(t, pool, seed.ID, "rej-mbid", "Rejected Once", 0.9) + req, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{ + UserID: user.ID, + Kind: dbq.LidarrRequestKindArtist, + LidarrArtistMbid: "rej-mbid", + ArtistName: "Rejected Once", + }) + if err != nil { + t.Fatalf("CreateLidarrRequest: %v", err) + } + rejNotes := "wrong artist" + if _, err := dbq.New(pool).RejectLidarrRequest(context.Background(), dbq.RejectLidarrRequestParams{ + ID: req.ID, Notes: &rejNotes, DecidedBy: user.ID, + }); err != nil { + t.Fatalf("RejectLidarrRequest: %v", err) + } + + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 1 { + t.Errorf("len = %d, want 1 (rejected requests don't hide the candidate)", len(out)) + } +} + +func TestSuggestArtists_EmptyForNewUser(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "newbie") + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 0 { + t.Errorf("len = %d, want 0 (new user has no signal)", len(out)) + } +} From ae2d69d37879e4ba8ff9fdea34e064a47fc0f3bf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Fri, 1 May 2026 06:23:08 -0400 Subject: [PATCH 64/67] feat(api): /api/discover/suggestions handler --- internal/api/api.go | 1 + internal/api/suggestions.go | 85 +++++++++++++++++++++++++ internal/api/suggestions_test.go | 104 +++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 internal/api/suggestions.go create mode 100644 internal/api/suggestions_test.go diff --git a/internal/api/api.go b/internal/api/api.go index 0ef8d086..fa2707f7 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -49,6 +49,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Get("/tracks/{id}/stream", h.handleGetStream) authed.Get("/search", h.handleSearch) authed.Get("/radio", h.handleRadio) + authed.Get("/discover/suggestions", h.handleListSuggestions) authed.Post("/events", h.handleEvents) authed.Post("/likes/tracks/{id}", h.handleLikeTrack) authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack) diff --git a/internal/api/suggestions.go b/internal/api/suggestions.go new file mode 100644 index 00000000..c4ab3134 --- /dev/null +++ b/internal/api/suggestions.go @@ -0,0 +1,85 @@ +package api + +import ( + "net/http" + "strconv" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" +) + +// suggestionView is the wire shape returned by GET /api/discover/suggestions. +type suggestionView struct { + MBID string `json:"mbid"` + Name string `json:"name"` + Score float64 `json:"score"` + Attribution []seedContributionView `json:"attribution"` +} + +type seedContributionView struct { + ArtistID pgtype.UUID `json:"artist_id"` + Name string `json:"name"` + Contribution float64 `json:"contribution"` + IsLiked bool `json:"is_liked"` + PlayCount int64 `json:"play_count"` +} + +// handleListSuggestions implements GET /api/discover/suggestions. +// +// Query params: +// - limit (default 12, capped at 50) +// - half_life_days (default 30) +// +// Returns 200 with a JSON array (possibly empty). Read-only; no admin gate. +func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + limit := 12 + if v := r.URL.Query().Get("limit"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid limit") + return + } + limit = n + } + halfLife := 30.0 + if v := r.URL.Query().Get("half_life_days"); v != "" { + f, err := strconv.ParseFloat(v, 64) + if err != nil || f <= 0 { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid half_life_days") + return + } + halfLife = f + } + + suggestions, err := recommendation.SuggestArtists(r.Context(), h.pool, user.ID, halfLife, limit) + if err != nil { + h.logger.Error("api: list suggestions", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "failed to load suggestions") + return + } + + out := make([]suggestionView, 0, len(suggestions)) + for _, s := range suggestions { + attr := make([]seedContributionView, 0, len(s.Attribution)) + for _, a := range s.Attribution { + attr = append(attr, seedContributionView{ + ArtistID: a.ArtistID, + Name: a.Name, + Contribution: a.Contribution, + IsLiked: a.IsLiked, + PlayCount: a.PlayCount, + }) + } + out = append(out, suggestionView{ + MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr, + }) + } + writeJSON(w, http.StatusOK, out) +} diff --git a/internal/api/suggestions_test.go b/internal/api/suggestions_test.go new file mode 100644 index 00000000..be7bb5de --- /dev/null +++ b/internal/api/suggestions_test.go @@ -0,0 +1,104 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func newSuggestionsRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Get("/api/discover/suggestions", h.handleListSuggestions) + return r +} + +func doListSuggestions(h *handlers, user dbq.User, query string) *httptest.ResponseRecorder { + url := "/api/discover/suggestions" + if query != "" { + url += "?" + query + } + req := httptest.NewRequest(http.MethodGet, url, nil) + req = withUser(req, user) + w := httptest.NewRecorder() + newSuggestionsRouter(h).ServeHTTP(w, req) + return w +} + +func TestSuggestions_HappyPath(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + + q := dbq.New(pool) + seed, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "Seed", SortName: "Seed", + }) + if err != nil { + t.Fatalf("UpsertArtist: %v", err) + } + if _, err := pool.Exec(context.Background(), + `INSERT INTO general_likes_artists (user_id, artist_id) VALUES ($1, $2)`, + user.ID, seed.ID, + ); err != nil { + t.Fatalf("like artist: %v", err) + } + if err := q.UpsertArtistSimilarityUnmatched(context.Background(), dbq.UpsertArtistSimilarityUnmatchedParams{ + SeedArtistID: seed.ID, + CandidateMbid: "out-mbid", + CandidateName: "Outsider", + Score: 0.9, + Source: "listenbrainz", + }); err != nil { + t.Fatalf("UpsertArtistSimilarityUnmatched: %v", err) + } + + w := doListSuggestions(h, user, "") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var got []suggestionView + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 1 { + t.Fatalf("len = %d, want 1; body = %s", len(got), w.Body.String()) + } + if got[0].MBID != "out-mbid" || got[0].Name != "Outsider" { + t.Errorf("got = %+v", got[0]) + } + if len(got[0].Attribution) != 1 || got[0].Attribution[0].Name != "Seed" { + t.Errorf("attribution = %+v, want one entry named Seed", got[0].Attribution) + } + if !got[0].Attribution[0].IsLiked { + t.Errorf("attribution.IsLiked = false, want true") + } +} + +func TestSuggestions_EmptyForNewUser(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "newbie", "pw", false) + + w := doListSuggestions(h, user, "") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + body := w.Body.String() + if body != "[]" && body != "[]\n" { + t.Errorf("body = %q, want []", body) + } +} + +func TestSuggestions_BadLimit(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + + w := doListSuggestions(h, user, "limit=not-a-number") + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400; body = %s", w.Code, w.Body.String()) + } +} From 95b706836d45d6a3cc7c6889626b3f62eaf5ffa5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Fri, 1 May 2026 06:24:21 -0400 Subject: [PATCH 65/67] feat(web): API client for /api/discover/suggestions --- web/src/lib/api/queries.ts | 2 ++ web/src/lib/api/suggestions.test.ts | 42 +++++++++++++++++++++++++++++ web/src/lib/api/suggestions.ts | 16 +++++++++++ web/src/lib/api/types.ts | 15 +++++++++++ 4 files changed, 75 insertions(+) create mode 100644 web/src/lib/api/suggestions.test.ts create mode 100644 web/src/lib/api/suggestions.ts diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 1d684959..307a8f90 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -32,6 +32,8 @@ export const qk = { adminQuarantine: () => ['adminQuarantine'] as const, adminQuarantineActions: (limit?: number) => ['adminQuarantineActions', { limit: limit ?? 50 }] as const, + suggestions: (limit?: number) => + ['suggestions', { limit: limit ?? 12 }] as const, }; export function createArtistsQuery(sort: ArtistSort) { diff --git a/web/src/lib/api/suggestions.test.ts b/web/src/lib/api/suggestions.test.ts new file mode 100644 index 00000000..094bd4ba --- /dev/null +++ b/web/src/lib/api/suggestions.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('./client', () => ({ + api: { get: vi.fn() } +})); + +import { listSuggestions } from './suggestions'; +import { qk } from './queries'; +import { api } from './client'; +import type { ArtistSuggestion } from './types'; + +afterEach(() => vi.clearAllMocks()); + +describe('suggestions client', () => { + test('listSuggestions hits the right URL with default limit', async () => { + const fixture: ArtistSuggestion[] = [ + { + mbid: 'm1', + name: 'Outsider', + score: 1.5, + attribution: [ + { artist_id: 'a1', name: 'Seed', contribution: 0.9, is_liked: true, play_count: 0 } + ] + } + ]; + (api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(fixture); + const got = await listSuggestions(); + expect(api.get).toHaveBeenCalledWith('/api/discover/suggestions?limit=12'); + expect(got).toEqual(fixture); + }); + + test('listSuggestions honors a custom limit', async () => { + (api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]); + await listSuggestions(20); + expect(api.get).toHaveBeenCalledWith('/api/discover/suggestions?limit=20'); + }); + + test('qk.suggestions key shape', () => { + expect(qk.suggestions()).toEqual(['suggestions', { limit: 12 }]); + expect(qk.suggestions(20)).toEqual(['suggestions', { limit: 20 }]); + }); +}); diff --git a/web/src/lib/api/suggestions.ts b/web/src/lib/api/suggestions.ts new file mode 100644 index 00000000..2d998898 --- /dev/null +++ b/web/src/lib/api/suggestions.ts @@ -0,0 +1,16 @@ +import { createQuery } from '@tanstack/svelte-query'; +import { api } from './client'; +import { qk } from './queries'; +import type { ArtistSuggestion } from './types'; + +export async function listSuggestions(limit = 12): Promise<ArtistSuggestion[]> { + return api.get<ArtistSuggestion[]>(`/api/discover/suggestions?limit=${limit}`); +} + +export function createSuggestionsQuery(limit = 12) { + return createQuery({ + queryKey: qk.suggestions(limit), + queryFn: () => listSuggestions(limit), + staleTime: 5 * 60_000 // 5 minutes — see M5c spec §5 + }); +} diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 6b8ce1fc..86674f63 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -221,3 +221,18 @@ export type ActionResult = { affected_users: number; deleted_track_count?: number; }; + +export type SeedContribution = { + artist_id: string; + name: string; + contribution: number; + is_liked: boolean; + play_count: number; +}; + +export type ArtistSuggestion = { + mbid: string; + name: string; + score: number; + attribution: SeedContribution[]; // up to 3 entries, ordered by contribution DESC +}; From 7f18a0416171a9bf6b325b0c5ba98bf97e208045 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Fri, 1 May 2026 06:25:42 -0400 Subject: [PATCH 66/67] feat(web): DiscoverResultCard attribution prop for M5c suggestions --- .../lib/components/DiscoverResultCard.svelte | 7 +++++++ .../lib/components/DiscoverResultCard.test.ts | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/web/src/lib/components/DiscoverResultCard.svelte b/web/src/lib/components/DiscoverResultCard.svelte index 345e316f..ce7dba3d 100644 --- a/web/src/lib/components/DiscoverResultCard.svelte +++ b/web/src/lib/components/DiscoverResultCard.svelte @@ -12,6 +12,7 @@ subtitle, imageUrl, state, + attribution, onRequest, }: { kind: DiscoverCardKind; @@ -19,6 +20,7 @@ subtitle?: string; imageUrl?: string; state: DiscoverCardState; + attribution?: string; onRequest?: () => void; } = $props(); @@ -55,6 +57,11 @@ {#if subtitle} <div class="subtitle text-sm text-text-secondary">{subtitle}</div> {/if} + {#if attribution} + <div class="attribution text-xs italic text-text-secondary" data-testid="attribution"> + {attribution} + </div> + {/if} <div class="badge-row" data-testid="badge-row"> {#if state === 'kept'} <span class="kept-pill" role="status">Kept</span> diff --git a/web/src/lib/components/DiscoverResultCard.test.ts b/web/src/lib/components/DiscoverResultCard.test.ts index acffc80f..b97bc8f4 100644 --- a/web/src/lib/components/DiscoverResultCard.test.ts +++ b/web/src/lib/components/DiscoverResultCard.test.ts @@ -104,4 +104,25 @@ describe('DiscoverResultCard', () => { // Lucide renders an inline <svg>; verify its presence as a proxy for "fallback rendered" expect(container.querySelector('svg')).toBeInTheDocument(); }); + + test('renders attribution line when prop is set', () => { + render(DiscoverResultCard, { + props: { + kind: 'artist', + title: 'Outsider', + state: 'requestable', + attribution: 'Because you liked Boards of Canada and played Aphex Twin.' + } + }); + expect(screen.getByTestId('attribution')).toHaveTextContent( + 'Because you liked Boards of Canada and played Aphex Twin.' + ); + }); + + test('omits attribution line when prop is absent', () => { + render(DiscoverResultCard, { + props: { kind: 'artist', title: 'Outsider', state: 'requestable' } + }); + expect(screen.queryByTestId('attribution')).not.toBeInTheDocument(); + }); }); From 2f3326aee65b0a5b2a8b398730e7890ee6fb69c6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen <bryan.vandeusen@fabledsword.com> Date: Fri, 1 May 2026 06:51:41 -0400 Subject: [PATCH 67/67] feat(web): suggestion feed on /discover (search-empty default) --- web/src/lib/components/SuggestionFeed.svelte | 75 ++++++++++++ web/src/lib/components/SuggestionFeed.test.ts | 109 ++++++++++++++++++ web/src/routes/discover/+page.svelte | 101 ++++++++-------- web/src/routes/discover/discover.test.ts | 43 ++++++- 4 files changed, 272 insertions(+), 56 deletions(-) create mode 100644 web/src/lib/components/SuggestionFeed.svelte create mode 100644 web/src/lib/components/SuggestionFeed.test.ts diff --git a/web/src/lib/components/SuggestionFeed.svelte b/web/src/lib/components/SuggestionFeed.svelte new file mode 100644 index 00000000..6f16b641 --- /dev/null +++ b/web/src/lib/components/SuggestionFeed.svelte @@ -0,0 +1,75 @@ +<script lang="ts"> + import { useQueryClient } from '@tanstack/svelte-query'; + import { createSuggestionsQuery } from '$lib/api/suggestions'; + import { createRequest } from '$lib/api/requests'; + import { qk } from '$lib/api/queries'; + import DiscoverResultCard from './DiscoverResultCard.svelte'; + import type { ArtistSuggestion, SeedContribution } from '$lib/api/types'; + + const client = useQueryClient(); + const queryStore = createSuggestionsQuery(); + const query = $derived($queryStore); + const suggestions = $derived((query.data ?? []) as ArtistSuggestion[]); + + // Track MBIDs the user just requested so the card flips immediately. + let optimisticRequested = $state(new Set<string>()); + + function visible(s: ArtistSuggestion): boolean { + return !optimisticRequested.has(s.mbid); + } + + function attributionText(attribution: SeedContribution[]): string { + if (attribution.length === 0) return ''; + const verb = (s: SeedContribution) => (s.is_liked ? 'liked' : 'played'); + const phrases = attribution.map((s) => `${verb(s)} ${s.name}`); + if (phrases.length === 1) { + return `Because you ${phrases[0]}.`; + } + if (phrases.length === 2) { + return `Because you ${phrases[0]} and ${phrases[1]}.`; + } + // 3 with Oxford comma + return `Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}.`; + } + + async function onRequest(s: ArtistSuggestion) { + try { + await createRequest({ + kind: 'artist', + lidarr_artist_mbid: s.mbid, + artist_name: s.name + }); + const next = new Set(optimisticRequested); + next.add(s.mbid); + optimisticRequested = next; + // The server-side filter hides this candidate on next refetch. + await client.invalidateQueries({ queryKey: qk.suggestions() }); + } catch { + // Swallow for v1; the SPA will refetch on next mount and the card + // stays requestable so the user can retry. + } + } +</script> + +<div> + <header class="mb-4 space-y-1"> + <h2 class="font-display text-2xl font-medium text-text-primary">Suggested for you</h2> + <p class="text-text-secondary">Out-of-library artists drawn from what you've liked and played.</p> + </header> + + {#if !query.isPending && suggestions.length === 0} + <p class="text-text-secondary">Listen to something or like an artist to start getting suggestions.</p> + {:else if suggestions.length > 0} + <div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> + {#each suggestions.filter(visible) as s (s.mbid)} + <DiscoverResultCard + kind="artist" + title={s.name} + state="requestable" + attribution={attributionText(s.attribution)} + onRequest={() => onRequest(s)} + /> + {/each} + </div> + {/if} +</div> diff --git a/web/src/lib/components/SuggestionFeed.test.ts b/web/src/lib/components/SuggestionFeed.test.ts new file mode 100644 index 00000000..0e6fdb43 --- /dev/null +++ b/web/src/lib/components/SuggestionFeed.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { mockQuery } from '../../test-utils/query'; + +const invalidateMock = vi.fn(); +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record<string, unknown>; + return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) }; +}); + +vi.mock('$lib/api/suggestions', () => ({ + createSuggestionsQuery: vi.fn() +})); + +vi.mock('$lib/api/requests', () => ({ + createRequest: vi.fn().mockResolvedValue({}) +})); + +import SuggestionFeed from './SuggestionFeed.svelte'; +import { createSuggestionsQuery } from '$lib/api/suggestions'; +import { createRequest } from '$lib/api/requests'; +import type { ArtistSuggestion } from '$lib/api/types'; + +const oneSeed: ArtistSuggestion = { + mbid: 'mb1', + name: 'Outsider', + score: 1.0, + attribution: [ + { artist_id: 'a1', name: 'Seed', contribution: 0.9, is_liked: true, play_count: 0 } + ] +}; + +const twoSeeds: ArtistSuggestion = { + mbid: 'mb2', + name: 'Outsider Two', + score: 2.0, + attribution: [ + { artist_id: 'a1', name: 'A', contribution: 0.8, is_liked: true, play_count: 0 }, + { artist_id: 'a2', name: 'B', contribution: 0.5, is_liked: false, play_count: 3 } + ] +}; + +const threeSeeds: ArtistSuggestion = { + mbid: 'mb3', + name: 'Outsider Three', + score: 3.0, + attribution: [ + { artist_id: 'a1', name: 'X', contribution: 0.9, is_liked: true, play_count: 0 }, + { artist_id: 'a2', name: 'Y', contribution: 0.6, is_liked: false, play_count: 5 }, + { artist_id: 'a3', name: 'Z', contribution: 0.3, is_liked: false, play_count: 1 } + ] +}; + +afterEach(() => vi.clearAllMocks()); + +describe('SuggestionFeed', () => { + test('renders one card per suggestion', () => { + (createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: [oneSeed, twoSeeds] }) + ); + render(SuggestionFeed); + expect(screen.getByText('Outsider')).toBeInTheDocument(); + expect(screen.getByText('Outsider Two')).toBeInTheDocument(); + }); + + test('attribution copy: 1 seed → "Because you liked X."', () => { + (createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: [oneSeed] }) + ); + render(SuggestionFeed); + expect(screen.getByText(/because you liked seed\./i)).toBeInTheDocument(); + }); + + test('attribution copy: 2 seeds → "Because you liked A and played B."', () => { + (createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: [twoSeeds] }) + ); + render(SuggestionFeed); + expect(screen.getByText(/because you liked a and played b\./i)).toBeInTheDocument(); + }); + + test('attribution copy: 3 seeds → Oxford comma', () => { + (createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: [threeSeeds] }) + ); + render(SuggestionFeed); + expect(screen.getByText(/because you liked x, played y, and played z\./i)).toBeInTheDocument(); + }); + + test('Request button calls createRequest with artist-kind body', async () => { + (createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue( + mockQuery({ data: [oneSeed] }) + ); + render(SuggestionFeed); + await fireEvent.click(screen.getByRole('button', { name: /request outsider/i })); + expect(createRequest).toHaveBeenCalledWith({ + kind: 'artist', + lidarr_artist_mbid: 'mb1', + artist_name: 'Outsider' + }); + expect(invalidateMock).toHaveBeenCalled(); + }); + + test('empty state when data is []', () => { + (createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] })); + render(SuggestionFeed); + expect(screen.getByText(/listen to something or like an artist/i)).toBeInTheDocument(); + }); +}); diff --git a/web/src/routes/discover/+page.svelte b/web/src/routes/discover/+page.svelte index e75f71de..dac4c2b5 100644 --- a/web/src/routes/discover/+page.svelte +++ b/web/src/routes/discover/+page.svelte @@ -3,6 +3,7 @@ import { createRequest } from '$lib/api/requests'; import DiscoverResultCard from '$lib/components/DiscoverResultCard.svelte'; import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte'; + import SuggestionFeed from '$lib/components/SuggestionFeed.svelte'; import type { LidarrRequestKind, LidarrSearchResult @@ -114,15 +115,6 @@ </script> <div class="space-y-6"> - <header class="space-y-1"> - <h2 class="font-display text-2xl font-medium text-text-primary"> - Add music to the library - </h2> - <p class="text-text-secondary"> - Search Lidarr to add new artists, albums, or tracks. - </p> - </header> - <input type="search" aria-label="Search Lidarr" @@ -131,48 +123,57 @@ class="w-full rounded-md border border-border bg-background px-3 py-2 text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" /> - <nav aria-label="Result kind" class="border-b border-border"> - <ul class="flex gap-2"> - {#each tabs as tab (tab.kind)} - <li> - <button - type="button" - aria-pressed={activeKind === tab.kind} - class="border-b-2 px-3 py-2 text-sm {activeKind === tab.kind - ? 'border-accent text-text-primary' - : 'border-transparent text-text-secondary hover:text-text-primary'}" - onclick={() => (activeKind = tab.kind)} - > - {tab.label} - </button> - </li> - {/each} - </ul> - </nav> - - {#if !debouncedQ} - <p class="text-text-secondary"> - Search Lidarr for music to add to the library. - </p> - {:else if query.isError} - <ApiErrorBanner error={query.error} onRetry={query.refetch} /> - {:else if query.isPending} - <p class="text-text-secondary">Searching…</p> - {:else if results.length === 0} - <p class="text-text-secondary">Nothing to add for that search yet.</p> + {#if debouncedQ === ''} + <SuggestionFeed /> {:else} - <div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> - {#each results as r (r.mbid)} - <DiscoverResultCard - kind={activeKind} - title={r.name} - subtitle={r.secondary_text} - imageUrl={r.image_url || undefined} - state={cardState(r)} - onRequest={() => handleRequestClick(r)} - /> - {/each} - </div> + <header class="space-y-1"> + <h2 class="font-display text-2xl font-medium text-text-primary"> + Add music to the library + </h2> + <p class="text-text-secondary"> + Search Lidarr to add new artists, albums, or tracks. + </p> + </header> + + <nav aria-label="Result kind" class="border-b border-border"> + <ul class="flex gap-2"> + {#each tabs as tab (tab.kind)} + <li> + <button + type="button" + aria-pressed={activeKind === tab.kind} + class="border-b-2 px-3 py-2 text-sm {activeKind === tab.kind + ? 'border-accent text-text-primary' + : 'border-transparent text-text-secondary hover:text-text-primary'}" + onclick={() => (activeKind = tab.kind)} + > + {tab.label} + </button> + </li> + {/each} + </ul> + </nav> + + {#if query.isError} + <ApiErrorBanner error={query.error} onRetry={query.refetch} /> + {:else if query.isPending} + <p class="text-text-secondary">Searching…</p> + {:else if results.length === 0} + <p class="text-text-secondary">Nothing to add for that search yet.</p> + {:else} + <div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> + {#each results as r (r.mbid)} + <DiscoverResultCard + kind={activeKind} + title={r.name} + subtitle={r.secondary_text} + imageUrl={r.image_url || undefined} + state={cardState(r)} + onRequest={() => handleRequestClick(r)} + /> + {/each} + </div> + {/if} {/if} </div> diff --git a/web/src/routes/discover/discover.test.ts b/web/src/routes/discover/discover.test.ts index 60e29e67..ba9bd13b 100644 --- a/web/src/routes/discover/discover.test.ts +++ b/web/src/routes/discover/discover.test.ts @@ -10,6 +10,10 @@ vi.mock('$lib/api/lidarr', () => ({ createLidarrSearchQuery: vi.fn() })); +vi.mock('$lib/api/suggestions', () => ({ + createSuggestionsQuery: vi.fn() +})); + vi.mock('$lib/api/requests', () => ({ createRequest: vi.fn().mockResolvedValue({ id: 'r1' }) })); @@ -25,9 +29,11 @@ vi.mock('@tanstack/svelte-query', async (orig) => { import DiscoverPage from './+page.svelte'; import { createLidarrSearchQuery } from '$lib/api/lidarr'; +import { createSuggestionsQuery } from '$lib/api/suggestions'; import { createRequest } from '$lib/api/requests'; const mockedCreateQuery = createLidarrSearchQuery as ReturnType<typeof vi.fn>; +const mockedCreateSuggestionsQuery = createSuggestionsQuery as ReturnType<typeof vi.fn>; const mockedCreateRequest = createRequest as ReturnType<typeof vi.fn>; function result(over: Partial<LidarrSearchResult> = {}): LidarrSearchResult { @@ -47,6 +53,9 @@ function result(over: Partial<LidarrSearchResult> = {}): LidarrSearchResult { beforeEach(() => { // Default: empty results, non-pending. Tests override per-case. mockedCreateQuery.mockReturnValue(mockQuery<LidarrSearchResult[]>({ data: [] })); + // Default: empty suggestion feed so its empty-state copy renders without + // interfering with search-mode tests. + mockedCreateSuggestionsQuery.mockReturnValue(mockQuery({ data: [] })); }); afterEach(() => { @@ -54,11 +63,31 @@ afterEach(() => { }); describe('Discover page', () => { - test('initial state (no query) shows the search prompt copy', () => { + test('initial state (no query) shows the suggestion feed', () => { render(DiscoverPage); + expect(screen.getByText(/suggested for you/i)).toBeInTheDocument(); + }); + + test('empty input shows the suggestion feed', () => { + render(DiscoverPage); + expect(screen.getByText(/suggested for you/i)).toBeInTheDocument(); + // Kind tabs should NOT be visible when input is empty. expect( - screen.getByText(/search lidarr for music to add/i) - ).toBeInTheDocument(); + screen.queryByRole('button', { name: 'Artists' }) + ).not.toBeInTheDocument(); + }); + + test('typing replaces feed with search', async () => { + vi.useFakeTimers(); + render(DiscoverPage); + const input = screen.getByLabelText(/search lidarr/i); + await fireEvent.input(input, { target: { value: 'miles' } }); + await vi.advanceTimersByTimeAsync(250); + expect(screen.queryByText(/suggested for you/i)).not.toBeInTheDocument(); + expect(screen.getByText(/add music to the library/i)).toBeInTheDocument(); + // Kind tabs visible when searching. + expect(screen.getByRole('button', { name: 'Artists' })).toBeInTheDocument(); + vi.useRealTimers(); }); test('debounced input fires query factory with typed value after 250ms', async () => { @@ -165,11 +194,13 @@ describe('Discover page', () => { }); mockedCreateQuery.mockReturnValue(mockQuery<LidarrSearchResult[]>({ data: [r] })); render(DiscoverPage); - // Switch to track kind first. - await fireEvent.click(screen.getByRole('button', { name: 'Tracks' })); + // Type first so the kind tabs become visible (empty-input mode shows the + // suggestion feed and hides tabs). const input = screen.getByLabelText(/search lidarr/i); await fireEvent.input(input, { target: { value: 'roy' } }); await vi.advanceTimersByTimeAsync(250); + // Switch to track kind. + await fireEvent.click(screen.getByRole('button', { name: 'Tracks' })); vi.useRealTimers(); const requestBtn = await screen.findByRole('button', { @@ -204,10 +235,10 @@ describe('Discover page', () => { }); mockedCreateQuery.mockReturnValue(mockQuery<LidarrSearchResult[]>({ data: [r] })); render(DiscoverPage); - await fireEvent.click(screen.getByRole('button', { name: 'Tracks' })); const input = screen.getByLabelText(/search lidarr/i); await fireEvent.input(input, { target: { value: 'roy' } }); await vi.advanceTimersByTimeAsync(250); + await fireEvent.click(screen.getByRole('button', { name: 'Tracks' })); vi.useRealTimers(); const requestBtn = await screen.findByRole('button', {