Commit Graph

192 Commits

Author SHA1 Message Date
bvandeusen 6fedd495be feat(api): /api/quarantine user + admin endpoints + Service wiring
User-facing /api/quarantine handlers (POST flag, DELETE unflag, GET mine)
plus the five /api/admin/quarantine endpoints (queue, resolve, delete-file,
delete-via-lidarr, actions). Mount() and the handlers struct now accept the
lidarrquarantine.Service, constructed in server.go alongside the existing
lidarrrequests wiring.
2026-04-30 20:19:39 -04:00
bvandeusen ba81e4f2ac feat(api): route track-list reads through user-context quarantine filter 2026-04-30 20:09:04 -04:00
bvandeusen 559fb5dd2c feat(db): add user-context track query variants honoring quarantine 2026-04-30 19:49:00 -04:00
bvandeusen ef3fffb3ea fix(lidarrquarantine): keep LidarrAlbumMbid nil on Resolve/DeleteFile audit rows + cover idempotent Resolve
The audit log treats lidarr_album_mbid as a 'this row triggered Lidarr'
marker. Setting it on Resolve/DeleteFile (where Lidarr is never contacted)
muddied that semantic. Reverting to plan: only DeleteViaLidarr writes the
mbid. Adds an idempotency test for Resolve over a track with zero rows
(audit row written, affected_users=0, mbid nil).
2026-04-30 19:42:19 -04:00
bvandeusen 782e72b595 feat(lidarrquarantine): admin actions Resolve/DeleteFile/DeleteViaLidarr 2026-04-30 19:37:40 -04:00
bvandeusen 7a0bc1f815 fix(lidarrquarantine): translate FK violation to ErrTrackNotFound + drop unneeded *string AlbumTitle
- Flag now detects Postgres SQLSTATE 23503 (foreign_key_violation) on
  UpsertQuarantine and surfaces ErrTrackNotFound instead of a wrapped
  generic. T8's handler can now return 404 track_not_found cleanly.
  Constraint-name guard ('track' substring) keeps a future user_id FK from
  mis-mapping to the same error.
- AdminQueueRow.AlbumTitle is now string (not *string). albums.title is
  NOT NULL upstream; the empty-string-to-nil dance was answering a
  nullability question that doesn't exist in the schema.
- Add TestFlag_NonexistentTrackReturnsErrTrackNotFound covering the new
  branch.
2026-04-30 17:42:55 -04:00
bvandeusen b4fe224a67 feat(lidarrquarantine): Service Flag/Unflag/ListMine/ListAdminQueue 2026-04-30 17:36:27 -04:00
bvandeusen e6e3f297d6 fix(dbtest,library): include quarantine tables in ResetDB; clarify DeleteTrackFile docstring
- dbtest.ResetDB.dataTables now truncates lidarr_quarantine + lidarr_quarantine_actions
  alongside the other M2-M4 data tables. Without this, M5b service tests would
  inherit residual state across runs.
- DeleteTrackFile godoc spells out the post-file/pre-DB failure window
  reconciles via the next library scan; the function is retry-safe by design,
  not atomic.
2026-04-30 17:32:08 -04:00
bvandeusen d0523da520 feat(library): DeleteTrackFile (rm file + tracks row, album/artist preserved) 2026-04-30 17:04:21 -04:00
bvandeusen dbe0e79f54 fix(lidarr): wrap MBID-lookup decode errors with ErrInvalidPayload + fill test gaps
- Decode errors in LookupArtist/AlbumByMBID now wrap ErrInvalidPayload so
  callers can errors.Is the same way they do against the M5a methods.
- Tighten TestLookupAlbumByMBID_BadJSON to assert on ErrInvalidPayload.
- Add empty-mbid rejection tests for both methods.
- Add TestLookupArtistByMBID_EmptyArrayReturnsErrNotFound (only the album
  variant had the test before — symmetric coverage now).
- Normalize the network-error test's api key to key123 for consistency
  with the rest of delete_test.go.
2026-04-30 17:01:50 -04:00
bvandeusen f2cdd23fd5 feat(lidarr): LookupArtistByMBID, LookupAlbumByMBID, DeleteAlbum 2026-04-30 16:57:14 -04:00
bvandeusen 71a9bd8dee fix(db): drop redundant quarantine user index + tighten ListQuarantineForUser
- Composite PK (user_id, track_id) already serves WHERE user_id queries;
  the secondary (user_id, created_at DESC) index just amplified writes.
