M5a frontend + M5b quarantine + M5c suggestions #30

Merged
bvandeusen merged 262 commits from dev into main 2026-05-01 07:14:20 -04:00
bvandeusen commented 2026-04-30 21:05:49 -04:00 (Migrated from git.fabledsword.com)

Bundles M5a frontend (T12–T21), M5b quarantine workflow (T1–T16), and M5c suggested-additions on /discover (T1–T9). 50 commits.

M5a frontend (T12–T21)

Lands the FabledSword design system + the four M5a pages on top of the M5a backend that already shipped.

  • T12 FabledSword design tokens + Tailwind aliases (Obsidian/Iron/Slate/Pewter, Parchment/Vellum/Ash, Moss/Bronze/Oxblood, forest-teal accent; Fraunces/Inter/JetBrains Mono).
  • T13 API client modules (lidarr.ts, requests.ts, admin.ts) + 6 query keys.
  • T14 <DiscoverResultCard> component with reserved badge slot + bottom-anchored button.
  • T15 <StatusPill> semantic-color status indicator.
  • T16 /discover route with debounced search, kind tabs, track-kind confirmation modal.
  • T17 /requests user history with cancel + listen affordances, status pills, voice-rule copy.
  • T18 /admin/* layout with hard role-gated load, sidebar, Overview landing.
  • T19 /admin/integrations Lidarr connection panel with typed-confirm Disconnect modal.
  • T20 /admin/requests approval queue with override + reject modals, lidarr-unreachable toast.

M5b quarantine workflow (T1–T16)

Per-user track soft-hide + admin resolution UI. Spec at docs/superpowers/specs/2026-04-30-m5b-quarantine-design.md, plan at docs/superpowers/plans/2026-04-30-m5b-quarantine.md.

Backend:

  • Migration 0011: lidarr_quarantine (per-user complaints, composite PK, ON DELETE CASCADE on track_id) + lidarr_quarantine_actions (admin audit log with snapshot text columns).
  • Lidarr client extensions: LookupArtistByMBID, LookupAlbumByMBID, DeleteAlbum (always with deleteFiles=true&addImportListExclusion=true), ErrNotFound sentinel.
  • internal/library.DeleteTrackFile — file-first, then DB; retry-safe.
  • internal/lidarrquarantine.Service with Flag/Unflag/ListMine/ListAdminQueue (read paths) and Resolve/DeleteFile/DeleteViaLidarr (admin actions). FK violation on Flag translates to ErrTrackNotFound for clean 404s.
  • Soft-hide enforcement on user-context track-list reads: *ForUser query variants for album detail + search; quarantine clause folded into both radio loaders. /rest/* Subsonic stays unfiltered per the legacy rule.
  • /api/quarantine/* user-facing CRUD (3 endpoints) + /api/admin/quarantine/* (5 endpoints) wired into the existing RequireAdmin route group.
  • Server now constructs a real per-call Lidarr clientFn so lidarrrequests.Approve and lidarrquarantine.DeleteViaLidarr reach Lidarr when configured (was nil-stubbed since M5a).

Frontend:

  • <TrackMenu> overflow (kebab) component opens <FlagPopover>. Mounted in <TrackRow> and <PlayerBar> so users can flag from any list or while listening.
  • /library/hidden page lists the caller's quarantines with one-click un-hide.
  • /admin/quarantine aggregated queue with Resolve / Delete file (modal-confirm) / Delete via Lidarr (typed-confirm "DELETE"); inline play-via-radio button; lidarr-unreachable toast; dimmed Delete-via-Lidarr when lidarr_album_mbid is null.
  • <AdminSidebar> Quarantine promoted from placeholder to real link.

M5c suggested-additions (T1–T9)

Personalized artist suggestions on /discover when the search input is empty. Spec at docs/superpowers/specs/2026-04-30-m5c-suggested-additions-design.md, plan at docs/superpowers/plans/2026-04-30-m5c-suggested-additions.md.

Diverges from the M5a §10 carve-out (which had said /api/radio would carry the suggestions): operator redirected during brainstorming. The suggestion surface is a dedicated recommendation feed on /discover, decoupled from radio.

Backend:

  • Migration 0012: artist_similarity_unmatched table mirrors artist_similarity shape with no FK on the candidate side. M4b worker now persists the unmatched MBIDs it used to discard (with Name field added to the LB SimilarArtist struct).
  • internal/recommendation.SuggestArtists runs a single CTE per request that joins the user's likes (×5) and recency-decayed plays (exp(-age_days/half_life), half_life=30d) against artist_similarity_unmatched, filters out in-library and non-terminal-requested candidates, and returns top-N candidates with top-3 contributing seeds for attribution.
  • GET /api/discover/suggestions?limit=&half_life_days= — read-only, sub-50ms at household scale.

Frontend:

  • <DiscoverResultCard> extended with optional attribution?: string prop (italic Vellum line below the title).
  • <SuggestionFeed> component owns the data fetch + attribution formatting + optimistic-requested removal. Attribution copy: 1 seed → "Because you liked X.", 2 → "Because you liked X and played Y.", 3 → Oxford-comma form.
  • /discover swaps the initial-copy state for <SuggestionFeed>. Kind tabs hide when input is empty, return when searching.

Verification

  • go test -short -race ./... — all packages pass.
  • golangci-lint run ./... — clean.
  • Coverage on Lidarr + recommendation packages: lidarr 89.3%, lidarrconfig 92.3%, lidarrrequests 81.5%, lidarrquarantine 81.4%, recommendation 90.9% (combined 86.9%; all individually ≥ 80% per spec §8).
  • Frontend: npm run check 0 errors, npm test -- --run 323/323 pass, npm run build clean.

The pre-existing internal/library/TestScanner_Integration flake is documented at project_scanner_flake.md; not introduced by this PR (zero internal/library/ changes in the diff aside from M5b's DeleteTrackFile addition).

Test plan

M5a

  • Hit /discover, search Lidarr, request an artist → verify it lands at /requests and at /admin/requests.
  • Approve a request from /admin/requests → wait for the next library scan → confirm status flips to "Kept" at /requests.
  • Connect/disconnect Lidarr from /admin/integrations; confirm typed-confirm gates Disconnect.

M5b

  • Flag a track from the kebab on a track row; confirm it disappears from album/search/library.
  • Open /library/hidden, confirm row is present, un-hide → track returns to album view.
  • As admin, view /admin/quarantine aggregated queue (with multiple users on the same track); test Resolve, Delete file, and Delete via Lidarr (typed-confirm).
  • Confirm /rest/* Subsonic responses still include quarantined tracks (legacy clients unaffected).

M5c

  • Like an artist (or play a few tracks); wait for the M4b worker tick to populate artist_similarity_unmatched.
  • Open /discover with the search input empty; verify "Suggested for you" header and grid render with attribution lines.
  • Click Request on a suggestion; confirm it disappears (optimistic) and a row appears at /requests.
  • Type a search term; confirm the feed swaps out for search results and kind tabs reappear.
  • Clear the search input; confirm the feed comes back (cached).
  • For a fresh user with no signal, verify the empty-state copy.

Cross-cutting

  • Non-admin user redirected when navigating to /admin/*.
Bundles M5a frontend (T12–T21), M5b quarantine workflow (T1–T16), and M5c suggested-additions on `/discover` (T1–T9). 50 commits. ## M5a frontend (T12–T21) Lands the FabledSword design system + the four M5a pages on top of the M5a backend that already shipped. - **T12** FabledSword design tokens + Tailwind aliases (Obsidian/Iron/Slate/Pewter, Parchment/Vellum/Ash, Moss/Bronze/Oxblood, forest-teal accent; Fraunces/Inter/JetBrains Mono). - **T13** API client modules (`lidarr.ts`, `requests.ts`, `admin.ts`) + 6 query keys. - **T14** `<DiscoverResultCard>` component with reserved badge slot + bottom-anchored button. - **T15** `<StatusPill>` semantic-color status indicator. - **T16** `/discover` route with debounced search, kind tabs, track-kind confirmation modal. - **T17** `/requests` user history with cancel + listen affordances, status pills, voice-rule copy. - **T18** `/admin/*` layout with hard role-gated `load`, sidebar, Overview landing. - **T19** `/admin/integrations` Lidarr connection panel with typed-confirm Disconnect modal. - **T20** `/admin/requests` approval queue with override + reject modals, lidarr-unreachable toast. ## M5b quarantine workflow (T1–T16) Per-user track soft-hide + admin resolution UI. Spec at `docs/superpowers/specs/2026-04-30-m5b-quarantine-design.md`, plan at `docs/superpowers/plans/2026-04-30-m5b-quarantine.md`. **Backend:** - Migration 0011: `lidarr_quarantine` (per-user complaints, composite PK, `ON DELETE CASCADE` on `track_id`) + `lidarr_quarantine_actions` (admin audit log with snapshot text columns). - Lidarr client extensions: `LookupArtistByMBID`, `LookupAlbumByMBID`, `DeleteAlbum` (always with `deleteFiles=true&addImportListExclusion=true`), `ErrNotFound` sentinel. - `internal/library.DeleteTrackFile` — file-first, then DB; retry-safe. - `internal/lidarrquarantine.Service` with Flag/Unflag/ListMine/ListAdminQueue (read paths) and Resolve/DeleteFile/DeleteViaLidarr (admin actions). FK violation on `Flag` translates to `ErrTrackNotFound` for clean 404s. - Soft-hide enforcement on user-context track-list reads: `*ForUser` query variants for album detail + search; quarantine clause folded into both radio loaders. `/rest/*` Subsonic stays unfiltered per the legacy rule. - `/api/quarantine/*` user-facing CRUD (3 endpoints) + `/api/admin/quarantine/*` (5 endpoints) wired into the existing `RequireAdmin` route group. - Server now constructs a real per-call Lidarr `clientFn` so `lidarrrequests.Approve` and `lidarrquarantine.DeleteViaLidarr` reach Lidarr when configured (was nil-stubbed since M5a). **Frontend:** - `<TrackMenu>` overflow (kebab) component opens `<FlagPopover>`. Mounted in `<TrackRow>` and `<PlayerBar>` so users can flag from any list or while listening. - `/library/hidden` page lists the caller's quarantines with one-click un-hide. - `/admin/quarantine` aggregated queue with Resolve / Delete file (modal-confirm) / Delete via Lidarr (typed-confirm "DELETE"); inline play-via-radio button; lidarr-unreachable toast; dimmed Delete-via-Lidarr when `lidarr_album_mbid` is null. - `<AdminSidebar>` Quarantine promoted from placeholder to real link. ## M5c suggested-additions (T1–T9) Personalized artist suggestions on `/discover` when the search input is empty. Spec at `docs/superpowers/specs/2026-04-30-m5c-suggested-additions-design.md`, plan at `docs/superpowers/plans/2026-04-30-m5c-suggested-additions.md`. Diverges from the M5a §10 carve-out (which had said `/api/radio` would carry the suggestions): operator redirected during brainstorming. The suggestion surface is a dedicated recommendation feed on `/discover`, decoupled from radio. **Backend:** - Migration 0012: `artist_similarity_unmatched` table mirrors `artist_similarity` shape with no FK on the candidate side. M4b worker now persists the unmatched MBIDs it used to discard (with `Name` field added to the LB `SimilarArtist` struct). - `internal/recommendation.SuggestArtists` runs a single CTE per request that joins the user's likes (×5) and recency-decayed plays (`exp(-age_days/half_life)`, half_life=30d) against `artist_similarity_unmatched`, filters out in-library and non-terminal-requested candidates, and returns top-N candidates with top-3 contributing seeds for attribution. - `GET /api/discover/suggestions?limit=&half_life_days=` — read-only, sub-50ms at household scale. **Frontend:** - `<DiscoverResultCard>` extended with optional `attribution?: string` prop (italic Vellum line below the title). - `<SuggestionFeed>` component owns the data fetch + attribution formatting + optimistic-requested removal. Attribution copy: 1 seed → "Because you liked X.", 2 → "Because you liked X and played Y.", 3 → Oxford-comma form. - `/discover` swaps the initial-copy state for `<SuggestionFeed>`. Kind tabs hide when input is empty, return when searching. ## Verification - `go test -short -race ./...` — all packages pass. - `golangci-lint run ./...` — clean. - Coverage on Lidarr + recommendation packages: `lidarr` 89.3%, `lidarrconfig` 92.3%, `lidarrrequests` 81.5%, `lidarrquarantine` 81.4%, `recommendation` 90.9% (combined 86.9%; all individually ≥ 80% per spec §8). - Frontend: `npm run check` 0 errors, `npm test -- --run` 323/323 pass, `npm run build` clean. The pre-existing `internal/library/TestScanner_Integration` flake is documented at `project_scanner_flake.md`; not introduced by this PR (zero `internal/library/` changes in the diff aside from M5b's `DeleteTrackFile` addition). ## Test plan ### M5a - [ ] Hit `/discover`, search Lidarr, request an artist → verify it lands at `/requests` and at `/admin/requests`. - [ ] Approve a request from `/admin/requests` → wait for the next library scan → confirm status flips to "Kept" at `/requests`. - [ ] Connect/disconnect Lidarr from `/admin/integrations`; confirm typed-confirm gates Disconnect. ### M5b - [ ] Flag a track from the kebab on a track row; confirm it disappears from album/search/library. - [ ] Open `/library/hidden`, confirm row is present, un-hide → track returns to album view. - [ ] As admin, view `/admin/quarantine` aggregated queue (with multiple users on the same track); test Resolve, Delete file, and Delete via Lidarr (typed-confirm). - [ ] Confirm `/rest/*` Subsonic responses still include quarantined tracks (legacy clients unaffected). ### M5c - [ ] Like an artist (or play a few tracks); wait for the M4b worker tick to populate `artist_similarity_unmatched`. - [ ] Open `/discover` with the search input empty; verify "Suggested for you" header and grid render with attribution lines. - [ ] Click Request on a suggestion; confirm it disappears (optimistic) and a row appears at `/requests`. - [ ] Type a search term; confirm the feed swaps out for search results and kind tabs reappear. - [ ] Clear the search input; confirm the feed comes back (cached). - [ ] For a fresh user with no signal, verify the empty-state copy. ### Cross-cutting - [ ] Non-admin user redirected when navigating to `/admin/*`.
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bvandeusen/minstrel#30