478 Commits

Author SHA1 Message Date
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
bvandeusen efc2308348 feat(db): add migration 0008 for scrobble (users LB columns + scrobble_queue table) 2026-04-28 08:40:39 -04:00
bvandeusen 95d68e3d3d feat: M3.5 polish — genre splitting + radio button surface
Two carry-overs from M3 verification, bundled with the search-input
fix already on dev (b7a59a9).

1. Genre splitting in BuildSessionVector
   The library has many tracks whose genre tag is a denormalized
   multi-genre string ("Indie Pop; Pop; Alternative Pop"). Reading them
   as one opaque tag means a single-genre "Pop" track and a multi-genre
   track listing Pop both fail to share the Pop key, so the tags axis
   in similarity scoring (weight 0.7!) returned 0 in nearly all cases
   on real libraries. Result: contextual scoring couldn't differentiate.

   Add splitGenres() that splits on ; and , and trims whitespace;
   strings without a delimiter come back as a single-element slice
   (so the existing single-genre cases keep working unchanged).
   Concatenated-without-separator output ("ElectronicComplextroGlitch
   Hop") stays opaque — that needs a genre dictionary, out of scope.

2. Play-radio button on TrackRow
   playRadio() was only wired to the click handler on /search and
   /search/tracks, leaving /library/liked, album pages, and search
   results' inner clicks with no way to start a radio. Adds a small
   radio button to TrackRow (between LikeButton and the +queue button).
   Click → playRadio(track.id), with stopPropagation so the row's own
   activate handler doesn't also fire.

Tests:
- 3 new sessionvector tests: TestSplitGenres, multi-genre semicolon
  split, no-separator stays opaque. Existing single-genre tests pass
  unchanged.
- 1 new TrackRow test: radio button calls playRadio with track id and
  does NOT trigger row play.

Verified locally: go -short -race ./... clean, golangci-lint clean,
svelte-check 0/0, 175 vitest tests (was 174), web build succeeds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 23:50:00 -04:00
bvandeusen ad56eb279f style(recommendation): drop trailing comments that gofmt-1.23 wants aligned
CI's golangci-lint (`gofmt -s`) on Go 1.23 flagged inline comments after
the SessionVector literals in TestContextualMatchScore_TakesMax because
the variable-length values produced misaligned trailing columns. Local
gofmt on Go 1.26 was lenient. Replace with a leading comment that covers
all three rows; same intent, no alignment dance.
2026-04-27 22:41:24 -04:00
bvandeusen 22622f3065 test(api): end-to-end contextual ranking test for /api/radio
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 21:20:01 -04:00
bvandeusen 284271c190 feat(api): radio handler reads current session vector + threads ContextWeight
Wire contextual scoring end-to-end: add ContextWeight to RecommendationConfig
(struct + Default()), fetch the user's current session vector in handleRadio
via loadCurrentSessionVector (cold-start returns Seed sentinel), and pass it
as the 6th arg to LoadCandidates so ContextualMatchScore flows into Shuffle.
2026-04-27 20:55:25 -04:00
bvandeusen 541698a8b1 test(recommendation): check LoadCandidates errors in new contextual tests
Code review caught four tests using `got, _` to discard load errors. A DB
failure would have surfaced as a misleading "wrong score" assertion rather
than a clear "load failed" message. Match the t.Fatalf pattern used by the
other LoadCandidates tests in this file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 20:51:55 -04:00
bvandeusen 5b79aa1047 feat(recommendation): LoadCandidates computes per-candidate ContextualMatchScore
Adds a 6th argument (currentVector SessionVector) to LoadCandidates,
bulk-fetches the user's active contextual_likes in one query, and sets
ContextualMatchScore on each candidate's ScoringInputs via max-similarity
over that candidate's like vectors. Adds helper + 6 integration tests
covering no-likes, single match, multi-like max, soft-delete filtering,
seed-like filtering, and seed-current short-circuit. Adds contextual_likes
to the TRUNCATE list in testPool to prevent cross-test leakage.

internal/api/radio.go is intentionally left broken (Task 6 fixes it).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 20:41:55 -04:00
bvandeusen cb46b3830f feat(db): add similarity lookup queries (ListActiveContextualLikesForUser, GetCurrentSessionVectorForUser) 2026-04-27 20:36:42 -04:00
bvandeusen 49871ba06d feat(recommendation): extend Score with ContextualMatchScore + ContextWeight 2026-04-27 20:31:54 -04:00
bvandeusen 347884bc2b feat(recommendation): add ContextualMatchScore (max over non-seed likes) 2026-04-27 20:20:53 -04:00
bvandeusen a7211aacff feat(recommendation): add pure Similarity function with weighted Jaccard 2026-04-27 20:04:35 -04:00
bvandeusen bf7c5ad47d feat(subsonic): wire contextual_likes capture/soft-delete into star/unstar
handleStar's track branch calls playevents.CaptureContextualLikeIfPlaying
when the underlying LikeTrack actually inserted a row. handleUnstar
calls SoftDeleteContextualLikes after every track-id unstar. Same
helpers as the api surface — single source of truth.

mediaHandlers struct gains a logger field threaded through Mount.
2026-04-27 11:25:10 -04:00
bvandeusen fe1cc46752 feat(api): capture contextual_likes on like during open play_event
Two new helpers in internal/playevents (CaptureContextualLikeIfPlaying,
SoftDeleteContextualLikes) called by both api and subsonic surfaces.
Like inserts a new contextual_likes row when LikeTrack actually
inserted (rows=1) AND there's an open play_event with a vector.
Unlike soft-deletes via deleted_at. Idempotent in both directions.

Subsonic wiring lands in the next commit.
2026-04-27 11:22:35 -04:00
bvandeusen d6acb3b63c feat(playevents): persist session_vector_at_play on every record path
RecordPlayStarted and RecordSyntheticCompletedPlay both capture the
session vector inside their existing transactions. Single private
helper queries the prior 5 tracks, builds the vector, UPDATEs the
just-inserted play_event. Tests verify the seed flag boundary and
session-scope isolation.
2026-04-27 11:19:16 -04:00