Merge pull request 'M5a frontend + M5b quarantine + M5c suggestions' (#30) from dev into main

This commit was merged in pull request #30.
This commit is contained in:
2026-05-01 11:14:18 +00:00
142 changed files with 23401 additions and 94 deletions
+9
View File
@@ -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)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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: "<code>", message: "<human>"}` envelope on errors. `/api/admin/*` group passes through `RequireAdmin` middleware (403 with `not_authorized` for non-admin tokens).
### User-facing (any authenticated user)
| Method | Path | Behavior |
|---|---|---|
| `GET` | `/api/lidarr/search?q=&kind=artist\|album\|track` | Proxies Lidarr lookup. Returns normalized list `[{mbid, name|title, secondary_text, image_url, in_library: bool, requested: bool}]`. The `in_library` and `requested` fields are computed server-side: `in_library` joins against `artists`/`albums`/`tracks` by MBID; `requested` is true when ANY user has a non-`rejected`, non-`failed` `lidarr_requests` row with the same MBID — covers `pending`, `approved`, and `completed`. (`completed` in the DB but `in_library=false` in the response would only happen briefly between Lidarr add and library scan; both flags can be true together — UI prefers `in_library` for that case.) Returns `503 lidarr_disabled` if `lidarr_config.enabled=false`. |
| `POST` | `/api/requests` | Create a request. Body: `{kind, lidarr_artist_mbid, lidarr_album_mbid?, lidarr_track_mbid?, artist_name, album_title?, track_title?}`. Server validates kind→required-fields invariant. Returns `201 {request}`. |
| `GET` | `/api/requests` | List the caller's own requests (any status). Ordered by `requested_at desc`. Pagination: `?limit=&before=` cursor. |
| `GET` | `/api/requests/:id` | Single request detail. 404 if not caller's own and caller is not admin. |
| `DELETE` | `/api/requests/:id` | Cancel a still-pending request the caller created. 409 `request_not_pending` if status != `pending`. |
### Admin-only
| Method | Path | Behavior |
|---|---|---|
| `GET` | `/api/admin/lidarr/config` | Returns current config. `api_key` masked as `"***"` when set, `null` when unset. |
| `PUT` | `/api/admin/lidarr/config` | Body: `{base_url, api_key, default_quality_profile_id, default_root_folder_path, enabled}`. `api_key`: empty string = leave saved value unchanged; non-empty = update. URL validated. |
| `POST` | `/api/admin/lidarr/test` | Body: `{base_url?, api_key?}`. Each field independently falls back to the saved value when absent or empty. Calls `Client.Ping`. Always returns 200 with envelope `{ok: true, version: "..."}` or `{ok: false, error: "..."}` — never an HTTP-level error envelope, so the SPA can render results uniformly. |
| `GET` | `/api/admin/lidarr/quality-profiles` | Proxies Lidarr's quality profile list — populates the Settings dropdown. |
| `GET` | `/api/admin/lidarr/root-folders` | Proxies Lidarr's root folder list — populates the Settings dropdown. |
| `GET` | `/api/admin/requests?status=pending\|approved\|rejected\|completed\|failed&limit=` | Admin's queue view. Default `status=pending`. |
| `POST` | `/api/admin/requests/:id/approve` | Body: `{quality_profile_id?, root_folder_path?}` (override fields; absent = use config default). Snapshots chosen values, calls `Client.AddArtist|AddAlbum`, transitions to `approved`, fires scan trigger. |
| `POST` | `/api/admin/requests/:id/reject` | Body: `{notes?}`. Sets status `rejected`, records `decided_*` and `notes`. |
### Error codes
`lidarr_disabled`, `lidarr_unreachable`, `lidarr_auth_failed`, `lidarr_lookup_failed`, `mbid_required`, `request_not_pending`, `request_not_found`, `not_authorized`.
## 6. UI surfaces
All four screens land at the FabledSword design system bar (memory: `project_design_system.md`). Tokens are referenced by name; concrete values live in the design-system memory. Mockups produced during brainstorming live in `.superpowers/brainstorm/<session>/content/` (gitignored) — `discover-fs-v2.html`, `admin-integrations.html`, `admin-requests.html`, `user-requests.html`.
### `/discover` (user-facing)
Search input at top, kind tabs (Artists / Albums / Tracks), card grid of results. Card state derives from the search response's `in_library` and `requested` flags:
- **Kept** (`in_library=true`) — wins over `requested`. Disabled ghost button "In library", "Kept" pill in the badge slot (forest-teal at 15% opacity bg).
- **Requested** (`in_library=false && requested=true`) — disabled ghost button "Requested", subtitle line shows "awaiting review" for pending requests, "downloading" for approved.
- **Requestable** (`in_library=false && requested=false`) — Moss `Request` button with plus icon.
Card layout discipline:
- Card body is `display: flex; flex-direction: column`. Inside, a `.text` block has `min-height` reserving title + meta + badge-row even when fields are absent. Actions block uses `margin-top: auto` so the button always anchors to the bottom of the card.
- `.badge-row` reserves 22px regardless of badge presence — title sits at the same Y across cards.
- Grid `align-items: stretch` keeps cards on the same row equal-height.
Implementation: `<DiscoverResultCard>` Svelte component with props `{kind, artistName, albumTitle?, trackTitle?, imageUrl?, state: 'requestable'|'kept'|'requested', onRequest}`. Reused for any future "list of music things" surface.
Track-kind result requests open a confirmation modal: "Requesting *Track X* will add the album *Album Y*. Continue?" Confirm = Moss, Cancel = Bronze. Disclosure is explicit, not silent.
### `/admin` shell + sidebar
`/admin/*` routes share `+layout.svelte`:
- Role gate: `if (!currentUser.is_admin) goto('/')` before child routes load.
- 220px sidebar with Iron card surface. Active item: 12% accent-tinted background, 2px forest-teal left strip ("you are here").
- Nav items (this slice): Overview · **Integrations** · **Requests** · Quarantine (placeholder for M5b) · Users (future) · Library (future).
Lucide icons at 16px, 1px stroke. Sidebar text: Vellum default, Parchment on active.
### `/admin/integrations`
Lidarr panel (single section in this slice; designed to host more integrations later):
- Header status pill: "Lidarr · connected" (Moss-tinted) when `enabled && reachable`, "unset" (Pewter ghost) otherwise.
- Form rows: Base URL · API key (masked) · Default quality profile (dropdown) · Default root folder (dropdown).
- Action row: **Save changes** = Moss, **Test connection** = Pewter ghost, **Disconnect** = Oxblood + trash icon (right-aligned, separated).
- Inputs on Obsidian (inset feel), 0.5px Pewter borders, 8px radius, focus = `box-shadow: 0 0 0 2px var(--fs-accent)` (no layout shift).
A second placeholder section for "MusicBrainz overrides" with status `unset` foreshadows the panel's role as the integration hub. Not implemented in this slice.
### `/admin/requests`
Tabbed list (Pending / Approved / Completed / Rejected). Tab counts as accent-tinted pills. Default tab `Pending` with badge showing count.
Row anatomy: 56px album-art square (Slate fallback when Lidarr returns no cover) · meta-row with kind pill + "requested by alice · 2h ago" small caps · title in Parchment · meta in Vellum · action cluster: **Override** (Pewter ghost, opens modal) → **Approve** (Moss + check icon) → **Reject** (Bronze + ✕ icon).
Override modal: collapsed-by-default override of `quality_profile_id` and `root_folder_path` for the specific approval. Most approvals click "Approve" without opening this.
Track-kind row's meta line spells out "Approving will add the album *Geogaddi*" — explicit disclosure of the album-promotion behavior.
### `/requests` (user's own)
Single panel listing the caller's requests, ordered by `requested_at desc`. Row anatomy mirrors `/admin/requests` but action cluster is reduced:
- **Pending** → Cancel (Pewter ghost + ✕ icon).
- **Approved** → no actions, "Approved · downloading" status pill (Info-tinted).
- **Completed** → "Listen" link in forest-teal (the page's only brand-moment), navigates to the matched track.
- **Rejected** → no actions, admin's note rendered as Vellum meta when present.
Status pills use the doc's semantic palette (Warning · Info · Moss/Success · Error). Voice rule applied: "Kept" instead of "Completed", "Set aside" instead of "Rejected", "Awaiting review" instead of "Pending review."
## 7. Error handling
### Lidarr unreachable / auth-failed
- `Client.*` methods return typed errors: `lidarr.ErrUnreachable`, `lidarr.ErrAuthFailed`, `lidarr.ErrLookupFailed`.
- Search proxy translates to `503 lidarr_unreachable` or `401 lidarr_auth_failed` — the SPA shows a callout: "Lidarr is unreachable right now. Try again, or check Settings → Integrations." (Voice rule: this is an error/waiting moment, gets the flavored register.)
- Admin approval handler same pattern: returns the error to admin so they can retry without losing the request. The request stays `pending` if the Lidarr call fails — never advances to `approved` without confirmation Lidarr accepted the add.
### Test connection from Settings
- `POST /api/admin/lidarr/test` always returns 200 with `{ok: bool, error?: string, version?: string}` — never an error envelope. Lets the SPA always render the result without parsing HTTP-level errors.
### Reconciler
- Worker errors logged at `WARN`, never propagated. A failing tick doesn't stop the worker — next tick retries.
- If `lidarr_config.enabled = false`, worker silently no-ops each tick.
- Postgres unavailability is a global concern; reconciler errors with the same backoff pattern as `internal/similarity.Worker`.
### Form validation
- Settings: URL must parse, API key must be non-empty if `enabled=true`. Quality profile + root folder must be present and known to Lidarr (validated against `Client.ListQualityProfiles` / `ListRootFolders`).
- Request creation: kind→required-MBID-fields invariant enforced server-side. SPA validates client-side first to avoid round-trips.
## 8. Testing
### Unit tests
- `internal/lidarr/` — table-driven request/response parsing tests against canned fixtures (real Lidarr response samples committed under `internal/lidarr/testdata/`). Covers happy path + auth-failure + 5xx + bad-JSON for each method.
- `internal/lidarrconfig/``Get` returns sensible zero-value when row says `enabled=false`; `Save` updates `updated_at`.
- `internal/lidarrrequests.Service` — pure-logic tests: kind→required-fields validation, status-transition validation (can only approve `pending`, can only cancel `pending`, etc.).
### Integration tests (gated on `MINSTREL_TEST_DATABASE_URL`)
- Reconciler — seeds an `approved` request + a matching track row, runs `tickOnce`, asserts status transitions to `completed` with correct `matched_*_id`. Covers:
- artist-kind matched by `lidarr_artist_mbid` against `artists.mbid`
- album-kind matched by `lidarr_album_mbid` against `albums.mbid`
- track-kind matched by `lidarr_album_mbid` (track-promoted) against `albums.mbid`
- no match (no transition)
- already-completed row not re-processed
- `lidarr_config.enabled=false` short-circuits to no-op
### HTTP tests (handler level)
- `internal/api/lidarr.go` — search proxy with stubbed `Client`: 200 happy path, 503 disabled, 503 unreachable, 401 auth-failed.
- `internal/api/requests.go` — create with valid + invalid kind/MBID combinations; list-mine returns only caller's rows; cancel pending vs cancel non-pending; cross-user 404.
- `internal/api/admin/lidarr.go` — config GET masks api_key; PUT empty-string preserves api_key; test-connection always returns 200 envelope.
- `internal/api/admin/requests.go` — approve fires Lidarr stub, transitions row, captures defaults from config; approve with override snapshots override values; reject without notes works; non-admin 403 across the board.
### Frontend tests (vitest)
- `<DiscoverResultCard>` — renders three states correctly; calls `onRequest` only in requestable state; reserved-slot CSS verified by computed-style assertion (badge-row min-height, button anchored).
- `/discover` page — debounce search input; renders results from store; transitions state on request submit; track-kind opens confirmation modal; modal Confirm calls API, modal Cancel does not.
- `/admin/requests` page — tab switch refetches; approve fires API and removes row from pending; override modal returns chosen values to approve handler; admin-only redirect verified at layout level.
- `/admin/integrations` panel — empty-state copy; test-connection updates status pill; Disconnect requires confirmation.
- `/requests` page — status pills render with correct semantic class; Cancel works on pending; "Listen" link only renders on completed rows.
### Coverage target
- `internal/lidarr/` ≥ 80%
- `internal/lidarrrequests/` ≥ 80%
- `internal/api/lidarr.go`, `internal/api/requests.go`, `internal/api/admin/*` — handler coverage measured combined ≥ 70% (matches current api package threshold)
## 9. Decisions ledger
| # | Decision | Rationale |
|---|---|---|
| 1 | Decompose M5 into M5a / M5b / M5c | Matches the M4 cadence; smaller PRs, faster review, clearer scope per slice |
| 2 | Permissions: search-all, add-admin (request queue) | Operator's call — "browse-and-suggest workflow lets non-trusted household members participate without giving them library-write access" |
| 3 | Config storage: DB-only, Settings UI as the entry point | Operator's product principle — "no one wants to configure yamls; this is a finished product, not a project" (memory: `project_product_not_project.md`) |
| 4 | Settings shape: dedicated `/admin/*` route group with hard route gate | Per-app product surface for admin actions; redirect at layout-level, never load admin content for non-admin (operator's instruction) |
| 5 | Search UX: standalone `/discover` route (option B), not inline-in-search | Operator preference — explicit "I want to add music" surface, separate from local-library search |
| 6 | Granularity: artist + album + track | Operator preference — track-kind resolves to album-kind under the hood (Lidarr's monitor unit is album), explicit modal disclosure rather than silent expansion |
| 7 | Lifecycle detection: library scan as source of truth | Reuses existing scanner; reconciler is a 5-min worker; webhook is a clean follow-up if latency matters |
| 8 | Quality profile / root folder: default + per-add override | Default covers >90% of approvals; override is the escape hatch. Modal is collapsed-by-default |
| 9 | Approve fires Lidarr synchronously, reconcile asynchronously (Approach 1) | Admin gets immediate "Lidarr accepted/rejected" feedback; reconciliation has to be async because downloads take minutes-to-hours |
| 10 | New `RequireAdmin` middleware on `/api/admin/*` route group | Centralized auth check; SPA route gate is UX, server middleware is the security boundary |
| 11 | UI lands at FabledSword design system bar (memory: `project_design_system.md`) | Operator's quality bar — "no more scaffolding-feel UI" (memory: `project_ui_quality.md`); accent only for brand-moments, Moss/Bronze/Oxblood for actions |
| 12 | Track-kind disclosure modal | Lidarr can't fetch a single track without its album; explicit "this will add the album" beats silently expanding the request |
| 13 | `lidarr_requests.failed` status reserved but not transitioned in this slice | Foreshadows a "stalled approval" timeout follow-up; not v1 |
## 10. Out of scope (this slice)
Tracked in the M5 milestone for later sub-plans:
- **Quarantine workflow** — `lidarr_quarantine` table, soft-hide on tracks, admin "delete via Lidarr" path, `/admin/quarantine` page. → M5b.
- **Radio suggested-additions** — `/api/radio` response includes a separate field for out-of-library MBIDs from `track_similarity`; SPA shows "Would you add these?" affordance with inline request submission. → M5c.
- **Lidarr webhook ingestion** — push notifications on download complete; near-real-time status updates on `/requests`. → cheap follow-up after M5c.
- **Failed-request timeout** — reconciler transitions long-stuck `approved` requests to `failed` with operator-facing diagnostics. → operational tightening; not v1.
- **Admin requestor notifications** — toast/badge when a user logs in if their request was approved/completed/rejected. → polish, slot into Fable #349 (UI polish pass) or earlier if it becomes friction.
## 11. Open questions
- **Album cover art proxying** — Lidarr returns image URLs that point at MusicBrainz/Cover Art Archive. The SPA could fetch directly (extra origins, CORS), or Minstrel could proxy them through `/api/cover-art?lidarr=...`. **Decision deferred to plan time** — start with direct fetch, add proxy if CORS bites.
- **Search debounce / cache** — Lidarr's lookup endpoint is the rate-limit-sensitive one. SPA debounce of 250ms + 60s server-side LRU cache on `(query, kind)` is the conservative starting point. Tunable.
- **`failed` status promotion** — at what time threshold does `approved``failed`? Suggest 7 days, but no logic for it ships in this slice.
@@ -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.
@@ -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.
+249
View File
@@ -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":"<code>"} 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})
}
+379
View File
@@ -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"])
}
})
}
}
+243
View File
@@ -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)
}
+429
View File
@@ -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())
}
})
}
}
+152
View File
@@ -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))
}
+446
View File
@@ -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"])
}
})
}
}
+49 -7
View File
@@ -13,15 +13,24 @@ 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"
)
// 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, 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}
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)
@@ -40,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)
@@ -51,14 +61,46 @@ 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)
authed.Post("/requests", h.handleCreateRequest)
authed.Get("/requests", h.handleListRequests)
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)
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)
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
pool *pgxpool.Pool
logger *slog.Logger
events *playevents.Writer
recCfg config.RecommendationConfig
rng func() float64
lidarrCfg *lidarrconfig.Service
lidarrRequests *lidarrrequests.Service
lidarrQuarantine *lidarrquarantine.Service
}
+7 -1
View File
@@ -22,6 +22,9 @@ 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/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
)
@@ -54,7 +57,10 @@ 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)
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
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
}
+9 -1
View File
@@ -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")
+1 -1
View File
@@ -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, h.lidarrRequests, h.lidarrQuarantine)
paths := []string{
"/api/artists",
+137
View File
@@ -0,0 +1,137 @@
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
}
+327
View File
@@ -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, _ *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)
}
}
+148
View File
@@ -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)
}
+305
View File
@@ -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())
}
})
}
}
+203
View File
@@ -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))
}
+356
View File
@@ -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())
}
}
+19 -4
View File
@@ -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")
+85
View File
@@ -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)
}
+104
View File
@@ -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())
}
}
+45
View File
@@ -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":"<code>"} and sets
// Content-Type. Uses a flat envelope (not the nested api.errorBody shape)
// because the spec for /api/admin/* errors defines {"error":"<code>"} 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})
}
+94
View File
@@ -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, _ *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(_ http.ResponseWriter, _ *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(_ http.ResponseWriter, _ *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)
}
}
+3 -38
View File
@@ -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
+1 -1
View File
@@ -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
+34 -1
View File
@@ -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
@@ -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
`
+1 -1
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// sqlc v1.31.1
package dbq
+1 -1
View File
@@ -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
+76
View File
@@ -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
}
+362
View File
@@ -0,0 +1,362 @@
// 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)::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
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 pgtype.Timestamptz
}
// 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 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
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
TrackIDJoin pgtype.UUID
TrackTitle string
TrackDurationMs int32
AlbumID pgtype.UUID
AlbumTitle string
AlbumCoverArtPath *string
ArtistID pgtype.UUID
ArtistName string
}
// 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 {
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.TrackIDJoin,
&i.TrackTitle,
&i.TrackDurationMs,
&i.AlbumID,
&i.AlbumTitle,
&i.AlbumCoverArtPath,
&i.ArtistID,
&i.ArtistName,
); 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
}
+490
View File
@@ -0,0 +1,490 @@
// 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
}
// $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
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. 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,
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. 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
}
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
}
+1 -1
View File
@@ -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
+245 -1
View File
@@ -1,13 +1,192 @@
// 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 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 (
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
@@ -37,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
@@ -65,6 +253,62 @@ 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 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
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
+105 -1
View File
@@ -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
@@ -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
@@ -286,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
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+33 -1
View File
@@ -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
@@ -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())
+136 -1
View File
@@ -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
@@ -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,
+1 -1
View File
@@ -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
@@ -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;
+63
View File
@@ -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;
@@ -0,0 +1,7 @@
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_track_idx;
DROP TABLE IF EXISTS lidarr_quarantine;
DROP TYPE IF EXISTS lidarr_quarantine_reason;
@@ -0,0 +1,48 @@
-- 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)
);
-- 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 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);
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS artist_similarity_unmatched_seed_score_idx;
DROP TABLE IF EXISTS artist_similarity_unmatched;
@@ -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);
+5
View File
@@ -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[]);
+17
View File
@@ -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;
+96
View File
@@ -0,0 +1,96 @@
-- 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 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),
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
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)::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
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;
+95
View File
@@ -0,0 +1,95 @@
-- 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
-- $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',
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. 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,
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. 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 = @mbid)
OR (kind = 'album' AND lidarr_album_mbid = @mbid)
OR (kind = 'track' AND lidarr_track_mbid = @mbid))
);
+59
View File
@@ -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
@@ -142,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;
+12
View File
@@ -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();
+31
View File
@@ -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
);
+3
View File
@@ -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",
@@ -49,6 +50,8 @@ var dataTables = []string{
"skip_events",
"play_sessions",
"sessions",
"lidarr_quarantine_actions",
"lidarr_quarantine",
"tracks",
"albums",
"artists",
+52
View File
@@ -0,0 +1,52 @@
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. 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)
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
}
+119
View File
@@ -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)
}
}
+383
View File
@@ -0,0 +1,383 @@
package lidarr
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// 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
}
// 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) {
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.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 = strings.TrimRight(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 []lidarrImage `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"`
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)
}
out := make([]LookupResult, 0, len(raw))
for _, r := range raw {
year := ""
if len(r.ReleaseDate) >= 4 {
year = r.ReleaseDate[:4]
}
secondary := ""
if r.ArtistName != "" {
secondary = r.ArtistName
}
if year != "" {
if secondary != "" {
secondary += " · "
}
secondary += year
}
if r.TrackCount > 0 {
if secondary != "" {
secondary += " · "
}
secondary += strconv.Itoa(r.TrackCount) + " tracks"
}
out = append(out, LookupResult{
MBID: r.ForeignAlbumID,
ArtistMBID: r.ForeignArtistID,
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"`
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,
ArtistMBID: r.ForeignArtistID,
AlbumMBID: r.ForeignAlbumID,
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, err := json.Marshal(map[string]any{
"foreignArtistId": p.ForeignArtistID,
"qualityProfileId": p.QualityProfileID,
"rootFolderPath": p.RootFolderPath,
"monitored": true,
"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
}
_ = 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, err := 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},
})
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
}
_ = 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 []lidarrImage) string {
for _, img := range imgs {
if img.CoverType == "poster" {
if img.RemoteURL != "" {
return img.RemoteURL
}
return img.URL
}
}
return ""
}
+607
View File
@@ -0,0 +1,607 @@
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].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)
}
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].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)
}
}
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)
}
}
// --- 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) {
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_NullBody(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))
}
}
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) {
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 := []lidarrImage{
{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 := []lidarrImage{
{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 := []lidarrImage{
{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)
}
}
+69
View File
@@ -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
}
+214
View File
@@ -0,0 +1,214 @@
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 !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")
}
}
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, "key123")
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(_ 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")
}
}
+19
View File
@@ -0,0 +1,19 @@
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")
// 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")
)
+54
View File
@@ -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 func() { _ = resp.Body.Close() }()
var rows []LidarrArtist
if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil {
return LidarrArtist{}, fmt.Errorf("%w: decode artist: %v", ErrInvalidPayload, 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 func() { _ = resp.Body.Close() }()
var rows []LidarrAlbum
if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil {
return LidarrAlbum{}, fmt.Errorf("%w: decode album: %v", ErrInvalidPayload, err)
}
if len(rows) == 0 {
return LidarrAlbum{}, ErrNotFound
}
return rows[0], nil
}
+8
View File
@@ -0,0 +1,8 @@
[
{
"id": 42,
"foreignAlbumId": "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d",
"title": "Music Has The Right To Children",
"artistId": 7
}
]
+7
View File
@@ -0,0 +1,7 @@
[
{
"id": 7,
"foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01",
"artistName": "Boards of Canada"
}
]
+23
View File
@@ -0,0 +1,23 @@
[
{
"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",
"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",
"foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01",
"title": "Geogaddi",
"artistName": "Boards of Canada",
"releaseDate": "2002-02-11",
"trackCount": 23,
"images": []
}
]
+19
View File
@@ -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": []
}
]
+18
View File
@@ -0,0 +1,18 @@
[
{
"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"
}
]
+5
View File
@@ -0,0 +1,5 @@
[
{"id": 1, "name": "Any"},
{"id": 2, "name": "Lossless"},
{"id": 3, "name": "Standard"}
]
+5
View File
@@ -0,0 +1,5 @@
[
{"path": "/music", "accessible": true, "freeSpace": 107374182400},
{"path": "/music-lossy", "accessible": true, "freeSpace": 53687091200},
{"path": "/music-offline", "accessible": false, "freeSpace": 0}
]
+78
View File
@@ -0,0 +1,78 @@
// 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}.
// 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
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.
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
}
// 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"`
}
+88
View File
@@ -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
}
+88
View File
@@ -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)
}
}
+354
View File
@@ -0,0 +1,354 @@
// 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"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"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/library"
"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 = &notes
}
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 (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
}
// 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,
})
}
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
}
// 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)
}
// 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: nil, 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. 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: nil, 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
}
+534
View File
@@ -0,0 +1,534 @@
package lidarrquarantine
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"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"
)
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_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")
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))
}
}
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)
}
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")
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 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)
}
// 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)
}
}
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)
}
}
+192
View File
@@ -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
}
@@ -0,0 +1,443 @@
package lidarrrequests
import (
"context"
"io"
"log/slog"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/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)
}
}
// 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)
}
}
+260
View File
@@ -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
}
+332
View File
@@ -0,0 +1,332 @@
package lidarrrequests
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"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/lidarr"
"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)
}
}
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)
}
}
+102
View File
@@ -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
}
@@ -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))
}
}
+3
View File
@@ -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"`
}
+21 -2
View File
@@ -17,6 +17,10 @@ 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"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
"git.fabledsword.com/bvandeusen/minstrel/web"
@@ -55,9 +59,24 @@ func (s *Server) Router() http.Handler {
s.EventsCfg.SkipMaxCompletionRatio,
s.EventsCfg.SkipMaxDurationPlayedMs,
)
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg)
lidarrCfg := lidarrconfig.New(s.Pool)
// 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.RequireAdmin(s.Pool))
admin.Use(auth.RequireUser(s.Pool))
admin.Use(auth.RequireAdmin())
if s.Scanner != nil {
admin.Post("/scan", s.handleAdminScan)
}
+32 -3
View File
@@ -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++
}
}
@@ -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
}
+11 -1
View File
@@ -8,7 +8,8 @@
"name": "minstrel-web",
"version": "0.0.0",
"dependencies": {
"@tanstack/svelte-query": "^5.90.2"
"@tanstack/svelte-query": "^5.90.2",
"lucide-svelte": "^1.0.1"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
@@ -2637,6 +2638,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/lucide-svelte": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-1.0.1.tgz",
"integrity": "sha512-WvzZgk0pqzgda+AErLvgWxHkfg/+GgUwqKMRHvzt0IqyMdmyEDzDCk3Z+Wo/3y753oIgx8u9Q4eUbWkghFa8Jg==",
"license": "ISC",
"peerDependencies": {
"svelte": "^3 || ^4 || ^5.0.0-next.42"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+2 -1
View File
@@ -28,6 +28,7 @@
"vitest": "^2.1.4"
},
"dependencies": {
"@tanstack/svelte-query": "^5.90.2"
"@tanstack/svelte-query": "^5.90.2",
"lucide-svelte": "^1.0.1"
}
}
+6
View File
@@ -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);
}
+7 -1
View File
@@ -5,9 +5,15 @@
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Minstrel</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500&family=Inter:wght@400;500&family=JetBrains+Mono:wght@400;500&display=swap"
/>
%sveltekit.head%
</head>
<body class="bg-surface-900 text-text-primary">
<body class="bg-background text-text-primary">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+347
View File
@@ -0,0 +1,347 @@
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,
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';
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce([baseRow]);
await listAdminRequests();
expect(api.get).toHaveBeenCalledWith('/api/admin/requests');
});
test('status only -> ?status=', async () => {
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce([baseRow]);
await listAdminRequests('approved');
expect(api.get).toHaveBeenCalledWith('/api/admin/requests?status=approved');
});
test('status + limit -> ?status=&limit=', async () => {
(api.get as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce(baseRow);
await rejectRequest('r1');
expect(api.post).toHaveBeenCalledWith('/api/admin/requests/r1/reject', {});
});
test('includes notes when provided', async () => {
(api.post as ReturnType<typeof vi.fn>).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 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([
'adminRequests',
{ status: 'approved' }
]);
});
});
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 }
]);
});
});
+150
View File
@@ -0,0 +1,150 @@
import { createQuery } from '@tanstack/svelte-query';
import { api } from './client';
import { qk } from './queries';
import type {
ActionResult,
AdminQuarantineRow,
LidarrConfig,
LidarrQualityProfile,
LidarrQuarantineActionRow,
LidarrRequest,
LidarrRequestStatus,
LidarrRootFolder,
LidarrTestResult
} from './types';
// Admin Lidarr config -----------------------------------------------------
export async function getLidarrConfig(): Promise<LidarrConfig> {
return api.get<LidarrConfig>('/api/admin/lidarr/config');
}
export async function putLidarrConfig(cfg: LidarrConfig): Promise<LidarrConfig> {
return api.put<LidarrConfig>('/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<LidarrTestResult> {
return api.post<LidarrTestResult>('/api/admin/lidarr/test', body);
}
export async function listQualityProfiles(): Promise<LidarrQualityProfile[]> {
return api.get<LidarrQualityProfile[]>('/api/admin/lidarr/quality-profiles');
}
export async function listRootFolders(): Promise<LidarrRootFolder[]> {
return api.get<LidarrRootFolder[]>('/api/admin/lidarr/root-folders');
}
// Admin request queue -----------------------------------------------------
export async function listAdminRequests(
status?: LidarrRequestStatus,
limit?: number
): Promise<LidarrRequest[]> {
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<LidarrRequest[]>(
qs ? `/api/admin/requests?${qs}` : '/api/admin/requests'
);
}
export async function approveRequest(
id: string,
overrides: { quality_profile_id?: number; root_folder_path?: string } = {}
): Promise<LidarrRequest> {
return api.post<LidarrRequest>(`/api/admin/requests/${id}/approve`, overrides);
}
export async function rejectRequest(
id: string,
notes?: string
): Promise<LidarrRequest> {
const body = notes !== undefined ? { notes } : {};
return api.post<LidarrRequest>(`/api/admin/requests/${id}/reject`, body);
}
// Query factories ---------------------------------------------------------
export function createLidarrConfigQuery() {
return createQuery({
queryKey: qk.lidarrConfig(),
queryFn: getLidarrConfig,
staleTime: 60_000
});
}
// `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)
});
}
// 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
});
}
+73
View File
@@ -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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce([]);
await searchLidarr('boards of canada & friends', 'album');
const calledWith = (api.get as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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' }
]);
});
});
+28
View File
@@ -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<LidarrSearchResult[]> {
const params = new URLSearchParams({ q, kind });
return api.get<LidarrSearchResult[]>(`/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
});
}
+116
View File
@@ -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']);
});
});
+37
View File
@@ -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
});
}
+15
View File
@@ -19,6 +19,21 @@ 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 ?? 'all' }] as const,
myQuarantine: () => ['myQuarantine'] as const,
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) {

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