- ListQuarantineForUser now selects only the joined fields the SPA card
  actually renders (~10 columns) rather than embedding three full structs
  (~35 columns); halves wire/DB bandwidth before consumers exist.
- max(q.created_at)::timestamptz cast emits pgtype.Timestamptz instead of
  interface{} so handlers can read latest_at without a type-assert.
2026-04-30 16:54:03 -04:00
bvandeusen a8df73fe42 feat(db): add lidarr_quarantine + actions schema (migration 0011) 2026-04-30 16:48:33 -04:00
bvandeusen db37deb6f8 test(lidarrrequests): expand coverage on Service.Approve + Reconciler
- Adds Service tests for ListPending/ListByStatus/ListForUser, the album-
  and unknown-kind validation branches, and an Approve happy path with a
  stub Lidarr server that exercises both default-snapshot and override-
  snapshot persistence.
- Adds Reconciler tests for Run (short tick + cancel), the album-not-yet-
  in-library no-op branch, and the track-kind 'parent album present but no
  tracks yet' branch.

Combined coverage on internal/lidarr*, lidarrconfig, lidarrrequests now
86.5% (lidarrrequests 81.5%), meeting the per-package >=80% target in
spec section 8.
2026-04-30 10:58:21 -04:00
bvandeusen d1c1853d49 fix(auth): silence revive unused-parameter on stub test handlers 2026-04-29 17:58:43 -04:00
bvandeusen 82ffb12f68 feat(api): add /api/admin/requests approval queue
Three admin-gated handlers: GET /admin/requests (list by status),
POST /admin/requests/:id/approve (with optional overrides), and
POST /admin/requests/:id/reject. testHandlers updated to inject a
real clientFn so Approve exercises Lidarr in integration tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:56:25 -04:00
bvandeusen 6fcae8dee4 feat(api): add /api/admin/lidarr/* config + profiles + folders + test
Five admin-only handlers under RequireAdmin middleware: GET/PUT config
(api_key masked, empty key on PUT preserves saved), POST test (always
200, maps Lidarr errors to stable codes), GET quality-profiles, GET
root-folders. 10 HTTP integration tests, all green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:44:54 -04:00
bvandeusen 03cbd4d0c1 feat(api): add /api/requests user-facing CRUD
Implements POST/GET/GET:id/DELETE /api/requests handlers delegating to
lidarrrequests.Service; wires routes in RequireUser group and threads
the service through Mount and server.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:40:49 -04:00
bvandeusen 9192eb9f0a fix(api): gofmt + revive cleanup on lidarr search handler 2026-04-29 17:35:45 -04:00
bvandeusen 905e27a988 feat(api): add /api/lidarr/search proxy with library/request enrichment
Implements GET /api/lidarr/search?q=&kind=artist|album|track. Validates
kind and q, loads lidarr_config per-request, calls the matching Lidarr
Lookup* method, enriches each result with in_library (EXISTS by MBID
against artists/albums/tracks) and requested (HasNonTerminalRequestForMBID),
and returns a normalized JSON array. Adds lidarrCfg field to handlers struct
and threads lidarrconfig.Service through Mount and server.Router.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:35:00 -04:00
bvandeusen 4492826354 feat(auth): add RequireAdmin middleware for /api/admin/* routes
Replaces the old X-API-Token-based RequireAdmin in middleware.go with a
context-aware RequireAdmin() that runs after RequireUser, checks
user.IsAdmin, and returns 403 {"error":"not_authorized"} for non-admins
or 500 {"error":"internal_error"} if RequireUser was bypassed. Updates
server.go to mount RequireUser then RequireAdmin on the /api/admin group.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:27:47 -04:00
bvandeusen f73a5ccef5 feat(lidarrrequests): add Reconciler worker matching approved requests to library
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:17:32 -04:00
bvandeusen b11fc117e7 feat(lidarrrequests): request lifecycle service (create/list/approve/reject/cancel)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:12:03 -04:00
bvandeusen 429b75131b feat(lidarrconfig): typed singleton config wrapper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 16:29:22 -04:00
bvandeusen ebff8f69a0 fix(lidarr): address review findings on HTTP client
- 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>
2026-04-29 16:26:23 -04:00
bvandeusen a43fa09a04 feat(lidarr): typed HTTP client for v1 API (lookup, add, profiles, ping)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 15:25:10 -04:00
bvandeusen a5bb79f2a8 fix(db): clarify lidarr_requests query semantics
- HasNonTerminalRequestForMBID: use named parameter @mbid so the
  generated Go signature is `mbid string` instead of the misleading
  `lidarrArtistMbid string` (the value applies to all three MBID
  columns, not just the artist column)
- CancelLidarrRequest: comment dual role of $2 (ownership guard +
  decided_by audit field) for future readers
- CompleteLidarrRequest: document the per-kind one-of-three contract
  for matched_*_id parameters

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 15:19:54 -04:00
bvandeusen 9ceac5c639 feat(db): add lidarr_config + lidarr_requests schema (migration 0010)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 15:10:01 -04:00
bvandeusen 21310e7716 test: bootstrap test saves/restores non-test users
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>
2026-04-29 12:04:16 -04:00
bvandeusen 937a1930ad test: add dbtest.ResetDB helper that preserves admin user
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>
2026-04-29 11:55:06 -04:00
bvandeusen 8647f9ebe0 feat(api): radio uses similarity pool with exclude param + M3 fallback
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>
2026-04-29 08:47:31 -04:00
bvandeusen b8e3019654 fix(db): split seed_info CTE so similar-artist works without seed genre
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.
2026-04-29 08:43:42 -04:00
bvandeusen 51811c9d35 feat(recommendation): add LoadCandidatesFromSimilarity (5-source candidate pool)
Implements M4c's similarity-driven candidate pool: CandidateSourceLimits,
DefaultCandidateSourceLimits, and LoadCandidatesFromSimilarity consuming
LoadRadioCandidatesV2 (5-way UNION + max-score dedup). Adds 10 integration
tests covering all sources, exclusions, dedup, and edge cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:30:53 -04:00
bvandeusen bb3f911761 feat(recommendation): extend Score with SimilarityScore + SimilarityWeight 2026-04-29 08:01:53 -04:00
bvandeusen 362cb2c9ce feat(db): add LoadRadioCandidatesV2 sqlc query (5-way UNION) 2026-04-29 07:56:57 -04:00
bvandeusen 893adf04c9 feat(similarity): implement Worker.tickOnce with track + artist passes
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>
2026-04-28 20:06:47 -04:00
bvandeusen 9adad094b0 feat(similarity): add Worker skeleton with constructor + Run loop 2026-04-28 20:02:15 -04:00
bvandeusen b0d1d6bb46 feat(listenbrainz): add SimilarArtists client method 2026-04-28 20:01:17 -04:00
bvandeusen 23d55a868b feat(listenbrainz): add SimilarRecordings client method 2026-04-28 19:59:38 -04:00
bvandeusen d4e0e5fae3 feat(db): add similarity sqlc queries (list-needing, get-by-mbids, upsert) 2026-04-28 19:58:05 -04:00
bvandeusen fc2d35d33c feat(db): add migration 0009 for similarity (track_similarity + artist_similarity) 2026-04-28 19:57:02 -04:00
bvandeusen 1d1119ac32 feat(api): add GET/PUT /api/me/listenbrainz endpoints
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>
2026-04-28 09:27:06 -04:00
bvandeusen 4d123f1b71 feat(playevents): post-commit MaybeEnqueue in RecordPlayEnded + Synthetic 2026-04-28 09:23:21 -04:00
bvandeusen 9490bad360 feat(scrobble): add Worker with tickOnce, batched submit, retry/fail/auth handling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 09:20:23 -04:00
bvandeusen 8ce39a8868 feat(scrobble): add backoffDelay schedule (1m, 5m, 30m, 2h, 6h, give up) 2026-04-28 09:14:08 -04:00
bvandeusen dba1deaad1 feat(scrobble): add MaybeEnqueue with idempotent insert + threshold gate
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>
2026-04-28 09:13:11 -04:00
bvandeusen 767e246b73 feat(scrobble): add pure Qualifies threshold function 2026-04-28 09:10:39 -04:00
bvandeusen 5359a16091 style(scrobble): rename unused r *http.Request to _ in client tests
Caught by revive's unused-parameter check. The four error-path tests
don't read the request body, so the parameter is properly named _.
2026-04-28 09:09:45 -04:00
bvandeusen 3d23f4930c feat(scrobble): add pure ListenBrainz HTTP client with typed errors
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.
2026-04-28 08:45:13 -04:00
bvandeusen a0e21f21b6 feat(db): add ListenBrainz user-config and scrobble_queue queries 2026-04-28 08:42:38 -04:00