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:
@@ -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.
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
// sqlc v1.31.1
|
||||
// source: albums.sql
|
||||
|
||||
package dbq
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package dbq
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
// sqlc v1.31.1
|
||||
// source: sessions.sql
|
||||
|
||||
package dbq
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,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;
|
||||
@@ -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);
|
||||
@@ -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[]);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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))
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{
|
||||
"id": 42,
|
||||
"foreignAlbumId": "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d",
|
||||
"title": "Music Has The Right To Children",
|
||||
"artistId": 7
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{
|
||||
"id": 7,
|
||||
"foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01",
|
||||
"artistName": "Boards of Canada"
|
||||
}
|
||||
]
|
||||
+23
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{"id": 1, "name": "Any"},
|
||||
{"id": 2, "name": "Lossless"},
|
||||
{"id": 3, "name": "Standard"}
|
||||
]
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{"path": "/music", "accessible": true, "freeSpace": 107374182400},
|
||||
{"path": "/music-lossy", "accessible": true, "freeSpace": 53687091200},
|
||||
{"path": "/music-offline", "accessible": false, "freeSpace": 0}
|
||||
]
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = ¬es
|
||||
}
|
||||
row, err := dbq.New(s.pool).UpsertQuarantine(ctx, dbq.UpsertQuarantineParams{
|
||||
UserID: userID,
|
||||
TrackID: trackID,
|
||||
Reason: dbq.LidarrQuarantineReason(reason),
|
||||
Notes: notesPtr,
|
||||
})
|
||||
if err != nil {
|
||||
// FK violation on track_id (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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Generated
+11
-1
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user