Three admin-gated handlers: GET /admin/requests (list by status),
POST /admin/requests/:id/approve (with optional overrides), and
POST /admin/requests/:id/reject. testHandlers updated to inject a
real clientFn so Approve exercises Lidarr in integration tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Five admin-only handlers under RequireAdmin middleware: GET/PUT config
(api_key masked, empty key on PUT preserves saved), POST test (always
200, maps Lidarr errors to stable codes), GET quality-profiles, GET
root-folders. 10 HTTP integration tests, all green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements POST/GET/GET:id/DELETE /api/requests handlers delegating to
lidarrrequests.Service; wires routes in RequireUser group and threads
the service through Mount and server.go.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements GET /api/lidarr/search?q=&kind=artist|album|track. Validates
kind and q, loads lidarr_config per-request, calls the matching Lidarr
Lookup* method, enriches each result with in_library (EXISTS by MBID
against artists/albums/tracks) and requested (HasNonTerminalRequestForMBID),
and returns a normalized JSON array. Adds lidarrCfg field to handlers struct
and threads lidarrconfig.Service through Mount and server.Router.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the old X-API-Token-based RequireAdmin in middleware.go with a
context-aware RequireAdmin() that runs after RequireUser, checks
user.IsAdmin, and returns 403 {"error":"not_authorized"} for non-admins
or 500 {"error":"internal_error"} if RequireUser was bypassed. Updates
server.go to mount RequireUser then RequireAdmin on the /api/admin group.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ArtistMBID/AlbumMBID to LookupResult so handlers can build the
full lidarr_requests row from album/track lookups (parent MBID is
needed for the AddAlbum API call and for the schema's NOT NULL
lidarr_artist_mbid)
- Trim trailing slash on BaseURL before joining paths to avoid
double-slash URLs when operators enter "http://lidarr.lan/"
- Check json.Marshal errors in AddArtist/AddAlbum
- Extract lidarrImage as a named unexported type instead of repeating
the anonymous struct three times
- Add NewClient(baseURL, apiKey) constructor with 30s default timeout
- Add ErrLookupFailed test coverage for the 4xx-non-auth branch on
both get and post helpers
- Rename TestListRootFolders_BadJSON -> _NullBody (it tests null,
not malformed); add a real malformed-JSON test
- Defensive separator guard on LookupAlbum.Secondary so an empty
ArtistName doesn't produce a leading " · "
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- HasNonTerminalRequestForMBID: use named parameter @mbid so the
generated Go signature is `mbid string` instead of the misleading
`lidarrArtistMbid string` (the value applies to all three MBID
columns, not just the artist column)
- CancelLidarrRequest: comment dual role of $2 (ownership guard +
decided_by audit field) for future readers
- CompleteLidarrRequest: document the per-kind one-of-three contract
for matched_*_id parameters
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
M5a is the foundation slice of M5 (Lidarr integration + quarantine).
Decomposed into M5a/M5b/M5c during brainstorming on 2026-04-29:
- M5a (this spec) — Lidarr connection + search/add + admin shell
- M5b — Quarantine workflow (per-user soft-hide, admin resolution)
- M5c — Radio suggested-additions (out-of-library MBIDs surfaced)
Each ships as its own PR with its own brainstorm/spec/plan cycle.
Key decisions captured:
- Permissions: search-all, add-admin via request queue
- Config: DB-only, Settings UI is the only entry point (no YAML)
- Settings shape: dedicated /admin/* route group, hard route gate
- Search UX: standalone /discover route, all three granularities
- Lifecycle: library scan as source of truth + 5-min reconciler worker
- Quality profile/root folder: default + per-add admin override
- UI lands at FabledSword design system bar (forest-teal accent;
Moss/Bronze/Oxblood action buttons; per project_design_system memory)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Companion to the dbtest.ResetDB change. The bootstrap integration
test fundamentally needs an empty users table — it exercises the
"first time admin is created" path, which Bootstrap() guards with
a count==0 check. So this test alone still has to TRUNCATE users.
To keep the operator's admin login intact on a shared dev DB:
- Before TRUNCATE, save every user that doesn't start with 'test-'.
- Use cfg.Username = 'test-admin' for the bootstrap call so the
test row is itself test-prefixed and gets cleaned up by other
tests' ResetDB calls.
- t.Cleanup restores the saved rows after assertions complete,
so admin/admin (or whatever password the operator set) keeps
working between test runs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Integration tests previously TRUNCATEd the users table at setup,
which wiped the operator's admin login every time the suite ran
against a Postgres instance shared with their dev environment.
This commit:
- Adds internal/dbtest/reset.go exposing ResetDB(t, pool) and
TestUserPrefix. ResetDB truncates every data table EXCEPT users,
then deletes only users whose username starts with TestUserPrefix.
- Migrates 9 test files (subsonic/scrobble, subsonic/star,
recommendation/candidates, scrobble/queue, similarity/worker,
playevents/writer, playsessions/service, api/auth, api/me) to
call ResetDB instead of issuing TRUNCATE-with-users.
- Renames hardcoded test usernames to use TestUserPrefix so ResetDB
cleans them between runs. seedUser in api/auth_test now prefixes
internally; existing call sites pass bare names.
- Leaves auth/bootstrap_test.go alone — it legitimately tests
first-time admin bootstrap and must wipe users.
Verified locally: full integration suite with -p 1 runs cleanly
under the existing MINSTREL_TEST_DATABASE_URL setup, and the admin
row in the shared dev DB survives the run.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds _radioSeedId state and a module-level $effect.root that triggers a
/api/radio?seed_track=…&exclude=… fetch when queue consumption reaches
80%, appending new tracks without resetting the radio seed. Clears seed
on any non-radio enqueue (playQueue/enqueueTrack/enqueueTracks). Five
new vitest cases cover the trigger threshold, append logic, in-flight
guard, and manual-enqueue reset path.
Wire LoadCandidatesFromSimilarity as the primary candidate loader with
an ?exclude= query param for comma-separated UUID filtering; fall back
to LoadCandidates on error. Thread SimilarityWeight into ScoringWeights
and update testHandlers recCfg accordingly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The combined seed_info CTE filtered the seed out when its genre was
NULL/empty. Restored the spec's two-CTE design (seed_artist + seed_tags)
so similar-artist matching works regardless of the seed's genre coverage.
Drops the genre workaround from the SimilarArtist test.
Third and final M4 sub-plan (Fable #347) — closes M4. Replaces M3's
whole-library candidate pool with a 5-way SQL UNION (LB-similar tracks /
tracks by similar artists / MB-tag overlap / likes-overlap / random
fill). Adds a new SimilarityScore × SimilarityWeight term to M3's Score()
formula. Web client auto-refreshes the queue when 80% consumed via a new
?exclude= query param. No lazy LB fetch (M4b's worker + random
augmentation handle sparse data). Fallback to M3 LoadCandidates on any
similarity-pool error.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace stub with full tickOnce: drains played tracks/artists via
ListPlayedTracksNeedingSimilarity / ListPlayedArtistsNeedingSimilarity,
calls LB SimilarRecordings/SimilarArtists, filters to local library via
bulk MBID lookup, enforces top-K=20 by score, and upserts similarity
rows. 429 aborts the entire tick; other errors log-and-skip. Ten
integration tests covering no-op, library filtering, top-K cap, 7-day
freshness cap, stale refresh, 429 abort, transient skip, and artist
pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Second M4 sub-plan (Fable #346). Periodic worker pulls track-track and
artist-artist similarity edges from LB's public /explore/* endpoints,
filters to the local library, stores top-20 per source track in two new
tables (track_similarity, artist_similarity). Hourly tick, batch=5,
weekly re-fetch cap per row, passive retry via timer. No auth (public
endpoints). Discovery within library handled by LB's collaborative-
filtering response naturally surfacing unplayed library tracks; spec
notes M4c will add a serendipity floor + lazy fetch + sparse-fallback.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds api.put helper, ListenBrainz API module with query/mutation
helpers, a /settings route with token + enable/disable UI, and 5
tests. Adds Settings to the Shell nav.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Exposes user ListenBrainz config (token_set bool, enabled, last_scrobbled_at)
via write-only token semantics; enforces no-enable-without-token at the API layer.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bridges closed play_events to the scrobble_queue: loads the event,
applies the Qualifies() threshold, checks user LB config (enabled + token),
and inserts via ON CONFLICT DO NOTHING for idempotency. Best-effort —
callers (playevents.Writer hooks, Task 8) will log and swallow errors.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements the HTTP client for ListenBrainz submit-listens with typed
sentinel errors (ErrAuth, ErrPermanent, ErrTransient, *RetryAfterError)
so the scrobble worker can branch on response semantics. All 9 httptest-
driven tests pass.