20 tasks covering backend SQL queries, recommendation service,
three new API endpoints (/api/home, /api/library/albums,
/api/artists/{id}/tracks), and frontend rewrite of / as the home
page composing four horizontal-scroll sections, plus new
/library/artists and /library/albums wrapping-grid pages.
ArtistRow component retired in favor of circular ArtistCard
(takes the click-target bug with it). AlbumCard gets the
FabledSword polish pass with a play-button overlay.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the brainstorm for the first UI polish slice (Fable #349):
new `/` home page with 4 sections (Recently added / Rediscover /
Most played / Last played) as independent horizontal-scroll rows
with arrow buttons, library list relocated to `/library/artists`
and `/library/albums` as wrapping grids with alphabetical dividers,
ArtistCard (circular) replacing ArtistRow (retires the click-target
bug), AlbumCard polish pass with play-button overlay.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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).
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.
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.
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.
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).
- 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.
- 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.
- 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.
- 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.
- 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.
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).
- 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.
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>
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.
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>
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.
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>
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>
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.
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>