Commit Graph

492 Commits

Author SHA1 Message Date
bvandeusen 277898a49a feat(recommendation): SuggestArtists service for M5c
Add per-user artist-suggestion service ranking out-of-library MBIDs by
signal x similarity. Single-CTE SQL collects user likes (5x weight) and
recency-decayed plays, joins against artist_similarity_unmatched, and
filters in-library candidates plus non-terminal lidarr_requests. The
service resolves top-3 attribution seeds to artist names in a batched
GetArtistsByIDs call so the UI can render "because you liked X" reasons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 06:20:02 -04:00
bvandeusen 5e73f590a9 feat(similarity): persist unmatched similar-artist MBIDs for M5c
upsertArtistSimilar keeps the existing matched path (top-K rows into
artist_similarity) and adds a parallel unmatched-persist loop with the
same top-K cap. Empty-name rows are skipped — we can't render a
suggestion card without a name. Logs unmatched-side errors at WARN
without aborting the tick (mirrors the matched-path policy).
2026-05-01 06:07:22 -04:00
bvandeusen be23ae488d feat(listenbrainz): expose Name on SimilarArtist for M5c suggestions 2026-05-01 05:51:29 -04:00
bvandeusen 2ca09749d9 feat(db): add artist_similarity_unmatched schema (migration 0012) 2026-05-01 05:50:22 -04:00
bvandeusen cf1b75ca12 docs(plan): add M5c suggested-additions implementation plan
9 tasks: migration 0012 (artist_similarity_unmatched), ListenBrainz client
extension (SimilarArtist.Name), similarity worker extension to persist
unmatched MBIDs, recommendation.SuggestArtists service with single CTE,
GET /api/discover/suggestions handler, frontend client + types, DiscoverResultCard
attribution prop, SuggestionFeed component + /discover integration.
2026-04-30 23:02:41 -04:00
bvandeusen a0a9fb201b docs(spec): add M5c suggested-additions design
Personalized artist suggestions on /discover (search-input-empty state).
On-demand SQL ranks out-of-library MBIDs from artist_similarity_unmatched
(new table, populated by extending the M4b similarity worker) by per-user
signal: likes weighted 5x plus recency-decayed plays (exp(-age_days/30)).
Top-12 with top-3 contributing seeds attributed per card. Reuses the M5a
DiscoverResultCard + POST /api/requests artist-add path.
2026-04-30 22:48:55 -04:00
bvandeusen 3e3ad89645 fix: T16 verification cleanups
- golangci-lint: errcheck on resp.Body.Close in Lidarr client + revive
  unused-parameter in delete_test.go
- coverage: 2 error-branch tests added to lidarrquarantine.Service
  (DeleteFile + DeleteViaLidarr track-not-found paths) bring per-package
  coverage to 81.4% (target >=80%)
- search/tracks.test.ts: same TrackMenu name-collision fix T13 applied to
  TrackRow.test.ts — exact-string button match instead of regex
2026-04-30 20:49:11 -04:00
bvandeusen 3bfec944c7 feat(web): /admin/quarantine aggregated queue with resolution actions
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:34 -04:00
bvandeusen 0d7a65cffb feat(web): /library/hidden user-facing quarantine view
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:35:11 -04:00
bvandeusen 88ff997af7 feat(web): mount TrackMenu in TrackRow + PlayerBar 2026-04-30 20:31:56 -04:00
bvandeusen 8da4a3f9c0 feat(web): TrackMenu overflow + FlagPopover for the quarantine flow 2026-04-30 20:29:07 -04:00
bvandeusen b653e83478 feat(web): API client modules for quarantine + admin quarantine 2026-04-30 20:26:07 -04:00
bvandeusen b7f8136502 fix(server): wire real Lidarr clientFn for requests + quarantine services
Both lidarrrequests.Approve and lidarrquarantine.DeleteViaLidarr need a
working client factory; the previous nil clientFn made them always return
ErrLidarrDisabled even when /admin/integrations had a valid Lidarr config
saved. Per-call factory re-reads config so admin saves take effect without
restart, matching the Service contract.
2026-04-30 20:23:07 -04:00
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 0806a37a42 docs(plan): add M5b quarantine workflow implementation plan
16 tasks covering migration 0011, Lidarr client extensions (LookupArtistByMBID,
LookupAlbumByMBID, DeleteAlbum), library.DeleteTrackFile, lidarrquarantine.Service
(Flag/Unflag/ListMine/ListAdminQueue + Resolve/DeleteFile/DeleteViaLidarr),
soft-hide enforcement on user-context track-list reads, /api/quarantine + admin
endpoints, and frontend (TrackMenu/FlagPopover, /library/hidden, /admin/quarantine,
sidebar promotion). Mirrors the M5a plan cadence.
2026-04-30 15:15:53 -04:00
bvandeusen 0b3919e4a1 docs(spec): add M5b quarantine workflow design
Per-user track-level soft-hide with reason taxonomy, aggregated admin queue
at /admin/quarantine with Resolve / Delete file / Delete via Lidarr actions,
audit log for admin actions, soft-hide honored by /api/* read endpoints
(Subsonic /rest/* unchanged per legacy rule). Lidarr client extends with
LookupAlbumByMBID + DeleteAlbum (deleteFiles + addImportListExclusion).
2026-04-30 14:54:44 -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 ab9be40bb2 feat(web): add /admin/requests approval queue with override modal
Pending/Approved/Completed/Rejected tabs (active count only). Override modal
collapsed by default; reject modal with notes textarea. Lidarr-unreachable
toast surfaces sync-Approve failures. Cancel buttons in both modals are
Pewter ghost; Reject/Confirm reject use Bronze (the design system reserves
Oxblood for irreversible actions like Disconnect).
2026-04-29 23:31:18 -04:00
bvandeusen b895e9ef7e feat(web): add /admin/requests approval queue with override modal
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 23:24:34 -04:00
bvandeusen b1b50187b3 fix(web): integrations panel review fixes
- Trim whitespace before checking the typed-confirm value (paste-with-spaces
  was silently keeping the button disabled).
- Wrap onConfirmDisconnect in try/catch and surface disconnect_failed inline
  in the modal — operators on flaky networks now see why nothing happened.
- Invalidate quality-profile + root-folder caches alongside config on both
  save and disconnect, so a base_url change refetches the lists immediately.
2026-04-29 23:11:32 -04:00
bvandeusen c281c8b5dd feat(web): add /admin/integrations Lidarr connection panel
Connection management for Lidarr — base URL, API key, default quality
profile, default root folder. Empty api_key on save preserves the
masked-saved key per backend semantics. Disconnect requires typed
"DISCONNECT" confirmation. Includes a placeholder MusicBrainz overrides
section for future integrations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 23:06:19 -04:00
bvandeusen 041e63744d fix(web): promote bg-accent-tint to a real Tailwind utility
Replaces the :global scoped-style workaround in AdminSidebar with a proper
accent.tint color tier in tailwind.config.js. Tailwind generates bg-accent-tint
deterministically; the global class no longer leaks from a single component.
Future consumers (e.g. /admin/requests tab counts) get the same utility.
2026-04-29 22:54:30 -04:00
bvandeusen bfd48f5a02 feat(web): add /admin layout with role-gated load + sidebar
Hard route gate in admin/+layout.ts redirects non-admins to / before
the layout (or any child) renders. AdminSidebar reads the active route
from $app/state and applies a 12% accent-tinted bg + 2px forest-teal
left strip on the active item. Overview landing shows pending-request
count and Lidarr connected/unset, each linking to its sub-page. Shell
nav exposes the Admin link only to is_admin users.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 22:48:26 -04:00
bvandeusen dd86ffca94 fix(web): a11y on /requests aria-labels when titles are absent
Album- and track-kind requests can have null album_title/track_title (the
type allows it). Previously aria-label rendered 'Cancel request for —' which
is meaningless to a screen reader. Fall back to 'this album by <artist>' /
'this track by <artist>' so the cancel/listen buttons stay disambiguable.
2026-04-29 22:29:07 -04:00
bvandeusen ad904afaf6 feat(web): add /requests user-facing request history
Renders the caller's Lidarr requests as rows with kind pill,
StatusPill, and per-status actions: Cancel on pending (which
calls cancelRequest then invalidates qk.myRequests()), Listen
link on completed (deepest match wins: track > album > artist),
admin notes on rejected. Empty state uses the voice-rule
"Nothing requested yet." copy. Shell nav gains /requests
between /discover and /playlists, visible to all authed users
since the view is per-user.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 22:24:17 -04:00
bvandeusen a7506d9413 fix(web): drop dead onkeydown stopPropagation on discover modal 2026-04-29 22:20:35 -04:00
bvandeusen 8a12b8c571 feat(web): add /discover route with search + request flow
Local-input + 250ms debounce drives Lidarr search; tab switch refetches
with new kind. Track-kind Request opens a confirm modal explaining the
album that will be added; Confirm fires createRequest, Cancel is a no-op.
Successful requests flip the card to 'requested' optimistically.
Shell nav now exposes /discover between Search and Playlists.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 22:15:52 -04:00
bvandeusen d27b381250 feat(web): add StatusPill semantic-color status indicator
Single-prop component mapping LidarrRequestStatus to voice-rule labels and
semantic tones (warning/info/success/error). Used by /requests and /admin/requests
in subsequent tasks.
2026-04-29 22:08:09 -04:00
bvandeusen dcb49e9687 fix(web): a11y on DiscoverResultCard + drop test-only inline styles
aria-labels now include the card title on all three button variants so
SR users navigating a grid can tell which card a button belongs to:
  - "Request <title>" / "<title> is already in library" / "<title>
    already requested"
The Kept pill gets role="status" so SR users hear the badge when it
appears (without aria-live's announce-on-mount noise).

The reserved-slot CSS (.badge-row { min-height: 22px } and
.actions { margin-top: auto }) was already in the scoped <style>
block; we drop the duplicate inline style="" attributes that existed
purely to satisfy jsdom's getComputedStyle. Tried stylesheet
introspection (document.styleSheets) as a replacement assertion, but
vitest's @testing-library/svelte renderer doesn't inject the scoped
<style> tag into jsdom (styleSheets.length === 0), so the two CSS
assertions are dropped with an explanatory comment. The layout
discipline is enforced visually at the consumer page rather than as
a "CSS exists in CSS" unit test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 21:05:27 -04:00
bvandeusen c8bd19d6f1 feat(web): add DiscoverResultCard with reserved badge slot + anchored button
T14 of the M5a Lidarr plan. The Discover grid in M5a renders mixed
requestable / kept / requested cards from Lidarr search. Without
layout discipline the cards in a row land their titles at different
Y coordinates whenever the badge presence varies, which reads as
visual noise. DiscoverResultCard enforces:

  - Flex column with `margin-top: auto` on `.actions`, so the action
    button is anchored to the bottom of the card body regardless of
    title/subtitle wrap differences.
  - `.badge-row` always rendered with `min-height: 22px`, so the
    title baseline holds even when no Kept pill is present.

Both load-bearing CSS values use inline style attributes — jsdom's
getComputedStyle does not resolve scoped <style> blocks, so the tests
verify positioning via inline styles directly. The remaining decorative
CSS (kept-pill background = accent at 15%, flex-direction, gap) lives
in a scoped <style> block.

State -> action mapping (sentence case per FabledSword voice):
  requestable -> bg-action-primary "Request" with Plus icon
  kept        -> disabled ghost "In library" + Kept pill
  requested   -> disabled ghost "Requested"

Cover art falls back to a Lucide glyph (Disc3 / Album / Music2) when
imageUrl is absent. Adds lucide-svelte@^1.0.1 dependency.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:27:58 -04:00
bvandeusen ce7424219c feat(web): adminRequests cache key + staleTime polish
- qk.adminRequests defaults to status='all' when no filter is set; the unfiltered
  endpoint returns every status, so caching it as 'pending' was a latent mismatch.
- 60s staleTime on lidarrConfig + myRequests — both are session-stable enough
  that refetching on every consumer-page mount produced needless flicker.
2026-04-29 20:04:51 -04:00
bvandeusen d7eaa189e2 feat(web): add API client modules for Lidarr, requests, admin
T13 of the M5a Lidarr plan. Three new client modules wrap api.get/post/put/del
helpers and the existing TanStack Query qk namespace:

  lidarr.ts   - searchLidarr() + createLidarrSearchQuery()
  requests.ts - createRequest, listMyRequests, getRequest, cancelRequest;
                createMyRequestsQuery(); cancel goes through apiFetch
                directly because the backend returns the cancelled row body
                (api.del's return type is fixed to null).
  admin.ts    - getLidarrConfig, putLidarrConfig, testLidarrConnection,
                listQualityProfiles, listRootFolders, listAdminRequests,
                approveRequest, rejectRequest; query factories for each
                read; quality profiles + root folders take an enabled prop
                so the call site decides when Lidarr is configured.

Shared LidarrRequestStatus / LidarrRequestKind enums and request/config/
search-result shapes added to types.ts. Per-module helpers (CreateRequestParams)
stay in their module files. testLidarrConnection returns a discriminated union
({ok:true,version} | {ok:false,error}) and never throws on ok:false so the
SPA can render either branch.

qk extended with lidarrSearch, myRequests, lidarrConfig,
lidarrQualityProfiles, lidarrRootFolders, adminRequests.

Tests mirror likes.test.ts (vi.mock('./client')) and cover URL construction,
query-param encoding, body shapes, the not-ok testLidarrConnection branch,
and qk additions. 32 new tests, 217 total passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:55:27 -04:00
bvandeusen 29968ae8da feat(web): wire Tailwind fontFamily to FS font tokens
Removes duplication between fabledsword-tokens.css and tailwind.config.js;
font stacks now live in one place so per-app overrides reach Tailwind utilities.
2026-04-29 19:42:53 -04:00
bvandeusen a7090c3768 feat(web): introduce FabledSword design system tokens + Tailwind aliases
Add the canonical FS token palette (surfaces, text, action, semantic,
accent, radii, fonts) as CSS custom properties under web/src/lib/styles/
fabledsword-tokens.css, with a [data-fs-app="minstrel"] hook reserved
for future per-app accent overrides. Wire the tokens through Tailwind by
aliasing semantic colour utilities (bg-background, bg-surface,
bg-surface-hover, text-text-primary/secondary/muted, border-border,
bg-action-primary/secondary/destructive, accent, warning/error/info) to
the new variables, plus rounded-sm..xl and font-display/sans/mono. Load
Fraunces, Inter, and JetBrains Mono (400/500 only) via Google Fonts and
default the body to Inter. Existing components inherit the new palette
without per-page changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 18:48:20 -04:00
bvandeusen d1c1853d49 fix(auth): silence revive unused-parameter on stub test handlers 2026-04-29 17:58:43 -04:00
bvandeusen 1bde1787b9 feat(cmd): start Lidarr reconciler worker alongside HTTP server
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:57:42 -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