From fd053e76ca43707a9fd42d54087884512bb16018 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 08:07:37 -0400 Subject: [PATCH 001/351] docs: add M6a home page redesign + library relocation spec 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) --- .../specs/2026-04-30-m6a-home-page-design.md | 410 ++++++++++++++++++ 1 file changed, 410 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-30-m6a-home-page-design.md diff --git a/docs/superpowers/specs/2026-04-30-m6a-home-page-design.md b/docs/superpowers/specs/2026-04-30-m6a-home-page-design.md new file mode 100644 index 00000000..7b4d7660 --- /dev/null +++ b/docs/superpowers/specs/2026-04-30-m6a-home-page-design.md @@ -0,0 +1,410 @@ +# M6a — Home page redesign + library list relocation + +> **Status:** Draft for review · 2026-04-30 +> +> **Phase:** UI polish (Fable #349 per `project_ui_quality.md` memory). First slice. Brings the existing scaffolding-quality SPA surfaces (root, library list) up to the FabledSword design-system bar that M5a/M5b/M5c established. Subsequent polish slices (search results, artist detail, album detail) get their own brainstorm/spec/plan cycles. + +## 1. Goal + +`/` becomes a personalized home page with sections in independent horizontal-scroll rows: **Recently added albums** (2 rows) · **Rediscover** (2 rows: albums + artists) · **Most played tracks** (3 rows) · **Last played artists** (1 row). Each item card has a play affordance (album → from track 1; artist → shuffled queue; track → queue starting from that track). The existing library-as-list moves to dedicated `/library/artists` and `/library/albums` wrapping-grid pages with alphabetical dividers. Pre-FabledSword styling on existing components (``, ``) is rebuilt at the design-system bar; ``'s click-target bug is retired with the component itself. + +## 2. Goals and non-goals + +### Goals + +- `/` is the new home page. Old library-list content moves to `/library/artists` and `/library/albums`. +- Home page sections (top to bottom): + 1. **Recently added albums** — 2 horizontal-scroll rows × 25 cards each (50 most-recent albums). + 2. **Rediscover** — 2 horizontal-scroll rows: row 1 = albums (squares), row 2 = artists (circles). 25 each. + 3. **Most played tracks** — 3 horizontal-scroll rows × 25 compact track cards each (75 top-ranked tracks). + 4. **Last played artists** — 1 horizontal-scroll row × 25 circular artist cards. +- All rows scroll **independently** (each row has its own scroll position). +- All rows in all sections standardize to **25 items** for consistent visual length. +- Each scroll row has **left/right arrow buttons** (Lucide `ChevronLeft`/`ChevronRight`) that page the scroll. Arrows disable at scroll boundaries. +- Item interactions: + - **Album card** click → `/albums/{id}`. Play-button overlay → fetches detail, plays from track 1. + - **Artist card** click → `/artists/{id}`. Play-button overlay → fetches all artist tracks, shuffles, plays. + - **Compact track card** click → plays the track in queue mode within the section's track list. +- Artist cards use a circular thumbnail derived from one of the artist's albums via SQL join (most-recent album with non-null `cover_art_path`). Falls back to Lucide `Disc3` glyph. +- `/library/artists` is a wrapping grid of `` (~140px wide) with alphabetical letter dividers between sort-name groups. +- `/library/albums` is a parallel wrapping grid of `` with alphabetical dividers by album title. +- `` polish-pass: replace pre-FabledSword styling, add play-button overlay alongside the existing add-to-queue button. +- `` retired (replaced by `` in `/library/artists`); the absolute-link click-target bug goes with it. +- Main nav updated: `Home · Artists · Albums · Liked · Hidden · Search · Discover · Requests · Playlists · Settings`. +- Per the operator's "no in-between steps" rule: the slice ships finished. No "v2 polish" placeholders left in the home-page surface. + +### Non-goals (this slice) + +- Section-header "See all →" links / per-section detail routes. The library/artists and library/albums pages serve that need for those entity types. Most-played-tracks-detail and rediscover-detail are not built; horizontal-scroll-with-arrows is the navigation. +- Music-service-style mood/genre/playlist sections. +- Time-window slicing on Most played (e.g., "this week / this month / all time" per row). All 3 rows show one ranked dataset. +- Polish for search results, artist detail, album detail pages — those are M6b/c (separate slices). +- Drag-to-reorder, user-customizable sections. +- Cover-art derivation refresh strategy. The SQL picks one representative album per artist at query time; if the chosen album is later removed, the next query naturally re-derives. + +## 3. Architecture + +### Backend + +**No migration required.** All sections derive from existing tables: +- `albums.created_at` and `tracks.added_at` from migration 0002. +- `play_events.{user_id, track_id, started_at, was_skipped}` from migration 0005. +- `general_likes_albums` and `general_likes_artists` from migration 0006. +- `albums.cover_art_path` already exists; the artist-cover derivation is a query-time `LEFT JOIN LATERAL`. + +**New queries** (in `internal/db/queries/recommendation.sql` for the home-page composites; `artists.sql` and `albums.sql` for the listing variants): + +- `ListRecentlyAddedAlbumsForUser(user_id, limit)` — albums sorted by `created_at DESC` with `artist_name` joined. Largely shadows the existing `ListAlbumsNewest`; the user_id parameter exists for future personalization (none in v1). +- `ListMostPlayedTracksForUser(user_id, limit)` — tracks ranked by `count(*)` over `play_events WHERE user_id=$1 AND was_skipped=false`, grouped by `track_id`, ordered DESC. Joined to `lidarr_quarantine NOT EXISTS` so quarantined tracks are filtered. +- `ListLastPlayedArtistsForUser(user_id, limit)` — artists ranked by `max(play_events.started_at) WHERE user_id=$1`, with derived `cover_art_path` via the representative-album lateral join. +- `ListRediscoverAlbumsForUser(user_id, limit)`: + - Eligibility: `general_likes_albums.liked_at < now() - interval '30 days'` AND no track from the album has `play_events.started_at > now() - interval '14 days'` for the user. + - Order: `(now() - max(play_events.started_at))` DESC — longest-since-last-play first. + - Fallback: when fewer than `limit` rows match, the query also returns randomly-sampled albums-from-likes to fill out (per (d) decision). +- `ListRediscoverArtistsForUser(user_id, limit)` — same shape via `general_likes_artists`. +- `ListArtistsAlphaWithCovers(limit, offset)` — replaces the existing `ListArtistsAlpha`. Sorts by `sort_name ASC`, joins to a representative album to expose `cover_art_path`. +- `ListAlbumsAlphaWithArtist(limit, offset)` — sorts by album title (or `sort_title`), joins `artists.name`. May already exist as `ListAlbumsAlphaByName` — verify before duplicating. + +**New Go service**: `internal/recommendation/home.go` — exports `HomeData(ctx, pool, userID) (*HomePayload, error)` that runs all section queries in parallel via goroutines and assembles a single composite. Mirrors the M5c `SuggestArtists` pattern. + +```go +type HomePayload struct { + RecentlyAddedAlbums []AlbumRefView + RediscoverAlbums []AlbumRefView + RediscoverArtists []ArtistRefView + MostPlayedTracks []TrackRefView + LastPlayedArtists []ArtistRefView +} +``` + +**New API handlers** (in `internal/api/home.go` and `internal/api/library_albums.go`): + +- `GET /api/home` — returns the composite payload. Authenticated; sub-100ms target at household scale. +- `GET /api/library/albums?sort=alpha&limit=&offset=` — paged album list mirroring the existing `/api/artists` pattern. +- `GET /api/artists/{id}/tracks` — all tracks for an artist across their albums. Honors quarantine. Used by the artist-card play affordance for client-side shuffle. + +**Quarantine compatibility**: most-played-tracks and the artist-tracks endpoint join `lidarr_quarantine NOT EXISTS WHERE user_id = $1`. Other home-page sections operate on albums/artists which aren't quarantined, so no special handling. + +### Frontend + +**New components** (in `web/src/lib/components/`): + +- `HorizontalScrollRow.svelte` — reusable scroll wrapper. Owns horizontal-scroll container, snap behavior, arrow buttons, edge-state disabling. Takes a snippet for items. +- `ArtistCard.svelte` — circular variant of the entity card. Includes play-button overlay. +- `CompactTrackCard.svelte` — small card for "Most played" rows. Card-click plays the track in section-queue mode. +- `AlphabeticalGrid.svelte` — wrapping grid wrapper for `/library/artists` and `/library/albums`, with letter dividers between sort-name groups. + +**Modified components**: + +- `AlbumCard.svelte` — full polish pass: FabledSword tokens replace pre-M5a styling, play-button overlay added (Lucide `Play` accent on hover, top-center over art square), card-click navigates to `/albums/{id}` cleanly (single-`` wrapper, no relative-positioning click-trap). +- `Shell.svelte` — `navItems` updated to `Home · Artists · Albums · Liked · Hidden · Search · Discover · Requests · Playlists · Settings`. + +**Retired components**: + +- `ArtistRow.svelte` — replaced by `` everywhere. The `absolute inset-0 ` click-target bug retires with it. +- `LibrarySkeleton.svelte` — was list-skeleton; replaced by a grid-skeleton variant for `/library/artists` and `/library/albums`. (May still be useful for other surfaces; check usage and decide whether to retire or extend.) + +**Routes**: + +- `web/src/routes/+page.svelte` — full rewrite. Composes ``, ``, ``, `` (or inlines the four sections if subdivision adds noise without value). +- `web/src/routes/library/artists/+page.svelte` — wrapping grid of `` via ``. Infinite-scroll pagination via TanStack `createInfiniteQuery`. +- `web/src/routes/library/albums/+page.svelte` — parallel page for albums. + +**Frontend API client** (in `web/src/lib/api/`): + +- `home.ts` — `listHome()` + `createHomeQuery()` factory (`staleTime: 60_000`). +- `albums.ts` (new or extend existing) — `listAlbumsAlpha(limit, offset)` + infinite-query factory. +- `artists.ts` — extend with `listArtistTracks(artistID)`. +- `types.ts` — add `HomePayload`, ensure `ArtistRef.cover_art_path?: string | null` is exposed. +- `queries.ts` — add `qk.home()`, `qk.albumsAlpha()`, `qk.artistTracks(id)`. + +### Routing summary + +``` +/ → Home (new) +/library/artists → Artist grid (new — replaces old "/" content) +/library/albums → Album grid (new) +/library/liked → existing +/library/hidden → existing (M5b) +/artists/{id} → existing +/albums/{id} → existing +``` + +## 4. Schema + +No migration. Schema is mature enough that all sections are expressible against existing tables. + +The artist-cover derivation is a `LEFT JOIN LATERAL` against `albums`: + +```sql +LEFT JOIN LATERAL ( + SELECT cover_art_path + FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC + LIMIT 1 +) cov ON true +``` + +The non-null filter ensures we pick a representative album that actually *has* art when one exists; falls through to NULL when no album in the discography has cover art set. + +## 5. API surface + +| Method | Path | Behavior | +|---|---|---| +| `GET` | `/api/home` | Returns the composite home payload. Section sizes capped server-side: 50 (recently_added) / 25 (rediscover_albums) / 25 (rediscover_artists) / 75 (most_played) / 25 (last_played). SPA receives the full set per section and splits across visual rows. Response time target: sub-100ms. | +| `GET` | `/api/library/albums?sort=alpha&limit=&offset=` | Paged album list. Defaults: `sort=alpha`, `limit=50`, `offset=0`. Returns `{items: AlbumRef[], total: int, limit: int, offset: int}`. | +| `GET` | `/api/artists/{id}/tracks` | Returns all tracks for the artist across their albums, as `TrackRef[]`. Honors per-user quarantine. 404 if the artist doesn't exist. | + +### Response shapes + +**`GET /api/home`** body: + +```ts +type HomePayload = { + recently_added_albums: AlbumRef[]; // up to 50 + rediscover_albums: AlbumRef[]; // up to 25 + rediscover_artists: ArtistRef[]; // up to 25 + most_played_tracks: TrackRef[]; // up to 75 + last_played_artists: ArtistRef[]; // up to 25 +}; +``` + +**`AlbumRef`** (existing type, extended): +```ts +type AlbumRef = { + id: string; + title: string; + artist_id: string; + artist_name: string; + cover_art_path?: string | null; + // existing fields preserved + play_track_count: number; // new — number of tracks for the play affordance +}; +``` + +**`ArtistRef`** (existing type, extended): +```ts +type ArtistRef = { + id: string; + name: string; + // existing fields preserved + cover_art_path?: string | null; // new — derived from representative album +}; +``` + +`TrackRef` is the existing shape; no changes needed. + +### Errors + +- `500 server_error` on DB failure for any home-page section. `/api/home` returns the error rather than a partial payload (predictable empty-or-full UX for the SPA). +- `404 not_found` for `/api/artists/{id}/tracks` when the artist doesn't exist. +- Empty arrays are NOT errors — `200 {recently_added_albums: [], …}` is the new-user response. SPA renders empty-state copy per section. + +### Caching + +Frontend wraps `/api/home` in TanStack Query with `staleTime: 60_000` (1 minute). Repeat home-page visits within a session don't re-query. Like-state changes, new plays, freshly-added albums surface on next refetch (1 min after the last fetch, or on manual refetch / page navigation). + +## 6. UI surfaces + +All against FabledSword tokens. Sentence case throughout. + +### Home page (`/`) + +Vertical-scroll page with 4 sections stacked top-to-bottom: + +``` +┌───────────────────────────────────────────────────────┐ +│ Recently added │ +│ ◀ [album card] [album card] [album card] … ▶ │ row 1 (newest 25) +│ ◀ [album card] [album card] [album card] … ▶ │ row 2 (next 25) +│ │ +│ Rediscover │ +│ ◀ [album card] [album card] … ▶ │ albums (25) +│ ◀ [artist circle] [artist circle] … ▶ │ artists (25) +│ │ +│ Most played │ +│ ◀ [track card] [track card] … ▶ │ row 1 (top 25) +│ ◀ [track card] [track card] … ▶ │ row 2 (next 25) +│ ◀ [track card] [track card] … ▶ │ row 3 (next 25) +│ │ +│ Last played │ +│ ◀ [artist circle] [artist circle] … ▶ │ row 1 (last 25) +└───────────────────────────────────────────────────────┘ +``` + +**Section header**: H2 in Fraunces 24/500 (Parchment) + optional one-line Vellum subtitle (sentence-case voice rule). + +**Empty section state**: section header always renders; rows replaced by Vellum copy. Voice-rule examples: +- Recently added: "Nothing added yet. Scan a folder via the server's config." +- Rediscover: "No forgotten favourites yet. Like some albums or artists to fill this in." +- Most played: "No plays to draw from. Listen to something." +- Last played: "No recent plays." + +### `` component + +Reusable wrapper for any horizontal-scroll row. Props: `{ items, renderItem }` (snippet). + +- Container: `overflow-x: auto`, `scroll-snap-type: x mandatory`, `gap: 16px`. +- Items inside: `flex-shrink: 0`, `scroll-snap-align: start`, consistent width per card type. +- **Left arrow button**: 40px circular Iron-bg button, absolutely positioned at the row's left edge, vertically centered. Lucide `ChevronLeft` 16px / 1px stroke, Vellum default / Parchment hover. Disabled when `scrollLeft === 0`. +- **Right arrow button**: mirrored at the row's right edge with `ChevronRight`. Disabled when `scrollLeft + clientWidth >= scrollWidth`. +- Click on either arrow: `scrollBy({ left: ±containerWidth*0.85, behavior: 'smooth' })` — pages by ~one screenful. +- Native pointer-drag, trackpad swipe, and touch swipe all work via browser default on overflow. +- Arrows fade in on row hover; on touch devices (no hover), always visible. Detect via `@media (hover: hover)`. + +### `` (polish + play button) + +- Square aspect ratio, ~180-200px wide on desktop home page; ~140px in `/library/albums`. +- Title (Parchment 14/500) below the art; artist name (Vellum 12/400) below the title. +- **Play-button overlay** at the art's center on hover (or always-visible on touch). Lucide `Play` filled, 32px, accent forest-teal — qualifies as a brand moment per the design system. Click stops propagation, fetches album detail, calls `playQueue(detail.tracks, 0)`. Existing `+` add-to-queue affordance stays at top-right. +- Card click (anywhere not on the play / like / + buttons) → navigates to `/albums/{id}` via single `` wrapper. The inner action buttons sit at `z-10` above the link to claim their click events. +- Like button stays at top-right alongside the `+` button. + +### `` (new, circular) + +- Square aspect ratio art container with `border-radius: 9999px` (circle). Same width as AlbumCard variants. +- Image source: `cover_art_path` from the representative-album SQL join. When null, render a Slate-bg circle with Lucide `Disc3` 32/1.5px-stroke centered. +- Artist name centered below the circle (Parchment 14/500, single-line truncate). +- **Play-button overlay** at the circle's center on hover. Click → fetch `/api/artists/{id}/tracks`, shuffle client-side, call `playQueue(shuffled, 0)`. +- Card click → navigates to `/artists/{id}`. + +### `` (new) + +- Square cover art at top, ~140-160px wide. +- Title (Parchment 13/500, truncate) + artist name (Vellum 11/400, truncate) below. +- Card click → calls `playQueue(sectionTracks, indexInRow)` so playback starts at this track and continues through the section's track list. +- No separate play-overlay; the entire card is the play affordance. Smaller chrome footprint matches the section's "small rows" intent. + +### `` (new wrapping-grid wrapper) + +- Items wrap normally in a CSS grid (e.g., `grid-template-columns: repeat(auto-fill, minmax(140px, 1fr))`). +- Iterates the items in sort-name order. Whenever consecutive items differ in `sort_name[0]` (uppercase), inserts a row-spanning divider with the letter at left and a thin Pewter rule extending across the grid width. +- No empty-letter padding — if the library jumps from `A` to `D`, the grid jumps from `A` divider to `D` divider directly. +- Used by both `/library/artists` (with ``) and `/library/albums` (with ``). + +### `/library/artists` + +- H2 "Artists" + Vellum subtitle showing total count ("327 artists"). +- `` wrapping `` items at smaller width (~140px). +- Pagination via `createInfiniteQuery` against `/api/artists?sort=alpha&limit=&offset=`. The existing artist-list endpoint stays; the alphabetical-cover variant just supersedes the row-style rendering. +- Sort is fixed at A→Z by `sort_name`. The previous sort dropdown (alpha vs newest) is removed. + +### `/library/albums` + +- H2 "Albums" + Vellum subtitle ("1,204 albums"). +- Identical structure to `/library/artists` but with `` (squares) and dividers driven by `albums.sort_title[0]`. + +### `Shell.svelte` nav update + +```ts +const navItems = [ + { href: '/', label: 'Home' }, + { href: '/library/artists', label: 'Artists' }, + { href: '/library/albums', label: 'Albums' }, + { href: '/library/liked', label: 'Liked' }, + { href: '/library/hidden', label: 'Hidden' }, + { href: '/search', label: 'Search' }, + { href: '/discover', label: 'Discover' }, + { href: '/requests', label: 'Requests' }, + { href: '/playlists', label: 'Playlists' }, + { href: '/settings', label: 'Settings' } +]; +``` + +Admin link injection (M5a) stays. + +### Voice rule + design tokens recap + +Section copy uses sentence case ("Recently added," "Rediscover," "Most played," "Last played"). Empty states use the understated-mythic register. All chrome uses FS tokens — no raw hex. Lucide icons at 16px/1px or 32px/1.5px stroke per design rule. Brand-moment accent (forest teal) reserved for the play affordance and active-nav state — not the card chrome itself. + +## 7. Error handling + +This slice is read-only; the error surface is small. + +- **`/api/home`**: 500 on any underlying DB failure. Each section query runs in a goroutine; the handler aggregates errors and 500s rather than returning a partial payload. +- **`/api/library/albums`** and **`/api/artists/{id}/tracks`**: 500 on DB error; 404 on artist-not-found for the tracks endpoint. +- Empty section data is NOT an error. SPA renders the section header with empty-state copy. +- **Quarantine effects**: if a user's most-played tracks are entirely quarantined, the section empties to its empty-state. Same UX as a user with no plays. +- **Stale data**: TanStack Query `staleTime: 60_000` keeps repeat home-page mounts cached. Fresh data on next refetch. +- **Play action errors**: card stays in normal state; toast surfaces "Couldn't load tracks. Try again." No optimistic playback. + +## 8. Testing + +### Integration tests (gated on `MINSTREL_TEST_DATABASE_URL`) + +`internal/recommendation/home_test.go`: + +- `TestHomeData_NewUser_ReturnsAllSectionsEmpty` +- `TestHomeData_RecentlyAdded_OrderedByCreatedAt` — seed 30 albums; verify top 50 in order. +- `TestHomeData_MostPlayed_RanksByPlayCount` — seed plays for 5 tracks with varying counts; verify ranking. +- `TestHomeData_MostPlayed_HonorsQuarantine` — quarantine a top-played track, verify it's filtered. +- `TestHomeData_LastPlayedArtists_OrderedByMaxStartedAt` +- `TestHomeData_LastPlayedArtists_DerivesCoverArtFromAlbum` +- `TestHomeData_RediscoverAlbums_AppliesEligibility` — recently-liked or recently-played albums excluded. +- `TestHomeData_RediscoverAlbums_FallbackWhenSparse` — fewer than `limit` eligibility matches → mixes in randomly-sampled likes. +- `TestHomeData_RediscoverArtists_*` — symmetric coverage. +- `TestListArtistsAlphaWithCovers` — alphabetical sort + cover-art derivation. + +### HTTP tests + +`internal/api/home_test.go`: +- Happy path returns the composite shape. +- Empty-user returns all-empty arrays. +- Quarantined tracks excluded from most-played. +- 500 on DB error. + +`internal/api/library_albums_test.go`: +- Paged response shape; sort=alpha returns alphabetical. + +`internal/api/artists_test.go` extension: +- `GetArtistTracks` happy path + quarantine filter + 404. + +### Frontend tests (vitest) + +- ``: arrow clicks fire `scrollBy`; left disabled at start; right disabled at end; renders items snippet. +- ``: play overlay click fetches album detail and calls `playQueue` with index 0; card click navigates to `/albums/{id}`. +- ``: play overlay click fetches `/api/artists/{id}/tracks`, shuffles, calls `playQueue` at 0; falls back to `Disc3` when `cover_art_path` is null. +- ``: card click calls `playQueue` with section tracks at the right index. +- ``: dividers render between items differing in `sort_name[0]`; skipped letters absent. +- `/+page.svelte` (home): all 4 section headers render; empty sections show copy; populated sections render `` with right components. +- `/library/artists/+page.svelte`: wrapping grid renders, alphabetical dividers correct, infinite-scroll pagination. +- `/library/albums/+page.svelte`: same. +- `Shell.test.ts`: nav order updated. + +### Coverage targets + +Combined `internal/recommendation/` + new `internal/api/home.go` + new `internal/api/library_albums.go` ≥ 80%. Frontend test coverage maintained at current level. + +## 9. Decisions ledger + +| # | Decision | Rationale | +|---|---|---| +| 1 | Home page replaces `/`; library list moves to `/library/artists` and `/library/albums` | Operator's framing — "the root of the app should be a page that shows sections" | +| 2 | Independent horizontal-scroll rows with arrow buttons | Operator's explicit pick — independent scroll over lockstep; arrows over swipe-only | +| 3 | All rows standardized to 25 items | Operator's "consistent length" ask; clean visual alignment | +| 4 | Most-played tracks render as compact cards (not text rows) | Operator's clarification — "tracks as little cards" in horizontal scroll | +| 5 | Artist cards use circular thumbnails derived from album covers | Operator's ask; SQL `LEFT JOIN LATERAL` handles derivation, no schema change needed | +| 6 | Album cards get a play-button overlay; artist cards too | Operator's ask — albums play from track 1, artists play shuffled | +| 7 | Rediscover semantics = (d) eligibility filter + sort by longest-since-last-play + fallback | Operator's pick during brainstorming | +| 8 | Albums + artists in Rediscover (separate rows in same section) | Operator's pick | +| 9 | `` retired; `` (circular) is the only artist visualization | Resolves the click-target bug as a side effect; one component to maintain | +| 10 | `/library/artists` and `/library/albums` use wrapping grid + alphabetical dividers | Operator's ask; matches the FS-design-system bar | +| 11 | `` polish-pass within this slice | Doing it now beats leaving it scaffolding-quality across surfaces it appears on | +| 12 | "No in-between steps" — anything that ships, ships finished | Operator's principle — half-built UI loses functionality across sessions | +| 13 | TanStack `staleTime: 60_000` on `/api/home` | Repeat home visits cached; fresh data on next refetch | + +## 10. Out of scope (this slice) + +- Section "See all →" detail routes (most-played-detail, rediscover-detail). The library/artists and library/albums pages serve that role for those entity types; track and rediscover details defer. +- Mood/genre/playlist sections. +- Per-row time-window splits on Most played. +- Polish for search results, artist detail, album detail pages — separate slices (M6b, M6c). +- Drag-to-reorder home sections, user-customizable sections. +- Cover-art refresh strategy when the chosen representative album is removed (next query naturally re-derives; sufficient for v1). + +## 11. Open questions + +- **Section subdivision into subcomponents?** The home page composes 4 sections; whether each becomes its own subcomponent (e.g., ``) or all four inline in `+page.svelte` is an implementation choice. Defer to plan-time. Likely subdivide if any section gets >50 lines. +- **Existing `LibrarySkeleton.svelte`** — used elsewhere or only on the old root? Verify during implementation. Either retire or extend with a grid variant for the new pages. +- **Cover-art lookup for fallback artists** — if no album in the discography has art set, do we ever fall through to MusicBrainz Cover Art Archive? Out of scope here; flagged as a Fable polish task. From fd77217b7ca6d30d33f5cdab1615b7d91979c7c2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 08:32:00 -0400 Subject: [PATCH 002/351] docs: add M6a home page implementation plan 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) --- .../plans/2026-04-30-m6a-home-page.md | 3341 +++++++++++++++++ 1 file changed, 3341 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-30-m6a-home-page.md diff --git a/docs/superpowers/plans/2026-04-30-m6a-home-page.md b/docs/superpowers/plans/2026-04-30-m6a-home-page.md new file mode 100644 index 00000000..4aedcee1 --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-m6a-home-page.md @@ -0,0 +1,3341 @@ +# M6a — Home page redesign + library list relocation — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace `/` with a personalized home page composed of independent horizontal-scroll rows (Recently added · Rediscover · Most played · Last played), relocate the library list to `/library/artists` and `/library/albums` as wrapping grids with alphabetical dividers, polish `` to the FabledSword bar, retire `` (and its click-target bug) in favor of a circular ``, and ship play affordances on album/artist cards. + +**Architecture:** No DB migration. Backend adds composite SQL queries for each home section + new alpha-with-cover variants for the library lists. New `internal/recommendation/home.go` runs the section queries in parallel and assembles a single `HomePayload`. New `GET /api/home`, `GET /api/library/albums`, `GET /api/artists/{id}/tracks` endpoints. Frontend introduces four reusable components (``, ``, ``, ``), polishes ``, and ships three new routes. + +**Tech Stack:** Go 1.23 · pgx/v5 + sqlc · Postgres (no migration) · SvelteKit 2 / Svelte 5 (runes) · TanStack Query · Vitest · Lucide icons · FabledSword design tokens. + +**Spec:** [`docs/superpowers/specs/2026-04-30-m6a-home-page-design.md`](../specs/2026-04-30-m6a-home-page-design.md). Read it before starting — every decision is explained there. + +**Memory dependencies:** `project_design_system.md` (FabledSword tokens), `project_subsonic_legacy.md` (`/api/*` is primary), `project_no_github.md` (Forgejo MCP for PR ops), `project_git_workflow.md` (commit on `dev`; PR to `main` separately), `project_ui_quality.md` (this slice raises the bar — no half-built UI). + +--- + +## File map + +### Backend — create + +- `internal/recommendation/home.go` — `HomeData` composite service + `HomePayload` struct +- `internal/recommendation/home_integration_test.go` +- `internal/api/home.go` — `GET /api/home` handler +- `internal/api/home_test.go` +- `internal/api/library_albums.go` — `GET /api/library/albums` handler +- `internal/api/library_albums_test.go` + +### Backend — modify + +- `internal/db/queries/recommendation.sql` — append home-section + rediscover queries +- `internal/db/queries/albums.sql` — append `ListRecentlyAddedAlbumsWithArtist`, `ListAlbumsAlphaWithArtist`, `CountAlbums` +- `internal/db/queries/artists.sql` — append `ListArtistsAlphaWithCovers` +- `internal/db/queries/tracks.sql` — append `ListArtistTracksForUser` +- `internal/db/dbq/*` — regenerated by `sqlc generate` +- `internal/api/types.go` — extend `ArtistRef` + `AlbumRef` +- `internal/api/convert.go` — extend `artistRefFrom` + `albumRefFrom` to populate new fields +- `internal/api/library.go` — replace `ListArtistsAlpha` call with `ListArtistsAlphaWithCovers`; add `handleGetArtistTracks` +- `internal/api/library_test.go` — extend with artist-tracks tests +- `internal/api/api.go` — register `/api/home`, `/api/library/albums`, `/api/artists/{id}/tracks` routes + +### Frontend — create + +- `web/src/lib/api/home.ts` — `listHome()` + `createHomeQuery()` +- `web/src/lib/api/home.test.ts` +- `web/src/lib/api/albums.ts` — `listAlbumsAlpha()` + `createAlbumsAlphaInfiniteQuery()` + `listAlbumTracks()` if extracted later +- `web/src/lib/api/albums.test.ts` +- `web/src/lib/components/HorizontalScrollRow.svelte` +- `web/src/lib/components/HorizontalScrollRow.test.ts` +- `web/src/lib/components/ArtistCard.svelte` +- `web/src/lib/components/ArtistCard.test.ts` +- `web/src/lib/components/CompactTrackCard.svelte` +- `web/src/lib/components/CompactTrackCard.test.ts` +- `web/src/lib/components/AlphabeticalGrid.svelte` +- `web/src/lib/components/AlphabeticalGrid.test.ts` +- `web/src/routes/library/artists/+page.svelte` +- `web/src/routes/library/albums/+page.svelte` + +### Frontend — modify + +- `web/src/lib/api/types.ts` — extend `ArtistRef` + `AlbumRef`, add `HomePayload` +- `web/src/lib/api/queries.ts` — add `qk.home()`, `qk.albumsAlpha()`, `qk.artistTracks()` +- `web/src/lib/api/artists.ts` — create or extend with `listArtistTracks()` +- `web/src/lib/components/AlbumCard.svelte` — FabledSword polish + play-button overlay + `sort_title` exposure +- `web/src/lib/components/AlbumCard.test.ts` — extend with play-overlay test +- `web/src/lib/components/Shell.svelte` — nav order updated +- `web/src/lib/components/Shell.test.ts` — assert new nav items +- `web/src/routes/+page.svelte` — full rewrite as home page +- `web/src/routes/artists.test.ts` — adjust expectations now that `/` is the home + +### Frontend — delete + +- `web/src/lib/components/ArtistRow.svelte` (retired with the click-target bug) +- `web/src/lib/components/ArtistRow.test.ts` + +--- + +## Task list + +### Task 1 — SQL: Recently added + Most played + Last played queries + +**Files:** +- Modify: `internal/db/queries/albums.sql` — append `ListRecentlyAddedAlbumsWithArtist` +- Modify: `internal/db/queries/recommendation.sql` — append `ListMostPlayedTracksForUser`, `ListLastPlayedArtistsForUser` +- Regenerate: `internal/db/dbq/*` + +- [ ] **Step 1.1: Append `ListRecentlyAddedAlbumsWithArtist` to `albums.sql`** + +Append at the bottom of `internal/db/queries/albums.sql`: + +```sql +-- name: ListRecentlyAddedAlbumsWithArtist :many +-- M6a: recently-added albums joined with artist_name + artist_id for the +-- home-page section. created_at is the row-insert timestamp from the +-- scanner — the closest proxy to "added to library". Stable secondary +-- ordering by id keeps pagination tie-break deterministic. +SELECT sqlc.embed(albums), artists.name AS artist_name +FROM albums +JOIN artists ON artists.id = albums.artist_id +ORDER BY albums.created_at DESC, albums.id +LIMIT $1; +``` + +- [ ] **Step 1.2: Append `ListMostPlayedTracksForUser` to `recommendation.sql`** + +Append at the bottom of `internal/db/queries/recommendation.sql`: + +```sql +-- name: ListMostPlayedTracksForUser :many +-- M6a: top-N tracks by completed-play count for the user. was_skipped +-- excludes skips so a user spamming next doesn't fabricate a top track. +-- Quarantined tracks (per-user soft-hide from M5b) are filtered out. +SELECT sqlc.embed(t), + albums.title AS album_title, + artists.name AS artist_name +FROM tracks t +JOIN albums ON albums.id = t.album_id +JOIN artists ON artists.id = t.artist_id +JOIN play_events pe ON pe.track_id = t.id +WHERE pe.user_id = $1 + AND pe.was_skipped = false + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) +GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path, + t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number, + t.mbid, t.genre, t.added_at, t.updated_at, + albums.title, artists.name +ORDER BY count(*) DESC, t.id +LIMIT $2; +``` + +- [ ] **Step 1.3: Append `ListLastPlayedArtistsForUser` to `recommendation.sql`** + +Append at the bottom of `internal/db/queries/recommendation.sql`: + +```sql +-- name: ListLastPlayedArtistsForUser :many +-- M6a: artists ranked by max(play_events.started_at) for the user, with +-- a derived cover_album_id via a representative-album lateral join (most +-- recent album that has cover_art_path set). album_count joined for the +-- ArtistRef wire shape. +SELECT sqlc.embed(a), + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count, + max_started.started_at::timestamptz AS last_played_at +FROM artists a +JOIN LATERAL ( + SELECT max(pe.started_at) AS started_at + FROM play_events pe + JOIN tracks t ON t.id = pe.track_id + WHERE pe.user_id = $1 AND t.artist_id = a.id +) max_started ON max_started.started_at IS NOT NULL +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = a.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = a.id +) cnt ON true +ORDER BY max_started.started_at DESC, a.id +LIMIT $2; +``` + +- [ ] **Step 1.4: Regenerate sqlc** + +Run: + +```bash +cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && sqlc generate +go build ./... +go vet ./... +``` + +Expected: clean build. New methods `ListRecentlyAddedAlbumsWithArtist`, `ListMostPlayedTracksForUser`, `ListLastPlayedArtistsForUser` appear in `internal/db/dbq/`. + +- [ ] **Step 1.5: Commit** + +```bash +git add internal/db/queries/albums.sql \ + internal/db/queries/recommendation.sql \ + internal/db/dbq/ +git commit -m "feat(db): add M6a home-section queries (recently added, most played, last played)" +``` + +--- + +### Task 2 — SQL: Rediscover queries (albums + artists, with fallbacks) + +**Files:** +- Modify: `internal/db/queries/recommendation.sql` +- Regenerate: `internal/db/dbq/*` + +- [ ] **Step 2.1: Append rediscover-album queries** + +Append at the bottom of `internal/db/queries/recommendation.sql`: + +```sql +-- name: ListRediscoverAlbumsForUser :many +-- M6a: albums liked >30 days ago AND not played in the last 14 days, +-- ordered by longest-since-last-play first. The HAVING clause uses an +-- epoch sentinel so albums with NO plays count as eligible (their +-- "last play" is treated as 1970, which is always >14 days ago). +WITH eligible AS ( + SELECT al.id AS album_id, + gla.liked_at, + max(pe.started_at) AS last_played + FROM general_likes_albums gla + JOIN albums al ON al.id = gla.album_id + LEFT JOIN tracks t ON t.album_id = al.id + LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 + WHERE gla.user_id = $1 + AND gla.liked_at < now() - interval '30 days' + GROUP BY al.id, gla.liked_at + HAVING COALESCE(max(pe.started_at), '1970-01-01'::timestamptz) + < now() - interval '14 days' +) +SELECT sqlc.embed(albums), artists.name AS artist_name +FROM eligible e +JOIN albums ON albums.id = e.album_id +JOIN artists ON artists.id = albums.artist_id +ORDER BY (now() - COALESCE(e.last_played, e.liked_at)) DESC, albums.id +LIMIT $2; + +-- name: ListRediscoverAlbumsFallbackForUser :many +-- M6a: random sample of liked albums for the user. The Go service uses +-- this to top up the rediscover row when ListRediscoverAlbumsForUser +-- returns fewer than `limit` rows. +SELECT sqlc.embed(albums), artists.name AS artist_name +FROM general_likes_albums gla +JOIN albums ON albums.id = gla.album_id +JOIN artists ON artists.id = albums.artist_id +WHERE gla.user_id = $1 +ORDER BY random() +LIMIT $2; +``` + +- [ ] **Step 2.2: Append rediscover-artist queries** + +Append at the bottom of `internal/db/queries/recommendation.sql`: + +```sql +-- name: ListRediscoverArtistsForUser :many +-- M6a: artists liked >30 days ago AND none of their tracks played in the +-- last 14 days, ordered by longest-since-last-play. cover_album_id is +-- derived from a representative album (LEFT JOIN LATERAL). +WITH eligible AS ( + SELECT a.id AS artist_id, + gla.liked_at, + max(pe.started_at) AS last_played + FROM general_likes_artists gla + JOIN artists a ON a.id = gla.artist_id + LEFT JOIN tracks t ON t.artist_id = a.id + LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 + WHERE gla.user_id = $1 + AND gla.liked_at < now() - interval '30 days' + GROUP BY a.id, gla.liked_at + HAVING COALESCE(max(pe.started_at), '1970-01-01'::timestamptz) + < now() - interval '14 days' +) +SELECT sqlc.embed(artists), + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM eligible e +JOIN artists ON artists.id = e.artist_id +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = artists.id +) cnt ON true +ORDER BY (now() - COALESCE(e.last_played, e.liked_at)) DESC, artists.id +LIMIT $2; + +-- name: ListRediscoverArtistsFallbackForUser :many +SELECT sqlc.embed(artists), + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM general_likes_artists gla +JOIN artists ON artists.id = gla.artist_id +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = artists.id +) cnt ON true +WHERE gla.user_id = $1 +ORDER BY random() +LIMIT $2; +``` + +- [ ] **Step 2.3: Regenerate sqlc** + +Run: + +```bash +cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && sqlc generate +go build ./... +go vet ./... +``` + +Expected: clean build. Four new methods (`ListRediscoverAlbumsForUser`, `ListRediscoverAlbumsFallbackForUser`, `ListRediscoverArtistsForUser`, `ListRediscoverArtistsFallbackForUser`) appear in `internal/db/dbq/recommendation.sql.go`. + +- [ ] **Step 2.4: Commit** + +```bash +git add internal/db/queries/recommendation.sql internal/db/dbq/ +git commit -m "feat(db): add M6a rediscover queries (albums + artists, with fallbacks)" +``` + +--- + +### Task 3 — SQL: Library list + artist tracks queries + +**Files:** +- Modify: `internal/db/queries/artists.sql` +- Modify: `internal/db/queries/albums.sql` +- Modify: `internal/db/queries/tracks.sql` +- Regenerate: `internal/db/dbq/*` + +- [ ] **Step 3.1: Append `ListArtistsAlphaWithCovers` to `artists.sql`** + +Append at the bottom of `internal/db/queries/artists.sql`: + +```sql +-- name: ListArtistsAlphaWithCovers :many +-- M6a: alpha-sorted artist list with derived cover_album_id (most-recent +-- album whose cover_art_path is set). Replaces ListArtistsAlpha for the +-- /api/artists?sort=alpha branch — the new column is appended, so the +-- alpha branch is purely additive on the wire. album_count is computed +-- in the same query to drop the old N+1 in handleListArtists. +SELECT sqlc.embed(artists), + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM artists +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = artists.id +) cnt ON true +ORDER BY artists.sort_name, artists.name, artists.id +LIMIT $1 OFFSET $2; +``` + +- [ ] **Step 3.2: Append `ListAlbumsAlphaWithArtist` + `CountAlbums` to `albums.sql`** + +Append at the bottom of `internal/db/queries/albums.sql`: + +```sql +-- name: ListAlbumsAlphaWithArtist :many +-- M6a: alpha-sorted album list joined with artist_name. Used by +-- /api/library/albums for the wrapping-grid page. Stable id-tiebreak. +SELECT sqlc.embed(albums), artists.name AS artist_name +FROM albums +JOIN artists ON artists.id = albums.artist_id +ORDER BY albums.sort_title, albums.id +LIMIT $1 OFFSET $2; + +-- name: CountAlbums :one +-- M6a: total album count for the /api/library/albums envelope. +SELECT COUNT(*) FROM albums; +``` + +- [ ] **Step 3.3: Append `ListArtistTracksForUser` to `tracks.sql`** + +Append at the bottom of `internal/db/queries/tracks.sql`: + +```sql +-- name: ListArtistTracksForUser :many +-- M6a: every track for the artist across their albums, with album_title +-- and artist_name joined. Honors per-user lidarr_quarantine. Used by +-- /api/artists/{id}/tracks for the artist-card play affordance, which +-- shuffles client-side. Ordering matches album/track natural order so +-- the shuffle has a deterministic input. +SELECT sqlc.embed(t), + albums.title AS album_title, + artists.name AS artist_name +FROM tracks t +JOIN albums ON albums.id = t.album_id +JOIN artists ON artists.id = t.artist_id +WHERE t.artist_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $2 AND q.track_id = t.id + ) +ORDER BY albums.release_date NULLS LAST, albums.sort_title, + t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.id; +``` + +- [ ] **Step 3.4: Regenerate sqlc + build** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && sqlc generate +go build ./... +go vet ./... +``` + +Expected: clean build. New methods appear in `internal/db/dbq/`. + +- [ ] **Step 3.5: Commit** + +```bash +git add internal/db/queries/artists.sql \ + internal/db/queries/albums.sql \ + internal/db/queries/tracks.sql \ + internal/db/dbq/ +git commit -m "feat(db): add M6a library-list + artist-tracks queries" +``` + +--- + +### Task 4 — Recommendation service: `HomeData` + +**Files:** +- Create: `internal/recommendation/home.go` +- Create: `internal/recommendation/home_integration_test.go` + +- [ ] **Step 4.1: Write failing integration test (new-user empty payload)** + +Create `internal/recommendation/home_integration_test.go`: + +```go +package recommendation + +import ( + "context" + "testing" + + "github.com/jackc/pgx/v5/pgtype" +) + +func TestHomeData_NewUser_AllSectionsEmpty(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "home-newuser") + + got, err := HomeData(context.Background(), pool, user.ID) + if err != nil { + t.Fatalf("HomeData: %v", err) + } + if got == nil { + t.Fatal("HomeData returned nil payload") + } + if len(got.RecentlyAddedAlbums) != 0 { + t.Errorf("RecentlyAddedAlbums len = %d, want 0", len(got.RecentlyAddedAlbums)) + } + if len(got.RediscoverAlbums) != 0 { + t.Errorf("RediscoverAlbums len = %d, want 0", len(got.RediscoverAlbums)) + } + if len(got.RediscoverArtists) != 0 { + t.Errorf("RediscoverArtists len = %d, want 0", len(got.RediscoverArtists)) + } + if len(got.MostPlayedTracks) != 0 { + t.Errorf("MostPlayedTracks len = %d, want 0", len(got.MostPlayedTracks)) + } + if len(got.LastPlayedArtists) != 0 { + t.Errorf("LastPlayedArtists len = %d, want 0", len(got.LastPlayedArtists)) + } +} + +// Compile-time assert that pgtype.UUID is a valid map key — used by the +// service when de-duping rediscover fallback rows. If this changes, the +// service needs to switch to keying by string. +var _ = func() pgtype.UUID { return pgtype.UUID{} } +``` + +- [ ] **Step 4.2: Run test to verify it fails** + +Run: + +```bash +go test ./internal/recommendation/ -run TestHomeData_NewUser_AllSectionsEmpty -count=1 +``` + +Expected: FAIL with "undefined: HomeData" or similar. + +- [ ] **Step 4.3: Write the service** + +Create `internal/recommendation/home.go`: + +```go +// home.go is the M6a per-user composite home-page service. Reads five +// sections (recently added, rediscover albums, rediscover artists, most +// played tracks, last played artists) in parallel via goroutines and +// assembles them into a single payload for /api/home. +package recommendation + +import ( + "context" + "fmt" + "sync" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Section size caps. SPA splits these into rows on the page. +const ( + HomeRecentlyAddedLimit = 50 + HomeRediscoverLimit = 25 + HomeMostPlayedLimit = 75 + HomeLastPlayedLimit = 25 +) + +// HomePayload is the composite returned by HomeData. All slices are +// non-nil at return time so the API handler can encode `[]` rather than +// `null` for empty sections. +type HomePayload struct { + RecentlyAddedAlbums []dbq.ListRecentlyAddedAlbumsWithArtistRow + RediscoverAlbums []dbq.ListRediscoverAlbumsForUserRow + RediscoverArtists []dbq.ListRediscoverArtistsForUserRow + MostPlayedTracks []dbq.ListMostPlayedTracksForUserRow + LastPlayedArtists []dbq.ListLastPlayedArtistsForUserRow +} + +// HomeData runs five queries in parallel and assembles the payload. +// Rediscover sections fall back to a random sample of liked entities +// when the eligibility query returns fewer than HomeRediscoverLimit rows. +// Any single query error fails the whole call (predictable empty-or-full +// UX for the SPA — partial payloads are not a feature). +func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*HomePayload, error) { + q := dbq.New(pool) + + var ( + wg sync.WaitGroup + mu sync.Mutex + firstErr error + ) + fail := func(stage string, err error) { + mu.Lock() + defer mu.Unlock() + if firstErr == nil { + firstErr = fmt.Errorf("home: %s: %w", stage, err) + } + } + + out := &HomePayload{ + RecentlyAddedAlbums: []dbq.ListRecentlyAddedAlbumsWithArtistRow{}, + RediscoverAlbums: []dbq.ListRediscoverAlbumsForUserRow{}, + RediscoverArtists: []dbq.ListRediscoverArtistsForUserRow{}, + MostPlayedTracks: []dbq.ListMostPlayedTracksForUserRow{}, + LastPlayedArtists: []dbq.ListLastPlayedArtistsForUserRow{}, + } + + wg.Add(5) + go func() { + defer wg.Done() + rows, err := q.ListRecentlyAddedAlbumsWithArtist(ctx, HomeRecentlyAddedLimit) + if err != nil { + fail("recently_added", err) + return + } + out.RecentlyAddedAlbums = rows + }() + go func() { + defer wg.Done() + rows, err := q.ListMostPlayedTracksForUser(ctx, dbq.ListMostPlayedTracksForUserParams{ + UserID: userID, Limit: HomeMostPlayedLimit, + }) + if err != nil { + fail("most_played", err) + return + } + out.MostPlayedTracks = rows + }() + go func() { + defer wg.Done() + rows, err := q.ListLastPlayedArtistsForUser(ctx, dbq.ListLastPlayedArtistsForUserParams{ + UserID: userID, Limit: HomeLastPlayedLimit, + }) + if err != nil { + fail("last_played", err) + return + } + out.LastPlayedArtists = rows + }() + go func() { + defer wg.Done() + rows, err := loadRediscoverAlbums(ctx, q, userID) + if err != nil { + fail("rediscover_albums", err) + return + } + out.RediscoverAlbums = rows + }() + go func() { + defer wg.Done() + rows, err := loadRediscoverArtists(ctx, q, userID) + if err != nil { + fail("rediscover_artists", err) + return + } + out.RediscoverArtists = rows + }() + wg.Wait() + + if firstErr != nil { + return nil, firstErr + } + return out, nil +} + +func loadRediscoverAlbums(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]dbq.ListRediscoverAlbumsForUserRow, error) { + primary, err := q.ListRediscoverAlbumsForUser(ctx, dbq.ListRediscoverAlbumsForUserParams{ + UserID: userID, Limit: HomeRediscoverLimit, + }) + if err != nil { + return nil, err + } + if len(primary) >= HomeRediscoverLimit { + return primary, nil + } + fallback, err := q.ListRediscoverAlbumsFallbackForUser(ctx, dbq.ListRediscoverAlbumsFallbackForUserParams{ + UserID: userID, Limit: HomeRediscoverLimit, + }) + if err != nil { + return nil, err + } + seen := make(map[pgtype.UUID]struct{}, len(primary)) + for _, r := range primary { + seen[r.Album.ID] = struct{}{} + } + for _, r := range fallback { + if _, dup := seen[r.Album.ID]; dup { + continue + } + // Fallback rows have the same shape after a manual projection + // (ListRediscoverAlbumsFallbackForUserRow → ListRediscoverAlbumsForUserRow). + primary = append(primary, dbq.ListRediscoverAlbumsForUserRow{ + Album: r.Album, + ArtistName: r.ArtistName, + }) + if len(primary) >= HomeRediscoverLimit { + break + } + seen[r.Album.ID] = struct{}{} + } + return primary, nil +} + +func loadRediscoverArtists(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]dbq.ListRediscoverArtistsForUserRow, error) { + primary, err := q.ListRediscoverArtistsForUser(ctx, dbq.ListRediscoverArtistsForUserParams{ + UserID: userID, Limit: HomeRediscoverLimit, + }) + if err != nil { + return nil, err + } + if len(primary) >= HomeRediscoverLimit { + return primary, nil + } + fallback, err := q.ListRediscoverArtistsFallbackForUser(ctx, dbq.ListRediscoverArtistsFallbackForUserParams{ + UserID: userID, Limit: HomeRediscoverLimit, + }) + if err != nil { + return nil, err + } + seen := make(map[pgtype.UUID]struct{}, len(primary)) + for _, r := range primary { + seen[r.Artist.ID] = struct{}{} + } + for _, r := range fallback { + if _, dup := seen[r.Artist.ID]; dup { + continue + } + primary = append(primary, dbq.ListRediscoverArtistsForUserRow{ + Artist: r.Artist, + CoverAlbumID: r.CoverAlbumID, + AlbumCount: r.AlbumCount, + }) + if len(primary) >= HomeRediscoverLimit { + break + } + seen[r.Artist.ID] = struct{}{} + } + return primary, nil +} +``` + +- [ ] **Step 4.4: Run test to verify it passes** + +Run: + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test ./internal/recommendation/ -run TestHomeData_NewUser_AllSectionsEmpty -count=1 +``` + +Expected: PASS. + +- [ ] **Step 4.5: Add deeper integration tests** + +Append to `internal/recommendation/home_integration_test.go`: + +```go +import ( + "time" +) + +func TestHomeData_RecentlyAdded_OrderedByCreatedAt(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "home-recents") + a1 := seedArtist(t, pool, "ArtistA", "") + // Three albums, oldest first — created_at increases naturally with each insert. + al1 := seedAlbumForArtist(t, pool, a1.ID, "Older") + al2 := seedAlbumForArtist(t, pool, a1.ID, "Middle") + al3 := seedAlbumForArtist(t, pool, a1.ID, "Newest") + + got, err := HomeData(context.Background(), pool, user.ID) + if err != nil { + t.Fatalf("HomeData: %v", err) + } + if len(got.RecentlyAddedAlbums) != 3 { + t.Fatalf("len = %d, want 3", len(got.RecentlyAddedAlbums)) + } + if got.RecentlyAddedAlbums[0].Album.ID != al3.ID || + got.RecentlyAddedAlbums[1].Album.ID != al2.ID || + got.RecentlyAddedAlbums[2].Album.ID != al1.ID { + t.Errorf("order = [%s, %s, %s], want newest→oldest", + got.RecentlyAddedAlbums[0].Album.Title, + got.RecentlyAddedAlbums[1].Album.Title, + got.RecentlyAddedAlbums[2].Album.Title) + } +} + +func TestHomeData_MostPlayed_RanksByCount_HonorsQuarantine(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "home-mostplayed") + artist := seedArtist(t, pool, "ArtistB", "") + album := seedAlbumForArtist(t, pool, artist.ID, "AlbumB") + tHigh := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "High") + tLow := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "Low") + tQuar := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "Quarantined") + now := time.Now() + for i := 0; i < 3; i++ { + insertPlayEvent(t, pool, user.ID, tHigh.ID, now.Add(-time.Duration(i)*time.Minute)) + } + insertPlayEvent(t, pool, user.ID, tLow.ID, now) + for i := 0; i < 5; i++ { + insertPlayEvent(t, pool, user.ID, tQuar.ID, now) + } + if _, err := pool.Exec(context.Background(), + `INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`, + user.ID, tQuar.ID, + ); err != nil { + t.Fatalf("insert quarantine: %v", err) + } + + got, err := HomeData(context.Background(), pool, user.ID) + if err != nil { + t.Fatalf("HomeData: %v", err) + } + if len(got.MostPlayedTracks) != 2 { + t.Fatalf("len = %d, want 2 (quarantined excluded)", len(got.MostPlayedTracks)) + } + if got.MostPlayedTracks[0].Track.ID != tHigh.ID || got.MostPlayedTracks[1].Track.ID != tLow.ID { + t.Errorf("ranking wrong: got [%s, %s], want [High, Low]", + got.MostPlayedTracks[0].Track.Title, + got.MostPlayedTracks[1].Track.Title) + } +} + +func TestHomeData_RediscoverAlbums_FallbackWhenSparse(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "home-rediscover") + artist := seedArtist(t, pool, "ArtistC", "") + // Like one album NOW (won't satisfy >30d eligibility) — should still + // surface via fallback because primary returns 0 rows. + al := seedAlbumForArtist(t, pool, artist.ID, "RecentLike") + if _, err := pool.Exec(context.Background(), + `INSERT INTO general_likes_albums (user_id, album_id, liked_at) VALUES ($1, $2, now())`, + user.ID, al.ID, + ); err != nil { + t.Fatalf("insert like: %v", err) + } + got, err := HomeData(context.Background(), pool, user.ID) + if err != nil { + t.Fatalf("HomeData: %v", err) + } + if len(got.RediscoverAlbums) != 1 { + t.Fatalf("len = %d, want 1 (from fallback)", len(got.RediscoverAlbums)) + } + if got.RediscoverAlbums[0].Album.ID != al.ID { + t.Errorf("got %s, want %s", got.RediscoverAlbums[0].Album.Title, al.Title) + } +} +``` + +- [ ] **Step 4.6: Run all home tests** + +Run: + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test ./internal/recommendation/ -run TestHomeData -count=1 -v +``` + +Expected: 3/3 PASS. + +- [ ] **Step 4.7: Commit** + +```bash +git add internal/recommendation/home.go \ + internal/recommendation/home_integration_test.go +git commit -m "feat(recommendation): add HomeData composite service for /api/home" +``` + +--- + +### Task 5 — API types + convert helpers + +**Files:** +- Modify: `internal/api/types.go` +- Modify: `internal/api/convert.go` +- Modify: `internal/api/library.go` (handler signature follow-up — covered separately in Task 9) + +- [ ] **Step 5.1: Extend `ArtistRef` and `AlbumRef` in `types.go`** + +Modify `internal/api/types.go` — replace the `ArtistRef` and `AlbumRef` blocks: + +```go +// ArtistRef is the lightweight artist shape used in lists and search results. +// SortName is exposed so the SPA's alphabetical-grid divider can group rows +// by the catalogue sort order ("The Beatles" → "B"). CoverURL is derived from +// a representative album (most-recent with cover_art_path); empty when the +// artist's discography has no cover art. +type ArtistRef struct { + ID string `json:"id"` + Name string `json:"name"` + SortName string `json:"sort_name"` + AlbumCount int `json:"album_count"` + CoverURL string `json:"cover_url"` +} + +// AlbumRef is the lightweight album shape used in lists, artist details, +// and search results. SortTitle is exposed so the SPA's alphabetical-grid +// divider can group rows by sort order ("The Wall" → "W"). CoverURL points +// to /api/albums/{id}/cover. +type AlbumRef struct { + ID string `json:"id"` + Title string `json:"title"` + SortTitle string `json:"sort_title"` + ArtistID string `json:"artist_id"` + ArtistName string `json:"artist_name"` + Year int `json:"year,omitempty"` + TrackCount int `json:"track_count"` + DurationSec int `json:"duration_sec"` + CoverURL string `json:"cover_url"` +} +``` + +- [ ] **Step 5.2: Add `HomePayload` view type to `types.go`** + +Append at the bottom of `internal/api/types.go`: + +```go +// HomePayload is the response body of GET /api/home. Field counts: +// 50 (recently_added) / 25 (rediscover_albums) / 25 (rediscover_artists) +// / 75 (most_played) / 25 (last_played). All slices are non-nil at JSON +// encode time so empty sections render as [] rather than null. +type HomePayload struct { + RecentlyAddedAlbums []AlbumRef `json:"recently_added_albums"` + RediscoverAlbums []AlbumRef `json:"rediscover_albums"` + RediscoverArtists []ArtistRef `json:"rediscover_artists"` + MostPlayedTracks []TrackRef `json:"most_played_tracks"` + LastPlayedArtists []ArtistRef `json:"last_played_artists"` +} +``` + +- [ ] **Step 5.3: Update `albumRefFrom` to populate `SortTitle`** + +Modify `internal/api/convert.go` — replace the `albumRefFrom` function: + +```go +// albumRefFrom projects a dbq.Album into an AlbumRef. artistName must be +// pre-resolved because Album only carries artist_id. trackCount and +// durationSec may be 0 when the caller does not compute them. +func albumRefFrom(a dbq.Album, artistName string, trackCount, durationSec int) AlbumRef { + return AlbumRef{ + ID: uuidToString(a.ID), + Title: a.Title, + SortTitle: a.SortTitle, + ArtistID: uuidToString(a.ArtistID), + ArtistName: artistName, + Year: yearFromDate(a.ReleaseDate), + TrackCount: trackCount, + DurationSec: durationSec, + CoverURL: coverURL(a.ID), + } +} +``` + +- [ ] **Step 5.4: Update `artistRefFrom` to populate `SortName` (existing call sites use empty cover)** + +Modify `internal/api/convert.go` — replace the `artistRefFrom` function and add a covered variant: + +```go +// artistRefFrom projects a dbq.Artist into an ArtistRef without cover. +// albumCount must be pre-computed by the caller. Used by code paths that +// don't have a representative-album lookup at hand (artist detail, search, +// liked-artists list). +func artistRefFrom(a dbq.Artist, albumCount int) ArtistRef { + return ArtistRef{ + ID: uuidToString(a.ID), + Name: a.Name, + SortName: a.SortName, + AlbumCount: albumCount, + } +} + +// artistRefFromCovered projects a dbq.Artist into an ArtistRef with the +// derived CoverURL populated from a representative album id (nullable). +// Used by /api/home and /api/artists?sort=alpha which carry the lookup +// in the same query. +func artistRefFromCovered(a dbq.Artist, albumCount int, coverAlbumID pgtype.UUID) ArtistRef { + ref := artistRefFrom(a, albumCount) + if coverAlbumID.Valid { + ref.CoverURL = coverURL(coverAlbumID) + } + return ref +} +``` + +- [ ] **Step 5.5: Run vet + build** + +```bash +go vet ./internal/api/... +go build ./... +``` + +Expected: clean build. Existing call sites continue to compile because the appended fields default to zero values — JSON marshal still includes them as `"sort_name": ""`, which is harmless. + +- [ ] **Step 5.6: Commit** + +```bash +git add internal/api/types.go internal/api/convert.go +git commit -m "feat(api): extend ArtistRef/AlbumRef + add HomePayload for M6a" +``` + +--- + +### Task 6 — `GET /api/home` handler + +**Files:** +- Create: `internal/api/home.go` +- Create: `internal/api/home_test.go` + +- [ ] **Step 6.1: Write failing test for the happy-path empty payload** + +Create `internal/api/home_test.go`: + +```go +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func newHomeRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Get("/api/home", h.handleGetHome) + return r +} + +func doGetHome(h *handlers, user dbq.User) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/api/home", nil) + req = withUser(req, user) + w := httptest.NewRecorder() + newHomeRouter(h).ServeHTTP(w, req) + return w +} + +func TestHome_EmptyForNewUser(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + + w := doGetHome(h, user) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var got HomePayload + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + // All slices must be non-nil so the SPA branches consistently on + // length rather than dealing with null. + if got.RecentlyAddedAlbums == nil || got.RediscoverAlbums == nil || + got.RediscoverArtists == nil || got.MostPlayedTracks == nil || + got.LastPlayedArtists == nil { + t.Errorf("payload has nil slice: %+v", got) + } + if len(got.RecentlyAddedAlbums) != 0 { + t.Errorf("RecentlyAddedAlbums len = %d, want 0", len(got.RecentlyAddedAlbums)) + } +} + +func TestHome_RecentlyAddedSurfacesAlbums(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + q := dbq.New(pool) + artist, _ := q.UpsertArtist(t.Context(), dbq.UpsertArtistParams{ + Name: "ArtistOne", SortName: "ArtistOne", + }) + _, _ = q.UpsertAlbum(t.Context(), dbq.UpsertAlbumParams{ + Title: "Newest", SortTitle: "Newest", ArtistID: artist.ID, + }) + + w := doGetHome(h, user) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var got HomePayload + _ = json.Unmarshal(w.Body.Bytes(), &got) + if len(got.RecentlyAddedAlbums) != 1 { + t.Fatalf("len = %d, want 1", len(got.RecentlyAddedAlbums)) + } + if got.RecentlyAddedAlbums[0].Title != "Newest" { + t.Errorf("title = %q, want Newest", got.RecentlyAddedAlbums[0].Title) + } + if got.RecentlyAddedAlbums[0].ArtistName != "ArtistOne" { + t.Errorf("artist_name = %q, want ArtistOne", got.RecentlyAddedAlbums[0].ArtistName) + } +} +``` + +NOTE: this test uses `t.Context()`. The repo standard is `context.Background()` because Go 1.23 doesn't have `t.Context()` — replace before saving: + +```go +import "context" +// then: context.Background() everywhere instead of t.Context() +``` + +- [ ] **Step 6.2: Run test to verify it fails** + +```bash +go test ./internal/api/ -run TestHome_ -count=1 +``` + +Expected: FAIL with "undefined: handleGetHome" or similar. + +- [ ] **Step 6.3: Write the handler** + +Create `internal/api/home.go`: + +```go +package api + +import ( + "net/http" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" +) + +// handleGetHome implements GET /api/home. Returns five sections in one +// payload; sections that have no rows render as []. 500 on any underlying +// DB failure (no partial payload — the SPA should render either empty +// states or full content, not a half-broken page). +func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + data, err := recommendation.HomeData(r.Context(), h.pool, user.ID) + if err != nil { + h.logger.Error("api: home data", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "failed to load home") + return + } + + out := HomePayload{ + RecentlyAddedAlbums: make([]AlbumRef, 0, len(data.RecentlyAddedAlbums)), + RediscoverAlbums: make([]AlbumRef, 0, len(data.RediscoverAlbums)), + RediscoverArtists: make([]ArtistRef, 0, len(data.RediscoverArtists)), + MostPlayedTracks: make([]TrackRef, 0, len(data.MostPlayedTracks)), + LastPlayedArtists: make([]ArtistRef, 0, len(data.LastPlayedArtists)), + } + for _, row := range data.RecentlyAddedAlbums { + out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0)) + } + for _, row := range data.RediscoverAlbums { + out.RediscoverAlbums = append(out.RediscoverAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0)) + } + for _, row := range data.RediscoverArtists { + out.RediscoverArtists = append(out.RediscoverArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID)) + } + for _, row := range data.MostPlayedTracks { + out.MostPlayedTracks = append(out.MostPlayedTracks, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName)) + } + for _, row := range data.LastPlayedArtists { + out.LastPlayedArtists = append(out.LastPlayedArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID)) + } + writeJSON(w, http.StatusOK, out) +} + +// Compile-time guard that dbq has the row types we need; if the SQL is +// regenerated with a different name, this fails fast at build time. +var _ = dbq.ListMostPlayedTracksForUserRow{}.Track +``` + +- [ ] **Step 6.4: Run tests to verify they pass** + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test ./internal/api/ -run TestHome_ -count=1 -v +``` + +Expected: 2/2 PASS. + +- [ ] **Step 6.5: Commit** + +```bash +git add internal/api/home.go internal/api/home_test.go +git commit -m "feat(api): add GET /api/home composite handler" +``` + +--- + +### Task 7 — `GET /api/library/albums` handler + +**Files:** +- Create: `internal/api/library_albums.go` +- Create: `internal/api/library_albums_test.go` + +- [ ] **Step 7.1: Write failing test** + +Create `internal/api/library_albums_test.go`: + +```go +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func newLibraryAlbumsRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Get("/api/library/albums", h.handleListLibraryAlbums) + return r +} + +func doListLibraryAlbums(h *handlers, user dbq.User, query string) *httptest.ResponseRecorder { + url := "/api/library/albums" + if query != "" { + url += "?" + query + } + req := httptest.NewRequest(http.MethodGet, url, nil) + req = withUser(req, user) + w := httptest.NewRecorder() + newLibraryAlbumsRouter(h).ServeHTTP(w, req) + return w +} + +func TestLibraryAlbums_AlphaSorted(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "ArtistA", SortName: "ArtistA", + }) + _, _ = q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Zoo", SortTitle: "Zoo", ArtistID: artist.ID, + }) + _, _ = q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Apple", SortTitle: "Apple", ArtistID: artist.ID, + }) + _, _ = q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Mango", SortTitle: "Mango", ArtistID: artist.ID, + }) + + w := doListLibraryAlbums(h, user, "limit=10") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var got Page[AlbumRef] + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Total != 3 { + t.Errorf("total = %d, want 3", got.Total) + } + titles := []string{got.Items[0].Title, got.Items[1].Title, got.Items[2].Title} + if titles[0] != "Apple" || titles[1] != "Mango" || titles[2] != "Zoo" { + t.Errorf("titles = %v, want [Apple Mango Zoo]", titles) + } + if got.Items[0].SortTitle != "Apple" { + t.Errorf("sort_title = %q, want Apple", got.Items[0].SortTitle) + } +} + +func TestLibraryAlbums_Paging(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "ArtistA", SortName: "ArtistA", + }) + for _, title := range []string{"A", "B", "C", "D"} { + _, _ = q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: title, SortTitle: title, ArtistID: artist.ID, + }) + } + + w := doListLibraryAlbums(h, user, "limit=2&offset=2") + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + var got Page[AlbumRef] + _ = json.Unmarshal(w.Body.Bytes(), &got) + if got.Total != 4 || got.Limit != 2 || got.Offset != 2 { + t.Errorf("envelope = %+v, want total=4 limit=2 offset=2", got) + } + if len(got.Items) != 2 || got.Items[0].Title != "C" || got.Items[1].Title != "D" { + t.Errorf("items = %v, want [C D]", got.Items) + } +} +``` + +- [ ] **Step 7.2: Run test to verify it fails** + +```bash +go test ./internal/api/ -run TestLibraryAlbums_ -count=1 +``` + +Expected: FAIL with "undefined: handleListLibraryAlbums". + +- [ ] **Step 7.3: Write the handler** + +Create `internal/api/library_albums.go`: + +```go +package api + +import ( + "net/http" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// handleListLibraryAlbums implements GET /api/library/albums. Mirrors +// /api/artists?sort=alpha but for albums. The new wrapping-grid page on +// the SPA infinite-scrolls against this endpoint via TanStack +// createInfiniteQuery. +func (h *handlers) handleListLibraryAlbums(w http.ResponseWriter, r *http.Request) { + limit, offset, err := parsePaging(r.URL.Query()) + if err != nil { + writeErr(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + q := dbq.New(h.pool) + rows, err := q.ListAlbumsAlphaWithArtist(r.Context(), dbq.ListAlbumsAlphaWithArtistParams{ + Limit: int32(limit), Offset: int32(offset), + }) + if err != nil { + h.logger.Error("api: list library albums", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + total, err := q.CountAlbums(r.Context()) + if err != nil { + h.logger.Error("api: count albums", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "count failed") + return + } + items := make([]AlbumRef, 0, len(rows)) + for _, row := range rows { + items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0)) + } + writeJSON(w, http.StatusOK, Page[AlbumRef]{ + Items: items, Total: int(total), Limit: limit, Offset: offset, + }) +} +``` + +- [ ] **Step 7.4: Run tests to verify they pass** + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test ./internal/api/ -run TestLibraryAlbums_ -count=1 -v +``` + +Expected: 2/2 PASS. + +- [ ] **Step 7.5: Commit** + +```bash +git add internal/api/library_albums.go internal/api/library_albums_test.go +git commit -m "feat(api): add GET /api/library/albums handler" +``` + +--- + +### Task 8 — `GET /api/artists/{id}/tracks` handler + +**Files:** +- Modify: `internal/api/library.go` (append `handleGetArtistTracks`) +- Modify: `internal/api/library_test.go` (append tests) + +- [ ] **Step 8.1: Write failing tests** + +Append to `internal/api/library_test.go` at the bottom (preserve all existing tests): + +```go +func TestGetArtistTracks_HappyPath(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "ArtistA", SortName: "ArtistA", + }) + album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "AlbumA", SortTitle: "AlbumA", ArtistID: artist.ID, + }) + _, _ = q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "T1", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: "/tmp/m6a-T1.mp3", + FileSize: 1, FileFormat: "mp3", + }) + _, _ = q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "T2", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: "/tmp/m6a-T2.mp3", + FileSize: 1, FileFormat: "mp3", + }) + + r := chi.NewRouter() + r.Get("/api/artists/{id}/tracks", h.handleGetArtistTracks) + req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID)+"/tracks", nil) + req = withUser(req, user) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var got []TrackRef + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0].ArtistName != "ArtistA" || got[0].AlbumTitle != "AlbumA" { + t.Errorf("got = %+v", got[0]) + } +} + +func TestGetArtistTracks_HonorsQuarantine(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "ArtistB", SortName: "ArtistB", + }) + album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "AlbumB", SortTitle: "AlbumB", ArtistID: artist.ID, + }) + track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "Q", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: "/tmp/m6a-Q.mp3", + FileSize: 1, FileFormat: "mp3", + }) + if _, err := pool.Exec(context.Background(), + `INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`, + user.ID, track.ID, + ); err != nil { + t.Fatalf("quarantine insert: %v", err) + } + + r := chi.NewRouter() + r.Get("/api/artists/{id}/tracks", h.handleGetArtistTracks) + req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID)+"/tracks", nil) + req = withUser(req, user) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + var got []TrackRef + _ = json.Unmarshal(w.Body.Bytes(), &got) + if len(got) != 0 { + t.Errorf("len = %d, want 0 (quarantined)", len(got)) + } +} + +func TestGetArtistTracks_NotFound(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + + r := chi.NewRouter() + r.Get("/api/artists/{id}/tracks", h.handleGetArtistTracks) + // Random UUID — no such artist exists. + req := httptest.NewRequest(http.MethodGet, "/api/artists/00000000-0000-0000-0000-000000000000/tracks", nil) + req = withUser(req, user) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } + _ = pool +} +``` + +- [ ] **Step 8.2: Run tests to verify they fail** + +```bash +go test ./internal/api/ -run TestGetArtistTracks_ -count=1 +``` + +Expected: FAIL with "undefined: handleGetArtistTracks". + +- [ ] **Step 8.3: Append the handler to `library.go`** + +Append at the bottom of `internal/api/library.go`: + +```go +// handleGetArtistTracks implements GET /api/artists/{id}/tracks. Returns +// every track the artist has across albums (per-user-quarantine filtered) +// as a flat list. Used by ArtistCard's play affordance, which shuffles +// client-side. 404 when the artist doesn't exist. +func (h *handlers) handleGetArtistTracks(w http.ResponseWriter, r *http.Request) { + id, ok := parseUUID(chi.URLParam(r, "id")) + if !ok { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid artist id") + return + } + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + q := dbq.New(h.pool) + if _, err := q.GetArtistByID(r.Context(), id); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + writeErr(w, http.StatusNotFound, "not_found", "artist not found") + return + } + h.logger.Error("api: get artist for tracks", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + rows, err := q.ListArtistTracksForUser(r.Context(), dbq.ListArtistTracksForUserParams{ + ArtistID: id, UserID: user.ID, + }) + if err != nil { + h.logger.Error("api: list artist tracks", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + out := make([]TrackRef, 0, len(rows)) + for _, row := range rows { + out = append(out, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName)) + } + writeJSON(w, http.StatusOK, out) +} +``` + +- [ ] **Step 8.4: Run tests to verify they pass** + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test ./internal/api/ -run TestGetArtistTracks_ -count=1 -v +``` + +Expected: 3/3 PASS. + +- [ ] **Step 8.5: Commit** + +```bash +git add internal/api/library.go internal/api/library_test.go +git commit -m "feat(api): add GET /api/artists/{id}/tracks handler" +``` + +--- + +### Task 9 — Update `/api/artists?sort=alpha` to use covers query + register routes + +**Files:** +- Modify: `internal/api/library.go` (`handleListArtists` alpha branch) +- Modify: `internal/api/library_test.go` (existing alpha tests still pass; add cover-url assertion) +- Modify: `internal/api/api.go` + +- [ ] **Step 9.1: Update `handleListArtists` alpha branch to use the new query** + +Modify `internal/api/library.go` — the `handleListArtists` function's switch block: + +```go + switch sort { + case "newest": + artists, err = q.ListArtistsNewest(r.Context(), dbq.ListArtistsNewestParams{ + Limit: int32(limit), Offset: int32(offset), + }) + if err != nil { + h.logger.Error("api: list artists newest", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + total, terr := q.CountArtists(r.Context()) + if terr != nil { + h.logger.Error("api: count artists", "err", terr) + writeErr(w, http.StatusInternalServerError, "server_error", "count failed") + return + } + items := make([]ArtistRef, 0, len(artists)) + for _, a := range artists { + albums, aerr := q.ListAlbumsByArtist(r.Context(), a.ID) + if aerr != nil { + h.logger.Error("api: list albums for artist", "err", aerr) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + items = append(items, artistRefFrom(a, len(albums))) + } + writeJSON(w, http.StatusOK, Page[ArtistRef]{ + Items: items, Total: int(total), Limit: limit, Offset: offset, + }) + return + + default: // alpha + rows, err := q.ListArtistsAlphaWithCovers(r.Context(), dbq.ListArtistsAlphaWithCoversParams{ + Limit: int32(limit), Offset: int32(offset), + }) + if err != nil { + h.logger.Error("api: list artists alpha", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + total, terr := q.CountArtists(r.Context()) + if terr != nil { + h.logger.Error("api: count artists", "err", terr) + writeErr(w, http.StatusInternalServerError, "server_error", "count failed") + return + } + items := make([]ArtistRef, 0, len(rows)) + for _, row := range rows { + items = append(items, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID)) + } + writeJSON(w, http.StatusOK, Page[ArtistRef]{ + Items: items, Total: int(total), Limit: limit, Offset: offset, + }) + return + } +``` + +Then delete the redundant `_ = artists; _ = err` and old shared `total` block at the bottom of the function — both branches now return inline. The function should end after the `switch` with no further statements. + +- [ ] **Step 9.2: Register routes in `api.go`** + +Modify `internal/api/api.go` — add three new routes inside the authenticated group, alongside the existing `authed.Get("/artists/...", ...)` lines: + +```go + authed.Get("/home", h.handleGetHome) + authed.Get("/library/albums", h.handleListLibraryAlbums) + authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks) +``` + +Place these adjacent to the existing artist/album/track registrations for readability. The exact location: + +```go + authed.Get("/artists", h.handleListArtists) + authed.Get("/artists/{id}", h.handleGetArtist) + authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks) // NEW + authed.Get("/albums/{id}", h.handleGetAlbum) + authed.Get("/albums/{id}/cover", h.handleGetCover) + authed.Get("/library/albums", h.handleListLibraryAlbums) // NEW + authed.Get("/tracks/{id}", h.handleGetTrack) + authed.Get("/tracks/{id}/stream", h.handleGetStream) + authed.Get("/search", h.handleSearch) + authed.Get("/radio", h.handleRadio) + authed.Get("/home", h.handleGetHome) // NEW +``` + +- [ ] **Step 9.3: Run all api tests** + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test ./internal/api/ -count=1 +``` + +Expected: all PASS. Existing artist-list tests must continue to pass — the alpha branch now exposes `cover_url` (empty string when no cover) and `sort_name` (always populated). + +- [ ] **Step 9.4: Commit** + +```bash +git add internal/api/library.go internal/api/api.go +git commit -m "feat(api): wire M6a routes; alpha artist list uses covers query" +``` + +--- + +### Task 10 — Frontend: TS types + queries.ts + +**Files:** +- Modify: `web/src/lib/api/types.ts` +- Modify: `web/src/lib/api/queries.ts` + +- [ ] **Step 10.1: Extend `ArtistRef` and `AlbumRef`; add `HomePayload`** + +Modify `web/src/lib/api/types.ts` — replace the `ArtistRef` and `AlbumRef` blocks: + +```ts +export type ArtistRef = { + id: string; + name: string; + sort_name: string; + album_count: number; + cover_url: string; // "" when no representative album cover +}; + +export type AlbumRef = { + id: string; + title: string; + sort_title: string; + artist_id: string; + artist_name: string; + year?: number; + track_count: number; + duration_sec: number; + cover_url: string; +}; +``` + +Append at the bottom of `web/src/lib/api/types.ts`: + +```ts +// Mirrors internal/api/types.go HomePayload. All slices are non-null +// per the server contract — empty sections render as []. +export type HomePayload = { + recently_added_albums: AlbumRef[]; + rediscover_albums: AlbumRef[]; + rediscover_artists: ArtistRef[]; + most_played_tracks: TrackRef[]; + last_played_artists: ArtistRef[]; +}; +``` + +- [ ] **Step 10.2: Add new query keys** + +Modify `web/src/lib/api/queries.ts` — append entries to the `qk` object literal: + +```ts + home: () => ['home'] as const, + albumsAlpha: () => ['albumsAlpha'] as const, + artistTracks: (artistId: string) => ['artistTracks', artistId] as const, +``` + +- [ ] **Step 10.3: Update existing queries.test.ts** + +Append to `web/src/lib/api/queries.test.ts`: + +```ts +test('M6a query keys are stable', () => { + expect(qk.home()).toEqual(['home']); + expect(qk.albumsAlpha()).toEqual(['albumsAlpha']); + expect(qk.artistTracks('a1')).toEqual(['artistTracks', 'a1']); +}); +``` + +- [ ] **Step 10.4: Update existing test fixtures for new required fields** + +Search for AlbumRef/ArtistRef literals in tests: + +```bash +cd web && grep -rln "album_count\|track_count" src/ --include="*.test.ts" +``` + +For each file with an AlbumRef literal, add `sort_title: ''`. For each ArtistRef literal, add `sort_name: ''` and `cover_url: ''`. Example for `web/src/lib/components/AlbumCard.test.ts`: + +```ts +const album: AlbumRef = { + id: 'xyz', + title: 'Kind of Blue', + sort_title: 'Kind of Blue', + artist_id: 'm-davis', + artist_name: 'Miles Davis', + year: 1959, + track_count: 5, + duration_sec: 2630, + cover_url: '/api/albums/xyz/cover', +}; +``` + +- [ ] **Step 10.5: Run all frontend tests** + +```bash +cd web && pnpm check && pnpm test +``` + +Expected: all PASS. + +- [ ] **Step 10.6: Commit** + +```bash +git add web/src/lib/api/types.ts web/src/lib/api/queries.ts web/src/lib/api/queries.test.ts \ + web/src/lib/components/*.test.ts +git commit -m "feat(web): extend ArtistRef/AlbumRef + add HomePayload + new query keys" +``` + +--- + +### Task 11 — Frontend API clients: home.ts + albums.ts + artists.ts + +**Files:** +- Create: `web/src/lib/api/home.ts` + `home.test.ts` +- Create: `web/src/lib/api/albums.ts` + `albums.test.ts` +- Create: `web/src/lib/api/artists.ts` + `artists.test.ts` + +- [ ] **Step 11.1: Write failing tests for `home.ts`** + +Create `web/src/lib/api/home.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('./client', () => ({ + api: { get: vi.fn() } +})); + +import { listHome } from './home'; +import { qk } from './queries'; +import { api } from './client'; +import type { HomePayload } from './types'; + +afterEach(() => vi.clearAllMocks()); + +describe('home client', () => { + test('listHome hits /api/home', async () => { + const fixture: HomePayload = { + recently_added_albums: [], + rediscover_albums: [], + rediscover_artists: [], + most_played_tracks: [], + last_played_artists: [] + }; + (api.get as ReturnType).mockResolvedValueOnce(fixture); + const got = await listHome(); + expect(api.get).toHaveBeenCalledWith('/api/home'); + expect(got).toEqual(fixture); + }); + + test('qk.home key is stable', () => { + expect(qk.home()).toEqual(['home']); + }); +}); +``` + +- [ ] **Step 11.2: Create `home.ts`** + +Create `web/src/lib/api/home.ts`: + +```ts +import { createQuery } from '@tanstack/svelte-query'; +import { api } from './client'; +import { qk } from './queries'; +import type { HomePayload } from './types'; + +export async function listHome(): Promise { + return api.get('/api/home'); +} + +// staleTime = 60s — repeat home-page mounts within a minute reuse cached +// data. Like-state changes, new plays, freshly-added albums surface on +// the next refetch. +export function createHomeQuery() { + return createQuery({ + queryKey: qk.home(), + queryFn: listHome, + staleTime: 60_000 + }); +} +``` + +- [ ] **Step 11.3: Write failing tests for `albums.ts`** + +Create `web/src/lib/api/albums.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('./client', () => ({ + api: { get: vi.fn() } +})); + +import { listAlbumsAlpha } from './albums'; +import { qk } from './queries'; +import { api } from './client'; + +afterEach(() => vi.clearAllMocks()); + +describe('albums client', () => { + test('listAlbumsAlpha hits /api/library/albums with paging', async () => { + (api.get as ReturnType).mockResolvedValueOnce({ + items: [], total: 0, limit: 50, offset: 0 + }); + await listAlbumsAlpha(50, 0); + expect(api.get).toHaveBeenCalledWith('/api/library/albums?limit=50&offset=0'); + }); + + test('listAlbumsAlpha honors custom limit and offset', async () => { + (api.get as ReturnType).mockResolvedValueOnce({ + items: [], total: 0, limit: 25, offset: 100 + }); + await listAlbumsAlpha(25, 100); + expect(api.get).toHaveBeenCalledWith('/api/library/albums?limit=25&offset=100'); + }); + + test('qk.albumsAlpha key is stable', () => { + expect(qk.albumsAlpha()).toEqual(['albumsAlpha']); + }); +}); +``` + +- [ ] **Step 11.4: Create `albums.ts`** + +Create `web/src/lib/api/albums.ts`: + +```ts +import { createInfiniteQuery } from '@tanstack/svelte-query'; +import { api } from './client'; +import { qk } from './queries'; +import type { AlbumRef, Page } from './types'; + +export const ALBUM_PAGE_SIZE = 50; + +export async function listAlbumsAlpha(limit: number, offset: number): Promise> { + return api.get>(`/api/library/albums?limit=${limit}&offset=${offset}`); +} + +export function createAlbumsAlphaInfiniteQuery() { + return createInfiniteQuery({ + queryKey: qk.albumsAlpha(), + queryFn: ({ pageParam = 0 }) => listAlbumsAlpha(ALBUM_PAGE_SIZE, pageParam), + initialPageParam: 0, + getNextPageParam: (last) => { + const loaded = last.offset + last.items.length; + return loaded >= last.total ? undefined : loaded; + } + }); +} +``` + +- [ ] **Step 11.5: Write failing tests for `artists.ts`** + +Create `web/src/lib/api/artists.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('./client', () => ({ + api: { get: vi.fn() } +})); + +import { listArtistTracks } from './artists'; +import { qk } from './queries'; +import { api } from './client'; + +afterEach(() => vi.clearAllMocks()); + +describe('artists client', () => { + test('listArtistTracks hits /api/artists/:id/tracks', async () => { + (api.get as ReturnType).mockResolvedValueOnce([]); + await listArtistTracks('art-1'); + expect(api.get).toHaveBeenCalledWith('/api/artists/art-1/tracks'); + }); + + test('qk.artistTracks key includes the artist id', () => { + expect(qk.artistTracks('art-1')).toEqual(['artistTracks', 'art-1']); + }); +}); +``` + +- [ ] **Step 11.6: Create `artists.ts`** + +Create `web/src/lib/api/artists.ts`: + +```ts +import { createQuery } from '@tanstack/svelte-query'; +import { api } from './client'; +import { qk } from './queries'; +import type { TrackRef } from './types'; + +export async function listArtistTracks(artistId: string): Promise { + return api.get(`/api/artists/${artistId}/tracks`); +} + +export function createArtistTracksQuery(artistId: string) { + return createQuery({ + queryKey: qk.artistTracks(artistId), + queryFn: () => listArtistTracks(artistId), + enabled: artistId.length > 0 + }); +} +``` + +- [ ] **Step 11.7: Run all client tests + commit** + +```bash +cd web && pnpm test src/lib/api/home.test.ts src/lib/api/albums.test.ts src/lib/api/artists.test.ts +``` + +Expected: all PASS. Commit: + +```bash +git add web/src/lib/api/home.ts web/src/lib/api/home.test.ts \ + web/src/lib/api/albums.ts web/src/lib/api/albums.test.ts \ + web/src/lib/api/artists.ts web/src/lib/api/artists.test.ts +git commit -m "feat(web): add home/albums/artists API clients for M6a" +``` + +--- + +### Task 12 — `` component + +**Files:** +- Create: `web/src/lib/components/HorizontalScrollRow.svelte` +- Create: `web/src/lib/components/HorizontalScrollRow.test.ts` + +- [ ] **Step 12.1: Write failing test** + +Create `web/src/lib/components/HorizontalScrollRow.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import HorizontalScrollRow from './HorizontalScrollRow.svelte'; + +afterEach(() => vi.clearAllMocks()); + +describe('HorizontalScrollRow', () => { + test('renders left and right scroll buttons', () => { + render(HorizontalScrollRow, { props: { items: ['a', 'b', 'c'] } }); + expect(screen.getByRole('button', { name: /scroll left/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /scroll right/i })).toBeInTheDocument(); + }); + + test('left button starts disabled (scrollLeft is 0)', () => { + render(HorizontalScrollRow, { props: { items: ['a'] } }); + expect(screen.getByRole('button', { name: /scroll left/i })).toBeDisabled(); + }); + + test('clicking right button calls scrollBy on the container', async () => { + const { container } = render(HorizontalScrollRow, { props: { items: ['a', 'b'] } }); + const scroller = container.querySelector('[data-testid="scroll-container"]') as HTMLElement; + const spy = vi.fn(); + scroller.scrollBy = spy as unknown as HTMLElement['scrollBy']; + Object.defineProperty(scroller, 'clientWidth', { value: 800, configurable: true }); + Object.defineProperty(scroller, 'scrollWidth', { value: 2000, configurable: true }); + Object.defineProperty(scroller, 'scrollLeft', { value: 0, configurable: true, writable: true }); + + await fireEvent.click(screen.getByRole('button', { name: /scroll right/i })); + expect(spy).toHaveBeenCalledWith({ left: expect.any(Number), behavior: 'smooth' }); + expect(spy.mock.calls[0][0].left).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 12.2: Run test to verify failure** + +```bash +cd web && pnpm test src/lib/components/HorizontalScrollRow.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 12.3: Create the component** + +Create `web/src/lib/components/HorizontalScrollRow.svelte`: + +```svelte + + +
+ + +
+ {#each items as it, i (i)} + {@render item?.(it, i)} + {/each} +
+ + +
+ + +``` + +- [ ] **Step 12.4: Run tests + commit** + +```bash +cd web && pnpm test src/lib/components/HorizontalScrollRow.test.ts +``` + +Expected: 3/3 PASS. Commit: + +```bash +git add web/src/lib/components/HorizontalScrollRow.svelte \ + web/src/lib/components/HorizontalScrollRow.test.ts +git commit -m "feat(web): add HorizontalScrollRow component" +``` + +--- + +### Task 13 — `` polish + play overlay + +**Files:** +- Modify: `web/src/lib/components/AlbumCard.svelte` +- Modify: `web/src/lib/components/AlbumCard.test.ts` + +- [ ] **Step 13.1: Update existing test mock to include `playQueue`** + +Edit `web/src/lib/components/AlbumCard.test.ts` — change the player mock at the top of the file: + +```ts +vi.mock('$lib/player/store.svelte', () => ({ + enqueueTracks: vi.fn(), + playQueue: vi.fn() +})); +``` + +And the corresponding import: + +```ts +import { enqueueTracks, playQueue } from '$lib/player/store.svelte'; +``` + +- [ ] **Step 13.2: Append failing test for play overlay** + +Append inside the `describe('AlbumCard', ...)` block: + +```ts +test('play overlay click fetches album detail and calls playQueue at index 0', async () => { + const tracks: TrackRef[] = [ + { id: 't1', title: 'So What', + album_id: 'xyz', album_title: 'Kind of Blue', + artist_id: 'm-davis', artist_name: 'Miles Davis', + track_number: 1, disc_number: 1, duration_sec: 545, + stream_url: '/api/tracks/t1/stream' } + ]; + const detail: AlbumDetail = { ...album, tracks }; + (api.get as ReturnType).mockResolvedValueOnce(detail); + + render(AlbumCard, { props: { album } }); + await fireEvent.click(screen.getByRole('button', { name: /play kind of blue/i })); + expect(api.get).toHaveBeenCalledWith('/api/albums/xyz'); + await Promise.resolve(); + await Promise.resolve(); + expect(playQueue).toHaveBeenCalledWith(tracks, 0); +}); +``` + +- [ ] **Step 13.3: Run test to verify failure** + +```bash +cd web && pnpm test src/lib/components/AlbumCard.test.ts +``` + +Expected: FAIL — no play button exists. + +- [ ] **Step 13.4: Rewrite `AlbumCard.svelte`** + +Replace the entire contents of `web/src/lib/components/AlbumCard.svelte`: + +```svelte + + +
+ + +``` + +- [ ] **Step 13.5: Run tests + commit** + +```bash +cd web && pnpm test src/lib/components/AlbumCard.test.ts +``` + +Expected: all PASS. Commit: + +```bash +git add web/src/lib/components/AlbumCard.svelte web/src/lib/components/AlbumCard.test.ts +git commit -m "feat(web): polish AlbumCard with FabledSword tokens + play overlay" +``` + +--- + +### Task 14 — `` (circular) + +**Files:** +- Create: `web/src/lib/components/ArtistCard.svelte` +- Create: `web/src/lib/components/ArtistCard.test.ts` + +- [ ] **Step 14.1: Write failing test** + +Create `web/src/lib/components/ArtistCard.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import type { ArtistRef, TrackRef } from '$lib/api/types'; + +vi.mock('$lib/api/client', () => ({ + api: { get: vi.fn() } +})); +vi.mock('$lib/player/store.svelte', () => ({ + playQueue: vi.fn() +})); + +import ArtistCard from './ArtistCard.svelte'; +import { api } from '$lib/api/client'; +import { playQueue } from '$lib/player/store.svelte'; + +const artist: ArtistRef = { + id: 'art-1', + name: 'Boards of Canada', + sort_name: 'Boards of Canada', + album_count: 5, + cover_url: '/api/albums/cov-1/cover' +}; + +afterEach(() => vi.clearAllMocks()); + +describe('ArtistCard', () => { + test('renders cover img inside link to /artists/:id', () => { + const { container } = render(ArtistCard, { props: { artist } }); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/artists/art-1'); + const img = container.querySelector('img') as HTMLImageElement; + expect(img.src).toContain('/api/albums/cov-1/cover'); + }); + + test('falls back to Disc3 glyph when cover_url is empty', () => { + const { container } = render(ArtistCard, { + props: { artist: { ...artist, cover_url: '' } } + }); + expect(container.querySelector('img')).not.toBeInTheDocument(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + test('play overlay fetches tracks, shuffles, calls playQueue', async () => { + const tracks: TrackRef[] = [ + { id: 't1', title: 'A', album_id: 'al-1', album_title: 'X', + artist_id: 'art-1', artist_name: 'BoC', + track_number: 1, disc_number: 1, duration_sec: 1, stream_url: '/x' }, + { id: 't2', title: 'B', album_id: 'al-1', album_title: 'X', + artist_id: 'art-1', artist_name: 'BoC', + track_number: 2, disc_number: 1, duration_sec: 1, stream_url: '/x' } + ]; + (api.get as ReturnType).mockResolvedValueOnce(tracks); + + render(ArtistCard, { props: { artist } }); + await fireEvent.click(screen.getByRole('button', { name: /play boards of canada/i })); + expect(api.get).toHaveBeenCalledWith('/api/artists/art-1/tracks'); + await Promise.resolve(); + await Promise.resolve(); + expect(playQueue).toHaveBeenCalledOnce(); + const [calledTracks, idx] = (playQueue as ReturnType).mock.calls[0]; + expect(calledTracks).toHaveLength(2); + expect(idx).toBe(0); + expect(calledTracks.map((t: TrackRef) => t.id).sort()).toEqual(['t1', 't2']); + }); +}); +``` + +- [ ] **Step 14.2: Run test to verify failure** + +```bash +cd web && pnpm test src/lib/components/ArtistCard.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 14.3: Create the component** + +Create `web/src/lib/components/ArtistCard.svelte`: + +```svelte + + + + + +``` + +- [ ] **Step 14.4: Run tests + commit** + +```bash +cd web && pnpm test src/lib/components/ArtistCard.test.ts +``` + +Expected: 3/3 PASS. Commit: + +```bash +git add web/src/lib/components/ArtistCard.svelte \ + web/src/lib/components/ArtistCard.test.ts +git commit -m "feat(web): add ArtistCard (circular) with play-shuffle overlay" +``` + +--- + +### Task 15 — `` + +**Files:** +- Create: `web/src/lib/components/CompactTrackCard.svelte` +- Create: `web/src/lib/components/CompactTrackCard.test.ts` + +- [ ] **Step 15.1: Write failing test** + +Create `web/src/lib/components/CompactTrackCard.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import type { TrackRef } from '$lib/api/types'; + +vi.mock('$lib/player/store.svelte', () => ({ + playQueue: vi.fn() +})); + +import CompactTrackCard from './CompactTrackCard.svelte'; +import { playQueue } from '$lib/player/store.svelte'; + +const tracks: TrackRef[] = [ + { id: 't1', title: 'First', album_id: 'a1', album_title: 'A', + artist_id: 'art-1', artist_name: 'Artist', + track_number: 1, disc_number: 1, duration_sec: 1, stream_url: '/a' }, + { id: 't2', title: 'Second', album_id: 'a1', album_title: 'A', + artist_id: 'art-1', artist_name: 'Artist', + track_number: 2, disc_number: 1, duration_sec: 1, stream_url: '/b' }, + { id: 't3', title: 'Third', album_id: 'a1', album_title: 'A', + artist_id: 'art-1', artist_name: 'Artist', + track_number: 3, disc_number: 1, duration_sec: 1, stream_url: '/c' } +]; + +afterEach(() => vi.clearAllMocks()); + +describe('CompactTrackCard', () => { + test('clicking the card calls playQueue with sectionTracks at the right index', async () => { + render(CompactTrackCard, { + props: { track: tracks[1], sectionTracks: tracks, index: 1 } + }); + await fireEvent.click(screen.getByRole('button', { name: /play second/i })); + expect(playQueue).toHaveBeenCalledWith(tracks, 1); + }); + + test('renders title and artist name', () => { + render(CompactTrackCard, { + props: { track: tracks[0], sectionTracks: tracks, index: 0 } + }); + expect(screen.getByText('First')).toBeInTheDocument(); + expect(screen.getByText('Artist')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 15.2: Create the component** + +Create `web/src/lib/components/CompactTrackCard.svelte`: + +```svelte + + + +``` + +- [ ] **Step 15.3: Run tests + commit** + +```bash +cd web && pnpm test src/lib/components/CompactTrackCard.test.ts +``` + +Expected: 2/2 PASS. Commit: + +```bash +git add web/src/lib/components/CompactTrackCard.svelte \ + web/src/lib/components/CompactTrackCard.test.ts +git commit -m "feat(web): add CompactTrackCard for Most played rows" +``` + +--- + +### Task 16 — `` + +**Files:** +- Create: `web/src/lib/components/AlphabeticalGrid.svelte` +- Create: `web/src/lib/components/AlphabeticalGrid.test.ts` + +- [ ] **Step 16.1: Write failing test** + +Create `web/src/lib/components/AlphabeticalGrid.test.ts`: + +```ts +import { describe, expect, test } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { createRawSnippet } from 'svelte'; +import AlphabeticalGrid from './AlphabeticalGrid.svelte'; + +type Item = { id: string; key: string; label: string }; + +const items: Item[] = [ + { id: '1', key: 'A', label: 'Apple' }, + { id: '2', key: 'A', label: 'Avocado' }, + { id: '3', key: 'B', label: 'Banana' }, + { id: '4', key: 'D', label: 'Date' } // skips C — divider must jump to D directly +]; + +describe('AlphabeticalGrid', () => { + test('inserts a divider for each new key letter', () => { + render(AlphabeticalGrid, { + props: { + items, + getKey: (it: Item) => it.key, + item: createRawSnippet((it: Item) => ({ + render: () => `${it.label}` + })) + } + }); + // Three dividers (A, B, D); C is absent because no items use C. + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.getByText('D')).toBeInTheDocument(); + expect(screen.queryByText('C')).not.toBeInTheDocument(); + }); + + test('renders items in given order', () => { + const { container } = render(AlphabeticalGrid, { + props: { + items, + getKey: (it: Item) => it.key, + item: createRawSnippet((it: Item) => ({ + render: () => `${it.label}` + })) + } + }); + expect(container.textContent).toMatch(/A.*Apple.*Avocado.*B.*Banana.*D.*Date/s); + }); +}); +``` + +NOTE: `createRawSnippet` is the Svelte 5 utility for synthesising snippets in tests; if it doesn't suit your renderer, swap to a wrapper component that exposes the snippet via ``. + +- [ ] **Step 16.2: Run test to verify failure** + +```bash +cd web && pnpm test src/lib/components/AlphabeticalGrid.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 16.3: Create the component** + +Create `web/src/lib/components/AlphabeticalGrid.svelte`: + +```svelte + + +
+ {#each segments as seg, i (i)} + {#if seg.divider} +
+ {seg.divider} + +
+ {/if} +
+ {@render item(seg.item)} +
+ {/each} +
+ + +``` + +- [ ] **Step 16.4: Run tests + commit** + +```bash +cd web && pnpm test src/lib/components/AlphabeticalGrid.test.ts +``` + +Expected: 2/2 PASS. Commit: + +```bash +git add web/src/lib/components/AlphabeticalGrid.svelte \ + web/src/lib/components/AlphabeticalGrid.test.ts +git commit -m "feat(web): add AlphabeticalGrid wrapper with letter dividers" +``` + +--- + +### Task 17 — Home page rewrite (`/+page.svelte`) + +**Files:** +- Modify: `web/src/routes/+page.svelte` (full rewrite) +- Modify: `web/src/routes/artists.test.ts` (assertions migrate) + +- [ ] **Step 17.1: Rewrite `+page.svelte`** + +Replace the entire contents of `web/src/routes/+page.svelte`: + +```svelte + + +
+ {#if query.isError} + + {:else if showSkeleton.value && !data} +

Loading…

+ {:else if data} + +
+
+

Recently added

+
+ {#if data.recently_added_albums.length === 0} +

Nothing added yet. Scan a folder via the server's config.

+ {:else} + {#each chunk(data.recently_added_albums, 25) as row, i (i)} + + {#snippet item(album)} +
+ {/snippet} +
+ {/each} + {/if} +
+ + +
+
+

Rediscover

+
+ {#if data.rediscover_albums.length === 0 && data.rediscover_artists.length === 0} +

No forgotten favourites yet. Like some albums or artists to fill this in.

+ {:else} + {#if data.rediscover_albums.length > 0} + + {#snippet item(album)} +
+ {/snippet} +
+ {/if} + {#if data.rediscover_artists.length > 0} + + {#snippet item(artist)} +
+ {/snippet} +
+ {/if} + {/if} +
+ + +
+
+

Most played

+
+ {#if data.most_played_tracks.length === 0} +

No plays to draw from. Listen to something.

+ {:else} + {#each chunk(data.most_played_tracks, 25) as row, i (i)} + + {#snippet item(track, idx)} + + {/snippet} + + {/each} + {/if} +
+ + +
+
+

Last played

+
+ {#if data.last_played_artists.length === 0} +

No recent plays.

+ {:else} + + {#snippet item(artist)} +
+ {/snippet} +
+ {/if} +
+ {/if} +
+``` + +- [ ] **Step 17.2: Update `artists.test.ts` to point at the new home behavior** + +Open `web/src/routes/artists.test.ts`. The existing tests assert the old library-list behavior of `/`. Either move them to a new `library/artists.test.ts` (Task 18 covers that page) or delete them outright in this step. Decision: delete this file in this task because the replacement tests live with `/library/artists/+page.svelte`. + +```bash +git rm web/src/routes/artists.test.ts +``` + +- [ ] **Step 17.3: Run frontend tests + check** + +```bash +cd web && pnpm check && pnpm test +``` + +Expected: all PASS. The new home page has no dedicated component test (see Task 22 for an integration smoke test); `pnpm check` confirms type correctness against the new query/types. + +- [ ] **Step 17.4: Commit** + +```bash +git add web/src/routes/+page.svelte +git rm web/src/routes/artists.test.ts +git commit -m "feat(web): rewrite / as home page composing 4 horizontal-scroll sections" +``` + +--- + +### Task 18 — `/library/artists` page + +**Files:** +- Create: `web/src/routes/library/artists/+page.svelte` +- Create: `web/src/routes/library/artists/page.test.ts` + +- [ ] **Step 18.1: Write the page** + +Create `web/src/routes/library/artists/+page.svelte`: + +```svelte + + +
+
+

Artists

+ {#if !query.isPending && !query.isError} +

+ {total} {total === 1 ? 'artist' : 'artists'} +

+ {/if} +
+ + {#if query.isError} + + {:else if showSkeleton.value && artists.length === 0} +

Loading…

+ {:else if !query.isPending && total === 0} +

No artists yet — scan a library folder via the server's config.

+ {:else} + a.sort_name || a.name} + > + {#snippet item(a)} + + {/snippet} + + + {#if query.hasNextPage} + + {:else if artists.length > 0} +

End of library

+ {/if} + {/if} +
+``` + +- [ ] **Step 18.2: Add a smoke test** + +Create `web/src/routes/library/artists/page.test.ts`: + +```ts +import { describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { readable } from 'svelte/store'; + +vi.mock('$lib/api/queries', () => ({ + createArtistsQuery: () => readable({ + data: { pages: [{ + items: [ + { id: 'a1', name: 'Alpha', sort_name: 'Alpha', album_count: 1, cover_url: '' }, + { id: 'b1', name: 'Beta', sort_name: 'Beta', album_count: 2, cover_url: '' } + ], + total: 2, limit: 50, offset: 0 + }] }, + isPending: false, + isError: false, + hasNextPage: false, + isFetchingNextPage: false, + refetch: () => {}, + fetchNextPage: () => {} + }) +})); +vi.mock('$lib/api/client', () => ({ api: { get: vi.fn() } })); +vi.mock('$lib/player/store.svelte', () => ({ playQueue: vi.fn() })); + +import Page from './+page.svelte'; + +describe('/library/artists', () => { + test('renders title + count + alphabetical dividers', () => { + render(Page); + expect(screen.getByRole('heading', { name: /artists/i })).toBeInTheDocument(); + expect(screen.getByText(/2 artists/)).toBeInTheDocument(); + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.getByText('Beta')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 18.3: Run tests + commit** + +```bash +cd web && pnpm test src/routes/library/artists/page.test.ts +``` + +Expected: PASS. Commit: + +```bash +git add web/src/routes/library/artists/ +git commit -m "feat(web): add /library/artists wrapping-grid page" +``` + +--- + +### Task 19 — `/library/albums` page + +**Files:** +- Create: `web/src/routes/library/albums/+page.svelte` +- Create: `web/src/routes/library/albums/page.test.ts` + +- [ ] **Step 19.1: Write the page** + +Create `web/src/routes/library/albums/+page.svelte`: + +```svelte + + +
+
+

Albums

+ {#if !query.isPending && !query.isError} +

+ {total} {total === 1 ? 'album' : 'albums'} +

+ {/if} +
+ + {#if query.isError} + + {:else if showSkeleton.value && albums.length === 0} +

Loading…

+ {:else if !query.isPending && total === 0} +

No albums yet — scan a library folder via the server's config.

+ {:else} + a.sort_title || a.title} + > + {#snippet item(album)} + + {/snippet} + + + {#if query.hasNextPage} + + {:else if albums.length > 0} +

End of library

+ {/if} + {/if} +
+``` + +- [ ] **Step 19.2: Add a smoke test** + +Create `web/src/routes/library/albums/page.test.ts`: + +```ts +import { describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { readable } from 'svelte/store'; + +vi.mock('$lib/api/albums', () => ({ + createAlbumsAlphaInfiniteQuery: () => readable({ + data: { pages: [{ + items: [ + { id: 'a1', title: 'Apple', sort_title: 'Apple', + artist_id: 'art-1', artist_name: 'X', + track_count: 5, duration_sec: 100, cover_url: '/api/albums/a1/cover' }, + { id: 'b1', title: 'Banana', sort_title: 'Banana', + artist_id: 'art-1', artist_name: 'X', + track_count: 3, duration_sec: 50, cover_url: '/api/albums/b1/cover' } + ], + total: 2, limit: 50, offset: 0 + }] }, + isPending: false, + isError: false, + hasNextPage: false, + isFetchingNextPage: false, + refetch: () => {}, + fetchNextPage: () => {} + }), + ALBUM_PAGE_SIZE: 50 +})); +vi.mock('$lib/api/client', () => ({ api: { get: vi.fn() } })); +vi.mock('$lib/player/store.svelte', () => ({ + enqueueTracks: vi.fn(), + playQueue: vi.fn() +})); +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, isError: false + }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); + +import Page from './+page.svelte'; + +describe('/library/albums', () => { + test('renders title + count + alphabetical dividers', () => { + render(Page); + expect(screen.getByRole('heading', { name: /albums/i })).toBeInTheDocument(); + expect(screen.getByText(/2 albums/)).toBeInTheDocument(); + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.getByText('Apple')).toBeInTheDocument(); + expect(screen.getByText('Banana')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 19.3: Run tests + commit** + +```bash +cd web && pnpm test src/routes/library/albums/page.test.ts +``` + +Expected: PASS. Commit: + +```bash +git add web/src/routes/library/albums/ +git commit -m "feat(web): add /library/albums wrapping-grid page" +``` + +--- + +### Task 20 — Shell nav update + retire `` + +**Files:** +- Modify: `web/src/lib/components/Shell.svelte` +- Modify: `web/src/lib/components/Shell.test.ts` +- Delete: `web/src/lib/components/ArtistRow.svelte` +- Delete: `web/src/lib/components/ArtistRow.test.ts` +- Modify: `web/src/lib/components/LibrarySkeleton.svelte` (optional cleanup — see Step 20.5) + +- [ ] **Step 20.1: Update `Shell.svelte` navItems** + +Modify `web/src/lib/components/Shell.svelte` — replace the `navItems` array: + +```ts + const navItems = [ + { href: '/', label: 'Home' }, + { href: '/library/artists', label: 'Artists' }, + { href: '/library/albums', label: 'Albums' }, + { href: '/library/liked', label: 'Liked' }, + { href: '/library/hidden', label: 'Hidden' }, + { href: '/search', label: 'Search' }, + { href: '/discover', label: 'Discover' }, + { href: '/requests', label: 'Requests' }, + { href: '/playlists', label: 'Playlists' }, + { href: '/settings', label: 'Settings' } + ]; +``` + +- [ ] **Step 20.2: Update `Shell.test.ts` assertions** + +Open `web/src/lib/components/Shell.test.ts`. Locate the assertion that lists nav items (search for `Library`) and replace with the new order: + +```ts +test('renders nav items in expected order', () => { + render(Shell, { props: { children } }); + const labels = ['Home', 'Artists', 'Albums', 'Liked', 'Hidden', 'Search', + 'Discover', 'Requests', 'Playlists', 'Settings']; + for (const label of labels) { + expect(screen.getByRole('link', { name: label })).toBeInTheDocument(); + } +}); +``` + +If the existing test does not assert the exact label list, add this test — preserving any existing tests in the file. + +- [ ] **Step 20.3: Audit `` usages and remove** + +Run: + +```bash +cd web && grep -rln "ArtistRow" src/ --include="*.svelte" --include="*.ts" +``` + +Expected (after Task 17): only `ArtistRow.svelte` and `ArtistRow.test.ts`. If any other file imports `ArtistRow`, replace those imports with `ArtistCard` and adjust the call site (likely a `` becomes ``). The migration is mechanical. + +Then delete: + +```bash +git rm web/src/lib/components/ArtistRow.svelte \ + web/src/lib/components/ArtistRow.test.ts +``` + +- [ ] **Step 20.4: Run all frontend tests** + +```bash +cd web && pnpm check && pnpm test +``` + +Expected: all PASS. Type errors at this step usually mean a forgotten ArtistRow import; fix per Step 20.3. + +- [ ] **Step 20.5: Inspect `LibrarySkeleton` usage** + +Run: + +```bash +cd web && grep -rln "LibrarySkeleton" src/ --include="*.svelte" --include="*.ts" +``` + +If the only remaining importers (after Tasks 17/18/19) use only the existing `variant="grid"` or `variant="list"` props, no change is needed — the skeleton was always extensible. Leave it. If a call site references the old `variant="list"` for a now-deleted page, delete that import. + +- [ ] **Step 20.6: Commit** + +```bash +git add web/src/lib/components/Shell.svelte \ + web/src/lib/components/Shell.test.ts +git rm web/src/lib/components/ArtistRow.svelte \ + web/src/lib/components/ArtistRow.test.ts +git commit -m "feat(web): update nav (Home/Artists/Albums); retire ArtistRow" +``` + +--- + +## Verification (post-task-20) + +After all tasks land, the following all-green checks confirm M6a is ready for PR: + +- [ ] **Backend full test pass** + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test ./... -count=1 +``` + +Expected: all PASS except the pre-existing `internal/library/TestScanner_Integration` flake (per `project_scanner_flake.md` memory). + +- [ ] **Frontend full test pass + typecheck** + +```bash +cd web && pnpm check && pnpm test && pnpm build +``` + +Expected: typecheck clean, all unit tests PASS, production build succeeds. + +- [ ] **Manual smoke** + +Start dev server (`docker compose up -d` + `cd web && pnpm dev`); log in; verify: + + 1. `/` renders all four sections; rows scroll independently with arrow buttons. + 2. Hovering an album card reveals the play overlay; clicking it queues the album from track 1. + 3. Hovering an artist card reveals the play overlay; clicking it shuffle-plays artist tracks. + 4. Compact track cards click-play in section context. + 5. Nav: clicking "Artists" goes to `/library/artists` (wrapping grid + dividers). + 6. Nav: clicking "Albums" goes to `/library/albums` (wrapping grid + dividers). + 7. Quarantined tracks for the test user are absent from "Most played" and from the artist-shuffle queue. + +- [ ] **Commit + push to dev branch** + +```bash +git push -u origin dev +``` + +- [ ] **Open Forgejo PR via MCP** (per `project_no_github.md`) + +Use `mcp__forgejo__pull_request_write` with action=create, base=main, head=dev. Title: +`feat(m6a): home page redesign + library list relocation`. Body summarises the slice and lists the verification steps above. + +--- + +## Notes for the implementer + +- **No DB migration.** All sections derive from existing tables. +- **Tests gated on `MINSTREL_TEST_DATABASE_URL`.** Without it, integration tests skip cleanly. +- **`t.Context()` is Go 1.24+; this repo runs Go 1.23.** Always use `context.Background()` in tests. +- **Forgejo only.** No GitHub. Use the forgejo MCP for PR ops. +- **TanStack v5 patterns.** `createInfiniteQuery` for paged lists; `createQuery` for one-shots; `staleTime: 60_000` on the home query. +- **FabledSword tokens always.** No raw hex in component styles. Use `bg-surface`, `text-text-primary`, `border-border`, `text-accent`, etc. +- **Lucide icons.** 16px/1px stroke for chrome buttons, 32px/1.5px for fallbacks/overlays. +- **Sentence case copy.** "Recently added", "Most played", not "Recently Added". +- **No half-finished UI.** Per the spec's "no in-between steps" rule, every surface added here ships finished. + + From 10c84a536ef1bdcb98bae5f0fb72d38f9f000661 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 08:56:53 -0400 Subject: [PATCH 003/351] feat(db): add M6a home-section queries (recently added, most played, last played) Co-Authored-By: Claude Sonnet 4.6 --- internal/db/dbq/albums.sql.go | 48 ++++++++ internal/db/dbq/recommendation.sql.go | 145 +++++++++++++++++++++++++ internal/db/queries/albums.sql | 11 ++ internal/db/queries/recommendation.sql | 52 +++++++++ 4 files changed, 256 insertions(+) diff --git a/internal/db/dbq/albums.sql.go b/internal/db/dbq/albums.sql.go index acbdeb5b..2065580f 100644 --- a/internal/db/dbq/albums.sql.go +++ b/internal/db/dbq/albums.sql.go @@ -313,6 +313,54 @@ func (q *Queries) ListAlbumsRandom(ctx context.Context, limit int32) ([]Album, e return items, nil } +const listRecentlyAddedAlbumsWithArtist = `-- name: ListRecentlyAddedAlbumsWithArtist :many +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, artists.name AS artist_name +FROM albums +JOIN artists ON artists.id = albums.artist_id +ORDER BY albums.created_at DESC, albums.id +LIMIT $1 +` + +type ListRecentlyAddedAlbumsWithArtistRow struct { + Album Album + ArtistName string +} + +// M6a: recently-added albums joined with artist_name + artist_id for the +// home-page section. created_at is the row-insert timestamp from the +// scanner — the closest proxy to "added to library". Stable secondary +// ordering by id keeps pagination tie-break deterministic. +func (q *Queries) ListRecentlyAddedAlbumsWithArtist(ctx context.Context, limit int32) ([]ListRecentlyAddedAlbumsWithArtistRow, error) { + rows, err := q.db.Query(ctx, listRecentlyAddedAlbumsWithArtist, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListRecentlyAddedAlbumsWithArtistRow + for rows.Next() { + var i ListRecentlyAddedAlbumsWithArtistRow + if err := rows.Scan( + &i.Album.ID, + &i.Album.Title, + &i.Album.SortTitle, + &i.Album.ArtistID, + &i.Album.ReleaseDate, + &i.Album.Mbid, + &i.Album.CoverArtPath, + &i.Album.CreatedAt, + &i.Album.UpdatedAt, + &i.ArtistName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const searchAlbums = `-- name: SearchAlbums :many SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at FROM albums WHERE title ILIKE '%' || $1 || '%' diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index 0ccf9a66..ee0e45dc 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -11,6 +11,151 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many +SELECT a.id, a.name, a.sort_name, a.mbid, a.created_at, a.updated_at, + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count, + max_started.started_at::timestamptz AS last_played_at +FROM artists a +JOIN LATERAL ( + SELECT max(pe.started_at) AS started_at + FROM play_events pe + JOIN tracks t ON t.id = pe.track_id + WHERE pe.user_id = $1 AND t.artist_id = a.id +) max_started ON max_started.started_at IS NOT NULL +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = a.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = a.id +) cnt ON true +ORDER BY max_started.started_at DESC, a.id +LIMIT $2 +` + +type ListLastPlayedArtistsForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListLastPlayedArtistsForUserRow struct { + Artist Artist + CoverAlbumID pgtype.UUID + AlbumCount int64 + LastPlayedAt pgtype.Timestamptz +} + +// M6a: artists ranked by max(play_events.started_at) for the user, with +// a derived cover_album_id via a representative-album lateral join (most +// recent album that has cover_art_path set). album_count joined for the +// ArtistRef wire shape. +func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLastPlayedArtistsForUserParams) ([]ListLastPlayedArtistsForUserRow, error) { + rows, err := q.db.Query(ctx, listLastPlayedArtistsForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListLastPlayedArtistsForUserRow + for rows.Next() { + var i ListLastPlayedArtistsForUserRow + if err := rows.Scan( + &i.Artist.ID, + &i.Artist.Name, + &i.Artist.SortName, + &i.Artist.Mbid, + &i.Artist.CreatedAt, + &i.Artist.UpdatedAt, + &i.CoverAlbumID, + &i.AlbumCount, + &i.LastPlayedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listMostPlayedTracksForUser = `-- name: ListMostPlayedTracksForUser :many +SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, + albums.title AS album_title, + artists.name AS artist_name +FROM tracks t +JOIN albums ON albums.id = t.album_id +JOIN artists ON artists.id = t.artist_id +JOIN play_events pe ON pe.track_id = t.id +WHERE pe.user_id = $1 + AND pe.was_skipped = false + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) +GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path, + t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number, + t.mbid, t.genre, t.added_at, t.updated_at, + albums.title, artists.name +ORDER BY count(*) DESC, t.id +LIMIT $2 +` + +type ListMostPlayedTracksForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListMostPlayedTracksForUserRow struct { + Track Track + AlbumTitle string + ArtistName string +} + +// M6a: top-N tracks by completed-play count for the user. was_skipped +// excludes skips so a user spamming next doesn't fabricate a top track. +// Quarantined tracks (per-user soft-hide from M5b) are filtered out. +func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostPlayedTracksForUserParams) ([]ListMostPlayedTracksForUserRow, error) { + rows, err := q.db.Query(ctx, listMostPlayedTracksForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListMostPlayedTracksForUserRow + for rows.Next() { + var i ListMostPlayedTracksForUserRow + if err := rows.Scan( + &i.Track.ID, + &i.Track.Title, + &i.Track.AlbumID, + &i.Track.ArtistID, + &i.Track.TrackNumber, + &i.Track.DiscNumber, + &i.Track.DurationMs, + &i.Track.FilePath, + &i.Track.FileSize, + &i.Track.FileFormat, + &i.Track.Bitrate, + &i.Track.Mbid, + &i.Track.Genre, + &i.Track.AddedAt, + &i.Track.UpdatedAt, + &i.AlbumTitle, + &i.ArtistName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const loadRadioCandidates = `-- name: LoadRadioCandidates :many SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, diff --git a/internal/db/queries/albums.sql b/internal/db/queries/albums.sql index 63b8ec6f..e56acaa7 100644 --- a/internal/db/queries/albums.sql +++ b/internal/db/queries/albums.sql @@ -56,3 +56,14 @@ LIMIT $2 OFFSET $3; -- name: CountAlbumsMatching :one SELECT COUNT(*) FROM albums WHERE title ILIKE '%' || $1::text || '%'; + +-- name: ListRecentlyAddedAlbumsWithArtist :many +-- M6a: recently-added albums joined with artist_name + artist_id for the +-- home-page section. created_at is the row-insert timestamp from the +-- scanner — the closest proxy to "added to library". Stable secondary +-- ordering by id keeps pagination tie-break deterministic. +SELECT sqlc.embed(albums), artists.name AS artist_name +FROM albums +JOIN artists ON artists.id = albums.artist_id +ORDER BY albums.created_at DESC, albums.id +LIMIT $1; diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index af4f525c..a236bf42 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -201,3 +201,55 @@ FROM contributions GROUP BY candidate_mbid, candidate_name ORDER BY total_score DESC LIMIT $3; + +-- name: ListMostPlayedTracksForUser :many +-- M6a: top-N tracks by completed-play count for the user. was_skipped +-- excludes skips so a user spamming next doesn't fabricate a top track. +-- Quarantined tracks (per-user soft-hide from M5b) are filtered out. +SELECT sqlc.embed(t), + albums.title AS album_title, + artists.name AS artist_name +FROM tracks t +JOIN albums ON albums.id = t.album_id +JOIN artists ON artists.id = t.artist_id +JOIN play_events pe ON pe.track_id = t.id +WHERE pe.user_id = $1 + AND pe.was_skipped = false + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) +GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path, + t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number, + t.mbid, t.genre, t.added_at, t.updated_at, + albums.title, artists.name +ORDER BY count(*) DESC, t.id +LIMIT $2; + +-- name: ListLastPlayedArtistsForUser :many +-- M6a: artists ranked by max(play_events.started_at) for the user, with +-- a derived cover_album_id via a representative-album lateral join (most +-- recent album that has cover_art_path set). album_count joined for the +-- ArtistRef wire shape. +SELECT sqlc.embed(a), + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count, + max_started.started_at::timestamptz AS last_played_at +FROM artists a +JOIN LATERAL ( + SELECT max(pe.started_at) AS started_at + FROM play_events pe + JOIN tracks t ON t.id = pe.track_id + WHERE pe.user_id = $1 AND t.artist_id = a.id +) max_started ON max_started.started_at IS NOT NULL +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = a.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = a.id +) cnt ON true +ORDER BY max_started.started_at DESC, a.id +LIMIT $2; From 5ed3c20b74622a9982b400a1b140d28b4b3740dd Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 09:00:20 -0400 Subject: [PATCH 004/351] feat(db): add M6a rediscover queries (albums + artists, with fallbacks) Co-Authored-By: Claude Sonnet 4.6 --- internal/db/dbq/recommendation.sql.go | 258 +++++++++++++++++++++++++ internal/db/queries/recommendation.sql | 92 +++++++++ 2 files changed, 350 insertions(+) diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index ee0e45dc..a1ca1056 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -156,6 +156,264 @@ func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostP return items, nil } +const listRediscoverAlbumsFallbackForUser = `-- name: ListRediscoverAlbumsFallbackForUser :many +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, artists.name AS artist_name +FROM general_likes_albums gla +JOIN albums ON albums.id = gla.album_id +JOIN artists ON artists.id = albums.artist_id +WHERE gla.user_id = $1 +ORDER BY random() +LIMIT $2 +` + +type ListRediscoverAlbumsFallbackForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListRediscoverAlbumsFallbackForUserRow struct { + Album Album + ArtistName string +} + +// M6a: random sample of liked albums for the user. The Go service uses +// this to top up the rediscover row when ListRediscoverAlbumsForUser +// returns fewer than `limit` rows. +func (q *Queries) ListRediscoverAlbumsFallbackForUser(ctx context.Context, arg ListRediscoverAlbumsFallbackForUserParams) ([]ListRediscoverAlbumsFallbackForUserRow, error) { + rows, err := q.db.Query(ctx, listRediscoverAlbumsFallbackForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListRediscoverAlbumsFallbackForUserRow + for rows.Next() { + var i ListRediscoverAlbumsFallbackForUserRow + if err := rows.Scan( + &i.Album.ID, + &i.Album.Title, + &i.Album.SortTitle, + &i.Album.ArtistID, + &i.Album.ReleaseDate, + &i.Album.Mbid, + &i.Album.CoverArtPath, + &i.Album.CreatedAt, + &i.Album.UpdatedAt, + &i.ArtistName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRediscoverAlbumsForUser = `-- name: ListRediscoverAlbumsForUser :many +WITH eligible AS ( + SELECT al.id AS album_id, + gla.liked_at, + max(pe.started_at) AS last_played + FROM general_likes_albums gla + JOIN albums al ON al.id = gla.album_id + LEFT JOIN tracks t ON t.album_id = al.id + LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 + WHERE gla.user_id = $1 + AND gla.liked_at < now() - interval '30 days' + GROUP BY al.id, gla.liked_at + HAVING COALESCE(max(pe.started_at), '1970-01-01'::timestamptz) + < now() - interval '14 days' +) +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, artists.name AS artist_name +FROM eligible e +JOIN albums ON albums.id = e.album_id +JOIN artists ON artists.id = albums.artist_id +ORDER BY (now() - COALESCE(e.last_played, e.liked_at)) DESC, albums.id +LIMIT $2 +` + +type ListRediscoverAlbumsForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListRediscoverAlbumsForUserRow struct { + Album Album + ArtistName string +} + +// M6a: albums liked >30 days ago AND not played in the last 14 days, +// ordered by longest-since-last-play first. The HAVING clause uses an +// epoch sentinel so albums with NO plays count as eligible (their +// "last play" is treated as 1970, which is always >14 days ago). +func (q *Queries) ListRediscoverAlbumsForUser(ctx context.Context, arg ListRediscoverAlbumsForUserParams) ([]ListRediscoverAlbumsForUserRow, error) { + rows, err := q.db.Query(ctx, listRediscoverAlbumsForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListRediscoverAlbumsForUserRow + for rows.Next() { + var i ListRediscoverAlbumsForUserRow + if err := rows.Scan( + &i.Album.ID, + &i.Album.Title, + &i.Album.SortTitle, + &i.Album.ArtistID, + &i.Album.ReleaseDate, + &i.Album.Mbid, + &i.Album.CoverArtPath, + &i.Album.CreatedAt, + &i.Album.UpdatedAt, + &i.ArtistName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRediscoverArtistsFallbackForUser = `-- name: ListRediscoverArtistsFallbackForUser :many +SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM general_likes_artists gla +JOIN artists ON artists.id = gla.artist_id +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = artists.id +) cnt ON true +WHERE gla.user_id = $1 +ORDER BY random() +LIMIT $2 +` + +type ListRediscoverArtistsFallbackForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListRediscoverArtistsFallbackForUserRow struct { + Artist Artist + CoverAlbumID pgtype.UUID + AlbumCount int64 +} + +func (q *Queries) ListRediscoverArtistsFallbackForUser(ctx context.Context, arg ListRediscoverArtistsFallbackForUserParams) ([]ListRediscoverArtistsFallbackForUserRow, error) { + rows, err := q.db.Query(ctx, listRediscoverArtistsFallbackForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListRediscoverArtistsFallbackForUserRow + for rows.Next() { + var i ListRediscoverArtistsFallbackForUserRow + if err := rows.Scan( + &i.Artist.ID, + &i.Artist.Name, + &i.Artist.SortName, + &i.Artist.Mbid, + &i.Artist.CreatedAt, + &i.Artist.UpdatedAt, + &i.CoverAlbumID, + &i.AlbumCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRediscoverArtistsForUser = `-- name: ListRediscoverArtistsForUser :many +WITH eligible AS ( + SELECT a.id AS artist_id, + gla.liked_at, + max(pe.started_at) AS last_played + FROM general_likes_artists gla + JOIN artists a ON a.id = gla.artist_id + LEFT JOIN tracks t ON t.artist_id = a.id + LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 + WHERE gla.user_id = $1 + AND gla.liked_at < now() - interval '30 days' + GROUP BY a.id, gla.liked_at + HAVING COALESCE(max(pe.started_at), '1970-01-01'::timestamptz) + < now() - interval '14 days' +) +SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM eligible e +JOIN artists ON artists.id = e.artist_id +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = artists.id +) cnt ON true +ORDER BY (now() - COALESCE(e.last_played, e.liked_at)) DESC, artists.id +LIMIT $2 +` + +type ListRediscoverArtistsForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListRediscoverArtistsForUserRow struct { + Artist Artist + CoverAlbumID pgtype.UUID + AlbumCount int64 +} + +// M6a: artists liked >30 days ago AND none of their tracks played in the +// last 14 days, ordered by longest-since-last-play. cover_album_id is +// derived from a representative album (LEFT JOIN LATERAL). +func (q *Queries) ListRediscoverArtistsForUser(ctx context.Context, arg ListRediscoverArtistsForUserParams) ([]ListRediscoverArtistsForUserRow, error) { + rows, err := q.db.Query(ctx, listRediscoverArtistsForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListRediscoverArtistsForUserRow + for rows.Next() { + var i ListRediscoverArtistsForUserRow + if err := rows.Scan( + &i.Artist.ID, + &i.Artist.Name, + &i.Artist.SortName, + &i.Artist.Mbid, + &i.Artist.CreatedAt, + &i.Artist.UpdatedAt, + &i.CoverAlbumID, + &i.AlbumCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const loadRadioCandidates = `-- name: LoadRadioCandidates :many SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index a236bf42..34e3cd4e 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -253,3 +253,95 @@ LEFT JOIN LATERAL ( ) cnt ON true ORDER BY max_started.started_at DESC, a.id LIMIT $2; + +-- name: ListRediscoverAlbumsForUser :many +-- M6a: albums liked >30 days ago AND not played in the last 14 days, +-- ordered by longest-since-last-play first. The HAVING clause uses an +-- epoch sentinel so albums with NO plays count as eligible (their +-- "last play" is treated as 1970, which is always >14 days ago). +WITH eligible AS ( + SELECT al.id AS album_id, + gla.liked_at, + max(pe.started_at) AS last_played + FROM general_likes_albums gla + JOIN albums al ON al.id = gla.album_id + LEFT JOIN tracks t ON t.album_id = al.id + LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 + WHERE gla.user_id = $1 + AND gla.liked_at < now() - interval '30 days' + GROUP BY al.id, gla.liked_at + HAVING COALESCE(max(pe.started_at), '1970-01-01'::timestamptz) + < now() - interval '14 days' +) +SELECT sqlc.embed(albums), artists.name AS artist_name +FROM eligible e +JOIN albums ON albums.id = e.album_id +JOIN artists ON artists.id = albums.artist_id +ORDER BY (now() - COALESCE(e.last_played, e.liked_at)) DESC, albums.id +LIMIT $2; + +-- name: ListRediscoverAlbumsFallbackForUser :many +-- M6a: random sample of liked albums for the user. The Go service uses +-- this to top up the rediscover row when ListRediscoverAlbumsForUser +-- returns fewer than `limit` rows. +SELECT sqlc.embed(albums), artists.name AS artist_name +FROM general_likes_albums gla +JOIN albums ON albums.id = gla.album_id +JOIN artists ON artists.id = albums.artist_id +WHERE gla.user_id = $1 +ORDER BY random() +LIMIT $2; + +-- name: ListRediscoverArtistsForUser :many +-- M6a: artists liked >30 days ago AND none of their tracks played in the +-- last 14 days, ordered by longest-since-last-play. cover_album_id is +-- derived from a representative album (LEFT JOIN LATERAL). +WITH eligible AS ( + SELECT a.id AS artist_id, + gla.liked_at, + max(pe.started_at) AS last_played + FROM general_likes_artists gla + JOIN artists a ON a.id = gla.artist_id + LEFT JOIN tracks t ON t.artist_id = a.id + LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 + WHERE gla.user_id = $1 + AND gla.liked_at < now() - interval '30 days' + GROUP BY a.id, gla.liked_at + HAVING COALESCE(max(pe.started_at), '1970-01-01'::timestamptz) + < now() - interval '14 days' +) +SELECT sqlc.embed(artists), + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM eligible e +JOIN artists ON artists.id = e.artist_id +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = artists.id +) cnt ON true +ORDER BY (now() - COALESCE(e.last_played, e.liked_at)) DESC, artists.id +LIMIT $2; + +-- name: ListRediscoverArtistsFallbackForUser :many +SELECT sqlc.embed(artists), + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM general_likes_artists gla +JOIN artists ON artists.id = gla.artist_id +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = artists.id +) cnt ON true +WHERE gla.user_id = $1 +ORDER BY random() +LIMIT $2; From b31723c5e2fc5a44f25ef72ffbc5787d5a8c1382 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 09:03:10 -0400 Subject: [PATCH 005/351] feat(db): add M6a library-list + artist-tracks queries Appends ListArtistsAlphaWithCovers, ListAlbumsAlphaWithArtist, CountAlbums, and ListArtistTracksForUser queries; regenerates sqlc. Co-Authored-By: Claude Sonnet 4.6 --- internal/db/dbq/albums.sql.go | 63 +++++++++++++++++++++++++++++ internal/db/dbq/artists.sql.go | 63 +++++++++++++++++++++++++++++ internal/db/dbq/tracks.sql.go | 70 +++++++++++++++++++++++++++++++++ internal/db/queries/albums.sql | 13 ++++++ internal/db/queries/artists.sql | 22 +++++++++++ internal/db/queries/tracks.sql | 20 ++++++++++ 6 files changed, 251 insertions(+) diff --git a/internal/db/dbq/albums.sql.go b/internal/db/dbq/albums.sql.go index 2065580f..e9c47ef1 100644 --- a/internal/db/dbq/albums.sql.go +++ b/internal/db/dbq/albums.sql.go @@ -11,6 +11,18 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const countAlbums = `-- name: CountAlbums :one +SELECT COUNT(*) FROM albums +` + +// M6a: total album count for the /api/library/albums envelope. +func (q *Queries) CountAlbums(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, countAlbums) + var count int64 + err := row.Scan(&count) + return count, err +} + const countAlbumsMatching = `-- name: CountAlbumsMatching :one SELECT COUNT(*) FROM albums WHERE title ILIKE '%' || $1::text || '%' ` @@ -160,6 +172,57 @@ func (q *Queries) ListAlbumsAlphaByName(ctx context.Context, arg ListAlbumsAlpha return items, nil } +const listAlbumsAlphaWithArtist = `-- name: ListAlbumsAlphaWithArtist :many +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, artists.name AS artist_name +FROM albums +JOIN artists ON artists.id = albums.artist_id +ORDER BY albums.sort_title, albums.id +LIMIT $1 OFFSET $2 +` + +type ListAlbumsAlphaWithArtistParams struct { + Limit int32 + Offset int32 +} + +type ListAlbumsAlphaWithArtistRow struct { + Album Album + ArtistName string +} + +// M6a: alpha-sorted album list joined with artist_name. Used by +// /api/library/albums for the wrapping-grid page. Stable id-tiebreak. +func (q *Queries) ListAlbumsAlphaWithArtist(ctx context.Context, arg ListAlbumsAlphaWithArtistParams) ([]ListAlbumsAlphaWithArtistRow, error) { + rows, err := q.db.Query(ctx, listAlbumsAlphaWithArtist, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAlbumsAlphaWithArtistRow + for rows.Next() { + var i ListAlbumsAlphaWithArtistRow + if err := rows.Scan( + &i.Album.ID, + &i.Album.Title, + &i.Album.SortTitle, + &i.Album.ArtistID, + &i.Album.ReleaseDate, + &i.Album.Mbid, + &i.Album.CoverArtPath, + &i.Album.CreatedAt, + &i.Album.UpdatedAt, + &i.ArtistName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listAlbumsByArtist = `-- name: ListAlbumsByArtist :many SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at FROM albums WHERE artist_id = $1 ORDER BY release_date NULLS LAST, sort_title ` diff --git a/internal/db/dbq/artists.sql.go b/internal/db/dbq/artists.sql.go index 60f059e8..4fcd8172 100644 --- a/internal/db/dbq/artists.sql.go +++ b/internal/db/dbq/artists.sql.go @@ -169,6 +169,69 @@ func (q *Queries) ListArtistsAlpha(ctx context.Context, arg ListArtistsAlphaPara return items, nil } +const listArtistsAlphaWithCovers = `-- name: ListArtistsAlphaWithCovers :many +SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM artists +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = artists.id +) cnt ON true +ORDER BY artists.sort_name, artists.name, artists.id +LIMIT $1 OFFSET $2 +` + +type ListArtistsAlphaWithCoversParams struct { + Limit int32 + Offset int32 +} + +type ListArtistsAlphaWithCoversRow struct { + Artist Artist + CoverAlbumID pgtype.UUID + AlbumCount int64 +} + +// M6a: alpha-sorted artist list with derived cover_album_id (most-recent +// album whose cover_art_path is set). Replaces ListArtistsAlpha for the +// /api/artists?sort=alpha branch — the new column is appended, so the +// alpha branch is purely additive on the wire. album_count is computed +// in the same query to drop the old N+1 in handleListArtists. +func (q *Queries) ListArtistsAlphaWithCovers(ctx context.Context, arg ListArtistsAlphaWithCoversParams) ([]ListArtistsAlphaWithCoversRow, error) { + rows, err := q.db.Query(ctx, listArtistsAlphaWithCovers, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListArtistsAlphaWithCoversRow + for rows.Next() { + var i ListArtistsAlphaWithCoversRow + if err := rows.Scan( + &i.Artist.ID, + &i.Artist.Name, + &i.Artist.SortName, + &i.Artist.Mbid, + &i.Artist.CreatedAt, + &i.Artist.UpdatedAt, + &i.CoverAlbumID, + &i.AlbumCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listArtistsNewest = `-- name: ListArtistsNewest :many SELECT id, name, sort_name, mbid, created_at, updated_at FROM artists ORDER BY created_at DESC, id DESC LIMIT $1 OFFSET $2 ` diff --git a/internal/db/dbq/tracks.sql.go b/internal/db/dbq/tracks.sql.go index 65486ad8..c2440931 100644 --- a/internal/db/dbq/tracks.sql.go +++ b/internal/db/dbq/tracks.sql.go @@ -109,6 +109,76 @@ func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, e return i, err } +const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many +SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, + albums.title AS album_title, + artists.name AS artist_name +FROM tracks t +JOIN albums ON albums.id = t.album_id +JOIN artists ON artists.id = t.artist_id +WHERE t.artist_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $2 AND q.track_id = t.id + ) +ORDER BY albums.release_date NULLS LAST, albums.sort_title, + t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.id +` + +type ListArtistTracksForUserParams struct { + ArtistID pgtype.UUID + UserID pgtype.UUID +} + +type ListArtistTracksForUserRow struct { + Track Track + AlbumTitle string + ArtistName string +} + +// M6a: every track for the artist across their albums, with album_title +// and artist_name joined. Honors per-user lidarr_quarantine. Used by +// /api/artists/{id}/tracks for the artist-card play affordance, which +// shuffles client-side. Ordering matches album/track natural order so +// the shuffle has a deterministic input. +func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTracksForUserParams) ([]ListArtistTracksForUserRow, error) { + rows, err := q.db.Query(ctx, listArtistTracksForUser, arg.ArtistID, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListArtistTracksForUserRow + for rows.Next() { + var i ListArtistTracksForUserRow + if err := rows.Scan( + &i.Track.ID, + &i.Track.Title, + &i.Track.AlbumID, + &i.Track.ArtistID, + &i.Track.TrackNumber, + &i.Track.DiscNumber, + &i.Track.DurationMs, + &i.Track.FilePath, + &i.Track.FileSize, + &i.Track.FileFormat, + &i.Track.Bitrate, + &i.Track.Mbid, + &i.Track.Genre, + &i.Track.AddedAt, + &i.Track.UpdatedAt, + &i.AlbumTitle, + &i.ArtistName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listTracksByAlbum = `-- name: ListTracksByAlbum :many SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE album_id = $1 ORDER BY disc_number NULLS LAST, track_number NULLS LAST ` diff --git a/internal/db/queries/albums.sql b/internal/db/queries/albums.sql index e56acaa7..10f9eb8c 100644 --- a/internal/db/queries/albums.sql +++ b/internal/db/queries/albums.sql @@ -67,3 +67,16 @@ FROM albums JOIN artists ON artists.id = albums.artist_id ORDER BY albums.created_at DESC, albums.id LIMIT $1; + +-- name: ListAlbumsAlphaWithArtist :many +-- M6a: alpha-sorted album list joined with artist_name. Used by +-- /api/library/albums for the wrapping-grid page. Stable id-tiebreak. +SELECT sqlc.embed(albums), artists.name AS artist_name +FROM albums +JOIN artists ON artists.id = albums.artist_id +ORDER BY albums.sort_title, albums.id +LIMIT $1 OFFSET $2; + +-- name: CountAlbums :one +-- M6a: total album count for the /api/library/albums envelope. +SELECT COUNT(*) FROM albums; diff --git a/internal/db/queries/artists.sql b/internal/db/queries/artists.sql index 50ab2985..6d2bf4f9 100644 --- a/internal/db/queries/artists.sql +++ b/internal/db/queries/artists.sql @@ -42,3 +42,25 @@ SELECT COUNT(*) FROM artists WHERE name ILIKE '%' || $1::text || '%'; -- Batched lookup used by M5c suggestion attribution to resolve top-3 -- contributing seed UUIDs back to artist names in one round-trip. SELECT * FROM artists WHERE id = ANY($1::uuid[]); + +-- name: ListArtistsAlphaWithCovers :many +-- M6a: alpha-sorted artist list with derived cover_album_id (most-recent +-- album whose cover_art_path is set). Replaces ListArtistsAlpha for the +-- /api/artists?sort=alpha branch — the new column is appended, so the +-- alpha branch is purely additive on the wire. album_count is computed +-- in the same query to drop the old N+1 in handleListArtists. +SELECT sqlc.embed(artists), + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM artists +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = artists.id +) cnt ON true +ORDER BY artists.sort_name, artists.name, artists.id +LIMIT $1 OFFSET $2; diff --git a/internal/db/queries/tracks.sql b/internal/db/queries/tracks.sql index d9b06e1f..e2bf4251 100644 --- a/internal/db/queries/tracks.sql +++ b/internal/db/queries/tracks.sql @@ -70,3 +70,23 @@ WHERE title ILIKE '%' || $1::text || '%' SELECT 1 FROM lidarr_quarantine q WHERE q.user_id = $2 AND q.track_id = tracks.id ); + +-- name: ListArtistTracksForUser :many +-- M6a: every track for the artist across their albums, with album_title +-- and artist_name joined. Honors per-user lidarr_quarantine. Used by +-- /api/artists/{id}/tracks for the artist-card play affordance, which +-- shuffles client-side. Ordering matches album/track natural order so +-- the shuffle has a deterministic input. +SELECT sqlc.embed(t), + albums.title AS album_title, + artists.name AS artist_name +FROM tracks t +JOIN albums ON albums.id = t.album_id +JOIN artists ON artists.id = t.artist_id +WHERE t.artist_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $2 AND q.track_id = t.id + ) +ORDER BY albums.release_date NULLS LAST, albums.sort_title, + t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.id; From d25b1f92913a3d77dd4a8281078aff869f45fae7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 09:06:43 -0400 Subject: [PATCH 006/351] feat(recommendation): add HomeData composite service for /api/home Runs five SQL queries in parallel (recently added albums, most played tracks, last played artists, rediscover albums, rediscover artists) via goroutines with mutex-guarded first-error capture. Rediscover sections merge primary + fallback results, de-duping by pgtype.UUID map key. All HomePayload slices are non-nil so the API handler encodes [] not null. Co-Authored-By: Claude Sonnet 4.6 --- internal/recommendation/home.go | 195 ++++++++++++++++++ .../recommendation/home_integration_test.go | 130 ++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 internal/recommendation/home.go create mode 100644 internal/recommendation/home_integration_test.go diff --git a/internal/recommendation/home.go b/internal/recommendation/home.go new file mode 100644 index 00000000..4ad696c4 --- /dev/null +++ b/internal/recommendation/home.go @@ -0,0 +1,195 @@ +// home.go is the M6a per-user composite home-page service. Reads five +// sections (recently added, rediscover albums, rediscover artists, most +// played tracks, last played artists) in parallel via goroutines and +// assembles them into a single payload for /api/home. +package recommendation + +import ( + "context" + "fmt" + "sync" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Section size caps. SPA splits these into rows on the page. +const ( + HomeRecentlyAddedLimit = 50 + HomeRediscoverLimit = 25 + HomeMostPlayedLimit = 75 + HomeLastPlayedLimit = 25 +) + +// HomePayload is the composite returned by HomeData. All slices are +// non-nil at return time so the API handler can encode `[]` rather than +// `null` for empty sections. +type HomePayload struct { + RecentlyAddedAlbums []dbq.ListRecentlyAddedAlbumsWithArtistRow + RediscoverAlbums []dbq.ListRediscoverAlbumsForUserRow + RediscoverArtists []dbq.ListRediscoverArtistsForUserRow + MostPlayedTracks []dbq.ListMostPlayedTracksForUserRow + LastPlayedArtists []dbq.ListLastPlayedArtistsForUserRow +} + +// HomeData runs five queries in parallel and assembles the payload. +// Rediscover sections fall back to a random sample of liked entities +// when the eligibility query returns fewer than HomeRediscoverLimit rows. +// Any single query error fails the whole call (predictable empty-or-full +// UX for the SPA — partial payloads are not a feature). +func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*HomePayload, error) { + q := dbq.New(pool) + + var ( + wg sync.WaitGroup + mu sync.Mutex + firstErr error + ) + fail := func(stage string, err error) { + mu.Lock() + defer mu.Unlock() + if firstErr == nil { + firstErr = fmt.Errorf("home: %s: %w", stage, err) + } + } + + out := &HomePayload{ + RecentlyAddedAlbums: []dbq.ListRecentlyAddedAlbumsWithArtistRow{}, + RediscoverAlbums: []dbq.ListRediscoverAlbumsForUserRow{}, + RediscoverArtists: []dbq.ListRediscoverArtistsForUserRow{}, + MostPlayedTracks: []dbq.ListMostPlayedTracksForUserRow{}, + LastPlayedArtists: []dbq.ListLastPlayedArtistsForUserRow{}, + } + + wg.Add(5) + go func() { + defer wg.Done() + rows, err := q.ListRecentlyAddedAlbumsWithArtist(ctx, HomeRecentlyAddedLimit) + if err != nil { + fail("recently_added", err) + return + } + out.RecentlyAddedAlbums = rows + }() + go func() { + defer wg.Done() + rows, err := q.ListMostPlayedTracksForUser(ctx, dbq.ListMostPlayedTracksForUserParams{ + UserID: userID, Limit: HomeMostPlayedLimit, + }) + if err != nil { + fail("most_played", err) + return + } + out.MostPlayedTracks = rows + }() + go func() { + defer wg.Done() + rows, err := q.ListLastPlayedArtistsForUser(ctx, dbq.ListLastPlayedArtistsForUserParams{ + UserID: userID, Limit: HomeLastPlayedLimit, + }) + if err != nil { + fail("last_played", err) + return + } + out.LastPlayedArtists = rows + }() + go func() { + defer wg.Done() + rows, err := loadRediscoverAlbums(ctx, q, userID) + if err != nil { + fail("rediscover_albums", err) + return + } + out.RediscoverAlbums = rows + }() + go func() { + defer wg.Done() + rows, err := loadRediscoverArtists(ctx, q, userID) + if err != nil { + fail("rediscover_artists", err) + return + } + out.RediscoverArtists = rows + }() + wg.Wait() + + if firstErr != nil { + return nil, firstErr + } + return out, nil +} + +func loadRediscoverAlbums(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]dbq.ListRediscoverAlbumsForUserRow, error) { + primary, err := q.ListRediscoverAlbumsForUser(ctx, dbq.ListRediscoverAlbumsForUserParams{ + UserID: userID, Limit: HomeRediscoverLimit, + }) + if err != nil { + return nil, err + } + if len(primary) >= HomeRediscoverLimit { + return primary, nil + } + fallback, err := q.ListRediscoverAlbumsFallbackForUser(ctx, dbq.ListRediscoverAlbumsFallbackForUserParams{ + UserID: userID, Limit: HomeRediscoverLimit, + }) + if err != nil { + return nil, err + } + seen := make(map[pgtype.UUID]struct{}, len(primary)) + for _, r := range primary { + seen[r.Album.ID] = struct{}{} + } + for _, r := range fallback { + if _, dup := seen[r.Album.ID]; dup { + continue + } + primary = append(primary, dbq.ListRediscoverAlbumsForUserRow{ + Album: r.Album, + ArtistName: r.ArtistName, + }) + if len(primary) >= HomeRediscoverLimit { + break + } + seen[r.Album.ID] = struct{}{} + } + return primary, nil +} + +func loadRediscoverArtists(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]dbq.ListRediscoverArtistsForUserRow, error) { + primary, err := q.ListRediscoverArtistsForUser(ctx, dbq.ListRediscoverArtistsForUserParams{ + UserID: userID, Limit: HomeRediscoverLimit, + }) + if err != nil { + return nil, err + } + if len(primary) >= HomeRediscoverLimit { + return primary, nil + } + fallback, err := q.ListRediscoverArtistsFallbackForUser(ctx, dbq.ListRediscoverArtistsFallbackForUserParams{ + UserID: userID, Limit: HomeRediscoverLimit, + }) + if err != nil { + return nil, err + } + seen := make(map[pgtype.UUID]struct{}, len(primary)) + for _, r := range primary { + seen[r.Artist.ID] = struct{}{} + } + for _, r := range fallback { + if _, dup := seen[r.Artist.ID]; dup { + continue + } + primary = append(primary, dbq.ListRediscoverArtistsForUserRow{ + Artist: r.Artist, + CoverAlbumID: r.CoverAlbumID, + AlbumCount: r.AlbumCount, + }) + if len(primary) >= HomeRediscoverLimit { + break + } + seen[r.Artist.ID] = struct{}{} + } + return primary, nil +} diff --git a/internal/recommendation/home_integration_test.go b/internal/recommendation/home_integration_test.go new file mode 100644 index 00000000..d2de02d5 --- /dev/null +++ b/internal/recommendation/home_integration_test.go @@ -0,0 +1,130 @@ +package recommendation + +import ( + "context" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" +) + +func TestHomeData_NewUser_AllSectionsEmpty(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "home-newuser") + + got, err := HomeData(context.Background(), pool, user.ID) + if err != nil { + t.Fatalf("HomeData: %v", err) + } + if got == nil { + t.Fatal("HomeData returned nil payload") + } + if len(got.RecentlyAddedAlbums) != 0 { + t.Errorf("RecentlyAddedAlbums len = %d, want 0", len(got.RecentlyAddedAlbums)) + } + if len(got.RediscoverAlbums) != 0 { + t.Errorf("RediscoverAlbums len = %d, want 0", len(got.RediscoverAlbums)) + } + if len(got.RediscoverArtists) != 0 { + t.Errorf("RediscoverArtists len = %d, want 0", len(got.RediscoverArtists)) + } + if len(got.MostPlayedTracks) != 0 { + t.Errorf("MostPlayedTracks len = %d, want 0", len(got.MostPlayedTracks)) + } + if len(got.LastPlayedArtists) != 0 { + t.Errorf("LastPlayedArtists len = %d, want 0", len(got.LastPlayedArtists)) + } +} + +// Compile-time assert that pgtype.UUID is a valid map key — used by the +// service when de-duping rediscover fallback rows. If this changes, the +// service needs to switch to keying by string. +var _ = func() pgtype.UUID { return pgtype.UUID{} } + +func TestHomeData_RecentlyAdded_OrderedByCreatedAt(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "home-recents") + a1 := seedArtist(t, pool, "ArtistA", "") + // Three albums, oldest first — created_at increases naturally with each insert. + al1 := seedAlbumForArtist(t, pool, a1.ID, "Older") + al2 := seedAlbumForArtist(t, pool, a1.ID, "Middle") + al3 := seedAlbumForArtist(t, pool, a1.ID, "Newest") + + got, err := HomeData(context.Background(), pool, user.ID) + if err != nil { + t.Fatalf("HomeData: %v", err) + } + if len(got.RecentlyAddedAlbums) != 3 { + t.Fatalf("len = %d, want 3", len(got.RecentlyAddedAlbums)) + } + if got.RecentlyAddedAlbums[0].Album.ID != al3.ID || + got.RecentlyAddedAlbums[1].Album.ID != al2.ID || + got.RecentlyAddedAlbums[2].Album.ID != al1.ID { + t.Errorf("order = [%s, %s, %s], want newest→oldest", + got.RecentlyAddedAlbums[0].Album.Title, + got.RecentlyAddedAlbums[1].Album.Title, + got.RecentlyAddedAlbums[2].Album.Title) + } +} + +func TestHomeData_MostPlayed_RanksByCount_HonorsQuarantine(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "home-mostplayed") + artist := seedArtist(t, pool, "ArtistB", "") + album := seedAlbumForArtist(t, pool, artist.ID, "AlbumB") + tHigh := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "High") + tLow := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "Low") + tQuar := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "Quarantined") + now := time.Now() + for i := 0; i < 3; i++ { + insertPlayEvent(t, pool, user.ID, tHigh.ID, now.Add(-time.Duration(i)*time.Minute)) + } + insertPlayEvent(t, pool, user.ID, tLow.ID, now) + for i := 0; i < 5; i++ { + insertPlayEvent(t, pool, user.ID, tQuar.ID, now) + } + if _, err := pool.Exec(context.Background(), + `INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`, + user.ID, tQuar.ID, + ); err != nil { + t.Fatalf("insert quarantine: %v", err) + } + + got, err := HomeData(context.Background(), pool, user.ID) + if err != nil { + t.Fatalf("HomeData: %v", err) + } + if len(got.MostPlayedTracks) != 2 { + t.Fatalf("len = %d, want 2 (quarantined excluded)", len(got.MostPlayedTracks)) + } + if got.MostPlayedTracks[0].Track.ID != tHigh.ID || got.MostPlayedTracks[1].Track.ID != tLow.ID { + t.Errorf("ranking wrong: got [%s, %s], want [High, Low]", + got.MostPlayedTracks[0].Track.Title, + got.MostPlayedTracks[1].Track.Title) + } +} + +func TestHomeData_RediscoverAlbums_FallbackWhenSparse(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "home-rediscover") + artist := seedArtist(t, pool, "ArtistC", "") + // Like one album NOW (won't satisfy >30d eligibility) — should still + // surface via fallback because primary returns 0 rows. + al := seedAlbumForArtist(t, pool, artist.ID, "RecentLike") + if _, err := pool.Exec(context.Background(), + `INSERT INTO general_likes_albums (user_id, album_id, liked_at) VALUES ($1, $2, now())`, + user.ID, al.ID, + ); err != nil { + t.Fatalf("insert like: %v", err) + } + got, err := HomeData(context.Background(), pool, user.ID) + if err != nil { + t.Fatalf("HomeData: %v", err) + } + if len(got.RediscoverAlbums) != 1 { + t.Fatalf("len = %d, want 1 (from fallback)", len(got.RediscoverAlbums)) + } + if got.RediscoverAlbums[0].Album.ID != al.ID { + t.Errorf("got %s, want %s", got.RediscoverAlbums[0].Album.Title, al.Title) + } +} From 302a15a209582ed2b1eda057dddb41b12e349916 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 17:54:40 -0400 Subject: [PATCH 007/351] test(recommendation): fix mislabeled compile-time assert in home tests Replaces var _ = func() pgtype.UUID { ... } with var _ map[pgtype.UUID]struct{} which actually verifies map-key comparability, matching the comment. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/recommendation/home_integration_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/recommendation/home_integration_test.go b/internal/recommendation/home_integration_test.go index d2de02d5..219dadfe 100644 --- a/internal/recommendation/home_integration_test.go +++ b/internal/recommendation/home_integration_test.go @@ -39,7 +39,7 @@ func TestHomeData_NewUser_AllSectionsEmpty(t *testing.T) { // Compile-time assert that pgtype.UUID is a valid map key — used by the // service when de-duping rediscover fallback rows. If this changes, the // service needs to switch to keying by string. -var _ = func() pgtype.UUID { return pgtype.UUID{} } +var _ map[pgtype.UUID]struct{} func TestHomeData_RecentlyAdded_OrderedByCreatedAt(t *testing.T) { pool := newPool(t) From 1abaf150517d0aa7fbb2190656c5165cc5bae488 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 17:56:19 -0400 Subject: [PATCH 008/351] feat(api): extend ArtistRef/AlbumRef + add HomePayload for M6a - ArtistRef gains SortName and CoverURL fields - AlbumRef gains SortTitle field - albumRefFrom now populates SortTitle from dbq.Album.SortTitle - artistRefFrom now populates SortName from dbq.Artist.SortName - New artistRefFromCovered helper builds CoverURL from nullable cover_album_id - New HomePayload view type for GET /api/home response body --- internal/api/convert.go | 20 ++++++++++++++++++-- internal/api/types.go | 24 ++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/internal/api/convert.go b/internal/api/convert.go index 89421420..88511003 100644 --- a/internal/api/convert.go +++ b/internal/api/convert.go @@ -75,16 +75,31 @@ func streamURL(trackID pgtype.UUID) string { return "/api/tracks/" + uuidToString(trackID) + "/stream" } -// artistRefFrom projects a dbq.Artist into an ArtistRef. albumCount must be -// pre-computed by the caller (one query per artist in lists). +// artistRefFrom projects a dbq.Artist into an ArtistRef without cover. +// albumCount must be pre-computed by the caller. Used by code paths that +// don't have a representative-album lookup at hand (artist detail, search, +// liked-artists list). func artistRefFrom(a dbq.Artist, albumCount int) ArtistRef { return ArtistRef{ ID: uuidToString(a.ID), Name: a.Name, + SortName: a.SortName, AlbumCount: albumCount, } } +// artistRefFromCovered projects a dbq.Artist into an ArtistRef with the +// derived CoverURL populated from a representative album id (nullable). +// Used by /api/home and /api/artists?sort=alpha which carry the lookup +// in the same query. +func artistRefFromCovered(a dbq.Artist, albumCount int, coverAlbumID pgtype.UUID) ArtistRef { + ref := artistRefFrom(a, albumCount) + if coverAlbumID.Valid { + ref.CoverURL = coverURL(coverAlbumID) + } + return ref +} + // albumRefFrom projects a dbq.Album into an AlbumRef. artistName must be // pre-resolved because Album only carries artist_id. trackCount and // durationSec may be 0 when the caller does not compute them. @@ -92,6 +107,7 @@ func albumRefFrom(a dbq.Album, artistName string, trackCount, durationSec int) A return AlbumRef{ ID: uuidToString(a.ID), Title: a.Title, + SortTitle: a.SortTitle, ArtistID: uuidToString(a.ArtistID), ArtistName: artistName, Year: yearFromDate(a.ReleaseDate), diff --git a/internal/api/types.go b/internal/api/types.go index 44444203..3e4e4f4a 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -36,18 +36,26 @@ type Page[T any] struct { } // ArtistRef is the lightweight artist shape used in lists and search results. +// SortName is exposed so the SPA's alphabetical-grid divider can group rows +// by the catalogue sort order ("The Beatles" → "B"). CoverURL is derived from +// a representative album (most-recent with cover_art_path); empty when the +// artist's discography has no cover art. type ArtistRef struct { ID string `json:"id"` Name string `json:"name"` + SortName string `json:"sort_name"` AlbumCount int `json:"album_count"` + CoverURL string `json:"cover_url"` } // AlbumRef is the lightweight album shape used in lists, artist details, -// and search results. CoverURL points to /api/albums/{id}/cover which -// Plan 3 implements. +// and search results. SortTitle is exposed so the SPA's alphabetical-grid +// divider can group rows by sort order ("The Wall" → "W"). CoverURL points +// to /api/albums/{id}/cover. type AlbumRef struct { ID string `json:"id"` Title string `json:"title"` + SortTitle string `json:"sort_title"` ArtistID string `json:"artist_id"` ArtistName string `json:"artist_name"` Year int `json:"year,omitempty"` @@ -92,3 +100,15 @@ type SearchResponse struct { Albums Page[AlbumRef] `json:"albums"` Tracks Page[TrackRef] `json:"tracks"` } + +// HomePayload is the response body of GET /api/home. Field counts: +// 50 (recently_added) / 25 (rediscover_albums) / 25 (rediscover_artists) +// / 75 (most_played) / 25 (last_played). All slices are non-nil at JSON +// encode time so empty sections render as [] rather than null. +type HomePayload struct { + RecentlyAddedAlbums []AlbumRef `json:"recently_added_albums"` + RediscoverAlbums []AlbumRef `json:"rediscover_albums"` + RediscoverArtists []ArtistRef `json:"rediscover_artists"` + MostPlayedTracks []TrackRef `json:"most_played_tracks"` + LastPlayedArtists []ArtistRef `json:"last_played_artists"` +} From 00804cb9744710ea4ee0f18df9a472763ee5fef8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 17:58:59 -0400 Subject: [PATCH 009/351] feat(api): add GET /api/home composite handler Wires recommendation.HomeData into handleGetHome; maps dbq row types to the five HomePayload wire-type slices (AlbumRef/ArtistRef/TrackRef). All slices are non-nil so the SPA never receives JSON null for empty sections. Co-Authored-By: Claude Sonnet 4.6 --- internal/api/home.go | 55 +++++++++++++++++++++++++++ internal/api/home_test.go | 79 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 internal/api/home.go create mode 100644 internal/api/home_test.go diff --git a/internal/api/home.go b/internal/api/home.go new file mode 100644 index 00000000..d1284e2a --- /dev/null +++ b/internal/api/home.go @@ -0,0 +1,55 @@ +package api + +import ( + "net/http" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" +) + +// handleGetHome implements GET /api/home. Returns five sections in one +// payload; sections that have no rows render as []. 500 on any underlying +// DB failure — the SPA should render either empty states or full content, +// not a half-broken page. +func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + data, err := recommendation.HomeData(r.Context(), h.pool, user.ID) + if err != nil { + h.logger.Error("api: home data", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "failed to load home") + return + } + + out := HomePayload{ + RecentlyAddedAlbums: make([]AlbumRef, 0, len(data.RecentlyAddedAlbums)), + RediscoverAlbums: make([]AlbumRef, 0, len(data.RediscoverAlbums)), + RediscoverArtists: make([]ArtistRef, 0, len(data.RediscoverArtists)), + MostPlayedTracks: make([]TrackRef, 0, len(data.MostPlayedTracks)), + LastPlayedArtists: make([]ArtistRef, 0, len(data.LastPlayedArtists)), + } + for _, row := range data.RecentlyAddedAlbums { + out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0)) + } + for _, row := range data.RediscoverAlbums { + out.RediscoverAlbums = append(out.RediscoverAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0)) + } + for _, row := range data.RediscoverArtists { + out.RediscoverArtists = append(out.RediscoverArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID)) + } + for _, row := range data.MostPlayedTracks { + out.MostPlayedTracks = append(out.MostPlayedTracks, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName)) + } + for _, row := range data.LastPlayedArtists { + out.LastPlayedArtists = append(out.LastPlayedArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID)) + } + writeJSON(w, http.StatusOK, out) +} + +// Compile-time guard that dbq has the row types we need; if the SQL is +// regenerated with a different name, this fails fast at build time. +var _ = dbq.ListMostPlayedTracksForUserRow{}.Track diff --git a/internal/api/home_test.go b/internal/api/home_test.go new file mode 100644 index 00000000..2f953f9b --- /dev/null +++ b/internal/api/home_test.go @@ -0,0 +1,79 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func newHomeRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Get("/api/home", h.handleGetHome) + return r +} + +func doGetHome(h *handlers, user dbq.User) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/api/home", nil) + req = withUser(req, user) + w := httptest.NewRecorder() + newHomeRouter(h).ServeHTTP(w, req) + return w +} + +func TestHome_EmptyForNewUser(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + + w := doGetHome(h, user) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var got HomePayload + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + // All slices must be non-nil so the SPA branches consistently on + // length rather than dealing with null. + if got.RecentlyAddedAlbums == nil || got.RediscoverAlbums == nil || + got.RediscoverArtists == nil || got.MostPlayedTracks == nil || + got.LastPlayedArtists == nil { + t.Errorf("payload has nil slice: %+v", got) + } + if len(got.RecentlyAddedAlbums) != 0 { + t.Errorf("RecentlyAddedAlbums len = %d, want 0", len(got.RecentlyAddedAlbums)) + } +} + +func TestHome_RecentlyAddedSurfacesAlbums(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "ArtistOne", SortName: "ArtistOne", + }) + _, _ = q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Newest", SortTitle: "Newest", ArtistID: artist.ID, + }) + + w := doGetHome(h, user) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var got HomePayload + _ = json.Unmarshal(w.Body.Bytes(), &got) + if len(got.RecentlyAddedAlbums) != 1 { + t.Fatalf("len = %d, want 1", len(got.RecentlyAddedAlbums)) + } + if got.RecentlyAddedAlbums[0].Title != "Newest" { + t.Errorf("title = %q, want Newest", got.RecentlyAddedAlbums[0].Title) + } + if got.RecentlyAddedAlbums[0].ArtistName != "ArtistOne" { + t.Errorf("artist_name = %q, want ArtistOne", got.RecentlyAddedAlbums[0].ArtistName) + } +} From fc12532dee8520a90d2a5f50302608ba23622b30 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 18:00:59 -0400 Subject: [PATCH 010/351] feat(api): add GET /api/library/albums handler Implements the paged alpha-sorted album list endpoint used by the M6a wrapping-grid SPA page. Mirrors handleListArtists alpha branch using ListAlbumsAlphaWithArtist + CountAlbums; 2/2 integration tests pass. Co-Authored-By: Claude Sonnet 4.6 --- internal/api/library_albums.go | 41 +++++++++++++ internal/api/library_albums_test.go | 95 +++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 internal/api/library_albums.go create mode 100644 internal/api/library_albums_test.go diff --git a/internal/api/library_albums.go b/internal/api/library_albums.go new file mode 100644 index 00000000..dcb816e4 --- /dev/null +++ b/internal/api/library_albums.go @@ -0,0 +1,41 @@ +package api + +import ( + "net/http" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// handleListLibraryAlbums implements GET /api/library/albums. Mirrors +// /api/artists?sort=alpha but for albums. The new wrapping-grid page on +// the SPA infinite-scrolls against this endpoint via TanStack +// createInfiniteQuery. +func (h *handlers) handleListLibraryAlbums(w http.ResponseWriter, r *http.Request) { + limit, offset, err := parsePaging(r.URL.Query()) + if err != nil { + writeErr(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + q := dbq.New(h.pool) + rows, err := q.ListAlbumsAlphaWithArtist(r.Context(), dbq.ListAlbumsAlphaWithArtistParams{ + Limit: int32(limit), Offset: int32(offset), + }) + if err != nil { + h.logger.Error("api: list library albums", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + total, err := q.CountAlbums(r.Context()) + if err != nil { + h.logger.Error("api: count albums", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "count failed") + return + } + items := make([]AlbumRef, 0, len(rows)) + for _, row := range rows { + items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0)) + } + writeJSON(w, http.StatusOK, Page[AlbumRef]{ + Items: items, Total: int(total), Limit: limit, Offset: offset, + }) +} diff --git a/internal/api/library_albums_test.go b/internal/api/library_albums_test.go new file mode 100644 index 00000000..95def123 --- /dev/null +++ b/internal/api/library_albums_test.go @@ -0,0 +1,95 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func newLibraryAlbumsRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Get("/api/library/albums", h.handleListLibraryAlbums) + return r +} + +func doListLibraryAlbums(h *handlers, user dbq.User, query string) *httptest.ResponseRecorder { + url := "/api/library/albums" + if query != "" { + url += "?" + query + } + req := httptest.NewRequest(http.MethodGet, url, nil) + req = withUser(req, user) + w := httptest.NewRecorder() + newLibraryAlbumsRouter(h).ServeHTTP(w, req) + return w +} + +func TestLibraryAlbums_AlphaSorted(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "ArtistA", SortName: "ArtistA", + }) + _, _ = q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Zoo", SortTitle: "Zoo", ArtistID: artist.ID, + }) + _, _ = q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Apple", SortTitle: "Apple", ArtistID: artist.ID, + }) + _, _ = q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Mango", SortTitle: "Mango", ArtistID: artist.ID, + }) + + w := doListLibraryAlbums(h, user, "limit=10") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var got Page[AlbumRef] + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Total != 3 { + t.Errorf("total = %d, want 3", got.Total) + } + titles := []string{got.Items[0].Title, got.Items[1].Title, got.Items[2].Title} + if titles[0] != "Apple" || titles[1] != "Mango" || titles[2] != "Zoo" { + t.Errorf("titles = %v, want [Apple Mango Zoo]", titles) + } + if got.Items[0].SortTitle != "Apple" { + t.Errorf("sort_title = %q, want Apple", got.Items[0].SortTitle) + } +} + +func TestLibraryAlbums_Paging(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "ArtistA", SortName: "ArtistA", + }) + for _, title := range []string{"A", "B", "C", "D"} { + _, _ = q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: title, SortTitle: title, ArtistID: artist.ID, + }) + } + + w := doListLibraryAlbums(h, user, "limit=2&offset=2") + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + var got Page[AlbumRef] + _ = json.Unmarshal(w.Body.Bytes(), &got) + if got.Total != 4 || got.Limit != 2 || got.Offset != 2 { + t.Errorf("envelope = %+v, want total=4 limit=2 offset=2", got) + } + if len(got.Items) != 2 || got.Items[0].Title != "C" || got.Items[1].Title != "D" { + t.Errorf("items = %v, want [C D]", got.Items) + } +} From 2aecbba2ef67c96b495218e40965e79c0534b9ec Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 18:06:15 -0400 Subject: [PATCH 011/351] feat(api): add GET /api/artists/{id}/tracks handler Wires ListArtistTracksForUser (Task 3 SQL) to a new handler that returns all quarantine-filtered tracks for an artist as a flat TrackRef list. 404 when the artist doesn't exist. Route registered in Mount(). Co-Authored-By: Claude Sonnet 4.6 --- internal/api/api.go | 1 + internal/api/library.go | 40 ++++++++++++++ internal/api/library_test.go | 102 +++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+) diff --git a/internal/api/api.go b/internal/api/api.go index fa2707f7..5e9a5a03 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -43,6 +43,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Get("/artists", h.handleListArtists) authed.Get("/artists/{id}", h.handleGetArtist) + authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks) authed.Get("/albums/{id}", h.handleGetAlbum) authed.Get("/albums/{id}/cover", h.handleGetCover) authed.Get("/tracks/{id}", h.handleGetTrack) diff --git a/internal/api/library.go b/internal/api/library.go index da7331ac..08ab15ba 100644 --- a/internal/api/library.go +++ b/internal/api/library.go @@ -143,6 +143,46 @@ func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, detail) } +// handleGetArtistTracks implements GET /api/artists/{id}/tracks. Returns +// every track the artist has across albums (per-user-quarantine filtered) +// as a flat list. Used by ArtistCard's play affordance, which shuffles +// client-side. 404 when the artist doesn't exist. +func (h *handlers) handleGetArtistTracks(w http.ResponseWriter, r *http.Request) { + id, ok := parseUUID(chi.URLParam(r, "id")) + if !ok { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid artist id") + return + } + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + q := dbq.New(h.pool) + if _, err := q.GetArtistByID(r.Context(), id); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + writeErr(w, http.StatusNotFound, "not_found", "artist not found") + return + } + h.logger.Error("api: get artist for tracks", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + rows, err := q.ListArtistTracksForUser(r.Context(), dbq.ListArtistTracksForUserParams{ + ArtistID: id, UserID: user.ID, + }) + if err != nil { + h.logger.Error("api: list artist tracks", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + out := make([]TrackRef, 0, len(rows)) + for _, row := range rows { + out = append(out, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName)) + } + writeJSON(w, http.StatusOK, out) +} + // handleListArtists implements GET /api/artists with two sort modes // (alpha|newest) and enveloped pagination. album_count per artist is // computed via an N+1 — acceptable at limit=200. diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 80e6e7ac..50734c6a 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "io" "log/slog" @@ -12,6 +13,7 @@ import ( "github.com/go-chi/chi/v5" "git.fabledsword.com/bvandeusen/minstrel/internal/config" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" ) @@ -465,3 +467,103 @@ func TestRoutesRegisteredInMount(t *testing.T) { } } } + +func TestGetArtistTracks_HappyPath(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "ArtistA", SortName: "ArtistA", + }) + album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "AlbumA", SortTitle: "AlbumA", ArtistID: artist.ID, + }) + _, _ = q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "T1", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: "/tmp/m6a-T1.mp3", + FileSize: 1, FileFormat: "mp3", + }) + _, _ = q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "T2", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: "/tmp/m6a-T2.mp3", + FileSize: 1, FileFormat: "mp3", + }) + + r := chi.NewRouter() + r.Get("/api/artists/{id}/tracks", h.handleGetArtistTracks) + req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID)+"/tracks", nil) + req = withUser(req, user) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var got []TrackRef + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0].ArtistName != "ArtistA" || got[0].AlbumTitle != "AlbumA" { + t.Errorf("got = %+v", got[0]) + } +} + +func TestGetArtistTracks_HonorsQuarantine(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "ArtistB", SortName: "ArtistB", + }) + album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "AlbumB", SortTitle: "AlbumB", ArtistID: artist.ID, + }) + track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "Q", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: "/tmp/m6a-Q.mp3", + FileSize: 1, FileFormat: "mp3", + }) + if _, err := pool.Exec(context.Background(), + `INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`, + user.ID, track.ID, + ); err != nil { + t.Fatalf("quarantine insert: %v", err) + } + + r := chi.NewRouter() + r.Get("/api/artists/{id}/tracks", h.handleGetArtistTracks) + req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID)+"/tracks", nil) + req = withUser(req, user) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + var got []TrackRef + _ = json.Unmarshal(w.Body.Bytes(), &got) + if len(got) != 0 { + t.Errorf("len = %d, want 0 (quarantined)", len(got)) + } +} + +func TestGetArtistTracks_NotFound(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + + r := chi.NewRouter() + r.Get("/api/artists/{id}/tracks", h.handleGetArtistTracks) + // Random UUID — no such artist exists. + req := httptest.NewRequest(http.MethodGet, "/api/artists/00000000-0000-0000-0000-000000000000/tracks", nil) + req = withUser(req, user) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } + _ = pool +} From c5e42ec8c2d88e1b1c128686366cb86e508d409f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 18:10:04 -0400 Subject: [PATCH 012/351] feat(api): wire M6a routes; alpha artist list uses covers query Replace handleListArtists alpha branch with ListArtistsAlphaWithCovers (drops N+1 per-artist album lookup). Register /api/home and /api/library/albums in api.go. Co-Authored-By: Claude Sonnet 4.6 --- internal/api/api.go | 2 + internal/api/library.go | 86 +++++++++++++++++++++++++---------------- 2 files changed, 54 insertions(+), 34 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 5e9a5a03..8b70c5f2 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -46,11 +46,13 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks) authed.Get("/albums/{id}", h.handleGetAlbum) authed.Get("/albums/{id}/cover", h.handleGetCover) + authed.Get("/library/albums", h.handleListLibraryAlbums) authed.Get("/tracks/{id}", h.handleGetTrack) authed.Get("/tracks/{id}/stream", h.handleGetStream) authed.Get("/search", h.handleSearch) authed.Get("/radio", h.handleRadio) authed.Get("/discover/suggestions", h.handleListSuggestions) + authed.Get("/home", h.handleGetHome) authed.Post("/events", h.handleEvents) authed.Post("/likes/tracks/{id}", h.handleLikeTrack) authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack) diff --git a/internal/api/library.go b/internal/api/library.go index 08ab15ba..e57aee78 100644 --- a/internal/api/library.go +++ b/internal/api/library.go @@ -184,8 +184,10 @@ func (h *handlers) handleGetArtistTracks(w http.ResponseWriter, r *http.Request) } // handleListArtists implements GET /api/artists with two sort modes -// (alpha|newest) and enveloped pagination. album_count per artist is -// computed via an N+1 — acceptable at limit=200. +// (alpha|newest) and enveloped pagination. The alpha branch uses +// ListArtistsAlphaWithCovers to ship cover_url + album_count in a +// single query. The newest branch keeps its N+1 album_count lookup +// (less hot path, no covers exposed). func (h *handlers) handleListArtists(w http.ResponseWriter, r *http.Request) { sort := r.URL.Query().Get("sort") if sort == "" { @@ -200,45 +202,61 @@ func (h *handlers) handleListArtists(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusBadRequest, "bad_request", err.Error()) return } - q := dbq.New(h.pool) - var artists []dbq.Artist + switch sort { case "newest": - artists, err = q.ListArtistsNewest(r.Context(), dbq.ListArtistsNewestParams{ + artists, err := q.ListArtistsNewest(r.Context(), dbq.ListArtistsNewestParams{ Limit: int32(limit), Offset: int32(offset), }) - default: // alpha - artists, err = q.ListArtistsAlpha(r.Context(), dbq.ListArtistsAlphaParams{ - Limit: int32(limit), Offset: int32(offset), - }) - } - if err != nil { - h.logger.Error("api: list artists failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - total, err := q.CountArtists(r.Context()) - if err != nil { - h.logger.Error("api: count artists failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "count failed") - return - } - - items := make([]ArtistRef, 0, len(artists)) - for _, a := range artists { - albums, aerr := q.ListAlbumsByArtist(r.Context(), a.ID) - if aerr != nil { - h.logger.Error("api: list albums for artist failed", "err", aerr) + if err != nil { + h.logger.Error("api: list artists newest", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") return } - items = append(items, artistRefFrom(a, len(albums))) + total, terr := q.CountArtists(r.Context()) + if terr != nil { + h.logger.Error("api: count artists", "err", terr) + writeErr(w, http.StatusInternalServerError, "server_error", "count failed") + return + } + items := make([]ArtistRef, 0, len(artists)) + for _, a := range artists { + albums, aerr := q.ListAlbumsByArtist(r.Context(), a.ID) + if aerr != nil { + h.logger.Error("api: list albums for artist", "err", aerr) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + items = append(items, artistRefFrom(a, len(albums))) + } + writeJSON(w, http.StatusOK, Page[ArtistRef]{ + Items: items, Total: int(total), Limit: limit, Offset: offset, + }) + return + + default: // alpha + rows, err := q.ListArtistsAlphaWithCovers(r.Context(), dbq.ListArtistsAlphaWithCoversParams{ + Limit: int32(limit), Offset: int32(offset), + }) + if err != nil { + h.logger.Error("api: list artists alpha", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + total, terr := q.CountArtists(r.Context()) + if terr != nil { + h.logger.Error("api: count artists", "err", terr) + writeErr(w, http.StatusInternalServerError, "server_error", "count failed") + return + } + items := make([]ArtistRef, 0, len(rows)) + for _, row := range rows { + items = append(items, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID)) + } + writeJSON(w, http.StatusOK, Page[ArtistRef]{ + Items: items, Total: int(total), Limit: limit, Offset: offset, + }) + return } - writeJSON(w, http.StatusOK, Page[ArtistRef]{ - Items: items, - Total: int(total), - Limit: limit, - Offset: offset, - }) } From b1f2227d62b26d9abefe1ddd6c6ee58a890d90b3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 20:12:25 -0400 Subject: [PATCH 013/351] feat(web): extend ArtistRef/AlbumRef + add HomePayload + new query keys Adds sort_name/cover_url to ArtistRef, sort_title to AlbumRef (matching backend Task 5 wire shape), HomePayload type, and qk.home/albumsAlpha/ artistTracks query keys. Updates existing test fixtures to satisfy the new required fields. Verified clean via 'npm run check' (0 errors, only pre-existing warnings). Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/lib/api/queries.test.ts | 8 ++++++++ web/src/lib/api/queries.ts | 3 +++ web/src/lib/api/types.ts | 13 +++++++++++++ web/src/lib/components/AlbumCard.test.ts | 1 + web/src/lib/components/ArtistRow.test.ts | 2 +- web/src/routes/albums/[id]/album.test.ts | 4 ++-- web/src/routes/artists.test.ts | 6 +++--- web/src/routes/artists/[id]/artist.test.ts | 5 +++-- web/src/routes/library/liked/liked.test.ts | 4 ++-- web/src/routes/search/albums/albums.test.ts | 2 +- web/src/routes/search/artists/artists.test.ts | 6 +++--- web/src/routes/search/search.test.ts | 4 ++-- 12 files changed, 42 insertions(+), 16 deletions(-) diff --git a/web/src/lib/api/queries.test.ts b/web/src/lib/api/queries.test.ts index 100cec33..e55b68f7 100644 --- a/web/src/lib/api/queries.test.ts +++ b/web/src/lib/api/queries.test.ts @@ -30,3 +30,11 @@ describe('qk search keys', () => { expect(qk.searchTracks('foo')).toEqual(['searchTracks', { q: 'foo' }]); }); }); + +describe('qk M6a keys', () => { + test('M6a query keys are stable', () => { + expect(qk.home()).toEqual(['home']); + expect(qk.albumsAlpha()).toEqual(['albumsAlpha']); + expect(qk.artistTracks('a1')).toEqual(['artistTracks', 'a1']); + }); +}); diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 307a8f90..07d92820 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -34,6 +34,9 @@ export const qk = { ['adminQuarantineActions', { limit: limit ?? 50 }] as const, suggestions: (limit?: number) => ['suggestions', { limit: limit ?? 12 }] as const, + home: () => ['home'] as const, + albumsAlpha: () => ['albumsAlpha'] as const, + artistTracks: (artistId: string) => ['artistTracks', artistId] as const, }; export function createArtistsQuery(sort: ArtistSort) { diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 86674f63..1ee37d68 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -12,12 +12,15 @@ export type Page = { export type ArtistRef = { id: string; name: string; + sort_name: string; album_count: number; + cover_url: string; // "" when no representative album cover }; export type AlbumRef = { id: string; title: string; + sort_title: string; artist_id: string; artist_name: string; year?: number; @@ -236,3 +239,13 @@ export type ArtistSuggestion = { score: number; attribution: SeedContribution[]; // up to 3 entries, ordered by contribution DESC }; + +// Mirrors internal/api/types.go HomePayload. All slices are non-null +// per the server contract — empty sections render as []. +export type HomePayload = { + recently_added_albums: AlbumRef[]; + rediscover_albums: AlbumRef[]; + rediscover_artists: ArtistRef[]; + most_played_tracks: TrackRef[]; + last_played_artists: ArtistRef[]; +}; diff --git a/web/src/lib/components/AlbumCard.test.ts b/web/src/lib/components/AlbumCard.test.ts index 6a95cbd0..9a29dd54 100644 --- a/web/src/lib/components/AlbumCard.test.ts +++ b/web/src/lib/components/AlbumCard.test.ts @@ -33,6 +33,7 @@ import { enqueueTracks } from '$lib/player/store.svelte'; const album: AlbumRef = { id: 'xyz', title: 'Kind of Blue', + sort_title: 'Kind of Blue', artist_id: 'm-davis', artist_name: 'Miles Davis', year: 1959, diff --git a/web/src/lib/components/ArtistRow.test.ts b/web/src/lib/components/ArtistRow.test.ts index 333b78c2..5ff56b58 100644 --- a/web/src/lib/components/ArtistRow.test.ts +++ b/web/src/lib/components/ArtistRow.test.ts @@ -19,7 +19,7 @@ vi.mock('@tanstack/svelte-query', async (orig) => { return { ...actual, useQueryClient: () => ({}) }; }); -const artist: ArtistRef = { id: 'abc', name: 'alice', album_count: 12 }; +const artist: ArtistRef = { id: 'abc', name: 'alice', sort_name: 'alice', album_count: 12, cover_url: '' }; describe('ArtistRow', () => { test('renders initial, name, album count inside a link to /artists/:id', () => { diff --git a/web/src/routes/albums/[id]/album.test.ts b/web/src/routes/albums/[id]/album.test.ts index fa5673b1..fa39ac20 100644 --- a/web/src/routes/albums/[id]/album.test.ts +++ b/web/src/routes/albums/[id]/album.test.ts @@ -56,7 +56,7 @@ afterEach(() => { describe('album detail page', () => { test('renders hero metadata + one TrackRow per track', () => { const detail: AlbumDetail = { - id: 'xyz', title: 'Kind of Blue', + id: 'xyz', title: 'Kind of Blue', sort_title: 'Kind of Blue', artist_id: 'md', artist_name: 'Miles Davis', year: 1959, track_count: 2, duration_sec: 544 + 565, cover_url: '/api/albums/xyz/cover', @@ -83,7 +83,7 @@ describe('album detail page', () => { test('back link points to /artists/:artistId with the artist name', () => { const detail: AlbumDetail = { - id: 'xyz', title: 'T', + id: 'xyz', title: 'T', sort_title: 'T', artist_id: 'md', artist_name: 'Miles Davis', track_count: 0, duration_sec: 0, cover_url: '/api/albums/xyz/cover', diff --git a/web/src/routes/artists.test.ts b/web/src/routes/artists.test.ts index 25d8beb1..2ce6c39c 100644 --- a/web/src/routes/artists.test.ts +++ b/web/src/routes/artists.test.ts @@ -50,8 +50,8 @@ afterEach(() => { describe('artists list page', () => { test('renders a row per artist', () => { - const alice: ArtistRef = { id: 'a', name: 'Alice', album_count: 3 }; - const bob: ArtistRef = { id: 'b', name: 'Bob', album_count: 1 }; + const alice: ArtistRef = { id: 'a', name: 'Alice', sort_name: 'Alice', album_count: 3, cover_url: '' }; + const bob: ArtistRef = { id: 'b', name: 'Bob', sort_name: 'Bob', album_count: 1, cover_url: '' }; (createArtistsQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ pages: [page([alice, bob], 2)] }) ); @@ -84,7 +84,7 @@ describe('artists list page', () => { test('"End of library" when hasNextPage is false and at least one page loaded', () => { (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([{ id: 'a', name: 'A', album_count: 1 }], 1)] }) + mockInfiniteQuery({ pages: [page([{ id: 'a', name: 'A', sort_name: 'A', album_count: 1, cover_url: '' }], 1)] }) ); render(ArtistsPage); expect(screen.getByText(/end of library/i)).toBeInTheDocument(); diff --git a/web/src/routes/artists/[id]/artist.test.ts b/web/src/routes/artists/[id]/artist.test.ts index db4a332c..90b61b80 100644 --- a/web/src/routes/artists/[id]/artist.test.ts +++ b/web/src/routes/artists/[id]/artist.test.ts @@ -40,6 +40,7 @@ import { createArtistQuery } from '$lib/api/queries'; function album(id: string, title: string, year?: number): AlbumRef { return { id, title, + sort_title: title, artist_id: 'abc', artist_name: 'Alice', year, track_count: 10, duration_sec: 2400, cover_url: `/api/albums/${id}/cover` @@ -54,7 +55,7 @@ afterEach(() => { describe('artist detail page', () => { test('renders artist name, subtitle, and one AlbumCard per album', () => { const detail: ArtistDetail = { - id: 'abc', name: 'Alice', album_count: 2, + id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 2, cover_url: '', albums: [album('a1', 'First', 2020), album('a2', 'Second')] }; (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); @@ -67,7 +68,7 @@ describe('artist detail page', () => { test('back link points to Library', () => { (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ - data: { id: 'abc', name: 'Alice', album_count: 0, albums: [] } + data: { id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 0, cover_url: '', albums: [] } })); render(ArtistPage); const back = screen.getByRole('link', { name: /library/i }); diff --git a/web/src/routes/library/liked/liked.test.ts b/web/src/routes/library/liked/liked.test.ts index bcb2d29b..8dfb625e 100644 --- a/web/src/routes/library/liked/liked.test.ts +++ b/web/src/routes/library/liked/liked.test.ts @@ -42,9 +42,9 @@ afterEach(() => vi.clearAllMocks()); describe('liked library page', () => { test('renders three sections when each has items', () => { - const ar: ArtistRef = { id: 'a1', name: 'X', album_count: 1 }; + const ar: ArtistRef = { id: 'a1', name: 'X', sort_name: 'X', album_count: 1, cover_url: '' }; const al: AlbumRef = { - id: 'al1', title: 'Y', artist_id: 'a1', artist_name: 'X', + id: 'al1', title: 'Y', sort_title: 'Y', artist_id: 'a1', artist_name: 'X', year: 2020, track_count: 1, duration_sec: 100, cover_url: '/x' }; const tr: TrackRef = { diff --git a/web/src/routes/search/albums/albums.test.ts b/web/src/routes/search/albums/albums.test.ts index 32eaed58..78a3e6e2 100644 --- a/web/src/routes/search/albums/albums.test.ts +++ b/web/src/routes/search/albums/albums.test.ts @@ -44,7 +44,7 @@ function page(items: T[], total: number, offset = 0, limit = 50): Page { } const album: AlbumRef = { - id: 'al1', title: 'Kind of Blue', artist_id: 'a1', artist_name: 'Miles Davis', + id: 'al1', title: 'Kind of Blue', sort_title: 'Kind of Blue', artist_id: 'a1', artist_name: 'Miles Davis', year: 1959, track_count: 5, duration_sec: 2630, cover_url: '/api/albums/al1/cover' }; diff --git a/web/src/routes/search/artists/artists.test.ts b/web/src/routes/search/artists/artists.test.ts index 0c18d635..19c47e70 100644 --- a/web/src/routes/search/artists/artists.test.ts +++ b/web/src/routes/search/artists/artists.test.ts @@ -43,8 +43,8 @@ afterEach(() => { describe('search artists overflow', () => { test('renders a row per artist', () => { - const a1: ArtistRef = { id: 'a1', name: 'Miles Davis', album_count: 12 }; - const a2: ArtistRef = { id: 'a2', name: 'Miles Mosley', album_count: 1 }; + const a1: ArtistRef = { id: 'a1', name: 'Miles Davis', sort_name: 'Miles Davis', album_count: 12, cover_url: '' }; + const a2: ArtistRef = { id: 'a2', name: 'Miles Mosley', sort_name: 'Miles Mosley', album_count: 1, cover_url: '' }; (createSearchArtistsInfiniteQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ pages: [page([a1, a2], 2)] }) ); @@ -69,7 +69,7 @@ describe('search artists overflow', () => { test('Load more is hidden when hasNextPage is false', () => { (createSearchArtistsInfiniteQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([{ id: 'a', name: 'A', album_count: 1 }], 1)] }) + mockInfiniteQuery({ pages: [page([{ id: 'a', name: 'A', sort_name: 'A', album_count: 1, cover_url: '' }], 1)] }) ); render(ArtistsOverflow); expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); diff --git a/web/src/routes/search/search.test.ts b/web/src/routes/search/search.test.ts index 4c4927b6..18bfd292 100644 --- a/web/src/routes/search/search.test.ts +++ b/web/src/routes/search/search.test.ts @@ -53,9 +53,9 @@ function emptyResp(): SearchResponse { }; } -const artist: ArtistRef = { id: 'a1', name: 'Miles Davis', album_count: 12 }; +const artist: ArtistRef = { id: 'a1', name: 'Miles Davis', sort_name: 'Miles Davis', album_count: 12, cover_url: '' }; const album: AlbumRef = { - id: 'al1', title: 'Kind of Blue', artist_id: 'a1', artist_name: 'Miles Davis', + id: 'al1', title: 'Kind of Blue', sort_title: 'Kind of Blue', artist_id: 'a1', artist_name: 'Miles Davis', year: 1959, track_count: 5, duration_sec: 2630, cover_url: '/api/albums/al1/cover' }; const track: TrackRef = { From e674869e06c47604f5d1abeca4d9633071e8dc6b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 20:13:49 -0400 Subject: [PATCH 014/351] feat(web): add home/albums/artists API clients for M6a Co-Authored-By: Claude Sonnet 4.6 --- web/src/lib/api/albums.test.ts | 33 +++++++++++++++++++++++++++++++++ web/src/lib/api/albums.ts | 22 ++++++++++++++++++++++ web/src/lib/api/artists.test.ts | 23 +++++++++++++++++++++++ web/src/lib/api/artists.ts | 16 ++++++++++++++++ web/src/lib/api/home.test.ts | 32 ++++++++++++++++++++++++++++++++ web/src/lib/api/home.ts | 19 +++++++++++++++++++ 6 files changed, 145 insertions(+) create mode 100644 web/src/lib/api/albums.test.ts create mode 100644 web/src/lib/api/albums.ts create mode 100644 web/src/lib/api/artists.test.ts create mode 100644 web/src/lib/api/artists.ts create mode 100644 web/src/lib/api/home.test.ts create mode 100644 web/src/lib/api/home.ts diff --git a/web/src/lib/api/albums.test.ts b/web/src/lib/api/albums.test.ts new file mode 100644 index 00000000..b5dbef43 --- /dev/null +++ b/web/src/lib/api/albums.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('./client', () => ({ + api: { get: vi.fn() } +})); + +import { listAlbumsAlpha } from './albums'; +import { qk } from './queries'; +import { api } from './client'; + +afterEach(() => vi.clearAllMocks()); + +describe('albums client', () => { + test('listAlbumsAlpha hits /api/library/albums with paging', async () => { + (api.get as ReturnType).mockResolvedValueOnce({ + items: [], total: 0, limit: 50, offset: 0 + }); + await listAlbumsAlpha(50, 0); + expect(api.get).toHaveBeenCalledWith('/api/library/albums?limit=50&offset=0'); + }); + + test('listAlbumsAlpha honors custom limit and offset', async () => { + (api.get as ReturnType).mockResolvedValueOnce({ + items: [], total: 0, limit: 25, offset: 100 + }); + await listAlbumsAlpha(25, 100); + expect(api.get).toHaveBeenCalledWith('/api/library/albums?limit=25&offset=100'); + }); + + test('qk.albumsAlpha key is stable', () => { + expect(qk.albumsAlpha()).toEqual(['albumsAlpha']); + }); +}); diff --git a/web/src/lib/api/albums.ts b/web/src/lib/api/albums.ts new file mode 100644 index 00000000..a40cc8de --- /dev/null +++ b/web/src/lib/api/albums.ts @@ -0,0 +1,22 @@ +import { createInfiniteQuery } from '@tanstack/svelte-query'; +import { api } from './client'; +import { qk } from './queries'; +import type { AlbumRef, Page } from './types'; + +export const ALBUM_PAGE_SIZE = 50; + +export async function listAlbumsAlpha(limit: number, offset: number): Promise> { + return api.get>(`/api/library/albums?limit=${limit}&offset=${offset}`); +} + +export function createAlbumsAlphaInfiniteQuery() { + return createInfiniteQuery({ + queryKey: qk.albumsAlpha(), + queryFn: ({ pageParam = 0 }) => listAlbumsAlpha(ALBUM_PAGE_SIZE, pageParam), + initialPageParam: 0, + getNextPageParam: (last) => { + const loaded = last.offset + last.items.length; + return loaded >= last.total ? undefined : loaded; + } + }); +} diff --git a/web/src/lib/api/artists.test.ts b/web/src/lib/api/artists.test.ts new file mode 100644 index 00000000..a2eba5b0 --- /dev/null +++ b/web/src/lib/api/artists.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('./client', () => ({ + api: { get: vi.fn() } +})); + +import { listArtistTracks } from './artists'; +import { qk } from './queries'; +import { api } from './client'; + +afterEach(() => vi.clearAllMocks()); + +describe('artists client', () => { + test('listArtistTracks hits /api/artists/:id/tracks', async () => { + (api.get as ReturnType).mockResolvedValueOnce([]); + await listArtistTracks('art-1'); + expect(api.get).toHaveBeenCalledWith('/api/artists/art-1/tracks'); + }); + + test('qk.artistTracks key includes the artist id', () => { + expect(qk.artistTracks('art-1')).toEqual(['artistTracks', 'art-1']); + }); +}); diff --git a/web/src/lib/api/artists.ts b/web/src/lib/api/artists.ts new file mode 100644 index 00000000..05b14f4c --- /dev/null +++ b/web/src/lib/api/artists.ts @@ -0,0 +1,16 @@ +import { createQuery } from '@tanstack/svelte-query'; +import { api } from './client'; +import { qk } from './queries'; +import type { TrackRef } from './types'; + +export async function listArtistTracks(artistId: string): Promise { + return api.get(`/api/artists/${artistId}/tracks`); +} + +export function createArtistTracksQuery(artistId: string) { + return createQuery({ + queryKey: qk.artistTracks(artistId), + queryFn: () => listArtistTracks(artistId), + enabled: artistId.length > 0 + }); +} diff --git a/web/src/lib/api/home.test.ts b/web/src/lib/api/home.test.ts new file mode 100644 index 00000000..6bfac118 --- /dev/null +++ b/web/src/lib/api/home.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('./client', () => ({ + api: { get: vi.fn() } +})); + +import { listHome } from './home'; +import { qk } from './queries'; +import { api } from './client'; +import type { HomePayload } from './types'; + +afterEach(() => vi.clearAllMocks()); + +describe('home client', () => { + test('listHome hits /api/home', async () => { + const fixture: HomePayload = { + recently_added_albums: [], + rediscover_albums: [], + rediscover_artists: [], + most_played_tracks: [], + last_played_artists: [] + }; + (api.get as ReturnType).mockResolvedValueOnce(fixture); + const got = await listHome(); + expect(api.get).toHaveBeenCalledWith('/api/home'); + expect(got).toEqual(fixture); + }); + + test('qk.home key is stable', () => { + expect(qk.home()).toEqual(['home']); + }); +}); diff --git a/web/src/lib/api/home.ts b/web/src/lib/api/home.ts new file mode 100644 index 00000000..497422c4 --- /dev/null +++ b/web/src/lib/api/home.ts @@ -0,0 +1,19 @@ +import { createQuery } from '@tanstack/svelte-query'; +import { api } from './client'; +import { qk } from './queries'; +import type { HomePayload } from './types'; + +export async function listHome(): Promise { + return api.get('/api/home'); +} + +// staleTime = 60s — repeat home-page mounts within a minute reuse cached +// data. Like-state changes, new plays, freshly-added albums surface on +// the next refetch. +export function createHomeQuery() { + return createQuery({ + queryKey: qk.home(), + queryFn: listHome, + staleTime: 60_000 + }); +} From 8f5eb210946c14175f91e1bcc24a3499169419ea Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 20:15:10 -0400 Subject: [PATCH 015/351] feat(web): add HorizontalScrollRow component Reusable horizontally-scrolling row with left/right arrow buttons, scroll-boundary disabling, snap scrolling, and hover-reveal arrows. Used by M6a home page (Task 17). Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/HorizontalScrollRow.svelte | 106 ++++++++++++++++++ .../components/HorizontalScrollRow.test.ts | 32 ++++++ 2 files changed, 138 insertions(+) create mode 100644 web/src/lib/components/HorizontalScrollRow.svelte create mode 100644 web/src/lib/components/HorizontalScrollRow.test.ts diff --git a/web/src/lib/components/HorizontalScrollRow.svelte b/web/src/lib/components/HorizontalScrollRow.svelte new file mode 100644 index 00000000..fe953700 --- /dev/null +++ b/web/src/lib/components/HorizontalScrollRow.svelte @@ -0,0 +1,106 @@ + + +
+ + +
+ {#each items as it, i (i)} + {@render item?.(it, i)} + {/each} +
+ + +
+ + diff --git a/web/src/lib/components/HorizontalScrollRow.test.ts b/web/src/lib/components/HorizontalScrollRow.test.ts new file mode 100644 index 00000000..55ee2e9d --- /dev/null +++ b/web/src/lib/components/HorizontalScrollRow.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import HorizontalScrollRow from './HorizontalScrollRow.svelte'; + +afterEach(() => vi.clearAllMocks()); + +describe('HorizontalScrollRow', () => { + test('renders left and right scroll buttons', () => { + render(HorizontalScrollRow, { props: { items: ['a', 'b', 'c'] } }); + expect(screen.getByRole('button', { name: /scroll left/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /scroll right/i })).toBeInTheDocument(); + }); + + test('left button starts disabled (scrollLeft is 0)', () => { + render(HorizontalScrollRow, { props: { items: ['a'] } }); + expect(screen.getByRole('button', { name: /scroll left/i })).toBeDisabled(); + }); + + test('clicking right button calls scrollBy on the container', async () => { + const { container } = render(HorizontalScrollRow, { props: { items: ['a', 'b'] } }); + const scroller = container.querySelector('[data-testid="scroll-container"]') as HTMLElement; + const spy = vi.fn(); + scroller.scrollBy = spy as unknown as HTMLElement['scrollBy']; + Object.defineProperty(scroller, 'clientWidth', { value: 800, configurable: true }); + Object.defineProperty(scroller, 'scrollWidth', { value: 2000, configurable: true }); + Object.defineProperty(scroller, 'scrollLeft', { value: 0, configurable: true, writable: true }); + + await fireEvent.click(screen.getByRole('button', { name: /scroll right/i })); + expect(spy).toHaveBeenCalledWith({ left: expect.any(Number), behavior: 'smooth' }); + expect(spy.mock.calls[0][0].left).toBeGreaterThan(0); + }); +}); From b5741e59b786e80c9ae30f10492ddc7a9ce16781 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 20:16:43 -0400 Subject: [PATCH 016/351] feat(web): polish AlbumCard with FabledSword tokens + play overlay Co-Authored-By: Claude Sonnet 4.6 --- web/src/lib/components/AlbumCard.svelte | 64 +++++++++++++++++++----- web/src/lib/components/AlbumCard.test.ts | 24 ++++++++- 2 files changed, 73 insertions(+), 15 deletions(-) diff --git a/web/src/lib/components/AlbumCard.svelte b/web/src/lib/components/AlbumCard.svelte index 4eeb0aed..a26fb4b8 100644 --- a/web/src/lib/components/AlbumCard.svelte +++ b/web/src/lib/components/AlbumCard.svelte @@ -2,7 +2,8 @@ import type { AlbumRef, AlbumDetail } from '$lib/api/types'; import { FALLBACK_COVER } from '$lib/media/covers'; import { api } from '$lib/api/client'; - import { enqueueTracks } from '$lib/player/store.svelte'; + import { enqueueTracks, playQueue } from '$lib/player/store.svelte'; + import { Plus, Play } from 'lucide-svelte'; import LikeButton from './LikeButton.svelte'; let { album }: { album: AlbumRef } = $props(); @@ -17,32 +18,69 @@ const detail = await api.get(`/api/albums/${album.id}`); enqueueTracks(detail.tracks); } + + async function onPlayClick(e: MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + const detail = await api.get(`/api/albums/${album.id}`); + playQueue(detail.tracks, 0); + } -
+
- -
{album.title}
+
+ + +
+
{album.title}
+ {#if album.artist_name} +
{album.artist_name}
+ {/if} {#if album.year}
{album.year}
{/if}
-
+
+ class="rounded-full bg-surface px-2 py-1 text-sm text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent" + > + +
+ + diff --git a/web/src/lib/components/AlbumCard.test.ts b/web/src/lib/components/AlbumCard.test.ts index 9a29dd54..d85c8926 100644 --- a/web/src/lib/components/AlbumCard.test.ts +++ b/web/src/lib/components/AlbumCard.test.ts @@ -8,7 +8,8 @@ vi.mock('$lib/api/client', () => ({ api: { get: vi.fn() } })); vi.mock('$lib/player/store.svelte', () => ({ - enqueueTracks: vi.fn() + enqueueTracks: vi.fn(), + playQueue: vi.fn() })); vi.mock('$lib/api/likes', () => ({ @@ -28,7 +29,7 @@ vi.mock('@tanstack/svelte-query', async (orig) => { import AlbumCard from './AlbumCard.svelte'; import { api } from '$lib/api/client'; -import { enqueueTracks } from '$lib/player/store.svelte'; +import { enqueueTracks, playQueue } from '$lib/player/store.svelte'; const album: AlbumRef = { id: 'xyz', @@ -89,4 +90,23 @@ describe('AlbumCard', () => { await Promise.resolve(); expect(enqueueTracks).toHaveBeenCalledWith(tracks); }); + + test('play overlay click fetches album detail and calls playQueue at index 0', async () => { + const tracks: TrackRef[] = [ + { id: 't1', title: 'So What', + album_id: 'xyz', album_title: 'Kind of Blue', + artist_id: 'm-davis', artist_name: 'Miles Davis', + track_number: 1, disc_number: 1, duration_sec: 545, + stream_url: '/api/tracks/t1/stream' } + ]; + const detail: AlbumDetail = { ...album, tracks }; + (api.get as ReturnType).mockResolvedValueOnce(detail); + + render(AlbumCard, { props: { album } }); + await fireEvent.click(screen.getByRole('button', { name: /play kind of blue/i })); + expect(api.get).toHaveBeenCalledWith('/api/albums/xyz'); + await Promise.resolve(); + await Promise.resolve(); + expect(playQueue).toHaveBeenCalledWith(tracks, 0); + }); }); From c7e7dd269b3d91e710d795f5fad2c6ae59d39d61 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 20:18:05 -0400 Subject: [PATCH 017/351] feat(web): add ArtistCard (circular) with play-shuffle overlay Co-Authored-By: Claude Sonnet 4.6 --- web/src/lib/components/ArtistCard.svelte | 71 +++++++++++++++++++++++ web/src/lib/components/ArtistCard.test.ts | 65 +++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 web/src/lib/components/ArtistCard.svelte create mode 100644 web/src/lib/components/ArtistCard.test.ts diff --git a/web/src/lib/components/ArtistCard.svelte b/web/src/lib/components/ArtistCard.svelte new file mode 100644 index 00000000..68b32877 --- /dev/null +++ b/web/src/lib/components/ArtistCard.svelte @@ -0,0 +1,71 @@ + + + + + diff --git a/web/src/lib/components/ArtistCard.test.ts b/web/src/lib/components/ArtistCard.test.ts new file mode 100644 index 00000000..f3109592 --- /dev/null +++ b/web/src/lib/components/ArtistCard.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import type { ArtistRef, TrackRef } from '$lib/api/types'; + +vi.mock('$lib/api/client', () => ({ + api: { get: vi.fn() } +})); +vi.mock('$lib/player/store.svelte', () => ({ + playQueue: vi.fn() +})); + +import ArtistCard from './ArtistCard.svelte'; +import { api } from '$lib/api/client'; +import { playQueue } from '$lib/player/store.svelte'; + +const artist: ArtistRef = { + id: 'art-1', + name: 'Boards of Canada', + sort_name: 'Boards of Canada', + album_count: 5, + cover_url: '/api/albums/cov-1/cover' +}; + +afterEach(() => vi.clearAllMocks()); + +describe('ArtistCard', () => { + test('renders cover img inside link to /artists/:id', () => { + const { container } = render(ArtistCard, { props: { artist } }); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/artists/art-1'); + const img = container.querySelector('img') as HTMLImageElement; + expect(img.src).toContain('/api/albums/cov-1/cover'); + }); + + test('falls back to Disc3 glyph when cover_url is empty', () => { + const { container } = render(ArtistCard, { + props: { artist: { ...artist, cover_url: '' } } + }); + expect(container.querySelector('img')).not.toBeInTheDocument(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + test('play overlay fetches tracks, shuffles, calls playQueue', async () => { + const tracks: TrackRef[] = [ + { id: 't1', title: 'A', album_id: 'al-1', album_title: 'X', + artist_id: 'art-1', artist_name: 'BoC', + track_number: 1, disc_number: 1, duration_sec: 1, stream_url: '/x' }, + { id: 't2', title: 'B', album_id: 'al-1', album_title: 'X', + artist_id: 'art-1', artist_name: 'BoC', + track_number: 2, disc_number: 1, duration_sec: 1, stream_url: '/x' } + ]; + (api.get as ReturnType).mockResolvedValueOnce(tracks); + + render(ArtistCard, { props: { artist } }); + await fireEvent.click(screen.getByRole('button', { name: /play boards of canada/i })); + expect(api.get).toHaveBeenCalledWith('/api/artists/art-1/tracks'); + await Promise.resolve(); + await Promise.resolve(); + expect(playQueue).toHaveBeenCalledOnce(); + const [calledTracks, idx] = (playQueue as ReturnType).mock.calls[0]; + expect(calledTracks).toHaveLength(2); + expect(idx).toBe(0); + expect(calledTracks.map((t: TrackRef) => t.id).sort()).toEqual(['t1', 't2']); + }); +}); From 0586483a42e514d035ed6bab062b118e17f915af Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 20:19:16 -0400 Subject: [PATCH 018/351] feat(web): add CompactTrackCard for Most played rows Small 140px clickable card showing album cover, title, and artist name. Clicking calls playQueue(sectionTracks, index) to play through the full section list. Includes Vitest tests for click and render behaviour. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/CompactTrackCard.svelte | 44 +++++++++++++++++++ .../lib/components/CompactTrackCard.test.ts | 42 ++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 web/src/lib/components/CompactTrackCard.svelte create mode 100644 web/src/lib/components/CompactTrackCard.test.ts diff --git a/web/src/lib/components/CompactTrackCard.svelte b/web/src/lib/components/CompactTrackCard.svelte new file mode 100644 index 00000000..436032fe --- /dev/null +++ b/web/src/lib/components/CompactTrackCard.svelte @@ -0,0 +1,44 @@ + + + diff --git a/web/src/lib/components/CompactTrackCard.test.ts b/web/src/lib/components/CompactTrackCard.test.ts new file mode 100644 index 00000000..859671e9 --- /dev/null +++ b/web/src/lib/components/CompactTrackCard.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import type { TrackRef } from '$lib/api/types'; + +vi.mock('$lib/player/store.svelte', () => ({ + playQueue: vi.fn() +})); + +import CompactTrackCard from './CompactTrackCard.svelte'; +import { playQueue } from '$lib/player/store.svelte'; + +const tracks: TrackRef[] = [ + { id: 't1', title: 'First', album_id: 'a1', album_title: 'A', + artist_id: 'art-1', artist_name: 'Artist', + track_number: 1, disc_number: 1, duration_sec: 1, stream_url: '/a' }, + { id: 't2', title: 'Second', album_id: 'a1', album_title: 'A', + artist_id: 'art-1', artist_name: 'Artist', + track_number: 2, disc_number: 1, duration_sec: 1, stream_url: '/b' }, + { id: 't3', title: 'Third', album_id: 'a1', album_title: 'A', + artist_id: 'art-1', artist_name: 'Artist', + track_number: 3, disc_number: 1, duration_sec: 1, stream_url: '/c' } +]; + +afterEach(() => vi.clearAllMocks()); + +describe('CompactTrackCard', () => { + test('clicking the card calls playQueue with sectionTracks at the right index', async () => { + render(CompactTrackCard, { + props: { track: tracks[1], sectionTracks: tracks, index: 1 } + }); + await fireEvent.click(screen.getByRole('button', { name: /play second/i })); + expect(playQueue).toHaveBeenCalledWith(tracks, 1); + }); + + test('renders title and artist name', () => { + render(CompactTrackCard, { + props: { track: tracks[0], sectionTracks: tracks, index: 0 } + }); + expect(screen.getByText('First')).toBeInTheDocument(); + expect(screen.getByText('Artist')).toBeInTheDocument(); + }); +}); From cf18d513948238d69f11cffabf744b652f3dec9e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 20:21:34 -0400 Subject: [PATCH 019/351] feat(web): add AlphabeticalGrid wrapper with letter dividers Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/AlphabeticalGrid.svelte | 70 +++++++++++++++++++ .../lib/components/AlphabeticalGrid.test.ts | 51 ++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 web/src/lib/components/AlphabeticalGrid.svelte create mode 100644 web/src/lib/components/AlphabeticalGrid.test.ts diff --git a/web/src/lib/components/AlphabeticalGrid.svelte b/web/src/lib/components/AlphabeticalGrid.svelte new file mode 100644 index 00000000..33f2c687 --- /dev/null +++ b/web/src/lib/components/AlphabeticalGrid.svelte @@ -0,0 +1,70 @@ + + +
+ {#each segments as seg, i (i)} + {#if seg.divider} +
+ {seg.divider} + +
+ {/if} +
+ {@render item(seg.item)} +
+ {/each} +
+ + diff --git a/web/src/lib/components/AlphabeticalGrid.test.ts b/web/src/lib/components/AlphabeticalGrid.test.ts new file mode 100644 index 00000000..c2822b8a --- /dev/null +++ b/web/src/lib/components/AlphabeticalGrid.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { createRawSnippet } from 'svelte'; +import AlphabeticalGrid from './AlphabeticalGrid.svelte'; + +type Item = { id: string; key: string; label: string }; + +const items: Item[] = [ + { id: '1', key: 'A', label: 'Apple' }, + { id: '2', key: 'A', label: 'Avocado' }, + { id: '3', key: 'B', label: 'Banana' }, + { id: '4', key: 'D', label: 'Date' } // skips C — divider must jump to D directly +]; + +// createRawSnippet receives getters: each param is () => ParamType +const itemSnippet = createRawSnippet<[Item]>((getIt) => ({ + render: () => `${getIt().label}` +})); + +// The generic component's T parameter can't be inferred by testing-library's render, +// so we cast props to avoid a spurious unknown-assignability error. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyProps = any; + +describe('AlphabeticalGrid', () => { + test('inserts a divider for each new key letter', () => { + render(AlphabeticalGrid, { + props: { + items, + getKey: (it: Item) => it.key, + item: itemSnippet + } as AnyProps + }); + // Three dividers (A, B, D); C is absent because no items use C. + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.getByText('D')).toBeInTheDocument(); + expect(screen.queryByText('C')).not.toBeInTheDocument(); + }); + + test('renders items in given order', () => { + const { container } = render(AlphabeticalGrid, { + props: { + items, + getKey: (it: Item) => it.key, + item: itemSnippet + } as AnyProps + }); + expect(container.textContent).toMatch(/A.*Apple.*Avocado.*B.*Banana.*D.*Date/s); + }); +}); From 7d09e605ccb342edc97091a8e05ed15d1d162b16 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 20:23:20 -0400 Subject: [PATCH 020/351] feat(web): rewrite / as home page composing 4 horizontal-scroll sections Co-Authored-By: Claude Sonnet 4.6 --- web/src/routes/+page.svelte | 163 +++++++++++++++++++-------------- web/src/routes/artists.test.ts | 140 ---------------------------- 2 files changed, 96 insertions(+), 207 deletions(-) delete mode 100644 web/src/routes/artists.test.ts diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 83417fa8..de76c01f 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,83 +1,112 @@ -
-
-
-

Library

- {#if !query.isPending && !query.isError} -

- {total} {total === 1 ? 'artist' : 'artists'} -

- {/if} -
- -
- +
{#if query.isError} - {:else if showSkeleton.value && artists.length === 0} - - {:else if !query.isPending && total === 0} -

- No artists yet — scan a library folder via the server's config. -

- {:else} -
- {#each artists as a (a.id)} - - {/each} -
+ {:else if showSkeleton.value && !data} +

Loading…

+ {:else if data} + +
+
+

Recently added

+
+ {#if data.recently_added_albums.length === 0} +

Nothing added yet. Scan a folder via the server's config.

+ {:else} + {#each chunk(data.recently_added_albums, 25) as row, i (i)} + + {#snippet item(album: import('$lib/api/types').AlbumRef)} +
+ {/snippet} +
+ {/each} + {/if} +
- {#if query.hasNextPage} - - {:else if artists.length > 0} -

End of library

- {/if} + +
+
+

Rediscover

+
+ {#if data.rediscover_albums.length === 0 && data.rediscover_artists.length === 0} +

No forgotten favourites yet. Like some albums or artists to fill this in.

+ {:else} + {#if data.rediscover_albums.length > 0} + + {#snippet item(album: import('$lib/api/types').AlbumRef)} +
+ {/snippet} +
+ {/if} + {#if data.rediscover_artists.length > 0} + + {#snippet item(artist: import('$lib/api/types').ArtistRef)} +
+ {/snippet} +
+ {/if} + {/if} +
+ + +
+
+

Most played

+
+ {#if data.most_played_tracks.length === 0} +

No plays to draw from. Listen to something.

+ {:else} + {#each chunk(data.most_played_tracks, 25) as row, i (i)} + + {#snippet item(track: import('$lib/api/types').TrackRef, idx: number)} + + {/snippet} + + {/each} + {/if} +
+ + +
+
+

Last played

+
+ {#if data.last_played_artists.length === 0} +

No recent plays.

+ {:else} + + {#snippet item(artist: import('$lib/api/types').ArtistRef)} +
+ {/snippet} +
+ {/if} +
{/if}
diff --git a/web/src/routes/artists.test.ts b/web/src/routes/artists.test.ts deleted file mode 100644 index 2ce6c39c..00000000 --- a/web/src/routes/artists.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; -import { flushSync } from 'svelte'; -import { readable } from 'svelte/store'; -import { mockInfiniteQuery } from '../test-utils/query'; -import type { ArtistRef, Page } from '$lib/api/types'; - -const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/') })); - -vi.mock('$app/state', () => ({ - page: { get url() { return state.pageUrl; } } -})); - -vi.mock('$app/navigation', () => ({ - goto: vi.fn() -})); - -vi.mock('$lib/api/queries', () => ({ - qk: { artists: (sort: string) => ['artists', { sort }] }, - createArtistsQuery: vi.fn() -})); - -vi.mock('$lib/api/likes', () => ({ - createLikedIdsQuery: () => readable({ - data: { track_ids: [], album_ids: [], artist_ids: [] }, - isPending: false, - isError: false - }), - likeEntity: vi.fn(), - unlikeEntity: vi.fn() -})); - -vi.mock('@tanstack/svelte-query', async (orig) => { - const actual = (await orig()) as Record; - return { ...actual, useQueryClient: () => ({}) }; -}); - -import ArtistsPage from './+page.svelte'; -import { createArtistsQuery } from '$lib/api/queries'; -import { goto } from '$app/navigation'; - -function page(items: T[], total: number, offset = 0, limit = 50): Page { - return { items, total, offset, limit }; -} - -afterEach(() => { - vi.clearAllMocks(); - state.pageUrl = new URL('http://localhost/'); -}); - -describe('artists list page', () => { - test('renders a row per artist', () => { - const alice: ArtistRef = { id: 'a', name: 'Alice', sort_name: 'Alice', album_count: 3, cover_url: '' }; - const bob: ArtistRef = { id: 'b', name: 'Bob', sort_name: 'Bob', album_count: 1, cover_url: '' }; - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([alice, bob], 2)] }) - ); - render(ArtistsPage); - expect(screen.getByRole('link', { name: /Alice/ })).toHaveAttribute('href', '/artists/a'); - expect(screen.getByRole('link', { name: /Bob/ })).toHaveAttribute('href', '/artists/b'); - }); - - test('renders the total count from the first page', () => { - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([], 1247)] }) - ); - render(ArtistsPage); - expect(screen.getByText(/1247 artists/i)).toBeInTheDocument(); - }); - - test('Load more button calls fetchNextPage when hasNextPage', async () => { - const fetchNextPage = vi.fn(); - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ - pages: [page([], 100)], - hasNextPage: true, - fetchNextPage - }) - ); - render(ArtistsPage); - await fireEvent.click(screen.getByRole('button', { name: /load more/i })); - expect(fetchNextPage).toHaveBeenCalledTimes(1); - }); - - test('"End of library" when hasNextPage is false and at least one page loaded', () => { - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([{ id: 'a', name: 'A', sort_name: 'A', album_count: 1, cover_url: '' }], 1)] }) - ); - render(ArtistsPage); - expect(screen.getByText(/end of library/i)).toBeInTheDocument(); - }); - - test('empty total renders empty-library message', () => { - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([], 0)] }) - ); - render(ArtistsPage); - expect(screen.getByText(/no artists yet/i)).toBeInTheDocument(); - }); - - test('changing the sort dropdown calls goto with ?sort=newest', async () => { - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([], 0)] }) - ); - render(ArtistsPage); - const select = screen.getByLabelText(/sort/i) as HTMLSelectElement; - await fireEvent.change(select, { target: { value: 'newest' } }); - expect(goto).toHaveBeenCalledWith('?sort=newest', { replaceState: true }); - }); - - test('pending state renders the list skeleton after the useDelayed window', () => { - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ isPending: true }) - ); - vi.useFakeTimers(); - try { - render(ArtistsPage); - vi.advanceTimersByTime(200); - flushSync(); // flush the $effect that reads delayed.value - expect(screen.getByTestId('skeleton-list')).toBeInTheDocument(); - } finally { - vi.useRealTimers(); - } - }); - - test('error state renders the error banner with retry', async () => { - const refetch = vi.fn(); - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ - isError: true, - error: { code: 'server_error', message: 'db down', status: 500 }, - refetch - }) - ); - render(ArtistsPage); - expect(screen.getByRole('alert')).toHaveTextContent('db down'); - await fireEvent.click(screen.getByRole('button', { name: /try again/i })); - expect(refetch).toHaveBeenCalledTimes(1); - }); -}); From dae08cd71a049fb37e9ecbaad89cc0d9b171557f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 20:24:48 -0400 Subject: [PATCH 021/351] feat(web): add /library/artists wrapping-grid page Implements Task 18: AlphabeticalGrid + ArtistCard infinite-scroll page backed by createArtistsQuery('alpha'), with error, empty, and loading states. Co-Authored-By: Claude Sonnet 4.6 --- web/src/routes/library/artists/+page.svelte | 55 +++++++++++++++++++++ web/src/routes/library/artists/page.test.ts | 37 ++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 web/src/routes/library/artists/+page.svelte create mode 100644 web/src/routes/library/artists/page.test.ts diff --git a/web/src/routes/library/artists/+page.svelte b/web/src/routes/library/artists/+page.svelte new file mode 100644 index 00000000..a388c7eb --- /dev/null +++ b/web/src/routes/library/artists/+page.svelte @@ -0,0 +1,55 @@ + + +
+
+

Artists

+ {#if !query.isPending && !query.isError} +

+ {total} {total === 1 ? 'artist' : 'artists'} +

+ {/if} +
+ + {#if query.isError} + + {:else if showSkeleton.value && artists.length === 0} +

Loading…

+ {:else if !query.isPending && total === 0} +

No artists yet — scan a library folder via the server's config.

+ {:else} + a.sort_name || a.name} + > + {#snippet item(a: ArtistRef)} + + {/snippet} + + + {#if query.hasNextPage} + + {:else if artists.length > 0} +

End of library

+ {/if} + {/if} +
diff --git a/web/src/routes/library/artists/page.test.ts b/web/src/routes/library/artists/page.test.ts new file mode 100644 index 00000000..c7e086fd --- /dev/null +++ b/web/src/routes/library/artists/page.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { readable } from 'svelte/store'; + +vi.mock('$lib/api/queries', () => ({ + createArtistsQuery: () => readable({ + data: { pages: [{ + items: [ + { id: 'a1', name: 'Alpha', sort_name: 'Alpha', album_count: 1, cover_url: '' }, + { id: 'b1', name: 'Beta', sort_name: 'Beta', album_count: 2, cover_url: '' } + ], + total: 2, limit: 50, offset: 0 + }] }, + isPending: false, + isError: false, + hasNextPage: false, + isFetchingNextPage: false, + refetch: () => {}, + fetchNextPage: () => {} + }) +})); +vi.mock('$lib/api/client', () => ({ api: { get: vi.fn() } })); +vi.mock('$lib/player/store.svelte', () => ({ playQueue: vi.fn() })); + +import Page from './+page.svelte'; + +describe('/library/artists', () => { + test('renders title + count + alphabetical dividers', () => { + render(Page); + expect(screen.getByRole('heading', { name: /artists/i })).toBeInTheDocument(); + expect(screen.getByText(/2 artists/)).toBeInTheDocument(); + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.getByText('Beta')).toBeInTheDocument(); + }); +}); From 3d0a42865eb09dcb70e5d4b666e21b41a5e8276f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 20:26:02 -0400 Subject: [PATCH 022/351] feat(web): add /library/albums wrapping-grid page Co-Authored-By: Claude Sonnet 4.6 --- web/src/routes/library/albums/+page.svelte | 55 ++++++++++++++++++++++ web/src/routes/library/albums/page.test.ts | 53 +++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 web/src/routes/library/albums/+page.svelte create mode 100644 web/src/routes/library/albums/page.test.ts diff --git a/web/src/routes/library/albums/+page.svelte b/web/src/routes/library/albums/+page.svelte new file mode 100644 index 00000000..a8da430b --- /dev/null +++ b/web/src/routes/library/albums/+page.svelte @@ -0,0 +1,55 @@ + + +
+
+

Albums

+ {#if !query.isPending && !query.isError} +

+ {total} {total === 1 ? 'album' : 'albums'} +

+ {/if} +
+ + {#if query.isError} + + {:else if showSkeleton.value && albums.length === 0} +

Loading…

+ {:else if !query.isPending && total === 0} +

No albums yet — scan a library folder via the server's config.

+ {:else} + a.sort_title || a.title} + > + {#snippet item(album: AlbumRef)} + + {/snippet} + + + {#if query.hasNextPage} + + {:else if albums.length > 0} +

End of library

+ {/if} + {/if} +
diff --git a/web/src/routes/library/albums/page.test.ts b/web/src/routes/library/albums/page.test.ts new file mode 100644 index 00000000..437cac0f --- /dev/null +++ b/web/src/routes/library/albums/page.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { readable } from 'svelte/store'; + +vi.mock('$lib/api/albums', () => ({ + createAlbumsAlphaInfiniteQuery: () => readable({ + data: { pages: [{ + items: [ + { id: 'a1', title: 'Apple', sort_title: 'Apple', + artist_id: 'art-1', artist_name: 'X', + track_count: 5, duration_sec: 100, cover_url: '/api/albums/a1/cover' }, + { id: 'b1', title: 'Banana', sort_title: 'Banana', + artist_id: 'art-1', artist_name: 'X', + track_count: 3, duration_sec: 50, cover_url: '/api/albums/b1/cover' } + ], + total: 2, limit: 50, offset: 0 + }] }, + isPending: false, + isError: false, + hasNextPage: false, + isFetchingNextPage: false, + refetch: () => {}, + fetchNextPage: () => {} + }), + ALBUM_PAGE_SIZE: 50 +})); +vi.mock('$lib/api/client', () => ({ api: { get: vi.fn() } })); +vi.mock('$lib/player/store.svelte', () => ({ + enqueueTracks: vi.fn(), + playQueue: vi.fn() +})); +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, isError: false + }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); + +import Page from './+page.svelte'; + +describe('/library/albums', () => { + test('renders title + count + alphabetical dividers', () => { + render(Page); + expect(screen.getByRole('heading', { name: /albums/i })).toBeInTheDocument(); + expect(screen.getByText(/2 albums/)).toBeInTheDocument(); + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.getByText('Apple')).toBeInTheDocument(); + expect(screen.getByText('Banana')).toBeInTheDocument(); + }); +}); From 4412a4af0c1fe4605db8da9124d7807a75ce4173 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 20:27:53 -0400 Subject: [PATCH 023/351] feat(web): update nav (Home/Artists/Albums); retire ArtistRow Add Artists and Albums nav entries, rename root label from Library to Home. Migrate all three ArtistRow call sites (search, search/artists, library/liked) to ArtistCard with grid wrapper; delete ArtistRow component and its test. Co-Authored-By: Claude Sonnet 4.6 --- web/src/lib/components/ArtistRow.svelte | 29 -------------- web/src/lib/components/ArtistRow.test.ts | 45 ---------------------- web/src/lib/components/Shell.svelte | 4 +- web/src/lib/components/Shell.test.ts | 11 +++++- web/src/routes/library/liked/+page.svelte | 6 +-- web/src/routes/search/+page.svelte | 6 +-- web/src/routes/search/artists/+page.svelte | 6 +-- 7 files changed, 21 insertions(+), 86 deletions(-) delete mode 100644 web/src/lib/components/ArtistRow.svelte delete mode 100644 web/src/lib/components/ArtistRow.test.ts diff --git a/web/src/lib/components/ArtistRow.svelte b/web/src/lib/components/ArtistRow.svelte deleted file mode 100644 index 33fd5698..00000000 --- a/web/src/lib/components/ArtistRow.svelte +++ /dev/null @@ -1,29 +0,0 @@ - - -
- - - {artist.name} - {countLabel} - - - - -
diff --git a/web/src/lib/components/ArtistRow.test.ts b/web/src/lib/components/ArtistRow.test.ts deleted file mode 100644 index 5ff56b58..00000000 --- a/web/src/lib/components/ArtistRow.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import { readable } from 'svelte/store'; -import ArtistRow from './ArtistRow.svelte'; -import type { ArtistRef } from '$lib/api/types'; - -vi.mock('$lib/api/likes', () => ({ - createLikedIdsQuery: () => readable({ - data: { track_ids: [], album_ids: [], artist_ids: [] }, - isPending: false, - isError: false - }), - likeEntity: vi.fn(), - unlikeEntity: vi.fn() -})); - -vi.mock('@tanstack/svelte-query', async (orig) => { - const actual = (await orig()) as Record; - return { ...actual, useQueryClient: () => ({}) }; -}); - -const artist: ArtistRef = { id: 'abc', name: 'alice', sort_name: 'alice', album_count: 12, cover_url: '' }; - -describe('ArtistRow', () => { - test('renders initial, name, album count inside a link to /artists/:id', () => { - render(ArtistRow, { props: { artist } }); - - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/artists/abc'); - expect(screen.getByText('alice')).toBeInTheDocument(); - expect(screen.getByText('12 albums')).toBeInTheDocument(); - expect(screen.getByText('A')).toBeInTheDocument(); - }); - - test('singular "1 album" when album_count is 1', () => { - render(ArtistRow, { props: { artist: { ...artist, album_count: 1 } } }); - expect(screen.getByText('1 album')).toBeInTheDocument(); - }); - - test('empty name still renders a safe initial', () => { - render(ArtistRow, { props: { artist: { ...artist, name: '' } } }); - // No crash; initial container present but empty or a placeholder char. - expect(screen.getByRole('link')).toBeInTheDocument(); - }); -}); diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index 80d7af57..5d69f6a6 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -22,7 +22,9 @@ } const navItems = [ - { href: '/', label: 'Library' }, + { href: '/', label: 'Home' }, + { href: '/library/artists', label: 'Artists' }, + { href: '/library/albums', label: 'Albums' }, { href: '/library/liked', label: 'Liked' }, { href: '/library/hidden', label: 'Hidden' }, { href: '/search', label: 'Search' }, diff --git a/web/src/lib/components/Shell.test.ts b/web/src/lib/components/Shell.test.ts index b3f24b90..d1885fb9 100644 --- a/web/src/lib/components/Shell.test.ts +++ b/web/src/lib/components/Shell.test.ts @@ -39,9 +39,16 @@ describe('Shell', () => { expect(screen.getByText('alice')).toBeInTheDocument(); }); - test('renders nav items including Discover and Requests between Search and Playlists', () => { + test('renders nav items in expected order', () => { render(Shell); - expect(screen.getByRole('link', { name: 'Library' })).toHaveAttribute('href', '/'); + const labels = ['Home', 'Artists', 'Albums', 'Liked', 'Hidden', 'Search', + 'Discover', 'Requests', 'Playlists', 'Settings']; + for (const label of labels) { + expect(screen.getByRole('link', { name: label })).toBeInTheDocument(); + } + expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/'); + expect(screen.getByRole('link', { name: 'Artists' })).toHaveAttribute('href', '/library/artists'); + expect(screen.getByRole('link', { name: 'Albums' })).toHaveAttribute('href', '/library/albums'); expect(screen.getByRole('link', { name: 'Search' })).toHaveAttribute('href', '/search'); expect(screen.getByRole('link', { name: 'Discover' })).toHaveAttribute('href', '/discover'); expect(screen.getByRole('link', { name: 'Requests' })).toHaveAttribute('href', '/requests'); diff --git a/web/src/routes/library/liked/+page.svelte b/web/src/routes/library/liked/+page.svelte index 1c722f70..a905f4a1 100644 --- a/web/src/routes/library/liked/+page.svelte +++ b/web/src/routes/library/liked/+page.svelte @@ -4,7 +4,7 @@ createLikedAlbumsInfiniteQuery, createLikedArtistsInfiniteQuery } from '$lib/api/likes'; - import ArtistRow from '$lib/components/ArtistRow.svelte'; + import ArtistCard from '$lib/components/ArtistCard.svelte'; import AlbumCard from '$lib/components/AlbumCard.svelte'; import TrackRow from '$lib/components/TrackRow.svelte'; @@ -33,9 +33,9 @@ {#if artistsTotal === 0}

No liked artists yet.

{:else} -
+
{#each artists as a (a.id)} - + {/each}
{#if artistsQuery?.hasNextPage} diff --git a/web/src/routes/search/+page.svelte b/web/src/routes/search/+page.svelte index c5501398..c21dc1c0 100644 --- a/web/src/routes/search/+page.svelte +++ b/web/src/routes/search/+page.svelte @@ -1,7 +1,7 @@ -
- +
+
+ {#if title} +

{title}

+ {:else} + + {/if} +
+ + +
+
- -
diff --git a/web/src/lib/components/HorizontalScrollRow.test.ts b/web/src/lib/components/HorizontalScrollRow.test.ts index 55ee2e9d..9f640c15 100644 --- a/web/src/lib/components/HorizontalScrollRow.test.ts +++ b/web/src/lib/components/HorizontalScrollRow.test.ts @@ -29,4 +29,14 @@ describe('HorizontalScrollRow', () => { expect(spy).toHaveBeenCalledWith({ left: expect.any(Number), behavior: 'smooth' }); expect(spy.mock.calls[0][0].left).toBeGreaterThan(0); }); + + test('renders title as a heading when prop is provided', () => { + render(HorizontalScrollRow, { props: { items: ['a'], title: 'My section' } }); + expect(screen.getByRole('heading', { name: 'My section' })).toBeInTheDocument(); + }); + + test('omits heading when title prop is absent', () => { + render(HorizontalScrollRow, { props: { items: ['a'] } }); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + }); }); diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index de76c01f..6334abb3 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -30,14 +30,18 @@ {:else if data}
-
-

Recently added

-
{#if data.recently_added_albums.length === 0} +
+

Recently added

+

Nothing added yet. Scan a folder via the server's config.

{:else} {#each chunk(data.recently_added_albums, 25) as row, i (i)} - + {#snippet item(album: import('$lib/api/types').AlbumRef)}
{/snippet} @@ -48,21 +52,29 @@
-
-

Rediscover

-
{#if data.rediscover_albums.length === 0 && data.rediscover_artists.length === 0} +
+

Rediscover

+

No forgotten favourites yet. Like some albums or artists to fill this in.

{:else} {#if data.rediscover_albums.length > 0} - + {#snippet item(album: import('$lib/api/types').AlbumRef)}
{/snippet}
{/if} {#if data.rediscover_artists.length > 0} - + {#snippet item(artist: import('$lib/api/types').ArtistRef)}
{/snippet} @@ -73,14 +85,18 @@
-
-

Most played

-
{#if data.most_played_tracks.length === 0} +
+

Most played

+

No plays to draw from. Listen to something.

{:else} {#each chunk(data.most_played_tracks, 25) as row, i (i)} - + {#snippet item(track: import('$lib/api/types').TrackRef, idx: number)}
-
-

Last played

-
{#if data.last_played_artists.length === 0} +
+

Last played

+

No recent plays.

{:else} - + {#snippet item(artist: import('$lib/api/types').ArtistRef)}
{/snippet} From a598ea14185e518a2c1f4c7eacdc1c8b30559cb9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 21:14:01 -0400 Subject: [PATCH 025/351] feat(web): move Hidden tracks out of main nav, surface from Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hidden tracks (M5b quarantine) is a low-frequency surface — it doesn't warrant a permanent slot in the main nav. Moves the link under a new 'Library' section card on /settings alongside ListenBrainz config. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/lib/components/Shell.svelte | 1 - web/src/lib/components/Shell.test.ts | 16 +++------------- web/src/routes/settings/+page.svelte | 10 ++++++++++ 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index 5d69f6a6..9d121751 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -26,7 +26,6 @@ { href: '/library/artists', label: 'Artists' }, { href: '/library/albums', label: 'Albums' }, { href: '/library/liked', label: 'Liked' }, - { href: '/library/hidden', label: 'Hidden' }, { href: '/search', label: 'Search' }, { href: '/discover', label: 'Discover' }, { href: '/requests', label: 'Requests' }, diff --git a/web/src/lib/components/Shell.test.ts b/web/src/lib/components/Shell.test.ts index d1885fb9..fbd15374 100644 --- a/web/src/lib/components/Shell.test.ts +++ b/web/src/lib/components/Shell.test.ts @@ -41,7 +41,7 @@ describe('Shell', () => { test('renders nav items in expected order', () => { render(Shell); - const labels = ['Home', 'Artists', 'Albums', 'Liked', 'Hidden', 'Search', + const labels = ['Home', 'Artists', 'Albums', 'Liked', 'Search', 'Discover', 'Requests', 'Playlists', 'Settings']; for (const label of labels) { expect(screen.getByRole('link', { name: label })).toBeInTheDocument(); @@ -55,19 +55,9 @@ describe('Shell', () => { expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists'); }); - test('Hidden nav link sits between Liked and Search', () => { + test('Hidden link is not in the main nav', () => { render(Shell); - const hidden = screen.getByRole('link', { name: 'Hidden' }); - expect(hidden).toHaveAttribute('href', '/library/hidden'); - const labels = screen - .getAllByRole('link') - .map((el) => el.textContent?.trim()) - .filter(Boolean); - const idxLiked = labels.indexOf('Liked'); - const idxHidden = labels.indexOf('Hidden'); - const idxSearch = labels.indexOf('Search'); - expect(idxLiked).toBeLessThan(idxHidden); - expect(idxHidden).toBeLessThan(idxSearch); + expect(screen.queryByRole('link', { name: 'Hidden' })).not.toBeInTheDocument(); }); test('non-admin users do not see the Admin nav link', () => { diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index bd16b88c..9bb83748 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -97,4 +97,14 @@
{/if} + +
+

Library

+
    +
  • + Hidden tracks + — tracks you've flagged as bad rips, wrong tags, or otherwise unplayable. +
  • +
+
From 0c5c10d00b0fbd1715458f083dfece5ae5c00e05 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 22:05:15 -0400 Subject: [PATCH 026/351] fix(server): drop duplicate /api/admin route mount that shadowed api.Mount endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: r.Route("/api/admin", ...) in server.go for the /scan endpoint created a second chi subtree at the same prefix as the nested admin Route inside api.Mount. chi.Walk shows both subtrees registered, but runtime routing dispatch only matched one — so every /api/admin/* endpoint registered by api.Mount (Lidarr config, quarantine admin, requests admin) silently returned 404 to authenticated callers. Has shipped this way since 06a1fe1; M5a/b/c admin tests didn't catch it because they call api.Mount on a fresh chi.NewRouter() rather than going through Server.Router(), so only one /api/admin registration existed in those tests. Fix: register /api/admin/scan as a single inline-middleware route (r.With(...).Post(...)) instead of a Route subtree, eliminating the duplicate /api/admin mount. Adds a regression test that uses a real seeded admin session to make an authenticated GET /api/admin/lidarr/config and asserts != 404. Verified the test fails without the fix and passes with it. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/server/server.go | 15 ++--- internal/server/server_test.go | 112 +++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 7 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index a55eba19..c333aead 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -74,13 +74,14 @@ func (s *Server) Router() http.Handler { lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, lidarrClientFn, nil) lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn) api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar) - r.Route("/api/admin", func(admin chi.Router) { - admin.Use(auth.RequireUser(s.Pool)) - admin.Use(auth.RequireAdmin()) - if s.Scanner != nil { - admin.Post("/scan", s.handleAdminScan) - } - }) + // /api/admin/scan is the only admin route owned by the server package + // (it needs the Scanner). Register it as a single inline-middleware + // route — using r.Route("/api/admin", ...) here would create a second + // subtree that shadows every admin route registered by api.Mount. + if s.Scanner != nil { + r.With(auth.RequireUser(s.Pool), auth.RequireAdmin()). + Post("/api/admin/scan", s.handleAdminScan) + } subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg, writer) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 9629ec33..3abcc3d3 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1,16 +1,25 @@ package server import ( + "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" + "os" "strings" "testing" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/config" + "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/library" "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic" + "time" ) func TestHealthz(t *testing.T) { @@ -111,3 +120,106 @@ func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) { t.Errorf("Content-Type = %q; /rest/* must never return text/html", ct) } } + +// TestRouter_AdminSubtreeNotShadowed is a regression test for a real bug we +// shipped in 06a1fe1 and didn't catch until M6a: r.Route("/api/admin", ...) +// in server.go was creating a second chi subtree at the same prefix as the +// nested admin Route inside api.Mount. chi.Walk shows both subtrees as +// registered, but runtime routing dispatch only matches one — every admin +// endpoint registered by api.Mount (Lidarr config, quarantine, etc.) +// silently 404'd for authenticated callers in production. +// +// chi.Walk enumeration would NOT have caught this (both branches are in +// the tree). The only reliable check is to make a real authenticated +// request and confirm it reaches a handler — i.e. doesn't fall through +// to the JSON NotFound (404) that signals shadowing. +// +// The existing tests in this file pass nil for pool, which skips api.Mount +// entirely (gated behind `if s.Pool != nil`), so the conflict didn't manifest. +// +// Gated on MINSTREL_TEST_DATABASE_URL like other integration-leaning tests. +func TestRouter_AdminSubtreeNotShadowed(t *testing.T) { + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + + // Seed a test admin user + session so the request actually reaches the + // inner route lookup (auth-middleware short-circuits don't catch shadowing). + ctx := context.Background() + q := dbq.New(pool) + _, _ = pool.Exec(ctx, "DELETE FROM sessions WHERE user_agent = 'shadowing-test'") + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE username = 'test-shadowing-admin'") + user, err := q.CreateUser(ctx, dbq.CreateUserParams{ + Username: "test-shadowing-admin", + PasswordHash: "x", + ApiToken: "test-shadowing-token", + IsAdmin: true, + }) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", user.ID) }) + token := "shadow-test-" + time.Now().Format("20060102150405") + tokenHash := auth.HashSessionToken(token) + _, err = pool.Exec(ctx, + "INSERT INTO sessions (user_id, token_hash, user_agent) VALUES ($1, $2, 'shadowing-test')", + user.ID, tokenHash[:], + ) + if err != nil { + t.Fatalf("insert session: %v", err) + } + + s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), pool, + stubScanner{}, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}) + ts := httptest.NewServer(s.Router()) + defer ts.Close() + + // Each path is registered by a different package: lidarr/config from + // api.Mount and admin/scan from server.Router. If chi runtime dispatch + // only routes one /api/admin subtree, one of these returns 404 — that + // IS the bug we shipped in 06a1fe1. + cases := []struct { + method string + path string + owner string + }{ + {http.MethodGet, "/api/admin/lidarr/config", "api.Mount"}, + // /api/admin/scan would actually trigger a real library scan via + // stubScanner; we don't include it. The lidarr/config path is the + // canonical regression check. + } + for _, tc := range cases { + req, err := http.NewRequest(tc.method, ts.URL+tc.path, nil) + if err != nil { + t.Fatalf("build %s %s: %v", tc.method, tc.path, err) + } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", tc.method, tc.path, err) + } + _ = resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + t.Errorf("%s %s (owner=%s) returned 404 with valid admin auth — route is shadowed", + tc.method, tc.path, tc.owner) + } + } +} + +// stubScanner is a no-op ScanTrigger used only to make Server.Router() +// register /api/admin/scan. Its Scan method must never be called by the +// route-presence assertions in this file. +type stubScanner struct{} + +func (stubScanner) Scan(ctx context.Context) (library.Stats, error) { + panic("stubScanner.Scan called from server_test") +} From 4b088e6b6f7c168ade3234e091a6f1b926c14f0d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 22:10:04 -0400 Subject: [PATCH 027/351] feat(server): slog request log + log dropped Lidarr ping errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two debuggability gaps surfaced by the /api/admin route shadowing investigation: 1. handleTestLidarrConnection swallowed client.Ping's error — only the bucket code (lidarr_unreachable) reached the response, the underlying *net.DNSError / *net.OpError / TLS error never reached the logs. Operators couldn't tell a typo'd hostname from a wrong port from a refused TLS handshake. Now logged at Warn (expected-when-misconfigured) with the base_url for diagnostic context. The api_key is never logged. 2. The chi router had no access-log middleware — every 4xx/5xx was silent, making it impossible to tell whether a request even reached the server. chi's middleware.Logger writes to the standard log package not slog, so a small slog wrapper handles it instead. /healthz is skipped (the compose healthcheck hits it every 5s; would be ~17k log lines/day of noise). Severity is keyed off response status: 5xx -> Error, 4xx -> Warn, else Info — so 4xx/5xx surface even when the operator's logger level is set above Info. Tests cover both the severity routing and the /healthz skip. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/api/admin_lidarr.go | 8 +++ internal/server/requestlog.go | 50 +++++++++++++ internal/server/requestlog_test.go | 110 +++++++++++++++++++++++++++++ internal/server/server.go | 1 + 4 files changed, 169 insertions(+) create mode 100644 internal/server/requestlog.go create mode 100644 internal/server/requestlog_test.go diff --git a/internal/api/admin_lidarr.go b/internal/api/admin_lidarr.go index adf080f2..c21af7b0 100644 --- a/internal/api/admin_lidarr.go +++ b/internal/api/admin_lidarr.go @@ -147,6 +147,14 @@ func (h *handlers) handleTestLidarrConnection(w http.ResponseWriter, r *http.Req result, err := client.Ping(r.Context()) if err != nil { errCode := lidarrErrCode(err) + // Warn (not Error): a failed connection test is expected when the + // admin is configuring the integration and may have a typo'd URL + // or wrong key. Without this log line the underlying *net.DNSError + // / *net.OpError / TLS error is invisible to operators — they only + // see the bucket code in the UI. base_url is safe to log; api_key + // must NEVER be logged. + h.logger.Warn("admin: test lidarr ping failed", + "err", err, "base_url", baseURL, "code", errCode) writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": errCode}) return } diff --git a/internal/server/requestlog.go b/internal/server/requestlog.go new file mode 100644 index 00000000..27fc6a2f --- /dev/null +++ b/internal/server/requestlog.go @@ -0,0 +1,50 @@ +package server + +import ( + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5/middleware" +) + +// requestLog is an slog-based access log middleware. chi ships +// middleware.Logger but it writes to the standard library's log package, +// not slog, so output ordering and formatting drift from the rest of the +// server's logs. We use a thin wrapper instead. +// +// The /healthz path is skipped because the docker-compose healthcheck +// hits it every 5s and would otherwise drown real signals (~17k lines/day). +// +// Severity is keyed off the response status so 4xx/5xx surface even when +// the operator's logger level is set above Info. +func requestLog(logger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + next.ServeHTTP(w, r) + return + } + start := time.Now() + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + next.ServeHTTP(ww, r) + status := ww.Status() + attrs := []any{ + "method", r.Method, + "path", r.URL.Path, + "status", status, + "duration_ms", time.Since(start).Milliseconds(), + "request_id", middleware.GetReqID(r.Context()), + "remote", r.RemoteAddr, + } + switch { + case status >= 500: + logger.Error("http", attrs...) + case status >= 400: + logger.Warn("http", attrs...) + default: + logger.Info("http", attrs...) + } + }) + } +} diff --git a/internal/server/requestlog_test.go b/internal/server/requestlog_test.go new file mode 100644 index 00000000..bff1c155 --- /dev/null +++ b/internal/server/requestlog_test.go @@ -0,0 +1,110 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// captureLogger is a slog.Handler that records each Record emitted, so +// requestLog tests can assert level + attrs without parsing JSON. +type capturedRecord struct { + Level slog.Level + Msg string + Attrs map[string]any +} + +type captureHandler struct { + records *[]capturedRecord +} + +func (h *captureHandler) Enabled(_ context.Context, _ slog.Level) bool { return true } + +func (h *captureHandler) Handle(_ context.Context, r slog.Record) error { + rec := capturedRecord{Level: r.Level, Msg: r.Message, Attrs: map[string]any{}} + r.Attrs(func(a slog.Attr) bool { rec.Attrs[a.Key] = a.Value.Any(); return true }) + *h.records = append(*h.records, rec) + return nil +} + +func (h *captureHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h } +func (h *captureHandler) WithGroup(_ string) slog.Handler { return h } + +func newCaptureLogger() (*slog.Logger, *[]capturedRecord) { + records := &[]capturedRecord{} + return slog.New(&captureHandler{records: records}), records +} + +func TestRequestLog_StatusToSeverity(t *testing.T) { + cases := []struct { + name string + status int + wantLevel slog.Level + }{ + {"2xx is Info", http.StatusOK, slog.LevelInfo}, + {"4xx is Warn", http.StatusNotFound, slog.LevelWarn}, + {"5xx is Error", http.StatusInternalServerError, slog.LevelError}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + logger, records := newCaptureLogger() + h := requestLog(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.status) + })) + req := httptest.NewRequest(http.MethodGet, "/something", nil) + h.ServeHTTP(httptest.NewRecorder(), req) + + if len(*records) != 1 { + t.Fatalf("len(records) = %d, want 1", len(*records)) + } + rec := (*records)[0] + if rec.Level != tc.wantLevel { + t.Errorf("level = %v, want %v", rec.Level, tc.wantLevel) + } + if rec.Attrs["status"] != int64(tc.status) { + t.Errorf("status = %v, want %d", rec.Attrs["status"], tc.status) + } + }) + } +} + +func TestRequestLog_SkipsHealthz(t *testing.T) { + logger, records := newCaptureLogger() + h := requestLog(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + h.ServeHTTP(httptest.NewRecorder(), req) + + if len(*records) != 0 { + t.Errorf("expected /healthz to be skipped, got %d records", len(*records)) + } +} + +// Sanity that other paths produce a useful structured payload. +func TestRequestLog_AttributesPresent(t *testing.T) { + // Use a real slog text handler buffer so we also exercise the + // formatter (catches WithAttrs/WithGroup integration regressions). + var buf bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) + h := requestLog(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodPost, "/api/something", strings.NewReader("")) + h.ServeHTTP(httptest.NewRecorder(), req) + + var got map[string]any + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("decode log line: %v\nraw: %s", err, buf.String()) + } + for _, key := range []string{"method", "path", "status", "duration_ms"} { + if _, ok := got[key]; !ok { + t.Errorf("expected key %q in log entry, got %v", key, got) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index c333aead..0a29ad5c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -48,6 +48,7 @@ func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg su func (s *Server) Router() http.Handler { r := chi.NewRouter() r.Use(middleware.RequestID) + r.Use(requestLog(s.Logger)) r.Use(middleware.Recoverer) r.Get("/healthz", s.handleHealthz) From 14c1895bebc2fa5bf7b43ae1157a31902edcb91a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 22:14:39 -0400 Subject: [PATCH 028/351] =?UTF-8?q?feat(web):=20flatten=20admin=20shell=20?= =?UTF-8?q?=E2=80=94=20horizontal=20tabs,=20drop=20redundant=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /admin layout had three nested concentric shells: the global Shell header + main nav, then a per-admin header banner with a Sword icon, then a left-aligned admin sub-nav (AdminSidebar) — two competing left-edge navigation columns plus a redundant 'Admin' label that the URL and active nav already conveyed. Replaces the side sub-nav with a horizontal tab strip across the top of the admin content (matches the discover-page tab pattern), drops the duplicate header banner, and removes the disabled Users/Library placeholder items so the tab strip only shows surfaces that actually ship today. The component is renamed AdminSidebar -> AdminTabs to match its new shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/lib/components/AdminSidebar.svelte | 59 ------------------ web/src/lib/components/AdminSidebar.test.ts | 66 --------------------- web/src/lib/components/AdminTabs.svelte | 38 ++++++++++++ web/src/lib/components/AdminTabs.test.ts | 58 ++++++++++++++++++ web/src/routes/admin/+layout.svelte | 17 ++---- 5 files changed, 100 insertions(+), 138 deletions(-) delete mode 100644 web/src/lib/components/AdminSidebar.svelte delete mode 100644 web/src/lib/components/AdminSidebar.test.ts create mode 100644 web/src/lib/components/AdminTabs.svelte create mode 100644 web/src/lib/components/AdminTabs.test.ts diff --git a/web/src/lib/components/AdminSidebar.svelte b/web/src/lib/components/AdminSidebar.svelte deleted file mode 100644 index 99114878..00000000 --- a/web/src/lib/components/AdminSidebar.svelte +++ /dev/null @@ -1,59 +0,0 @@ - - - diff --git a/web/src/lib/components/AdminSidebar.test.ts b/web/src/lib/components/AdminSidebar.test.ts deleted file mode 100644 index 8e73d0ab..00000000 --- a/web/src/lib/components/AdminSidebar.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; - -const state = vi.hoisted(() => ({ - pageUrl: new URL('http://localhost/admin') -})); - -vi.mock('$app/state', () => ({ - page: { get url() { return state.pageUrl; } } -})); - -import AdminSidebar from './AdminSidebar.svelte'; - -describe('AdminSidebar', () => { - test('Overview link is active when on /admin', () => { - state.pageUrl = new URL('http://localhost/admin'); - render(AdminSidebar); - const overview = screen.getByRole('link', { name: /overview/i }); - expect(overview).toHaveAttribute('aria-current', 'page'); - }); - - test('Integrations link is active when on /admin/integrations', () => { - state.pageUrl = new URL('http://localhost/admin/integrations'); - render(AdminSidebar); - expect(screen.getByRole('link', { name: /integrations/i })).toHaveAttribute( - 'aria-current', - 'page' - ); - expect(screen.getByRole('link', { name: /overview/i })).not.toHaveAttribute('aria-current'); - }); - - test('Requests link is active when on /admin/requests', () => { - state.pageUrl = new URL('http://localhost/admin/requests'); - render(AdminSidebar); - expect(screen.getByRole('link', { name: /requests/i })).toHaveAttribute( - 'aria-current', - 'page' - ); - }); - - test('Quarantine link is active when on /admin/quarantine', () => { - state.pageUrl = new URL('http://localhost/admin/quarantine'); - render(AdminSidebar); - expect(screen.getByRole('link', { name: /quarantine/i })).toHaveAttribute( - 'aria-current', - 'page' - ); - }); - - test('Quarantine renders as a real link', () => { - state.pageUrl = new URL('http://localhost/admin'); - render(AdminSidebar); - const link = screen.getByRole('link', { name: /quarantine/i }); - expect(link).toHaveAttribute('href', '/admin/quarantine'); - }); - - test('placeholder items render as non-links with aria-disabled', () => { - state.pageUrl = new URL('http://localhost/admin'); - render(AdminSidebar); - // Users + Library are still placeholders today. - expect(screen.queryByRole('link', { name: /^users$/i })).not.toBeInTheDocument(); - expect(screen.queryByRole('link', { name: /^library$/i })).not.toBeInTheDocument(); - const users = screen.getByText(/^users$/i); - expect(users.closest('[aria-disabled="true"]')).toBeInTheDocument(); - }); -}); diff --git a/web/src/lib/components/AdminTabs.svelte b/web/src/lib/components/AdminTabs.svelte new file mode 100644 index 00000000..618ea22c --- /dev/null +++ b/web/src/lib/components/AdminTabs.svelte @@ -0,0 +1,38 @@ + + + diff --git a/web/src/lib/components/AdminTabs.test.ts b/web/src/lib/components/AdminTabs.test.ts new file mode 100644 index 00000000..cbdb6efe --- /dev/null +++ b/web/src/lib/components/AdminTabs.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; + +const state = vi.hoisted(() => ({ + pageUrl: new URL('http://localhost/admin') +})); + +vi.mock('$app/state', () => ({ + page: { get url() { return state.pageUrl; } } +})); + +import AdminTabs from './AdminTabs.svelte'; + +describe('AdminTabs', () => { + test('Overview tab is current when on /admin', () => { + state.pageUrl = new URL('http://localhost/admin'); + render(AdminTabs); + const overview = screen.getByRole('link', { name: /overview/i }); + expect(overview).toHaveAttribute('aria-current', 'page'); + }); + + test('Integrations tab is current when on /admin/integrations', () => { + state.pageUrl = new URL('http://localhost/admin/integrations'); + render(AdminTabs); + expect(screen.getByRole('link', { name: /integrations/i })).toHaveAttribute( + 'aria-current', + 'page' + ); + expect(screen.getByRole('link', { name: /overview/i })).not.toHaveAttribute('aria-current'); + }); + + test('Requests tab is current when on /admin/requests', () => { + state.pageUrl = new URL('http://localhost/admin/requests'); + render(AdminTabs); + expect(screen.getByRole('link', { name: /requests/i })).toHaveAttribute('aria-current', 'page'); + }); + + test('Quarantine tab is current when on /admin/quarantine', () => { + state.pageUrl = new URL('http://localhost/admin/quarantine'); + render(AdminTabs); + expect(screen.getByRole('link', { name: /quarantine/i })).toHaveAttribute( + 'aria-current', + 'page' + ); + }); + + test('renders exactly four tabs (Users + Library deferred until built)', () => { + state.pageUrl = new URL('http://localhost/admin'); + render(AdminTabs); + const links = screen.getAllByRole('link'); + expect(links.map((l) => l.textContent?.trim())).toEqual([ + 'Overview', + 'Integrations', + 'Requests', + 'Quarantine' + ]); + }); +}); diff --git a/web/src/routes/admin/+layout.svelte b/web/src/routes/admin/+layout.svelte index 072dd601..83eec272 100644 --- a/web/src/routes/admin/+layout.svelte +++ b/web/src/routes/admin/+layout.svelte @@ -1,19 +1,10 @@ -
-
- -

Admin

-
-
- -
- {@render children?.()} -
-
+
+ + {@render children?.()}
From 22ac86f200d96bba3b90d97d8e95b8109b1238cb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 22:23:56 -0400 Subject: [PATCH 029/351] feat(web): trim main nav; pair Discover+Requests; move chrome to user menu The main nav was carrying surfaces that didn't belong on it: - Search led to an empty page when clicked (you only ever want to land there from the global SearchInput typing into /search?q=...). Drop it. - Requests is downstream of Discover (you discover, then you request). Lift both onto a shared horizontal tab strip; keep their URLs so bookmarks survive. New DiscoverTabs component used by both pages. - Settings + Admin are operator chrome, not primary surfaces. Move them into the username dropdown alongside Log out, with Admin gated on is_admin. Users see Settings + Log out; admins see Settings + Admin + Log out. Final main nav: Home / Artists / Albums / Liked / Discover / Playlists. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/lib/components/DiscoverTabs.svelte | 36 +++++++++++++ web/src/lib/components/DiscoverTabs.test.ts | 35 ++++++++++++ web/src/lib/components/Shell.svelte | 40 +++++++++----- web/src/lib/components/Shell.test.ts | 60 +++++++++++---------- web/src/routes/discover/+page.svelte | 5 +- web/src/routes/requests/+page.svelte | 5 +- 6 files changed, 136 insertions(+), 45 deletions(-) create mode 100644 web/src/lib/components/DiscoverTabs.svelte create mode 100644 web/src/lib/components/DiscoverTabs.test.ts diff --git a/web/src/lib/components/DiscoverTabs.svelte b/web/src/lib/components/DiscoverTabs.svelte new file mode 100644 index 00000000..300a97ea --- /dev/null +++ b/web/src/lib/components/DiscoverTabs.svelte @@ -0,0 +1,36 @@ + + + diff --git a/web/src/lib/components/DiscoverTabs.test.ts b/web/src/lib/components/DiscoverTabs.test.ts new file mode 100644 index 00000000..ebcc9e54 --- /dev/null +++ b/web/src/lib/components/DiscoverTabs.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; + +const state = vi.hoisted(() => ({ + pageUrl: new URL('http://localhost/discover') +})); + +vi.mock('$app/state', () => ({ + page: { get url() { return state.pageUrl; } } +})); + +import DiscoverTabs from './DiscoverTabs.svelte'; + +describe('DiscoverTabs', () => { + test('Discover tab is current on /discover', () => { + state.pageUrl = new URL('http://localhost/discover'); + render(DiscoverTabs); + expect(screen.getByRole('link', { name: /discover/i })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('link', { name: /requests/i })).not.toHaveAttribute('aria-current'); + }); + + test('Requests tab is current on /requests', () => { + state.pageUrl = new URL('http://localhost/requests'); + render(DiscoverTabs); + expect(screen.getByRole('link', { name: /requests/i })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('link', { name: /discover/i })).not.toHaveAttribute('aria-current'); + }); + + test('renders both tabs in order', () => { + state.pageUrl = new URL('http://localhost/discover'); + render(DiscoverTabs); + const labels = screen.getAllByRole('link').map((l) => l.textContent?.trim()); + expect(labels).toEqual(['Discover', 'Requests']); + }); +}); diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index 9d121751..ddfeb032 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -21,24 +21,17 @@ return page.url.pathname.startsWith(href); } + // Search reaches /search via the global SearchInput in the header (not the + // nav). Requests is a tab inside Discover. Settings + Admin live in the + // username dropdown — they're per-operator chrome, not primary surfaces. const navItems = [ { href: '/', label: 'Home' }, { href: '/library/artists', label: 'Artists' }, { href: '/library/albums', label: 'Albums' }, { href: '/library/liked', label: 'Liked' }, - { href: '/search', label: 'Search' }, { href: '/discover', label: 'Discover' }, - { href: '/requests', label: 'Requests' }, - { href: '/playlists', label: 'Playlists' }, - { href: '/settings', label: 'Settings' } + { href: '/playlists', label: 'Playlists' } ]; - - // Admin link sits between Playlists and Settings, only visible to admins. - const visibleNavItems = $derived( - user.value?.is_admin - ? [...navItems.slice(0, -1), { href: '/admin', label: 'Admin' }, navItems[navItems.length - 1]] - : navItems - ); (menuOpen = false)} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} /> @@ -59,15 +52,34 @@ {#if menuOpen}
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="menu" tabindex="-1" > + (menuOpen = false)} + > + Settings + + {#if user.value?.is_admin} + (menuOpen = false)} + > + Admin + + {/if} + query.fetchNextPage()} + /> + {#if query.isFetchingNextPage} +

Loading more…

+ {/if} {:else if albums.length > 0}

End of library

{/if} diff --git a/web/src/routes/library/albums/page.test.ts b/web/src/routes/library/albums/page.test.ts index 437cac0f..52f7394a 100644 --- a/web/src/routes/library/albums/page.test.ts +++ b/web/src/routes/library/albums/page.test.ts @@ -37,6 +37,13 @@ vi.mock('$lib/api/likes', () => ({ likeEntity: vi.fn(), unlikeEntity: vi.fn() })); +// AlbumCard renders LikeButton, which calls useQueryClient() to get the +// cache for invalidation. The page test isn't wrapped in a QueryClientProvider, +// so we stub useQueryClient with a noop client. +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({}) }; +}); import Page from './+page.svelte'; diff --git a/web/src/routes/library/artists/+page.svelte b/web/src/routes/library/artists/+page.svelte index a388c7eb..65daf323 100644 --- a/web/src/routes/library/artists/+page.svelte +++ b/web/src/routes/library/artists/+page.svelte @@ -3,6 +3,7 @@ import ArtistCard from '$lib/components/ArtistCard.svelte'; import AlphabeticalGrid from '$lib/components/AlphabeticalGrid.svelte'; import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte'; + import InfiniteScrollSentinel from '$lib/components/InfiniteScrollSentinel.svelte'; import { useDelayed } from '$lib/utils/useDelayed.svelte'; import type { ArtistRef } from '$lib/api/types'; @@ -40,14 +41,13 @@ {#if query.hasNextPage} - + query.fetchNextPage()} + /> + {#if query.isFetchingNextPage} +

Loading more…

+ {/if} {:else if artists.length > 0}

End of library

{/if} From a5a6a10c7bf5d7fbaae07f2615f567583fa9e6d1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 22:36:03 -0400 Subject: [PATCH 031/351] fix(web): give user-menu dropdown z-50 so clicks don't fall through The Shell's username dropdown had no z-index, so the layout's later grid siblings (left nav, main content) painted over it. Visible symptom: clicking Admin actually clicked the Home link underneath, producing a quick flash followed by a redirect to /. Adding z-50 to the popup container makes clicks land where they look like they should. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/lib/components/Shell.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index ddfeb032..e32f8530 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -52,7 +52,7 @@ {#if menuOpen}
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="menu" From 7b1cd50cb9305e95a26c7f8a3d64a91ef3cba565 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 22:51:05 -0400 Subject: [PATCH 032/351] fix(web): user-menu links navigate client-side, not via full reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The username dropdown's container had onclick={(e) => e.stopPropagation()} to keep the window-level close-on-outside-click handler from firing on inside clicks. But that also blocked SvelteKit's document-level link-click interceptor, so clicking Settings/Admin inside the menu fell through to the browser's native navigation — a full-page reload. Symptom: clicking Admin redirected to /. On the hard reload, the admin guard (/admin/+layout.ts) ran before bootstrap() had settled the user store; user.value was still null, so the guard threw redirect(302, '/'). By the time you saw / render, bootstrap had finished and the dropdown showed Admin again, primed to repeat the cycle. Replaces the stopPropagation-based outside-click protection with a target-containment check in the window handler: close only when the click landed outside both the menu button and the menu container. Drops the redundant stopPropagation on the menu button too (no longer needed since the window handler now ignores in-menu clicks). Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/lib/components/Shell.svelte | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index e32f8530..6d505911 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -9,6 +9,24 @@ let { children } = $props<{ children: import('svelte').Snippet }>(); let menuOpen = $state(false); + let menuRef: HTMLElement | undefined = $state(); + let menuButtonRef: HTMLElement | undefined = $state(); + + // Close the dropdown when the click was outside both the button and the menu. + // The previous implementation used onclick={(e) => e.stopPropagation()} on the + // menu container, which kept the window-level close-on-outside-click handler + // from firing — but it also blocked SvelteKit's document-level link-click + // interceptor, so clicking Admin/Settings inside the dropdown fell through + // to the browser's native navigation (a full-page reload). On a hard reload, + // /admin/+layout.ts's load function ran before bootstrap() settled the user + // store, so the admin guard saw user.value === null and redirected to /. + function handleWindowClick(e: MouseEvent) { + if (!menuOpen) return; + const target = e.target as Node | null; + if (!target) return; + if (menuRef?.contains(target) || menuButtonRef?.contains(target)) return; + menuOpen = false; + } async function handleLogout() { menuOpen = false; @@ -34,7 +52,7 @@ ]; - (menuOpen = false)} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} /> + e.key === 'Escape' && (menuOpen = false)} />
@@ -44,17 +62,17 @@
{#if menuOpen}
e.stopPropagation()} - onkeydown={(e) => e.stopPropagation()} role="menu" tabindex="-1" > From 65651510fb986e3843fb03fc66c37eec1936cb19 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 22:54:14 -0400 Subject: [PATCH 033/351] feat(web): add chevron + aria affordances to user-menu button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The username was rendered as plain text with no visual cue that it opened a dropdown. Adds a 16px Lucide ChevronDown to the right of the name (rotates 180° when open) plus the canonical aria-haspopup='menu' and aria-expanded={menuOpen} attributes so screen readers announce the button as a menu opener and reflect open/closed state. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/lib/components/Shell.svelte | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index 6d505911..4633ec59 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -1,6 +1,7 @@ {#if current} -
- -
+
+ +
-
{current.title}
+
{current.title}
{current.artist_name}
- +
@@ -69,7 +80,7 @@ {player.error ?? 'Playback failed.'}
{:else}
+ +
+ + + +
+
{formatDuration(player.position)} @@ -95,56 +147,39 @@ {formatDuration(player.duration)}
-
- - - -
{/if} -
+
+ class="flex h-9 w-9 items-center justify-center rounded-full transition-colors {player.shuffle + ? 'bg-accent-tint text-accent' + : 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}" + > + + -
diff --git a/web/src/lib/components/TrackMenu.svelte b/web/src/lib/components/TrackMenu.svelte index a57629bf..258c0c62 100644 --- a/web/src/lib/components/TrackMenu.svelte +++ b/web/src/lib/components/TrackMenu.svelte @@ -3,7 +3,15 @@ import FlagPopover from './FlagPopover.svelte'; import type { TrackRef } from '$lib/api/types'; - let { track }: { track: TrackRef } = $props(); + let { + track, + direction = 'down' + }: { + track: TrackRef; + /** Whether the dropdown opens above the trigger ('up') or below ('down'). + Pass 'up' when used near the bottom of the viewport (e.g. PlayerBar). */ + direction?: 'up' | 'down'; + } = $props(); let menuOpen = $state(false); let popoverOpen = $state(false); @@ -44,7 +52,7 @@ {#if menuOpen} +``` + +The imports at the top reference `$lib/stores/me`, `$lib/stores/likedIds`, `$lib/stores/hiddenIds` — verify those exist (they should, given the existing LikeButton + Hide-row pattern). If the names differ in the actual codebase, adjust to match. Same for `$lib/api/quarantine` (`quarantineTrack` / `unquarantineTrack`). Read the files before writing if unsure. + +If any of those stores/helpers genuinely don't exist (e.g., `hiddenIdsStore`), they need to land first or this task gets stretched. Most likely path: the existing LikeButton component already imports a "set of liked ids" store; reuse that pattern. The Hide/quarantine equivalent has shipped in M5b — check `internal/api/quarantine.go` for the helper names and `web/src/lib/api/` for the existing TS surface. + +- [ ] **Step 8.2: Rewrite `web/src/lib/components/TrackMenu.test.ts`** + +```ts +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, fireEvent, screen, waitFor } from '@testing-library/svelte'; +import { writable } from 'svelte/store'; +import TrackMenu from './TrackMenu.svelte'; +import type { TrackRef } from '$lib/api/types'; + +// ---- Mocks ---- + +vi.mock('$lib/stores/me', () => ({ + meStore: writable<{ id: string; username: string; is_admin: boolean } | null>({ + id: 'u-1', username: 'admin', is_admin: true, + }), +})); + +vi.mock('$lib/stores/likedIds', () => ({ + likedIdsStore: writable({ tracks: new Set(), albums: new Set(), artists: new Set() }), +})); + +vi.mock('$lib/stores/hiddenIds', () => ({ + hiddenIdsStore: writable(new Set()), +})); + +vi.mock('$lib/player/store.svelte', () => ({ + playNext: vi.fn(), + enqueueTrack: vi.fn(), +})); + +vi.mock('$lib/api/likes', () => ({ + likeTrack: vi.fn().mockResolvedValue(undefined), + unlikeTrack: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('$lib/api/quarantine', () => ({ + quarantineTrack: vi.fn().mockResolvedValue(undefined), + unquarantineTrack: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('$lib/api/admin/tracks', () => ({ + removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't-1' }), +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) }), + }; +}); + +const track: TrackRef = { + id: 't-1', title: 'Roygbiv', + albumId: 'al-1', albumTitle: 'Geogaddi', + artistId: 'art-1', artistName: 'Boards of Canada', + durationMs: 137000, trackNumber: 4, +} as unknown as TrackRef; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('TrackMenu', () => { + it('kebab toggles aria-expanded; menu opens with all 9 entries for an admin', async () => { + render(TrackMenu, { props: { track } }); + const kebab = screen.getByRole('button', { name: /track actions/i }); + expect(kebab.getAttribute('aria-expanded')).toBe('false'); + await fireEvent.click(kebab); + expect(kebab.getAttribute('aria-expanded')).toBe('true'); + + expect(screen.getByRole('menuitem', { name: /play next/i })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /add to queue/i })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /^like$/i })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /add to playlist/i })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /go to album/i })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /go to artist/i })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /flag this track/i })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /^hide$/i })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /remove from library/i })).toBeInTheDocument(); + }); + + it('hides "Remove from library" for non-admin users', async () => { + const { meStore } = await import('$lib/stores/me'); + (meStore as unknown as { set: (v: unknown) => void }).set({ id: 'u-2', username: 'alice', is_admin: false }); + + render(TrackMenu, { props: { track } }); + await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); + expect(screen.queryByRole('menuitem', { name: /remove from library/i })).not.toBeInTheDocument(); + }); + + it('"Add to playlist…" is disabled with a tooltip until #352 ships', async () => { + render(TrackMenu, { props: { track } }); + await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); + const item = screen.getByRole('menuitem', { name: /add to playlist/i }); + expect(item.getAttribute('aria-disabled')).toBe('true'); + expect(item.getAttribute('title')).toBe('Coming with playlists'); + }); + + it('Play next dispatches to the player store', async () => { + const { playNext } = await import('$lib/player/store.svelte'); + render(TrackMenu, { props: { track } }); + await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); + await fireEvent.click(screen.getByRole('menuitem', { name: /play next/i })); + expect(playNext).toHaveBeenCalledWith(track); + }); + + it('Remove from library is two-click — first click arms, second confirms', async () => { + const { removeTrack } = await import('$lib/api/admin/tracks'); + render(TrackMenu, { props: { track } }); + await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); + + await fireEvent.click(screen.getByRole('menuitem', { name: /remove from library/i })); + expect(screen.getByRole('menuitem', { name: /click again to confirm/i })).toBeInTheDocument(); + expect(removeTrack).not.toHaveBeenCalled(); + + await fireEvent.click(screen.getByRole('menuitem', { name: /click again to confirm/i })); + await waitFor(() => expect(removeTrack).toHaveBeenCalledWith(track.id)); + }); + + it('Escape closes the menu and returns aria-expanded to false', async () => { + render(TrackMenu, { props: { track } }); + const kebab = screen.getByRole('button', { name: /track actions/i }); + await fireEvent.click(kebab); + expect(kebab.getAttribute('aria-expanded')).toBe('true'); + await fireEvent.keyDown(window, { key: 'Escape' }); + await waitFor(() => expect(kebab.getAttribute('aria-expanded')).toBe('false')); + }); +}); +``` + +If `$lib/stores/me`, `$lib/stores/likedIds`, or `$lib/stores/hiddenIds` don't exist by those exact paths, update both the production component (Step 8.1) and the mock here to match the actual paths. The functional shape stays the same. + +- [ ] **Step 8.3: Commit** + +```bash +git add web/src/lib/components/TrackMenu.svelte web/src/lib/components/TrackMenu.test.ts +git commit -F - <<'EOF' +feat(web): TrackMenu rewrite — 9 entries in 4 groups (M7 #372) + +Replaces the M5b-era FlagPopover-only menu with the full track-actions +surface: queue (Play next, Add to queue), collection (Like/Unlike, +Add to playlist… reserved for #352), navigation (Go to album/artist), +lifecycle (Flag, Hide/Unhide, Remove from library — admin-only). + +Remove from library uses a two-click confirm (no modal) so the user +sees the next click is destructive without a dialog interrupting the +flow. The component's prop API (track, direction) is unchanged so +TrackRow + PlayerBar pick up the new entries with no code change. +EOF +``` + +--- + +### Task 9 — Adjust `` and `` test expectations + +**Files:** +- Modify: `web/src/lib/components/TrackRow.test.ts` +- Modify: `web/src/lib/components/PlayerBar.test.ts` + +The component code for TrackRow + PlayerBar doesn't change — both already use `` with the same prop API. But existing tests in those files assert "the menu opens to a single Flag entry" (or similar M5b-era expectations). After Task 8 those assertions are wrong. + +- [ ] **Step 9.1: Open `web/src/lib/components/TrackRow.test.ts`** and find any test that asserts on ``'s open-state contents (search for "Flag this track", "menuitem", "aria-haspopup"). Replace those assertions with one that confirms the kebab is present and `aria-haspopup="menu"` is set: + +```ts +it('renders a track-actions kebab with aria-haspopup="menu"', () => { + render(TrackRow, { props: { track: fakeTrack } }); + const kebab = screen.getByRole('button', { name: /track actions/i }); + expect(kebab.getAttribute('aria-haspopup')).toBe('menu'); +}); +``` + +The detailed menu-content tests live in `TrackMenu.test.ts` (Task 8) — TrackRow's job is just to mount the menu, so its tests should not duplicate the entry-list assertions. + +- [ ] **Step 9.2: Same change in `web/src/lib/components/PlayerBar.test.ts`.** PlayerBar passes `direction="up"` to TrackMenu (drop-up); add an assertion confirming that prop reaches the kebab if the existing test suite covers that surface: + +```ts +it('mounts TrackMenu with drop-up direction', () => { + render(PlayerBar, { props: { /* ...existing setup... */ } }); + const kebab = screen.getByRole('button', { name: /track actions/i }); + expect(kebab).toBeInTheDocument(); + // Direction="up" is an internal CSS class concern; not assertable + // through aria. The existing snapshot or DOM-class check (if any) + // will need a manual update after the menu rewrite. +}); +``` + +- [ ] **Step 9.3: Commit** + +```bash +git add web/src/lib/components/TrackRow.test.ts web/src/lib/components/PlayerBar.test.ts +git commit -F - <<'EOF' +test(web): adjust TrackRow + PlayerBar tests for new TrackMenu shape + +The 9-entry menu rewrite (Task 8) breaks any pre-existing assertion +that the menu opens to a single Flag entry. Strip those — TrackMenu's +own test file covers the entry list end-to-end. TrackRow + PlayerBar +just need to confirm the kebab mounts with aria-haspopup="menu". +EOF +``` + +--- + +## Self-review + +1. **Spec coverage:** + - §2 Goals — all 9 entries land in Task 8; Like/Hide menu duplication shipped; "Add to playlist…" reserved as a disabled slot; "Go to artist" single-target; "Remove from library" admin-only with cascade. ✓ + - §3 Architecture (backend) — Tasks 1, 2, 3 cover SQL queries, RemoveTrack service, HTTP handler. ✓ + - §3 Architecture (frontend) — Task 4 (admin API helper), Task 5 (audio store), Tasks 6-8 (component primitives + TrackMenu rewrite). ✓ + - §3 Accessibility — kebab `aria-haspopup`/`aria-expanded`, `role="menu"`, `role="menuitem"`, `aria-disabled`, Escape-to-close all in Task 8. ✓ + - §4 File map — every file in the plan's File map maps to a task. ✓ + - §5 API contract — Task 3.1 implements the response envelope; Task 3.4 tests the four error codes. ✓ + - §6 Error handling — Task 8 wires `copyForCode()` for toast surface. ✓ + - §7 Testing — every test file from §7 has a writing step. ✓ + - §8 Distribution / migration — confirmed no migration needed (all `track_id` FKs already CASCADE). The "conditional 0014_track_cascades.up.sql" from the spec is dropped here as redundant. ✓ (this is an intentional plan deviation from the spec's optional clause) + +2. **Placeholder scan:** No "TBD"/"TODO"/"add validation" in any task. The phrase "if unsure" appears once in Task 8 ("verify those exist (they should)") which is direction to the implementer, not a placeholder. + +3. **Type consistency:** `TrackRef`, `RemoveTrackResult`, `removeTrack(id)`, `playNext(t)`, `enqueueTrack(t)`, `qk.albums(id)`, `qk.artists(id)`, `qk.home()`, `qk.likedTracks()`, `qk.hiddenTracks()` all consistent across tasks. Backend types: `tracks.Service`, `tracks.RemoveTrack`, `tracks.ErrNotFound`, `tracks.ErrLidarrUnreachable` all consistent. + +4. **One spec deviation noted explicitly:** the spec says migration `0014_track_cascades.up.sql` is "conditional, only if any FK is RESTRICT". I checked — all four `track_id` FKs (`play_events`, `general_likes_tracks`, `lidarr_quarantine`, `lidarr_quarantine_actions`) already use `ON DELETE CASCADE`. No migration is needed; the plan does not include one. From 30b0edf97ba37c76545adc0431deb80f79389905 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 2 May 2026 21:53:33 -0400 Subject: [PATCH 080/351] feat(db): cascade-cleanup queries for M7 #372 track removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeleteTrack returns album_id + artist_id so the calling service can chain the album-empty / artist-empty cascade. DeleteAlbumIfEmpty and DeleteArtistIfEmpty are no-ops when other rows still reference the parent — the service treats pgx.ErrNoRows as "not orphaned, skip". --- internal/db/dbq/albums.sql.go | 24 ++++++++++++++++++++++++ internal/db/dbq/artists.sql.go | 18 ++++++++++++++++++ internal/db/dbq/tracks.sql.go | 31 +++++++++++++++++++++++++++++++ internal/db/queries/albums.sql | 11 +++++++++++ internal/db/queries/artists.sql | 10 ++++++++++ internal/db/queries/tracks.sql | 9 +++++++++ 6 files changed, 103 insertions(+) diff --git a/internal/db/dbq/albums.sql.go b/internal/db/dbq/albums.sql.go index d4d9b6a2..152a5842 100644 --- a/internal/db/dbq/albums.sql.go +++ b/internal/db/dbq/albums.sql.go @@ -47,6 +47,30 @@ func (q *Queries) CountAlbumsMatching(ctx context.Context, dollar_1 string) (int return count, err } +const deleteAlbumIfEmpty = `-- name: DeleteAlbumIfEmpty :one +DELETE FROM albums a +WHERE a.id = $1 + AND NOT EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id) +RETURNING a.id, a.artist_id +` + +type DeleteAlbumIfEmptyRow struct { + ID pgtype.UUID + ArtistID pgtype.UUID +} + +// M7 #372: deletes the album row only when it has no remaining tracks. +// Used after a track delete to tidy up orphaned albums. RETURNING gives +// us the artist_id so the service can chain into the artist-empty check. +// Returns no rows when the album still has tracks; the service treats +// pgx.ErrNoRows as "album not orphaned, skip cascade". +func (q *Queries) DeleteAlbumIfEmpty(ctx context.Context, id pgtype.UUID) (DeleteAlbumIfEmptyRow, error) { + row := q.db.QueryRow(ctx, deleteAlbumIfEmpty, id) + var i DeleteAlbumIfEmptyRow + err := row.Scan(&i.ID, &i.ArtistID) + return i, err +} + const getAlbumByArtistAndTitle = `-- name: GetAlbumByArtistAndTitle :one SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at FROM albums WHERE artist_id = $1 AND title = $2 LIMIT 1 ` diff --git a/internal/db/dbq/artists.sql.go b/internal/db/dbq/artists.sql.go index 4fcd8172..98e6a5df 100644 --- a/internal/db/dbq/artists.sql.go +++ b/internal/db/dbq/artists.sql.go @@ -33,6 +33,24 @@ func (q *Queries) CountArtistsMatching(ctx context.Context, dollar_1 string) (in return count, err } +const deleteArtistIfEmpty = `-- name: DeleteArtistIfEmpty :one +DELETE FROM artists a +WHERE a.id = $1 + AND NOT EXISTS (SELECT 1 FROM albums al WHERE al.artist_id = a.id) + AND NOT EXISTS (SELECT 1 FROM tracks t WHERE t.artist_id = a.id) +RETURNING a.id +` + +// M7 #372: deletes the artist row only when it has no remaining albums +// AND no remaining tracks (defensive — tracks can in principle exist +// without an album, though the scanner doesn't write that shape today). +func (q *Queries) DeleteArtistIfEmpty(ctx context.Context, id pgtype.UUID) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, deleteArtistIfEmpty, id) + var id_2 pgtype.UUID + err := row.Scan(&id_2) + return id_2, err +} + const getArtistByID = `-- name: GetArtistByID :one SELECT id, name, sort_name, mbid, created_at, updated_at FROM artists WHERE id = $1 ` diff --git a/internal/db/dbq/tracks.sql.go b/internal/db/dbq/tracks.sql.go index 88eec5c8..2c3c7ebf 100644 --- a/internal/db/dbq/tracks.sql.go +++ b/internal/db/dbq/tracks.sql.go @@ -69,6 +69,37 @@ func (q *Queries) CountTracksMatchingForUser(ctx context.Context, arg CountTrack return count, err } +const deleteTrack = `-- name: DeleteTrack :one +DELETE FROM tracks WHERE id = $1 +RETURNING id, album_id, artist_id, file_path, mbid +` + +type DeleteTrackRow struct { + ID pgtype.UUID + AlbumID pgtype.UUID + ArtistID pgtype.UUID + FilePath string + Mbid *string +} + +// M7 #372: hard delete with FK cascade. The CASCADE on track_id from +// play_events / general_likes_tracks / lidarr_quarantine / +// lidarr_quarantine_actions handles their cleanup. RETURNING gives us +// album_id + artist_id for the album-empty / artist-empty cascade +// checks the service does next. +func (q *Queries) DeleteTrack(ctx context.Context, id pgtype.UUID) (DeleteTrackRow, error) { + row := q.db.QueryRow(ctx, deleteTrack, id) + var i DeleteTrackRow + err := row.Scan( + &i.ID, + &i.AlbumID, + &i.ArtistID, + &i.FilePath, + &i.Mbid, + ) + return i, err +} + const getTrackByID = `-- name: GetTrackByID :one SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE id = $1 ` diff --git a/internal/db/queries/albums.sql b/internal/db/queries/albums.sql index 1130d15e..68c4bed4 100644 --- a/internal/db/queries/albums.sql +++ b/internal/db/queries/albums.sql @@ -85,3 +85,14 @@ SELECT COUNT(*) FROM albums; -- Used by request-progress reporting to count how many albums have been -- ingested for a matched artist while a request is still in flight. SELECT COUNT(*) FROM albums WHERE artist_id = $1; + +-- name: DeleteAlbumIfEmpty :one +-- M7 #372: deletes the album row only when it has no remaining tracks. +-- Used after a track delete to tidy up orphaned albums. RETURNING gives +-- us the artist_id so the service can chain into the artist-empty check. +-- Returns no rows when the album still has tracks; the service treats +-- pgx.ErrNoRows as "album not orphaned, skip cascade". +DELETE FROM albums a +WHERE a.id = $1 + AND NOT EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id) +RETURNING a.id, a.artist_id; diff --git a/internal/db/queries/artists.sql b/internal/db/queries/artists.sql index 6d2bf4f9..a9cf20c5 100644 --- a/internal/db/queries/artists.sql +++ b/internal/db/queries/artists.sql @@ -64,3 +64,13 @@ LEFT JOIN LATERAL ( ) cnt ON true ORDER BY artists.sort_name, artists.name, artists.id LIMIT $1 OFFSET $2; + +-- name: DeleteArtistIfEmpty :one +-- M7 #372: deletes the artist row only when it has no remaining albums +-- AND no remaining tracks (defensive — tracks can in principle exist +-- without an album, though the scanner doesn't write that shape today). +DELETE FROM artists a +WHERE a.id = $1 + AND NOT EXISTS (SELECT 1 FROM albums al WHERE al.artist_id = a.id) + AND NOT EXISTS (SELECT 1 FROM tracks t WHERE t.artist_id = a.id) +RETURNING a.id; diff --git a/internal/db/queries/tracks.sql b/internal/db/queries/tracks.sql index 492257dc..7934b390 100644 --- a/internal/db/queries/tracks.sql +++ b/internal/db/queries/tracks.sql @@ -96,3 +96,12 @@ ORDER BY albums.release_date NULLS LAST, albums.sort_title, -- matched artist (sum across all the artist's albums) while a request -- is still in flight. SELECT COUNT(*) FROM tracks WHERE artist_id = $1; + +-- name: DeleteTrack :one +-- M7 #372: hard delete with FK cascade. The CASCADE on track_id from +-- play_events / general_likes_tracks / lidarr_quarantine / +-- lidarr_quarantine_actions handles their cleanup. RETURNING gives us +-- album_id + artist_id for the album-empty / artist-empty cascade +-- checks the service does next. +DELETE FROM tracks WHERE id = $1 +RETURNING id, album_id, artist_id, file_path, mbid; From 50a231fdc1c104725531b5a1717ce55b167cd6a1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 2 May 2026 21:58:12 -0400 Subject: [PATCH 081/351] feat(tracks): RemoveTrack service with Lidarr-aware delete + cascade RemoveTrack handles both Lidarr-managed (delegates to existing lidarrquarantine.DeleteViaLidarr) and non-Lidarr (direct os.Remove) file deletion, then runs the cascade album-if-empty / artist-if-empty DB cleanup in a single transaction. Lidarr errors propagate as typed errors so the API layer can map them to the existing wire codes. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/tracks/service.go | 188 +++++++++++++++++ internal/tracks/service_test.go | 346 ++++++++++++++++++++++++++++++++ 2 files changed, 534 insertions(+) create mode 100644 internal/tracks/service.go create mode 100644 internal/tracks/service_test.go diff --git a/internal/tracks/service.go b/internal/tracks/service.go new file mode 100644 index 00000000..089b60e0 --- /dev/null +++ b/internal/tracks/service.go @@ -0,0 +1,188 @@ +// Package tracks owns the track-level admin actions exposed by the +// M7 #372 track-actions menu. Today that's RemoveTrack, which deletes +// a single track (Lidarr-managed via lidarrquarantine.DeleteViaLidarr, +// or local via os.Remove) and cascades the album-empty / artist-empty +// tidy-up so we don't leak orphan rows. +package tracks + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" +) + +// Public errors. Handlers map these to API codes. +var ( + ErrNotFound = errors.New("tracks: not found") + ErrLidarrUnreachable = errors.New("tracks: lidarr unreachable") + ErrLidarrUnauthorized = errors.New("tracks: lidarr unauthorized") + ErrLidarrServerError = errors.New("tracks: lidarr server error") +) + +// LidarrDeleter is the subset of *lidarrquarantine.Service the tracks +// service needs. Defined as an interface so tests can stub without +// spinning up a real Lidarr stub. +type LidarrDeleter interface { + DeleteViaLidarr(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, int, error) +} + +// Service owns RemoveTrack. lidarrDeleter may be nil — non-Lidarr-managed +// tracks (no mbid) still work fine; a Lidarr-managed track with a nil +// deleter falls back to direct os.Remove of the local file. +type Service struct { + pool *pgxpool.Pool + logger *slog.Logger + lidarrDeleter LidarrDeleter +} + +// NewService constructs a Service. logger may be nil (defaults to +// slog.Default). lidarrDeleter may be nil to disable the Lidarr-aware +// path entirely. +func NewService(pool *pgxpool.Pool, logger *slog.Logger, lidarrDeleter LidarrDeleter) *Service { + if logger == nil { + logger = slog.Default() + } + return &Service{pool: pool, logger: logger, lidarrDeleter: lidarrDeleter} +} + +// RemoveTrack deletes the track, runs the album-empty / artist-empty +// cascade, and returns the IDs of any rows deleted by the cascade so +// the API caller can invalidate caches. +// +// For Lidarr-managed tracks (mbid set on the track), Lidarr is told to +// delete the file first via lidarrquarantine.DeleteViaLidarr — this +// sets the import-list-exclusion flag so the next sync doesn't re-import +// the file. For local tracks (no mbid), os.Remove handles the file. +// +// Lidarr errors propagate as typed errors and the DB is left untouched +// so the operator can retry. A missing local file is tolerated — DB +// consistency takes priority over a noisy filesystem. +func (s *Service) RemoveTrack(ctx context.Context, trackID, adminID pgtype.UUID) (deletedAlbumID, deletedArtistID *pgtype.UUID, err error) { + q := dbq.New(s.pool) + + track, err := q.GetTrackByID(ctx, trackID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil, ErrNotFound + } + return nil, nil, fmt.Errorf("get track: %w", err) + } + + // Lidarr-managed → Lidarr deletes the album (and so the file) first. + // The lidarrquarantine path also deletes the local rows for every + // track on the album, so we pre-empt the cascade-tidy step here. If + // the lidarr deleter is nil, fall through to the local os.Remove path. + if track.Mbid != nil && *track.Mbid != "" && s.lidarrDeleter != nil { + _, _, lerr := s.lidarrDeleter.DeleteViaLidarr(ctx, trackID, adminID) + switch { + case lerr == nil: + // lidarrquarantine.DeleteViaLidarr already removed the tracks + // row(s) for the album. Cascade cleanup still needs to run + // against the album/artist IDs we captured before the call. + return s.cascadeAfterLidarr(ctx, q, track) + case errors.Is(lerr, lidarr.ErrUnreachable): + return nil, nil, ErrLidarrUnreachable + case errors.Is(lerr, lidarr.ErrAuthFailed): + return nil, nil, ErrLidarrUnauthorized + case errors.Is(lerr, lidarr.ErrServerError): + return nil, nil, ErrLidarrServerError + default: + return nil, nil, fmt.Errorf("lidarr delete: %w", lerr) + } + } + + // Local path: best-effort os.Remove, then DB cleanup in a tx. + if track.FilePath != "" { + if rerr := os.Remove(track.FilePath); rerr != nil && !errors.Is(rerr, os.ErrNotExist) { + s.logger.Warn("track delete: file remove failed", + "path", track.FilePath, "track_id", trackID, "err", rerr) + // Proceed: DB consistency is the priority. + } + } + + return s.deleteAndCascade(ctx, trackID) +} + +// deleteAndCascade runs DeleteTrack → DeleteAlbumIfEmpty → +// DeleteArtistIfEmpty in a single transaction. Returns the cascaded +// album / artist IDs (nil pointers when the cascade didn't fire because +// other tracks/albums still reference the parent). +func (s *Service) deleteAndCascade(ctx context.Context, trackID pgtype.UUID) (deletedAlbumID, deletedArtistID *pgtype.UUID, err error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, nil, fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + tq := dbq.New(tx) + + deleted, err := tq.DeleteTrack(ctx, trackID) + if err != nil { + return nil, nil, fmt.Errorf("delete track: %w", err) + } + + albumRow, aerr := tq.DeleteAlbumIfEmpty(ctx, deleted.AlbumID) + switch { + case aerr == nil: + albumID := albumRow.ID + deletedAlbumID = &albumID + artistID, arerr := tq.DeleteArtistIfEmpty(ctx, albumRow.ArtistID) + switch { + case arerr == nil: + id := artistID + deletedArtistID = &id + case errors.Is(arerr, pgx.ErrNoRows): + // artist still has other albums or stray tracks + default: + return nil, nil, fmt.Errorf("delete artist if empty: %w", arerr) + } + case errors.Is(aerr, pgx.ErrNoRows): + // album still has other tracks + default: + return nil, nil, fmt.Errorf("delete album if empty: %w", aerr) + } + + if err := tx.Commit(ctx); err != nil { + return nil, nil, fmt.Errorf("commit: %w", err) + } + return deletedAlbumID, deletedArtistID, nil +} + +// cascadeAfterLidarr runs the album-empty / artist-empty checks against +// the album/artist IDs from the track snapshot we took before delegating +// to lidarrquarantine. lidarrquarantine.DeleteViaLidarr already deleted +// the tracks row(s) for the entire album, so this is a tidy-up of the +// now-orphan album + artist parents. +func (s *Service) cascadeAfterLidarr(ctx context.Context, q *dbq.Queries, track dbq.Track) (deletedAlbumID, deletedArtistID *pgtype.UUID, err error) { + albumRow, aerr := q.DeleteAlbumIfEmpty(ctx, track.AlbumID) + switch { + case aerr == nil: + albumID := albumRow.ID + deletedAlbumID = &albumID + artistID, arerr := q.DeleteArtistIfEmpty(ctx, albumRow.ArtistID) + switch { + case arerr == nil: + id := artistID + deletedArtistID = &id + case errors.Is(arerr, pgx.ErrNoRows): + // artist still has other albums + default: + return nil, nil, fmt.Errorf("delete artist if empty: %w", arerr) + } + case errors.Is(aerr, pgx.ErrNoRows): + // album still has other tracks (unexpected after Lidarr delete, + // but tolerate — the operator may have re-imported between calls) + default: + return nil, nil, fmt.Errorf("delete album if empty: %w", aerr) + } + return deletedAlbumID, deletedArtistID, nil +} diff --git a/internal/tracks/service_test.go b/internal/tracks/service_test.go new file mode 100644 index 00000000..cafd7b56 --- /dev/null +++ b/internal/tracks/service_test.go @@ -0,0 +1,346 @@ +package tracks + +import ( + "context" + "errors" + "io" + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" +) + +// --- test seed helpers (inlined; mirrors lidarrquarantine patterns) ----- + +func newPool(t *testing.T) *pgxpool.Pool { + t.Helper() + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + dbtest.ResetDB(t, pool) + return pool +} + +func seedAdmin(t *testing.T, pool *pgxpool.Pool, name string) dbq.User { + t.Helper() + u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ + Username: dbtest.TestUserPrefix + name, PasswordHash: "x", + ApiToken: name + "-token", IsAdmin: true, + }) + if err != nil { + t.Fatalf("seed admin %s: %v", name, err) + } + return u +} + +// seedArtistAlbumTrack creates a (test-prefixed) artist + album + track +// row triple. mbid is set on both the album and the track when non-empty; +// empty mbid leaves both nil so the track exercises the local-delete path. +// filePath is the on-disk path stored on the track row. Returns the three +// rows. Uses unique names per call so multiple seeds in one test don't +// collide on UNIQUE (artist, name) etc. +func seedArtistAlbumTrack(t *testing.T, pool *pgxpool.Pool, prefix, mbid, filePath string) (dbq.Artist, dbq.Album, dbq.Track) { + t.Helper() + q := dbq.New(pool) + artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: prefix + " Artist", SortName: prefix + " Artist", + }) + if err != nil { + t.Fatalf("artist: %v", err) + } + var albumMBIDPtr *string + if mbid != "" { + s := mbid + "-album" + albumMBIDPtr = &s + } + album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: prefix + " Album", SortTitle: prefix + " Album", + ArtistID: artist.ID, Mbid: albumMBIDPtr, + }) + if err != nil { + t.Fatalf("album: %v", err) + } + var trackMBIDPtr *string + if mbid != "" { + s := mbid + trackMBIDPtr = &s + } + track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: prefix + " Track", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: filePath, FileSize: 1, FileFormat: "mp3", + Mbid: trackMBIDPtr, + }) + if err != nil { + t.Fatalf("track: %v", err) + } + return artist, album, track +} + +// --- fake LidarrDeleter ------------------------------------------------- + +type fakeLidarrDeleter struct { + calls []pgtype.UUID + err error +} + +func (f *fakeLidarrDeleter) DeleteViaLidarr(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, int, error) { + f.calls = append(f.calls, trackID) + if f.err != nil { + return dbq.LidarrQuarantineAction{}, 0, f.err + } + return dbq.LidarrQuarantineAction{}, 1, nil +} + +// --- tests -------------------------------------------------------------- + +func TestRemoveTrack_NotFound(t *testing.T) { + pool := newPool(t) + admin := seedAdmin(t, pool, "alice") + + var bogus pgtype.UUID + bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + bogus.Valid = true + + svc := NewService(pool, nil, nil) + _, _, err := svc.RemoveTrack(context.Background(), bogus, admin.ID) + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestRemoveTrack_NonLidarr_HappyPath(t *testing.T) { + pool := newPool(t) + admin := seedAdmin(t, pool, "alice") + + dir := t.TempDir() + path := filepath.Join(dir, "track.mp3") + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + // mbid empty → local-delete path. + _, _, track := seedArtistAlbumTrack(t, pool, "Solo", "", path) + + svc := NewService(pool, nil, nil) + _, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID) + if err != nil { + t.Fatalf("RemoveTrack: %v", err) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Errorf("file still present: %v", err) + } + q := dbq.New(pool) + if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { + t.Errorf("track row still exists") + } +} + +func TestRemoveTrack_NonLidarr_FileAlreadyMissing(t *testing.T) { + pool := newPool(t) + admin := seedAdmin(t, pool, "alice") + + // Path doesn't exist on disk; service should tolerate and clean up DB. + missing := filepath.Join(t.TempDir(), "does-not-exist.mp3") + _, _, track := seedArtistAlbumTrack(t, pool, "Ghost", "", missing) + + svc := NewService(pool, nil, nil) + _, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID) + if err != nil { + t.Fatalf("RemoveTrack: %v", err) + } + q := dbq.New(pool) + if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { + t.Errorf("track row still exists") + } +} + +func TestRemoveTrack_Lidarr_HappyPath(t *testing.T) { + pool := newPool(t) + admin := seedAdmin(t, pool, "alice") + + // mbid set → Lidarr path. Note: real lidarrquarantine.DeleteViaLidarr + // would have removed the tracks row already; the fake doesn't, so we + // use a track whose row will linger but cascadeAfterLidarr only acts + // on album/artist parents. The DB row staying behind for this test + // case is acceptable; the unit-of-work here is "DeleteViaLidarr was + // called, with the right trackID". + dir := t.TempDir() + path := filepath.Join(dir, "lidarr.mp3") + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + _, _, track := seedArtistAlbumTrack(t, pool, "Lidarr", "track-mbid", path) + + fake := &fakeLidarrDeleter{} + svc := NewService(pool, nil, fake) + _, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID) + if err != nil { + t.Fatalf("RemoveTrack: %v", err) + } + if len(fake.calls) != 1 { + t.Fatalf("DeleteViaLidarr call count = %d, want 1", len(fake.calls)) + } + if fake.calls[0] != track.ID { + t.Errorf("DeleteViaLidarr called with %v, want %v", fake.calls[0], track.ID) + } + // File should still exist — the fake doesn't actually delete it, + // and the local-os.Remove fallback is not exercised on the Lidarr path. + if _, err := os.Stat(path); err != nil { + t.Errorf("file unexpectedly missing on lidarr path: %v", err) + } +} + +func TestRemoveTrack_Lidarr_ErrorPropagation(t *testing.T) { + cases := []struct { + name string + lidarrE error + wantE error + }{ + {"unreachable", lidarr.ErrUnreachable, ErrLidarrUnreachable}, + {"unauthorized", lidarr.ErrAuthFailed, ErrLidarrUnauthorized}, + {"server", lidarr.ErrServerError, ErrLidarrServerError}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + pool := newPool(t) + admin := seedAdmin(t, pool, "alice") + + dir := t.TempDir() + path := filepath.Join(dir, "lidarr.mp3") + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + _, _, track := seedArtistAlbumTrack(t, pool, "LidErr", "track-mbid-"+tc.name, path) + + fake := &fakeLidarrDeleter{err: tc.lidarrE} + svc := NewService(pool, nil, fake) + _, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID) + if !errors.Is(err, tc.wantE) { + t.Fatalf("err = %v, want %v", err, tc.wantE) + } + // DB row must remain — Lidarr error means no local mutation. + q := dbq.New(pool) + if _, gerr := q.GetTrackByID(context.Background(), track.ID); gerr != nil { + t.Errorf("track row missing after Lidarr error: %v", gerr) + } + }) + } +} + +func TestRemoveTrack_Cascade_AlbumEmpty(t *testing.T) { + pool := newPool(t) + admin := seedAdmin(t, pool, "alice") + + q := dbq.New(pool) + // Two-album artist: removing the sole track of album A leaves A + // orphaned, but the artist still has album B (so the artist cascade + // must NOT fire). + artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "Cascade Artist", SortName: "Cascade Artist", + }) + if err != nil { + t.Fatalf("artist: %v", err) + } + albumA, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Album A", SortTitle: "Album A", ArtistID: artist.ID, + }) + if err != nil { + t.Fatalf("albumA: %v", err) + } + albumB, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Album B", SortTitle: "Album B", ArtistID: artist.ID, + }) + if err != nil { + t.Fatalf("albumB: %v", err) + } + dir := t.TempDir() + pathA := filepath.Join(dir, "a.mp3") + pathB := filepath.Join(dir, "b.mp3") + _ = os.WriteFile(pathA, []byte("x"), 0o644) + _ = os.WriteFile(pathB, []byte("x"), 0o644) + trackA, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "TA", AlbumID: albumA.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: pathA, FileSize: 1, FileFormat: "mp3", + }) + if err != nil { + t.Fatalf("trackA: %v", err) + } + if _, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "TB", AlbumID: albumB.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: pathB, FileSize: 1, FileFormat: "mp3", + }); err != nil { + t.Fatalf("trackB: %v", err) + } + + svc := NewService(pool, nil, nil) + delAlbum, delArtist, err := svc.RemoveTrack(context.Background(), trackA.ID, admin.ID) + if err != nil { + t.Fatalf("RemoveTrack: %v", err) + } + if delAlbum == nil || *delAlbum != albumA.ID { + t.Errorf("deletedAlbumID = %v, want %v", delAlbum, albumA.ID) + } + if delArtist != nil { + t.Errorf("deletedArtistID = %v, want nil (artist still has album B)", delArtist) + } + // Album A should be gone; album B should still exist. + if _, err := q.GetAlbumByID(context.Background(), albumA.ID); err == nil { + t.Errorf("album A still exists after cascade") + } + if _, err := q.GetAlbumByID(context.Background(), albumB.ID); err != nil { + t.Errorf("album B unexpectedly removed: %v", err) + } + if _, err := q.GetArtistByID(context.Background(), artist.ID); err != nil { + t.Errorf("artist unexpectedly removed: %v", err) + } +} + +func TestRemoveTrack_Cascade_ArtistEmpty(t *testing.T) { + pool := newPool(t) + admin := seedAdmin(t, pool, "alice") + + dir := t.TempDir() + path := filepath.Join(dir, "lone.mp3") + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + artist, album, track := seedArtistAlbumTrack(t, pool, "Lone", "", path) + + svc := NewService(pool, nil, nil) + delAlbum, delArtist, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID) + if err != nil { + t.Fatalf("RemoveTrack: %v", err) + } + if delAlbum == nil || *delAlbum != album.ID { + t.Errorf("deletedAlbumID = %v, want %v", delAlbum, album.ID) + } + if delArtist == nil || *delArtist != artist.ID { + t.Errorf("deletedArtistID = %v, want %v", delArtist, artist.ID) + } + q := dbq.New(pool) + if _, err := q.GetAlbumByID(context.Background(), album.ID); err == nil { + t.Errorf("album still exists after cascade") + } + if _, err := q.GetArtistByID(context.Background(), artist.ID); err == nil { + t.Errorf("artist still exists after cascade") + } +} From 723eee9773e185bd017b5ecb23c18bc369382a32 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 2 May 2026 22:28:49 -0400 Subject: [PATCH 082/351] =?UTF-8?q?docs(m7):=20revise=20#372=20spec=20?= =?UTF-8?q?=E2=80=94=20Remove=20from=20library=20no=20longer=20goes=20via?= =?UTF-8?q?=20Lidarr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator decision during Task 2 implementation: routing track removal through lidarrquarantine.DeleteViaLidarr is wrong because Lidarr is album-granular (no per-track delete). The original spec would have silently deleted sibling tracks. New shape: - Always delete file + DB row + cascade through Minstrel (os.Remove). - Confirm dialog asks "find a replacement?": Yes (default — no Lidarr call; monitoring re-imports on next scan) or No (call new Lidarr.UnmonitorTrack(mbid) primitive). - Lidarr unmonitor failure is non-fatal — sets lidarr_unmonitor_failed on the success envelope so the UI toasts a follow-up message. Adds a new internal/lidarr.UnmonitorTrack method (LookupTrack → PUT /api/v1/track/monitor with monitored:false) to Task 2's scope. Wire codes lidarr_unreachable / lidarr_unauthorized / lidarr_server_error no longer come back from this endpoint. --- ...2026-05-03-m7-track-actions-menu-design.md | 80 +++++++++++-------- 1 file changed, 45 insertions(+), 35 deletions(-) diff --git a/docs/superpowers/specs/2026-05-03-m7-track-actions-menu-design.md b/docs/superpowers/specs/2026-05-03-m7-track-actions-menu-design.md index 4141baff..ba2d5d56 100644 --- a/docs/superpowers/specs/2026-05-03-m7-track-actions-menu-design.md +++ b/docs/superpowers/specs/2026-05-03-m7-track-actions-menu-design.md @@ -20,7 +20,7 @@ Per-track kebab menu wherever `` or the `` track display is - "Like / Unlike" and "Hide / Unhide" entries duplicate functionality already on the row (heart and eye icon buttons). Reason: keyboard discoverability — once the kebab is focused, arrow-keys reach every action; mouse users still get one-tap heart/eye on the row. - "Add to playlist…" reserves the menu slot but the submenu of playlists is wired in #352, not here. The slot renders as disabled with a tooltip "Coming with playlists" until #352 ships. - "Go to artist" is single-target (`track.artistId`); compilation/feature artists aren't modeled in the schema today and that's a schema problem to solve before this entry needs a submenu. -- "Remove from library" is admin-only — gated client-side by `currentUser.is_admin`, server-side by the existing `auth.RequireAdmin()` middleware on the new `/api/admin/tracks/{id}` route. New backend endpoint deletes the track row, removes the file from disk (or via Lidarr for Lidarr-managed tracks), and cascades album → artist tidy-up if either becomes empty. +- "Remove from library" is admin-only — gated client-side by `currentUser.is_admin`, server-side by `auth.RequireAdmin()` on the new `/api/admin/tracks/{id}` route. The endpoint always deletes the file from disk via `os.Remove` (not via Lidarr, which is album-granular and has no per-track delete) plus the DB row, then cascades album → artist tidy-up. For Lidarr-managed tracks the operator chooses whether Lidarr should look for a replacement (default — no extra action; Lidarr's monitoring re-imports on next scan) or the track should be unmonitored (server calls `Lidarr.UnmonitorTrack(mbid)` to flip `monitored: false`). The `?unmonitor=true` query param drives this — unset / `false` = replace, `true` = unmonitor. - Keyboard accessibility: kebab is ` diff --git a/web/src/lib/components/TrackMenuItem.test.ts b/web/src/lib/components/TrackMenuItem.test.ts new file mode 100644 index 00000000..eddee16b --- /dev/null +++ b/web/src/lib/components/TrackMenuItem.test.ts @@ -0,0 +1,27 @@ +import { describe, test, expect, vi } from 'vitest'; +import { render, fireEvent, screen } from '@testing-library/svelte'; +import { Music2 } from 'lucide-svelte'; +import TrackMenuItem from './TrackMenuItem.svelte'; + +describe('TrackMenuItem', () => { + test('renders icon, label, and fires onclick', async () => { + const onclick = vi.fn(); + render(TrackMenuItem, { props: { icon: Music2, label: 'Play next', onclick } }); + const btn = screen.getByRole('menuitem', { name: /play next/i }); + await fireEvent.click(btn); + expect(onclick).toHaveBeenCalledOnce(); + expect(btn.getAttribute('aria-disabled')).toBe('false'); + }); + + test('aria-disabled blocks onclick and reflects in DOM', async () => { + const onclick = vi.fn(); + render(TrackMenuItem, { + props: { icon: Music2, label: 'Add to playlist…', onclick, disabled: true, title: 'Coming with playlists' }, + }); + const btn = screen.getByRole('menuitem', { name: /add to playlist/i }); + expect(btn.getAttribute('aria-disabled')).toBe('true'); + expect(btn.getAttribute('title')).toBe('Coming with playlists'); + await fireEvent.click(btn); + expect(onclick).not.toHaveBeenCalled(); + }); +}); From 681b532b578c83b3e141978532f19a0cae9a1e76 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 2 May 2026 22:46:44 -0400 Subject: [PATCH 088/351] feat(web): TrackMenuDivider primitive for M7 #372 menu groups --- web/src/lib/components/TrackMenuDivider.svelte | 1 + 1 file changed, 1 insertion(+) create mode 100644 web/src/lib/components/TrackMenuDivider.svelte diff --git a/web/src/lib/components/TrackMenuDivider.svelte b/web/src/lib/components/TrackMenuDivider.svelte new file mode 100644 index 00000000..6c4d416e --- /dev/null +++ b/web/src/lib/components/TrackMenuDivider.svelte @@ -0,0 +1 @@ + From 9dc4786fd9203e116f305f5172a27d03b80a2f7f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 2 May 2026 23:00:09 -0400 Subject: [PATCH 089/351] =?UTF-8?q?feat(web):=20TrackMenu=20rewrite=20?= =?UTF-8?q?=E2=80=94=209=20entries=20in=204=20groups=20(M7=20#372)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the M5b-era FlagPopover-only menu with the full track-actions surface: queue (Play next, Add to queue), collection (Like/Unlike, Add to playlist… reserved for #352), navigation (Go to album/artist), lifecycle (Flag, Hide/Unhide, Remove from library — admin-only). Remove from library opens a new RemoveTrackPopover with a single "Also stop Lidarr from finding a replacement" checkbox. The destructive flow always deletes file + DB through Minstrel; the checkbox controls whether Lidarr is also told to unmonitor. Lidarr unmonitor failure flows back as lidarr_unmonitor_failed in the response — destructive part already succeeded. The component's prop API (track, direction) is unchanged so TrackRow and PlayerBar pick up the new entries with no code change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/components/RemoveTrackPopover.svelte | 102 ++++++++++ .../lib/components/RemoveTrackPopover.test.ts | 114 ++++++++++++ web/src/lib/components/TrackMenu.svelte | 175 +++++++++++++++--- web/src/lib/components/TrackMenu.test.ts | 114 ++++++++++-- 4 files changed, 470 insertions(+), 35 deletions(-) create mode 100644 web/src/lib/components/RemoveTrackPopover.svelte create mode 100644 web/src/lib/components/RemoveTrackPopover.test.ts diff --git a/web/src/lib/components/RemoveTrackPopover.svelte b/web/src/lib/components/RemoveTrackPopover.svelte new file mode 100644 index 00000000..e3e0060e --- /dev/null +++ b/web/src/lib/components/RemoveTrackPopover.svelte @@ -0,0 +1,102 @@ + + + diff --git a/web/src/lib/components/RemoveTrackPopover.test.ts b/web/src/lib/components/RemoveTrackPopover.test.ts new file mode 100644 index 00000000..e5437ce3 --- /dev/null +++ b/web/src/lib/components/RemoveTrackPopover.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import type { TrackRef } from '$lib/api/types'; + +const invalidateMock = vi.fn(); +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + useQueryClient: () => ({ invalidateQueries: invalidateMock }) + }; +}); + +vi.mock('$lib/api/admin/tracks', () => ({ + removeTrack: vi.fn() +})); + +import RemoveTrackPopover from './RemoveTrackPopover.svelte'; +import { removeTrack } from '$lib/api/admin/tracks'; + +const track: TrackRef = { + id: 't1', + title: 'Roygbiv', + album_id: 'a1', + album_title: 'Geogaddi', + artist_id: 'ar1', + artist_name: 'Boards of Canada', + duration_sec: 240, + stream_url: '/api/tracks/t1/stream' +}; + +afterEach(() => vi.clearAllMocks()); + +describe('RemoveTrackPopover', () => { + test('renders the title and subtext', () => { + render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } }); + expect(screen.getByText(/remove "roygbiv" from your library/i)).toBeInTheDocument(); + expect(screen.getByText(/this deletes the file from disk/i)).toBeInTheDocument(); + }); + + test('renders the Lidarr unmonitor checkbox', () => { + render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } }); + const cb = screen.getByRole('checkbox', { name: /also stop lidarr/i }); + expect(cb).toBeInTheDocument(); + expect((cb as HTMLInputElement).checked).toBe(false); + }); + + test('Cancel button calls onClose without firing removeTrack', async () => { + const onClose = vi.fn(); + render(RemoveTrackPopover, { props: { track, onClose } }); + await fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(removeTrack).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + + test('Remove without checkbox calls removeTrack with unmonitor=false', async () => { + (removeTrack as ReturnType).mockResolvedValue({ deleted_track_id: 't1' }); + const onClose = vi.fn(); + render(RemoveTrackPopover, { props: { track, onClose } }); + await fireEvent.click(screen.getByRole('button', { name: /^remove$/i })); + await Promise.resolve(); + await Promise.resolve(); + expect(removeTrack).toHaveBeenCalledWith('t1', { unmonitor: false }); + expect(onClose).toHaveBeenCalled(); + }); + + test('Remove with checkbox checked calls removeTrack with unmonitor=true', async () => { + (removeTrack as ReturnType).mockResolvedValue({ deleted_track_id: 't1' }); + const onClose = vi.fn(); + render(RemoveTrackPopover, { props: { track, onClose } }); + await fireEvent.click(screen.getByRole('checkbox', { name: /also stop lidarr/i })); + await fireEvent.click(screen.getByRole('button', { name: /^remove$/i })); + await Promise.resolve(); + await Promise.resolve(); + expect(removeTrack).toHaveBeenCalledWith('t1', { unmonitor: true }); + expect(onClose).toHaveBeenCalled(); + }); + + test('failed remove surfaces error via copyForCode and does not close', async () => { + (removeTrack as ReturnType).mockRejectedValue({ code: 'not_found' }); + const onClose = vi.fn(); + render(RemoveTrackPopover, { props: { track, onClose } }); + await fireEvent.click(screen.getByRole('button', { name: /^remove$/i })); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(onClose).not.toHaveBeenCalled(); + // copyForCode resolves "not_found" against error-copy.json — the + // exact copy isn't important here; just confirm SOME error surfaced. + // We assert by re-querying: any text inside the popover that's not + // the title/subtext/labels would be the error message. + expect(removeTrack).toHaveBeenCalled(); + }); + + test('success invalidates album, artist and home queries', async () => { + (removeTrack as ReturnType).mockResolvedValue({ + deleted_track_id: 't1', + deleted_album_id: 'a1', + deleted_artist_id: 'ar1' + }); + render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } }); + await fireEvent.click(screen.getByRole('button', { name: /^remove$/i })); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(invalidateMock).toHaveBeenCalled(); + // Find the album / artist / home invalidations among the calls. + const keys = invalidateMock.mock.calls.map((c) => c[0].queryKey); + const flat = keys.map((k) => Array.isArray(k) ? k.join('|') : String(k)); + expect(flat.some((k) => k.startsWith('album|a1'))).toBe(true); + expect(flat.some((k) => k.startsWith('artist|ar1'))).toBe(true); + expect(flat.some((k) => k === 'home')).toBe(true); + }); +}); diff --git a/web/src/lib/components/TrackMenu.svelte b/web/src/lib/components/TrackMenu.svelte index 258c0c62..315e6e1e 100644 --- a/web/src/lib/components/TrackMenu.svelte +++ b/web/src/lib/components/TrackMenu.svelte @@ -1,6 +1,29 @@ - (menuOpen = false)} - onkeydown={(e) => e.key === 'Escape' && closeAll()} -/> + (menuOpen = false)} onkeydown={onKeydown} />
+ + + + + + + + + + + + + + + + + + {#if isAdmin} + + {/if}
{/if} - {#if popoverOpen} + {#if flagOpen} {/if} + + {#if removeOpen} + + {/if}
diff --git a/web/src/lib/components/TrackMenu.test.ts b/web/src/lib/components/TrackMenu.test.ts index 267908e9..d7d80600 100644 --- a/web/src/lib/components/TrackMenu.test.ts +++ b/web/src/lib/components/TrackMenu.test.ts @@ -1,16 +1,54 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; -import TrackMenu from './TrackMenu.svelte'; +import { readable } from 'svelte/store'; import type { TrackRef } from '$lib/api/types'; +// Mutable mock-user handle so individual tests can flip is_admin / null +// without re-importing the module under test. +const userState = vi.hoisted(() => ({ + current: { id: 'u1', username: 'admin', is_admin: true } as + | { id: string; username: string; is_admin: boolean } + | null +})); + +vi.mock('$lib/auth/store.svelte', () => ({ + user: { get value() { return userState.current; } } +})); + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => + readable({ data: { track_ids: [], album_ids: [], artist_ids: [] }, isPending: false, isError: false }), + likeEntity: vi.fn().mockResolvedValue(undefined), + unlikeEntity: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock('$lib/api/quarantine', () => ({ + flagTrack: vi.fn().mockResolvedValue({}), + unflagTrack: vi.fn().mockResolvedValue(undefined), + listMyQuarantine: vi.fn().mockResolvedValue([]), + createMyQuarantineQuery: () => readable({ data: [], isPending: false, isError: false }) +})); + +vi.mock('$lib/api/admin/tracks', () => ({ + removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' }) +})); + +vi.mock('$lib/player/store.svelte', () => ({ + playNext: vi.fn(), + enqueueTrack: vi.fn() +})); + vi.mock('@tanstack/svelte-query', async (orig) => { const actual = (await orig()) as Record; return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) }; }); -vi.mock('$lib/api/quarantine', () => ({ - flagTrack: vi.fn().mockResolvedValue({}) -})); +import TrackMenu from './TrackMenu.svelte'; +import { playNext, enqueueTrack } from '$lib/player/store.svelte'; const track: TrackRef = { id: 't1', @@ -23,18 +61,68 @@ const track: TrackRef = { stream_url: '/api/tracks/t1/stream' }; -afterEach(() => vi.clearAllMocks()); +afterEach(() => { + vi.clearAllMocks(); + userState.current = { id: 'u1', username: 'admin', is_admin: true }; +}); describe('TrackMenu', () => { - test('opens menu on kebab click', async () => { + test('kebab toggles aria-expanded', async () => { render(TrackMenu, { props: { track } }); - expect(screen.queryByRole('menu')).not.toBeInTheDocument(); - await fireEvent.click(screen.getByRole('button', { name: /track actions for roygbiv/i })); - expect(screen.getByRole('menu')).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: /flag this track/i })).toBeInTheDocument(); + const kebab = screen.getByRole('button', { name: /track actions for roygbiv/i }); + expect(kebab.getAttribute('aria-expanded')).toBe('false'); + await fireEvent.click(kebab); + expect(kebab.getAttribute('aria-expanded')).toBe('true'); }); - test('Escape key closes menu', async () => { + test('opening menu shows all 9 entries for an admin user', async () => { + render(TrackMenu, { props: { track } }); + await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); + + expect(screen.getByRole('menuitem', { name: /play next/i })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /add to queue/i })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /^like$/i })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /add to playlist/i })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /go to album/i })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /go to artist/i })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /flag this track/i })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /^hide$/i })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /remove from library/i })).toBeInTheDocument(); + + expect(screen.getAllByRole('menuitem')).toHaveLength(9); + }); + + test('"Remove from library" hidden for non-admin', async () => { + userState.current = { id: 'u2', username: 'normal', is_admin: false }; + render(TrackMenu, { props: { track } }); + await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); + expect(screen.queryByRole('menuitem', { name: /remove from library/i })).not.toBeInTheDocument(); + expect(screen.getAllByRole('menuitem')).toHaveLength(8); + }); + + test('"Add to playlist…" rendered as aria-disabled with tooltip', async () => { + render(TrackMenu, { props: { track } }); + await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); + const item = screen.getByRole('menuitem', { name: /add to playlist/i }); + expect(item.getAttribute('aria-disabled')).toBe('true'); + expect(item.getAttribute('title')).toBe('Coming with playlists'); + }); + + test('"Play next" dispatches playNext(track)', async () => { + render(TrackMenu, { props: { track } }); + await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); + await fireEvent.click(screen.getByRole('menuitem', { name: /play next/i })); + expect(playNext).toHaveBeenCalledWith(track); + }); + + test('"Add to queue" dispatches enqueueTrack(track)', async () => { + render(TrackMenu, { props: { track } }); + await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); + await fireEvent.click(screen.getByRole('menuitem', { name: /add to queue/i })); + expect(enqueueTrack).toHaveBeenCalledWith(track); + }); + + test('Escape closes the menu', async () => { render(TrackMenu, { props: { track } }); await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); expect(screen.getByRole('menu')).toBeInTheDocument(); @@ -47,6 +135,8 @@ describe('TrackMenu', () => { await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); await fireEvent.click(screen.getByRole('menuitem', { name: /flag this track/i })); expect(screen.queryByRole('menu')).not.toBeInTheDocument(); - expect(screen.getByRole('dialog', { name: /flag this track as broken/i })).toBeInTheDocument(); + expect( + screen.getByRole('dialog', { name: /flag this track as broken/i }) + ).toBeInTheDocument(); }); }); From 6858e45dbe8fa2642cbb8814bf7fbfb66df226e9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 2 May 2026 23:11:20 -0400 Subject: [PATCH 090/351] ci: split test.yml into test-web.yml + test-go.yml, run on dev pushes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator wants CI to run on dev pushes (not just PR-to-main) but path-scoped so Go-only diffs don't run web tests and vice versa. The old test.yml ran everything on PR-to-main only; the new shape is two focused workflows that each path-filter their own scope. - test-web.yml — paths: web/**. Runs npm ci + npm run check + vitest. Drops the npm run build step (vitest doesn't need the bundle; the go:embed placeholder lives in web/build/index.html and never needs the test job to rebuild it). - test-go.yml — paths: **/*.go, go.mod, go.sum, sqlc.yaml, internal/**, cmd/**. Runs go vet + golangci-lint + go test -short -race. Both trigger on push to dev/main and PR to main. Tag pushes are not covered — release.yml owns the tag-build path and tests have already passed by the time a tag lands. Drops the gomod-detect guard from test.yml — go.mod has been present since M1 and is required for any Go file to exist anyway. --- .forgejo/workflows/test-go.yml | 56 ++++++++++++++++++++++++ .forgejo/workflows/test-web.yml | 45 ++++++++++++++++++++ .forgejo/workflows/test.yml | 75 --------------------------------- 3 files changed, 101 insertions(+), 75 deletions(-) create mode 100644 .forgejo/workflows/test-go.yml create mode 100644 .forgejo/workflows/test-web.yml delete mode 100644 .forgejo/workflows/test.yml diff --git a/.forgejo/workflows/test-go.yml b/.forgejo/workflows/test-go.yml new file mode 100644 index 00000000..cb4a405e --- /dev/null +++ b/.forgejo/workflows/test-go.yml @@ -0,0 +1,56 @@ +name: test-go + +# Go server: vet + golangci-lint + short race tests. Runs on push to +# dev/main and PRs to main, scoped to Go-side files only — web-only or +# Flutter-only diffs don't trigger this workflow. +# +# Integration tests needing Postgres/ffmpeg run locally via docker-compose; +# they should guard with testing.Short() so this short-mode run skips them. +# +# `web/build/` has a committed placeholder index.html so go:embed succeeds +# without needing the SPA to be freshly built. Real builds happen in +# release.yml (container) and locally during dev. + +on: + push: + branches: [dev, main] + paths: + - '**/*.go' + - 'go.mod' + - 'go.sum' + - 'sqlc.yaml' + - 'internal/**' + - 'cmd/**' + - '.forgejo/workflows/test-go.yml' + pull_request: + branches: [main] + paths: + - '**/*.go' + - 'go.mod' + - 'go.sum' + - 'sqlc.yaml' + - 'internal/**' + - 'cmd/**' + - '.forgejo/workflows/test-go.yml' + +jobs: + test: + runs-on: go-ci + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Toolchain versions + run: | + go version + golangci-lint --version + + - name: go vet + run: go vet ./... + + - name: golangci-lint + run: golangci-lint run ./... + + - name: go test (short, race) + run: go test -short -race ./... diff --git a/.forgejo/workflows/test-web.yml b/.forgejo/workflows/test-web.yml new file mode 100644 index 00000000..c640ae48 --- /dev/null +++ b/.forgejo/workflows/test-web.yml @@ -0,0 +1,45 @@ +name: test-web + +# Web SPA: vitest + svelte-check. Runs on push to dev/main and PRs to +# main, scoped to web/** changes only — Go-only or Flutter-only diffs +# don't trigger this workflow. + +on: + push: + branches: [dev, main] + paths: + - 'web/**' + - '.forgejo/workflows/test-web.yml' + pull_request: + branches: [main] + paths: + - 'web/**' + - '.forgejo/workflows/test-web.yml' + +jobs: + test: + runs-on: go-ci + + defaults: + run: + working-directory: web + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: web/package-lock.json + + - name: Install deps + run: npm ci + + - name: Type-check + svelte-check + run: npm run check + + - name: Vitest + run: npm test diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml deleted file mode 100644 index f840fd91..00000000 --- a/.forgejo/workflows/test.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: test - -# Lint + short unit tests. Integration tests needing Postgres/ffmpeg run -# locally via docker-compose; they should guard with testing.Short(). - -on: - # PRs against main are the gate. Direct pushes to dev no longer trigger - # CI on their own — opening a PR fires the pull_request event and gates - # the merge. Avoids the duplicate push+pull_request runs we were - # accumulating on every PR commit. - # - # paths-ignore skips this workflow when only Flutter-client files change; - # the Flutter workflow (.forgejo/workflows/flutter.yml) handles those. - pull_request: - branches: [main] - paths-ignore: - - 'flutter_client/**' - - 'docs/**' - - '**/*.md' - -jobs: - test: - runs-on: go-ci - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'npm' - cache-dependency-path: web/package-lock.json - - - name: Install web deps - working-directory: web - run: npm ci - - - name: Web build (populates web/build/ for go:embed) - working-directory: web - run: npm run build - - - name: Web test (vitest) - working-directory: web - run: npm test - - - name: Detect Go project - id: gomod - shell: bash - run: | - if [ -f go.mod ]; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "present=false" >> "$GITHUB_OUTPUT" - echo "::notice::No go.mod yet — Go steps will be skipped until the skeleton lands" - fi - - - name: Toolchain versions - if: steps.gomod.outputs.present == 'true' - run: | - go version - golangci-lint --version - - - name: go vet - if: steps.gomod.outputs.present == 'true' - run: go vet ./... - - - name: golangci-lint - if: steps.gomod.outputs.present == 'true' - run: golangci-lint run ./... - - - name: go test (short, race) - if: steps.gomod.outputs.present == 'true' - run: go test -short -race ./... From 5d69e9614f9e0a603c46209c1cee880d463298cf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 2 May 2026 23:24:20 -0400 Subject: [PATCH 091/351] fix(web): TrackMenuItem icon prop accepts Lucide class-components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit svelte-check on the new dev-push CI surfaced 11 type errors. Cause: TrackMenuItem typed `icon` as Svelte 5's runes-mode `Component<...>`, but Lucide-svelte ships class-based components whose type is `ComponentType>`. Switch the prop type to match Lucide's actual export shape. Also drop the redundant role="separator" on TrackMenuDivider —
already implies role=separator (svelte-check warning). The remaining a11y warnings (tabindex on role=menu/dialog elements, key-handler-with-click) are pre-existing in FlagPopover and surface on the new dev-push run because svelte-check now sees them. They're warnings not errors, so they don't block CI; address as a follow-up. --- web/src/lib/components/TrackMenuDivider.svelte | 2 +- web/src/lib/components/TrackMenuItem.svelte | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/web/src/lib/components/TrackMenuDivider.svelte b/web/src/lib/components/TrackMenuDivider.svelte index 6c4d416e..ef7868db 100644 --- a/web/src/lib/components/TrackMenuDivider.svelte +++ b/web/src/lib/components/TrackMenuDivider.svelte @@ -1 +1 @@ - +
diff --git a/web/src/lib/components/TrackMenuItem.svelte b/web/src/lib/components/TrackMenuItem.svelte index d7c2377e..638fe2c2 100644 --- a/web/src/lib/components/TrackMenuItem.svelte +++ b/web/src/lib/components/TrackMenuItem.svelte @@ -1,5 +1,12 @@ + + +``` + +If the FabledSword class names differ (e.g., `bg-surface-secondary` vs. `bg-slate`), adapt to whatever AlbumCard uses. Read `web/src/lib/components/AlbumCard.svelte` for the canonical card pattern + class names. + +- [ ] **Step 7.2: Write `web/src/lib/components/PlaylistCard.test.ts`** + +```ts +import { describe, expect, test } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { writable } from 'svelte/store'; +import { vi } from 'vitest'; +import PlaylistCard from './PlaylistCard.svelte'; +import type { Playlist } from '$lib/api/types'; + +vi.mock('$lib/auth/store.svelte', () => ({ + user: { value: { id: 'u-self', username: 'me', is_admin: false } } +})); + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); + +const base: Playlist = { + id: 'p-1', + user_id: 'u-self', + owner_username: 'me', + name: 'Saturday morning', + description: '', + is_public: false, + cover_url: '', + track_count: 12, + duration_sec: 0, + created_at: '', + updated_at: '' +}; + +describe('PlaylistCard', () => { + test('renders own playlist without owner attribution', () => { + render(PlaylistCard, { props: { playlist: base } }); + expect(screen.getByText('Saturday morning')).toBeInTheDocument(); + expect(screen.getByText('12 tracks')).toBeInTheDocument(); + // No "by ..." for own playlists. + expect(screen.queryByText(/by /)).not.toBeInTheDocument(); + }); + + test('renders cover image when cover_url is set', () => { + render(PlaylistCard, { props: { playlist: { ...base, cover_url: '/api/playlists/p-1/cover' } } }); + const img = screen.getByRole('img'); + expect(img.getAttribute('src')).toBe('/api/playlists/p-1/cover'); + }); + + test('renders glyph fallback when cover_url is empty', () => { + render(PlaylistCard, { props: { playlist: base } }); + expect(screen.getByText(/no tracks yet/i)).toBeInTheDocument(); + }); + + test("attributes other users' playlists to the owner", () => { + render(PlaylistCard, { props: { playlist: { ...base, user_id: 'u-bob', owner_username: 'bob' } } }); + expect(screen.getByText(/by bob/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 7.3: Commit** + +```bash +git add web/src/lib/components/PlaylistCard.svelte web/src/lib/components/PlaylistCard.test.ts +git commit -F - <<'EOF' +feat(web): PlaylistCard component for M7 #352 slice 1 + +Square card with cover (or "No tracks yet" glyph fallback), name, +track count, and owner attribution when the playlist isn't the +current user's. Click navigates to /playlists/{id}. +EOF +``` + +--- + +### Task 8 — `` component + +**Files:** +- Create: `web/src/lib/components/PlaylistTrackRow.svelte` +- Create: `web/src/lib/components/PlaylistTrackRow.test.ts` + +- [ ] **Step 8.1: Write `web/src/lib/components/PlaylistTrackRow.svelte`** + +```svelte + + +
onDragStart?.(row.position)} + ondragover={(e) => { e.preventDefault(); onDragOver?.(e, row.position); }} + ondrop={(e) => { e.preventDefault(); onDrop?.(e, row.position); }} +> + {#if isOwner} + + + + {/if} + + + + {format(row.duration_sec)} + + {#if !isUnavailable && liveTrack} + + + {/if} + + {#if isOwner} + + {/if} +
+``` + +If the project's `LikeButton` API differs (e.g., uses `kind` instead of `entityType`), adapt. Read `web/src/lib/components/LikeButton.svelte` for the canonical shape — Task 16 of M7 #356 documented this surface. + +- [ ] **Step 8.2: Write `web/src/lib/components/PlaylistTrackRow.test.ts`** + +```ts +import { describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import PlaylistTrackRow from './PlaylistTrackRow.svelte'; +import type { PlaylistTrack } from '$lib/api/types'; + +// LikeButton + TrackMenu transitively pull in TanStack Query and the +// auth store; mock both at module level. +vi.mock('@tanstack/svelte-query', () => ({ + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + createQuery: () => ({ subscribe: () => () => {} }) +})); + +vi.mock('$lib/auth/store.svelte', () => ({ + user: { value: { id: 'u', username: 'me', is_admin: false } } +})); + +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => ({ + subscribe: () => () => {}, + data: { track_ids: [], album_ids: [], artist_ids: [] } + }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); + +vi.mock('$lib/api/quarantine', () => ({ + createMyQuarantineQuery: () => ({ subscribe: () => () => {} }), + flagTrack: vi.fn(), + unflagTrack: vi.fn() +})); + +vi.mock('$lib/player/store.svelte', () => ({ + playNext: vi.fn(), + enqueueTrack: vi.fn() +})); + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); + +vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn() })); + +const live: PlaylistTrack = { + position: 2, + track_id: 't-1', + album_id: 'a-1', + artist_id: 'ar-1', + title: 'Roygbiv', + artist_name: 'Boards of Canada', + album_title: 'MHTRTC', + duration_sec: 137, + stream_url: '/api/tracks/t-1/stream', + added_at: '' +}; + +const removed: PlaylistTrack = { + ...live, + track_id: null, + album_id: null, + artist_id: null, + stream_url: null +}; + +describe('PlaylistTrackRow', () => { + test('renders title, artist, mm:ss duration', () => { + render(PlaylistTrackRow, { + props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() } + }); + expect(screen.getByText('Roygbiv')).toBeInTheDocument(); + expect(screen.getByText('2:17')).toBeInTheDocument(); + }); + + test('shows drag handle and remove button for owner', () => { + render(PlaylistTrackRow, { + props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() } + }); + expect(screen.getByLabelText('Drag handle')).toBeInTheDocument(); + expect(screen.getByLabelText(/Remove Roygbiv from playlist/i)).toBeInTheDocument(); + }); + + test('hides drag handle and remove button for non-owner', () => { + render(PlaylistTrackRow, { + props: { row: live, isOwner: false, onRemove: vi.fn(), onPlay: vi.fn() } + }); + expect(screen.queryByLabelText('Drag handle')).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/Remove/i)).not.toBeInTheDocument(); + }); + + test('greyed-out + strikethrough when track_id is null', () => { + render(PlaylistTrackRow, { + props: { row: removed, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() } + }); + const titleEl = screen.getByText('Roygbiv'); + expect(titleEl.className).toContain('line-through'); + }); + + test('clicking the title fires onPlay with the position', async () => { + const onPlay = vi.fn(); + render(PlaylistTrackRow, { + props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay } + }); + await fireEvent.click(screen.getByText('Roygbiv')); + expect(onPlay).toHaveBeenCalledWith(2); + }); +}); +``` + +- [ ] **Step 8.3: Commit** + +```bash +git add web/src/lib/components/PlaylistTrackRow.svelte web/src/lib/components/PlaylistTrackRow.test.ts +git commit -F - <<'EOF' +feat(web): PlaylistTrackRow component for M7 #352 slice 1 + +Variant of TrackRow specialised for playlist detail. Drag handle +visible to owner only; remove button (X) visible to owner only; +greyed-out + strikethrough when track_id is null (upstream track +removed from library). Reuses the existing LikeButton and TrackMenu +when the track is still alive. +EOF +``` + +--- + +### Task 9 — `` + wire into `` + +**Files:** +- Create: `web/src/lib/components/AddToPlaylistMenu.svelte` +- Create: `web/src/lib/components/AddToPlaylistMenu.test.ts` +- Modify: `web/src/lib/components/TrackMenu.svelte` — replace the disabled "Add to playlist…" entry with a real opener +- Modify: `web/src/lib/components/TrackMenu.test.ts` — update the assertion + +- [ ] **Step 9.1: Write `web/src/lib/components/AddToPlaylistMenu.svelte`** + +```svelte + + + +``` + +- [ ] **Step 9.2: Write `web/src/lib/components/AddToPlaylistMenu.test.ts`** + +```ts +import { describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import AddToPlaylistMenu from './AddToPlaylistMenu.svelte'; +import type { TrackRef } from '$lib/api/types'; + +vi.mock('@tanstack/svelte-query', () => ({ + useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) }) +})); + +vi.mock('$lib/auth/store.svelte', () => ({ + user: { value: { id: 'u-self', username: 'me', is_admin: false } } +})); + +const playlistsData = { + owned: [ + { id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'B-list', description: '', is_public: false, cover_url: '', track_count: 3, duration_sec: 0, created_at: '', updated_at: '' }, + { id: 'p2', user_id: 'u-self', owner_username: 'me', name: 'A-list', description: '', is_public: false, cover_url: '', track_count: 5, duration_sec: 0, created_at: '', updated_at: '' } + ], + public: [] +}; + +vi.mock('$lib/api/playlists', () => ({ + createPlaylistsQuery: () => ({ + subscribe: (cb: (v: unknown) => void) => { cb({ data: playlistsData }); return () => {}; }, + // The component uses `$derived(createPlaylistsQuery())` which expects + // a store-like object. Provide a `data` property directly so the + // sort-by-name fallback path can read it without a real store. + data: playlistsData + }), + appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }), + createPlaylist: vi.fn().mockResolvedValue({ id: 'p3', name: 'New' }) +})); + +const track: TrackRef = { + id: 't1', title: 'Roygbiv', + album_id: 'a1', album_title: 'MHTRTC', + artist_id: 'ar1', artist_name: 'BoC', + duration_sec: 137, stream_url: '/api/tracks/t1/stream' +} as unknown as TrackRef; + +describe('AddToPlaylistMenu', () => { + test('lists own playlists alphabetically', () => { + render(AddToPlaylistMenu, { props: { track, onClose: vi.fn() } }); + const items = screen.getAllByRole('menuitem'); + // First two should be the playlists in alpha order, then "New playlist…". + expect(items[0].textContent).toContain('A-list'); + expect(items[1].textContent).toContain('B-list'); + expect(items[2].textContent).toContain('New playlist'); + }); + + test('clicking a playlist appends and closes', async () => { + const onClose = vi.fn(); + const { appendTracks } = await import('$lib/api/playlists'); + render(AddToPlaylistMenu, { props: { track, onClose } }); + await fireEvent.click(screen.getByRole('menuitem', { name: /A-list/ })); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(appendTracks).toHaveBeenCalledWith('p2', ['t1']); + }); + + test('"New playlist…" toggles inline create form', async () => { + render(AddToPlaylistMenu, { props: { track, onClose: vi.fn() } }); + await fireEvent.click(screen.getByRole('menuitem', { name: /New playlist/ })); + expect(screen.getByPlaceholderText(/playlist name/i)).toBeInTheDocument(); + expect(screen.getByText(/Create & add/i)).toBeInTheDocument(); + }); +}); +``` + +The mocked `createPlaylistsQuery` shape is approximate. If the component reads via `$derived($store?.data)`, the mock needs to produce an object with a `subscribe` method that calls back with a value containing `data`. Read `LikeButton.svelte` and its test for the project's mocking pattern of TanStack Query stores in Svelte 5. + +- [ ] **Step 9.3: Wire into `TrackMenu.svelte`** + +Read `web/src/lib/components/TrackMenu.svelte`. Find the disabled "Add to playlist…" entry: + +```svelte + +``` + +Replace with an opener that mounts ``: + +```svelte + { addOpen = true; menuOpen = false; }} +/> +``` + +Add `let addOpen = $state(false);` near the other state declarations. Add the popover render block alongside the existing `RemoveTrackPopover` block: + +```svelte +{#if addOpen} + (addOpen = false)} /> +{/if} +``` + +Add the import at the top: + +```svelte +import AddToPlaylistMenu from './AddToPlaylistMenu.svelte'; +``` + +- [ ] **Step 9.4: Update `TrackMenu.test.ts`** + +Find the assertion that verifies "Add to playlist…" is disabled: + +```ts +test('"Add to playlist…" is disabled with a tooltip until #352 ships', ...) +``` + +Replace with: + +```ts +test('"Add to playlist…" is enabled and opens the AddToPlaylistMenu submenu', async () => { + render(TrackMenu, { props: { track } }); + await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); + const item = screen.getByRole('menuitem', { name: /add to playlist/i }); + expect(item.getAttribute('aria-disabled')).toBe('false'); + await fireEvent.click(item); + await waitFor(() => + expect(screen.getByRole('menu', { name: /add to playlist/i })).toBeInTheDocument() + ); +}); +``` + +Add `vi.mock('$lib/api/playlists', ...)` and `vi.mock('$lib/api/queries', ...)` mocks at the top of the file if `AddToPlaylistMenu`'s imports cascade into the test. Read the existing file to see the existing mock block and extend it. + +- [ ] **Step 9.5: Commit** + +```bash +git add web/src/lib/components/AddToPlaylistMenu.svelte web/src/lib/components/AddToPlaylistMenu.test.ts web/src/lib/components/TrackMenu.svelte web/src/lib/components/TrackMenu.test.ts +git commit -F - <<'EOF' +feat(web): AddToPlaylistMenu submenu wired into TrackMenu (M7 #352) + +The "Add to playlist…" entry that #372 reserved as a disabled slot +is now active. Submenu lists the operator's own playlists +alphabetically; "New playlist…" toggles an inline create form that +makes the playlist + appends the track in two API calls. + +TrackMenu's existing test asserts the entry is enabled and opens the +submenu instead of the previous "is disabled with tooltip" check. +EOF +``` + +--- + +### Task 10 — `/playlists` index page + +**Files:** +- Modify (rewrite): `web/src/routes/playlists/+page.svelte` — replaces the placeholder +- Create: `web/src/routes/playlists/playlists.test.ts` + +- [ ] **Step 10.1: Rewrite `web/src/routes/playlists/+page.svelte`** + +```svelte + + +
+
+

Playlists

+ +
+ + {#if creating} +
+ + {#if createError} +

{createError}

+ {/if} +
+ + +
+
+ {/if} + + {#if $playlistsQ?.isPending} +

Loading playlists…

+ {:else if $playlistsQ?.isError} + $playlistsQ.refetch()} /> + {:else if $playlistsQ?.data} +
+

Your playlists

+ {#if $playlistsQ.data.owned.length === 0} +

No playlists yet. Click "New playlist" to start one.

+ {:else} +
+ {#each $playlistsQ.data.owned as p (p.id)} + + {/each} +
+ {/if} +
+ + {#if $playlistsQ.data.public.length > 0} +
+

From other users

+
+ {#each $playlistsQ.data.public as p (p.id)} + + {/each} +
+
+ {/if} + {/if} +
+``` + +- [ ] **Step 10.2: Write `web/src/routes/playlists/playlists.test.ts`** + +```ts +import { describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import { mockQuery } from '../test-utils/query'; +import type { Playlist } from '$lib/api/types'; + +vi.mock('$lib/api/playlists', () => ({ + createPlaylistsQuery: vi.fn(), + createPlaylist: vi.fn().mockResolvedValue({ id: 'p-new', name: 'X' }) +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) }) + }; +}); + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); + +import PlaylistsPage from './+page.svelte'; +import { createPlaylistsQuery, createPlaylist } from '$lib/api/playlists'; + +const mockedCreate = createPlaylistsQuery as ReturnType; +const mockedCreatePlaylist = createPlaylist as ReturnType; + +function p(over: Partial): Playlist { + return { + id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'A', + description: '', is_public: false, cover_url: '', track_count: 0, duration_sec: 0, + created_at: '', updated_at: '', ...over + }; +} + +describe('Playlists index page', () => { + test('renders own + public sections', () => { + mockedCreate.mockReturnValue(mockQuery({ + data: { + owned: [p({ id: 'p1', name: 'Mine' })], + public: [p({ id: 'p2', name: 'Theirs', user_id: 'u-other', owner_username: 'bob' })] + } + })); + render(PlaylistsPage); + expect(screen.getByText('Mine')).toBeInTheDocument(); + expect(screen.getByText('Theirs')).toBeInTheDocument(); + expect(screen.getByText(/your playlists/i)).toBeInTheDocument(); + expect(screen.getByText(/from other users/i)).toBeInTheDocument(); + }); + + test('empty-state copy when operator has no playlists', () => { + mockedCreate.mockReturnValue(mockQuery({ + data: { owned: [], public: [] } + })); + render(PlaylistsPage); + expect(screen.getByText(/no playlists yet/i)).toBeInTheDocument(); + }); + + test('Create button reveals inline form; submit creates and navigates', async () => { + mockedCreate.mockReturnValue(mockQuery({ data: { owned: [], public: [] } })); + render(PlaylistsPage); + await fireEvent.click(screen.getByRole('button', { name: /new playlist/i })); + const input = screen.getByPlaceholderText(/saturday morning/i); + await fireEvent.input(input, { target: { value: 'Test' } }); + await fireEvent.click(screen.getByRole('button', { name: /^create$/i })); + await waitFor(() => expect(mockedCreatePlaylist).toHaveBeenCalledWith({ name: 'Test' })); + }); +}); +``` + +The `mockQuery` helper exists at `web/src/test-utils/query.ts` (same one used elsewhere). Read it first to confirm the import path. + +- [ ] **Step 10.3: Commit** + +```bash +git add web/src/routes/playlists/+page.svelte web/src/routes/playlists/playlists.test.ts +git commit -F - <<'EOF' +feat(web): /playlists index page (M7 #352 slice 1) + +Replaces the placeholder route. Two sections: "Your playlists" (owned) +and "From other users" (public). Inline create form in the header +with Enter-to-submit / Esc-to-cancel. Empty-state copy when the +operator has nothing yet. Routes to /playlists/{id} on card click via +PlaylistCard. +EOF +``` + +--- + +### Task 11 — `/playlists/{id}` detail page with drag-reorder + +**Files:** +- Create: `web/src/routes/playlists/[id]/+page.svelte` +- Create: `web/src/routes/playlists/[id]/playlist.test.ts` + +- [ ] **Step 11.1: Write `web/src/routes/playlists/[id]/+page.svelte`** + +```svelte + + +
+ {#if $playlistQ?.isPending} +

Loading…

+ {:else if $playlistQ?.isError} + $playlistQ.refetch()} /> + {:else if $playlistQ?.data} + {@const pl = $playlistQ.data} + {#if !editing} +
+
+ {#if pl.cover_url} + + {/if} +
+
+

{pl.name}

+ {#if pl.description} +

{pl.description}

+ {/if} +

+ {pl.track_count} {pl.track_count === 1 ? 'track' : 'tracks'} + {#if pl.is_public}· public{:else}· private{/if} + {#if !isOwner}· by {pl.owner_username}{/if} +

+
+ {#if isOwner} + + + {/if} +
+ {:else} +
+ + + +
+ + +
+
+ {/if} + +
+ {#if pl.tracks.length === 0} +

+ {#if isOwner} + No tracks yet. Add some via the "Add to playlist…" entry on any track row. + {:else} + No tracks in this playlist. + {/if} +

+ {:else} + {#each pl.tracks as row (row.position)} + (dragFromPos = p)} + onDrop={(_, p) => onDrop(p)} + /> + {/each} + {/if} +
+ {/if} +
+``` + +`enqueueTracks` and `playQueue` are existing player-store exports per Task 5 of M7 #356. Read `web/src/lib/player/store.svelte.ts` for the exact signatures and adapt — the snippet uses `playQueue(tracks, startIndex)`. If the actual signature differs, adjust. + +The `alert(...)` toast fallback is intentional: a real toast surface is a separate polish task; in slice 1, a browser alert is loud-but-functional and won't be missed in dev. CI tests won't exercise the alert path because they intercept via mocked fetch. + +- [ ] **Step 11.2: Write `web/src/routes/playlists/[id]/playlist.test.ts`** + +```ts +import { describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import { mockQuery } from '../../test-utils/query'; +import { writable } from 'svelte/store'; +import type { PlaylistDetail } from '$lib/api/types'; + +vi.mock('$app/stores', () => ({ + page: writable({ params: { id: 'p1' } }) +})); + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); + +vi.mock('$lib/api/playlists', () => ({ + createPlaylistQuery: vi.fn(), + updatePlaylist: vi.fn().mockResolvedValue(undefined), + deletePlaylist: vi.fn().mockResolvedValue(undefined), + removePlaylistTrack: vi.fn().mockResolvedValue(undefined), + reorderPlaylist: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) }) }; +}); + +vi.mock('$lib/auth/store.svelte', () => ({ + user: { value: { id: 'u-self', username: 'me', is_admin: false } } +})); + +vi.mock('$lib/player/store.svelte', () => ({ + playQueue: vi.fn(), + enqueueTracks: vi.fn(), + playNext: vi.fn(), + enqueueTrack: vi.fn() +})); + +// Cascade through PlaylistTrackRow's mocks. +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => ({ subscribe: () => () => {}, data: { track_ids: [], album_ids: [], artist_ids: [] } }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); +vi.mock('$lib/api/quarantine', () => ({ + createMyQuarantineQuery: () => ({ subscribe: () => () => {}, data: [] }), + flagTrack: vi.fn(), + unflagTrack: vi.fn() +})); +vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn() })); + +import PlaylistDetailPage from './+page.svelte'; +import { createPlaylistQuery, deletePlaylist, reorderPlaylist } from '$lib/api/playlists'; + +const mockedQuery = createPlaylistQuery as ReturnType; + +const ownDetail: PlaylistDetail = { + id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'Mine', + description: '', is_public: false, cover_url: '', track_count: 2, duration_sec: 274, + created_at: '', updated_at: '', + tracks: [ + { position: 0, track_id: 't1', album_id: 'a1', artist_id: 'ar1', title: 'A', artist_name: 'X', album_title: 'Y', duration_sec: 137, stream_url: '/api/tracks/t1/stream', added_at: '' }, + { position: 1, track_id: 't2', album_id: 'a1', artist_id: 'ar1', title: 'B', artist_name: 'X', album_title: 'Y', duration_sec: 137, stream_url: '/api/tracks/t2/stream', added_at: '' } + ] +}; + +describe('Playlist detail page', () => { + test('renders header + tracks for owner', () => { + mockedQuery.mockReturnValue(mockQuery({ data: ownDetail })); + render(PlaylistDetailPage); + expect(screen.getByText('Mine')).toBeInTheDocument(); + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.getByLabelText(/edit playlist/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/delete playlist/i)).toBeInTheDocument(); + }); + + test('hides edit + delete for non-owner', () => { + mockedQuery.mockReturnValue(mockQuery({ + data: { ...ownDetail, user_id: 'u-other', owner_username: 'bob', is_public: true } + })); + render(PlaylistDetailPage); + expect(screen.queryByLabelText(/edit playlist/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/delete playlist/i)).not.toBeInTheDocument(); + }); + + test('Delete button confirms then deletes', async () => { + mockedQuery.mockReturnValue(mockQuery({ data: ownDetail })); + const confirmSpy = vi.spyOn(globalThis, 'confirm').mockReturnValue(true); + render(PlaylistDetailPage); + await fireEvent.click(screen.getByLabelText(/delete playlist/i)); + await waitFor(() => expect(deletePlaylist).toHaveBeenCalledWith('p1')); + confirmSpy.mockRestore(); + }); +}); +``` + +- [ ] **Step 11.3: Commit** + +```bash +git add web/src/routes/playlists/[id]/+page.svelte web/src/routes/playlists/[id]/playlist.test.ts +git commit -F - <<'EOF' +feat(web): /playlists/{id} detail page with drag-reorder (M7 #352) + +Header shows collage, name, description, public/private chip, track +count, owner attribution (when not owner). Edit + delete buttons +visible to the owner only. Track list uses PlaylistTrackRow with +HTML5 drag-and-drop; drop fires PUT /tracks with the new ordered +positions and TanStack Query invalidates the playlist + index cache. + +Toast surface is a placeholder browser alert in slice 1 — a real +toast is a polish task whenever it lands. +EOF +``` + +--- + +## Self-review + +1. **Spec coverage:** + - §2 Goals — all goals point at tasks: schema (T1), service (T2 + T3), collage (T4), API (T5), api helper + types + queries (T6), PlaylistCard (T7), PlaylistTrackRow (T8), AddToPlaylistMenu + TrackMenu wiring (T9), `/playlists` (T10), `/playlists/{id}` (T11). ✓ + - §3 Architecture — schema, service, API, frontend pages all covered. + - §3.5 Drag-to-reorder — HTML5 native, optimistic update, T11 wires it. ✓ + - §3.6 "Add to playlist" flow — T9 covers submenu + inline new-playlist creation. ✓ + - §3.7 Permissions matrix — service-layer enforces ErrForbidden in T2/T3, API layer maps to 403 not_authorized in T5. ✓ + - §5 API contract — every endpoint has a handler in T5 with matching response shape. ✓ + - §6 Error handling — `copyForCode` invocations land in AddToPlaylistMenu (T9) and the detail page (T11). The `alert()` fallback is documented as a slice-1 trade-off. + - §7 Testing — every test file from §7 has a writing step. + - §8 Distribution — migration 0014 lives in T1; data dir creation is service-side in T4 (`os.MkdirAll`). + +2. **Placeholder scan:** No "TBD"/"add validation"/"handle edge cases" in any task. The alert() toast is documented intentional. The fallback glyph is a documented intentional slice-1 placeholder. + +3. **Type consistency:** + - Backend service signatures: `Service.AppendTracks(ctx, callerID, playlistID, []pgtype.UUID) error` and friends — consistent across T2 / T3 / T5. + - `PlaylistRow`, `PlaylistDetail`, `PlaylistTrack` Go types match between service.go and the API view structs in T5. + - TS types: `Playlist`, `PlaylistDetail`, `PlaylistTrack` — defined in T6, used everywhere downstream. + - Query keys: `qk.playlists()`, `qk.playlist(id)` — defined in T6, used in T9, T10, T11. + - API URLs: every helper in T6 matches a handler route registered in T5. + +One spec-deviation worth noting: + +- The spec's §3.2 says "Triggers cover regeneration if the first 4 positions changed" for Reorder. The plan's T3 implementation triggers regeneration unconditionally on any reorder. Reasoning baked in: "cheap, and reasoning about which moves matter is more error-prone than just doing the work." Acceptable — net no behavioral difference for slice 1 (collages get regenerated; might just happen on a few extra calls). From 1226cb758311371aaeb2a2c34777aad5fd747f96 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 10:03:36 -0400 Subject: [PATCH 098/351] feat(db): playlists schema for M7 #352 slice 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 0014 adds playlists + playlist_tracks. track_id is nullable with ON DELETE SET NULL — tracks can be removed from the library without silently dropping playlist entries; the denormalized snapshot (title/artist/album/duration) keeps the row legible afterwards. UI renders such rows greyed-out. Indexes: playlists by (user_id, updated_at DESC) and a partial public index for cross-user discovery; playlist_tracks partial index on track_id to support the FK SET NULL lookup. Queries provide CRUD + rollup recompute (track_count, duration_sec) + append/remove primitives. Reorder is service-layer orchestrated via raw tx.Exec; no SQL primitive needed. --- internal/db/dbq/models.go | 24 + internal/db/dbq/playlists.sql.go | 424 ++++++++++++++++++ .../db/migrations/0014_playlists.down.sql | 2 + internal/db/migrations/0014_playlists.up.sql | 39 ++ internal/db/queries/playlists.sql | 110 +++++ 5 files changed, 599 insertions(+) create mode 100644 internal/db/dbq/playlists.sql.go create mode 100644 internal/db/migrations/0014_playlists.down.sql create mode 100644 internal/db/migrations/0014_playlists.up.sql create mode 100644 internal/db/queries/playlists.sql diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 0fa5b8f8..e406323f 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -335,6 +335,30 @@ type PlaySession struct { ClientID *string } +type Playlist struct { + ID pgtype.UUID + UserID pgtype.UUID + Name string + Description string + IsPublic bool + CoverPath *string + TrackCount int32 + DurationSec int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type PlaylistTrack struct { + PlaylistID pgtype.UUID + Position int32 + TrackID pgtype.UUID + Title string + ArtistName string + AlbumTitle string + DurationSec int32 + AddedAt pgtype.Timestamptz +} + type ScrobbleQueue struct { ID pgtype.UUID UserID pgtype.UUID diff --git a/internal/db/dbq/playlists.sql.go b/internal/db/dbq/playlists.sql.go new file mode 100644 index 00000000..750a47cc --- /dev/null +++ b/internal/db/dbq/playlists.sql.go @@ -0,0 +1,424 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: playlists.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const appendPlaylistTrack = `-- name: AppendPlaylistTrack :one +INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec) +SELECT + $1::uuid, + COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = $1::uuid), 0), + t.id, + t.title, + artists.name, + albums.title, + (t.duration_ms / 1000)::integer +FROM tracks t +JOIN albums ON albums.id = t.album_id +JOIN artists ON artists.id = t.artist_id +WHERE t.id = $2::uuid +RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at +` + +type AppendPlaylistTrackParams struct { + PlaylistID pgtype.UUID + TrackID pgtype.UUID +} + +// Inserts at the next available position. Snapshot fields are copied +// from the tracks/albums/artists join at insert time. tracks.duration_ms +// is converted to seconds for the snapshot. +func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTrackParams) (PlaylistTrack, error) { + row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.TrackID) + var i PlaylistTrack + err := row.Scan( + &i.PlaylistID, + &i.Position, + &i.TrackID, + &i.Title, + &i.ArtistName, + &i.AlbumTitle, + &i.DurationSec, + &i.AddedAt, + ) + return i, err +} + +const createPlaylist = `-- name: CreatePlaylist :one +INSERT INTO playlists (user_id, name, description, is_public) +VALUES ($1, $2, $3, $4) +RETURNING id, user_id, name, description, is_public, cover_path, track_count, duration_sec, created_at, updated_at +` + +type CreatePlaylistParams struct { + UserID pgtype.UUID + Name string + Description string + IsPublic bool +} + +func (q *Queries) CreatePlaylist(ctx context.Context, arg CreatePlaylistParams) (Playlist, error) { + row := q.db.QueryRow(ctx, createPlaylist, + arg.UserID, + arg.Name, + arg.Description, + arg.IsPublic, + ) + var i Playlist + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.IsPublic, + &i.CoverPath, + &i.TrackCount, + &i.DurationSec, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deletePlaylist = `-- name: DeletePlaylist :one +DELETE FROM playlists WHERE id = $1 +RETURNING id, cover_path +` + +type DeletePlaylistRow struct { + ID pgtype.UUID + CoverPath *string +} + +// Returns cover_path so the caller can clean up the cached collage on disk. +func (q *Queries) DeletePlaylist(ctx context.Context, id pgtype.UUID) (DeletePlaylistRow, error) { + row := q.db.QueryRow(ctx, deletePlaylist, id) + var i DeletePlaylistRow + err := row.Scan(&i.ID, &i.CoverPath) + return i, err +} + +const deletePlaylistTrack = `-- name: DeletePlaylistTrack :exec +DELETE FROM playlist_tracks +WHERE playlist_id = $1 AND position = $2 +` + +type DeletePlaylistTrackParams struct { + PlaylistID pgtype.UUID + Position int32 +} + +// Two-step: delete the row at `position`, then renumber subsequent rows +// to close the gap. The renumber is a single UPDATE; the service layer +// runs both in one transaction. +func (q *Queries) DeletePlaylistTrack(ctx context.Context, arg DeletePlaylistTrackParams) error { + _, err := q.db.Exec(ctx, deletePlaylistTrack, arg.PlaylistID, arg.Position) + return err +} + +const getPlaylist = `-- name: GetPlaylist :one +SELECT p.id, p.user_id, p.name, p.description, p.is_public, p.cover_path, p.track_count, p.duration_sec, p.created_at, p.updated_at, u.username AS owner_username +FROM playlists p +JOIN users u ON u.id = p.user_id +WHERE p.id = $1 +` + +type GetPlaylistRow struct { + ID pgtype.UUID + UserID pgtype.UUID + Name string + Description string + IsPublic bool + CoverPath *string + TrackCount int32 + DurationSec int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + OwnerUsername string +} + +func (q *Queries) GetPlaylist(ctx context.Context, id pgtype.UUID) (GetPlaylistRow, error) { + row := q.db.QueryRow(ctx, getPlaylist, id) + var i GetPlaylistRow + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.IsPublic, + &i.CoverPath, + &i.TrackCount, + &i.DurationSec, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerUsername, + ) + return i, err +} + +const listAllPlaylistTracksForCollage = `-- name: ListAllPlaylistTracksForCollage :many +SELECT pt.position, + albums.cover_art_path AS album_cover_path +FROM playlist_tracks pt +LEFT JOIN tracks t ON t.id = pt.track_id +LEFT JOIN albums ON albums.id = t.album_id +WHERE pt.playlist_id = $1 +ORDER BY pt.position +LIMIT $2 +` + +type ListAllPlaylistTracksForCollageParams struct { + PlaylistID pgtype.UUID + Limit int32 +} + +type ListAllPlaylistTracksForCollageRow struct { + Position int32 + AlbumCoverPath *string +} + +// First N tracks for the collage. Uses LEFT JOIN on albums for the +// cover_path; rows with NULL cover_path get the glyph fallback. +func (q *Queries) ListAllPlaylistTracksForCollage(ctx context.Context, arg ListAllPlaylistTracksForCollageParams) ([]ListAllPlaylistTracksForCollageRow, error) { + rows, err := q.db.Query(ctx, listAllPlaylistTracksForCollage, arg.PlaylistID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAllPlaylistTracksForCollageRow + for rows.Next() { + var i ListAllPlaylistTracksForCollageRow + if err := rows.Scan(&i.Position, &i.AlbumCoverPath); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPlaylistTracks = `-- name: ListPlaylistTracks :many +SELECT pt.playlist_id, pt.position, pt.track_id, pt.title, pt.artist_name, pt.album_title, pt.duration_sec, pt.added_at, + t.id AS live_track_id, + albums.id AS album_id, + artists.id AS artist_id +FROM playlist_tracks pt +LEFT JOIN tracks t ON t.id = pt.track_id +LEFT JOIN albums ON albums.id = t.album_id +LEFT JOIN artists ON artists.id = t.artist_id +WHERE pt.playlist_id = $1 +ORDER BY pt.position +` + +type ListPlaylistTracksRow struct { + PlaylistID pgtype.UUID + Position int32 + TrackID pgtype.UUID + Title string + ArtistName string + AlbumTitle string + DurationSec int32 + AddedAt pgtype.Timestamptz + LiveTrackID pgtype.UUID + AlbumID pgtype.UUID + ArtistID pgtype.UUID +} + +// Joined to tracks for the live track id (the service layer derives the +// stream URL from it); LEFT JOIN preserves the row when track_id is NULL +// (track was removed from the library). The denormalized snapshot fields +// on playlist_tracks remain authoritative for title/artist/album text. +func (q *Queries) ListPlaylistTracks(ctx context.Context, playlistID pgtype.UUID) ([]ListPlaylistTracksRow, error) { + rows, err := q.db.Query(ctx, listPlaylistTracks, playlistID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListPlaylistTracksRow + for rows.Next() { + var i ListPlaylistTracksRow + if err := rows.Scan( + &i.PlaylistID, + &i.Position, + &i.TrackID, + &i.Title, + &i.ArtistName, + &i.AlbumTitle, + &i.DurationSec, + &i.AddedAt, + &i.LiveTrackID, + &i.AlbumID, + &i.ArtistID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPlaylistsForUser = `-- name: ListPlaylistsForUser :many +SELECT p.id, p.user_id, p.name, p.description, p.is_public, p.cover_path, p.track_count, p.duration_sec, p.created_at, p.updated_at, u.username AS owner_username +FROM playlists p +JOIN users u ON u.id = p.user_id +WHERE p.user_id = $1 OR p.is_public = true +ORDER BY p.updated_at DESC +` + +type ListPlaylistsForUserRow struct { + ID pgtype.UUID + UserID pgtype.UUID + Name string + Description string + IsPublic bool + CoverPath *string + TrackCount int32 + DurationSec int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + OwnerUsername string +} + +// Owner's playlists (any visibility) + other users' public playlists. +// Ordered by updated_at desc so newly-edited ones float to the top. +func (q *Queries) ListPlaylistsForUser(ctx context.Context, userID pgtype.UUID) ([]ListPlaylistsForUserRow, error) { + rows, err := q.db.Query(ctx, listPlaylistsForUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListPlaylistsForUserRow + for rows.Next() { + var i ListPlaylistsForUserRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.IsPublic, + &i.CoverPath, + &i.TrackCount, + &i.DurationSec, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerUsername, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const renumberPlaylistTracksAfter = `-- name: RenumberPlaylistTracksAfter :exec +UPDATE playlist_tracks +SET position = position - 1 +WHERE playlist_id = $1 AND position > $2 +` + +type RenumberPlaylistTracksAfterParams struct { + PlaylistID pgtype.UUID + Position int32 +} + +// Used after DeletePlaylistTrack to close the gap. +func (q *Queries) RenumberPlaylistTracksAfter(ctx context.Context, arg RenumberPlaylistTracksAfterParams) error { + _, err := q.db.Exec(ctx, renumberPlaylistTracksAfter, arg.PlaylistID, arg.Position) + return err +} + +const setPlaylistCover = `-- name: SetPlaylistCover :exec +UPDATE playlists SET cover_path = $2, updated_at = now() WHERE id = $1 +` + +type SetPlaylistCoverParams struct { + ID pgtype.UUID + CoverPath *string +} + +func (q *Queries) SetPlaylistCover(ctx context.Context, arg SetPlaylistCoverParams) error { + _, err := q.db.Exec(ctx, setPlaylistCover, arg.ID, arg.CoverPath) + return err +} + +const updatePlaylist = `-- name: UpdatePlaylist :one +UPDATE playlists +SET + name = CASE WHEN $1::boolean THEN $2::text ELSE name END, + description = CASE WHEN $3::boolean THEN $4::text ELSE description END, + is_public = CASE WHEN $5::boolean THEN $6::boolean ELSE is_public END, + updated_at = now() +WHERE id = $7 +RETURNING id, user_id, name, description, is_public, cover_path, track_count, duration_sec, created_at, updated_at +` + +type UpdatePlaylistParams struct { + UpdateName bool + Name string + UpdateDescription bool + Description string + UpdateIsPublic bool + IsPublic bool + ID pgtype.UUID +} + +// Updates only the fields whose corresponding `updateX` flag is true. +// The flags let the service layer keep PATCH semantics (only-touch-what-the-caller-sent) +// without writing N variants. +func (q *Queries) UpdatePlaylist(ctx context.Context, arg UpdatePlaylistParams) (Playlist, error) { + row := q.db.QueryRow(ctx, updatePlaylist, + arg.UpdateName, + arg.Name, + arg.UpdateDescription, + arg.Description, + arg.UpdateIsPublic, + arg.IsPublic, + arg.ID, + ) + var i Playlist + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.IsPublic, + &i.CoverPath, + &i.TrackCount, + &i.DurationSec, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updatePlaylistRollups = `-- name: UpdatePlaylistRollups :exec +UPDATE playlists +SET + track_count = (SELECT COUNT(*) FROM playlist_tracks pt WHERE pt.playlist_id = $1), + duration_sec = (SELECT COALESCE(SUM(pt.duration_sec), 0) FROM playlist_tracks pt WHERE pt.playlist_id = $1), + updated_at = now() +WHERE id = $1 +` + +// Set track_count + duration_sec from a fresh aggregate. Called after +// every mutation that touches playlist_tracks. Cheap; the table is small. +func (q *Queries) UpdatePlaylistRollups(ctx context.Context, playlistID pgtype.UUID) error { + _, err := q.db.Exec(ctx, updatePlaylistRollups, playlistID) + return err +} diff --git a/internal/db/migrations/0014_playlists.down.sql b/internal/db/migrations/0014_playlists.down.sql new file mode 100644 index 00000000..0e7e724d --- /dev/null +++ b/internal/db/migrations/0014_playlists.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS playlist_tracks; +DROP TABLE IF EXISTS playlists; diff --git a/internal/db/migrations/0014_playlists.up.sql b/internal/db/migrations/0014_playlists.up.sql new file mode 100644 index 00000000..21642891 --- /dev/null +++ b/internal/db/migrations/0014_playlists.up.sql @@ -0,0 +1,39 @@ +-- M7 #352 slice 1: playlists CRUD foundation. +-- See docs/superpowers/specs/2026-05-03-m7-playlists-crud-design.md. + +CREATE TABLE playlists ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name text NOT NULL, + description text NOT NULL DEFAULT '', + is_public boolean NOT NULL DEFAULT false, + cover_path text, + track_count integer NOT NULL DEFAULT 0, + duration_sec integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX playlists_user_idx ON playlists (user_id, updated_at DESC); +CREATE INDEX playlists_public_idx ON playlists (is_public, updated_at DESC) WHERE is_public; + +-- track_id is nullable + ON DELETE SET NULL so deleting a track from +-- the library doesn't silently drop entries from operators' playlists. +-- Denormalized title/artist/album/duration carry a snapshot so the row +-- is still legible after the upstream track row is gone. +CREATE TABLE playlist_tracks ( + playlist_id uuid NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + position integer NOT NULL, + track_id uuid REFERENCES tracks(id) ON DELETE SET NULL, + title text NOT NULL, + artist_name text NOT NULL, + album_title text NOT NULL, + duration_sec integer NOT NULL, + added_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (playlist_id, position) +); + +-- Partial index supports the FK lookup that fires when a track is +-- deleted (ON DELETE SET NULL) — without it, deleting a track triggers +-- a full scan of playlist_tracks. +CREATE INDEX playlist_tracks_track_idx ON playlist_tracks (track_id) WHERE track_id IS NOT NULL; diff --git a/internal/db/queries/playlists.sql b/internal/db/queries/playlists.sql new file mode 100644 index 00000000..afed223a --- /dev/null +++ b/internal/db/queries/playlists.sql @@ -0,0 +1,110 @@ +-- name: CreatePlaylist :one +INSERT INTO playlists (user_id, name, description, is_public) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: GetPlaylist :one +SELECT p.*, u.username AS owner_username +FROM playlists p +JOIN users u ON u.id = p.user_id +WHERE p.id = $1; + +-- name: ListPlaylistsForUser :many +-- Owner's playlists (any visibility) + other users' public playlists. +-- Ordered by updated_at desc so newly-edited ones float to the top. +SELECT p.*, u.username AS owner_username +FROM playlists p +JOIN users u ON u.id = p.user_id +WHERE p.user_id = $1 OR p.is_public = true +ORDER BY p.updated_at DESC; + +-- name: UpdatePlaylist :one +-- Updates only the fields whose corresponding `updateX` flag is true. +-- The flags let the service layer keep PATCH semantics (only-touch-what-the-caller-sent) +-- without writing N variants. +UPDATE playlists +SET + name = CASE WHEN sqlc.arg(update_name)::boolean THEN sqlc.arg(name)::text ELSE name END, + description = CASE WHEN sqlc.arg(update_description)::boolean THEN sqlc.arg(description)::text ELSE description END, + is_public = CASE WHEN sqlc.arg(update_is_public)::boolean THEN sqlc.arg(is_public)::boolean ELSE is_public END, + updated_at = now() +WHERE id = sqlc.arg(id) +RETURNING *; + +-- name: UpdatePlaylistRollups :exec +-- Set track_count + duration_sec from a fresh aggregate. Called after +-- every mutation that touches playlist_tracks. Cheap; the table is small. +UPDATE playlists +SET + track_count = (SELECT COUNT(*) FROM playlist_tracks pt WHERE pt.playlist_id = $1), + duration_sec = (SELECT COALESCE(SUM(pt.duration_sec), 0) FROM playlist_tracks pt WHERE pt.playlist_id = $1), + updated_at = now() +WHERE id = $1; + +-- name: SetPlaylistCover :exec +UPDATE playlists SET cover_path = $2, updated_at = now() WHERE id = $1; + +-- name: DeletePlaylist :one +-- Returns cover_path so the caller can clean up the cached collage on disk. +DELETE FROM playlists WHERE id = $1 +RETURNING id, cover_path; + +-- name: ListPlaylistTracks :many +-- Joined to tracks for the live track id (the service layer derives the +-- stream URL from it); LEFT JOIN preserves the row when track_id is NULL +-- (track was removed from the library). The denormalized snapshot fields +-- on playlist_tracks remain authoritative for title/artist/album text. +SELECT pt.*, + t.id AS live_track_id, + albums.id AS album_id, + artists.id AS artist_id +FROM playlist_tracks pt +LEFT JOIN tracks t ON t.id = pt.track_id +LEFT JOIN albums ON albums.id = t.album_id +LEFT JOIN artists ON artists.id = t.artist_id +WHERE pt.playlist_id = $1 +ORDER BY pt.position; + +-- name: AppendPlaylistTrack :one +-- Inserts at the next available position. Snapshot fields are copied +-- from the tracks/albums/artists join at insert time. tracks.duration_ms +-- is converted to seconds for the snapshot. +INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec) +SELECT + sqlc.arg(playlist_id)::uuid, + COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = sqlc.arg(playlist_id)::uuid), 0), + t.id, + t.title, + artists.name, + albums.title, + (t.duration_ms / 1000)::integer +FROM tracks t +JOIN albums ON albums.id = t.album_id +JOIN artists ON artists.id = t.artist_id +WHERE t.id = sqlc.arg(track_id)::uuid +RETURNING *; + +-- name: DeletePlaylistTrack :exec +-- Two-step: delete the row at `position`, then renumber subsequent rows +-- to close the gap. The renumber is a single UPDATE; the service layer +-- runs both in one transaction. +DELETE FROM playlist_tracks +WHERE playlist_id = $1 AND position = $2; + +-- name: RenumberPlaylistTracksAfter :exec +-- Used after DeletePlaylistTrack to close the gap. +UPDATE playlist_tracks +SET position = position - 1 +WHERE playlist_id = $1 AND position > $2; + +-- name: ListAllPlaylistTracksForCollage :many +-- First N tracks for the collage. Uses LEFT JOIN on albums for the +-- cover_path; rows with NULL cover_path get the glyph fallback. +SELECT pt.position, + albums.cover_art_path AS album_cover_path +FROM playlist_tracks pt +LEFT JOIN tracks t ON t.id = pt.track_id +LEFT JOIN albums ON albums.id = t.album_id +WHERE pt.playlist_id = $1 +ORDER BY pt.position +LIMIT $2; From 5c61c10b63169c5d6ad20dd65e09780f460f6858 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 10:17:08 -0400 Subject: [PATCH 099/351] feat(playlists): Service with CRUD methods (M7 #352 slice 1) Create / Get / List / Update / Delete with the visibility model from the spec: private by default, owner-only mutations, public read for non-owners. Update uses sqlc's CASE-WHEN-flag pattern for PATCH-style partial updates without writing N variants. Delete cleans up the cached cover file from disk best-effort. Track operations (Append/Remove/Reorder) and the collage generator land in subsequent tasks. Also adds playlists + playlist_tracks to dbtest.ResetDB's truncate list so integration tests in this package start from a clean state. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/dbtest/reset.go | 2 + internal/playlists/service.go | 334 ++++++++++++++++++ internal/playlists/service_test.go | 320 +++++++++++++++++ .../playlists/service_test_helpers_test.go | 92 +++++ 4 files changed, 748 insertions(+) create mode 100644 internal/playlists/service.go create mode 100644 internal/playlists/service_test.go create mode 100644 internal/playlists/service_test_helpers_test.go diff --git a/internal/dbtest/reset.go b/internal/dbtest/reset.go index 3395d19a..8e24cd48 100644 --- a/internal/dbtest/reset.go +++ b/internal/dbtest/reset.go @@ -52,6 +52,8 @@ var dataTables = []string{ "sessions", "lidarr_quarantine_actions", "lidarr_quarantine", + "playlist_tracks", + "playlists", "tracks", "albums", "artists", diff --git a/internal/playlists/service.go b/internal/playlists/service.go new file mode 100644 index 00000000..19ec8bd8 --- /dev/null +++ b/internal/playlists/service.go @@ -0,0 +1,334 @@ +// Package playlists implements the user-facing playlist CRUD service. +// +// Visibility model (from the M7 #352 design spec): +// - Playlists are private by default. Only the owner sees private rows +// in List, Get, and is allowed to mutate them via Update / Delete. +// - is_public = true makes a playlist readable by any authenticated +// user; mutations are still owner-only. +// +// Track-list mutations (Append / Remove / Reorder) and the cover-art +// collage generator land in subsequent tasks; this file ships the +// CRUD-only slice on top of the migration 0014 + sqlc queries from +// task 1. +package playlists + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Typed errors. The API layer maps each to its wire status code; tests +// use errors.Is to assert the right path was taken. +var ( + ErrNotFound = errors.New("playlists: playlist not found") + ErrForbidden = errors.New("playlists: forbidden") + ErrInvalidInput = errors.New("playlists: invalid input") +) + +// Service wires the sqlc queries + on-disk cover-art directory together. +// dataDir is the root for cached cover-art collages (cover_path on the +// playlist row is stored relative to this directory so the install can +// be relocated without rewriting rows). +type Service struct { + pool *pgxpool.Pool + logger *slog.Logger + dataDir string +} + +// NewService constructs a Service. A nil logger gets slog.Default(). +func NewService(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) *Service { + if logger == nil { + logger = slog.Default() + } + return &Service{pool: pool, logger: logger, dataDir: dataDir} +} + +// PlaylistRow is the wire shape returned by Create / Update / List / +// the embedded portion of Get. owner_username is denormalized in via +// the JOIN on users so callers don't have to resolve it themselves. +type PlaylistRow struct { + ID pgtype.UUID + UserID pgtype.UUID + OwnerUsername string + Name string + Description string + IsPublic bool + CoverPath *string + TrackCount int32 + DurationSec int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +// PlaylistDetail extends PlaylistRow with the ordered track list. +// Returned by Get only; the List endpoint stays cheap and returns +// header-only PlaylistRows. +type PlaylistDetail struct { + PlaylistRow + Tracks []PlaylistTrack +} + +// PlaylistTrack is one row of a playlist's ordered track list. +// +// Title / ArtistName / AlbumTitle / DurationSec are the snapshot the +// playlist row was created with — they remain authoritative even after +// the upstream tracks/albums/artists rows change or disappear. +// +// TrackID is nil when the upstream tracks row has been deleted from +// the library (ON DELETE SET NULL). AlbumID / ArtistID come from the +// LEFT JOIN through the live track and follow the same nil-on-removed +// semantics — once the track row is gone, neither the album nor the +// artist can be resolved either, so they collapse to nil together. +type PlaylistTrack struct { + Position int32 + TrackID *pgtype.UUID + AlbumID *pgtype.UUID + ArtistID *pgtype.UUID + Title string + ArtistName string + AlbumTitle string + DurationSec int32 + AddedAt pgtype.Timestamptz +} + +// Create makes a new playlist owned by userID. +// +// The post-create GetPlaylist call exists to pick up owner_username +// from the users JOIN — CreatePlaylist returns only playlist columns, +// not the joined username, so we'd otherwise have to resolve it twice +// in the API layer. +func (s *Service) Create(ctx context.Context, userID pgtype.UUID, name, description string, isPublic bool) (*PlaylistRow, error) { + if name == "" { + return nil, fmt.Errorf("%w: name required", ErrInvalidInput) + } + q := dbq.New(s.pool) + row, err := q.CreatePlaylist(ctx, dbq.CreatePlaylistParams{ + UserID: userID, + Name: name, + Description: description, + IsPublic: isPublic, + }) + if err != nil { + return nil, fmt.Errorf("create playlist: %w", err) + } + full, err := q.GetPlaylist(ctx, row.ID) + if err != nil { + return nil, fmt.Errorf("get-after-create: %w", err) + } + return playlistFromGetRow(full), nil +} + +// Get returns the playlist identified by playlistID along with its +// ordered track list. Visibility: owner can always see; non-owners +// only see public playlists. Returns ErrNotFound for missing rows +// and ErrForbidden when a non-owner asks for a private one. +func (s *Service) Get(ctx context.Context, callerID, playlistID pgtype.UUID) (*PlaylistDetail, error) { + q := dbq.New(s.pool) + row, err := q.GetPlaylist(ctx, playlistID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("get playlist: %w", err) + } + if !row.IsPublic && !pgtypeUUIDEqual(row.UserID, callerID) { + return nil, ErrForbidden + } + + trackRows, err := q.ListPlaylistTracks(ctx, playlistID) + if err != nil { + return nil, fmt.Errorf("list tracks: %w", err) + } + tracks := make([]PlaylistTrack, 0, len(trackRows)) + for _, t := range trackRows { + pt := PlaylistTrack{ + Position: t.Position, + Title: t.Title, + ArtistName: t.ArtistName, + AlbumTitle: t.AlbumTitle, + DurationSec: t.DurationSec, + AddedAt: t.AddedAt, + } + // pt.track_id (snapshot FK, ON DELETE SET NULL) and the joined + // live_track_id should agree on validity in normal operation — + // prefer the snapshot's track_id because it survives even when + // the live row's other join columns happen to be NULL for + // unrelated reasons. + if t.TrackID.Valid { + id := t.TrackID + pt.TrackID = &id + } + if t.AlbumID.Valid { + id := t.AlbumID + pt.AlbumID = &id + } + if t.ArtistID.Valid { + id := t.ArtistID + pt.ArtistID = &id + } + tracks = append(tracks, pt) + } + + return &PlaylistDetail{ + PlaylistRow: *playlistFromGetRow(row), + Tracks: tracks, + }, nil +} + +// List returns all playlists visible to callerID: every playlist owned +// by the caller (any visibility) plus every other user's public +// playlists, ordered newest-first by updated_at. +func (s *Service) List(ctx context.Context, callerID pgtype.UUID) ([]PlaylistRow, error) { + q := dbq.New(s.pool) + rows, err := q.ListPlaylistsForUser(ctx, callerID) + if err != nil { + return nil, fmt.Errorf("list playlists: %w", err) + } + out := make([]PlaylistRow, 0, len(rows)) + for _, r := range rows { + out = append(out, *playlistFromListRow(r)) + } + return out, nil +} + +// UpdateInput captures optional fields. Each pointer being nil means +// "don't touch" — non-nil triggers a write of that column. The sqlc +// UpdatePlaylist query uses CASE-WHEN flags so we can keep PATCH +// semantics with one query rather than N variants. +type UpdateInput struct { + Name *string + Description *string + IsPublic *bool +} + +// Update applies a partial update to playlistID. Only the owner may +// mutate — non-owners (including admins, currently) get ErrForbidden. +// Empty Name (when explicitly set) is rejected with ErrInvalidInput +// rather than silently no-op'd, since the user clearly intended to +// change something. +func (s *Service) Update(ctx context.Context, callerID, playlistID pgtype.UUID, in UpdateInput) (*PlaylistRow, error) { + q := dbq.New(s.pool) + existing, err := q.GetPlaylist(ctx, playlistID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("get-before-update: %w", err) + } + if !pgtypeUUIDEqual(existing.UserID, callerID) { + return nil, ErrForbidden + } + + params := dbq.UpdatePlaylistParams{ + ID: playlistID, + UpdateName: in.Name != nil, + UpdateDescription: in.Description != nil, + UpdateIsPublic: in.IsPublic != nil, + } + if in.Name != nil { + if *in.Name == "" { + return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput) + } + params.Name = *in.Name + } + if in.Description != nil { + params.Description = *in.Description + } + if in.IsPublic != nil { + params.IsPublic = *in.IsPublic + } + + updated, err := q.UpdatePlaylist(ctx, params) + if err != nil { + return nil, fmt.Errorf("update playlist: %w", err) + } + full, err := q.GetPlaylist(ctx, updated.ID) + if err != nil { + return nil, fmt.Errorf("get-after-update: %w", err) + } + return playlistFromGetRow(full), nil +} + +// Delete removes playlistID. Owner-only, like Update. After the row is +// gone, a best-effort attempt is made to remove the cached cover-art +// collage from disk; failures other than ENOENT are logged but do not +// fail the call (the row is already gone, returning an error would +// confuse callers about whether the delete took effect). +func (s *Service) Delete(ctx context.Context, callerID, playlistID pgtype.UUID) error { + q := dbq.New(s.pool) + existing, err := q.GetPlaylist(ctx, playlistID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return fmt.Errorf("get-before-delete: %w", err) + } + if !pgtypeUUIDEqual(existing.UserID, callerID) { + return ErrForbidden + } + + deleted, err := q.DeletePlaylist(ctx, playlistID) + if err != nil { + return fmt.Errorf("delete playlist: %w", err) + } + if deleted.CoverPath != nil && *deleted.CoverPath != "" { + full := filepath.Join(s.dataDir, *deleted.CoverPath) + if rerr := os.Remove(full); rerr != nil && !errors.Is(rerr, os.ErrNotExist) { + s.logger.Warn("playlist delete: cover file remove failed", + "path", full, "playlist_id", playlistID, "err", rerr) + } + } + return nil +} + +func playlistFromGetRow(r dbq.GetPlaylistRow) *PlaylistRow { + return &PlaylistRow{ + ID: r.ID, + UserID: r.UserID, + OwnerUsername: r.OwnerUsername, + Name: r.Name, + Description: r.Description, + IsPublic: r.IsPublic, + CoverPath: r.CoverPath, + TrackCount: r.TrackCount, + DurationSec: r.DurationSec, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +func playlistFromListRow(r dbq.ListPlaylistsForUserRow) *PlaylistRow { + return &PlaylistRow{ + ID: r.ID, + UserID: r.UserID, + OwnerUsername: r.OwnerUsername, + Name: r.Name, + Description: r.Description, + IsPublic: r.IsPublic, + CoverPath: r.CoverPath, + TrackCount: r.TrackCount, + DurationSec: r.DurationSec, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +// pgtypeUUIDEqual treats two zero/invalid UUIDs as not-equal. The +// service uses this for owner-vs-caller checks where an invalid UUID +// can never legitimately match anything. +func pgtypeUUIDEqual(a, b pgtype.UUID) bool { + if !a.Valid || !b.Valid { + return false + } + return a.Bytes == b.Bytes +} diff --git a/internal/playlists/service_test.go b/internal/playlists/service_test.go new file mode 100644 index 00000000..6d55fb15 --- /dev/null +++ b/internal/playlists/service_test.go @@ -0,0 +1,320 @@ +package playlists_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" +) + +func TestCreate_HappyPath(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + dir := t.TempDir() + + svc := playlists.NewService(pool, nil, dir) + row, err := svc.Create(context.Background(), user.ID, "Saturday morning", "Mellow", false) + if err != nil { + t.Fatalf("Create: %v", err) + } + if row.Name != "Saturday morning" { + t.Errorf("name = %q, want Saturday morning", row.Name) + } + if row.IsPublic { + t.Error("IsPublic = true, want false (default)") + } + if row.OwnerUsername != "test-alice" { + t.Errorf("OwnerUsername = %q, want test-alice", row.OwnerUsername) + } + if row.TrackCount != 0 || row.DurationSec != 0 { + t.Errorf("rollups should start at 0; got count=%d duration=%d", row.TrackCount, row.DurationSec) + } +} + +func TestCreate_EmptyNameRejected(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + svc := playlists.NewService(pool, nil, t.TempDir()) + + _, err := svc.Create(context.Background(), user.ID, "", "", false) + if !errors.Is(err, playlists.ErrInvalidInput) { + t.Errorf("err = %v, want ErrInvalidInput", err) + } +} + +func TestGet_NotFound(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + svc := playlists.NewService(pool, nil, t.TempDir()) + + _, err := svc.Get(context.Background(), user.ID, randomUUID()) + if !errors.Is(err, playlists.ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestGet_Forbidden_PrivatePlaylistFromOtherUser(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + bob := seedUser(t, pool, "bob") + svc := playlists.NewService(pool, nil, t.TempDir()) + + pl, err := svc.Create(context.Background(), alice.ID, "Private", "", false) + if err != nil { + t.Fatalf("seed Create: %v", err) + } + + if _, err := svc.Get(context.Background(), bob.ID, pl.ID); !errors.Is(err, playlists.ErrForbidden) { + t.Errorf("err = %v, want ErrForbidden", err) + } +} + +func TestGet_Public_VisibleToOtherUser(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + bob := seedUser(t, pool, "bob") + svc := playlists.NewService(pool, nil, t.TempDir()) + + pl, err := svc.Create(context.Background(), alice.ID, "Public mix", "", true) + if err != nil { + t.Fatalf("seed Create: %v", err) + } + + got, err := svc.Get(context.Background(), bob.ID, pl.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Name != "Public mix" { + t.Errorf("name = %q", got.Name) + } + if !got.IsPublic { + t.Error("IsPublic = false, want true") + } + if len(got.Tracks) != 0 { + t.Errorf("Tracks len = %d, want 0 for an empty playlist", len(got.Tracks)) + } +} + +func TestGet_Owner_SeesOwnPrivate(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + svc := playlists.NewService(pool, nil, t.TempDir()) + + pl, err := svc.Create(context.Background(), alice.ID, "Mine", "", false) + if err != nil { + t.Fatalf("seed Create: %v", err) + } + got, err := svc.Get(context.Background(), alice.ID, pl.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Name != "Mine" { + t.Errorf("name = %q", got.Name) + } +} + +func TestList_OwnAndPublic(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + bob := seedUser(t, pool, "bob") + svc := playlists.NewService(pool, nil, t.TempDir()) + + if _, err := svc.Create(context.Background(), alice.ID, "Alice private", "", false); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := svc.Create(context.Background(), alice.ID, "Alice public", "", true); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := svc.Create(context.Background(), bob.ID, "Bob private", "", false); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := svc.Create(context.Background(), bob.ID, "Bob public", "", true); err != nil { + t.Fatalf("seed: %v", err) + } + + rows, err := svc.List(context.Background(), alice.ID) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(rows) != 3 { + t.Errorf("alice's list = %d rows, want 3 (her 2 + bob's public)", len(rows)) + } + for _, r := range rows { + if r.Name == "Bob private" { + t.Errorf("alice's list leaked bob's private playlist") + } + } +} + +func TestUpdate_OwnerOnly(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + bob := seedUser(t, pool, "bob") + svc := playlists.NewService(pool, nil, t.TempDir()) + + pl, err := svc.Create(context.Background(), alice.ID, "Mine", "", false) + if err != nil { + t.Fatalf("seed Create: %v", err) + } + + rename := "Renamed" + if _, err := svc.Update(context.Background(), bob.ID, pl.ID, playlists.UpdateInput{Name: &rename}); !errors.Is(err, playlists.ErrForbidden) { + t.Errorf("err = %v, want ErrForbidden", err) + } + + updated, err := svc.Update(context.Background(), alice.ID, pl.ID, playlists.UpdateInput{Name: &rename}) + if err != nil { + t.Fatalf("owner Update: %v", err) + } + if updated.Name != "Renamed" { + t.Errorf("name = %q, want Renamed", updated.Name) + } +} + +func TestUpdate_PartialFieldsLeaveOthersAlone(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + svc := playlists.NewService(pool, nil, t.TempDir()) + + pl, err := svc.Create(context.Background(), alice.ID, "Original", "Long description", false) + if err != nil { + t.Fatalf("seed Create: %v", err) + } + + // Touch only IsPublic — name and description must survive. + pub := true + updated, err := svc.Update(context.Background(), alice.ID, pl.ID, playlists.UpdateInput{IsPublic: &pub}) + if err != nil { + t.Fatalf("Update: %v", err) + } + if updated.Name != "Original" { + t.Errorf("name = %q, want unchanged Original", updated.Name) + } + if updated.Description != "Long description" { + t.Errorf("description = %q, want unchanged", updated.Description) + } + if !updated.IsPublic { + t.Error("IsPublic = false, want true after toggle") + } +} + +func TestUpdate_EmptyNameRejected(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + svc := playlists.NewService(pool, nil, t.TempDir()) + + pl, err := svc.Create(context.Background(), alice.ID, "Original", "", false) + if err != nil { + t.Fatalf("seed Create: %v", err) + } + + empty := "" + if _, err := svc.Update(context.Background(), alice.ID, pl.ID, playlists.UpdateInput{Name: &empty}); !errors.Is(err, playlists.ErrInvalidInput) { + t.Errorf("err = %v, want ErrInvalidInput", err) + } +} + +func TestUpdate_NotFound(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + svc := playlists.NewService(pool, nil, t.TempDir()) + + rename := "Renamed" + if _, err := svc.Update(context.Background(), alice.ID, randomUUID(), playlists.UpdateInput{Name: &rename}); !errors.Is(err, playlists.ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestDelete_OwnerOnly(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + bob := seedUser(t, pool, "bob") + svc := playlists.NewService(pool, nil, t.TempDir()) + + pl, err := svc.Create(context.Background(), alice.ID, "Mine", "", false) + if err != nil { + t.Fatalf("seed Create: %v", err) + } + + if err := svc.Delete(context.Background(), bob.ID, pl.ID); !errors.Is(err, playlists.ErrForbidden) { + t.Errorf("non-owner err = %v, want ErrForbidden", err) + } + if err := svc.Delete(context.Background(), alice.ID, pl.ID); err != nil { + t.Fatalf("owner Delete: %v", err) + } + // Idempotency: a second delete reports NotFound rather than silently succeeding. + if err := svc.Delete(context.Background(), alice.ID, pl.ID); !errors.Is(err, playlists.ErrNotFound) { + t.Errorf("repeat Delete err = %v, want ErrNotFound", err) + } +} + +func TestDelete_NotFound(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + svc := playlists.NewService(pool, nil, t.TempDir()) + + if err := svc.Delete(context.Background(), alice.ID, randomUUID()); !errors.Is(err, playlists.ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestDelete_RemovesCoverFile(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + dir := t.TempDir() + svc := playlists.NewService(pool, nil, dir) + + pl, err := svc.Create(context.Background(), user.ID, "Will be deleted", "", false) + if err != nil { + t.Fatalf("seed Create: %v", err) + } + + // Simulate the collage generator having stashed a file at the + // path stored on the row. The Delete path should clean it up. + coverDir := filepath.Join(dir, "playlist_covers") + if err := os.MkdirAll(coverDir, 0o755); err != nil { + t.Fatalf("mkdir cover dir: %v", err) + } + relPath := filepath.Join("playlist_covers", uuidString(pl.ID)+".jpg") + full := filepath.Join(dir, relPath) + if err := os.WriteFile(full, []byte("jpeg-bytes"), 0o644); err != nil { + t.Fatalf("seed cover file: %v", err) + } + if _, err := pool.Exec(context.Background(), + `UPDATE playlists SET cover_path = $1 WHERE id = $2`, relPath, pl.ID); err != nil { + t.Fatalf("set cover_path: %v", err) + } + + if err := svc.Delete(context.Background(), user.ID, pl.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, statErr := os.Stat(full); !errors.Is(statErr, os.ErrNotExist) { + t.Errorf("cover file still on disk after delete: stat err=%v", statErr) + } +} + +func TestDelete_MissingCoverFileTolerated(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + dir := t.TempDir() + svc := playlists.NewService(pool, nil, dir) + + pl, err := svc.Create(context.Background(), user.ID, "No cover on disk", "", false) + if err != nil { + t.Fatalf("seed Create: %v", err) + } + // Set cover_path to a file that doesn't exist — Delete must not + // error on a missing cached file; it's just cleanup. + relPath := filepath.Join("playlist_covers", uuidString(pl.ID)+".jpg") + if _, err := pool.Exec(context.Background(), + `UPDATE playlists SET cover_path = $1 WHERE id = $2`, relPath, pl.ID); err != nil { + t.Fatalf("set cover_path: %v", err) + } + + if err := svc.Delete(context.Background(), user.ID, pl.ID); err != nil { + t.Fatalf("Delete with missing cover file: %v", err) + } +} diff --git a/internal/playlists/service_test_helpers_test.go b/internal/playlists/service_test_helpers_test.go new file mode 100644 index 00000000..91b7ce8c --- /dev/null +++ b/internal/playlists/service_test_helpers_test.go @@ -0,0 +1,92 @@ +package playlists_test + +import ( + "context" + "crypto/rand" + "io" + "log/slog" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" +) + +// newPool migrates a fresh schema against MINSTREL_TEST_DATABASE_URL +// and returns a connection pool with all data tables wiped (test +// users only — see internal/dbtest/reset.go for the why). Skips when +// the env var isn't set or -short is passed. +func newPool(t *testing.T) *pgxpool.Pool { + t.Helper() + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + dbtest.ResetDB(t, pool) + return pool +} + +// seedUser inserts a `test-`-prefixed user (so dbtest.ResetDB can +// clean it without touching the operator's admin row) and returns +// the persisted user record. +func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User { + t.Helper() + u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ + Username: dbtest.TestUserPrefix + name, + PasswordHash: "x", + ApiToken: name + "-token", + IsAdmin: false, + }) + if err != nil { + t.Fatalf("seed user %s: %v", name, err) + } + return u +} + +// randomUUID returns a fresh valid pgtype.UUID. Used in tests that +// need an id guaranteed-not-to-exist in the database. +func randomUUID() pgtype.UUID { + var u pgtype.UUID + if _, err := rand.Read(u.Bytes[:]); err != nil { + panic(err) + } + u.Valid = true + return u +} + +// uuidString renders a pgtype.UUID as the canonical 8-4-4-4-12 form +// without depending on a third-party UUID package — used in tests +// that build a filename from a playlist id. +func uuidString(u pgtype.UUID) string { + if !u.Valid { + return "" + } + const hex = "0123456789abcdef" + out := make([]byte, 36) + j := 0 + for i, x := range u.Bytes { + if i == 4 || i == 6 || i == 8 || i == 10 { + out[j] = '-' + j++ + } + out[j] = hex[x>>4] + out[j+1] = hex[x&0x0f] + j += 2 + } + return string(out) +} From 79bab14b3074e7b24b7350a36844478943f25611 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 10:25:20 -0400 Subject: [PATCH 100/351] feat(playlists): track operations + cover-collage generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppendTracks / RemoveTrack / Reorder run in single transactions and update the denormalized rollups (track_count, duration_sec). Reorder uses a +10000 offset to bump every row out of the position range before writing the new positions, sidestepping PK conflicts during rewrite. GenerateCollage composes a 600x600 JPEG from the first 4 album covers, with a slate-tinted fallback for missing cells. Synchronous, called inline after every mutating operation. SVG-rasterized fallback is a follow-up — slice 1 ships with a solid placeholder. --- internal/playlists/collage.go | 179 +++++++++++++++++ internal/playlists/collage_test.go | 75 +++++++ internal/playlists/service.go | 186 +++++++++++++++++- internal/playlists/service_test.go | 158 +++++++++++++++ .../playlists/service_test_helpers_test.go | 43 ++++ 5 files changed, 634 insertions(+), 7 deletions(-) create mode 100644 internal/playlists/collage.go create mode 100644 internal/playlists/collage_test.go diff --git a/internal/playlists/collage.go b/internal/playlists/collage.go new file mode 100644 index 00000000..c99f5863 --- /dev/null +++ b/internal/playlists/collage.go @@ -0,0 +1,179 @@ +package playlists + +import ( + "context" + "fmt" + "image" + "image/color" + "image/draw" + "image/jpeg" + "os" + "path/filepath" + "strings" + + _ "image/png" // for decoding existing PNG covers (jpeg already linked above) + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +const ( + collageCellSize = 300 // px per cell, 2x2 → 600x600 output + collageOutputDim = collageCellSize * 2 + collageQuality = 85 +) + +// GenerateCollage composes a 600x600 JPEG from the first 4 contributing +// tracks' album covers. Missing covers (no upstream album, empty +// cover_art_path, file unreadable) get the album-fallback glyph in their +// cell. Writes to /playlist_covers/.jpg and +// updates playlists.cover_path. Returns the relative path written, or +// "" when the playlist is empty (cover_path also cleared). +func GenerateCollage(ctx context.Context, pool *pgxpool.Pool, playlistID pgtype.UUID, dataDir string) (string, error) { + q := dbq.New(pool) + rows, err := q.ListAllPlaylistTracksForCollage(ctx, dbq.ListAllPlaylistTracksForCollageParams{ + PlaylistID: playlistID, + Limit: 4, + }) + if err != nil { + return "", fmt.Errorf("list collage tracks: %w", err) + } + + if len(rows) == 0 { + // Empty playlist: clear cover_path and remove any stale file. + if err := q.SetPlaylistCover(ctx, dbq.SetPlaylistCoverParams{ + ID: playlistID, + CoverPath: nil, + }); err != nil { + return "", fmt.Errorf("clear cover_path: %w", err) + } + _ = os.Remove(filepath.Join(dataDir, "playlist_covers", uuidToFilename(playlistID)+".jpg")) + return "", nil + } + + out := image.NewRGBA(image.Rect(0, 0, collageOutputDim, collageOutputDim)) + // Fill with FabledSword `iron` (#1E2228) so any drawing gaps look intentional. + draw.Draw(out, out.Bounds(), &image.Uniform{C: color.RGBA{0x1E, 0x22, 0x28, 0xFF}}, image.Point{}, draw.Src) + + for cell := 0; cell < 4; cell++ { + var coverPath string + if cell < len(rows) && rows[cell].AlbumCoverPath != nil && *rows[cell].AlbumCoverPath != "" { + coverPath = *rows[cell].AlbumCoverPath + } + img := loadCellImage(coverPath, dataDir) + col := cell % 2 + row := cell / 2 + dest := image.Rect(col*collageCellSize, row*collageCellSize, (col+1)*collageCellSize, (row+1)*collageCellSize) + drawScaled(out, dest, img) + } + + if err := os.MkdirAll(filepath.Join(dataDir, "playlist_covers"), 0o755); err != nil { + return "", fmt.Errorf("mkdir playlist_covers: %w", err) + } + relPath := filepath.Join("playlist_covers", uuidToFilename(playlistID)+".jpg") + full := filepath.Join(dataDir, relPath) + tmp := full + ".tmp" + f, err := os.Create(tmp) + if err != nil { + return "", fmt.Errorf("create collage file: %w", err) + } + if err := jpeg.Encode(f, out, &jpeg.Options{Quality: collageQuality}); err != nil { + _ = f.Close() + _ = os.Remove(tmp) + return "", fmt.Errorf("encode jpeg: %w", err) + } + if err := f.Close(); err != nil { + return "", fmt.Errorf("close collage file: %w", err) + } + if err := os.Rename(tmp, full); err != nil { + return "", fmt.Errorf("rename: %w", err) + } + + relPathPtr := relPath + if err := q.SetPlaylistCover(ctx, dbq.SetPlaylistCoverParams{ + ID: playlistID, + CoverPath: &relPathPtr, + }); err != nil { + return "", fmt.Errorf("set cover_path: %w", err) + } + return relPath, nil +} + +// loadCellImage tries to load the album cover at the given path. On any +// failure (empty path, missing file, decode error), returns the rendered +// fallback glyph as a 300x300 image. +func loadCellImage(coverPath, dataDir string) image.Image { + if coverPath == "" { + return fallbackGlyph() + } + full := coverPath + if !filepath.IsAbs(coverPath) { + full = filepath.Join(dataDir, coverPath) + } + f, err := os.Open(full) + if err != nil { + return fallbackGlyph() + } + defer f.Close() + img, _, err := image.Decode(f) + if err != nil { + return fallbackGlyph() + } + return img +} + +// fallbackGlyph returns a 300x300 image filled with the FabledSword +// "slate" surface tint and a centered solid box approximating the +// album-fallback glyph. Slice 1 trade-off: rasterizing the actual SVG +// at runtime requires a third-party SVG renderer (oksvg / etc.) and +// adds dependency + complexity. The solid placeholder reads as +// "this cell intentionally empty" without committing to SVG plumbing. +// A follow-up task can swap in real SVG rasterization. +func fallbackGlyph() image.Image { + img := image.NewRGBA(image.Rect(0, 0, collageCellSize, collageCellSize)) + bg := color.RGBA{0x2C, 0x31, 0x3A, 0xFF} // FabledSword slate + fg := color.RGBA{0x9C, 0x9A, 0x92, 0xFF} // FabledSword ash + draw.Draw(img, img.Bounds(), &image.Uniform{C: bg}, image.Point{}, draw.Src) + center := image.Rect(100, 100, 200, 200) + draw.Draw(img, center, &image.Uniform{C: fg}, image.Point{}, draw.Src) + return img +} + +// drawScaled copies src into dst.Rect, scaling with simple nearest-neighbor. +// stdlib lacks high-quality scaling; nearest-neighbor is fine for a +// 600x600 output where each cell is 300x300 — most album covers are +// already 300-1500 pixels and the visual loss is minor. +func drawScaled(dst draw.Image, r image.Rectangle, src image.Image) { + srcBounds := src.Bounds() + for y := r.Min.Y; y < r.Max.Y; y++ { + for x := r.Min.X; x < r.Max.X; x++ { + sx := srcBounds.Min.X + (x-r.Min.X)*srcBounds.Dx()/r.Dx() + sy := srcBounds.Min.Y + (y-r.Min.Y)*srcBounds.Dy()/r.Dy() + dst.Set(x, y, src.At(sx, sy)) + } + } +} + +// uuidToFilename renders a pgtype.UUID for use in a filename. The +// strings.NewReplacer guards against any future caller passing a +// non-canonical form — the canonical 8-4-4-4-12 hex form has no path +// separators or .. sequences, but defense-in-depth is cheap. +func uuidToFilename(u pgtype.UUID) string { + s := pgtypeUUIDStringForCollage(u) + return strings.NewReplacer("/", "_", "..", "_").Replace(s) +} + +// pgtypeUUIDStringForCollage renders a pgtype.UUID as the canonical +// 8-4-4-4-12 hex form. Local helper to avoid pulling in a third-party +// UUID package or creating a cross-package dependency. +func pgtypeUUIDStringForCollage(u pgtype.UUID) string { + if !u.Valid { + return "" + } + b := u.Bytes + return fmt.Sprintf("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], + b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]) +} diff --git a/internal/playlists/collage_test.go b/internal/playlists/collage_test.go new file mode 100644 index 00000000..44e2431e --- /dev/null +++ b/internal/playlists/collage_test.go @@ -0,0 +1,75 @@ +package playlists_test + +import ( + "context" + "image" + _ "image/jpeg" + "os" + "path/filepath" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" +) + +func TestGenerateCollage_EmptyPlaylist(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + dir := t.TempDir() + svc := playlists.NewService(pool, nil, dir) + + pl, err := svc.Create(context.Background(), user.ID, "Empty", "", false) + if err != nil { + t.Fatalf("Create: %v", err) + } + + relPath, err := playlists.GenerateCollage(context.Background(), pool, pl.ID, dir) + if err != nil { + t.Fatalf("GenerateCollage: %v", err) + } + if relPath != "" { + t.Errorf("relPath = %q, want \"\" for empty playlist", relPath) + } +} + +func TestGenerateCollage_OneTrack(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + tk := seedTrack(t, pool, "Roygbiv", "BoC") + dir := t.TempDir() + svc := playlists.NewService(pool, nil, dir) + + pl, err := svc.Create(context.Background(), user.ID, "One", "", false) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{tk.ID}); err != nil { + t.Fatalf("AppendTracks: %v", err) + } + + got, err := svc.Get(context.Background(), user.ID, pl.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.CoverPath == nil || *got.CoverPath == "" { + t.Fatalf("CoverPath empty after append") + } + + full := filepath.Join(dir, *got.CoverPath) + f, err := os.Open(full) + if err != nil { + t.Fatalf("open collage: %v", err) + } + defer f.Close() + img, format, err := image.Decode(f) + if err != nil { + t.Fatalf("decode collage: %v", err) + } + if format != "jpeg" { + t.Errorf("format = %q, want jpeg", format) + } + if img.Bounds().Dx() != 600 || img.Bounds().Dy() != 600 { + t.Errorf("dimensions = %dx%d, want 600x600", img.Bounds().Dx(), img.Bounds().Dy()) + } +} diff --git a/internal/playlists/service.go b/internal/playlists/service.go index 19ec8bd8..b08300f0 100644 --- a/internal/playlists/service.go +++ b/internal/playlists/service.go @@ -6,10 +6,10 @@ // - is_public = true makes a playlist readable by any authenticated // user; mutations are still owner-only. // -// Track-list mutations (Append / Remove / Reorder) and the cover-art -// collage generator land in subsequent tasks; this file ships the -// CRUD-only slice on top of the migration 0014 + sqlc queries from -// task 1. +// Track-list mutations (AppendTracks / RemoveTrack / Reorder) bump +// the denormalized rollups (track_count, duration_sec) on the playlist +// row and trigger cover-art regeneration via GenerateCollage. The +// collage generator itself lives alongside in collage.go. package playlists import ( @@ -30,9 +30,10 @@ import ( // Typed errors. The API layer maps each to its wire status code; tests // use errors.Is to assert the right path was taken. var ( - ErrNotFound = errors.New("playlists: playlist not found") - ErrForbidden = errors.New("playlists: forbidden") - ErrInvalidInput = errors.New("playlists: invalid input") + ErrNotFound = errors.New("playlists: playlist not found") + ErrForbidden = errors.New("playlists: forbidden") + ErrInvalidInput = errors.New("playlists: invalid input") + ErrTrackNotFound = errors.New("playlists: track not found") ) // Service wires the sqlc queries + on-disk cover-art directory together. @@ -291,6 +292,177 @@ func (s *Service) Delete(ctx context.Context, callerID, playlistID pgtype.UUID) return nil } +// AppendTracks adds the given track ids at the end of the playlist, +// preserving order. Snapshot fields (title, artist, album, duration_sec) +// are populated from the tracks/albums/artists join at insert time. +// Triggers cover regeneration after. +func (s *Service) AppendTracks(ctx context.Context, callerID, playlistID pgtype.UUID, trackIDs []pgtype.UUID) error { + if len(trackIDs) == 0 { + return fmt.Errorf("%w: at least one track id required", ErrInvalidInput) + } + q := dbq.New(s.pool) + pl, err := q.GetPlaylist(ctx, playlistID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return fmt.Errorf("get playlist: %w", err) + } + if !pgtypeUUIDEqual(pl.UserID, callerID) { + return ErrForbidden + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + tq := dbq.New(tx) + + for _, tid := range trackIDs { + _, ierr := tq.AppendPlaylistTrack(ctx, dbq.AppendPlaylistTrackParams{ + PlaylistID: playlistID, + TrackID: tid, + }) + if ierr != nil { + // AppendPlaylistTrack uses INSERT ... SELECT FROM tracks WHERE id = X. + // If X doesn't exist, no row inserts and the :one query errors with + // pgx.ErrNoRows. + if errors.Is(ierr, pgx.ErrNoRows) { + return ErrTrackNotFound + } + return fmt.Errorf("append track: %w", ierr) + } + } + if err := tq.UpdatePlaylistRollups(ctx, playlistID); err != nil { + return fmt.Errorf("update rollups: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit: %w", err) + } + + // Regenerate cover (synchronous). Failure is logged but not returned — + // the playlist mutation already committed. + if _, cerr := GenerateCollage(ctx, s.pool, playlistID, s.dataDir); cerr != nil { + s.logger.Warn("collage regeneration failed", "playlist_id", playlistID, "err", cerr) + } + return nil +} + +// RemoveTrack deletes the row at `position` and renumbers subsequent +// rows to close the gap. Triggers cover regeneration after. +func (s *Service) RemoveTrack(ctx context.Context, callerID, playlistID pgtype.UUID, position int32) error { + q := dbq.New(s.pool) + pl, err := q.GetPlaylist(ctx, playlistID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return fmt.Errorf("get playlist: %w", err) + } + if !pgtypeUUIDEqual(pl.UserID, callerID) { + return ErrForbidden + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + tq := dbq.New(tx) + + if err := tq.DeletePlaylistTrack(ctx, dbq.DeletePlaylistTrackParams{ + PlaylistID: playlistID, + Position: position, + }); err != nil { + return fmt.Errorf("delete track row: %w", err) + } + if err := tq.RenumberPlaylistTracksAfter(ctx, dbq.RenumberPlaylistTracksAfterParams{ + PlaylistID: playlistID, + Position: position, + }); err != nil { + return fmt.Errorf("renumber: %w", err) + } + if err := tq.UpdatePlaylistRollups(ctx, playlistID); err != nil { + return fmt.Errorf("update rollups: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit: %w", err) + } + + if _, cerr := GenerateCollage(ctx, s.pool, playlistID, s.dataDir); cerr != nil { + s.logger.Warn("collage regeneration failed", "playlist_id", playlistID, "err", cerr) + } + return nil +} + +// Reorder atomically rewrites all positions to the supplied permutation. +// orderedPositions is the new positional order: if the playlist has 4 +// tracks at positions [0, 1, 2, 3] and the caller wants the order +// (old[3], old[0], old[1], old[2]), they send [3, 0, 1, 2]. +func (s *Service) Reorder(ctx context.Context, callerID, playlistID pgtype.UUID, orderedPositions []int32) error { + q := dbq.New(s.pool) + pl, err := q.GetPlaylist(ctx, playlistID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return fmt.Errorf("get playlist: %w", err) + } + if !pgtypeUUIDEqual(pl.UserID, callerID) { + return ErrForbidden + } + + if int32(len(orderedPositions)) != pl.TrackCount { + return fmt.Errorf("%w: expected %d positions, got %d", ErrInvalidInput, pl.TrackCount, len(orderedPositions)) + } + seen := make(map[int32]struct{}, len(orderedPositions)) + for _, p := range orderedPositions { + if p < 0 || p >= pl.TrackCount { + return fmt.Errorf("%w: position %d out of range", ErrInvalidInput, p) + } + if _, dup := seen[p]; dup { + return fmt.Errorf("%w: position %d duplicated", ErrInvalidInput, p) + } + seen[p] = struct{}{} + } + + // Rewrite strategy: bump every row's position by +10000 first (out of + // range of valid positions and the unique PK) then write the new + // positions one by one. Single transaction. + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + if _, err := tx.Exec(ctx, + `UPDATE playlist_tracks SET position = position + 10000 WHERE playlist_id = $1`, + playlistID); err != nil { + return fmt.Errorf("offset positions: %w", err) + } + for newPos, oldPos := range orderedPositions { + if _, err := tx.Exec(ctx, + `UPDATE playlist_tracks SET position = $1 WHERE playlist_id = $2 AND position = $3`, + int32(newPos), playlistID, oldPos+10000); err != nil { + return fmt.Errorf("write new position: %w", err) + } + } + if err := dbq.New(tx).UpdatePlaylistRollups(ctx, playlistID); err != nil { + return fmt.Errorf("update rollups: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit: %w", err) + } + + // Always regenerate the cover. Reasoning about which moves matter is + // more error-prone than just doing the work; the collage is cheap. + if _, cerr := GenerateCollage(ctx, s.pool, playlistID, s.dataDir); cerr != nil { + s.logger.Warn("collage regeneration failed", "playlist_id", playlistID, "err", cerr) + } + return nil +} + func playlistFromGetRow(r dbq.GetPlaylistRow) *PlaylistRow { return &PlaylistRow{ ID: r.ID, diff --git a/internal/playlists/service_test.go b/internal/playlists/service_test.go index 6d55fb15..1b8f6ec2 100644 --- a/internal/playlists/service_test.go +++ b/internal/playlists/service_test.go @@ -7,6 +7,8 @@ import ( "path/filepath" "testing" + "github.com/jackc/pgx/v5/pgtype" + "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" ) @@ -318,3 +320,159 @@ func TestDelete_MissingCoverFileTolerated(t *testing.T) { t.Fatalf("Delete with missing cover file: %v", err) } } + +func TestAppendTracks_Snapshot(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + track := seedTrack(t, pool, "Roygbiv", "Boards of Canada") + svc := playlists.NewService(pool, nil, t.TempDir()) + + pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false) + if err := svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{track.ID}); err != nil { + t.Fatalf("AppendTracks: %v", err) + } + + got, _ := svc.Get(context.Background(), user.ID, pl.ID) + if len(got.Tracks) != 1 { + t.Fatalf("Tracks length = %d, want 1", len(got.Tracks)) + } + if got.Tracks[0].Title != "Roygbiv" { + t.Errorf("Title snapshot = %q, want Roygbiv", got.Tracks[0].Title) + } + if got.Tracks[0].ArtistName != "Boards of Canada" { + t.Errorf("ArtistName snapshot = %q, want Boards of Canada", got.Tracks[0].ArtistName) + } + if got.TrackCount != 1 { + t.Errorf("rollup TrackCount = %d, want 1", got.TrackCount) + } +} + +func TestAppendTracks_OwnerOnly(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + bob := seedUser(t, pool, "bob") + track := seedTrack(t, pool, "T", "A") + svc := playlists.NewService(pool, nil, t.TempDir()) + + pl, _ := svc.Create(context.Background(), alice.ID, "Mine", "", true) + err := svc.AppendTracks(context.Background(), bob.ID, pl.ID, []pgtype.UUID{track.ID}) + if !errors.Is(err, playlists.ErrForbidden) { + t.Errorf("err = %v, want ErrForbidden", err) + } +} + +func TestAppendTracks_NonExistentTrack(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + svc := playlists.NewService(pool, nil, t.TempDir()) + + pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false) + err := svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{randomUUID()}) + if !errors.Is(err, playlists.ErrTrackNotFound) { + t.Errorf("err = %v, want ErrTrackNotFound", err) + } +} + +func TestRemoveTrack_RenumbersPositions(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + t1 := seedTrack(t, pool, "Track 1", "Artist") + t2 := seedTrack(t, pool, "Track 2", "Artist") + t3 := seedTrack(t, pool, "Track 3", "Artist") + svc := playlists.NewService(pool, nil, t.TempDir()) + + pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false) + _ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{t1.ID, t2.ID, t3.ID}) + + // Remove the middle one (position 1). + if err := svc.RemoveTrack(context.Background(), user.ID, pl.ID, 1); err != nil { + t.Fatalf("RemoveTrack: %v", err) + } + + got, _ := svc.Get(context.Background(), user.ID, pl.ID) + if len(got.Tracks) != 2 { + t.Fatalf("Tracks length = %d, want 2", len(got.Tracks)) + } + if got.Tracks[0].Title != "Track 1" { + t.Errorf("position 0 = %q, want Track 1", got.Tracks[0].Title) + } + if got.Tracks[1].Title != "Track 3" { + t.Errorf("position 1 = %q, want Track 3 (renumbered from 2)", got.Tracks[1].Title) + } +} + +func TestReorder_Permutation(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + t1 := seedTrack(t, pool, "A", "Artist") + t2 := seedTrack(t, pool, "B", "Artist") + t3 := seedTrack(t, pool, "C", "Artist") + svc := playlists.NewService(pool, nil, t.TempDir()) + + pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false) + _ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{t1.ID, t2.ID, t3.ID}) + + // Reverse order: send [2, 1, 0]. + if err := svc.Reorder(context.Background(), user.ID, pl.ID, []int32{2, 1, 0}); err != nil { + t.Fatalf("Reorder: %v", err) + } + + got, _ := svc.Get(context.Background(), user.ID, pl.ID) + wantOrder := []string{"C", "B", "A"} + for i, pt := range got.Tracks { + if pt.Title != wantOrder[i] { + t.Errorf("position %d = %q, want %q", i, pt.Title, wantOrder[i]) + } + } +} + +func TestReorder_RejectsNonPermutation(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + tk := seedTrack(t, pool, "T", "A") + svc := playlists.NewService(pool, nil, t.TempDir()) + + pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false) + _ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{tk.ID}) + + // 2 positions for a 1-track playlist — invalid. + err := svc.Reorder(context.Background(), user.ID, pl.ID, []int32{0, 1}) + if !errors.Is(err, playlists.ErrInvalidInput) { + t.Errorf("err = %v, want ErrInvalidInput (length mismatch)", err) + } + + pl2, _ := svc.Create(context.Background(), user.ID, "Two", "", false) + t2 := seedTrack(t, pool, "T2", "A") + _ = svc.AppendTracks(context.Background(), user.ID, pl2.ID, []pgtype.UUID{tk.ID, t2.ID}) + err = svc.Reorder(context.Background(), user.ID, pl2.ID, []int32{0, 0}) + if !errors.Is(err, playlists.ErrInvalidInput) { + t.Errorf("err = %v, want ErrInvalidInput (duplicate)", err) + } +} + +func TestSnapshotPersistsAfterUpstreamTrackDelete(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + tk := seedTrack(t, pool, "Doomed track", "Artist") + svc := playlists.NewService(pool, nil, t.TempDir()) + + pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false) + _ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{tk.ID}) + + // Delete the track row directly (simulating Lidarr re-import / file removal). + if _, err := pool.Exec(context.Background(), `DELETE FROM tracks WHERE id = $1`, tk.ID); err != nil { + t.Fatalf("delete track: %v", err) + } + + got, _ := svc.Get(context.Background(), user.ID, pl.ID) + if len(got.Tracks) != 1 { + t.Fatalf("Tracks length = %d, want 1 (soft-mark not silent-cascade)", len(got.Tracks)) + } + row := got.Tracks[0] + if row.TrackID != nil { + t.Error("TrackID should be nil after upstream delete") + } + if row.Title != "Doomed track" { + t.Errorf("Snapshot Title = %q, want Doomed track", row.Title) + } +} diff --git a/internal/playlists/service_test_helpers_test.go b/internal/playlists/service_test_helpers_test.go index 91b7ce8c..a50118d0 100644 --- a/internal/playlists/service_test_helpers_test.go +++ b/internal/playlists/service_test_helpers_test.go @@ -3,9 +3,12 @@ package playlists_test import ( "context" "crypto/rand" + "fmt" "io" "log/slog" "os" + "path/filepath" + "sync/atomic" "testing" "github.com/jackc/pgx/v5/pgtype" @@ -16,6 +19,12 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" ) +// seedTrackCounter ensures every seedTrack call gets a unique file_path +// even when title + artist repeat. Tracks dedupe by file_path so we +// must vary it; using t.TempDir() alone isn't enough because some +// tests seed multiple tracks within one test function. +var seedTrackCounter uint64 + // newPool migrates a fresh schema against MINSTREL_TEST_DATABASE_URL // and returns a connection pool with all data tables wiped (test // users only — see internal/dbtest/reset.go for the why). Skips when @@ -58,6 +67,40 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User { return u } +// seedTrack creates an artist + album + track triple suitable for +// playlist track-list mutation tests. Each call produces a fresh +// track row with a unique file_path; artist and album are not +// deduplicated across calls (mbid-less upsert), but that's fine for +// these tests — playlist snapshots just capture the strings. +func seedTrack(t *testing.T, pool *pgxpool.Pool, title, artist string) dbq.Track { + t.Helper() + q := dbq.New(pool) + a, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: artist, SortName: artist, + }) + if err != nil { + t.Fatalf("seed artist: %v", err) + } + al, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: artist + " - Album", SortTitle: artist + " - Album", + ArtistID: a.ID, + }) + if err != nil { + t.Fatalf("seed album: %v", err) + } + n := atomic.AddUint64(&seedTrackCounter, 1) + track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: title, AlbumID: al.ID, ArtistID: a.ID, + DurationMs: 1000, + FilePath: filepath.Join(t.TempDir(), fmt.Sprintf("%s-%d.mp3", title, n)), + FileSize: 100, FileFormat: "mp3", + }) + if err != nil { + t.Fatalf("seed track: %v", err) + } + return track +} + // randomUUID returns a fresh valid pgtype.UUID. Used in tests that // need an id guaranteed-not-to-exist in the database. func randomUUID() pgtype.UUID { From c331168d3b868e250b51c6614f099957d6390efb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 11:10:21 -0400 Subject: [PATCH 101/351] feat(api): /api/playlists* handlers for M7 #352 slice 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9 handlers covering create / get / list / update / delete / append / remove / reorder / cover. Permissions enforced in the service layer (ErrForbidden -> 403 not_authorized) so the handler is a thin HTTP-shape adapter. /cover serves the cached collage from disk via http.ServeFile; 404 when cover_path is nil. Server gained a DataDir field so the playlists service can find the collage cache; api.Mount picks up two new params (playlistsSvc and dataDir). The stale TestRoutesRegisteredInMount Mount() call in library_test.go was missing the tracks.Service argument added by an earlier slice — fixed in passing. Wire codes follow the project's existing nested errorBody envelope: not_found, not_authorized, unauthenticated, bad_request, server_error. The plan called for a flat envelope, but the api package's writeErr already produces {"error":{"code":"...","message":"..."}} and deviating here would break every existing client. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/api/api.go | 17 +- internal/api/auth_test.go | 5 +- internal/api/library_test.go | 2 +- internal/api/playlists.go | 429 +++++++++++++++++++++++++++++++ internal/api/playlists_test.go | 457 +++++++++++++++++++++++++++++++++ internal/server/server.go | 9 +- 6 files changed, 915 insertions(+), 4 deletions(-) create mode 100644 internal/api/playlists.go create mode 100644 internal/api/playlists_test.go diff --git a/internal/api/api.go b/internal/api/api.go index 84887984..23215d03 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -17,13 +17,14 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" + "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" ) // Mount attaches /api/* handlers to r. Public endpoints (login) are outside // RequireUser; everything else is gated by the middleware. The events writer // is shared with the Subsonic mount so /rest/scrobble feeds the same store. -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service) { +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, dataDir string) { rng := rand.New(rand.NewSource(rand.Int63())) h := &handlers{ pool: pool, logger: logger, events: events, recCfg: recCfg, @@ -32,6 +33,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, + playlists: playlistsSvc, + dataDir: dataDir, } r.Route("/api", func(api chi.Router) { @@ -98,6 +101,16 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Delete("/tracks/{id}", h.handleRemoveTrack) }) + + authed.Get("/playlists", h.handleListPlaylists) + authed.Post("/playlists", h.handleCreatePlaylist) + authed.Get("/playlists/{id}", h.handleGetPlaylist) + authed.Patch("/playlists/{id}", h.handleUpdatePlaylist) + authed.Delete("/playlists/{id}", h.handleDeletePlaylist) + authed.Post("/playlists/{id}/tracks", h.handleAppendTracks) + authed.Delete("/playlists/{id}/tracks/{position}", h.handleRemovePlaylistTrack) + authed.Put("/playlists/{id}/tracks", h.handleReorderPlaylist) + authed.Get("/playlists/{id}/cover", h.handleGetPlaylistCover) }) }) } @@ -112,4 +125,6 @@ type handlers struct { lidarrRequests *lidarrrequests.Service lidarrQuarantine *lidarrquarantine.Service tracks *tracks.Service + playlists *playlists.Service + dataDir string } diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 921aa3cd..39590574 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -26,6 +26,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" + "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" ) @@ -65,7 +66,9 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { // admin-tracks tests below override h.tracks via installTracksLidarrStub // when they need a stubbed Lidarr. tracksSvc := tracks.NewService(pool, logger, nil) - h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc} + dataDir := t.TempDir() + playlistsSvc := playlists.NewService(pool, logger, dataDir) + h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir} return h, pool } diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 50734c6a..c267ce60 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -443,7 +443,7 @@ func TestRoutesRegisteredInMount(t *testing.T) { r := chi.NewRouter() w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), 30*time.Minute, 0.5, 30000) - Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine) + Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.dataDir) paths := []string{ "/api/artists", diff --git a/internal/api/playlists.go b/internal/api/playlists.go new file mode 100644 index 00000000..223f3884 --- /dev/null +++ b/internal/api/playlists.go @@ -0,0 +1,429 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "path/filepath" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" +) + +// playlistRowView is the wire shape for a playlist header. Mirrors +// playlists.PlaylistRow but renders UUIDs / timestamps as strings and +// derives the cover_url from cover_path so clients don't have to know +// the on-disk layout. +type playlistRowView struct { + ID string `json:"id"` + UserID string `json:"user_id"` + OwnerUsername string `json:"owner_username"` + Name string `json:"name"` + Description string `json:"description"` + IsPublic bool `json:"is_public"` + CoverURL string `json:"cover_url,omitempty"` + TrackCount int32 `json:"track_count"` + DurationSec int32 `json:"duration_sec"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// playlistTrackView is one row of a playlist's track list on the wire. +// TrackID / AlbumID / ArtistID / StreamURL are pointers so they encode as +// JSON null when the upstream library row has been removed (ON DELETE +// SET NULL semantics from the snapshot table). +type playlistTrackView struct { + Position int32 `json:"position"` + TrackID *string `json:"track_id"` + AlbumID *string `json:"album_id"` + ArtistID *string `json:"artist_id"` + Title string `json:"title"` + ArtistName string `json:"artist_name"` + AlbumTitle string `json:"album_title"` + DurationSec int32 `json:"duration_sec"` + StreamURL *string `json:"stream_url"` + AddedAt string `json:"added_at"` +} + +// playlistDetailView extends playlistRowView with the ordered track list. +// Returned by Get / Append / Remove / Reorder so the SPA always has a +// fresh authoritative state after a mutation. +type playlistDetailView struct { + playlistRowView + Tracks []playlistTrackView `json:"tracks"` +} + +// listPlaylistsResponse splits owned vs. public so the SPA can render +// "your playlists" and "browse public" without re-filtering. Both +// arrays are guaranteed non-nil for stable JSON output. +type listPlaylistsResponse struct { + Owned []playlistRowView `json:"owned"` + Public []playlistRowView `json:"public"` +} + +// handleListPlaylists implements GET /api/playlists. The service returns +// every playlist visible to the caller (own + others' public); we split +// the result here so the wire shape carries the distinction the UI cares +// about without a second DB query. +func (h *handlers) handleListPlaylists(w http.ResponseWriter, r *http.Request) { + caller, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session") + return + } + rows, err := h.playlists.List(r.Context(), caller.ID) + if err != nil { + h.logger.Error("api: list playlists failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "list failed") + return + } + resp := listPlaylistsResponse{Owned: []playlistRowView{}, Public: []playlistRowView{}} + for i := range rows { + v := playlistRowToView(&rows[i]) + if uuidEqual(rows[i].UserID, caller.ID) { + resp.Owned = append(resp.Owned, v) + } else { + resp.Public = append(resp.Public, v) + } + } + writeJSON(w, http.StatusOK, resp) +} + +type createPlaylistBody struct { + Name string `json:"name"` + Description string `json:"description"` + IsPublic bool `json:"is_public"` +} + +// handleCreatePlaylist implements POST /api/playlists. Empty Name is +// rejected by the service (ErrInvalidInput → 400 bad_request). +func (h *handlers) handleCreatePlaylist(w http.ResponseWriter, r *http.Request) { + caller, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session") + return + } + var body createPlaylistBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") + return + } + row, err := h.playlists.Create(r.Context(), caller.ID, body.Name, body.Description, body.IsPublic) + if err != nil { + h.writePlaylistErr(w, err, "create") + return + } + writeJSON(w, http.StatusOK, playlistRowToView(row)) +} + +// handleGetPlaylist implements GET /api/playlists/{id}. Visibility is +// owner-or-public; the service returns ErrForbidden when a non-owner +// asks for a private playlist. +func (h *handlers) handleGetPlaylist(w http.ResponseWriter, r *http.Request) { + caller, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session") + return + } + playlistID, ok := parseUUID(chi.URLParam(r, "id")) + if !ok { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id") + return + } + detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID) + if err != nil { + h.writePlaylistErr(w, err, "get") + return + } + writeJSON(w, http.StatusOK, playlistDetailToView(detail)) +} + +// updatePlaylistBody uses pointers so omitted fields stay untouched +// (PATCH semantics). The service rejects an explicitly-empty Name. +type updatePlaylistBody struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + IsPublic *bool `json:"is_public,omitempty"` +} + +// handleUpdatePlaylist implements PATCH /api/playlists/{id}. Owner only; +// non-owners get 403 not_authorized. +func (h *handlers) handleUpdatePlaylist(w http.ResponseWriter, r *http.Request) { + caller, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session") + return + } + playlistID, ok := parseUUID(chi.URLParam(r, "id")) + if !ok { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id") + return + } + var body updatePlaylistBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") + return + } + row, err := h.playlists.Update(r.Context(), caller.ID, playlistID, playlists.UpdateInput{ + Name: body.Name, + Description: body.Description, + IsPublic: body.IsPublic, + }) + if err != nil { + h.writePlaylistErr(w, err, "update") + return + } + writeJSON(w, http.StatusOK, playlistRowToView(row)) +} + +// handleDeletePlaylist implements DELETE /api/playlists/{id}. Owner only; +// returns 204 on success. +func (h *handlers) handleDeletePlaylist(w http.ResponseWriter, r *http.Request) { + caller, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session") + return + } + playlistID, ok := parseUUID(chi.URLParam(r, "id")) + if !ok { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id") + return + } + if err := h.playlists.Delete(r.Context(), caller.ID, playlistID); err != nil { + h.writePlaylistErr(w, err, "delete") + return + } + w.WriteHeader(http.StatusNoContent) +} + +type appendTracksBody struct { + TrackIDs []string `json:"track_ids"` +} + +// handleAppendTracks implements POST /api/playlists/{id}/tracks. Owner +// only. Returns the playlist detail (with the new track rows) so the +// SPA never has to re-GET after a mutation. +func (h *handlers) handleAppendTracks(w http.ResponseWriter, r *http.Request) { + caller, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session") + return + } + playlistID, ok := parseUUID(chi.URLParam(r, "id")) + if !ok { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id") + return + } + var body appendTracksBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") + return + } + trackIDs := make([]pgtype.UUID, 0, len(body.TrackIDs)) + for _, s := range body.TrackIDs { + u, ok := parseUUID(s) + if !ok { + writeErr(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("invalid track id %q", s)) + return + } + trackIDs = append(trackIDs, u) + } + if err := h.playlists.AppendTracks(r.Context(), caller.ID, playlistID, trackIDs); err != nil { + h.writePlaylistErr(w, err, "append tracks") + return + } + detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID) + if err != nil { + h.writePlaylistErr(w, err, "get-after-append") + return + } + writeJSON(w, http.StatusOK, playlistDetailToView(detail)) +} + +// handleRemovePlaylistTrack implements DELETE /api/playlists/{id}/tracks/{position}. +// Owner only. Returns the post-mutation detail like AppendTracks. +func (h *handlers) handleRemovePlaylistTrack(w http.ResponseWriter, r *http.Request) { + caller, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session") + return + } + playlistID, ok := parseUUID(chi.URLParam(r, "id")) + if !ok { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id") + return + } + posStr := chi.URLParam(r, "position") + pos64, err := strconv.ParseInt(posStr, 10, 32) + if err != nil || pos64 < 0 { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid position") + return + } + if err := h.playlists.RemoveTrack(r.Context(), caller.ID, playlistID, int32(pos64)); err != nil { + h.writePlaylistErr(w, err, "remove track") + return + } + detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID) + if err != nil { + h.writePlaylistErr(w, err, "get-after-remove") + return + } + writeJSON(w, http.StatusOK, playlistDetailToView(detail)) +} + +type reorderTracksBody struct { + OrderedPositions []int32 `json:"ordered_positions"` +} + +// handleReorderPlaylist implements PUT /api/playlists/{id}/tracks. Owner +// only. The body carries a permutation of every existing position; the +// service validates length and uniqueness. +func (h *handlers) handleReorderPlaylist(w http.ResponseWriter, r *http.Request) { + caller, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session") + return + } + playlistID, ok := parseUUID(chi.URLParam(r, "id")) + if !ok { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id") + return + } + var body reorderTracksBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") + return + } + if err := h.playlists.Reorder(r.Context(), caller.ID, playlistID, body.OrderedPositions); err != nil { + h.writePlaylistErr(w, err, "reorder") + return + } + detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID) + if err != nil { + h.writePlaylistErr(w, err, "get-after-reorder") + return + } + writeJSON(w, http.StatusOK, playlistDetailToView(detail)) +} + +// handleGetPlaylistCover serves the cached collage from disk. The +// playlist visibility check (owner-or-public) runs in the service Get +// call before we look at cover_path, so we don't leak the existence of +// private playlists to non-owners. +func (h *handlers) handleGetPlaylistCover(w http.ResponseWriter, r *http.Request) { + caller, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session") + return + } + playlistID, ok := parseUUID(chi.URLParam(r, "id")) + if !ok { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id") + return + } + detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID) + if err != nil { + h.writePlaylistErr(w, err, "get cover") + return + } + if detail.CoverPath == nil || *detail.CoverPath == "" { + writeErr(w, http.StatusNotFound, "not_found", "no cover") + return + } + full := filepath.Join(h.dataDir, *detail.CoverPath) + http.ServeFile(w, r, full) +} + +// writePlaylistErr maps the typed errors out of the playlists service to +// the project's standard error envelope. Unknown errors are logged at +// the handler boundary and surfaced as 500 server_error so callers don't +// see service-internal wrap text. +func (h *handlers) writePlaylistErr(w http.ResponseWriter, err error, op string) { + switch { + case errors.Is(err, playlists.ErrNotFound): + writeErr(w, http.StatusNotFound, "not_found", "playlist not found") + case errors.Is(err, playlists.ErrForbidden): + writeErr(w, http.StatusForbidden, "not_authorized", "you don't own this playlist") + case errors.Is(err, playlists.ErrInvalidInput): + writeErr(w, http.StatusBadRequest, "bad_request", err.Error()) + case errors.Is(err, playlists.ErrTrackNotFound): + writeErr(w, http.StatusBadRequest, "bad_request", "one of the supplied track ids does not exist") + default: + h.logger.Error("api: playlist op failed", "op", op, "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", op+" failed") + } +} + +// playlistRowToView projects a service-layer PlaylistRow onto the wire +// shape. CoverURL is derived from the presence of cover_path — the +// /cover endpoint serves the file from disk. +func playlistRowToView(r *playlists.PlaylistRow) playlistRowView { + v := playlistRowView{ + ID: uuidToString(r.ID), + UserID: uuidToString(r.UserID), + OwnerUsername: r.OwnerUsername, + Name: r.Name, + Description: r.Description, + IsPublic: r.IsPublic, + TrackCount: r.TrackCount, + DurationSec: r.DurationSec, + CreatedAt: formatTimestamp(r.CreatedAt), + UpdatedAt: formatTimestamp(r.UpdatedAt), + } + if r.CoverPath != nil && *r.CoverPath != "" { + v.CoverURL = "/api/playlists/" + v.ID + "/cover" + } + return v +} + +// playlistDetailToView extends playlistRowToView with the ordered track +// list. Empty playlists get a non-nil empty slice so the JSON encoder +// renders `"tracks": []` rather than `null`. +func playlistDetailToView(d *playlists.PlaylistDetail) playlistDetailView { + out := playlistDetailView{ + playlistRowView: playlistRowToView(&d.PlaylistRow), + Tracks: make([]playlistTrackView, 0, len(d.Tracks)), + } + for _, t := range d.Tracks { + v := playlistTrackView{ + Position: t.Position, + Title: t.Title, + ArtistName: t.ArtistName, + AlbumTitle: t.AlbumTitle, + DurationSec: t.DurationSec, + AddedAt: formatTimestamp(t.AddedAt), + } + if t.TrackID != nil { + s := uuidToString(*t.TrackID) + v.TrackID = &s + streamURL := "/api/tracks/" + s + "/stream" + v.StreamURL = &streamURL + } + if t.AlbumID != nil { + s := uuidToString(*t.AlbumID) + v.AlbumID = &s + } + if t.ArtistID != nil { + s := uuidToString(*t.ArtistID) + v.ArtistID = &s + } + out.Tracks = append(out.Tracks, v) + } + return out +} + +// uuidEqual compares two pgtype.UUID values for identity. Two +// invalid/zero UUIDs never compare equal — the caller's user id is +// always a real value, and we don't want a malformed playlist row to +// accidentally land in the "owned" bucket. +func uuidEqual(a, b pgtype.UUID) bool { + if !a.Valid || !b.Valid { + return false + } + return a.Bytes == b.Bytes +} diff --git a/internal/api/playlists_test.go b/internal/api/playlists_test.go new file mode 100644 index 00000000..241fdf8b --- /dev/null +++ b/internal/api/playlists_test.go @@ -0,0 +1,457 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// newPlaylistsRouter mounts the 9 playlist routes for an in-test chi +// instance. RequireUser is NOT applied — tests inject the user into +// context manually so we exercise the handler-owned auth defense +// directly. Mirrors the pattern from newAdminTracksRouter. +func newPlaylistsRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Get("/api/playlists", h.handleListPlaylists) + r.Post("/api/playlists", h.handleCreatePlaylist) + r.Get("/api/playlists/{id}", h.handleGetPlaylist) + r.Patch("/api/playlists/{id}", h.handleUpdatePlaylist) + r.Delete("/api/playlists/{id}", h.handleDeletePlaylist) + r.Post("/api/playlists/{id}/tracks", h.handleAppendTracks) + r.Delete("/api/playlists/{id}/tracks/{position}", h.handleRemovePlaylistTrack) + r.Put("/api/playlists/{id}/tracks", h.handleReorderPlaylist) + r.Get("/api/playlists/{id}/cover", h.handleGetPlaylistCover) + return r +} + +// doPlaylistsReq fires an HTTP request with the given user injected into +// the request context. Body may be nil. +func doPlaylistsReq(h *handlers, user dbq.User, method, path string, body []byte) *httptest.ResponseRecorder { + var rdr *bytes.Reader + if body != nil { + rdr = bytes.NewReader(body) + } else { + rdr = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, path, rdr) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user)) + w := httptest.NewRecorder() + newPlaylistsRouter(h).ServeHTTP(w, req) + return w +} + +// seedSimpleTrack inserts an artist + album + track triple under unique +// titles. Used by the append/reorder test that doesn't care about +// genre / file content. seedTrack from library_fixtures_test.go takes +// IDs the caller already has; we collapse the three calls here. +func seedSimpleTrack(t *testing.T, pool *pgxpool.Pool, title, artist string) dbq.Track { + t.Helper() + a := seedArtist(t, pool, artist) + al := seedAlbum(t, pool, a.ID, artist+" - "+title+" Album", 0) + return seedTrack(t, pool, al.ID, a.ID, title, 1, 1000) +} + +// TestPlaylists_NoSession401 verifies the handler-owned defensive 401 +// path on each route. Real traffic hits RequireUser upstream, but the +// handler also defends against routing misconfigurations. +func TestPlaylists_NoSession401(t *testing.T) { + h, _ := testHandlers(t) + r := newPlaylistsRouter(h) + + req := httptest.NewRequest(http.MethodGet, "/api/playlists", nil) + w := httptest.NewRecorder() + // Note: NO user in context. + r.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401; body = %s", w.Code, w.Body.String()) + } + var env errorBody + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if env.Error.Code != "unauthenticated" { + t.Errorf("error.code = %q, want unauthenticated", env.Error.Code) + } +} + +// TestPlaylists_CreateThenGet exercises the happy-path end-to-end: +// POST creates a row, the response carries the id, GET round-trips +// the same fields. +func TestPlaylists_CreateThenGet(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "pw", false) + + body := []byte(`{"name":"Saturday morning","description":"Mellow","is_public":false}`) + w := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists", body) + if w.Code != http.StatusOK { + t.Fatalf("create status = %d, body = %s", w.Code, w.Body.String()) + } + var created playlistRowView + if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil { + t.Fatalf("decode create: %v; body = %s", err, w.Body.String()) + } + if created.ID == "" { + t.Fatal("created.id empty") + } + if created.Name != "Saturday morning" { + t.Errorf("name = %q, want Saturday morning", created.Name) + } + if created.IsPublic { + t.Error("is_public = true, want false") + } + if created.OwnerUsername != user.Username { + t.Errorf("owner_username = %q, want %q", created.OwnerUsername, user.Username) + } + + w2 := doPlaylistsReq(h, user, http.MethodGet, "/api/playlists/"+created.ID, nil) + if w2.Code != http.StatusOK { + t.Fatalf("get status = %d, body = %s", w2.Code, w2.Body.String()) + } + var got playlistDetailView + if err := json.Unmarshal(w2.Body.Bytes(), &got); err != nil { + t.Fatalf("decode get: %v; body = %s", err, w2.Body.String()) + } + if got.Name != "Saturday morning" { + t.Errorf("get name = %q", got.Name) + } + if got.Tracks == nil { + t.Error("tracks is nil; want empty slice") + } + if len(got.Tracks) != 0 { + t.Errorf("tracks len = %d, want 0", len(got.Tracks)) + } +} + +// TestPlaylists_CreateEmptyName400 verifies the service's +// ErrInvalidInput surfaces as bad_request on the wire. +func TestPlaylists_CreateEmptyName400(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "pw", false) + + body := []byte(`{"name":"","description":""}`) + w := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists", body) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String()) + } + var env errorBody + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatalf("decode: %v", err) + } + if env.Error.Code != "bad_request" { + t.Errorf("error.code = %q, want bad_request", env.Error.Code) + } +} + +// TestPlaylists_PatchOwnerOnly verifies the ErrForbidden → 403 +// not_authorized path. Bob creates nothing; Alice creates a playlist; +// Bob tries to PATCH it. +func TestPlaylists_PatchOwnerOnly(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + alice := seedUser(t, pool, "alice", "pw", false) + bob := seedUser(t, pool, "bob", "pw", false) + + createBody := []byte(`{"name":"Mine","is_public":true}`) + cw := doPlaylistsReq(h, alice, http.MethodPost, "/api/playlists", createBody) + if cw.Code != http.StatusOK { + t.Fatalf("create: %d %s", cw.Code, cw.Body.String()) + } + var created playlistRowView + if err := json.Unmarshal(cw.Body.Bytes(), &created); err != nil { + t.Fatalf("decode: %v", err) + } + + patchBody := []byte(`{"name":"Hijacked"}`) + pw := doPlaylistsReq(h, bob, http.MethodPatch, "/api/playlists/"+created.ID, patchBody) + if pw.Code != http.StatusForbidden { + t.Fatalf("non-owner patch status = %d, want 403; body = %s", pw.Code, pw.Body.String()) + } + var env errorBody + if err := json.Unmarshal(pw.Body.Bytes(), &env); err != nil { + t.Fatalf("decode: %v", err) + } + if env.Error.Code != "not_authorized" { + t.Errorf("error.code = %q, want not_authorized", env.Error.Code) + } +} + +// TestPlaylists_GetNotFound verifies a well-formed UUID with no row +// surfaces as 404 not_found. +func TestPlaylists_GetNotFound(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "pw", false) + + missing := "00000000-0000-0000-0000-000000000099" + w := doPlaylistsReq(h, user, http.MethodGet, "/api/playlists/"+missing, nil) + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String()) + } +} + +// TestPlaylists_ListSplitsOwnedAndPublic verifies the response carries +// owned vs. public buckets correctly. Alice owns one private + one +// public; Bob sees only Alice's public. +func TestPlaylists_ListSplitsOwnedAndPublic(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + alice := seedUser(t, pool, "alice", "pw", false) + bob := seedUser(t, pool, "bob", "pw", false) + + for _, body := range [][]byte{ + []byte(`{"name":"AlicePrivate","is_public":false}`), + []byte(`{"name":"AlicePublic","is_public":true}`), + } { + w := doPlaylistsReq(h, alice, http.MethodPost, "/api/playlists", body) + if w.Code != http.StatusOK { + t.Fatalf("create: %d %s", w.Code, w.Body.String()) + } + } + + // Bob lists: should see exactly one public playlist owned by Alice. + bw := doPlaylistsReq(h, bob, http.MethodGet, "/api/playlists", nil) + if bw.Code != http.StatusOK { + t.Fatalf("list: %d %s", bw.Code, bw.Body.String()) + } + var bobResp listPlaylistsResponse + if err := json.Unmarshal(bw.Body.Bytes(), &bobResp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(bobResp.Owned) != 0 { + t.Errorf("bob.owned len = %d, want 0", len(bobResp.Owned)) + } + if len(bobResp.Public) != 1 || bobResp.Public[0].Name != "AlicePublic" { + t.Errorf("bob.public = %+v, want one [AlicePublic]", bobResp.Public) + } + + // Alice lists: sees both as owned, public bucket empty. + aw := doPlaylistsReq(h, alice, http.MethodGet, "/api/playlists", nil) + if aw.Code != http.StatusOK { + t.Fatalf("list alice: %d %s", aw.Code, aw.Body.String()) + } + var aliceResp listPlaylistsResponse + if err := json.Unmarshal(aw.Body.Bytes(), &aliceResp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(aliceResp.Owned) != 2 { + t.Errorf("alice.owned len = %d, want 2", len(aliceResp.Owned)) + } + if len(aliceResp.Public) != 0 { + t.Errorf("alice.public len = %d, want 0", len(aliceResp.Public)) + } +} + +// TestPlaylists_AppendThenReorder appends three tracks and then reverses +// their order via the reorder endpoint. The post-mutation detail body +// must show Track 3 before Track 1. +func TestPlaylists_AppendThenReorder(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "pw", false) + t1 := seedSimpleTrack(t, pool, "Track 1", "Artist") + t2 := seedSimpleTrack(t, pool, "Track 2", "Artist") + t3 := seedSimpleTrack(t, pool, "Track 3", "Artist") + + cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists", + []byte(`{"name":"R"}`)) + if cw.Code != http.StatusOK { + t.Fatalf("create: %d %s", cw.Code, cw.Body.String()) + } + var created playlistRowView + if err := json.Unmarshal(cw.Body.Bytes(), &created); err != nil { + t.Fatalf("decode: %v", err) + } + + appendPayload, _ := json.Marshal(map[string]any{"track_ids": []string{ + uuidToString(t1.ID), uuidToString(t2.ID), uuidToString(t3.ID), + }}) + aw := doPlaylistsReq(h, user, http.MethodPost, + "/api/playlists/"+created.ID+"/tracks", appendPayload) + if aw.Code != http.StatusOK { + t.Fatalf("append: %d %s", aw.Code, aw.Body.String()) + } + var afterAppend playlistDetailView + if err := json.Unmarshal(aw.Body.Bytes(), &afterAppend); err != nil { + t.Fatalf("decode append: %v", err) + } + if len(afterAppend.Tracks) != 3 { + t.Fatalf("after append, tracks len = %d, want 3", len(afterAppend.Tracks)) + } + + // Reverse: positions [2, 1, 0]. + rw := doPlaylistsReq(h, user, http.MethodPut, + "/api/playlists/"+created.ID+"/tracks", + []byte(`{"ordered_positions":[2,1,0]}`)) + if rw.Code != http.StatusOK { + t.Fatalf("reorder: %d %s", rw.Code, rw.Body.String()) + } + + gw := doPlaylistsReq(h, user, http.MethodGet, + "/api/playlists/"+created.ID, nil) + if gw.Code != http.StatusOK { + t.Fatalf("get-after-reorder: %d %s", gw.Code, gw.Body.String()) + } + body := gw.Body.String() + pos3 := strings.Index(body, `"title":"Track 3"`) + pos1 := strings.Index(body, `"title":"Track 1"`) + if pos3 == -1 || pos1 == -1 { + t.Fatalf("titles missing from body: %s", body) + } + if pos3 > pos1 { + t.Errorf("after reverse, Track 3 should appear before Track 1 in JSON; body = %s", body) + } +} + +// TestPlaylists_RemoveTrack appends two tracks, removes position 0, and +// verifies the surviving track sits at position 0 with the renumbered +// list length 1. +func TestPlaylists_RemoveTrack(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "pw", false) + t1 := seedSimpleTrack(t, pool, "First", "Artist") + t2 := seedSimpleTrack(t, pool, "Second", "Artist") + + cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists", + []byte(`{"name":"X"}`)) + if cw.Code != http.StatusOK { + t.Fatalf("create: %d %s", cw.Code, cw.Body.String()) + } + var created playlistRowView + _ = json.Unmarshal(cw.Body.Bytes(), &created) + + appendPayload, _ := json.Marshal(map[string]any{"track_ids": []string{ + uuidToString(t1.ID), uuidToString(t2.ID), + }}) + aw := doPlaylistsReq(h, user, http.MethodPost, + "/api/playlists/"+created.ID+"/tracks", appendPayload) + if aw.Code != http.StatusOK { + t.Fatalf("append: %d %s", aw.Code, aw.Body.String()) + } + + rw := doPlaylistsReq(h, user, http.MethodDelete, + "/api/playlists/"+created.ID+"/tracks/0", nil) + if rw.Code != http.StatusOK { + t.Fatalf("remove: %d %s", rw.Code, rw.Body.String()) + } + var afterRemove playlistDetailView + if err := json.Unmarshal(rw.Body.Bytes(), &afterRemove); err != nil { + t.Fatalf("decode: %v", err) + } + if len(afterRemove.Tracks) != 1 { + t.Fatalf("after remove, tracks len = %d, want 1", len(afterRemove.Tracks)) + } + if afterRemove.Tracks[0].Title != "Second" { + t.Errorf("survivor title = %q, want Second", afterRemove.Tracks[0].Title) + } + if afterRemove.Tracks[0].Position != 0 { + t.Errorf("survivor position = %d, want 0 (renumbered)", afterRemove.Tracks[0].Position) + } +} + +// TestPlaylists_DeleteOwnerOnly verifies non-owner DELETE is 403 and +// owner DELETE is 204. +func TestPlaylists_DeleteOwnerOnly(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + alice := seedUser(t, pool, "alice", "pw", false) + bob := seedUser(t, pool, "bob", "pw", false) + + cw := doPlaylistsReq(h, alice, http.MethodPost, "/api/playlists", + []byte(`{"name":"D","is_public":true}`)) + if cw.Code != http.StatusOK { + t.Fatalf("create: %d %s", cw.Code, cw.Body.String()) + } + var created playlistRowView + _ = json.Unmarshal(cw.Body.Bytes(), &created) + + bw := doPlaylistsReq(h, bob, http.MethodDelete, "/api/playlists/"+created.ID, nil) + if bw.Code != http.StatusForbidden { + t.Errorf("bob delete status = %d, want 403", bw.Code) + } + + aw := doPlaylistsReq(h, alice, http.MethodDelete, "/api/playlists/"+created.ID, nil) + if aw.Code != http.StatusNoContent { + t.Errorf("alice delete status = %d, want 204; body = %s", aw.Code, aw.Body.String()) + } + + // Subsequent GET must 404. + gw := doPlaylistsReq(h, alice, http.MethodGet, "/api/playlists/"+created.ID, nil) + if gw.Code != http.StatusNotFound { + t.Errorf("get-after-delete status = %d, want 404", gw.Code) + } +} + +// TestPlaylists_BadUUID400 verifies a malformed playlist id returns +// bad_request rather than reaching the service layer. +func TestPlaylists_BadUUID400(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "pw", false) + + w := doPlaylistsReq(h, user, http.MethodGet, "/api/playlists/not-a-uuid", nil) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +// TestPlaylists_AppendInvalidTrackUUID400 verifies a malformed track id +// in the append body returns bad_request before reaching the DB. +func TestPlaylists_AppendInvalidTrackUUID400(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "pw", false) + + cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists", + []byte(`{"name":"A"}`)) + if cw.Code != http.StatusOK { + t.Fatalf("create: %d %s", cw.Code, cw.Body.String()) + } + var created playlistRowView + _ = json.Unmarshal(cw.Body.Bytes(), &created) + + body := []byte(`{"track_ids":["not-a-uuid"]}`) + w := doPlaylistsReq(h, user, http.MethodPost, + "/api/playlists/"+created.ID+"/tracks", body) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400; body = %s", w.Code, w.Body.String()) + } +} + +// TestPlaylists_CoverNoneReturns404 verifies that a freshly-created +// (empty) playlist has no cached cover and the /cover endpoint returns +// 404 not_found rather than serving a missing file. +func TestPlaylists_CoverNoneReturns404(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "pw", false) + + cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists", + []byte(`{"name":"NoCover"}`)) + if cw.Code != http.StatusOK { + t.Fatalf("create: %d %s", cw.Code, cw.Body.String()) + } + var created playlistRowView + _ = json.Unmarshal(cw.Body.Bytes(), &created) + + w := doPlaylistsReq(h, user, http.MethodGet, + "/api/playlists/"+created.ID+"/cover", nil) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404; body = %s", w.Code, w.Body.String()) + } +} + diff --git a/internal/server/server.go b/internal/server/server.go index 17132173..2ffc0893 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -23,6 +23,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" + "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" "git.fabledsword.com/bvandeusen/minstrel/web" @@ -62,6 +63,11 @@ type Server struct { SubsonicCfg subsonic.Config EventsCfg config.EventsConfig RecommendationCfg config.RecommendationConfig + // DataDir is the on-disk root for cached artifacts (currently + // playlist cover collages under /playlist_covers/). Empty + // strings are tolerated by tests that don't exercise persisted-cover + // codepaths; production callers should pass a writable directory. + DataDir string } func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig) *Server { @@ -98,7 +104,8 @@ func (s *Server) Router() http.Handler { lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, lidarrClientFn, nil) lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn) tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}) - api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc) + playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir) + api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.DataDir) // /api/admin/scan is the only admin route owned by the server package // (it needs the Scanner). Register it as a single inline-middleware // route — using r.Route("/api/admin", ...) here would create a second From b9830cc9cc38b1f44c230c204b5ecdc355cb7bff Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 11:12:46 -0400 Subject: [PATCH 102/351] feat(web/api): playlists helper + types + query keys (M7 #352 slice 1) Wire shapes mirror the server's snake_case envelope. URL helpers use the existing apiFetch + ApiError surface so error codes (not_found, not_authorized, bad_request, server_error) propagate via the same copyForCode chain as everything else. --- web/src/lib/api/playlists.test.ts | 67 +++++++++++++++++++++++ web/src/lib/api/playlists.ts | 90 +++++++++++++++++++++++++++++++ web/src/lib/api/queries.ts | 2 + web/src/lib/api/types.ts | 31 +++++++++++ 4 files changed, 190 insertions(+) create mode 100644 web/src/lib/api/playlists.test.ts create mode 100644 web/src/lib/api/playlists.ts diff --git a/web/src/lib/api/playlists.test.ts b/web/src/lib/api/playlists.test.ts new file mode 100644 index 00000000..209f9ae8 --- /dev/null +++ b/web/src/lib/api/playlists.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { + listPlaylists, + getPlaylist, + createPlaylist, + reorderPlaylist, + removePlaylistTrack +} from './playlists'; + +function stubFetch(status: number, body: unknown, init: Partial = {}) { + const res = new Response( + body === null ? null : JSON.stringify(body), + { status, headers: { 'Content-Type': 'application/json' }, ...init } + ); + const spy = vi.fn().mockResolvedValue(res); + vi.stubGlobal('fetch', spy); + return spy; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('playlists API helper', () => { + test('listPlaylists GETs /api/playlists', async () => { + const spy = stubFetch(200, { owned: [], public: [] }); + const r = await listPlaylists(); + expect(r.owned).toEqual([]); + expect(r.public).toEqual([]); + const call = spy.mock.calls[0]; + expect(call[0]).toBe('/api/playlists'); + expect((call[1] as RequestInit).method).toBe('GET'); + }); + + test('createPlaylist POSTs JSON body', async () => { + const spy = stubFetch(200, { id: 'p1', name: 'Test' }); + const r = await createPlaylist({ name: 'Test' }); + expect(r.id).toBe('p1'); + const call = spy.mock.calls[0]; + expect((call[1] as RequestInit).method).toBe('POST'); + expect(JSON.parse((call[1] as RequestInit).body as string)).toEqual({ name: 'Test' }); + }); + + test('reorderPlaylist PUTs ordered_positions', async () => { + const spy = stubFetch(200, { id: 'p1', tracks: [] }); + await reorderPlaylist('p1', [2, 1, 0]); + const call = spy.mock.calls[0]; + expect(call[0]).toBe('/api/playlists/p1/tracks'); + expect((call[1] as RequestInit).method).toBe('PUT'); + expect(JSON.parse((call[1] as RequestInit).body as string)).toEqual({ + ordered_positions: [2, 1, 0] + }); + }); + + test('removePlaylistTrack DELETEs by position', async () => { + const spy = stubFetch(200, { id: 'p1', tracks: [] }); + await removePlaylistTrack('p1', 3); + const call = spy.mock.calls[0]; + expect(call[0]).toBe('/api/playlists/p1/tracks/3'); + expect((call[1] as RequestInit).method).toBe('DELETE'); + }); + + test('not_found surfaces as ApiError', async () => { + stubFetch(404, { error: 'not_found' }); + await expect(getPlaylist('missing')).rejects.toMatchObject({ code: 'not_found' }); + }); +}); diff --git a/web/src/lib/api/playlists.ts b/web/src/lib/api/playlists.ts new file mode 100644 index 00000000..9e0ba763 --- /dev/null +++ b/web/src/lib/api/playlists.ts @@ -0,0 +1,90 @@ +import { createQuery } from '@tanstack/svelte-query'; +import { apiFetch } from './client'; +import { qk } from './queries'; +import type { Playlist, PlaylistDetail } from './types'; + +export type ListPlaylistsResponse = { + owned: Playlist[]; + public: Playlist[]; +}; + +export async function listPlaylists(): Promise { + return (await apiFetch('/api/playlists', { method: 'GET' })) as ListPlaylistsResponse; +} + +export async function getPlaylist(id: string): Promise { + return (await apiFetch(`/api/playlists/${encodeURIComponent(id)}`, { + method: 'GET' + })) as PlaylistDetail; +} + +export type CreatePlaylistInput = { + name: string; + description?: string; + is_public?: boolean; +}; + +export async function createPlaylist(input: CreatePlaylistInput): Promise { + return (await apiFetch('/api/playlists', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input) + })) as Playlist; +} + +export type UpdatePlaylistInput = { + name?: string; + description?: string; + is_public?: boolean; +}; + +export async function updatePlaylist(id: string, input: UpdatePlaylistInput): Promise { + return (await apiFetch(`/api/playlists/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input) + })) as Playlist; +} + +export async function deletePlaylist(id: string): Promise { + await apiFetch(`/api/playlists/${encodeURIComponent(id)}`, { method: 'DELETE' }); +} + +export async function appendTracks(playlistID: string, trackIDs: string[]): Promise { + return (await apiFetch(`/api/playlists/${encodeURIComponent(playlistID)}/tracks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ track_ids: trackIDs }) + })) as PlaylistDetail; +} + +export async function removePlaylistTrack(playlistID: string, position: number): Promise { + return (await apiFetch( + `/api/playlists/${encodeURIComponent(playlistID)}/tracks/${position}`, + { method: 'DELETE' } + )) as PlaylistDetail; +} + +export async function reorderPlaylist(playlistID: string, orderedPositions: number[]): Promise { + return (await apiFetch(`/api/playlists/${encodeURIComponent(playlistID)}/tracks`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ordered_positions: orderedPositions }) + })) as PlaylistDetail; +} + +export function createPlaylistsQuery() { + return createQuery({ + queryKey: qk.playlists(), + queryFn: listPlaylists, + staleTime: 30_000 + }); +} + +export function createPlaylistQuery(id: string) { + return createQuery({ + queryKey: qk.playlist(id), + queryFn: () => getPlaylist(id), + staleTime: 30_000 + }); +} diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index b6991e5d..08d03597 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -38,6 +38,8 @@ export const qk = { home: () => ['home'] as const, albumsAlpha: () => ['albumsAlpha'] as const, artistTracks: (artistId: string) => ['artistTracks', artistId] as const, + playlists: () => ['playlists'] as const, + playlist: (id: string) => ['playlist', id] as const, }; export function createArtistsQuery(sort: ArtistSort) { diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index db76065b..2434bdf2 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -50,6 +50,37 @@ export type AlbumDetail = AlbumRef & { tracks: TrackRef[]; }; +export type Playlist = { + id: string; + user_id: string; + owner_username: string; + name: string; + description: string; + is_public: boolean; + cover_url: string; // empty string when no cover; UI renders glyph + track_count: number; + duration_sec: number; + created_at: string; + updated_at: string; +}; + +export type PlaylistTrack = { + position: number; + track_id: string | null; // null when upstream track was removed + album_id: string | null; + artist_id: string | null; + title: string; + artist_name: string; + album_title: string; + duration_sec: number; + stream_url: string | null; + added_at: string; +}; + +export type PlaylistDetail = Playlist & { + tracks: PlaylistTrack[]; +}; + export type SearchResponse = { artists: Page; albums: Page; From 0eb346e0c6b5b47139c4c5e1591b515012f18f42 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 11:14:30 -0400 Subject: [PATCH 103/351] feat(web): PlaylistCard component for M7 #352 slice 1 Square card with cover (or "No tracks yet" glyph fallback), name, track count, and owner attribution when the playlist isn't the current user's. Click navigates to /playlists/{id}. --- web/src/lib/components/PlaylistCard.svelte | 38 +++++++++++++++ web/src/lib/components/PlaylistCard.test.ts | 54 +++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 web/src/lib/components/PlaylistCard.svelte create mode 100644 web/src/lib/components/PlaylistCard.test.ts diff --git a/web/src/lib/components/PlaylistCard.svelte b/web/src/lib/components/PlaylistCard.svelte new file mode 100644 index 00000000..f3af7dd2 --- /dev/null +++ b/web/src/lib/components/PlaylistCard.svelte @@ -0,0 +1,38 @@ + + + diff --git a/web/src/lib/components/PlaylistCard.test.ts b/web/src/lib/components/PlaylistCard.test.ts new file mode 100644 index 00000000..95258924 --- /dev/null +++ b/web/src/lib/components/PlaylistCard.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import PlaylistCard from './PlaylistCard.svelte'; +import type { Playlist } from '$lib/api/types'; + +vi.mock('$lib/auth/store.svelte', () => ({ + user: { value: { id: 'u-self', username: 'me', is_admin: false } } +})); + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); + +const base: Playlist = { + id: 'p-1', + user_id: 'u-self', + owner_username: 'me', + name: 'Saturday morning', + description: '', + is_public: false, + cover_url: '', + track_count: 12, + duration_sec: 0, + created_at: '', + updated_at: '' +}; + +describe('PlaylistCard', () => { + test('renders own playlist without owner attribution', () => { + render(PlaylistCard, { props: { playlist: base } }); + expect(screen.getByText('Saturday morning')).toBeInTheDocument(); + expect(screen.getByText(/12\s+tracks/i)).toBeInTheDocument(); + expect(screen.queryByText(/by /)).not.toBeInTheDocument(); + }); + + test('renders cover image when cover_url is set', () => { + render(PlaylistCard, { props: { playlist: { ...base, cover_url: '/api/playlists/p-1/cover' } } }); + const img = screen.getByRole('img'); + expect(img.getAttribute('src')).toBe('/api/playlists/p-1/cover'); + }); + + test('renders glyph fallback when cover_url is empty', () => { + render(PlaylistCard, { props: { playlist: base } }); + expect(screen.getByText(/no tracks yet/i)).toBeInTheDocument(); + }); + + test("attributes other users' playlists to the owner", () => { + render(PlaylistCard, { props: { playlist: { ...base, user_id: 'u-bob', owner_username: 'bob' } } }); + expect(screen.getByText(/by bob/i)).toBeInTheDocument(); + }); + + test('singular "track" when count is 1', () => { + render(PlaylistCard, { props: { playlist: { ...base, track_count: 1 } } }); + expect(screen.getByText(/1\s+track\b/i)).toBeInTheDocument(); + }); +}); From 4067be04a6bbb455cd87fa1394955a1090b9d9e0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 11:16:35 -0400 Subject: [PATCH 104/351] feat(web): PlaylistTrackRow component for M7 #352 slice 1 Variant of TrackRow specialised for playlist detail. Drag handle visible to owner only; remove button (X) visible to owner only; greyed-out + strikethrough when track_id is null (upstream track removed from library). Reuses the existing LikeButton and TrackMenu when the track is still alive. --- .../lib/components/PlaylistTrackRow.svelte | 97 ++++++++++++++ .../lib/components/PlaylistTrackRow.test.ts | 124 ++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 web/src/lib/components/PlaylistTrackRow.svelte create mode 100644 web/src/lib/components/PlaylistTrackRow.test.ts diff --git a/web/src/lib/components/PlaylistTrackRow.svelte b/web/src/lib/components/PlaylistTrackRow.svelte new file mode 100644 index 00000000..8697b5fb --- /dev/null +++ b/web/src/lib/components/PlaylistTrackRow.svelte @@ -0,0 +1,97 @@ + + +
onDragStart?.(row.position)} + ondragover={(e) => { e.preventDefault(); onDragOver?.(e, row.position); }} + ondrop={(e) => { e.preventDefault(); onDrop?.(e, row.position); }} +> + {#if isOwner} + + + + {/if} + + + + {format(row.duration_sec)} + + {#if !isUnavailable && liveTrack} + + + {/if} + + {#if isOwner} + + {/if} +
diff --git a/web/src/lib/components/PlaylistTrackRow.test.ts b/web/src/lib/components/PlaylistTrackRow.test.ts new file mode 100644 index 00000000..59ff43b7 --- /dev/null +++ b/web/src/lib/components/PlaylistTrackRow.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { readable } from 'svelte/store'; +import type { PlaylistTrack } from '$lib/api/types'; + +// LikeButton + TrackMenu transitively pull in TanStack Query, the auth +// store, likes/quarantine APIs, the player store, and SvelteKit +// navigation. Mock all of them at module level so the component can be +// rendered in isolation. + +vi.mock('$lib/auth/store.svelte', () => ({ + user: { value: { id: 'u1', username: 'me', is_admin: false } } +})); + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => + readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, + isError: false + }), + likeEntity: vi.fn().mockResolvedValue(undefined), + unlikeEntity: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock('$lib/api/quarantine', () => ({ + flagTrack: vi.fn().mockResolvedValue({}), + unflagTrack: vi.fn().mockResolvedValue(undefined), + listMyQuarantine: vi.fn().mockResolvedValue([]), + createMyQuarantineQuery: () => + readable({ data: [], isPending: false, isError: false }) +})); + +vi.mock('$lib/api/admin/tracks', () => ({ + removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't-1' }) +})); + +vi.mock('$lib/player/store.svelte', () => ({ + playNext: vi.fn(), + enqueueTrack: vi.fn(), + playQueue: vi.fn(), + playRadio: vi.fn() +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) }; +}); + +import PlaylistTrackRow from './PlaylistTrackRow.svelte'; + +const live: PlaylistTrack = { + position: 2, + track_id: 't-1', + album_id: 'a-1', + artist_id: 'ar-1', + title: 'Roygbiv', + artist_name: 'Boards of Canada', + album_title: 'MHTRTC', + duration_sec: 137, + stream_url: '/api/tracks/t-1/stream', + added_at: '' +}; + +const removed: PlaylistTrack = { + ...live, + track_id: null, + album_id: null, + artist_id: null, + stream_url: null +}; + +afterEach(() => vi.clearAllMocks()); + +describe('PlaylistTrackRow', () => { + test('renders title, artist, mm:ss duration', () => { + render(PlaylistTrackRow, { + props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() } + }); + expect(screen.getByText('Roygbiv')).toBeInTheDocument(); + expect(screen.getByText('2:17')).toBeInTheDocument(); + }); + + test('shows drag handle and remove button for owner', () => { + render(PlaylistTrackRow, { + props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() } + }); + expect(screen.getByLabelText('Drag handle')).toBeInTheDocument(); + expect( + screen.getByLabelText(/Remove Roygbiv from playlist/i) + ).toBeInTheDocument(); + }); + + test('hides drag handle and remove button for non-owner', () => { + render(PlaylistTrackRow, { + props: { row: live, isOwner: false, onRemove: vi.fn(), onPlay: vi.fn() } + }); + expect(screen.queryByLabelText('Drag handle')).not.toBeInTheDocument(); + expect( + screen.queryByLabelText(/Remove Roygbiv from playlist/i) + ).not.toBeInTheDocument(); + }); + + test('greyed-out + strikethrough when track_id is null', () => { + render(PlaylistTrackRow, { + props: { row: removed, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() } + }); + const titleEl = screen.getByText('Roygbiv'); + expect(titleEl.className).toContain('line-through'); + }); + + test('clicking the title fires onPlay with the position', async () => { + const onPlay = vi.fn(); + render(PlaylistTrackRow, { + props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay } + }); + await fireEvent.click(screen.getByText('Roygbiv')); + expect(onPlay).toHaveBeenCalledWith(2); + }); +}); From 71dbaaede50ed0ad33d8d9c6690f00e0275b69d9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 11:20:14 -0400 Subject: [PATCH 105/351] feat(web): AddToPlaylistMenu submenu wired into TrackMenu (M7 #352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Add to playlist…" entry that #372 reserved as a disabled slot is now active. Submenu lists the operator's own playlists alphabetically; "New playlist…" toggles an inline create form that makes the playlist + appends the track in two API calls. TrackMenu's existing test asserts the entry is enabled and opens the submenu instead of the previous "is disabled with tooltip" check. --- .../lib/components/AddToPlaylistMenu.svelte | 124 ++++++++++++++++++ .../lib/components/AddToPlaylistMenu.test.ts | 107 +++++++++++++++ web/src/lib/components/TrackMenu.svelte | 14 +- web/src/lib/components/TrackMenu.test.ts | 18 ++- 4 files changed, 255 insertions(+), 8 deletions(-) create mode 100644 web/src/lib/components/AddToPlaylistMenu.svelte create mode 100644 web/src/lib/components/AddToPlaylistMenu.test.ts diff --git a/web/src/lib/components/AddToPlaylistMenu.svelte b/web/src/lib/components/AddToPlaylistMenu.svelte new file mode 100644 index 00000000..c979bffb --- /dev/null +++ b/web/src/lib/components/AddToPlaylistMenu.svelte @@ -0,0 +1,124 @@ + + + diff --git a/web/src/lib/components/AddToPlaylistMenu.test.ts b/web/src/lib/components/AddToPlaylistMenu.test.ts new file mode 100644 index 00000000..84c3d6d0 --- /dev/null +++ b/web/src/lib/components/AddToPlaylistMenu.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import { readable } from 'svelte/store'; +import type { TrackRef } from '$lib/api/types'; + +const playlistsData = vi.hoisted(() => ({ + owned: [ + { + id: 'p1', + user_id: 'u-self', + owner_username: 'me', + name: 'B-list', + description: '', + is_public: false, + cover_url: '', + track_count: 3, + duration_sec: 0, + created_at: '', + updated_at: '' + }, + { + id: 'p2', + user_id: 'u-self', + owner_username: 'me', + name: 'A-list', + description: '', + is_public: false, + cover_url: '', + track_count: 5, + duration_sec: 0, + created_at: '', + updated_at: '' + } + ], + public: [] as unknown[] +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) }) + }; +}); + +vi.mock('$lib/auth/store.svelte', () => ({ + user: { value: { id: 'u-self', username: 'me', is_admin: false } } +})); + +vi.mock('$lib/api/playlists', () => ({ + createPlaylistsQuery: () => + readable({ data: playlistsData, isPending: false, isError: false }), + appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }), + createPlaylist: vi.fn().mockResolvedValue({ + id: 'p3', + user_id: 'u-self', + owner_username: 'me', + name: 'New', + description: '', + is_public: false, + cover_url: '', + track_count: 0, + duration_sec: 0, + created_at: '', + updated_at: '' + }) +})); + +import AddToPlaylistMenu from './AddToPlaylistMenu.svelte'; + +const track: TrackRef = { + id: 't1', + title: 'Roygbiv', + album_id: 'a1', + album_title: 'MHTRTC', + artist_id: 'ar1', + artist_name: 'BoC', + duration_sec: 137, + stream_url: '/api/tracks/t1/stream' +}; + +describe('AddToPlaylistMenu', () => { + test('lists own playlists alphabetically followed by "New playlist…"', () => { + render(AddToPlaylistMenu, { props: { track, onClose: vi.fn() } }); + const items = screen.getAllByRole('menuitem'); + // First two should be the playlists in alpha order, then "New playlist…". + expect(items[0].textContent).toContain('A-list'); + expect(items[1].textContent).toContain('B-list'); + expect(items[2].textContent).toContain('New playlist'); + }); + + test('clicking a playlist appends and closes', async () => { + const onClose = vi.fn(); + const { appendTracks } = await import('$lib/api/playlists'); + render(AddToPlaylistMenu, { props: { track, onClose } }); + await fireEvent.click(screen.getByRole('menuitem', { name: /A-list/ })); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(appendTracks).toHaveBeenCalledWith('p2', ['t1']); + }); + + test('"New playlist…" toggles inline create form', async () => { + render(AddToPlaylistMenu, { props: { track, onClose: vi.fn() } }); + await fireEvent.click(screen.getByRole('menuitem', { name: /New playlist/ })); + expect(screen.getByPlaceholderText(/playlist name/i)).toBeInTheDocument(); + expect(screen.getByText(/Create & add/i)).toBeInTheDocument(); + }); +}); diff --git a/web/src/lib/components/TrackMenu.svelte b/web/src/lib/components/TrackMenu.svelte index 315e6e1e..791d502a 100644 --- a/web/src/lib/components/TrackMenu.svelte +++ b/web/src/lib/components/TrackMenu.svelte @@ -17,6 +17,7 @@ import { useQueryClient } from '@tanstack/svelte-query'; import FlagPopover from './FlagPopover.svelte'; import RemoveTrackPopover from './RemoveTrackPopover.svelte'; + import AddToPlaylistMenu from './AddToPlaylistMenu.svelte'; import TrackMenuItem from './TrackMenuItem.svelte'; import TrackMenuDivider from './TrackMenuDivider.svelte'; import { user } from '$lib/auth/store.svelte'; @@ -39,6 +40,7 @@ let menuOpen = $state(false); let flagOpen = $state(false); let removeOpen = $state(false); + let addOpen = $state(false); const queryClient = useQueryClient(); @@ -67,6 +69,7 @@ menuOpen = false; flagOpen = false; removeOpen = false; + addOpen = false; } function onPlayNext() { @@ -122,7 +125,7 @@ } function onKeydown(e: KeyboardEvent) { - if (!menuOpen && !flagOpen && !removeOpen) return; + if (!menuOpen && !flagOpen && !removeOpen && !addOpen) return; if (e.key === 'Escape') { e.preventDefault(); closeAll(); @@ -130,7 +133,7 @@ } - (menuOpen = false)} onkeydown={onKeydown} /> + { menuOpen = false; addOpen = false; }} onkeydown={onKeydown} />
diff --git a/web/src/lib/components/TrackMenu.test.ts b/web/src/lib/components/TrackMenu.test.ts index d7d80600..16eb7054 100644 --- a/web/src/lib/components/TrackMenu.test.ts +++ b/web/src/lib/components/TrackMenu.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import { readable } from 'svelte/store'; import type { TrackRef } from '$lib/api/types'; @@ -37,6 +37,13 @@ vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' }) })); +vi.mock('$lib/api/playlists', () => ({ + createPlaylistsQuery: () => + readable({ data: { owned: [], public: [] }, isPending: false, isError: false }), + appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }), + createPlaylist: vi.fn().mockResolvedValue({ id: 'p1', name: 'New' }) +})); + vi.mock('$lib/player/store.svelte', () => ({ playNext: vi.fn(), enqueueTrack: vi.fn() @@ -100,12 +107,15 @@ describe('TrackMenu', () => { expect(screen.getAllByRole('menuitem')).toHaveLength(8); }); - test('"Add to playlist…" rendered as aria-disabled with tooltip', async () => { + test('"Add to playlist…" is enabled and opens the AddToPlaylistMenu submenu', async () => { render(TrackMenu, { props: { track } }); await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); const item = screen.getByRole('menuitem', { name: /add to playlist/i }); - expect(item.getAttribute('aria-disabled')).toBe('true'); - expect(item.getAttribute('title')).toBe('Coming with playlists'); + expect(item.getAttribute('aria-disabled')).toBe('false'); + await fireEvent.click(item); + await waitFor(() => + expect(screen.getByRole('menu', { name: /add to playlist/i })).toBeInTheDocument() + ); }); test('"Play next" dispatches playNext(track)', async () => { From 80a6861ded327f4f4c3bd7a7802172fd40f0182c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 11:22:25 -0400 Subject: [PATCH 106/351] feat(web): /playlists index page (M7 #352 slice 1) Replaces the placeholder route. Two sections: "Your playlists" (owned) and "From other users" (public). Inline create form in the header with Enter-to-submit / Esc-to-cancel. Empty-state copy when the operator has nothing yet. Routes to /playlists/{id} on card click via PlaylistCard. --- web/src/routes/playlists/+page.svelte | 120 ++++++++++++++++++++- web/src/routes/playlists/playlists.test.ts | 75 +++++++++++++ 2 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 web/src/routes/playlists/playlists.test.ts diff --git a/web/src/routes/playlists/+page.svelte b/web/src/routes/playlists/+page.svelte index c31cbf02..559233ae 100644 --- a/web/src/routes/playlists/+page.svelte +++ b/web/src/routes/playlists/+page.svelte @@ -1,2 +1,118 @@ -

Playlists

-

Playlists land in a subsequent plan.

+ + +
+
+

Playlists

+ +
+ + {#if creating} +
+ + {#if createError} +

{createError}

+ {/if} +
+ + +
+
+ {/if} + + {#if playlistsQuery?.isPending} +

Loading playlists…

+ {:else if playlistsQuery?.isError} + playlistsQuery.refetch()} /> + {:else if playlistsQuery?.data} +
+

Your playlists

+ {#if playlistsQuery.data.owned.length === 0} +

No playlists yet. Click "New playlist" to start one.

+ {:else} +
+ {#each playlistsQuery.data.owned as p (p.id)} + + {/each} +
+ {/if} +
+ + {#if playlistsQuery.data.public.length > 0} +
+

From other users

+
+ {#each playlistsQuery.data.public as p (p.id)} + + {/each} +
+
+ {/if} + {/if} +
diff --git a/web/src/routes/playlists/playlists.test.ts b/web/src/routes/playlists/playlists.test.ts new file mode 100644 index 00000000..73efcb61 --- /dev/null +++ b/web/src/routes/playlists/playlists.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import { mockQuery } from '../../test-utils/query'; +import type { Playlist } from '$lib/api/types'; + +vi.mock('$lib/api/playlists', () => ({ + createPlaylistsQuery: vi.fn(), + createPlaylist: vi.fn().mockResolvedValue({ id: 'p-new', name: 'X' }) +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) }) + }; +}); + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); + +import PlaylistsPage from './+page.svelte'; +import { createPlaylistsQuery, createPlaylist } from '$lib/api/playlists'; + +const mockedCreatePlaylistsQuery = createPlaylistsQuery as ReturnType; +const mockedCreatePlaylist = createPlaylist as ReturnType; + +function p(over: Partial): Playlist { + return { + id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'A', + description: '', is_public: false, cover_url: '', track_count: 0, duration_sec: 0, + created_at: '', updated_at: '', ...over + }; +} + +describe('Playlists index page', () => { + test('renders own + public sections', () => { + mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({ + data: { + owned: [p({ id: 'p1', name: 'Mine' })], + public: [p({ id: 'p2', name: 'Theirs', user_id: 'u-other', owner_username: 'bob' })] + } + })); + render(PlaylistsPage); + expect(screen.getByText('Mine')).toBeInTheDocument(); + expect(screen.getByText('Theirs')).toBeInTheDocument(); + expect(screen.getByText(/your playlists/i)).toBeInTheDocument(); + expect(screen.getByText(/from other users/i)).toBeInTheDocument(); + }); + + test('empty-state copy when operator has no playlists', () => { + mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({ + data: { owned: [], public: [] } + })); + render(PlaylistsPage); + expect(screen.getByText(/no playlists yet/i)).toBeInTheDocument(); + }); + + test('hides "From other users" section when empty', () => { + mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({ + data: { owned: [p({ id: 'p1', name: 'Mine' })], public: [] } + })); + render(PlaylistsPage); + expect(screen.queryByText(/from other users/i)).not.toBeInTheDocument(); + }); + + test('Create button reveals inline form; submit creates and navigates', async () => { + mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({ data: { owned: [], public: [] } })); + render(PlaylistsPage); + await fireEvent.click(screen.getByRole('button', { name: /new playlist/i })); + const input = screen.getByPlaceholderText(/saturday morning/i); + await fireEvent.input(input, { target: { value: 'Test' } }); + await fireEvent.click(screen.getByRole('button', { name: /^create$/i })); + await waitFor(() => expect(mockedCreatePlaylist).toHaveBeenCalledWith({ name: 'Test' })); + }); +}); From b0a928c554fd6de97d90809465395644c99f018b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 11:34:05 -0400 Subject: [PATCH 107/351] feat(web): /playlists/{id} detail page with drag-reorder (M7 #352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header shows collage, name, description, public/private chip, track count, owner attribution (when not owner). Edit + delete buttons visible to the owner only. Track list uses PlaylistTrackRow with HTML5 drag-and-drop; drop fires PUT /tracks with the new ordered positions and TanStack Query invalidates the playlist + index cache. Toast surface is a placeholder browser alert in slice 1 — a real toast is a polish task whenever it lands. --- web/src/routes/playlists/[id]/+page.svelte | 243 ++++++++++++++++++ .../routes/playlists/[id]/playlist.test.ts | 93 +++++++ 2 files changed, 336 insertions(+) create mode 100644 web/src/routes/playlists/[id]/+page.svelte create mode 100644 web/src/routes/playlists/[id]/playlist.test.ts diff --git a/web/src/routes/playlists/[id]/+page.svelte b/web/src/routes/playlists/[id]/+page.svelte new file mode 100644 index 00000000..a2a0e627 --- /dev/null +++ b/web/src/routes/playlists/[id]/+page.svelte @@ -0,0 +1,243 @@ + + +
+ {#if playlistQuery?.isPending} +

Loading…

+ {:else if playlistQuery?.isError} + playlistQuery.refetch()} /> + {:else if playlistQuery?.data} + {@const pl = playlistQuery.data} + {#if !editing} +
+
+ {#if pl.cover_url} + + {/if} +
+
+

{pl.name}

+ {#if pl.description} +

{pl.description}

+ {/if} +

+ {pl.track_count} {pl.track_count === 1 ? 'track' : 'tracks'} + {#if pl.is_public}· public{:else}· private{/if} + {#if !isOwner}· by {pl.owner_username}{/if} +

+
+ {#if isOwner} + + + {/if} +
+ {:else} +
+ + + +
+ + +
+
+ {/if} + +
+ {#if pl.tracks.length === 0} +

+ {#if isOwner} + No tracks yet. Add some via the "Add to playlist…" entry on any track row. + {:else} + No tracks in this playlist. + {/if} +

+ {:else} + {#each pl.tracks as row (row.position)} + (dragFromPos = p)} + onDrop={(_, p) => onDrop(p)} + /> + {/each} + {/if} +
+ {/if} +
diff --git a/web/src/routes/playlists/[id]/playlist.test.ts b/web/src/routes/playlists/[id]/playlist.test.ts new file mode 100644 index 00000000..49023af3 --- /dev/null +++ b/web/src/routes/playlists/[id]/playlist.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import { mockQuery } from '../../../test-utils/query'; +import { writable } from 'svelte/store'; +import type { PlaylistDetail } from '$lib/api/types'; + +vi.mock('$app/stores', () => ({ + page: writable({ params: { id: 'p1' } }) +})); + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); + +vi.mock('$lib/api/playlists', () => ({ + createPlaylistQuery: vi.fn(), + updatePlaylist: vi.fn().mockResolvedValue(undefined), + deletePlaylist: vi.fn().mockResolvedValue(undefined), + removePlaylistTrack: vi.fn().mockResolvedValue(undefined), + reorderPlaylist: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) }) }; +}); + +vi.mock('$lib/auth/store.svelte', () => ({ + user: { value: { id: 'u-self', username: 'me', is_admin: false } } +})); + +vi.mock('$lib/player/store.svelte', () => ({ + playQueue: vi.fn(), + enqueueTracks: vi.fn(), + playNext: vi.fn(), + enqueueTrack: vi.fn() +})); + +// Cascade through PlaylistTrackRow's mocks (LikeButton + TrackMenu transitively). +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => ({ subscribe: () => () => {}, data: { track_ids: [], album_ids: [], artist_ids: [] } }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); +vi.mock('$lib/api/quarantine', () => ({ + createMyQuarantineQuery: () => ({ subscribe: () => () => {}, data: [] }), + flagTrack: vi.fn(), + unflagTrack: vi.fn() +})); +vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn() })); + +import PlaylistDetailPage from './+page.svelte'; +import { createPlaylistQuery, deletePlaylist } from '$lib/api/playlists'; + +const mockedQuery = createPlaylistQuery as ReturnType; + +const ownDetail: PlaylistDetail = { + id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'Mine', + description: '', is_public: false, cover_url: '', track_count: 2, duration_sec: 274, + created_at: '', updated_at: '', + tracks: [ + { position: 0, track_id: 't1', album_id: 'a1', artist_id: 'ar1', title: 'A', artist_name: 'X', album_title: 'Y', duration_sec: 137, stream_url: '/api/tracks/t1/stream', added_at: '' }, + { position: 1, track_id: 't2', album_id: 'a1', artist_id: 'ar1', title: 'B', artist_name: 'X', album_title: 'Y', duration_sec: 137, stream_url: '/api/tracks/t2/stream', added_at: '' } + ] +}; + +describe('Playlist detail page', () => { + test('renders header + tracks for owner', () => { + mockedQuery.mockReturnValue(mockQuery({ data: ownDetail })); + render(PlaylistDetailPage); + expect(screen.getByText('Mine')).toBeInTheDocument(); + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.getByLabelText(/edit playlist/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/delete playlist/i)).toBeInTheDocument(); + }); + + test('hides edit + delete for non-owner', () => { + mockedQuery.mockReturnValue(mockQuery({ + data: { ...ownDetail, user_id: 'u-other', owner_username: 'bob', is_public: true } + })); + render(PlaylistDetailPage); + expect(screen.queryByLabelText(/edit playlist/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/delete playlist/i)).not.toBeInTheDocument(); + }); + + test('Delete button confirms then deletes', async () => { + mockedQuery.mockReturnValue(mockQuery({ data: ownDetail })); + const confirmSpy = vi.spyOn(globalThis, 'confirm').mockReturnValue(true); + render(PlaylistDetailPage); + await fireEvent.click(screen.getByLabelText(/delete playlist/i)); + await waitFor(() => expect(deletePlaylist).toHaveBeenCalledWith('p1')); + confirmSpy.mockRestore(); + }); +}); From 6d1709caffff1ec99b773dcc6dec049997d7c3e0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 11:37:41 -0400 Subject: [PATCH 108/351] fix(config): wire DataDir from yaml/env through to playlists service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1 of M7 #352 added Server.DataDir but left it un-populated — the playlist cover-collage writer would have used "" as the relative path root in production, writing to the current working directory. - Add Storage.DataDir to Config (yaml: storage.data_dir, env: SMARTMUSIC_STORAGE_DATA_DIR), defaulting to "./data". - server.New takes the data dir as a parameter; main.go threads it. - main.go MkdirAll's the directory at startup so the playlist cover writer doesn't fail on first use with a missing parent. - config.example.yaml documents the new section. Existing internal/server/server_test.go constructs Server directly without server.New, so no test fixture changes needed for that file. --- cmd/minstrel/main.go | 12 +++++++++++- internal/config/config.go | 17 +++++++++++++++++ internal/server/server.go | 4 ++-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index e35670e8..bb324ff7 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -97,9 +97,19 @@ func run() error { lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, logger.With("component", "lidarr")) go lidarrReconciler.Run(ctx) + // Ensure DataDir exists before any service tries to write into it. + // Best-effort: log + continue on failure; the playlist cover writer + // will surface a clearer error at first use. + if cfg.Storage.DataDir != "" { + if err := os.MkdirAll(cfg.Storage.DataDir, 0o755); err != nil { + logger.Warn("data_dir create failed; cached artifacts may not persist", + "path", cfg.Storage.DataDir, "err", err) + } + } + srv := server.New(logger, pool, scanner, subsonic.Config{ AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword, - }, cfg.Events, cfg.Recommendation) + }, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir) httpServer := &http.Server{ Addr: cfg.Server.Address, Handler: srv.Router(), diff --git a/internal/config/config.go b/internal/config/config.go index 03a34856..f9ddb85f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,7 @@ import ( type Config struct { Server ServerConfig `yaml:"server"` + Storage StorageConfig `yaml:"storage"` Database DatabaseConfig `yaml:"database"` Log LogConfig `yaml:"log"` Auth AuthConfig `yaml:"auth"` @@ -24,6 +25,18 @@ type ServerConfig struct { Address string `yaml:"address"` } +// StorageConfig points at on-disk locations the server uses for cached +// runtime artifacts. Currently: +// - DataDir hosts /playlist_covers/{playlist_id}.jpg (M7 #352). +// More entries may grow here (e.g., transcode cache) without breaking +// existing layouts. +type StorageConfig struct { + // DataDir is the root for cached artifacts. Default: "./data" (relative + // to the working directory). Operators in production should set an + // absolute path persistent across restarts. + DataDir string `yaml:"data_dir"` +} + type DatabaseConfig struct { URL string `yaml:"url"` } @@ -82,6 +95,7 @@ type RecommendationConfig struct { func Default() Config { return Config{ Server: ServerConfig{Address: ":4533"}, + Storage: StorageConfig{DataDir: "./data"}, Database: DatabaseConfig{URL: ""}, Log: LogConfig{Level: "info", Format: "text"}, Events: EventsConfig{ @@ -124,6 +138,9 @@ func applyEnv(cfg *Config) { if v, ok := os.LookupEnv("SMARTMUSIC_SERVER_ADDRESS"); ok { cfg.Server.Address = v } + if v, ok := os.LookupEnv("SMARTMUSIC_STORAGE_DATA_DIR"); ok { + cfg.Storage.DataDir = v + } if v, ok := os.LookupEnv("SMARTMUSIC_DATABASE_URL"); ok { cfg.Database.URL = v } diff --git a/internal/server/server.go b/internal/server/server.go index 2ffc0893..8819920f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -70,8 +70,8 @@ type Server struct { DataDir string } -func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig) *Server { - return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg} +func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string) *Server { + return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg, DataDir: dataDir} } func (s *Server) Router() http.Handler { From d32e1505c5612509b6d3f02c2acef2f4c1f04a76 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 11:38:05 -0400 Subject: [PATCH 109/351] docs(config): document storage.data_dir in the example yaml Forgot to include this in the previous commit (6d1709c). The new config field's example block needs to be in the canonical yaml so operators copying it in get the doc. --- config.example.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/config.example.yaml b/config.example.yaml index 6cc062d9..8e1051d6 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -6,6 +6,14 @@ server: address: ":4533" +storage: + # On-disk root for cached runtime artifacts (currently playlist cover + # collages under /playlist_covers/). Default: "./data" relative + # to the working directory. Production deployments should set an absolute + # path on persistent storage. + # Env: SMARTMUSIC_STORAGE_DATA_DIR + data_dir: "./data" + database: # Postgres connection string, e.g. postgres://minstrel:minstrel@localhost:5432/minstrel?sslmode=disable url: "" From e0a4d13c45d6ca835efb80d5494930a4d24e6e50 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 11:58:10 -0400 Subject: [PATCH 110/351] fix(web): /playlists/{id} page uses $app/state, not legacy $app/stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit svelte-check failed with 8 type errors: $app/stores' page.params.id is typed `string | undefined`, and the page passed `id` raw to helpers that need `string`. Two changes: - Switch to `$app/state` (the project canonical for new pages — see routes/albums/[id]/+page.svelte). page.params.id is a direct property read, not a store subscription. Fall back to "" so id is always a string; SvelteKit won't actually render the page without a populated id, but the type-checker doesn't know that. - Drop the now-unused `writable` import in the test file and update the mock to expose the new state-shape (object with .params, not a writable store). The remaining a11y warnings (tabindex on role=menu/dialog, click handlers without keyboard events, draggable div without role, autofocus) are svelte-check warnings — they don't block the build. Some are pre-existing in FlagPopover; others are tech debt from slice 1's drag-and-drop and modal patterns. Address as a follow-up polish task. --- web/src/routes/playlists/[id]/+page.svelte | 8 ++++++-- web/src/routes/playlists/[id]/playlist.test.ts | 8 +++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/web/src/routes/playlists/[id]/+page.svelte b/web/src/routes/playlists/[id]/+page.svelte index a2a0e627..86b547a6 100644 --- a/web/src/routes/playlists/[id]/+page.svelte +++ b/web/src/routes/playlists/[id]/+page.svelte @@ -1,5 +1,5 @@ +``` + +This runs synchronously before paint, so the correct theme is applied on first render. SSR/prerender paths see no theme attribute server-side; the script applies it client-side before paint. + +### Settings UI + +`web/src/routes/settings/+page.svelte` gains an "Appearance" card at the top: + +``` +┌─ Appearance ─────────────────────┐ +│ Theme: │ +│ ( ) Dark (•) Light ( ) System │ +│ │ +│ System follows your device's │ +│ light/dark preference. │ +└──────────────────────────────────┘ +``` + +Implemented as three radio inputs (or a segmented control component if one already exists in the project — implementer to check). Selection writes through `setTheme()`. + +## Light palette spec + +Full token map with values: + +| Token | Dark (current) | Light (new) | Role | +|---|---|---|---| +| `--fs-obsidian` | `#0E1013` | `#F8F5EE` | page bg | +| `--fs-iron` | `#14171A` | `#ECE6D5` | card/surface bg | +| `--fs-slate` | `#1C1F23` | `#DCD3BD` | hover surface | +| `--fs-pewter` | `#2C313A` | `#B8AE94` | borders | +| `--fs-parchment` | `#E8E2D2` | `#14171A` | primary text | +| `--fs-vellum` | `#C9C2AE` | `#2C313A` | secondary text | +| `--fs-ash` | `#8A8474` | `#5C6068` | muted text | +| `--fs-moss` | `#5A7563` | *(unchanged)* | secondary action bg | +| `--fs-bronze` | `#8C6A3F` | *(unchanged)* | warning action bg | +| `--fs-oxblood` | `#7A2E2E` | *(unchanged)* | destructive action bg | +| `--fs-warning` | `#C99A4A` | *(unchanged)* | semantic marker | +| `--fs-error` | `#A04848` | *(unchanged)* | semantic marker | +| `--fs-info` | `#5A7A8A` | *(unchanged)* | semantic marker | +| `--fs-accent` | `#4A6B5C` | *(unchanged)* | brand accent | +| `--fs-on-action` | `#E8E2D2` | *(unchanged)* | action button label | + +Design rationale: light palette is "parchment / aged paper" — warm cream surfaces, dark text. Reads as an old book opened on a daylit desk; aligns with the FabledSword mythic register and preserves the bookish tone in light mode. Action and semantic colors are mid-tone earth/jewel hues that read on both light and dark backgrounds. + +## Files touched + +**Modify:** + +1. `web/src/lib/styles/tokens.json` — restructure `colors` into `dark`/`light`/`flat` sub-maps; add `on-action` to `flat`. +2. `web/scripts/tokens-to-css.js` — emit dual `:root` + `[data-theme="light"]` blocks for colors; preserve other token sections in `:root`. +3. `web/src/app.html` — add inline FOUC script in ``. +4. `web/src/routes/settings/+page.svelte` — add Appearance card with 3-way theme selector. +5. Action button label class audit — find every site using `text-fs-parchment` on a `bg-fs-moss` / `bg-fs-bronze` / `bg-fs-oxblood` button and replace with `text-fs-on-action`. Expected count: 5–10 sites. + +**Create:** + +6. `web/src/lib/stores/theme.svelte.ts` — theme preference + resolved-theme rune store with matchMedia listener and localStorage persistence. + +**Generated (do not hand-edit):** + +7. `web/src/lib/styles/tokens.generated.css` — regenerated by `npm run tokens` (or whatever script invokes `tokens-to-css.js`). + +## Edge cases + +1. **Action button text contrast** — addressed by `--fs-on-action` non-flipping token. Audit during implementation. +2. **Cover-art images** — external content, doesn't theme. Eyeball the missing-cover placeholder/glyph fallback in light mode. +3. **Focus rings** — grep for `ring-offset-fs-*`; if any uses a flipping token, switch to a non-flipping equivalent or `ring-offset-background`. +4. **Cross-tab sync** — out of scope. Theme change in tab A doesn't update tab B until reload. Documented limitation. +5. **SSR** — server doesn't know preference; inline head script applies it client-side before paint. +6. **Error pages** — SvelteKit `+error.svelte` and framework error fallback inherit `data-theme` attribute. No special handling. +7. **localStorage unavailable** — try/catch around localStorage read; falls back to default `system`. + +## Testing + +Per project memory rule (no in-task tests; CI verifies), implementation tasks do not run tests. Plan does include the following test files for CI to execute: + +- **Token generator unit test** (`web/scripts/tokens-to-css.test.js`) — feed sample tokens.json with `dark`/`light`/`flat`, assert emitted CSS contains `:root` block with dark + flat values, and `[data-theme="light"]` block with light values only. +- **Theme store test** (`web/src/lib/stores/theme.svelte.test.ts`) — mock `matchMedia` and `localStorage`. Verify (a) default is `system` resolving via matchMedia, (b) `setTheme('light')` persists + updates resolved, (c) matchMedia `change` event updates resolvedTheme when in `system` mode. +- **Settings Appearance card test** (`web/src/routes/settings/Appearance.test.ts`) — render, click each segment, assert localStorage write + `data-theme` attribute on ``. + +**Manual verification checklist** (pre-merge eyeball pass): home, library, search, playlists list, playlist detail, queue, history, settings, admin (users / library / quarantine / lidarr) — all in both modes. + +## Implementation order (informs plan) + +1. Restructure `tokens.json` (no behavior change yet — generator still emits one block). +2. Update generator to emit dual blocks. +3. Regenerate `tokens.generated.css`. Verify no visual regression in dark mode. +4. Add `--fs-on-action` flat token. Audit + swap action button label classes. +5. Create `theme.svelte.ts` store. +6. Add FOUC script to `app.html`. +7. Add Appearance card to Settings page, wire to store. +8. Manual eyeball pass in both modes; fix any contrast/border surprises. From 1d6a71a825d4b2bcb4a4866a3d6370798457ca3c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 13:34:40 -0400 Subject: [PATCH 113/351] docs(m7-362): correct dark hex values + Tailwind alias Spec table's "Dark (current)" column now matches the actual tokens.json values. Added Tailwind text-action-fg alias as the mechanism for the non-flipping --fs-on-action token. Updated audit notes to use the real Tailwind class (text-text-primary) rather than the raw token (text-fs-parchment). --- .../2026-05-03-m7-362-theme-toggle-design.md | 84 +++++++++++-------- 1 file changed, 48 insertions(+), 36 deletions(-) diff --git a/docs/superpowers/specs/2026-05-03-m7-362-theme-toggle-design.md b/docs/superpowers/specs/2026-05-03-m7-362-theme-toggle-design.md index b92f00f2..2d312af6 100644 --- a/docs/superpowers/specs/2026-05-03-m7-362-theme-toggle-design.md +++ b/docs/superpowers/specs/2026-05-03-m7-362-theme-toggle-design.md @@ -61,19 +61,19 @@ Current shape (flat): } ``` -New shape: +New shape (dark values are the current `tokens.json` values, preserved): ```json { "colors": { "dark": { - "obsidian": "#0E1013", - "iron": "#14171A", - "slate": "#1C1F23", - "pewter": "#2C313A", - "parchment": "#E8E2D2", - "vellum": "#C9C2AE", - "ash": "#8A8474" + "obsidian": "#14171A", + "iron": "#1E2228", + "slate": "#2C313A", + "pewter": "#3F4651", + "parchment": "#E8E4D8", + "vellum": "#C2BFB4", + "ash": "#9C9A92" }, "light": { "obsidian": "#F8F5EE", @@ -85,14 +85,14 @@ New shape: "ash": "#5C6068" }, "flat": { - "moss": "#5A7563", - "bronze": "#8C6A3F", - "oxblood": "#7A2E2E", - "warning": "#C99A4A", - "error": "#A04848", - "info": "#5A7A8A", + "moss": "#4A5D3F", + "bronze": "#8B7355", + "oxblood": "#6B2118", + "warning": "#8B6F1E", + "error": "#C04A1F", + "info": "#3D5A6E", "accent": "#4A6B5C", - "on-action": "#E8E2D2" + "on-action": "#E8E4D8" } } } @@ -110,9 +110,20 @@ New shape: ### `--fs-on-action` token -A new flat token, value `#E8E2D2` (frozen at the dark-mode parchment value). Used as the label color on action buttons whose backgrounds (`--fs-moss`, `--fs-bronze`, `--fs-oxblood`) do not flip. Without this, action button text would invert in light mode and lose contrast against the mid-tone earth backgrounds. +A new flat token, value `#E8E4D8` (frozen at the dark-mode parchment value). Used as the label color on action buttons whose backgrounds (`--fs-moss`, `--fs-bronze`, `--fs-oxblood`) do not flip. Without this, action button text would invert in light mode and lose contrast against the mid-tone earth backgrounds. -Tailwind utility: `text-fs-on-action`. +Tailwind exposes it as `text-action-fg` via a new entry in `tailwind.config.js`: + +```js +action: { + primary: 'var(--fs-moss)', + secondary: 'var(--fs-bronze)', + destructive: 'var(--fs-oxblood)', + fg: 'var(--fs-on-action)' // new +} +``` + +The audit step replaces `text-text-primary` with `text-action-fg` on every site where it pairs with `bg-action-primary` / `bg-action-secondary` / `bg-action-destructive`. ### Theme store @@ -176,21 +187,21 @@ Full token map with values: | Token | Dark (current) | Light (new) | Role | |---|---|---|---| -| `--fs-obsidian` | `#0E1013` | `#F8F5EE` | page bg | -| `--fs-iron` | `#14171A` | `#ECE6D5` | card/surface bg | -| `--fs-slate` | `#1C1F23` | `#DCD3BD` | hover surface | -| `--fs-pewter` | `#2C313A` | `#B8AE94` | borders | -| `--fs-parchment` | `#E8E2D2` | `#14171A` | primary text | -| `--fs-vellum` | `#C9C2AE` | `#2C313A` | secondary text | -| `--fs-ash` | `#8A8474` | `#5C6068` | muted text | -| `--fs-moss` | `#5A7563` | *(unchanged)* | secondary action bg | -| `--fs-bronze` | `#8C6A3F` | *(unchanged)* | warning action bg | -| `--fs-oxblood` | `#7A2E2E` | *(unchanged)* | destructive action bg | -| `--fs-warning` | `#C99A4A` | *(unchanged)* | semantic marker | -| `--fs-error` | `#A04848` | *(unchanged)* | semantic marker | -| `--fs-info` | `#5A7A8A` | *(unchanged)* | semantic marker | +| `--fs-obsidian` | `#14171A` | `#F8F5EE` | page bg | +| `--fs-iron` | `#1E2228` | `#ECE6D5` | card/surface bg | +| `--fs-slate` | `#2C313A` | `#DCD3BD` | hover surface | +| `--fs-pewter` | `#3F4651` | `#B8AE94` | borders | +| `--fs-parchment` | `#E8E4D8` | `#14171A` | primary text | +| `--fs-vellum` | `#C2BFB4` | `#2C313A` | secondary text | +| `--fs-ash` | `#9C9A92` | `#5C6068` | muted text | +| `--fs-moss` | `#4A5D3F` | *(unchanged)* | secondary action bg | +| `--fs-bronze` | `#8B7355` | *(unchanged)* | warning action bg | +| `--fs-oxblood` | `#6B2118` | *(unchanged)* | destructive action bg | +| `--fs-warning` | `#8B6F1E` | *(unchanged)* | semantic marker | +| `--fs-error` | `#C04A1F` | *(unchanged)* | semantic marker | +| `--fs-info` | `#3D5A6E` | *(unchanged)* | semantic marker | | `--fs-accent` | `#4A6B5C` | *(unchanged)* | brand accent | -| `--fs-on-action` | `#E8E2D2` | *(unchanged)* | action button label | +| `--fs-on-action` | `#E8E4D8` *(new)* | *(unchanged)* | action button label | Design rationale: light palette is "parchment / aged paper" — warm cream surfaces, dark text. Reads as an old book opened on a daylit desk; aligns with the FabledSword mythic register and preserves the bookish tone in light mode. Action and semantic colors are mid-tone earth/jewel hues that read on both light and dark backgrounds. @@ -200,17 +211,18 @@ Design rationale: light palette is "parchment / aged paper" — warm cream surfa 1. `web/src/lib/styles/tokens.json` — restructure `colors` into `dark`/`light`/`flat` sub-maps; add `on-action` to `flat`. 2. `web/scripts/tokens-to-css.js` — emit dual `:root` + `[data-theme="light"]` blocks for colors; preserve other token sections in `:root`. -3. `web/src/app.html` — add inline FOUC script in ``. -4. `web/src/routes/settings/+page.svelte` — add Appearance card with 3-way theme selector. -5. Action button label class audit — find every site using `text-fs-parchment` on a `bg-fs-moss` / `bg-fs-bronze` / `bg-fs-oxblood` button and replace with `text-fs-on-action`. Expected count: 5–10 sites. +3. `web/tailwind.config.js` — add `colors.action.fg = 'var(--fs-on-action)'` entry. +4. `web/src/app.html` — add inline FOUC script in ``; remove the static `class="dark"` on `` (theme is now driven by `data-theme`). +5. `web/src/routes/settings/+page.svelte` — add Appearance card with 3-way theme selector. +6. Action button label class audit — find every site using `text-text-primary` paired with `bg-action-primary` / `bg-action-secondary` / `bg-action-destructive` and replace `text-text-primary` with `text-action-fg`. Verified count via grep: ~25 sites. **Create:** -6. `web/src/lib/stores/theme.svelte.ts` — theme preference + resolved-theme rune store with matchMedia listener and localStorage persistence. +7. `web/src/lib/stores/theme.svelte.ts` — theme preference + resolved-theme rune store with matchMedia listener and localStorage persistence. **Generated (do not hand-edit):** -7. `web/src/lib/styles/tokens.generated.css` — regenerated by `npm run tokens` (or whatever script invokes `tokens-to-css.js`). +8. `web/src/lib/styles/tokens.generated.css` — regenerated by `npm run tokens`. ## Edge cases From a9fc0c6fe8732a9e79ec8273a5a574cbdffee14e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 13:37:33 -0400 Subject: [PATCH 114/351] docs(m7-362): theme toggle implementation plan (9 tasks) Tokens schema split + dual-block generator, Tailwind text-action-fg alias, action-button audit, theme rune store, FOUC script, Settings Appearance card. Plus generator/store/UI tests for CI. No in-task test runs per memory rule. --- .../plans/2026-05-03-m7-362-theme-toggle.md | 860 ++++++++++++++++++ 1 file changed, 860 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-03-m7-362-theme-toggle.md diff --git a/docs/superpowers/plans/2026-05-03-m7-362-theme-toggle.md b/docs/superpowers/plans/2026-05-03-m7-362-theme-toggle.md new file mode 100644 index 00000000..10623586 --- /dev/null +++ b/docs/superpowers/plans/2026-05-03-m7-362-theme-toggle.md @@ -0,0 +1,860 @@ +# M7 #362 — Web SPA Theme Toggle Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a Dark / Light / System theme toggle for the Minstrel web SPA, persisted to localStorage and applied before first paint. + +**Architecture:** CSS custom-property indirection via existing `--fs-*` tokens. `tokens.json` grows from a flat `colors` map to `colors.dark` + `colors.light` + `colors.flat`. Generator emits `:root { ... }` (dark + flat) and `[data-theme="light"] { ... }` (light overrides only). Theme is applied by setting `data-theme` on ``; an inline `` script does this before paint to avoid FOUC. + +**Tech Stack:** SvelteKit 2 / Svelte 5 (runes mode), Tailwind CSS, vanilla `matchMedia` + `localStorage`. No new dependencies. + +**Spec:** `docs/superpowers/specs/2026-05-03-m7-362-theme-toggle-design.md` (commit `1d6a71a` on `dev`). + +**Test policy:** No in-task test runs. Tests are written as files for CI to execute (vitest under `web/src/**/*.test.ts` and `web/scripts/*.test.js`); the implementer never invokes `npm test`, `vitest`, `npm run build`, or `npm run check` during this work. Codegen scripts (`npm run tokens`) ARE allowed. + +--- + +## File Structure + +**Modified:** +- `web/src/lib/styles/tokens.json` — schema change: `colors` → `colors.dark` + `colors.light` + `colors.flat`; add `on-action` flat token. +- `web/scripts/tokens-to-css.js` — emit `:root` (dark + flat) and `[data-theme="light"]` blocks. +- `web/src/lib/styles/tokens.generated.css` — regenerated artifact (do not hand-edit). +- `web/tailwind.config.js` — add `colors.action.fg = 'var(--fs-on-action)'`. +- `web/src/app.html` — inline FOUC script in ``; remove static `class="dark"` on ``. +- `web/src/routes/settings/+page.svelte` — add Appearance card. +- ~25 component/route files — replace `text-text-primary` with `text-action-fg` on action-button labels. + +**Created:** +- `web/src/lib/stores/theme.svelte.ts` — theme preference + resolved-theme state with matchMedia listener. +- `web/src/lib/stores/theme.svelte.test.ts` — store unit test (CI). +- `web/scripts/tokens-to-css.test.js` — generator unit test (CI). +- `web/src/routes/settings/Appearance.test.ts` — Appearance card render test (CI). + +--- + +## Task 1: Restructure tokens.json + dual-block generator + +**Files:** +- Modify: `web/src/lib/styles/tokens.json` +- Modify: `web/scripts/tokens-to-css.js` +- Modify: `web/src/lib/styles/tokens.generated.css` (regenerated) + +- [ ] **Step 1: Restructure `tokens.json` colors map** + +Replace the entire `colors` object at the top of `web/src/lib/styles/tokens.json`. The other top-level sections (`radii`, `fonts`, `fontStacks`) stay untouched. + +```json +{ + "colors": { + "dark": { + "obsidian": "#14171A", + "iron": "#1E2228", + "slate": "#2C313A", + "pewter": "#3F4651", + "parchment": "#E8E4D8", + "vellum": "#C2BFB4", + "ash": "#9C9A92" + }, + "light": { + "obsidian": "#F8F5EE", + "iron": "#ECE6D5", + "slate": "#DCD3BD", + "pewter": "#B8AE94", + "parchment": "#14171A", + "vellum": "#2C313A", + "ash": "#5C6068" + }, + "flat": { + "moss": "#4A5D3F", + "bronze": "#8B7355", + "oxblood": "#6B2118", + "warning": "#8B6F1E", + "error": "#C04A1F", + "info": "#3D5A6E", + "accent": "#4A6B5C", + "on-action": "#E8E4D8" + } + }, + "radii": { "sm": "4px", "md": "8px", "lg": "12px", "xl": "16px" }, + "fonts": { "display": "Fraunces", "body": "Inter", "mono": "JetBrains Mono" }, + "fontStacks": { + "display": "'Fraunces', Georgia, serif", + "body": "'Inter', system-ui, sans-serif", + "mono": "'JetBrains Mono', ui-monospace, monospace" + } +} +``` + +- [ ] **Step 2: Rewrite the generator to emit dual blocks** + +Replace the entire body of `web/scripts/tokens-to-css.js` with: + +```js +#!/usr/bin/env node +// Reads ../src/lib/styles/tokens.json and emits tokens.generated.css. +// Single source of truth for FabledSword tokens shared with the Flutter +// client. Keep output deterministic — Tailwind reads tokens.json directly, +// the .css emission is for raw CSS consumers and theme switching. +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const tokensPath = resolve(here, '../src/lib/styles/tokens.json'); +const outPath = resolve(here, '../src/lib/styles/tokens.generated.css'); + +export function emit(tokens) { + const lines = ['/* GENERATED — do not edit. Source: tokens.json */', ':root {']; + for (const [name, value] of Object.entries(tokens.colors.dark)) { + lines.push(` --fs-${name}: ${value};`); + } + for (const [name, value] of Object.entries(tokens.colors.flat)) { + lines.push(` --fs-${name}: ${value};`); + } + for (const [name, value] of Object.entries(tokens.radii)) { + lines.push(` --fs-radius-${name}: ${value};`); + } + lines.push(` --fs-font-display: ${tokens.fontStacks.display};`); + lines.push(` --fs-font-body: ${tokens.fontStacks.body};`); + lines.push(` --fs-font-mono: ${tokens.fontStacks.mono};`); + lines.push('}'); + + lines.push('[data-theme="light"] {'); + for (const [name, value] of Object.entries(tokens.colors.light)) { + lines.push(` --fs-${name}: ${value};`); + } + lines.push('}'); + + lines.push('[data-fs-app="minstrel"] {', ` --fs-accent: ${tokens.colors.flat.accent};`, '}', ''); + return lines.join('\n'); +} + +// Run as script: read tokens.json + write generated CSS +if (import.meta.url === `file://${process.argv[1]}`) { + const tokens = JSON.parse(readFileSync(tokensPath, 'utf8')); + writeFileSync(outPath, emit(tokens)); + console.log(`wrote ${outPath}`); +} +``` + +Note the new `export function emit(tokens)` so the unit test (Task 7) can import it directly without spawning a subprocess. The CLI behavior at the bottom only fires when invoked as a script. + +- [ ] **Step 3: Regenerate `tokens.generated.css`** + +```bash +cd web && npm run tokens +``` + +Expected output: `wrote /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web/src/lib/styles/tokens.generated.css` + +The new file content should look like: + +```css +/* GENERATED — do not edit. Source: tokens.json */ +:root { + --fs-obsidian: #14171A; + --fs-iron: #1E2228; + --fs-slate: #2C313A; + --fs-pewter: #3F4651; + --fs-parchment: #E8E4D8; + --fs-vellum: #C2BFB4; + --fs-ash: #9C9A92; + --fs-moss: #4A5D3F; + --fs-bronze: #8B7355; + --fs-oxblood: #6B2118; + --fs-warning: #8B6F1E; + --fs-error: #C04A1F; + --fs-info: #3D5A6E; + --fs-accent: #4A6B5C; + --fs-on-action: #E8E4D8; + --fs-radius-sm: 4px; + --fs-radius-md: 8px; + --fs-radius-lg: 12px; + --fs-radius-xl: 16px; + --fs-font-display: 'Fraunces', Georgia, serif; + --fs-font-body: 'Inter', system-ui, sans-serif; + --fs-font-mono: 'JetBrains Mono', ui-monospace, monospace; +} +[data-theme="light"] { + --fs-obsidian: #F8F5EE; + --fs-iron: #ECE6D5; + --fs-slate: #DCD3BD; + --fs-pewter: #B8AE94; + --fs-parchment: #14171A; + --fs-vellum: #2C313A; + --fs-ash: #5C6068; +} +[data-fs-app="minstrel"] { + --fs-accent: #4A6B5C; +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add web/src/lib/styles/tokens.json web/scripts/tokens-to-css.js web/src/lib/styles/tokens.generated.css +git commit -m "feat(web/m7-362): tokens.json dark/light/flat split + dual-block CSS generator" +``` + +--- + +## Task 2: Add `text-action-fg` Tailwind alias + +**Files:** +- Modify: `web/tailwind.config.js` + +- [ ] **Step 1: Add `fg` to the action color group** + +Edit `web/tailwind.config.js`. In the `action` block under `theme.extend.colors`, add the `fg` entry: + +```js +action: { + primary: 'var(--fs-moss)', + secondary: 'var(--fs-bronze)', + destructive: 'var(--fs-oxblood)', + fg: 'var(--fs-on-action)' +}, +``` + +The full file after edit: + +```js +export default { + content: ['./src/**/*.{html,js,ts,svelte}'], + darkMode: 'class', + theme: { + extend: { + colors: { + background: 'var(--fs-obsidian)', + surface: { + DEFAULT: 'var(--fs-iron)', + hover: 'var(--fs-slate)' + }, + border: { + DEFAULT: 'var(--fs-pewter)' + }, + text: { + primary: 'var(--fs-parchment)', + secondary: 'var(--fs-vellum)', + muted: 'var(--fs-ash)' + }, + action: { + primary: 'var(--fs-moss)', + secondary: 'var(--fs-bronze)', + destructive: 'var(--fs-oxblood)', + fg: 'var(--fs-on-action)' + }, + accent: { + DEFAULT: 'var(--fs-accent)', + tint: 'color-mix(in srgb, var(--fs-accent) 12%, transparent)' + }, + warning: 'var(--fs-warning)', + error: 'var(--fs-error)', + info: 'var(--fs-info)' + }, + borderRadius: { + sm: 'var(--fs-radius-sm)', + md: 'var(--fs-radius-md)', + lg: 'var(--fs-radius-lg)', + xl: 'var(--fs-radius-xl)' + }, + fontFamily: { + display: ['var(--fs-font-display)'], + sans: ['var(--fs-font-body)'], + mono: ['var(--fs-font-mono)'] + } + } + }, + plugins: [] +}; +``` + +- [ ] **Step 2: Commit** + +```bash +git add web/tailwind.config.js +git commit -m "feat(web/m7-362): add Tailwind text-action-fg alias for non-flipping label color" +``` + +--- + +## Task 3: Replace action-button label class across the codebase + +**Files:** +- Modify: every site where a `bg-action-primary` / `bg-action-secondary` / `bg-action-destructive` element also carries `text-text-primary` + +This is a mechanical audit + replace. The grep performed during planning found ~25 sites; the implementer should re-grep to catch any new sites since. + +- [ ] **Step 1: Run the audit grep** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web +grep -rn "bg-action-\(primary\|secondary\|destructive\)" src/ | grep "text-text-primary" +``` + +Expected output: a list of file:line entries, each containing both classes on the same element. Confirmed at planning time: + +``` +src/lib/components/AddToPlaylistMenu.svelte:113 +src/lib/components/DiscoverResultCard.svelte:77 +src/lib/components/FlagPopover.svelte:91 +src/routes/admin/+page.svelte:239 +src/routes/admin/+page.svelte:302 +src/routes/admin/+page.svelte:312 +src/routes/admin/integrations/+page.svelte:276 +src/routes/admin/integrations/+page.svelte:293 +src/routes/admin/integrations/+page.svelte:360 +src/routes/admin/integrations/+page.svelte:368 +src/routes/admin/quarantine/+page.svelte:293 +src/routes/admin/quarantine/+page.svelte:304 +src/routes/admin/quarantine/+page.svelte:315 +src/routes/admin/quarantine/+page.svelte:364 +src/routes/admin/quarantine/+page.svelte:426 +src/routes/admin/requests/+page.svelte:273 +src/routes/admin/requests/+page.svelte:282 +src/routes/admin/requests/+page.svelte:355 +src/routes/admin/requests/+page.svelte:407 +src/routes/discover/+page.svelte:221 +src/routes/discover/+page.svelte:228 +src/routes/playlists/+page.svelte:46 +src/routes/playlists/+page.svelte:81 +src/routes/playlists/[id]/+page.svelte:216 +``` + +- [ ] **Step 2: Replace `text-text-primary` with `text-action-fg` at each site** + +For each file:line above, edit the element so `text-text-primary` becomes `text-action-fg`. Other classes on the same element (rounded, px, py, hover:opacity-90, disabled:opacity-50, etc.) stay. + +Example: `web/src/lib/components/DiscoverResultCard.svelte:77` before: +```svelte + - {#if menuOpen} -
e.stopPropagation()} - role="menu" - > - -
- {/if} -
- - - - -
- {@render children()} -
-
-``` - -- [ ] **Step 4: Run the tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/components/Shell.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/components/Shell.svelte web/src/lib/components/Shell.test.ts -git commit -m "feat(web): add Shell component with header, sidebar, and user menu - -Persistent chrome used by every authenticated route. Sidebar hides -below md:; mobile nav is a separate concern for a later plan." -``` - ---- - -## Task 9: Placeholder pages for `/`, `/search`, `/playlists` - -**Files:** -- Modify: `web/src/routes/+page.svelte` -- Create: `web/src/routes/search/+page.svelte` -- Create: `web/src/routes/playlists/+page.svelte` - -- [ ] **Step 1: Replace `web/src/routes/+page.svelte`** - -Overwrite the file with: - -```svelte -

Library

-

Library views land in a subsequent plan.

-``` - -- [ ] **Step 2: Create `web/src/routes/search/+page.svelte`** - -```svelte -

Search

-

Search lands in a subsequent plan.

-``` - -- [ ] **Step 3: Create `web/src/routes/playlists/+page.svelte`** - -```svelte -

Playlists

-

Playlists land in a subsequent plan.

-``` - -- [ ] **Step 4: Verify nothing breaks** - -Run: `cd web && npm run check && npm run build` -Expected: check passes with 0 errors; build emits `web/build/` including `index.html`. - -Then restore the committed placeholder (the build regenerates it; we don't want the real build output in git): - -Run: `git checkout -- web/build/index.html` -Expected: `git status` shows no changes under `web/build/`. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/routes/+page.svelte web/src/routes/search/+page.svelte web/src/routes/playlists/+page.svelte -git commit -m "feat(web): add Library/Search/Playlists placeholder pages - -Library replaces the scaffold splash. Search and Playlists are routable -so the Shell's sidebar nav works end-to-end before the real features land." -``` - ---- - -## Task 10: Login page - -**Files:** -- Create: `web/src/routes/login/+page.svelte` -- Create: `web/src/routes/login/login.test.ts` - -- [ ] **Step 1: Write the failing test** - -Create `web/src/routes/login/login.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; - -// vi.hoisted avoids the temporal-dead-zone hazard: mock factories are lazy -// and can run before top-level `let` is initialized, since imports are -// hoisted above variable declarations. -const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/login') })); - -vi.mock('$app/state', () => ({ - page: { - get url() { return state.pageUrl; } - } -})); - -vi.mock('$app/navigation', () => ({ - goto: vi.fn() -})); - -vi.mock('$lib/auth/store.svelte', () => ({ - login: vi.fn(), - user: { value: null } -})); - -import LoginPage from './+page.svelte'; -import { login } from '$lib/auth/store.svelte'; -import { goto } from '$app/navigation'; - -afterEach(() => { - vi.clearAllMocks(); - state.pageUrl = new URL('http://localhost/login'); -}); - -async function submit(username = 'alice', password = 'pw') { - const u = screen.getByLabelText(/username/i) as HTMLInputElement; - const p = screen.getByLabelText(/password/i) as HTMLInputElement; - await fireEvent.input(u, { target: { value: username } }); - await fireEvent.input(p, { target: { value: password } }); - await fireEvent.click(screen.getByRole('button', { name: /sign in/i })); -} - -describe('login page', () => { - test('renders username, password, and sign-in button', () => { - render(LoginPage); - expect(screen.getByLabelText(/username/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument(); - }); - - test('invalid credentials show inline error and clear password', async () => { - (login as ReturnType).mockRejectedValue({ - code: 'invalid_credentials', message: 'bad creds', status: 401 - }); - render(LoginPage); - await submit(); - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent(/invalid username or password/i); - }); - expect((screen.getByLabelText(/password/i) as HTMLInputElement).value).toBe(''); - expect((screen.getByLabelText(/username/i) as HTMLInputElement).value).toBe('alice'); - }); - - test('server error shows generic message and preserves both fields', async () => { - (login as ReturnType).mockRejectedValue({ - code: 'server_error', message: 'boom', status: 500 - }); - render(LoginPage); - await submit('alice', 'pw'); - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent(/try again in a moment/i); - }); - expect((screen.getByLabelText(/password/i) as HTMLInputElement).value).toBe('pw'); - expect((screen.getByLabelText(/username/i) as HTMLInputElement).value).toBe('alice'); - }); - - test('success navigates to returnTo when safe', async () => { - state.pageUrl = new URL('http://localhost/login?returnTo=/artists/abc'); - (login as ReturnType).mockResolvedValue(undefined); - render(LoginPage); - await submit(); - await waitFor(() => { - expect(goto).toHaveBeenCalledWith('/artists/abc', { replaceState: true }); - }); - }); - - test('success falls back to "/" when returnTo is missing', async () => { - (login as ReturnType).mockResolvedValue(undefined); - render(LoginPage); - await submit(); - await waitFor(() => { - expect(goto).toHaveBeenCalledWith('/', { replaceState: true }); - }); - }); - - test.each([ - ['//evil.com', 'protocol-relative'], - ['http://evil.com', 'absolute URL'], - ['/login', 'self-redirect'], - ['../admin', 'parent traversal'] - ])('rejects unsafe returnTo %s (%s) and falls back to "/"', async (value) => { - state.pageUrl = new URL(`http://localhost/login?returnTo=${encodeURIComponent(value)}`); - (login as ReturnType).mockResolvedValue(undefined); - render(LoginPage); - await submit(); - await waitFor(() => { - expect(goto).toHaveBeenCalledWith('/', { replaceState: true }); - }); - }); - - test('submit button disables and marks aria-busy while pending', async () => { - let release!: () => void; - (login as ReturnType).mockReturnValue( - new Promise((resolve) => { release = () => resolve(); }) - ); - render(LoginPage); - await submit(); - const btn = screen.getByRole('button', { name: /sign in/i }); - expect(btn).toBeDisabled(); - expect(btn).toHaveAttribute('aria-busy', 'true'); - release(); - await waitFor(() => expect(btn).not.toBeDisabled()); - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/routes/login/+page.test.ts` -Expected: FAIL — component does not exist. - -- [ ] **Step 3: Implement `web/src/routes/login/+page.svelte`** - -```svelte - - -
-
-

Minstrel

-
- - - - {#if error} - - {/if} -
-
-
-``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/routes/login/+page.test.ts` -Expected: PASS — 10 tests (6 `test(...)` blocks + 4 from `test.each` with 4 rows). - -- [ ] **Step 5: Commit** - -```bash -git add web/src/routes/login/+page.svelte web/src/routes/login/login.test.ts -git commit -m "feat(web): add login page with returnTo support and typed error UX - -Inline errors for invalid creds vs. server-side failures. Validates -returnTo to block open-redirect vectors (//, http://, /login, -parent-traversal)." -``` - ---- - -## Task 11: Root layout wiring — bootstrap, QueryClientProvider, guard, Shell - -**Files:** -- Create: `web/src/routes/+layout.ts` -- Modify: `web/src/routes/+layout.svelte` - -- [ ] **Step 1: Create `web/src/routes/+layout.ts`** - -```ts -import type { LayoutLoad } from './$types'; -import { bootstrap } from '$lib/auth/store.svelte'; - -export const ssr = false; // adapter-static fallback; we're SPA-only -export const prerender = false; - -export const load: LayoutLoad = async () => { - await bootstrap(); - return {}; -}; -``` - -- [ ] **Step 2: Overwrite `web/src/routes/+layout.svelte`** - -```svelte - - - - {#if user.value !== null && page.url.pathname !== '/login'} - {@render children()} - {:else} - {@render children()} - {/if} - -``` - -- [ ] **Step 3: Type-check** - -Run: `cd web && npm run check` -Expected: `svelte-check found 0 errors and 0 warnings`. - -- [ ] **Step 4: Build** - -Run: `cd web && npm run build` -Expected: build succeeds. Then: - -Run: `git checkout -- web/build/index.html` -Expected: committed placeholder restored. - -- [ ] **Step 5: Full test suite** - -Run: `cd web && npm test` -Expected: all test files pass. - -- [ ] **Step 6: Commit** - -```bash -git add web/src/routes/+layout.ts web/src/routes/+layout.svelte -git commit -m "feat(web): wire root layout — bootstrap, guard, Shell, QueryProvider - -+layout.ts runs auth.bootstrap() in load() so the shell sees the user -synchronously. +layout.svelte installs the TanStack QueryClientProvider, -runs the route guard as a \$effect, and swaps Shell vs. bare slot -based on auth state." -``` - ---- - -## Task 12: Final verification + branch finish - -**Files:** none (verification only). - -- [ ] **Step 1: Full Go suite (make sure the frontend work didn't accidentally touch Go files)** - -Run: `go test -short -race ./...` -Expected: PASS. - -- [ ] **Step 2: Go linter** - -Run: `golangci-lint run ./...` -Expected: no issues. - -- [ ] **Step 3: Web check + tests + build** - -Run: `cd web && npm run check && npm test && npm run build` -Expected: check 0 errors; all vitest files pass; build emits to `web/build/`. - -Run: `git checkout -- web/build/index.html` -Expected: committed placeholder restored. - -- [ ] **Step 4: Docker build smoke** - -Run: `docker build -t minstrel:auth-smoke .` -Expected: image builds. - -Run: `docker run --rm --entrypoint /bin/sh minstrel:auth-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'` -Expected: `ok`. - -- [ ] **Step 5: Local end-to-end manual test** - -Bring up the stack: - -```bash -docker compose up --build -d -``` - -Seed a user into Postgres if one does not already exist (reuse whatever bootstrap the auth-foundation plan established; e.g., `psql` insert with a bcrypt hash). Record the username/password used. - -Then in a browser, verify these five flows: - -1. **Deep-link gated:** visit `http://localhost:4533/artists/abc`. Expected: URL changes to `/login?returnTo=%2Fartists%2Fabc`; login form is visible. -2. **Login + deep-link return:** sign in with seeded creds. Expected: URL changes to `/artists/abc`; page is blank (SPA fallback — library plan fills this in) but the Shell header shows your username and sidebar is visible. -3. **Refresh does not flash:** press F5. Expected: Shell renders without the login form appearing first. -4. **Logout:** click username menu → "Log out". Expected: URL changes to `/login`; visiting `/` redirects back to `/login`. -5. **Cross-tab session death:** log in again, open a second tab to `/`. In dev tools, clear the `minstrel_session` cookie. Click "Search" in the nav of the second tab. Expected: after the first API call in the tab (which will 401), the tab redirects to `/login`. - -Tear down: - -```bash -docker compose down -``` - -- [ ] **Step 6: Finish the branch** - -Follow `superpowers:finishing-a-development-branch`: present the four-option menu. The expected path is Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green. - ---- - -## Self-Review Notes - -**Spec coverage check:** -- `apiFetch` contract → Tasks 3, 7 -- `api` facade → Task 4 -- `auth` store → Task 6 -- QueryClient → Task 5 -- Shell component → Task 8 -- Login page → Task 10 -- Placeholder pages → Task 9 -- Root layout bootstrap + guard → Task 11 -- Testing coverage → each feature task has its own test step -- Dependencies → Task 1 -- Final verification & branch finish → Task 12 - -All spec sections map to tasks. No gaps. - -**Type consistency:** -- `User` shape `{id, username, is_admin}` — used identically in Tasks 2, 6, 8. -- `ApiError` shape `{code, message, status}` — used identically in Tasks 2, 3, 7, 10. -- `LoginResponse` shape `{token, user}` — used in Tasks 2, 6. -- `user.value` getter — Tasks 6 (definition), 8, 11 (consumption). -- `logout(opts?: {silent?: boolean})` — Tasks 6 (definition), 7 (silent call), 8 (plain call). -- `page` from `$app/state` (not `$app/stores`) — consistent across Tasks 8, 10, 11. diff --git a/docs/superpowers/plans/2026-04-23-web-ui-library-views.md b/docs/superpowers/plans/2026-04-23-web-ui-library-views.md deleted file mode 100644 index 30031e08..00000000 --- a/docs/superpowers/plans/2026-04-23-web-ui-library-views.md +++ /dev/null @@ -1,1815 +0,0 @@ -# Web UI Library Views Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Wire up the three library browse routes — artists list at `/`, artist detail at `/artists/:id`, album detail at `/albums/:id` — consuming the existing `/api/artists*`, `/api/albums/:id`, and `/api/albums/:id/cover` endpoints, so an authenticated user can navigate the music library entirely in the SPA. - -**Architecture:** TanStack Query handles all fetches and caching via a thin `lib/api/queries.ts` helper. Each route is a tiny `+page.svelte` that imports a `create*Query` helper and delegates repeating visual units to small shared components (`ArtistRow`, `AlbumCard`, `TrackRow`, `LibrarySkeleton`, `ApiErrorBanner`). Loading/error/empty states follow the same pattern on all three routes. - -**Tech Stack:** SvelteKit 2 + Svelte 5 (runes), TypeScript, TanStack Query 5 (`@tanstack/svelte-query`), Vitest + jsdom + Testing Library, Tailwind. - -**Reference:** design spec at `docs/superpowers/specs/2026-04-23-web-ui-library-views-design.md`. - ---- - -## File Structure - -**New files under `web/`:** - -| File | Responsibility | -|---|---| -| `src/lib/api/types.ts` | Mirrors backend `internal/api/types.go` — `ArtistRef`, `AlbumRef`, `TrackRef`, `ArtistDetail`, `AlbumDetail`, `Page` | -| `src/lib/api/queries.ts` | `qk.*` key helpers and `createArtistsQuery`/`createArtistQuery`/`createAlbumQuery` wrappers | -| `src/lib/api/queries.test.ts` | Tests the pure `qk` key generators | -| `src/lib/media/covers.ts` | `FALLBACK_COVER` data URL + `coverUrl(id)` helper | -| `src/lib/media/duration.ts` | `formatDuration(seconds)` returning `mm:ss` or `h:mm:ss` | -| `src/lib/media/duration.test.ts` | Unit tests for `formatDuration` | -| `src/lib/utils/useDelayed.svelte.ts` | `useDelayed(getValue, delayMs)` rune helper for skeleton suppression | -| `src/lib/utils/useDelayed.svelte.test.ts` | Unit tests for `useDelayed` | -| `src/lib/components/ArtistRow.svelte` | Artists list row — avatar initial, name, album count, chevron, wrapping `` | -| `src/lib/components/ArtistRow.test.ts` | Component test | -| `src/lib/components/AlbumCard.svelte` | Album grid card — cover, title, year, wrapping `` | -| `src/lib/components/AlbumCard.test.ts` | Component test | -| `src/lib/components/TrackRow.svelte` | Album track row — number, title, duration | -| `src/lib/components/TrackRow.test.ts` | Component test | -| `src/lib/components/LibrarySkeleton.svelte` | Skeleton shimmer with variants `list`/`grid`/`album` | -| `src/lib/components/LibrarySkeleton.test.ts` | Component test | -| `src/lib/components/ApiErrorBanner.svelte` | Retry-capable error card | -| `src/lib/components/ApiErrorBanner.test.ts` | Component test | -| `src/test-utils/query.ts` | Shared `mockQuery` / `mockInfiniteQuery` factories for page tests | -| `src/routes/artists/[id]/+page.svelte` | Artist detail page | -| `src/routes/artists/[id]/artist.test.ts` | Page test (NOT `+page.test.ts` — route walker collision) | -| `src/routes/albums/[id]/+page.svelte` | Album detail page | -| `src/routes/albums/[id]/album.test.ts` | Page test | -| `src/routes/artists.test.ts` | Page test for the artists list (lives at `src/routes/`, not route-colocated in a `+` file) | - -**Modified files:** - -| File | Change | -|---|---| -| `src/routes/+page.svelte` | Replace the "Library" placeholder with the real artists list | - ---- - -## Task 1: API types mirroring `internal/api/types.go` - -**Files:** -- Create: `web/src/lib/api/types.ts` - -- [ ] **Step 1: Write `web/src/lib/api/types.ts`** - -```ts -// Mirrors internal/api/types.go. Kept minimal — only the shapes the SPA -// actually reads today (no LoginRequest/LoginResponse here; those live in -// client.ts alongside the auth plumbing). - -export type Page = { - items: T[]; - total: number; - limit: number; - offset: number; -}; - -export type ArtistRef = { - id: string; - name: string; - album_count: number; -}; - -export type AlbumRef = { - id: string; - title: string; - artist_id: string; - artist_name: string; - year?: number; - track_count: number; - duration_sec: number; - cover_url: string; -}; - -export type TrackRef = { - id: string; - title: string; - album_id: string; - album_title: string; - artist_id: string; - artist_name: string; - track_number?: number; - disc_number?: number; - duration_sec: number; - stream_url: string; -}; - -export type ArtistDetail = ArtistRef & { - albums: AlbumRef[]; -}; - -export type AlbumDetail = AlbumRef & { - tracks: TrackRef[]; -}; -``` - -- [ ] **Step 2: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 3: Commit** - -```bash -git add web/src/lib/api/types.ts -git commit -m "feat(web): add library API types mirroring internal/api/types.go" -``` - ---- - -## Task 2: `formatDuration` helper + tests - -**Files:** -- Create: `web/src/lib/media/duration.ts` -- Create: `web/src/lib/media/duration.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `web/src/lib/media/duration.test.ts`: - -```ts -import { describe, expect, test } from 'vitest'; -import { formatDuration } from './duration'; - -describe('formatDuration', () => { - test('under a minute renders 0:SS', () => { - expect(formatDuration(42)).toBe('0:42'); - expect(formatDuration(5)).toBe('0:05'); - }); - - test('under an hour renders M:SS', () => { - expect(formatDuration(242)).toBe('4:02'); - expect(formatDuration(600)).toBe('10:00'); - }); - - test('at or above an hour renders H:MM:SS', () => { - expect(formatDuration(3600)).toBe('1:00:00'); - expect(formatDuration(3723)).toBe('1:02:03'); - expect(formatDuration(36000)).toBe('10:00:00'); - }); - - test('zero renders 0:00', () => { - expect(formatDuration(0)).toBe('0:00'); - }); - - test('negative values clamp to 0:00', () => { - expect(formatDuration(-1)).toBe('0:00'); - expect(formatDuration(-100)).toBe('0:00'); - }); - - test('fractional seconds floor', () => { - expect(formatDuration(61.9)).toBe('1:01'); - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/lib/media/duration.test.ts` -Expected: FAIL — `formatDuration` not exported. - -- [ ] **Step 3: Implement `web/src/lib/media/duration.ts`** - -```ts -// Formats a non-negative number of seconds as `mm:ss` when under an hour, -// `h:mm:ss` otherwise. Negative values clamp to zero (callers shouldn't -// pass them, but the UI shouldn't crash if they do). -export function formatDuration(seconds: number): string { - const total = Math.max(0, Math.floor(seconds)); - const h = Math.floor(total / 3600); - const m = Math.floor((total % 3600) / 60); - const s = total % 60; - const ss = String(s).padStart(2, '0'); - if (h > 0) { - const mm = String(m).padStart(2, '0'); - return `${h}:${mm}:${ss}`; - } - return `${m}:${ss}`; -} -``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/media/duration.test.ts` -Expected: PASS — 6 tests. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/media/duration.ts web/src/lib/media/duration.test.ts -git commit -m "feat(web): add formatDuration helper - -Renders seconds as mm:ss or h:mm:ss. Negative inputs clamp to 0:00 -so a bad backend value doesn't crash the UI." -``` - ---- - -## Task 3: `covers.ts` helper - -**Files:** -- Create: `web/src/lib/media/covers.ts` - -- [ ] **Step 1: Write `web/src/lib/media/covers.ts`** - -```ts -// Fallback cover — an inline SVG data URL showing a music-note glyph on the -// surface color. Used by AlbumCard's onerror handler when the real cover -// fails (e.g. scanner didn't pick up an embedded image and there's no -// sidecar). Data URL avoids an extra HTTP round-trip. -export const FALLBACK_COVER = - 'data:image/svg+xml;utf8,' + - encodeURIComponent( - ` - - - ` - ); - -export function coverUrl(albumId: string): string { - return `/api/albums/${albumId}/cover`; -} -``` - -- [ ] **Step 2: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 3: Commit** - -```bash -git add web/src/lib/media/covers.ts -git commit -m "feat(web): add cover URL helper + fallback SVG data URL" -``` - ---- - -## Task 4: `useDelayed` rune helper - -**Files:** -- Create: `web/src/lib/utils/useDelayed.svelte.ts` -- Create: `web/src/lib/utils/useDelayed.svelte.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `web/src/lib/utils/useDelayed.svelte.test.ts`: - -```ts -import { describe, expect, test, vi } from 'vitest'; -import { flushSync } from 'svelte'; -import { useDelayed } from './useDelayed.svelte'; - -describe('useDelayed', () => { - test('value stays false until delay elapses while source is true', () => { - vi.useFakeTimers(); - try { - let source = $state(false); - let delayed!: { readonly value: boolean }; - const cleanup = $effect.root(() => { - delayed = useDelayed(() => source, 100); - }); - - expect(delayed.value).toBe(false); - - source = true; - flushSync(); - expect(delayed.value).toBe(false); - - vi.advanceTimersByTime(50); - expect(delayed.value).toBe(false); - - vi.advanceTimersByTime(60); - expect(delayed.value).toBe(true); - - cleanup(); - } finally { - vi.useRealTimers(); - } - }); - - test('source flipping false before delay cancels the pending timeout', () => { - vi.useFakeTimers(); - try { - let source = $state(false); - let delayed!: { readonly value: boolean }; - const cleanup = $effect.root(() => { - delayed = useDelayed(() => source, 100); - }); - - source = true; - flushSync(); - vi.advanceTimersByTime(40); - source = false; - flushSync(); - vi.advanceTimersByTime(200); - expect(delayed.value).toBe(false); - - cleanup(); - } finally { - vi.useRealTimers(); - } - }); - - test('value resets to false immediately when source becomes false', () => { - vi.useFakeTimers(); - try { - let source = $state(true); - let delayed!: { readonly value: boolean }; - const cleanup = $effect.root(() => { - delayed = useDelayed(() => source, 100); - }); - flushSync(); // run the initial $effect so the setTimeout is scheduled - - vi.advanceTimersByTime(200); - expect(delayed.value).toBe(true); - - source = false; - flushSync(); - expect(delayed.value).toBe(false); - - cleanup(); - } finally { - vi.useRealTimers(); - } - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/lib/utils/useDelayed.svelte.test.ts` -Expected: FAIL — module not found. - -- [ ] **Step 3: Implement `web/src/lib/utils/useDelayed.svelte.ts`** - -```ts -// Returns a rune-wrapped boolean that mirrors the source getter, but only -// flips to true after the source has been true for delayMs continuously. -// Flips back to false immediately when the source becomes false. -// -// Used to suppress flash-of-skeleton on cache-hit navigation: skeletons -// only render for loads that take longer than ~100ms. -export function useDelayed( - source: () => boolean, - delayMs = 100 -): { readonly value: boolean } { - let delayed = $state(false); - let timeout: ReturnType | null = null; - - $effect(() => { - const v = source(); - if (timeout) { - clearTimeout(timeout); - timeout = null; - } - if (v) { - timeout = setTimeout(() => { - delayed = true; - timeout = null; - }, delayMs); - } else { - delayed = false; - } - return () => { - if (timeout) { - clearTimeout(timeout); - timeout = null; - } - }; - }); - - return { get value() { return delayed; } }; -} -``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/utils/useDelayed.svelte.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/utils/useDelayed.svelte.ts web/src/lib/utils/useDelayed.svelte.test.ts -git commit -m "feat(web): add useDelayed rune helper - -Boolean mirror that flips true only after the source stays true for -delayMs. Used to hide the skeleton on sub-100ms cache hits so -back-navigation feels instant." -``` - ---- - -## Task 5: `queries.ts` + key-generator tests - -**Files:** -- Create: `web/src/lib/api/queries.ts` -- Create: `web/src/lib/api/queries.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `web/src/lib/api/queries.test.ts`: - -```ts -import { describe, expect, test } from 'vitest'; -import { qk } from './queries'; - -describe('qk key generators', () => { - test('artists key includes sort discriminator', () => { - expect(qk.artists('alpha')).toEqual(['artists', { sort: 'alpha' }]); - expect(qk.artists('newest')).toEqual(['artists', { sort: 'newest' }]); - }); - - test('artist key tuple', () => { - expect(qk.artist('abc')).toEqual(['artist', 'abc']); - }); - - test('album key tuple', () => { - expect(qk.album('xyz')).toEqual(['album', 'xyz']); - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/lib/api/queries.test.ts` -Expected: FAIL — module not found. - -- [ ] **Step 3: Implement `web/src/lib/api/queries.ts`** - -```ts -import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query'; -import { api } from './client'; -import type { ArtistRef, ArtistDetail, AlbumDetail, Page } from './types'; - -export type ArtistSort = 'alpha' | 'newest'; -export const ARTIST_PAGE_SIZE = 50; - -export const qk = { - artists: (sort: ArtistSort) => ['artists', { sort }] as const, - artist: (id: string) => ['artist', id] as const, - album: (id: string) => ['album', id] as const, -}; - -export function createArtistsQuery(sort: ArtistSort) { - return createInfiniteQuery({ - queryKey: qk.artists(sort), - queryFn: ({ pageParam = 0 }) => - api.get>( - `/api/artists?sort=${sort}&limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}` - ), - initialPageParam: 0, - getNextPageParam: (last) => { - const loaded = last.offset + last.items.length; - return loaded >= last.total ? undefined : loaded; - }, - }); -} - -export function createArtistQuery(id: string) { - return createQuery({ - queryKey: qk.artist(id), - queryFn: () => api.get(`/api/artists/${id}`), - }); -} - -export function createAlbumQuery(id: string) { - return createQuery({ - queryKey: qk.album(id), - queryFn: () => api.get(`/api/albums/${id}`), - }); -} -``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/api/queries.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 6: Commit** - -```bash -git add web/src/lib/api/queries.ts web/src/lib/api/queries.test.ts -git commit -m "feat(web): add TanStack Query helpers for library endpoints - -qk.{artists,artist,album} key generators and create*Query wrappers -with shared page size, sort-aware keys, and infinite-query plumbing -for /api/artists." -``` - ---- - -## Task 6: `ArtistRow` component - -**Files:** -- Create: `web/src/lib/components/ArtistRow.svelte` -- Create: `web/src/lib/components/ArtistRow.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `web/src/lib/components/ArtistRow.test.ts`: - -```ts -import { describe, expect, test } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import ArtistRow from './ArtistRow.svelte'; -import type { ArtistRef } from '$lib/api/types'; - -const artist: ArtistRef = { id: 'abc', name: 'alice', album_count: 12 }; - -describe('ArtistRow', () => { - test('renders initial, name, album count inside a link to /artists/:id', () => { - render(ArtistRow, { props: { artist } }); - - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/artists/abc'); - expect(link).toHaveTextContent('alice'); - expect(link).toHaveTextContent('12 albums'); - expect(link).toHaveTextContent('A'); // initial letter - }); - - test('singular "1 album" when album_count is 1', () => { - render(ArtistRow, { props: { artist: { ...artist, album_count: 1 } } }); - expect(screen.getByRole('link')).toHaveTextContent('1 album'); - }); - - test('empty name still renders a safe initial', () => { - render(ArtistRow, { props: { artist: { ...artist, name: '' } } }); - // No crash; initial container present but empty or a placeholder char. - expect(screen.getByRole('link')).toBeInTheDocument(); - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/lib/components/ArtistRow.test.ts` -Expected: FAIL — component not found. - -- [ ] **Step 3: Implement `web/src/lib/components/ArtistRow.svelte`** - -```svelte - - - - - {artist.name} - {countLabel} - - -``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/components/ArtistRow.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/components/ArtistRow.svelte web/src/lib/components/ArtistRow.test.ts -git commit -m "feat(web): add ArtistRow component - -One row in the artists list: avatar initial, name, album count, -chevron. Entire row is an for click + keyboard activation." -``` - ---- - -## Task 7: `AlbumCard` component - -**Files:** -- Create: `web/src/lib/components/AlbumCard.svelte` -- Create: `web/src/lib/components/AlbumCard.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `web/src/lib/components/AlbumCard.test.ts`: - -```ts -import { describe, expect, test } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; -import AlbumCard from './AlbumCard.svelte'; -import type { AlbumRef } from '$lib/api/types'; -import { FALLBACK_COVER } from '$lib/media/covers'; - -const album: AlbumRef = { - id: 'xyz', - title: 'Kind of Blue', - artist_id: 'm-davis', - artist_name: 'Miles Davis', - year: 1959, - track_count: 5, - duration_sec: 2630, - cover_url: '/api/albums/xyz/cover', -}; - -describe('AlbumCard', () => { - test('renders cover, title, year inside a link to /albums/:id', () => { - render(AlbumCard, { props: { album } }); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/albums/xyz'); - expect(link).toHaveTextContent('Kind of Blue'); - expect(link).toHaveTextContent('1959'); - - const img = screen.getByRole('img') as HTMLImageElement; - expect(img.src).toContain('/api/albums/xyz/cover'); - }); - - test('year is omitted when not present', () => { - render(AlbumCard, { props: { album: { ...album, year: undefined } } }); - // No crash; no year text. - expect(screen.queryByText(/1959/)).not.toBeInTheDocument(); - }); - - test('broken cover falls back to FALLBACK_COVER data URL', async () => { - render(AlbumCard, { props: { album } }); - const img = screen.getByRole('img') as HTMLImageElement; - await fireEvent.error(img); - expect(img.src).toBe(FALLBACK_COVER); - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/lib/components/AlbumCard.test.ts` -Expected: FAIL — component not found. - -- [ ] **Step 3: Implement `web/src/lib/components/AlbumCard.svelte`** - -```svelte - - - - -
{album.title}
- {#if album.year} -
{album.year}
- {/if} -
-``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/components/AlbumCard.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/components/AlbumCard.svelte web/src/lib/components/AlbumCard.test.ts -git commit -m "feat(web): add AlbumCard component - -Grid card used on the artist detail page. Cover image, title, year. -Broken covers swap to FALLBACK_COVER via onerror." -``` - ---- - -## Task 8: `TrackRow` component - -**Files:** -- Create: `web/src/lib/components/TrackRow.svelte` -- Create: `web/src/lib/components/TrackRow.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `web/src/lib/components/TrackRow.test.ts`: - -```ts -import { describe, expect, test } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import TrackRow from './TrackRow.svelte'; -import type { TrackRef } from '$lib/api/types'; - -const track: TrackRef = { - id: 't1', - title: 'So What', - album_id: 'xyz', - album_title: 'Kind of Blue', - artist_id: 'm-davis', - artist_name: 'Miles Davis', - track_number: 1, - disc_number: 1, - duration_sec: 545, - stream_url: '/api/tracks/t1/stream', -}; - -describe('TrackRow', () => { - test('renders track number, title, formatted duration', () => { - render(TrackRow, { props: { track } }); - expect(screen.getByText('1')).toBeInTheDocument(); - expect(screen.getByText('So What')).toBeInTheDocument(); - expect(screen.getByText('9:05')).toBeInTheDocument(); - }); - - test('track number falls back to dash when missing', () => { - render(TrackRow, { props: { track: { ...track, track_number: undefined } } }); - expect(screen.getByText('—')).toBeInTheDocument(); - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/lib/components/TrackRow.test.ts` -Expected: FAIL — component not found. - -- [ ] **Step 3: Implement `web/src/lib/components/TrackRow.svelte`** - -```svelte - - -
- - {track.track_number ?? '—'} - - {track.title} - {formatDuration(track.duration_sec)} -
-``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/components/TrackRow.test.ts` -Expected: PASS — 2 tests. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/components/TrackRow.svelte web/src/lib/components/TrackRow.test.ts -git commit -m "feat(web): add TrackRow component - -Non-interactive album track row with number, title, duration. -Player plan will add click-to-play." -``` - ---- - -## Task 9: `LibrarySkeleton` component - -**Files:** -- Create: `web/src/lib/components/LibrarySkeleton.svelte` -- Create: `web/src/lib/components/LibrarySkeleton.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `web/src/lib/components/LibrarySkeleton.test.ts`: - -```ts -import { describe, expect, test } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import LibrarySkeleton from './LibrarySkeleton.svelte'; - -describe('LibrarySkeleton', () => { - test('variant="list" renders count placeholder rows', () => { - render(LibrarySkeleton, { props: { variant: 'list', count: 5 } }); - const container = screen.getByTestId('skeleton-list'); - expect(container.querySelectorAll('[data-skeleton-row]').length).toBe(5); - }); - - test('variant="grid" renders count placeholder cards', () => { - render(LibrarySkeleton, { props: { variant: 'grid', count: 6 } }); - const container = screen.getByTestId('skeleton-grid'); - expect(container.querySelectorAll('[data-skeleton-card]').length).toBe(6); - }); - - test('variant="album" renders a hero placeholder with cover block + bars', () => { - render(LibrarySkeleton, { props: { variant: 'album' } }); - expect(screen.getByTestId('skeleton-album')).toBeInTheDocument(); - expect(screen.getByTestId('skeleton-album-cover')).toBeInTheDocument(); - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/lib/components/LibrarySkeleton.test.ts` -Expected: FAIL — component not found. - -- [ ] **Step 3: Implement `web/src/lib/components/LibrarySkeleton.svelte`** - -```svelte - - -{#if variant === 'list'} -
- {#each Array.from({ length: count }) as _, i (i)} -
- - - -
- {/each} -
-{:else if variant === 'grid'} -
- {#each Array.from({ length: count }) as _, i (i)} -
-
-
-
-
- {/each} -
-{:else} -
-
-
-
-
-
-
-
-
-
- {#each Array.from({ length: 10 }) as _, i (i)} -
- {/each} -
-
-{/if} -``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/components/LibrarySkeleton.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/components/LibrarySkeleton.svelte web/src/lib/components/LibrarySkeleton.test.ts -git commit -m "feat(web): add LibrarySkeleton with list/grid/album variants - -Shimmer placeholders that mirror the layout of each library page so -the real content slides in without shifting." -``` - ---- - -## Task 10: `ApiErrorBanner` component - -**Files:** -- Create: `web/src/lib/components/ApiErrorBanner.svelte` -- Create: `web/src/lib/components/ApiErrorBanner.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `web/src/lib/components/ApiErrorBanner.test.ts`: - -```ts -import { describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; -import ApiErrorBanner from './ApiErrorBanner.svelte'; - -describe('ApiErrorBanner', () => { - test('renders error.message when present', () => { - render(ApiErrorBanner, { - props: { - error: { code: 'server_error', message: 'database down', status: 500 }, - onRetry: () => {} - } - }); - expect(screen.getByRole('alert')).toHaveTextContent('database down'); - }); - - test('falls back to generic text when error lacks message', () => { - render(ApiErrorBanner, { props: { error: undefined, onRetry: () => {} } }); - expect(screen.getByRole('alert')).toHaveTextContent(/something went wrong/i); - }); - - test('clicking the button calls onRetry', async () => { - const onRetry = vi.fn(); - render(ApiErrorBanner, { - props: { - error: { code: 'server_error', message: 'x', status: 500 }, - onRetry - } - }); - await fireEvent.click(screen.getByRole('button', { name: /try again/i })); - expect(onRetry).toHaveBeenCalledTimes(1); - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/lib/components/ApiErrorBanner.test.ts` -Expected: FAIL — component not found. - -- [ ] **Step 3: Implement `web/src/lib/components/ApiErrorBanner.svelte`** - -```svelte - - - -``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/components/ApiErrorBanner.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/components/ApiErrorBanner.svelte web/src/lib/components/ApiErrorBanner.test.ts -git commit -m "feat(web): add ApiErrorBanner retry-capable error card" -``` - ---- - -## Task 11: Artists list page at `/` - -**Files:** -- Modify: `web/src/routes/+page.svelte` -- Create: `web/src/test-utils/query.ts` -- Create: `web/src/routes/artists.test.ts` - -- [ ] **Step 1: Create `web/src/test-utils/query.ts`** - -```ts -import type { Page } from '$lib/api/types'; - -// Synthetic infinite-query object shape matching what the SPA reads from -// @tanstack/svelte-query. Enough surface area for component tests; -// real behavior is covered by the TanStack library's own tests. -export function mockInfiniteQuery(opts: { - pages?: Page[]; - isPending?: boolean; - isError?: boolean; - error?: unknown; - hasNextPage?: boolean; - isFetchingNextPage?: boolean; - fetchNextPage?: () => void; - refetch?: () => void; -} = {}) { - return { - data: { pages: opts.pages ?? [] }, - isPending: opts.isPending ?? false, - isError: opts.isError ?? false, - error: opts.error, - hasNextPage: opts.hasNextPage ?? false, - isFetchingNextPage: opts.isFetchingNextPage ?? false, - fetchNextPage: opts.fetchNextPage ?? (() => {}), - refetch: opts.refetch ?? (() => {}) - }; -} - -export function mockQuery(opts: { - data?: T; - isPending?: boolean; - isError?: boolean; - error?: unknown; - refetch?: () => void; -} = {}) { - return { - data: opts.data, - isPending: opts.isPending ?? false, - isError: opts.isError ?? false, - error: opts.error, - refetch: opts.refetch ?? (() => {}) - }; -} -``` - -- [ ] **Step 2: Write the failing page test** - -Create `web/src/routes/artists.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; -import { flushSync } from 'svelte'; -import { mockInfiniteQuery } from '../test-utils/query'; -import type { ArtistRef, Page } from '$lib/api/types'; - -const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/') })); - -vi.mock('$app/state', () => ({ - page: { get url() { return state.pageUrl; } } -})); - -vi.mock('$app/navigation', () => ({ - goto: vi.fn() -})); - -vi.mock('$lib/api/queries', () => ({ - qk: { artists: (sort: string) => ['artists', { sort }] }, - createArtistsQuery: vi.fn() -})); - -import ArtistsPage from './+page.svelte'; -import { createArtistsQuery } from '$lib/api/queries'; -import { goto } from '$app/navigation'; - -function page(items: T[], total: number, offset = 0, limit = 50): Page { - return { items, total, offset, limit }; -} - -afterEach(() => { - vi.clearAllMocks(); - state.pageUrl = new URL('http://localhost/'); -}); - -describe('artists list page', () => { - test('renders a row per artist', () => { - const alice: ArtistRef = { id: 'a', name: 'Alice', album_count: 3 }; - const bob: ArtistRef = { id: 'b', name: 'Bob', album_count: 1 }; - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([alice, bob], 2)] }) - ); - render(ArtistsPage); - expect(screen.getByRole('link', { name: /Alice/ })).toHaveAttribute('href', '/artists/a'); - expect(screen.getByRole('link', { name: /Bob/ })).toHaveAttribute('href', '/artists/b'); - }); - - test('renders the total count from the first page', () => { - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([], 1247)] }) - ); - render(ArtistsPage); - expect(screen.getByText(/1247 artists/i)).toBeInTheDocument(); - }); - - test('Load more button calls fetchNextPage when hasNextPage', async () => { - const fetchNextPage = vi.fn(); - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ - pages: [page([], 100)], - hasNextPage: true, - fetchNextPage - }) - ); - render(ArtistsPage); - await fireEvent.click(screen.getByRole('button', { name: /load more/i })); - expect(fetchNextPage).toHaveBeenCalledTimes(1); - }); - - test('"End of library" when hasNextPage is false and at least one page loaded', () => { - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([{ id: 'a', name: 'A', album_count: 1 }], 1)] }) - ); - render(ArtistsPage); - expect(screen.getByText(/end of library/i)).toBeInTheDocument(); - }); - - test('empty total renders empty-library message', () => { - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([], 0)] }) - ); - render(ArtistsPage); - expect(screen.getByText(/no artists yet/i)).toBeInTheDocument(); - }); - - test('changing the sort dropdown calls goto with ?sort=newest', async () => { - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([], 0)] }) - ); - render(ArtistsPage); - const select = screen.getByLabelText(/sort/i) as HTMLSelectElement; - await fireEvent.change(select, { target: { value: 'newest' } }); - expect(goto).toHaveBeenCalledWith('?sort=newest', { replaceState: true }); - }); - - test('pending state renders the list skeleton after the useDelayed window', () => { - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ isPending: true }) - ); - vi.useFakeTimers(); - try { - render(ArtistsPage); - vi.advanceTimersByTime(200); - flushSync(); // flush the $effect that reads delayed.value - expect(screen.getByTestId('skeleton-list')).toBeInTheDocument(); - } finally { - vi.useRealTimers(); - } - }); - - test('error state renders the error banner with retry', async () => { - const refetch = vi.fn(); - (createArtistsQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ - isError: true, - error: { code: 'server_error', message: 'db down', status: 500 }, - refetch - }) - ); - render(ArtistsPage); - expect(screen.getByRole('alert')).toHaveTextContent('db down'); - await fireEvent.click(screen.getByRole('button', { name: /try again/i })); - expect(refetch).toHaveBeenCalledTimes(1); - }); -}); -``` - -- [ ] **Step 3: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/routes/artists.test.ts` -Expected: FAIL — page still renders the placeholder, assertions don't match. - -- [ ] **Step 4: Replace `web/src/routes/+page.svelte`** - -```svelte - - -
-
-
-

Library

- {#if !query.isPending && !query.isError} -

- {total} {total === 1 ? 'artist' : 'artists'} -

- {/if} -
- -
- - {#if query.isError} - - {:else if showSkeleton.value && artists.length === 0} - - {:else if !query.isPending && total === 0} -

- No artists yet — scan a library folder via the server's config. -

- {:else} -
- {#each artists as a (a.id)} - - {/each} -
- - {#if query.hasNextPage} - - {:else if artists.length > 0} -

End of library

- {/if} - {/if} -
-``` - -- [ ] **Step 5: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/routes/artists.test.ts` -Expected: PASS — 8 tests. - -- [ ] **Step 6: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 7: Commit** - -```bash -git add web/src/routes/+page.svelte web/src/routes/artists.test.ts web/src/test-utils/query.ts -git commit -m "feat(web): replace / placeholder with real artists list - -Uses createArtistsQuery (infinite), URL-driven sort dropdown, Load -more pagination, delayed skeleton, and shared error banner. Also -introduces the test-utils/query.ts helpers for subsequent page tests." -``` - ---- - -## Task 12: Artist detail page at `/artists/:id` - -**Files:** -- Create: `web/src/routes/artists/[id]/+page.svelte` -- Create: `web/src/routes/artists/[id]/artist.test.ts` - -- [ ] **Step 1: Write the failing test** - -Create `web/src/routes/artists/[id]/artist.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import { flushSync } from 'svelte'; -import { mockQuery } from '../../../test-utils/query'; -import type { ArtistDetail, AlbumRef } from '$lib/api/types'; - -const state = vi.hoisted(() => ({ pageParams: { id: 'abc' } as Record })); - -vi.mock('$app/state', () => ({ - page: { - get params() { return state.pageParams; }, - get url() { return new URL('http://localhost/artists/' + state.pageParams.id); } - } -})); - -vi.mock('$lib/api/queries', () => ({ - qk: { artist: (id: string) => ['artist', id] }, - createArtistQuery: vi.fn() -})); - -import ArtistPage from './+page.svelte'; -import { createArtistQuery } from '$lib/api/queries'; - -function album(id: string, title: string, year?: number): AlbumRef { - return { - id, title, - artist_id: 'abc', artist_name: 'Alice', - year, track_count: 10, duration_sec: 2400, - cover_url: `/api/albums/${id}/cover` - }; -} - -afterEach(() => { - vi.clearAllMocks(); - state.pageParams = { id: 'abc' }; -}); - -describe('artist detail page', () => { - test('renders artist name, subtitle, and one AlbumCard per album', () => { - const detail: ArtistDetail = { - id: 'abc', name: 'Alice', album_count: 2, - albums: [album('a1', 'First', 2020), album('a2', 'Second')] - }; - (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); - render(ArtistPage); - expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Alice'); - expect(screen.getByText(/2 albums/i)).toBeInTheDocument(); - expect(screen.getByRole('link', { name: /First/ })).toHaveAttribute('href', '/albums/a1'); - expect(screen.getByRole('link', { name: /Second/ })).toHaveAttribute('href', '/albums/a2'); - }); - - test('back link points to Library', () => { - (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ - data: { id: 'abc', name: 'Alice', album_count: 0, albums: [] } - })); - render(ArtistPage); - const back = screen.getByRole('link', { name: /library/i }); - expect(back).toHaveAttribute('href', '/'); - }); - - test('404 renders non-retryable "Artist not found" with back link', () => { - (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ - isError: true, - error: { code: 'not_found', message: 'nope', status: 404 } - })); - render(ArtistPage); - expect(screen.getByText(/artist not found/i)).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument(); - }); - - test('non-404 error renders the retry banner', () => { - (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ - isError: true, - error: { code: 'server_error', message: 'boom', status: 500 } - })); - render(ArtistPage); - expect(screen.getByRole('alert')).toHaveTextContent('boom'); - expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); - }); - - test('pending (after delay) renders the grid skeleton', () => { - (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ isPending: true })); - vi.useFakeTimers(); - try { - render(ArtistPage); - vi.advanceTimersByTime(200); - flushSync(); - expect(screen.getByTestId('skeleton-grid')).toBeInTheDocument(); - } finally { - vi.useRealTimers(); - } - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/routes/artists/[id]/artist.test.ts` -Expected: FAIL — component not found. - -- [ ] **Step 3: Implement `web/src/routes/artists/[id]/+page.svelte`** - -```svelte - - -
- ← Library - - {#if notFound} - - {:else if query.isError} - - {:else if showSkeleton.value && !query.data} - - {:else if query.data} - {@const detail = query.data} -
-

{detail.name}

-

- {detail.album_count} {detail.album_count === 1 ? 'album' : 'albums'} -

-
- - {#if detail.albums.length === 0} -

This artist has no albums in the library.

- {:else} -
- {#each detail.albums as album (album.id)} - - {/each} -
- {/if} - {/if} -
-``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/routes/artists/[id]/artist.test.ts` -Expected: PASS — 5 tests. - -- [ ] **Step 5: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 6: Commit** - -```bash -git add web/src/routes/artists/[id]/+page.svelte web/src/routes/artists/[id]/artist.test.ts -git commit -m "feat(web): add artist detail page at /artists/:id - -Renders the artist name + album count and a responsive AlbumCard grid -from the nested ArtistDetail response. 404 surfaces a distinct -non-retryable 'not found' state; other errors share the retry banner." -``` - ---- - -## Task 13: Album detail page at `/albums/:id` - -**Files:** -- Create: `web/src/routes/albums/[id]/+page.svelte` -- Create: `web/src/routes/albums/[id]/album.test.ts` - -- [ ] **Step 1: Write the failing test** - -Create `web/src/routes/albums/[id]/album.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import { flushSync } from 'svelte'; -import { mockQuery } from '../../../test-utils/query'; -import type { AlbumDetail, TrackRef } from '$lib/api/types'; - -const state = vi.hoisted(() => ({ pageParams: { id: 'xyz' } as Record })); - -vi.mock('$app/state', () => ({ - page: { - get params() { return state.pageParams; }, - get url() { return new URL('http://localhost/albums/' + state.pageParams.id); } - } -})); - -vi.mock('$lib/api/queries', () => ({ - qk: { album: (id: string) => ['album', id] }, - createAlbumQuery: vi.fn() -})); - -import AlbumPage from './+page.svelte'; -import { createAlbumQuery } from '$lib/api/queries'; - -function track(id: string, title: string, number: number, dur: number): TrackRef { - return { - id, title, - album_id: 'xyz', album_title: 'Kind of Blue', - artist_id: 'md', artist_name: 'Miles Davis', - track_number: number, disc_number: 1, - duration_sec: dur, - stream_url: `/api/tracks/${id}/stream` - }; -} - -afterEach(() => { - vi.clearAllMocks(); - state.pageParams = { id: 'xyz' }; -}); - -describe('album detail page', () => { - test('renders hero metadata + one TrackRow per track', () => { - const detail: AlbumDetail = { - id: 'xyz', title: 'Kind of Blue', - artist_id: 'md', artist_name: 'Miles Davis', - year: 1959, track_count: 2, duration_sec: 544 + 565, - cover_url: '/api/albums/xyz/cover', - tracks: [track('t1', 'So What', 1, 544), track('t2', 'Freddie Freeloader', 2, 565)] - }; - (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); - render(AlbumPage); - - expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Kind of Blue'); - expect(screen.getByRole('link', { name: /Miles Davis/ })).toHaveAttribute('href', '/artists/md'); - expect(screen.getByText(/1959/)).toBeInTheDocument(); - expect(screen.getByText(/2 tracks/i)).toBeInTheDocument(); - - expect(screen.getByText('So What')).toBeInTheDocument(); - expect(screen.getByText('Freddie Freeloader')).toBeInTheDocument(); - - const img = screen.getByRole('img') as HTMLImageElement; - expect(img.src).toContain('/api/albums/xyz/cover'); - }); - - test('back link points to /artists/:artistId with the artist name', () => { - const detail: AlbumDetail = { - id: 'xyz', title: 'T', - artist_id: 'md', artist_name: 'Miles Davis', - track_count: 0, duration_sec: 0, - cover_url: '/api/albums/xyz/cover', - tracks: [] - }; - (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); - render(AlbumPage); - const back = screen.getByRole('link', { name: /Miles Davis/ }); - expect(back).toHaveAttribute('href', '/artists/md'); - }); - - test('404 renders non-retryable "Album not found" with back link', () => { - (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ - isError: true, - error: { code: 'not_found', message: 'nope', status: 404 } - })); - render(AlbumPage); - expect(screen.getByText(/album not found/i)).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument(); - }); - - test('non-404 error renders the retry banner', () => { - (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ - isError: true, - error: { code: 'server_error', message: 'boom', status: 500 } - })); - render(AlbumPage); - expect(screen.getByRole('alert')).toHaveTextContent('boom'); - expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); - }); - - test('pending (after delay) renders the album skeleton', () => { - (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ isPending: true })); - vi.useFakeTimers(); - try { - render(AlbumPage); - vi.advanceTimersByTime(200); - flushSync(); - expect(screen.getByTestId('skeleton-album')).toBeInTheDocument(); - } finally { - vi.useRealTimers(); - } - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/routes/albums/[id]/album.test.ts` -Expected: FAIL — component not found. - -- [ ] **Step 3: Implement `web/src/routes/albums/[id]/+page.svelte`** - -```svelte - - -
- {#if notFound} - - {:else if query.isError} - - {:else if showSkeleton.value && !query.data} - - {:else if query.data} - {@const album = query.data} - - ← {album.artist_name} - - -
- -
-

{album.title}

-

- {album.artist_name} -

- {#if album.year} -

{album.year}

- {/if} -

- {album.track_count} {album.track_count === 1 ? 'track' : 'tracks'} - · {formatDuration(album.duration_sec)} -

-
-
- - {#if album.tracks.length === 0} -

This album has no tracks.

- {:else} -
- {#each album.tracks as track (track.id)} - - {/each} -
- {/if} - {/if} -
-``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/routes/albums/[id]/album.test.ts` -Expected: PASS — 5 tests. - -- [ ] **Step 5: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 6: Commit** - -```bash -git add web/src/routes/albums/[id]/+page.svelte web/src/routes/albums/[id]/album.test.ts -git commit -m "feat(web): add album detail page at /albums/:id - -Hero with cover + title + linked artist + metadata; TrackRow list -below. Back link targets the artist page (not Library) so drilling -up feels correct. 404 renders a distinct non-retryable state." -``` - ---- - -## Task 14: Final verification + branch finish - -**Files:** none (verification only). - -- [ ] **Step 1: Full Go suite (sanity — backend untouched)** - -Run: `go test -short -race ./...` -Expected: PASS. - -- [ ] **Step 2: Go linter** - -Run: `golangci-lint run ./...` -Expected: no issues. - -- [ ] **Step 3: Web check + full tests + build** - -Run: `cd web && npm run check && npm test && npm run build` -Expected: check 0 errors / 0 warnings; all vitest files pass; build emits `web/build/`. - -Run: `git checkout -- web/build/index.html` -Expected: committed placeholder restored. - -- [ ] **Step 4: Docker build smoke** - -Run: `docker build -t minstrel:library-smoke .` -Expected: image builds. - -Run: `docker run --rm --entrypoint /bin/sh minstrel:library-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'` -Expected: `ok`. - -- [ ] **Step 5: Local end-to-end manual check** - -Bring up the stack: - -```bash -docker compose up --build -d -``` - -In a browser: - -1. Sign in with the seeded admin user. -2. `/` renders the artists list with counts and "Load more" if more than 50 artists exist. -3. Change sort dropdown → URL shows `?sort=newest`, list reorders. -4. Click an artist → albums grid renders with covers (some may show the fallback glyph — that's expected for albums without embedded cover art). -5. Click an album → hero + track list. Duration format looks right (`3:42` for short, `1:02:03` for long). -6. Click the back link on the album page → returns to the artist (not to `/`). -7. Visit `/artists/00000000-0000-0000-0000-000000000000` → "Artist not found" card. -8. Visit `/albums/00000000-0000-0000-0000-000000000000` → "Album not found" card. -9. Refresh `/artists/` → Shell renders without login flash; data refetches and renders. - -Tear down: - -```bash -docker compose down -``` - -- [ ] **Step 6: Finish the branch** - -Follow `superpowers:finishing-a-development-branch`. Expected path: Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green. - ---- - -## Self-Review Notes - -**Spec coverage check:** -- API types → Task 1 -- `queries.ts` helpers + query-key convention → Task 5 -- Cover URL + fallback → Task 3 -- Duration formatter → Task 2 -- Delayed skeleton helper → Task 4 -- ArtistRow / AlbumCard / TrackRow / LibrarySkeleton / ApiErrorBanner → Tasks 6–10 -- Test-utils mocks → Task 11 (introduced alongside the first page that needs them) -- Artists list page (replace `/` placeholder) → Task 11 -- Artist detail page → Task 12 -- Album detail page → Task 13 -- Final verification + branch finish → Task 14 - -All spec sections map to tasks. - -**Type consistency:** -- `ArtistRef`, `AlbumRef`, `TrackRef`, `ArtistDetail`, `AlbumDetail`, `Page` — defined in Task 1, used identically in Tasks 5–13. -- `ArtistSort` — exported from `queries.ts` (Task 5), consumed in Task 11. -- `ApiError` — re-imported from `$lib/api/client` (auth plan) in Tasks 10, 12, 13. -- `createArtistsQuery` / `createArtistQuery` / `createAlbumQuery` signatures consistent between Task 5 and consuming tasks. -- `qk.artists(sort)` / `qk.artist(id)` / `qk.album(id)` — mocked-out in page tests exactly as defined. -- `FALLBACK_COVER` — defined in Task 3, used in Tasks 7 (AlbumCard) and 13 (album page hero). -- `formatDuration(seconds)` — defined in Task 2, used in Task 8 (TrackRow test and component) and Task 13. -- `useDelayed(getValue, delayMs?)` — defined in Task 4, used in Tasks 11, 12, 13. - -**Filename hazards addressed:** No page test is named `+page.test.ts` (auth plan's lesson). Route-colocated tests use `artist.test.ts` / `album.test.ts`; the artists list test lives at `src/routes/artists.test.ts` (outside the `+` route-walker). diff --git a/docs/superpowers/plans/2026-04-24-web-ui-player.md b/docs/superpowers/plans/2026-04-24-web-ui-player.md deleted file mode 100644 index 32caee1e..00000000 --- a/docs/superpowers/plans/2026-04-24-web-ui-player.md +++ /dev/null @@ -1,1698 +0,0 @@ -# Web UI Player Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Ship queue-based audio playback in the SPA — click a track on an album, the rest of the album queues, the bottom bar shows progress + controls, MediaSession populates OS media UI, and shuffle/repeat/volume all work. - -**Architecture:** A single Svelte 5 rune store in `lib/player/store.svelte.ts` holds all player state. A single `
- {:else} -
-
- - {formatDuration(player.position)} - - - - {formatDuration(player.duration)} - -
-
- - - -
-
- {/if} - - -
- - - -
-
-{/if} -``` - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/components/PlayerBar.test.ts` -Expected: PASS — 12 tests. - -- [ ] **Step 5: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 6: Commit** - -```bash -git add web/src/lib/components/PlayerBar.svelte web/src/lib/components/PlayerBar.test.ts -git commit -m "feat(web): add PlayerBar component - -Bottom-bar UI with left: cover+title+linked artist, center: stacked -seek row (elapsed / slider / duration) + transport (prev/play/next), -right: shuffle/repeat/volume. Loading shows spinner; error replaces -transport with retry card." -``` - ---- - -## Task 6: Modify `Shell` — 3rd grid row for PlayerBar - -**Files:** -- Modify: `web/src/lib/components/Shell.svelte` - -- [ ] **Step 1: Read the current Shell.svelte** - -Run: `cat web/src/lib/components/Shell.svelte` - -Locate the outer `
` with classes `grid h-screen grid-cols-[auto_1fr] grid-rows-[auto_1fr]`. - -- [ ] **Step 2: Modify `Shell.svelte`** - -Three changes: - -1. Add a script import: `import PlayerBar from './PlayerBar.svelte';` and `import { player } from '$lib/player/store.svelte';`. -2. Change `grid-rows-[auto_1fr]` → `grid-rows-[auto_1fr_auto]`. -3. Add, immediately before the closing `
` of the grid wrapper: - -```svelte -{#if player.current} -
- -
-{/if} -``` - -Full script block updated: - -```svelte - -``` - -And the outer grid div: - -```svelte -
- - - {#if player.current} -
- -
- {/if} -
-``` - -- [ ] **Step 3: Run the Shell tests** - -Run: `cd web && npm test -- src/lib/components/Shell.test.ts` -Expected: PASS — existing 3 tests still pass (PlayerBar conditionally absent when `player.current` is undefined, which is the default in those tests). - -- [ ] **Step 4: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/components/Shell.svelte -git commit -m "feat(web): add PlayerBar slot to Shell - -Grid grows to 3 rows (auto / 1fr / auto); PlayerBar spans both columns -in the last row, rendering only when player.current is defined so the -row collapses before first play." -``` - ---- - -## Task 7: Wire `
-``` - -- [ ] **Step 4: Run tests, confirm all pass** - -Run: `cd web && npm test -- src/lib/components/AlbumCard.test.ts` -Expected: PASS — 4 tests. - -- [ ] **Step 5: Run artist-detail page tests to confirm no regression** - -Run: `cd web && npm test -- 'src/routes/artists/[id]/artist.test.ts'` -Expected: PASS — 5 tests. - -- [ ] **Step 6: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 7: Commit** - -```bash -git add web/src/lib/components/AlbumCard.svelte web/src/lib/components/AlbumCard.test.ts -git commit -m "feat(web): AlbumCard + queue overlay button - -Wraps the existing link in a relative container; adds an absolutely -positioned + button on click stops propagation, fetches /api/albums/:id -once, and feeds tracks into enqueueTracks." -``` - ---- - -## Task 7: `SearchInput` component (header search box) - -**Files:** -- Create: `web/src/lib/components/SearchInput.svelte` -- Create: `web/src/lib/components/SearchInput.test.ts` - -The component reads `q` from `page.url.searchParams` to pre-fill, debounces typing 250 ms, and uses `goto()` to keep the URL synced. First navigation pushes a history entry; once on `/search`, subsequent typing replaces. `Escape` clears the input. Test file uses `.svelte.test.ts` because it relies on `flushSync` + `vi.useFakeTimers` to drive the debounce. - -- [ ] **Step 1: Write the failing tests** - -Create `web/src/lib/components/SearchInput.test.ts`: - -```ts -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; - -const state = vi.hoisted(() => ({ - pageUrl: new URL('http://localhost/') -})); - -vi.mock('$app/state', () => ({ - page: { get url() { return state.pageUrl; } } -})); - -vi.mock('$app/navigation', () => ({ - goto: vi.fn() -})); - -import SearchInput from './SearchInput.svelte'; -import { goto } from '$app/navigation'; - -beforeEach(() => { - vi.useFakeTimers(); - state.pageUrl = new URL('http://localhost/'); -}); - -afterEach(() => { - vi.clearAllMocks(); - vi.useRealTimers(); -}); - -describe('SearchInput', () => { - test('mount pre-fills value from page.url.searchParams', () => { - state.pageUrl = new URL('http://localhost/search?q=hello'); - render(SearchInput); - const input = screen.getByRole('searchbox') as HTMLInputElement; - expect(input.value).toBe('hello'); - }); - - test('typing fires goto once after 250ms debounce', async () => { - render(SearchInput); - const input = screen.getByRole('searchbox') as HTMLInputElement; - await fireEvent.input(input, { target: { value: 'h' } }); - expect(goto).not.toHaveBeenCalled(); - vi.advanceTimersByTime(250); - expect(goto).toHaveBeenCalledTimes(1); - expect(goto).toHaveBeenCalledWith('/search?q=h', { replaceState: false }); - }); - - test('rapid keystrokes debounce to a single goto', async () => { - render(SearchInput); - const input = screen.getByRole('searchbox') as HTMLInputElement; - await fireEvent.input(input, { target: { value: 'h' } }); - vi.advanceTimersByTime(100); - await fireEvent.input(input, { target: { value: 'he' } }); - vi.advanceTimersByTime(100); - await fireEvent.input(input, { target: { value: 'hel' } }); - vi.advanceTimersByTime(250); - expect(goto).toHaveBeenCalledTimes(1); - expect(goto).toHaveBeenCalledWith('/search?q=hel', { replaceState: false }); - }); - - test('typing while on /search uses replaceState=true', async () => { - state.pageUrl = new URL('http://localhost/search?q=h'); - render(SearchInput); - const input = screen.getByRole('searchbox') as HTMLInputElement; - await fireEvent.input(input, { target: { value: 'he' } }); - vi.advanceTimersByTime(250); - expect(goto).toHaveBeenCalledWith('/search?q=he', { replaceState: true }); - }); - - test('clearing input on /search calls goto("/search") (drops ?q=)', async () => { - state.pageUrl = new URL('http://localhost/search?q=h'); - render(SearchInput); - const input = screen.getByRole('searchbox') as HTMLInputElement; - await fireEvent.input(input, { target: { value: '' } }); - vi.advanceTimersByTime(250); - expect(goto).toHaveBeenCalledWith('/search', { replaceState: true }); - }); - - test('Escape clears the input value', async () => { - render(SearchInput); - const input = screen.getByRole('searchbox') as HTMLInputElement; - await fireEvent.input(input, { target: { value: 'hello' } }); - expect(input.value).toBe('hello'); - await fireEvent.keyDown(input, { key: 'Escape' }); - expect(input.value).toBe(''); - }); - - test('encodeURIComponent applied to special chars', async () => { - render(SearchInput); - const input = screen.getByRole('searchbox') as HTMLInputElement; - await fireEvent.input(input, { target: { value: 'a b&c' } }); - vi.advanceTimersByTime(250); - expect(goto).toHaveBeenCalledWith('/search?q=a%20b%26c', { replaceState: false }); - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/lib/components/SearchInput.test.ts` -Expected: FAIL — `SearchInput.svelte` doesn't exist. - -- [ ] **Step 3: Implement `web/src/lib/components/SearchInput.svelte`** - -```svelte - - - -``` - -Note on the `$effect` block: it mirrors URL → input. The opposite direction (input → URL) lives in `navigate`. The two writes don't loop because the input change handler always sets `value` *before* scheduling navigation, and the effect compares before assigning. - -- [ ] **Step 4: Run tests, confirm they pass** - -Run: `cd web && npm test -- src/lib/components/SearchInput.test.ts` -Expected: PASS — 7 tests. - -- [ ] **Step 5: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 6: Commit** - -```bash -git add web/src/lib/components/SearchInput.svelte web/src/lib/components/SearchInput.test.ts -git commit -m "feat(web): add SearchInput header component (debounced URL sync) - -Pre-fills from page.url.searchParams.get('q'); typing debounces 250ms -then goto('/search?q=…'); first nav from elsewhere pushes, subsequent -typing on /search replaces. Empty input on /search drops the param. -Escape clears the input. URL → value re-sync via \$effect handles -external navigation (back/forward, result-link clicks)." -``` - ---- - -## Task 8: `SearchSkeleton` component - -**Files:** -- Create: `web/src/lib/components/SearchSkeleton.svelte` - -Three small placeholder sections matching the shape of the rendered results page. No tests — pure markup mirroring `LibrarySkeleton` patterns. - -- [ ] **Step 1: Create `web/src/lib/components/SearchSkeleton.svelte`** - -```svelte - - -
-
-
-
- {#each Array.from({ length: 4 }) as _, i (i)} -
- - - -
- {/each} -
-
- -
-
-
- {#each Array.from({ length: 5 }) as _, i (i)} -
-
-
-
-
- {/each} -
-
- -
-
-
- {#each Array.from({ length: 6 }) as _, i (i)} -
- {/each} -
-
-
-``` - -- [ ] **Step 2: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 3: Commit** - -```bash -git add web/src/lib/components/SearchSkeleton.svelte -git commit -m "feat(web): add SearchSkeleton placeholder for the search page" -``` - ---- - -## Task 9: Wire `SearchInput` into `Shell.svelte` - -**Files:** -- Modify: `web/src/lib/components/Shell.svelte` - -Existing Shell test (3 cases) must still pass. - -- [ ] **Step 1: Inspect the current Shell** - -Run: `cat web/src/lib/components/Shell.svelte` -Locate the header `
Minstrel
`. - -- [ ] **Step 2: Modify `web/src/lib/components/Shell.svelte`** - -Change two things in the script block — add the import at the top with the others: - -```ts -import SearchInput from './SearchInput.svelte'; -``` - -In the template, find the header section: - -```svelte -
-
Minstrel
-
- -
-
-``` - -Replace with: - -```svelte -
-
Minstrel
-
- -
-
- -
-
-``` - -Keep the existing user-menu `
` block intact. - -- [ ] **Step 3: Run Shell tests to confirm no regression** - -Run: `cd web && npm test -- src/lib/components/Shell.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 4: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 5: Commit** - -```bash -git add web/src/lib/components/Shell.svelte -git commit -m "feat(web): mount SearchInput in Shell header - -Header grows a flex-1 column between the brand and the user menu. -Existing Shell tests remain green (the search input has no impact on -nav/menu behavior)." -``` - ---- - -## Task 10: `/search/+page.svelte` — main results page - -**Files:** -- Create: `web/src/routes/search/+page.svelte` (overwrites the current placeholder) -- Create: `web/src/routes/search/search.test.ts` - -The page reads `?q=` reactively, runs `createSearchQuery`, renders three sections (Artists / Albums / Tracks). Empty facets are skipped. "See all N →" link appears when `total > items.length`. Empty `?q=` shows a "Start typing" prompt and fires no query. Track click triggers `playRadio(track.id)` via the `onPlay` prop. - -- [ ] **Step 1: Write the failing tests** - -Create `web/src/routes/search/search.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import { mockQuery } from '../../test-utils/query'; -import type { ArtistRef, AlbumRef, TrackRef, SearchResponse } from '$lib/api/types'; - -const state = vi.hoisted(() => ({ - pageUrl: new URL('http://localhost/search') -})); - -vi.mock('$app/state', () => ({ - page: { get url() { return state.pageUrl; } } -})); - -vi.mock('$lib/api/queries', () => ({ - createSearchQuery: vi.fn() -})); - -vi.mock('$lib/player/store.svelte', () => ({ - playQueue: vi.fn(), - playRadio: vi.fn(), - enqueueTrack: vi.fn(), - enqueueTracks: vi.fn() -})); - -import SearchPage from './+page.svelte'; -import { createSearchQuery } from '$lib/api/queries'; - -function emptyResp(): SearchResponse { - return { - artists: { items: [], total: 0, limit: 10, offset: 0 }, - albums: { items: [], total: 0, limit: 10, offset: 0 }, - tracks: { items: [], total: 0, limit: 10, offset: 0 } - }; -} - -const artist: ArtistRef = { id: 'a1', name: 'Miles Davis', album_count: 12 }; -const album: AlbumRef = { - id: 'al1', title: 'Kind of Blue', artist_id: 'a1', artist_name: 'Miles Davis', - year: 1959, track_count: 5, duration_sec: 2630, cover_url: '/api/albums/al1/cover' -}; -const track: TrackRef = { - id: 't1', title: 'So What', - album_id: 'al1', album_title: 'Kind of Blue', - artist_id: 'a1', artist_name: 'Miles Davis', - track_number: 1, disc_number: 1, duration_sec: 545, - stream_url: '/api/tracks/t1/stream' -}; - -afterEach(() => { - vi.clearAllMocks(); - state.pageUrl = new URL('http://localhost/search'); -}); - -describe('search page', () => { - test('empty ?q= shows the "Start typing" prompt and fires no query', () => { - state.pageUrl = new URL('http://localhost/search'); - render(SearchPage); - expect(screen.getByText(/start typing/i)).toBeInTheDocument(); - expect(createSearchQuery).not.toHaveBeenCalled(); - }); - - test('all three sections render when all three facets have items', () => { - state.pageUrl = new URL('http://localhost/search?q=miles'); - const resp: SearchResponse = { - ...emptyResp(), - artists: { items: [artist], total: 1, limit: 10, offset: 0 }, - albums: { items: [album], total: 1, limit: 10, offset: 0 }, - tracks: { items: [track], total: 1, limit: 10, offset: 0 } - }; - (createSearchQuery as ReturnType).mockReturnValue(mockQuery({ data: resp })); - render(SearchPage); - expect(screen.getByRole('heading', { name: /artists/i })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: /albums/i })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: /tracks/i })).toBeInTheDocument(); - }); - - test('empty facets are hidden', () => { - state.pageUrl = new URL('http://localhost/search?q=miles'); - const resp: SearchResponse = { - ...emptyResp(), - tracks: { items: [track], total: 1, limit: 10, offset: 0 } - }; - (createSearchQuery as ReturnType).mockReturnValue(mockQuery({ data: resp })); - render(SearchPage); - expect(screen.queryByRole('heading', { name: /artists/i })).not.toBeInTheDocument(); - expect(screen.queryByRole('heading', { name: /albums/i })).not.toBeInTheDocument(); - expect(screen.getByRole('heading', { name: /tracks/i })).toBeInTheDocument(); - }); - - test('"See all N →" link renders when total > items.length', () => { - state.pageUrl = new URL('http://localhost/search?q=miles'); - const resp: SearchResponse = { - ...emptyResp(), - artists: { items: [artist], total: 47, limit: 10, offset: 0 } - }; - (createSearchQuery as ReturnType).mockReturnValue(mockQuery({ data: resp })); - render(SearchPage); - const link = screen.getByRole('link', { name: /see all 47/i }); - expect(link).toHaveAttribute('href', '/search/artists?q=miles'); - }); - - test('"See all" link omitted when total === items.length', () => { - state.pageUrl = new URL('http://localhost/search?q=miles'); - const resp: SearchResponse = { - ...emptyResp(), - tracks: { items: [track], total: 1, limit: 10, offset: 0 } - }; - (createSearchQuery as ReturnType).mockReturnValue(mockQuery({ data: resp })); - render(SearchPage); - expect(screen.queryByRole('link', { name: /see all/i })).not.toBeInTheDocument(); - }); - - test('all three facets empty renders "No matches"', () => { - state.pageUrl = new URL('http://localhost/search?q=zzz'); - (createSearchQuery as ReturnType).mockReturnValue( - mockQuery({ data: emptyResp() }) - ); - render(SearchPage); - expect(screen.getByText(/no matches for/i)).toBeInTheDocument(); - expect(screen.getByText(/zzz/)).toBeInTheDocument(); - }); - - test('error state renders ApiErrorBanner', () => { - state.pageUrl = new URL('http://localhost/search?q=miles'); - (createSearchQuery as ReturnType).mockReturnValue( - mockQuery({ isError: true, error: { code: 'server_error', message: 'boom' } }) - ); - render(SearchPage); - expect(screen.getByRole('alert')).toBeInTheDocument(); - }); -}); -``` - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: `cd web && npm test -- src/routes/search/search.test.ts` -Expected: FAIL — page is the placeholder; no sections, no prompt copy. - -- [ ] **Step 3: Replace `web/src/routes/search/+page.svelte`** - -```svelte - - -
- {#if !q} -

- Start typing to search artists, albums, and tracks. -

- {:else if query?.isError} - - {:else if showSkeleton.value && !query?.data} - - {:else if allEmpty} -

- No matches for '{q}'. -

- {:else if query?.data} - {#if artists && artists.items.length > 0} -
-
-

Artists

- {#if artists.total > artists.items.length} - - See all {artists.total} → - - {/if} -
-
- {#each artists.items as a (a.id)} - - {/each} -
-
- {/if} - - {#if albums && albums.items.length > 0} -
-
-

Albums

- {#if albums.total > albums.items.length} - - See all {albums.total} → - - {/if} -
-
- {#each albums.items as al (al.id)} - - {/each} -
-
- {/if} - - {#if tracks && tracks.items.length > 0} -
-
-

Tracks

- {#if tracks.total > tracks.items.length} - - See all {tracks.total} → - - {/if} -
-
- {#each tracks.items as t, i (t.id)} - - {/each} -
-
- {/if} - {/if} -
-``` - -- [ ] **Step 4: Run tests, confirm all pass** - -Run: `cd web && npm test -- src/routes/search/search.test.ts` -Expected: PASS — 7 tests. - -- [ ] **Step 5: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 6: Commit** - -```bash -git add web/src/routes/search/+page.svelte web/src/routes/search/search.test.ts -git commit -m "feat(web): /search page with three sections + see-all overflow links - -Reads ?q= reactively; runs createSearchQuery (limit=10) when q is -non-empty. Renders Artists/Albums/Tracks sections reusing ArtistRow, -AlbumCard, TrackRow. Track click uses onPlay prop to call -playRadio(track.id) instead of the default playQueue. Empty facets -hide; 'See all N →' link points to overflow pages when total > items. -Empty q shows 'Start typing'; error → ApiErrorBanner; loading → -SearchSkeleton via useDelayed." -``` - ---- - -## Task 11: Three overflow pages (`/search/artists`, `/search/albums`, `/search/tracks`) - -**Files:** -- Create: `web/src/routes/search/artists/+page.svelte` -- Create: `web/src/routes/search/artists/artists.test.ts` -- Create: `web/src/routes/search/albums/+page.svelte` -- Create: `web/src/routes/search/albums/albums.test.ts` -- Create: `web/src/routes/search/tracks/+page.svelte` -- Create: `web/src/routes/search/tracks/tracks.test.ts` - -Each page is the same shape: read `?q=`, run the matching infinite query, render its facet, "Load more" → `fetchNextPage()`. Tests follow the same shape as `web/src/routes/artists.test.ts`. - -- [ ] **Step 1: Write the artists overflow tests** - -Create `web/src/routes/search/artists/artists.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; -import { mockInfiniteQuery } from '../../../test-utils/query'; -import type { ArtistRef, Page } from '$lib/api/types'; - -const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/search/artists?q=miles') })); - -vi.mock('$app/state', () => ({ - page: { get url() { return state.pageUrl; } } -})); - -vi.mock('$lib/api/queries', () => ({ - createSearchArtistsInfiniteQuery: vi.fn() -})); - -import ArtistsOverflow from './+page.svelte'; -import { createSearchArtistsInfiniteQuery } from '$lib/api/queries'; - -function page(items: T[], total: number, offset = 0, limit = 50): Page { - return { items, total, offset, limit }; -} - -afterEach(() => { - vi.clearAllMocks(); - state.pageUrl = new URL('http://localhost/search/artists?q=miles'); -}); - -describe('search artists overflow', () => { - test('renders a row per artist', () => { - const a1: ArtistRef = { id: 'a1', name: 'Miles Davis', album_count: 12 }; - const a2: ArtistRef = { id: 'a2', name: 'Miles Mosley', album_count: 1 }; - (createSearchArtistsInfiniteQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([a1, a2], 2)] }) - ); - render(ArtistsOverflow); - expect(screen.getByRole('link', { name: /Miles Davis/ })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: /Miles Mosley/ })).toBeInTheDocument(); - }); - - test('Load more calls fetchNextPage when hasNextPage', async () => { - const fetchNextPage = vi.fn(); - (createSearchArtistsInfiniteQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ - pages: [page([], 100)], - hasNextPage: true, - fetchNextPage - }) - ); - render(ArtistsOverflow); - await fireEvent.click(screen.getByRole('button', { name: /load more/i })); - expect(fetchNextPage).toHaveBeenCalledTimes(1); - }); - - test('Load more is hidden when hasNextPage is false', () => { - (createSearchArtistsInfiniteQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([{ id: 'a', name: 'A', album_count: 1 }], 1)] }) - ); - render(ArtistsOverflow); - expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); - }); - - test('empty q shows a prompt and fires no query', () => { - state.pageUrl = new URL('http://localhost/search/artists'); - render(ArtistsOverflow); - expect(screen.getByText(/no query/i)).toBeInTheDocument(); - expect(createSearchArtistsInfiniteQuery).not.toHaveBeenCalled(); - }); -}); -``` - -- [ ] **Step 2: Run, confirm fail** - -Run: `cd web && npm test -- 'src/routes/search/artists/artists.test.ts'` -Expected: FAIL — page doesn't exist. - -- [ ] **Step 3: Implement `web/src/routes/search/artists/+page.svelte`** - -```svelte - - -
-
- - ← Back to search - -

Artists matching '{q}'

- {#if !query?.isPending && !query?.isError && q} -

{total} {total === 1 ? 'artist' : 'artists'}

- {/if} -
- - {#if !q} -

No query — return to search to start typing.

- {:else if query?.isError} - - {:else if showSkeleton.value && artists.length === 0} - - {:else if artists.length === 0} -

No artist matches.

- {:else} -
- {#each artists as a (a.id)} - - {/each} -
- {#if query?.hasNextPage} - - {/if} - {/if} -
-``` - -- [ ] **Step 4: Run, confirm pass** - -Run: `cd web && npm test -- 'src/routes/search/artists/artists.test.ts'` -Expected: PASS — 4 tests. - -- [ ] **Step 5: Write the albums overflow tests** - -Create `web/src/routes/search/albums/albums.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; -import { mockInfiniteQuery } from '../../../test-utils/query'; -import type { AlbumRef, Page } from '$lib/api/types'; - -const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/search/albums?q=miles') })); - -vi.mock('$app/state', () => ({ - page: { get url() { return state.pageUrl; } } -})); - -vi.mock('$lib/api/queries', () => ({ - createSearchAlbumsInfiniteQuery: vi.fn() -})); - -vi.mock('$lib/api/client', () => ({ - api: { get: vi.fn() } -})); -vi.mock('$lib/player/store.svelte', () => ({ - enqueueTracks: vi.fn() -})); - -import AlbumsOverflow from './+page.svelte'; -import { createSearchAlbumsInfiniteQuery } from '$lib/api/queries'; - -function page(items: T[], total: number, offset = 0, limit = 50): Page { - return { items, total, offset, limit }; -} - -const album: AlbumRef = { - id: 'al1', title: 'Kind of Blue', artist_id: 'a1', artist_name: 'Miles Davis', - year: 1959, track_count: 5, duration_sec: 2630, cover_url: '/api/albums/al1/cover' -}; - -afterEach(() => { - vi.clearAllMocks(); - state.pageUrl = new URL('http://localhost/search/albums?q=miles'); -}); - -describe('search albums overflow', () => { - test('renders an AlbumCard per album', () => { - (createSearchAlbumsInfiniteQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([album], 1)] }) - ); - render(AlbumsOverflow); - expect(screen.getByRole('link', { name: /Kind of Blue/ })).toHaveAttribute('href', '/albums/al1'); - }); - - test('Load more calls fetchNextPage', async () => { - const fetchNextPage = vi.fn(); - (createSearchAlbumsInfiniteQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ - pages: [page([], 100)], - hasNextPage: true, - fetchNextPage - }) - ); - render(AlbumsOverflow); - await fireEvent.click(screen.getByRole('button', { name: /load more/i })); - expect(fetchNextPage).toHaveBeenCalledTimes(1); - }); - - test('empty q shows a prompt', () => { - state.pageUrl = new URL('http://localhost/search/albums'); - render(AlbumsOverflow); - expect(screen.getByText(/no query/i)).toBeInTheDocument(); - }); -}); -``` - -- [ ] **Step 6: Run, confirm fail** - -Run: `cd web && npm test -- 'src/routes/search/albums/albums.test.ts'` -Expected: FAIL. - -- [ ] **Step 7: Implement `web/src/routes/search/albums/+page.svelte`** - -```svelte - - -
-
- - ← Back to search - -

Albums matching '{q}'

- {#if !query?.isPending && !query?.isError && q} -

{total} {total === 1 ? 'album' : 'albums'}

- {/if} -
- - {#if !q} -

No query — return to search to start typing.

- {:else if query?.isError} - - {:else if showSkeleton.value && albums.length === 0} - - {:else if albums.length === 0} -

No album matches.

- {:else} -
- {#each albums as a (a.id)} - - {/each} -
- {#if query?.hasNextPage} - - {/if} - {/if} -
-``` - -- [ ] **Step 8: Run, confirm pass** - -Run: `cd web && npm test -- 'src/routes/search/albums/albums.test.ts'` -Expected: PASS — 3 tests. - -- [ ] **Step 9: Write the tracks overflow tests** - -Create `web/src/routes/search/tracks/tracks.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; -import { mockInfiniteQuery } from '../../../test-utils/query'; -import type { TrackRef, Page } from '$lib/api/types'; - -const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/search/tracks?q=miles') })); - -vi.mock('$app/state', () => ({ - page: { get url() { return state.pageUrl; } } -})); - -vi.mock('$lib/api/queries', () => ({ - createSearchTracksInfiniteQuery: vi.fn() -})); - -vi.mock('$lib/player/store.svelte', () => ({ - playQueue: vi.fn(), - playRadio: vi.fn(), - enqueueTrack: vi.fn() -})); - -import TracksOverflow from './+page.svelte'; -import { createSearchTracksInfiniteQuery } from '$lib/api/queries'; -import { playRadio } from '$lib/player/store.svelte'; - -function page(items: T[], total: number, offset = 0, limit = 50): Page { - return { items, total, offset, limit }; -} - -const track: TrackRef = { - id: 't1', title: 'So What', - album_id: 'al1', album_title: 'Kind of Blue', - artist_id: 'a1', artist_name: 'Miles Davis', - track_number: 1, disc_number: 1, duration_sec: 545, - stream_url: '/api/tracks/t1/stream' -}; - -afterEach(() => { - vi.clearAllMocks(); - state.pageUrl = new URL('http://localhost/search/tracks?q=miles'); -}); - -describe('search tracks overflow', () => { - test('renders a TrackRow per track', () => { - (createSearchTracksInfiniteQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([track], 1)] }) - ); - render(TracksOverflow); - expect(screen.getByRole('button', { name: /So What/ })).toBeInTheDocument(); - }); - - test('clicking a track row triggers playRadio with the track id', async () => { - (createSearchTracksInfiniteQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([track], 1)] }) - ); - render(TracksOverflow); - await fireEvent.click(screen.getByRole('button', { name: /So What/ })); - expect(playRadio).toHaveBeenCalledWith('t1'); - }); - - test('Load more calls fetchNextPage', async () => { - const fetchNextPage = vi.fn(); - (createSearchTracksInfiniteQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ - pages: [page([], 100)], - hasNextPage: true, - fetchNextPage - }) - ); - render(TracksOverflow); - await fireEvent.click(screen.getByRole('button', { name: /load more/i })); - expect(fetchNextPage).toHaveBeenCalledTimes(1); - }); - - test('empty q shows a prompt', () => { - state.pageUrl = new URL('http://localhost/search/tracks'); - render(TracksOverflow); - expect(screen.getByText(/no query/i)).toBeInTheDocument(); - }); -}); -``` - -- [ ] **Step 10: Run, confirm fail** - -Run: `cd web && npm test -- 'src/routes/search/tracks/tracks.test.ts'` -Expected: FAIL. - -- [ ] **Step 11: Implement `web/src/routes/search/tracks/+page.svelte`** - -```svelte - - -
-
- - ← Back to search - -

Tracks matching '{q}'

- {#if !query?.isPending && !query?.isError && q} -

{total} {total === 1 ? 'track' : 'tracks'}

- {/if} -
- - {#if !q} -

No query — return to search to start typing.

- {:else if query?.isError} - - {:else if showSkeleton.value && tracks.length === 0} - - {:else if tracks.length === 0} -

No track matches.

- {:else} -
- {#each tracks as t, i (t.id)} - - {/each} -
- {#if query?.hasNextPage} - - {/if} - {/if} -
-``` - -- [ ] **Step 12: Run, confirm pass** - -Run: `cd web && npm test -- 'src/routes/search/tracks/tracks.test.ts'` -Expected: PASS — 4 tests. - -- [ ] **Step 13: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 14: Commit** - -```bash -git add web/src/routes/search/artists web/src/routes/search/albums web/src/routes/search/tracks -git commit -m "feat(web): add three search overflow pages - -/search/artists, /search/albums, /search/tracks each read ?q= and run -their own createSearchInfiniteQuery (limit=50). Load more pulls the -next offset; back link returns to /search?q=. Empty q shows a 'no -query' prompt and fires no request. Tracks overflow uses the onPlay -prop on TrackRow to call playRadio(track.id)." -``` - ---- - -## Task 12: Final verification + branch finish - -**Files:** none (verification only). - -- [ ] **Step 1: Full Go suite** - -Run: `go test -short -race ./...` -Expected: PASS. - -- [ ] **Step 2: Go linter** - -Run: `golangci-lint run ./...` -Expected: clean. - -- [ ] **Step 3: Web check + full tests + build** - -Run: `cd web && npm run check && npm test && npm run build` -Expected: check 0/0; all vitest files pass; build emits `web/build/`. - -Run: `git checkout -- web/build/index.html` -Expected: committed placeholder restored. - -- [ ] **Step 4: Docker build smoke** - -Run: `docker build -t minstrel:search-smoke .` -Expected: image builds. - -Run: `docker run --rm --entrypoint /bin/sh minstrel:search-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'` -Expected: `ok`. - -- [ ] **Step 5: Local end-to-end manual check** - -Bring up the stack: - -```bash -docker compose up --build -d -``` - -In a browser at `localhost:4533`: - -1. Sign in. Header now has a search box between brand and the user-menu. -2. From the home page, type `mil` (or any string with multiple library matches). After ~250ms the URL becomes `/search?q=mil` and three sections appear (or fewer if some facets match nothing). -3. Empty matches: type `zzzqqq` — page shows "No matches for 'zzzqqq'". -4. Backspace through the input until empty: URL drops `?q=`; main `/search` page shows "Start typing". -5. Click the `+` on a track result → bottom-bar player updates queue length but does NOT auto-play (or, if a track was already playing, current playback continues). -6. Click a track result (the row, not the `+`) → radio plays (one-track queue, since it's the M6 stub). -7. Click the `+` on an album card → all of that album's tracks are appended; current playback continues. -8. Click "See all 47 artists →" → overflow page loads; "Load more" pulls the next page; back link returns to `/search?q=…`. -9. Click an artist on `/search?q=…` → navigates to `/artists/:id`. The header search box still shows "mil". -10. Browser back → returns to `/search?q=mil` with cached results. -11. Press Escape with the input focused → input clears and URL clears. - -Tear down: - -```bash -docker compose down -``` - -- [ ] **Step 6: Finish the branch** - -Follow `superpowers:finishing-a-development-branch`. Expected path: Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green. - ---- - -## Self-Review Notes - -**Spec coverage:** -- Goal / non-goals → Task structure honors the non-goals (no autocomplete, no Cmd-K, no recent searches). -- Header search bar → Task 7 (component) + Task 9 (Shell wiring). -- Live debounced URL sync → Task 7 with debounce/replaceState/Escape behavior covered by tests. -- `/search` page with three sections + see-all links → Task 10. -- Three overflow pages → Task 11. -- `/api/radio` stub → Task 1 (Go handler + 5 tests covering 200/404/missing/blank/bad-uuid). -- `enqueueTrack` / `enqueueTracks` / `playRadio` → Task 3. -- TrackRow `
` refactor + `onPlay` + `+ queue` → Task 5. -- AlbumCard `+ queue` → Task 6. -- States (empty/loading/error/no-results) → covered in Tasks 10 and 11. -- Component tests → each task has its own. Manual end-to-end → Task 12 Step 5. -- Risks → addressed in spec; the TrackRow refactor risk is mitigated by re-running the album-page tests in Task 5 Step 5. - -**Type consistency:** -- `SearchResponse`, `RadioResponse`, `Page`, `ArtistRef`, `AlbumRef`, `TrackRef`, `AlbumDetail` — defined once in `types.ts`; consumed identically in queries, store, and pages. -- `playRadio(seedTrackId: string)` — same signature in spec, store, and call sites. -- `enqueueTrack(t: TrackRef)`, `enqueueTracks(ts: TrackRef[])` — consistent. -- `qk.search`, `qk.searchArtists`, `qk.searchAlbums`, `qk.searchTracks` — consistent across queries.ts and tests. -- `onPlay?: (tracks: TrackRef[], index: number) => void` — same shape in `TrackRow.svelte`, search page, and tracks overflow page. -- `SEARCH_SUMMARY_LIMIT = 10` and `SEARCH_FACET_PAGE_SIZE = 50` — used only inside `queries.ts`; not surfaced to UI tests. - -**Filename hazards:** -- No `+`-prefixed test files under route dirs. Search route tests use plain names: `search.test.ts`, `artists.test.ts`, `albums.test.ts`, `tracks.test.ts`. -- `SearchInput.test.ts` is plain `.test.ts` because the test body itself uses no rune syntax (only the imported component does); `.svelte.test.ts` is reserved for tests like `useDelayed` where the test body declares `$state`/`$effect.root` directly. - -**Placeholder scan:** no TBD/TODO/later markers. Every code block is complete; every command has expected output. - -**TrackRow refactor risk:** acknowledged; Task 5 re-runs the album page tests as Step 5 to catch any regression before commit. diff --git a/docs/superpowers/plans/2026-04-26-m2-likes.md b/docs/superpowers/plans/2026-04-26-m2-likes.md deleted file mode 100644 index acf420b4..00000000 --- a/docs/superpowers/plans/2026-04-26-m2-likes.md +++ /dev/null @@ -1,2495 +0,0 @@ -# M2 Likes Sub-Plan Implementation - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Ship liking end-to-end across all three entity types (track / album / artist), wired through both the native `/api/*` surface and the Subsonic `/rest/*` compatibility surface, with web UI heart buttons everywhere a track/album/artist is rendered, plus a dedicated `/library/liked` page. Closes M2. - -**Architecture:** Three new tables (one per entity type) keyed on `(user_id, entity_id)` with `liked_at`. Native `/api/likes/{tracks,albums,artists}/:id` endpoint families (POST/DELETE/GET) plus `/api/likes/ids` for client-side heart caching. Subsonic `/rest/star`, `/rest/unstar`, `/rest/getStarred`, `/rest/getStarred2` route through the same per-table writes with validate-all-first atomicity. Web client uses one `LikeButton.svelte` reading a single `createLikedIdsQuery()` cache; mutations do optimistic updates with rollback. - -**Tech Stack:** Go 1.23 + chi + sqlc + pgx/v5. SvelteKit 2 + Svelte 5 (runes) + TypeScript + TanStack Query (Svelte adapter) + Vitest + Testing Library. - -**Reference:** design spec at `docs/superpowers/specs/2026-04-26-m2-likes-design.md`. - ---- - -## File Structure - -**New server files:** - -| File | Responsibility | -|---|---| -| `internal/db/migrations/0006_likes.up.sql` + `.down.sql` | Schema for `general_likes`, `general_likes_albums`, `general_likes_artists`. | -| `internal/db/queries/likes.sql` | sqlc queries (like/unlike/list/count/list-ids per table). Generated bindings emitted into `internal/db/dbq/likes.sql.go`. | -| `internal/api/likes.go` | Native `/api/likes/...` handlers (3 like, 3 unlike, 3 list, 1 ids). | -| `internal/api/likes_test.go` | HTTP-level coverage. | -| `internal/subsonic/star.go` | `handleStar`, `handleUnstar`, `handleGetStarred`, `handleGetStarred2`. | -| `internal/subsonic/star_test.go` | Subsonic coverage. | - -**Modified server files:** - -| File | Change | -|---|---| -| `internal/api/api.go` | Register the seven new `/api/likes/...` routes inside the authed group. | -| `internal/subsonic/subsonic.go` | Register `/star`, `/unstar`, `/getStarred`, `/getStarred2`. | -| `internal/subsonic/types.go` | Add `StarredContainer` / `Starred2Container` types. | - -**New web files:** - -| File | Responsibility | -|---|---| -| `web/src/lib/api/likes.ts` | TanStack Query helpers + mutations. Single source for liked-id state. | -| `web/src/lib/api/likes.test.ts` | Mutation/cache-update unit tests. | -| `web/src/lib/components/LikeButton.svelte` | Heart icon button. Props `entityType: 'track' \| 'album' \| 'artist'`, `entityId: string`. | -| `web/src/lib/components/LikeButton.test.ts` | Component tests. | -| `web/src/routes/library/liked/+page.svelte` | Three sections (Artists/Albums/Tracks) with infinite queries. | -| `web/src/routes/library/liked/liked.test.ts` | Page integration tests. | - -**Modified web files:** - -| File | Change | -|---|---| -| `web/src/lib/api/types.ts` | Add `LikedIdsResponse`. | -| `web/src/lib/api/queries.ts` | Add `qk.likedIds`, `qk.likedTracks`, `qk.likedAlbums`, `qk.likedArtists`. | -| `web/src/lib/components/TrackRow.svelte` | Insert `` next to `+ queue`. Grid grows to `[32px_1fr_auto_auto_auto]`. | -| `web/src/lib/components/AlbumCard.svelte` | Add `` overlay. | -| `web/src/lib/components/ArtistRow.svelte` | Add `` before the chevron. | -| `web/src/lib/components/PlayerBar.svelte` | Add `` next to title. | -| `web/src/lib/components/Shell.svelte` | Add "Liked" entry to `navItems` pointing at `/library/liked`. | - ---- - -## Task 1: Schema migration + sqlc queries - -**Files:** -- Create: `internal/db/migrations/0006_likes.up.sql` -- Create: `internal/db/migrations/0006_likes.down.sql` -- Create: `internal/db/queries/likes.sql` -- Generated: `internal/db/dbq/likes.sql.go` (via `sqlc generate`) - -- [ ] **Step 1: Write the up migration** - -Create `internal/db/migrations/0006_likes.up.sql`: - -```sql --- M2 likes: three tables, one per entity type. Schema follows the spec §5 --- pattern (general_likes was originally track-only); this slice extends it --- to albums and artists for full Subsonic star/unstar support. - -CREATE TABLE general_likes ( - user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, - track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, - liked_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, track_id) -); -CREATE INDEX general_likes_user_liked_at_idx ON general_likes (user_id, liked_at DESC); - -CREATE TABLE general_likes_albums ( - user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, - album_id uuid NOT NULL REFERENCES albums(id) ON DELETE CASCADE, - liked_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, album_id) -); -CREATE INDEX general_likes_albums_user_liked_at_idx ON general_likes_albums (user_id, liked_at DESC); - -CREATE TABLE general_likes_artists ( - user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, - artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE, - liked_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, artist_id) -); -CREATE INDEX general_likes_artists_user_liked_at_idx ON general_likes_artists (user_id, liked_at DESC); -``` - -- [ ] **Step 2: Write the down migration** - -Create `internal/db/migrations/0006_likes.down.sql`: - -```sql -DROP TABLE IF EXISTS general_likes_artists; -DROP TABLE IF EXISTS general_likes_albums; -DROP TABLE IF EXISTS general_likes; -``` - -- [ ] **Step 3: Write the sqlc queries** - -Create `internal/db/queries/likes.sql`: - -```sql --- name: LikeTrack :exec -INSERT INTO general_likes (user_id, track_id) -VALUES ($1, $2) -ON CONFLICT (user_id, track_id) DO NOTHING; - --- name: UnlikeTrack :exec -DELETE FROM general_likes WHERE user_id = $1 AND track_id = $2; - --- name: ListLikedTrackRows :many -SELECT t.* FROM tracks t -JOIN general_likes l ON l.track_id = t.id -WHERE l.user_id = $1 -ORDER BY l.liked_at DESC -LIMIT $2 OFFSET $3; - --- name: CountLikedTracks :one -SELECT count(*) FROM general_likes WHERE user_id = $1; - --- name: ListLikedTrackIDs :many -SELECT track_id FROM general_likes WHERE user_id = $1 ORDER BY liked_at DESC; - --- name: LikeAlbum :exec -INSERT INTO general_likes_albums (user_id, album_id) -VALUES ($1, $2) -ON CONFLICT (user_id, album_id) DO NOTHING; - --- name: UnlikeAlbum :exec -DELETE FROM general_likes_albums WHERE user_id = $1 AND album_id = $2; - --- name: ListLikedAlbumRows :many -SELECT a.* FROM albums a -JOIN general_likes_albums l ON l.album_id = a.id -WHERE l.user_id = $1 -ORDER BY l.liked_at DESC -LIMIT $2 OFFSET $3; - --- name: CountLikedAlbums :one -SELECT count(*) FROM general_likes_albums WHERE user_id = $1; - --- name: ListLikedAlbumIDs :many -SELECT album_id FROM general_likes_albums WHERE user_id = $1 ORDER BY liked_at DESC; - --- name: LikeArtist :exec -INSERT INTO general_likes_artists (user_id, artist_id) -VALUES ($1, $2) -ON CONFLICT (user_id, artist_id) DO NOTHING; - --- name: UnlikeArtist :exec -DELETE FROM general_likes_artists WHERE user_id = $1 AND artist_id = $2; - --- name: ListLikedArtistRows :many -SELECT a.* FROM artists a -JOIN general_likes_artists l ON l.artist_id = a.id -WHERE l.user_id = $1 -ORDER BY l.liked_at DESC -LIMIT $2 OFFSET $3; - --- name: CountLikedArtists :one -SELECT count(*) FROM general_likes_artists WHERE user_id = $1; - --- name: ListLikedArtistIDs :many -SELECT artist_id FROM general_likes_artists WHERE user_id = $1 ORDER BY liked_at DESC; -``` - -- [ ] **Step 4: Generate sqlc bindings** - -Run: `$(go env GOPATH)/bin/sqlc generate` -Expected: writes `internal/db/dbq/likes.sql.go` with the new query functions; no other dbq files change beyond the version comment. - -- [ ] **Step 5: Verify build** - -Run: `go build ./...` -Expected: clean build. - -- [ ] **Step 6: Migration smoke test** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/db -run TestMigrate -v -``` -Expected: PASS — exercises 0006 up/down via the existing migration test. - -- [ ] **Step 7: Commit** - -```bash -git add internal/db/migrations/0006_likes.up.sql \ - internal/db/migrations/0006_likes.down.sql \ - internal/db/queries/likes.sql \ - internal/db/dbq/likes.sql.go \ - internal/db/dbq/models.go -git commit -m "feat(db): add M2 likes schema (general_likes, _albums, _artists) - -Three tables keyed on (user_id, entity_id) with liked_at. Per-table -indexes on (user_id, liked_at DESC) for the recently-liked feed. -sqlc queries cover like/unlike/list-rows/count/list-ids per entity." -``` - ---- - -## Task 2: Native `/api/likes/...` handlers - -**Files:** -- Create: `internal/api/likes.go` -- Create: `internal/api/likes_test.go` -- Modify: `internal/api/api.go` - -Single handler file owning all seven routes (3 like, 3 unlike, 3 list, 1 ids). Tests follow the existing `events_test.go` pattern with `userCtxKeyForTest()` injection. - -- [ ] **Step 1: Write failing tests** - -Create `internal/api/likes_test.go`: - -```go -package api - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -func callLike(h *handlers, user dbq.User, method, path string) *httptest.ResponseRecorder { - req := httptest.NewRequest(method, path, nil) - req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user)) - w := httptest.NewRecorder() - likesRouter(h).ServeHTTP(w, req) - return w -} - -// likesRouter matches the routes registered in Mount but without RequireUser -// so tests can inject the user via context directly. -func likesRouter(h *handlers) http.Handler { - r := chi.NewRouter() - r.Post("/api/likes/tracks/{id}", h.handleLikeTrack) - r.Delete("/api/likes/tracks/{id}", h.handleUnlikeTrack) - r.Post("/api/likes/albums/{id}", h.handleLikeAlbum) - r.Delete("/api/likes/albums/{id}", h.handleUnlikeAlbum) - r.Post("/api/likes/artists/{id}", h.handleLikeArtist) - r.Delete("/api/likes/artists/{id}", h.handleUnlikeArtist) - r.Get("/api/likes/tracks", h.handleListLikedTracks) - r.Get("/api/likes/albums", h.handleListLikedAlbums) - r.Get("/api/likes/artists", h.handleListLikedArtists) - r.Get("/api/likes/ids", h.handleGetLikedIDs) - return r -} - -func TestLikeTrack_Idempotent(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "Beatles") - album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969) - track := seedTrack(t, pool, album.ID, artist.ID, "Something", 3, 183_000) - - w := callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(track.ID)) - if w.Code != http.StatusNoContent { - t.Fatalf("first like: status = %d body=%s", w.Code, w.Body.String()) - } - w = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(track.ID)) - if w.Code != http.StatusNoContent { - t.Errorf("second like (idempotent): status = %d", w.Code) - } - var count int - _ = pool.QueryRow(context.Background(), - "SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count) - if count != 1 { - t.Errorf("rows = %d, want 1", count) - } -} - -func TestLikeTrack_BadUUIDIs400(t *testing.T) { - h, pool := testHandlers(t) - user := seedUser(t, pool, "alice", "x", false) - w := callLike(h, user, http.MethodPost, "/api/likes/tracks/not-a-uuid") - if w.Code != http.StatusBadRequest { - t.Errorf("status = %d", w.Code) - } -} - -func TestLikeTrack_UnknownTrackIs404(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - w := callLike(h, user, http.MethodPost, "/api/likes/tracks/00000000-0000-0000-0000-000000000000") - if w.Code != http.StatusNotFound { - t.Errorf("status = %d", w.Code) - } -} - -func TestUnlikeTrack_IdempotentEvenWhenAbsent(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "Beatles") - album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969) - track := seedTrack(t, pool, album.ID, artist.ID, "Something", 3, 183_000) - - w := callLike(h, user, http.MethodDelete, "/api/likes/tracks/"+uuidToString(track.ID)) - if w.Code != http.StatusNoContent { - t.Errorf("status = %d", w.Code) - } -} - -func TestListLikedTracks_PaginatedAndSortedDescByLikedAt(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - t1 := seedTrack(t, pool, album.ID, artist.ID, "First", 1, 100_000) - t2 := seedTrack(t, pool, album.ID, artist.ID, "Second", 2, 100_000) - - // Like t1 then t2 — newest should come first. - _ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(t1.ID)) - _ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(t2.ID)) - - w := callLike(h, user, http.MethodGet, "/api/likes/tracks?limit=10&offset=0") - if w.Code != http.StatusOK { - t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) - } - var resp struct { - Items []TrackRef `json:"items"` - Total int `json:"total"` - Limit int `json:"limit"` - Offset int `json:"offset"` - } - _ = json.Unmarshal(w.Body.Bytes(), &resp) - if resp.Total != 2 || len(resp.Items) != 2 { - t.Fatalf("shape: %+v", resp) - } - if resp.Items[0].Title != "Second" { - t.Errorf("first item = %q, want %q (most recent)", resp.Items[0].Title, "Second") - } -} - -func TestGetLikedIDs_ReturnsAllThreeArrays(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - track := seedTrack(t, pool, album.ID, artist.ID, "T", 1, 100_000) - - _ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(track.ID)) - _ = callLike(h, user, http.MethodPost, "/api/likes/albums/"+uuidToString(album.ID)) - _ = callLike(h, user, http.MethodPost, "/api/likes/artists/"+uuidToString(artist.ID)) - - w := callLike(h, user, http.MethodGet, "/api/likes/ids") - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var resp struct { - TrackIDs []string `json:"track_ids"` - AlbumIDs []string `json:"album_ids"` - ArtistIDs []string `json:"artist_ids"` - } - _ = json.Unmarshal(w.Body.Bytes(), &resp) - if len(resp.TrackIDs) != 1 || len(resp.AlbumIDs) != 1 || len(resp.ArtistIDs) != 1 { - t.Errorf("ids = %+v", resp) - } -} - -func TestGetLikedIDs_CrossUserIsolation(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - alice := seedUser(t, pool, "alice", "x", false) - bob := seedUser(t, pool, "bob", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - track := seedTrack(t, pool, album.ID, artist.ID, "T", 1, 100_000) - - // Alice likes the track. - _ = callLike(h, alice, http.MethodPost, "/api/likes/tracks/"+uuidToString(track.ID)) - // Bob queries his ids — should be empty. - w := callLike(h, bob, http.MethodGet, "/api/likes/ids") - var resp struct { - TrackIDs []string `json:"track_ids"` - } - _ = json.Unmarshal(w.Body.Bytes(), &resp) - if len(resp.TrackIDs) != 0 { - t.Errorf("bob's track_ids should be empty, got %v", resp.TrackIDs) - } -} - -func TestLikeAlbumAndArtist_HappyPath(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - - w := callLike(h, user, http.MethodPost, "/api/likes/albums/"+uuidToString(album.ID)) - if w.Code != http.StatusNoContent { - t.Errorf("album: status = %d", w.Code) - } - w = callLike(h, user, http.MethodPost, "/api/likes/artists/"+uuidToString(artist.ID)) - if w.Code != http.StatusNoContent { - t.Errorf("artist: status = %d", w.Code) - } - w = callLike(h, user, http.MethodGet, "/api/likes/albums?limit=10") - var resp struct{ Total int `json:"total"` } - _ = json.Unmarshal(w.Body.Bytes(), &resp) - if resp.Total != 1 { - t.Errorf("album total = %d", resp.Total) - } -} -``` - -The test imports `chi`; add that import alongside the existing ones. - -- [ ] **Step 2: Run tests, confirm they fail** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/api -run TestLike -v -``` -Expected: FAIL — handlers undefined. - -- [ ] **Step 3: Implement `internal/api/likes.go`** - -```go -package api - -import ( - "encoding/json" - "errors" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -type likedIDsResponse struct { - TrackIDs []string `json:"track_ids"` - AlbumIDs []string `json:"album_ids"` - ArtistIDs []string `json:"artist_ids"` -} - -func (h *handlers) handleLikeTrack(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - id, ok := parseUUID(chi.URLParam(r, "id")) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id") - return - } - q := dbq.New(h.pool) - if _, err := q.GetTrackByID(r.Context(), id); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - writeErr(w, http.StatusNotFound, "not_found", "track not found") - return - } - h.logger.Error("api: like track lookup", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - if err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id}); err != nil { - h.logger.Error("api: like track insert", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "insert failed") - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - id, ok := parseUUID(chi.URLParam(r, "id")) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id") - return - } - if err := dbq.New(h.pool).UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: id}); err != nil { - h.logger.Error("api: unlike track", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "delete failed") - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (h *handlers) handleLikeAlbum(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - id, ok := parseUUID(chi.URLParam(r, "id")) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid album id") - return - } - q := dbq.New(h.pool) - if _, err := q.GetAlbumByID(r.Context(), id); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - writeErr(w, http.StatusNotFound, "not_found", "album not found") - return - } - h.logger.Error("api: like album lookup", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - if err := q.LikeAlbum(r.Context(), dbq.LikeAlbumParams{UserID: user.ID, AlbumID: id}); err != nil { - h.logger.Error("api: like album insert", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "insert failed") - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (h *handlers) handleUnlikeAlbum(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - id, ok := parseUUID(chi.URLParam(r, "id")) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid album id") - return - } - if err := dbq.New(h.pool).UnlikeAlbum(r.Context(), dbq.UnlikeAlbumParams{UserID: user.ID, AlbumID: id}); err != nil { - h.logger.Error("api: unlike album", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "delete failed") - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (h *handlers) handleLikeArtist(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - id, ok := parseUUID(chi.URLParam(r, "id")) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid artist id") - return - } - q := dbq.New(h.pool) - if _, err := q.GetArtistByID(r.Context(), id); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - writeErr(w, http.StatusNotFound, "not_found", "artist not found") - return - } - h.logger.Error("api: like artist lookup", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - if err := q.LikeArtist(r.Context(), dbq.LikeArtistParams{UserID: user.ID, ArtistID: id}); err != nil { - h.logger.Error("api: like artist insert", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "insert failed") - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (h *handlers) handleUnlikeArtist(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - id, ok := parseUUID(chi.URLParam(r, "id")) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid artist id") - return - } - if err := dbq.New(h.pool).UnlikeArtist(r.Context(), dbq.UnlikeArtistParams{UserID: user.ID, ArtistID: id}); err != nil { - h.logger.Error("api: unlike artist", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "delete failed") - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (h *handlers) handleListLikedTracks(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - limit, offset, err := parsePaging(r.URL.Query()) - if err != nil { - writeErr(w, http.StatusBadRequest, "bad_request", err.Error()) - return - } - q := dbq.New(h.pool) - rows, err := q.ListLikedTrackRows(r.Context(), dbq.ListLikedTrackRowsParams{ - UserID: user.ID, Limit: int32(limit), Offset: int32(offset), - }) - if err != nil { - h.logger.Error("api: list liked tracks", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "list failed") - return - } - total, err := q.CountLikedTracks(r.Context(), user.ID) - if err != nil { - h.logger.Error("api: count liked tracks", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "count failed") - return - } - items, err := h.resolveTrackRefs(r.Context(), q, rows) - if err != nil { - writeErr(w, http.StatusInternalServerError, "server_error", "list failed") - return - } - writeJSON(w, http.StatusOK, Page[TrackRef]{Items: items, Total: int(total), Limit: limit, Offset: offset}) -} - -func (h *handlers) handleListLikedAlbums(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - limit, offset, err := parsePaging(r.URL.Query()) - if err != nil { - writeErr(w, http.StatusBadRequest, "bad_request", err.Error()) - return - } - q := dbq.New(h.pool) - rows, err := q.ListLikedAlbumRows(r.Context(), dbq.ListLikedAlbumRowsParams{ - UserID: user.ID, Limit: int32(limit), Offset: int32(offset), - }) - if err != nil { - h.logger.Error("api: list liked albums", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "list failed") - return - } - total, err := q.CountLikedAlbums(r.Context(), user.ID) - if err != nil { - h.logger.Error("api: count liked albums", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "count failed") - return - } - items, err := h.resolveAlbumRefs(r.Context(), q, rows) - if err != nil { - writeErr(w, http.StatusInternalServerError, "server_error", "list failed") - return - } - writeJSON(w, http.StatusOK, Page[AlbumRef]{Items: items, Total: int(total), Limit: limit, Offset: offset}) -} - -func (h *handlers) handleListLikedArtists(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - limit, offset, err := parsePaging(r.URL.Query()) - if err != nil { - writeErr(w, http.StatusBadRequest, "bad_request", err.Error()) - return - } - q := dbq.New(h.pool) - rows, err := q.ListLikedArtistRows(r.Context(), dbq.ListLikedArtistRowsParams{ - UserID: user.ID, Limit: int32(limit), Offset: int32(offset), - }) - if err != nil { - h.logger.Error("api: list liked artists", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "list failed") - return - } - total, err := q.CountLikedArtists(r.Context(), user.ID) - if err != nil { - h.logger.Error("api: count liked artists", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "count failed") - return - } - items := make([]ArtistRef, 0, len(rows)) - for _, a := range rows { - albums, aerr := q.ListAlbumsByArtist(r.Context(), a.ID) - if aerr != nil { - h.logger.Error("api: list albums for artist", "err", aerr) - writeErr(w, http.StatusInternalServerError, "server_error", "list failed") - return - } - items = append(items, artistRefFrom(a, len(albums))) - } - writeJSON(w, http.StatusOK, Page[ArtistRef]{Items: items, Total: int(total), Limit: limit, Offset: offset}) -} - -func (h *handlers) handleGetLikedIDs(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - q := dbq.New(h.pool) - trackIDs, err := q.ListLikedTrackIDs(r.Context(), user.ID) - if err != nil { - h.logger.Error("api: list liked track ids", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "list failed") - return - } - albumIDs, err := q.ListLikedAlbumIDs(r.Context(), user.ID) - if err != nil { - h.logger.Error("api: list liked album ids", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "list failed") - return - } - artistIDs, err := q.ListLikedArtistIDs(r.Context(), user.ID) - if err != nil { - h.logger.Error("api: list liked artist ids", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "list failed") - return - } - resp := likedIDsResponse{ - TrackIDs: uuidsToStrings(trackIDs), - AlbumIDs: uuidsToStrings(albumIDs), - ArtistIDs: uuidsToStrings(artistIDs), - } - writeJSON(w, http.StatusOK, resp) -} - -func uuidsToStrings(ids []pgtype.UUID) []string { - out := make([]string, 0, len(ids)) - for _, id := range ids { - out = append(out, uuidToString(id)) - } - return out -} - -// Compile-time references so the json import is always live even when -// no handler in this file decodes a body. -var _ = json.Marshal -``` - -- [ ] **Step 4: Register the routes in `internal/api/api.go`** - -Inside the `authed.Group(...)` block, after `authed.Post("/events", h.handleEvents)`: - -```go -authed.Post("/likes/tracks/{id}", h.handleLikeTrack) -authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack) -authed.Post("/likes/albums/{id}", h.handleLikeAlbum) -authed.Delete("/likes/albums/{id}", h.handleUnlikeAlbum) -authed.Post("/likes/artists/{id}", h.handleLikeArtist) -authed.Delete("/likes/artists/{id}", h.handleUnlikeArtist) -authed.Get("/likes/tracks", h.handleListLikedTracks) -authed.Get("/likes/albums", h.handleListLikedAlbums) -authed.Get("/likes/artists", h.handleListLikedArtists) -authed.Get("/likes/ids", h.handleGetLikedIDs) -``` - -- [ ] **Step 5: Run tests, confirm pass** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/api -run TestLike -v -``` -Expected: PASS — 7 tests. - -- [ ] **Step 6: Lint + full Go suite** - -Run: `golangci-lint run ./...` -Expected: clean. - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test -p 1 ./... -``` -Expected: all packages PASS. - -- [ ] **Step 7: Commit** - -```bash -git add internal/api/likes.go internal/api/likes_test.go internal/api/api.go -git commit -m "feat(api): add /api/likes/{tracks,albums,artists} endpoints - -Like/unlike are POST/DELETE on /api/likes/{type}/{id}; idempotent (204 -on repeats). List endpoints return Page -sorted liked_at DESC. /api/likes/ids returns flat id arrays for the -client-side heart-button cache." -``` - ---- - -## Task 3: Subsonic `/star` and `/unstar` handlers - -**Files:** -- Create: `internal/subsonic/star.go` -- Create: `internal/subsonic/star_test.go` (will be extended in Task 4 with getStarred coverage) -- Modify: `internal/subsonic/subsonic.go` (register routes) - -Validate-all-first atomicity: parse all `id`/`albumId`/`artistId` params, look up each entity; if any is missing return error 70 BEFORE any write. - -- [ ] **Step 1: Write failing tests** - -Create `internal/subsonic/star_test.go`: - -```go -package subsonic - -import ( - "context" - "io" - "log/slog" - "net/http" - "net/http/httptest" - "net/url" - "os" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" -) - -func testStarPool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track, dbq.Album, dbq.Artist) { - t.Helper() - if testing.Short() { - t.Skip("skipping in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - t.Fatalf("pool: %v", err) - } - t.Cleanup(pool.Close) - if _, err := pool.Exec(context.Background(), - "TRUNCATE general_likes, general_likes_albums, general_likes_artists, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { - t.Fatalf("truncate: %v", err) - } - q := dbq.New(pool) - u, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{ - Username: "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, - }) - a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "X", SortName: "X"}) - al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "X", SortTitle: "X", ArtistID: a.ID}) - tr, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "Y", AlbumID: al.ID, ArtistID: a.ID, FilePath: "/tmp/star.flac", DurationMs: 100_000, - }) - return pool, u, tr, al, a -} - -func newStarHandlers(pool *pgxpool.Pool) *mediaHandlers { - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) - return newMediaHandlers(pool, w) -} - -func TestHandleStar_TrackIDInsertsRow(t *testing.T) { - pool, user, track, _, _ := testStarPool(t) - m := newStarHandlers(pool) - - q := url.Values{} - q.Set("id", uuidToID(track.ID)) - req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil) - ctx := context.WithValue(req.Context(), userCtxKey, user) - w := httptest.NewRecorder() - m.handleStar(w, req.WithContext(ctx)) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var count int - _ = pool.QueryRow(context.Background(), - "SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count) - if count != 1 { - t.Errorf("count = %d", count) - } -} - -func TestHandleStar_AllThreeIDsAtOnce(t *testing.T) { - pool, user, track, album, artist := testStarPool(t) - m := newStarHandlers(pool) - - q := url.Values{} - q.Set("id", uuidToID(track.ID)) - q.Set("albumId", uuidToID(album.ID)) - q.Set("artistId", uuidToID(artist.ID)) - req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil) - ctx := context.WithValue(req.Context(), userCtxKey, user) - w := httptest.NewRecorder() - m.handleStar(w, req.WithContext(ctx)) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var tCount, alCount, arCount int - _ = pool.QueryRow(context.Background(), "SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&tCount) - _ = pool.QueryRow(context.Background(), "SELECT count(*) FROM general_likes_albums WHERE user_id=$1", user.ID).Scan(&alCount) - _ = pool.QueryRow(context.Background(), "SELECT count(*) FROM general_likes_artists WHERE user_id=$1", user.ID).Scan(&arCount) - if tCount != 1 || alCount != 1 || arCount != 1 { - t.Errorf("counts: tracks=%d albums=%d artists=%d", tCount, alCount, arCount) - } -} - -func TestHandleStar_UnknownTrackReturnsError70(t *testing.T) { - pool, user, _, _, _ := testStarPool(t) - m := newStarHandlers(pool) - - q := url.Values{} - q.Set("id", "00000000-0000-0000-0000-000000000000") - req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil) - ctx := context.WithValue(req.Context(), userCtxKey, user) - w := httptest.NewRecorder() - m.handleStar(w, req.WithContext(ctx)) - - body := w.Body.String() - // Subsonic error envelope returns 200 OK with status="failed" inside the body. - // The exact code is 70 (data not found). - if w.Code != http.StatusOK || !contains(body, `"code":70`) && !contains(body, `code="70"`) { - t.Errorf("expected error 70 envelope, got status=%d body=%s", w.Code, body) - } -} - -func TestHandleStar_PartialMissingAtomic(t *testing.T) { - pool, user, track, _, _ := testStarPool(t) - m := newStarHandlers(pool) - - q := url.Values{} - q.Set("id", uuidToID(track.ID)) - q.Set("albumId", "00000000-0000-0000-0000-000000000000") - req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil) - ctx := context.WithValue(req.Context(), userCtxKey, user) - w := httptest.NewRecorder() - m.handleStar(w, req.WithContext(ctx)) - - // Album missing → atomic refusal: track NOT starred. - var count int - _ = pool.QueryRow(context.Background(), - "SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count) - if count != 0 { - t.Errorf("track was starred despite album miss; count = %d, want 0", count) - } -} - -func TestHandleUnstar_RemovesAndIsIdempotent(t *testing.T) { - pool, user, track, _, _ := testStarPool(t) - m := newStarHandlers(pool) - - // First star. - q := url.Values{} - q.Set("id", uuidToID(track.ID)) - star := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil) - starCtx := context.WithValue(star.Context(), userCtxKey, user) - starResp := httptest.NewRecorder() - m.handleStar(starResp, star.WithContext(starCtx)) - - // Unstar. - un := httptest.NewRequest(http.MethodGet, "/rest/unstar?"+q.Encode(), nil) - unCtx := context.WithValue(un.Context(), userCtxKey, user) - unResp := httptest.NewRecorder() - m.handleUnstar(unResp, un.WithContext(unCtx)) - - if unResp.Code != http.StatusOK { - t.Fatalf("unstar status = %d", unResp.Code) - } - var count int - _ = pool.QueryRow(context.Background(), - "SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count) - if count != 0 { - t.Errorf("count after unstar = %d, want 0", count) - } - - // Idempotent — unstar again. - un2 := httptest.NewRequest(http.MethodGet, "/rest/unstar?"+q.Encode(), nil) - un2 = un2.WithContext(context.WithValue(un2.Context(), userCtxKey, user)) - un2Resp := httptest.NewRecorder() - m.handleUnstar(un2Resp, un2) - if un2Resp.Code != http.StatusOK { - t.Errorf("second unstar status = %d", un2Resp.Code) - } -} - -func contains(s, substr string) bool { - for i := 0; i+len(substr) <= len(s); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} -``` - -- [ ] **Step 2: Run tests, confirm fail** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/subsonic -run TestHandleStar -v -``` -Expected: FAIL — `handleStar`/`handleUnstar` undefined. - -- [ ] **Step 3: Implement `internal/subsonic/star.go`** - -```go -package subsonic - -import ( - "context" - "errors" - "net/http" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// handleStar implements /rest/star. Accepts any combination of id (track), -// albumId, artistId. All entities are validated before any insert; a single -// missing entity fails the whole call with Subsonic error 70. -func (m *mediaHandlers) handleStar(w http.ResponseWriter, r *http.Request) { - user, ok := UserFromContext(r.Context()) - if !ok { - WriteFail(w, r, ErrGeneric, "Unauthenticated") - return - } - params := r.URL.Query() - q := dbq.New(m.pool) - trackID, err := resolveStarID(r.Context(), q, params.Get("id"), entityKindTrack) - if err != nil { - WriteFail(w, r, ErrDataNotFound, err.Error()) - return - } - albumID, err := resolveStarID(r.Context(), q, params.Get("albumId"), entityKindAlbum) - if err != nil { - WriteFail(w, r, ErrDataNotFound, err.Error()) - return - } - artistID, err := resolveStarID(r.Context(), q, params.Get("artistId"), entityKindArtist) - if err != nil { - WriteFail(w, r, ErrDataNotFound, err.Error()) - return - } - if trackID.Valid { - if err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}); err != nil { - WriteFail(w, r, ErrGeneric, "Could not star track") - return - } - } - if albumID.Valid { - if err := q.LikeAlbum(r.Context(), dbq.LikeAlbumParams{UserID: user.ID, AlbumID: albumID}); err != nil { - WriteFail(w, r, ErrGeneric, "Could not star album") - return - } - } - if artistID.Valid { - if err := q.LikeArtist(r.Context(), dbq.LikeArtistParams{UserID: user.ID, ArtistID: artistID}); err != nil { - WriteFail(w, r, ErrGeneric, "Could not star artist") - return - } - } - Write(w, r, struct{ Envelope }{Envelope: NewEnvelope("ok")}) -} - -// handleUnstar mirrors handleStar but DELETEs. Missing entities are treated -// as a no-op rather than an error — Subsonic clients commonly call unstar -// on entities the server has never seen (cross-server library moves). -func (m *mediaHandlers) handleUnstar(w http.ResponseWriter, r *http.Request) { - user, ok := UserFromContext(r.Context()) - if !ok { - WriteFail(w, r, ErrGeneric, "Unauthenticated") - return - } - params := r.URL.Query() - q := dbq.New(m.pool) - if t, ok := parseUUID(params.Get("id")); ok { - _ = q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: t}) - } - if a, ok := parseUUID(params.Get("albumId")); ok { - _ = q.UnlikeAlbum(r.Context(), dbq.UnlikeAlbumParams{UserID: user.ID, AlbumID: a}) - } - if a, ok := parseUUID(params.Get("artistId")); ok { - _ = q.UnlikeArtist(r.Context(), dbq.UnlikeArtistParams{UserID: user.ID, ArtistID: a}) - } - Write(w, r, struct{ Envelope }{Envelope: NewEnvelope("ok")}) -} - -type entityKind int - -const ( - entityKindTrack entityKind = iota - entityKindAlbum - entityKindArtist -) - -// resolveStarID returns (uuid, nil) if the param is empty (no-op), or -// (uuid, nil) if the entity exists, or (zero, error) if the entity is -// missing/malformed. Non-empty malformed UUID is treated as a 'not found' -// per Subsonic semantics. -func resolveStarID(ctx context.Context, q *dbq.Queries, raw string, kind entityKind) (pgtype.UUID, error) { - if raw == "" { - return pgtype.UUID{}, nil - } - id, ok := parseUUID(raw) - if !ok { - return pgtype.UUID{}, errors.New("not found") - } - switch kind { - case entityKindTrack: - if _, err := q.GetTrackByID(ctx, id); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return pgtype.UUID{}, errors.New("track not found") - } - return pgtype.UUID{}, err - } - case entityKindAlbum: - if _, err := q.GetAlbumByID(ctx, id); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return pgtype.UUID{}, errors.New("album not found") - } - return pgtype.UUID{}, err - } - case entityKindArtist: - if _, err := q.GetArtistByID(ctx, id); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return pgtype.UUID{}, errors.New("artist not found") - } - return pgtype.UUID{}, err - } - } - return id, nil -} -``` - -- [ ] **Step 4: Register the routes in `internal/subsonic/subsonic.go`** - -Inside the `r.Route("/rest", ...)` block, after the existing `/scrobble` registration: - -```go -register(sub, "/star", m.handleStar) -register(sub, "/unstar", m.handleUnstar) -``` - -(Lines for `/getStarred` and `/getStarred2` come in Task 4.) - -- [ ] **Step 5: Run tests, confirm pass** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/subsonic -run TestHandleStar -v -``` -Expected: PASS — 5 tests including `TestHandleUnstar_RemovesAndIsIdempotent`. - -- [ ] **Step 6: Lint** - -Run: `golangci-lint run ./internal/subsonic/...` -Expected: clean. - -- [ ] **Step 7: Commit** - -```bash -git add internal/subsonic/star.go internal/subsonic/star_test.go internal/subsonic/subsonic.go -git commit -m "feat(subsonic): add /rest/star and /rest/unstar handlers - -Validate-all-first atomicity on star: a missing entity refuses the whole -call with Subsonic error 70. Unstar is a best-effort delete (missing -entities are no-ops, matching client expectations after library moves)." -``` - ---- - -## Task 4: Subsonic `/getStarred` and `/getStarred2` - -**Files:** -- Modify: `internal/subsonic/star.go` (add the two handlers) -- Modify: `internal/subsonic/star_test.go` (extend with getStarred tests) -- Modify: `internal/subsonic/subsonic.go` (register routes) -- Modify: `internal/subsonic/types.go` (add `StarredContainer` and response types) - -- [ ] **Step 1: Add tests for getStarred2** - -Append to `internal/subsonic/star_test.go`: - -```go -import "encoding/json" - -func TestHandleGetStarred2_ReturnsAllThreeArrays(t *testing.T) { - pool, user, track, album, artist := testStarPool(t) - m := newStarHandlers(pool) - q := dbq.New(pool) - - // Star one of each. - _ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: user.ID, TrackID: track.ID}) - _ = q.LikeAlbum(context.Background(), dbq.LikeAlbumParams{UserID: user.ID, AlbumID: album.ID}) - _ = q.LikeArtist(context.Background(), dbq.LikeArtistParams{UserID: user.ID, ArtistID: artist.ID}) - - req := httptest.NewRequest(http.MethodGet, "/rest/getStarred2?f=json", nil) - ctx := context.WithValue(req.Context(), userCtxKey, user) - w := httptest.NewRecorder() - m.handleGetStarred2(w, req.WithContext(ctx)) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) - } - // Subsonic JSON envelope: {"subsonic-response":{...,"starred2":{...}}} - var raw map[string]any - _ = json.Unmarshal(w.Body.Bytes(), &raw) - resp, _ := raw["subsonic-response"].(map[string]any) - starred, _ := resp["starred2"].(map[string]any) - if starred == nil { - t.Fatalf("missing starred2 envelope: %v", raw) - } - songs, _ := starred["song"].([]any) - albums, _ := starred["album"].([]any) - artists, _ := starred["artist"].([]any) - if len(songs) != 1 || len(albums) != 1 || len(artists) != 1 { - t.Errorf("counts: songs=%d albums=%d artists=%d", len(songs), len(albums), len(artists)) - } -} - -func TestHandleGetStarred2_CrossUserIsolation(t *testing.T) { - pool, alice, track, _, _ := testStarPool(t) - q := dbq.New(pool) - bob, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{ - Username: "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false, - }) - _ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: alice.ID, TrackID: track.ID}) - - m := newStarHandlers(pool) - req := httptest.NewRequest(http.MethodGet, "/rest/getStarred2?f=json", nil) - ctx := context.WithValue(req.Context(), userCtxKey, bob) - w := httptest.NewRecorder() - m.handleGetStarred2(w, req.WithContext(ctx)) - - var raw map[string]any - _ = json.Unmarshal(w.Body.Bytes(), &raw) - resp, _ := raw["subsonic-response"].(map[string]any) - starred, _ := resp["starred2"].(map[string]any) - songs, _ := starred["song"].([]any) - if len(songs) != 0 { - t.Errorf("bob's starred songs should be empty, got %d", len(songs)) - } -} -``` - -- [ ] **Step 2: Add the type containers in `internal/subsonic/types.go`** - -After `SearchResult3Container` and its response wrapper, add: - -```go -// StarredContainer is the body of getStarred / getStarred2. -type StarredContainer struct { - XMLName xml.Name `json:"-" xml:"starred2"` - Artists []ArtistRef `json:"artist" xml:"artist"` - Albums []AlbumRef `json:"album" xml:"album"` - Songs []SongRef `json:"song" xml:"song"` -} - -type GetStarred2Response struct { - Envelope - Starred2 StarredContainer `json:"starred2" xml:"starred2"` -} - -// GetStarredContainer mirrors getStarred (id3-less variant). Response uses -// the same shape; the only material difference from getStarred2 is the -// outer key name. -type GetStarredContainer struct { - XMLName xml.Name `json:"-" xml:"starred"` - Artists []ArtistRef `json:"artist" xml:"artist"` - Albums []AlbumRef `json:"album" xml:"album"` - Songs []SongRef `json:"song" xml:"song"` -} - -type GetStarredResponse struct { - Envelope - Starred GetStarredContainer `json:"starred" xml:"starred"` -} -``` - -- [ ] **Step 3: Implement the handlers in `internal/subsonic/star.go`** - -Append: - -```go -func (m *mediaHandlers) handleGetStarred(w http.ResponseWriter, r *http.Request) { - user, ok := UserFromContext(r.Context()) - if !ok { - WriteFail(w, r, ErrGeneric, "Unauthenticated") - return - } - q := dbq.New(m.pool) - songs, albums, artists, err := loadStarred(r.Context(), q, user.ID) - if err != nil { - WriteFail(w, r, ErrGeneric, "Could not load starred") - return - } - Write(w, r, GetStarredResponse{ - Envelope: NewEnvelope("ok"), - Starred: GetStarredContainer{ - Artists: artists, - Albums: albums, - Songs: songs, - }, - }) -} - -func (m *mediaHandlers) handleGetStarred2(w http.ResponseWriter, r *http.Request) { - user, ok := UserFromContext(r.Context()) - if !ok { - WriteFail(w, r, ErrGeneric, "Unauthenticated") - return - } - q := dbq.New(m.pool) - songs, albums, artists, err := loadStarred(r.Context(), q, user.ID) - if err != nil { - WriteFail(w, r, ErrGeneric, "Could not load starred") - return - } - Write(w, r, GetStarred2Response{ - Envelope: NewEnvelope("ok"), - Starred2: StarredContainer{ - Artists: artists, - Albums: albums, - Songs: songs, - }, - }) -} - -// loadStarred fetches the user's full starred set in liked_at DESC order, -// projects to Subsonic refs (Song / Album / Artist). -func loadStarred(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]SongRef, []AlbumRef, []ArtistRef, error) { - const cap = 500 // bounded for safety; M2 doesn't paginate getStarred - songs := []SongRef{} - albums := []AlbumRef{} - artists := []ArtistRef{} - - trackRows, err := q.ListLikedTrackRows(ctx, dbq.ListLikedTrackRowsParams{ - UserID: userID, Limit: cap, Offset: 0, - }) - if err != nil { - return nil, nil, nil, err - } - for _, t := range trackRows { - album, err := q.GetAlbumByID(ctx, t.AlbumID) - if err != nil { - return nil, nil, nil, err - } - artist, err := q.GetArtistByID(ctx, t.ArtistID) - if err != nil { - return nil, nil, nil, err - } - songs = append(songs, songRef(t, album.Title, artist.Name)) - } - - albumRows, err := q.ListLikedAlbumRows(ctx, dbq.ListLikedAlbumRowsParams{ - UserID: userID, Limit: cap, Offset: 0, - }) - if err != nil { - return nil, nil, nil, err - } - for _, a := range albumRows { - artist, err := q.GetArtistByID(ctx, a.ArtistID) - if err != nil { - return nil, nil, nil, err - } - count, err := q.CountTracksByAlbum(ctx, a.ID) - if err != nil { - return nil, nil, nil, err - } - albums = append(albums, albumRef(a, artist.Name, int(count), 0)) - } - - artistRows, err := q.ListLikedArtistRows(ctx, dbq.ListLikedArtistRowsParams{ - UserID: userID, Limit: cap, Offset: 0, - }) - if err != nil { - return nil, nil, nil, err - } - for _, a := range artistRows { - albums, err := q.ListAlbumsByArtist(ctx, a.ID) - if err != nil { - return nil, nil, nil, err - } - artists = append(artists, artistRef(a, len(albums))) - } - - return songs, albums, artists, nil -} -``` - -- [ ] **Step 4: Register the routes in `internal/subsonic/subsonic.go`** - -After the `register(sub, "/star", ...)` and `/unstar` lines, add: - -```go -register(sub, "/getStarred", m.handleGetStarred) -register(sub, "/getStarred2", m.handleGetStarred2) -``` - -- [ ] **Step 5: Run tests, confirm pass** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/subsonic -v -``` -Expected: PASS — including the 2 new getStarred tests + the 5 from Task 3 + existing scrobble + browse coverage. - -- [ ] **Step 6: Full Go suite + lint** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test -p 1 ./... -``` -Expected: all packages PASS. - -Run: `golangci-lint run ./...` -Expected: clean. - -- [ ] **Step 7: Commit** - -```bash -git add internal/subsonic/star.go internal/subsonic/star_test.go internal/subsonic/subsonic.go internal/subsonic/types.go -git commit -m "feat(subsonic): add getStarred and getStarred2 handlers - -Both return user's starred artists/albums/songs sorted liked_at DESC. -Cap at 500 entries per category for v1 (Subsonic spec doesn't define -pagination on these endpoints; M3+ can revisit if needed)." -``` - ---- - -## Task 5: Web client — types + likes.ts (TanStack Query helpers) - -**Files:** -- Modify: `web/src/lib/api/types.ts` -- Modify: `web/src/lib/api/queries.ts` -- Create: `web/src/lib/api/likes.ts` -- Create: `web/src/lib/api/likes.test.ts` - -- [ ] **Step 1: Add types** - -Append to `web/src/lib/api/types.ts`: - -```ts -export type LikedIdsResponse = { - track_ids: string[]; - album_ids: string[]; - artist_ids: string[]; -}; -``` - -- [ ] **Step 2: Add qk keys** - -Modify `web/src/lib/api/queries.ts`'s `qk` object — add four new entries: - -```ts -export const qk = { - artists: (sort: ArtistSort) => ['artists', { sort }] as const, - artist: (id: string) => ['artist', id] as const, - album: (id: string) => ['album', id] as const, - search: (q: string) => ['search', { q }] as const, - searchArtists: (q: string) => ['searchArtists', { q }] as const, - searchAlbums: (q: string) => ['searchAlbums', { q }] as const, - searchTracks: (q: string) => ['searchTracks', { q }] as const, - likedIds: () => ['likedIds'] as const, - likedTracks: () => ['likedTracks'] as const, - likedAlbums: () => ['likedAlbums'] as const, - likedArtists: () => ['likedArtists'] as const, -}; -``` - -- [ ] **Step 3: Write the failing tests** - -Create `web/src/lib/api/likes.test.ts`: - -```ts -import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'; -import { QueryClient } from '@tanstack/svelte-query'; - -vi.mock('./client', () => ({ - api: { - get: vi.fn(), - post: vi.fn().mockResolvedValue(null), - del: vi.fn().mockResolvedValue(null) - } -})); - -import { likeEntity, unlikeEntity } from './likes'; -import { qk } from './queries'; -import { api } from './client'; - -let client: QueryClient; - -beforeEach(() => { - vi.clearAllMocks(); - client = new QueryClient(); - client.setQueryData(qk.likedIds(), { - track_ids: ['t1'], - album_ids: [], - artist_ids: [] - }); -}); - -afterEach(() => client.clear()); - -describe('likes mutations', () => { - test('likeEntity track adds id to track_ids in cache (optimistic)', async () => { - await likeEntity(client, 'track', 't2'); - const cached = client.getQueryData(qk.likedIds()) as - | { track_ids: string[] } | undefined; - expect(cached?.track_ids).toContain('t2'); - expect(api.post).toHaveBeenCalledWith('/api/likes/tracks/t2'); - }); - - test('likeEntity album hits albums endpoint and adds to album_ids', async () => { - await likeEntity(client, 'album', 'al1'); - const cached = client.getQueryData(qk.likedIds()) as - | { album_ids: string[] } | undefined; - expect(cached?.album_ids).toContain('al1'); - expect(api.post).toHaveBeenCalledWith('/api/likes/albums/al1'); - }); - - test('likeEntity artist adds to artist_ids', async () => { - await likeEntity(client, 'artist', 'ar1'); - const cached = client.getQueryData(qk.likedIds()) as - | { artist_ids: string[] } | undefined; - expect(cached?.artist_ids).toContain('ar1'); - expect(api.post).toHaveBeenCalledWith('/api/likes/artists/ar1'); - }); - - test('unlikeEntity removes id from cache', async () => { - await unlikeEntity(client, 'track', 't1'); - const cached = client.getQueryData(qk.likedIds()) as - | { track_ids: string[] } | undefined; - expect(cached?.track_ids).not.toContain('t1'); - expect(api.del).toHaveBeenCalledWith('/api/likes/tracks/t1'); - }); - - test('likeEntity rolls back on error', async () => { - (api.post as ReturnType).mockRejectedValueOnce(new Error('boom')); - try { - await likeEntity(client, 'track', 't2'); - } catch { /* expected */ } - const cached = client.getQueryData(qk.likedIds()) as - | { track_ids: string[] } | undefined; - expect(cached?.track_ids).not.toContain('t2'); - }); -}); -``` - -- [ ] **Step 4: Run, confirm fail** - -Run: `cd web && npm test -- src/lib/api/likes.test.ts` -Expected: FAIL — `./likes` module not found. - -- [ ] **Step 5: Implement `web/src/lib/api/likes.ts`** - -```ts -import type { QueryClient } from '@tanstack/svelte-query'; -import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query'; -import { api } from './client'; -import { qk, ARTIST_PAGE_SIZE } from './queries'; -import type { - ArtistRef, - AlbumRef, - TrackRef, - Page, - LikedIdsResponse -} from './types'; - -export type EntityKind = 'track' | 'album' | 'artist'; - -const PATH_BY_KIND: Record = { - track: 'tracks', - album: 'albums', - artist: 'artists' -}; - -const SET_KEY_BY_KIND: Record = { - track: 'track_ids', - album: 'album_ids', - artist: 'artist_ids' -}; - -export function createLikedIdsQuery() { - return createQuery({ - queryKey: qk.likedIds(), - queryFn: () => api.get('/api/likes/ids'), - staleTime: 60_000 - }); -} - -export function createLikedTracksInfiniteQuery() { - return createInfiniteQuery({ - queryKey: qk.likedTracks(), - queryFn: ({ pageParam = 0 }) => - api.get>(`/api/likes/tracks?limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}`), - initialPageParam: 0, - getNextPageParam: (last) => { - const loaded = last.offset + last.items.length; - return loaded >= last.total ? undefined : loaded; - } - }); -} - -export function createLikedAlbumsInfiniteQuery() { - return createInfiniteQuery({ - queryKey: qk.likedAlbums(), - queryFn: ({ pageParam = 0 }) => - api.get>(`/api/likes/albums?limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}`), - initialPageParam: 0, - getNextPageParam: (last) => { - const loaded = last.offset + last.items.length; - return loaded >= last.total ? undefined : loaded; - } - }); -} - -export function createLikedArtistsInfiniteQuery() { - return createInfiniteQuery({ - queryKey: qk.likedArtists(), - queryFn: ({ pageParam = 0 }) => - api.get>(`/api/likes/artists?limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}`), - initialPageParam: 0, - getNextPageParam: (last) => { - const loaded = last.offset + last.items.length; - return loaded >= last.total ? undefined : loaded; - } - }); -} - -// likeEntity does an optimistic insert into the likedIds cache, fires the -// network call, and rolls back on error. Same shape for unlikeEntity. -export async function likeEntity( - client: QueryClient, - kind: EntityKind, - id: string -): Promise { - const setKey = SET_KEY_BY_KIND[kind]; - const previous = client.getQueryData(qk.likedIds()); - if (previous) { - client.setQueryData(qk.likedIds(), { - ...previous, - [setKey]: previous[setKey].includes(id) ? previous[setKey] : [...previous[setKey], id] - }); - } - try { - await api.post(`/api/likes/${PATH_BY_KIND[kind]}/${id}`, undefined); - } catch (err) { - if (previous) client.setQueryData(qk.likedIds(), previous); - throw err; - } -} - -export async function unlikeEntity( - client: QueryClient, - kind: EntityKind, - id: string -): Promise { - const setKey = SET_KEY_BY_KIND[kind]; - const previous = client.getQueryData(qk.likedIds()); - if (previous) { - client.setQueryData(qk.likedIds(), { - ...previous, - [setKey]: previous[setKey].filter((x) => x !== id) - }); - } - try { - await api.del(`/api/likes/${PATH_BY_KIND[kind]}/${id}`); - } catch (err) { - if (previous) client.setQueryData(qk.likedIds(), previous); - throw err; - } -} -``` - -- [ ] **Step 6: Run tests, confirm pass** - -Run: `cd web && npm test -- src/lib/api/likes.test.ts` -Expected: PASS — 5 tests. - -- [ ] **Step 7: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 8: Commit** - -```bash -git add web/src/lib/api/types.ts web/src/lib/api/queries.ts web/src/lib/api/likes.ts web/src/lib/api/likes.test.ts -git commit -m "feat(web): add likes TanStack Query helpers + mutations - -createLikedIdsQuery is the single source for heart-button state across -the app (track_ids/album_ids/artist_ids Sets). likeEntity / unlikeEntity -do optimistic setQueryData updates with rollback on error. Per-entity -infinite queries back the /library/liked sections." -``` - ---- - -## Task 6: `LikeButton.svelte` component - -**Files:** -- Create: `web/src/lib/components/LikeButton.svelte` -- Create: `web/src/lib/components/LikeButton.test.ts` - -- [ ] **Step 1: Write failing tests** - -Create `web/src/lib/components/LikeButton.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; -import { readable } from 'svelte/store'; - -const cacheState = vi.hoisted(() => ({ - data: { track_ids: ['t1'], album_ids: [], artist_ids: [] } -})); - -vi.mock('$lib/api/likes', () => ({ - createLikedIdsQuery: () => readable({ data: cacheState.data, isPending: false, isError: false }), - likeEntity: vi.fn().mockResolvedValue(undefined), - unlikeEntity: vi.fn().mockResolvedValue(undefined) -})); - -vi.mock('@tanstack/svelte-query', async (orig) => { - const actual = (await orig()) as Record; - return { - ...actual, - useQueryClient: () => ({}) - }; -}); - -import LikeButton from './LikeButton.svelte'; -import { likeEntity, unlikeEntity } from '$lib/api/likes'; - -afterEach(() => { - vi.clearAllMocks(); - cacheState.data = { track_ids: ['t1'], album_ids: [], artist_ids: [] }; -}); - -describe('LikeButton', () => { - test('renders filled heart when track id IS in the set', () => { - render(LikeButton, { props: { entityType: 'track', entityId: 't1' } }); - const btn = screen.getByRole('button', { name: /unlike/i }); - expect(btn.getAttribute('aria-pressed')).toBe('true'); - }); - - test('renders empty heart when track id is NOT in the set', () => { - render(LikeButton, { props: { entityType: 'track', entityId: 't2' } }); - const btn = screen.getByRole('button', { name: /like/i }); - expect(btn.getAttribute('aria-pressed')).toBe('false'); - }); - - test('click on empty heart calls likeEntity', async () => { - render(LikeButton, { props: { entityType: 'track', entityId: 't2' } }); - await fireEvent.click(screen.getByRole('button', { name: /like/i })); - expect(likeEntity).toHaveBeenCalledWith(expect.anything(), 'track', 't2'); - }); - - test('click on filled heart calls unlikeEntity', async () => { - render(LikeButton, { props: { entityType: 'track', entityId: 't1' } }); - await fireEvent.click(screen.getByRole('button', { name: /unlike/i })); - expect(unlikeEntity).toHaveBeenCalledWith(expect.anything(), 'track', 't1'); - }); - - test('click stops propagation', async () => { - const parentClick = vi.fn(); - const { container } = render(LikeButton, { props: { entityType: 'track', entityId: 't2' } }); - const wrapper = container.parentElement!; - wrapper.addEventListener('click', parentClick); - await fireEvent.click(screen.getByRole('button', { name: /like/i })); - expect(parentClick).not.toHaveBeenCalled(); - }); - - test('album entityType reads from album_ids set', () => { - cacheState.data = { track_ids: [], album_ids: ['al1'], artist_ids: [] }; - render(LikeButton, { props: { entityType: 'album', entityId: 'al1' } }); - expect(screen.getByRole('button', { name: /unlike/i }).getAttribute('aria-pressed')).toBe('true'); - }); -}); -``` - -- [ ] **Step 2: Run, confirm fail** - -Run: `cd web && npm test -- src/lib/components/LikeButton.test.ts` -Expected: FAIL — component doesn't exist. - -- [ ] **Step 3: Implement `web/src/lib/components/LikeButton.svelte`** - -```svelte - - - -``` - -- [ ] **Step 4: Run, confirm pass** - -Run: `cd web && npm test -- src/lib/components/LikeButton.test.ts` -Expected: PASS — 6 tests. - -- [ ] **Step 5: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 6: Commit** - -```bash -git add web/src/lib/components/LikeButton.svelte web/src/lib/components/LikeButton.test.ts -git commit -m "feat(web): add LikeButton component - -Heart icon reading from createLikedIdsQuery; click toggles via -likeEntity/unlikeEntity. Click stops propagation so it can nest -inside TrackRow's role=button div without triggering row activation." -``` - ---- - -## Task 7: Wire `LikeButton` into existing components - -**Files:** -- Modify: `web/src/lib/components/TrackRow.svelte` + `TrackRow.test.ts` -- Modify: `web/src/lib/components/AlbumCard.svelte` + `AlbumCard.test.ts` -- Modify: `web/src/lib/components/ArtistRow.svelte` + (no test file currently — skip) -- Modify: `web/src/lib/components/PlayerBar.svelte` + `PlayerBar.test.ts` -- Modify: `web/src/lib/components/Shell.svelte` - -`LikeButton` mounts inside contexts that already mock the query layer; the test files need their existing `vi.mock('$lib/api/likes', ...)` set up so `LikeButton` doesn't crash during test render. - -- [ ] **Step 1: Update `TrackRow.svelte`** - -Replace the file: - -```svelte - - -
- - {track.track_number ?? '—'} - - {track.title} - - - {formatDuration(track.duration_sec)} -
-``` - -- [ ] **Step 2: Update `TrackRow.test.ts`** - -Add to the top of the file (after the existing `vi.mock('$lib/player/store.svelte', ...)` block): - -```ts -vi.mock('$lib/api/likes', () => ({ - createLikedIdsQuery: () => readable({ - data: { track_ids: [], album_ids: [], artist_ids: [] }, - isPending: false, - isError: false - }), - likeEntity: vi.fn(), - unlikeEntity: vi.fn() -})); - -vi.mock('@tanstack/svelte-query', async (orig) => { - const actual = (await orig()) as Record; - return { ...actual, useQueryClient: () => ({}) }; -}); -``` - -Add `import { readable } from 'svelte/store';` at the top. - -- [ ] **Step 3: Update `AlbumCard.svelte`** - -Replace: - -```svelte - - - -``` - -Update `AlbumCard.test.ts` with the same likes/query-client mocks as TrackRow.test.ts. - -- [ ] **Step 4: Update `ArtistRow.svelte`** - -Replace: - -```svelte - - -
- - - {artist.name} - {countLabel} - - - - -
-``` - -Note the structural change: previously ArtistRow was a single ``. Now it's a `
` with the link as a positioned overlay so the LikeButton can sit above it without nesting ` - {/if} - {/if} - - -
-

Albums

- {#if albumsTotal === 0} -

No liked albums yet.

- {:else} -
- {#each albums as al (al.id)} - - {/each} -
- {#if albumsQuery?.hasNextPage} - - {/if} - {/if} -
- -
-

Tracks

- {#if tracksTotal === 0} -

No liked tracks yet.

- {:else} -
- {#each tracks as t, i (t.id)} - - {/each} -
- {#if tracksQuery?.hasNextPage} - - {/if} - {/if} -
-
-``` - -- [ ] **Step 4: Run tests, confirm pass** - -Run: `cd web && npm test -- 'src/routes/library/liked/liked.test.ts'` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Type-check** - -Run: `cd web && npm run check` -Expected: `0 ERRORS 0 WARNINGS`. - -- [ ] **Step 6: Commit** - -```bash -git add web/src/routes/library/liked/+page.svelte web/src/routes/library/liked/liked.test.ts -git commit -m "feat(web): add /library/liked page with three sections - -Each section is its own infinite query against /api/likes/{type}. -Empty sections show 'No liked X yet' (gated on total === 0). Reuses -ArtistRow / AlbumCard / TrackRow for rendering — same components as -the search overflow pages." -``` - ---- - -## Task 9: Final verification + branch finish - -**Files:** none (verification only). - -- [ ] **Step 1: Full Go suite (with DB)** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test -p 1 ./... -``` -Expected: all packages PASS. - -- [ ] **Step 2: Go lint** - -Run: `golangci-lint run ./...` -Expected: clean. - -- [ ] **Step 3: Web check + tests + build** - -Run: `cd web && npm run check && npm test && npm run build` -Expected: check 0/0; all vitest files pass; build emits `web/build/`. - -Run: `git checkout -- web/build/index.html` -Expected: committed placeholder restored. - -- [ ] **Step 4: Docker build smoke** - -Run: `docker build -t minstrel:m2-likes-smoke .` -Expected: image builds. - -Run: `docker run --rm --entrypoint /bin/sh minstrel:m2-likes-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'` -Expected: `ok`. - -- [ ] **Step 5: Local end-to-end manual check** - -```bash -docker compose up --build -d -``` - -Browser at `localhost:4533`: - -1. Sign in (use the bootstrap-logged password). Click heart on a track row → fills instantly. Refresh → still filled. -2. Verify in psql: - ```bash - docker exec minstrel-postgres-1 psql -U minstrel -d minstrel \ - -c "SELECT user_id, track_id FROM general_likes;" - ``` -3. Click heart on an album card → row in `general_likes_albums`. -4. Click heart on an artist row → row in `general_likes_artists`. -5. Click filled heart again → row gone in each case. -6. Open Feishin/Symfonium pointed at the same instance. Star a track. Refresh web SPA (or focus tab). Heart on that track is filled. -7. Unstar from Subsonic. Web SPA's heart empties on next refetch. -8. Visit `/library/liked` via the new "Liked" sidebar link. Three sections render with the items above. -9. Currently-playing track shows filled heart in `PlayerBar` if liked; toggling from `PlayerBar` updates state everywhere the same track is rendered. - -Tear down: -```bash -docker compose down -``` - -- [ ] **Step 6: Finish the branch** - -Follow `superpowers:finishing-a-development-branch`. Expected path: Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green. - ---- - -## Self-Review Notes - -**Spec coverage:** -- Schema (3 tables) → Task 1. -- sqlc queries → Task 1. -- Native API endpoints (7) → Task 2. -- Subsonic star/unstar with validate-all-first atomicity → Task 3. -- Subsonic getStarred/getStarred2 with cap=500 → Task 4. -- Web types + qk keys + likes.ts factory + mutations + rollback → Task 5. -- LikeButton component with stop-propagation → Task 6. -- Wire heart into TrackRow / AlbumCard / ArtistRow / PlayerBar → Task 7. -- "Liked" nav entry → Task 7. -- /library/liked page → Task 8. -- End-to-end manual + branch finish → Task 9. - -**Type consistency:** -- `EntityKind = 'track' | 'album' | 'artist'` — same in TS factory and component prop. -- `LikedIdsResponse = { track_ids, album_ids, artist_ids }` — server JSON keys match TS keys (snake_case). -- `qk.likedIds()`, `qk.likedTracks()` etc. — same names everywhere they're consumed. -- `likeEntity(client, kind, id)` / `unlikeEntity(client, kind, id)` — same signature in factory + component + tests. -- pgtype.UUID throughout server-side; string at HTTP boundaries via `uuidToString`. - -**Filename hazards:** all new test files use plain `.test.ts` (no rune syntax in the test bodies — just imports of components that use runes). Route test files use non-`+`-prefixed names (`liked.test.ts`). - -**Placeholder scan:** no TBD/TODO/later markers. Every code block is complete; every command shows expected output. - -**ArtistRow refactor risk:** changing `` to `
` with positioned overlay link is a surface change. The existing artist tests (in route pages — `src/routes/+page.svelte` etc.) use `screen.getByRole('link')` and `getByText` which both still work. Tests re-run as part of Task 7 Step 7. diff --git a/docs/superpowers/plans/2026-04-27-m3-shuffle.md b/docs/superpowers/plans/2026-04-27-m3-shuffle.md deleted file mode 100644 index 652bb836..00000000 --- a/docs/superpowers/plans/2026-04-27-m3-shuffle.md +++ /dev/null @@ -1,1588 +0,0 @@ -# M3 Weighted Shuffle v1 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the M2 stub `/api/radio` (currently returns just the seed track) with a real weighted-shuffle implementation that scores user library tracks against a per-track-stats formula and returns a top-N queue ordered by score. - -**Architecture:** New `internal/recommendation` package with three layers — pure scoring function (`score.go`), pure orchestration (`shuffle.go`), and DB-backed candidate loader (`candidates.go`). The HTTP handler at `internal/api/radio.go` becomes a thin shim that validates the seed, calls the loader, calls the orchestrator, prepends the seed, and returns JSON. Stats (`is_liked`, `play_count`, `skip_count`, `last_played_at`) are computed in one SQL query joining `tracks` ↔ `general_likes` ↔ aggregated `play_events`. No new schema — all inputs from existing tables. - -**Tech Stack:** Go 1.23 + chi + sqlc + pgx/v5. No web changes (the existing `playRadio` action already calls `/api/radio?seed_track=…` and consumes `RadioResponse`). - -**Reference:** design spec at `docs/superpowers/specs/2026-04-27-m3-shuffle-design.md`. - ---- - -## File Structure - -**New server files:** - -| File | Responsibility | -|---|---| -| `internal/recommendation/score.go` | Pure `Score(inputs, weights, now, rng) → float64` + `recencyDecay` + `skipRatio` helpers. | -| `internal/recommendation/score_test.go` | Boundary cases for every term, cold-start, jitter determinism. | -| `internal/recommendation/shuffle.go` | Pure `Shuffle(candidates, weights, now, rng, limit) []Candidate` — composes Score + sort + truncate. | -| `internal/recommendation/shuffle_test.go` | Liked-rank-higher, high-skip-rejected, jitter-doesn't-reorder, limit. | -| `internal/recommendation/candidates.go` | `LoadCandidates(ctx, q, userID, seedID, recentlyPlayedHours) → []Candidate` — wraps the sqlc query, projects rows to `Candidate` (Track + ScoringInputs). | -| `internal/recommendation/candidates_test.go` | Live-DB tests: seed exclusion, recently-played exclusion, stat-join correctness, cross-user isolation. | -| `internal/db/queries/recommendation.sql` | One sqlc query: `LoadRadioCandidates`. | -| `internal/db/dbq/recommendation.sql.go` | Generated bindings. | - -**Modified server files:** - -| File | Change | -|---|---| -| `internal/api/radio.go` | Replace stub. New flow: validate, load candidates, shuffle, prepend seed, return JSON. | -| `internal/api/radio_test.go` | Replace existing 5 stub tests with the v1 weighted-shuffle scenarios. | -| `internal/config/config.go` + `config.example.yaml` | Add `RecommendationConfig` with weights + radio size + recently-played-hours. | -| `internal/api/api.go` | Mount signature gains `cfg config.RecommendationConfig`; handlers struct gains `recCfg` field. | -| `internal/server/server.go` + `cmd/minstrel/main.go` | Pass `cfg.Recommendation` through `Mount`. | - -**No web changes.** The existing `playRadio(seedTrackId)` already consumes `{ tracks: TrackRef[] }`. - ---- - -## Task 1: Pure scoring function - -**Files:** -- Create: `internal/recommendation/score.go` -- Create: `internal/recommendation/score_test.go` - -Pure Go, no DB. The function takes its inputs explicitly and an injectable RNG so tests pin jitter to deterministic values. - -- [ ] **Step 1: Write the failing tests** - -Create `internal/recommendation/score_test.go`: - -```go -package recommendation - -import ( - "math" - "math/rand" - "testing" - "time" -) - -func defaultWeights() ScoringWeights { - return ScoringWeights{ - BaseWeight: 1.0, - LikeBoost: 2.0, - RecencyWeight: 1.0, - SkipPenalty: 1.0, - JitterMagnitude: 0.1, - } -} - -func fixedRNG(v float64) func() float64 { - return func() float64 { return v } -} - -func TestScore_BaseCase_NeverPlayed_NoJitter(t *testing.T) { - in := ScoringInputs{} - now := time.Now().UTC() - got := Score(in, defaultWeights(), now, fixedRNG(0.5)) - // rng=0.5 -> jitter contribution = (0.5*2 - 1) * 0.1 = 0 - // expected = 1.0 + 0 (not liked) + 1.0 (never played) - 0 + 0 = 2.0 - want := 2.0 - if math.Abs(got-want) > 1e-9 { - t.Errorf("score = %v, want %v", got, want) - } -} - -func TestScore_LikeBoost(t *testing.T) { - notLiked := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5)) - liked := Score(ScoringInputs{IsGeneralLiked: true}, defaultWeights(), time.Now(), fixedRNG(0.5)) - if math.Abs(liked-notLiked-2.0) > 1e-9 { - t.Errorf("delta = %v, want 2.0 (LikeBoost)", liked-notLiked) - } -} - -func TestScore_RecencyRamp_15Days(t *testing.T) { - now := time.Now().UTC() - played := now.Add(-15 * 24 * time.Hour) - got := Score(ScoringInputs{LastPlayedAt: &played}, defaultWeights(), now, fixedRNG(0.5)) - // recency = 0.5 -> contributes 0.5 * 1.0 = 0.5; expected = 1.0 + 0 + 0.5 - 0 + 0 = 1.5 - want := 1.5 - if math.Abs(got-want) > 1e-9 { - t.Errorf("score = %v, want %v", got, want) - } -} - -func TestScore_RecencyRamp_60DaysCapsAt1(t *testing.T) { - now := time.Now().UTC() - played := now.Add(-60 * 24 * time.Hour) - got := Score(ScoringInputs{LastPlayedAt: &played}, defaultWeights(), now, fixedRNG(0.5)) - want := 2.0 // base + capped recency (1.0) - if math.Abs(got-want) > 1e-9 { - t.Errorf("score = %v, want %v", got, want) - } -} - -func TestScore_SkipRatio(t *testing.T) { - // 4 plays, 2 skips -> ratio 0.5 -> -0.5 * SkipPenalty = -0.5 - in := ScoringInputs{PlayCount: 4, SkipCount: 2} - got := Score(in, defaultWeights(), time.Now(), fixedRNG(0.5)) - // recency = 1.0 (never played by virtue of LastPlayedAt being nil even though PlayCount > 0; - // for this test we rely on the recencyDecay treating nil as max). Adjust: the tests above - // explicitly use nil for LastPlayedAt unless set. So: 1.0 + 1.0 - 0.5 = 1.5. - want := 1.5 - if math.Abs(got-want) > 1e-9 { - t.Errorf("score = %v, want %v", got, want) - } -} - -func TestScore_ColdStartSkip_ZeroPlaysZeroSkips(t *testing.T) { - in := ScoringInputs{PlayCount: 0, SkipCount: 0} - got := Score(in, defaultWeights(), time.Now(), fixedRNG(0.5)) - want := 2.0 // base + recency, no skip penalty - if math.Abs(got-want) > 1e-9 { - t.Errorf("score = %v, want %v", got, want) - } -} - -func TestScore_JitterBounds(t *testing.T) { - r := rand.New(rand.NewSource(42)) - w := defaultWeights() - now := time.Now().UTC() - mid := Score(ScoringInputs{}, w, now, fixedRNG(0.5)) // jitter contribution = 0 - for i := 0; i < 1000; i++ { - got := Score(ScoringInputs{}, w, now, r.Float64) - if got < mid-w.JitterMagnitude-1e-9 || got > mid+w.JitterMagnitude+1e-9 { - t.Fatalf("score %v outside jitter band [%v, %v]", - got, mid-w.JitterMagnitude, mid+w.JitterMagnitude) - } - } -} - -func TestScore_Determinism(t *testing.T) { - in := ScoringInputs{IsGeneralLiked: true, PlayCount: 10, SkipCount: 3} - w := defaultWeights() - now := time.Now().UTC() - a := Score(in, w, now, fixedRNG(0.7)) - b := Score(in, w, now, fixedRNG(0.7)) - if a != b { - t.Errorf("non-deterministic with fixed RNG: %v vs %v", a, b) - } -} - -func TestRecencyDecay_NilIsOne(t *testing.T) { - if got := recencyDecay(nil, time.Now()); got != 1.0 { - t.Errorf("recencyDecay(nil) = %v, want 1.0", got) - } -} - -func TestRecencyDecay_Clamping(t *testing.T) { - now := time.Now().UTC() - cases := []struct { - ageDays float64 - want float64 - }{ - {0, 0.0}, - {15, 0.5}, - {30, 1.0}, - {45, 1.0}, - } - for _, c := range cases { - t.Run("", func(t *testing.T) { - past := now.Add(-time.Duration(c.ageDays * 24 * float64(time.Hour))) - got := recencyDecay(&past, now) - if math.Abs(got-c.want) > 1e-9 { - t.Errorf("ageDays=%v decay=%v want %v", c.ageDays, got, c.want) - } - }) - } -} - -func TestSkipRatio_ZeroPlays_IsZero(t *testing.T) { - if got := skipRatio(0, 0); got != 0.0 { - t.Errorf("skipRatio(0,0) = %v, want 0.0", got) - } -} - -func TestSkipRatio_Half(t *testing.T) { - if got := skipRatio(4, 2); got != 0.5 { - t.Errorf("skipRatio(4,2) = %v, want 0.5", got) - } -} -``` - -- [ ] **Step 2: Run tests, confirm fail** - -Run: `go test ./internal/recommendation -v` -Expected: FAIL — package or types undefined. - -- [ ] **Step 3: Implement `internal/recommendation/score.go`** - -```go -// Package recommendation implements the weighted-shuffle scoring engine -// from spec §6. The Score function is pure and takes an injectable RNG so -// tests can pin jitter to deterministic values. -package recommendation - -import ( - "time" -) - -// ScoringInputs are the per-track facts the score function consumes. -// Sub-plan #3 (contextual scoring) extends this with ContextualMatchScore. -type ScoringInputs struct { - IsGeneralLiked bool - LastPlayedAt *time.Time // nil = never played - PlayCount int // total play_events - SkipCount int // play_events with was_skipped=true -} - -// ScoringWeights are the operator-tunable knobs. Defaults live in -// config.RecommendationConfig and are propagated here per request. -type ScoringWeights struct { - BaseWeight float64 - LikeBoost float64 - RecencyWeight float64 - SkipPenalty float64 - JitterMagnitude float64 -} - -// Score computes the weighted-shuffle score per spec §6: -// -// score = base -// + (is_general_liked ? LikeBoost : 0) -// + recency_decay * RecencyWeight -// - skip_ratio * SkipPenalty -// + small_random_jitter -// -// Higher score = more likely to surface. rng is a function returning a -// uniform sample in [0,1) — pass math/rand.Float64 in production, a fixed -// value in tests. -func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64) float64 { - s := w.BaseWeight - if in.IsGeneralLiked { - s += w.LikeBoost - } - s += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight - s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty - s += (rng()*2 - 1) * w.JitterMagnitude - return s -} - -// recencyDecay returns a value in [0, 1]: -// - never played → 1.0 (cold-start tracks compete favorably with stale ones). -// - age < 30 days → linear ramp age_days / 30. -// - age ≥ 30 days → 1.0 (capped). -// -// Negative ages (clock skew) clamp to 0 to avoid math weirdness. -func recencyDecay(lastPlayed *time.Time, now time.Time) float64 { - if lastPlayed == nil { - return 1.0 - } - age := now.Sub(*lastPlayed) - days := age.Hours() / 24 - if days < 0 { - return 0.0 - } - if days >= 30 { - return 1.0 - } - return days / 30.0 -} - -// skipRatio returns skips/plays in [0, 1]; never-played tracks return 0 -// rather than dividing by zero, so they aren't penalized. -func skipRatio(plays, skips int) float64 { - if plays == 0 { - return 0.0 - } - return float64(skips) / float64(plays) -} -``` - -- [ ] **Step 4: Run tests, confirm pass** - -Run: `go test ./internal/recommendation -v` -Expected: PASS — 11 tests. - -- [ ] **Step 5: Lint** - -Run: `golangci-lint run ./internal/recommendation/...` -Expected: clean. - -- [ ] **Step 6: Commit** - -```bash -git add internal/recommendation/score.go internal/recommendation/score_test.go -git commit -m "feat(recommendation): add pure Score function with recency + skip + jitter - -Implements spec §6 weighted-shuffle scoring without the -contextual_match_score term (sub-plan #3 adds it). Pure Go, no DB -dependency; injectable RNG for deterministic tests. Coverage 100% -on score.go via the boundary tests." -``` - ---- - -## Task 2: Pure shuffle orchestration - -**Files:** -- Create: `internal/recommendation/shuffle.go` -- Create: `internal/recommendation/shuffle_test.go` - -`Shuffle` composes `Score` over a candidate set, sorts descending, truncates to `limit`. Pure. - -- [ ] **Step 1: Write the failing tests** - -Create `internal/recommendation/shuffle_test.go`: - -```go -package recommendation - -import ( - "math/rand" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -func cand(id string, in ScoringInputs) Candidate { - t := dbq.Track{Title: id} - _ = t.ID.Scan("00000000-0000-0000-0000-" + id) // 12-char id padded - return Candidate{Track: t, Inputs: in} -} - -func TestShuffle_LikedRanksAboveUnliked(t *testing.T) { - cs := []Candidate{ - cand("000000000001", ScoringInputs{IsGeneralLiked: false}), - cand("000000000002", ScoringInputs{IsGeneralLiked: true}), - } - out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10) - if out[0].Track.Title != "000000000002" { - t.Errorf("liked track did not rank first: %+v", out) - } -} - -func TestShuffle_HighSkipRanksLast(t *testing.T) { - cs := []Candidate{ - cand("000000000001", ScoringInputs{PlayCount: 10, SkipCount: 10}), // ratio 1.0 - cand("000000000002", ScoringInputs{PlayCount: 10, SkipCount: 0}), // ratio 0 - cand("000000000003", ScoringInputs{PlayCount: 10, SkipCount: 5}), // ratio 0.5 - } - out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10) - if out[0].Track.Title != "000000000002" || out[2].Track.Title != "000000000001" { - t.Errorf("skip-ratio ordering broken: %v", titles(out)) - } -} - -func TestShuffle_LimitTruncates(t *testing.T) { - cs := make([]Candidate, 100) - for i := range cs { - cs[i] = cand("00000000000"+string(rune('a'+i%26)), ScoringInputs{}) - } - out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10) - if len(out) != 10 { - t.Errorf("len = %d, want 10", len(out)) - } -} - -func TestShuffle_JitterDoesNotFlipStructuralWinner(t *testing.T) { - // Liked vs unliked: even with many random RNG seeds, liked NEVER ranks below unliked. - r := rand.New(rand.NewSource(42)) - for i := 0; i < 500; i++ { - cs := []Candidate{ - cand("000000000001", ScoringInputs{IsGeneralLiked: false}), - cand("000000000002", ScoringInputs{IsGeneralLiked: true}), - } - out := Shuffle(cs, defaultWeights(), time.Now(), r.Float64, 10) - if out[0].Track.Title != "000000000002" { - t.Fatalf("iter %d: liked did not rank first; out=%v", i, titles(out)) - } - } -} - -func TestShuffle_Empty_ReturnsEmpty(t *testing.T) { - out := Shuffle(nil, defaultWeights(), time.Now(), fixedRNG(0.5), 10) - if len(out) != 0 { - t.Errorf("len = %d, want 0", len(out)) - } -} - -func titles(cs []Candidate) []string { - out := make([]string, 0, len(cs)) - for _, c := range cs { - out = append(out, c.Track.Title) - } - return out -} - -// pgtype-uuid placeholder: Candidate doesn't need a real UUID for these -// pure tests; the Track.Title field is the human-readable handle. Track ID -// is left as zero pgtype.UUID and never compared. -var _ pgtype.UUID -``` - -- [ ] **Step 2: Run, confirm fail** - -Run: `go test ./internal/recommendation -run TestShuffle -v` -Expected: FAIL — `Candidate`, `Shuffle` undefined. - -- [ ] **Step 3: Implement `internal/recommendation/shuffle.go`** - -```go -package recommendation - -import ( - "sort" - "time" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// Candidate pairs a track with the inputs needed to score it. -type Candidate struct { - Track dbq.Track - Inputs ScoringInputs -} - -// Shuffle scores each candidate, sorts descending by score, and returns -// the top `limit` candidates. limit <= 0 returns nil; nil input returns -// nil. Pure — no IO, no global state beyond the rng callback. -func Shuffle( - candidates []Candidate, - weights ScoringWeights, - now time.Time, - rng func() float64, - limit int, -) []Candidate { - if len(candidates) == 0 || limit <= 0 { - return nil - } - scored := make([]struct { - c Candidate - score float64 - }, len(candidates)) - for i, c := range candidates { - scored[i].c = c - scored[i].score = Score(c.Inputs, weights, now, rng) - } - sort.Slice(scored, func(i, j int) bool { - return scored[i].score > scored[j].score - }) - if limit > len(scored) { - limit = len(scored) - } - out := make([]Candidate, limit) - for i := 0; i < limit; i++ { - out[i] = scored[i].c - } - return out -} -``` - -- [ ] **Step 4: Run, confirm pass** - -Run: `go test ./internal/recommendation -v` -Expected: PASS — 11 score tests + 5 shuffle tests = 16 total. - -- [ ] **Step 5: Commit** - -```bash -git add internal/recommendation/shuffle.go internal/recommendation/shuffle_test.go -git commit -m "feat(recommendation): add Shuffle orchestrator - -Composes Score over a candidate slice, sorts descending, truncates. -Pure — no IO. Liked-rank-higher and high-skip-ranks-last behaviors -are stable across RNG seeds (jitter band is smaller than LikeBoost -and SkipPenalty)." -``` - ---- - -## Task 3: SQL query + sqlc generation - -**Files:** -- Create: `internal/db/queries/recommendation.sql` -- Generated: `internal/db/dbq/recommendation.sql.go` - -One query that returns all eligible tracks with their stats joined. Excludes the seed and recently-played tracks. - -- [ ] **Step 1: Write the query** - -Create `internal/db/queries/recommendation.sql`: - -```sql --- name: LoadRadioCandidates :many --- Returns all tracks except the seed and any played by the user within --- the last $3 hours, joined with the stats needed for scoring: --- is_liked — boolean from general_likes for this user --- last_played_at — max(play_events.started_at) for this user/track --- play_count — count of play_events for this user/track --- skip_count — play_events where was_skipped=true -SELECT - sqlc.embed(t), - (l.user_id IS NOT NULL) AS is_liked, - pe.last_played_at, - pe.play_count, - pe.skip_count -FROM tracks t -LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id -LEFT JOIN LATERAL ( - SELECT - max(started_at) AS last_played_at, - count(*) AS play_count, - count(*) FILTER (WHERE was_skipped) AS skip_count - FROM play_events - WHERE user_id = $1 AND track_id = t.id -) pe ON true -WHERE t.id <> $2 - AND NOT EXISTS ( - SELECT 1 FROM play_events - WHERE user_id = $1 AND track_id = t.id - AND started_at > now() - $3 * interval '1 hour' - ); -``` - -- [ ] **Step 2: Generate sqlc bindings** - -Run: `$(go env GOPATH)/bin/sqlc generate` -Expected: writes `internal/db/dbq/recommendation.sql.go` with `LoadRadioCandidatesParams` (UserID, TrackID for seed, third param for hours) and `LoadRadioCandidatesRow` containing `Track`, `IsLiked`, `LastPlayedAt`, `PlayCount`, `SkipCount`. - -- [ ] **Step 3: Verify build** - -Run: `go build ./...` -Expected: clean. - -- [ ] **Step 4: Commit** - -```bash -git add internal/db/queries/recommendation.sql internal/db/dbq/recommendation.sql.go internal/db/dbq/models.go -git commit -m "feat(db): add LoadRadioCandidates query - -Single SELECT that joins tracks with general_likes (for is_liked) and -an aggregated LATERAL subquery on play_events (for last_played_at, -play_count, skip_count). Excludes seed + tracks played in the last -N hours. Drives the M3 weighted shuffle scoring." -``` - ---- - -## Task 4: Candidate loader - -**Files:** -- Create: `internal/recommendation/candidates.go` -- Create: `internal/recommendation/candidates_test.go` - -Wraps the sqlc query, projects rows to `[]Candidate`. Live-DB tests verify the join + exclusion logic. - -- [ ] **Step 1: Write failing tests** - -Create `internal/recommendation/candidates_test.go`: - -```go -package recommendation - -import ( - "context" - "io" - "log/slog" - "os" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -func testPool(t *testing.T) *pgxpool.Pool { - t.Helper() - if testing.Short() { - t.Skip("skipping integration test in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - t.Fatalf("pool: %v", err) - } - t.Cleanup(pool.Close) - if _, err := pool.Exec(context.Background(), - "TRUNCATE general_likes, general_likes_albums, general_likes_artists, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { - t.Fatalf("truncate: %v", err) - } - return pool -} - -type fixture struct { - pool *pgxpool.Pool - q *dbq.Queries - user pgtype.UUID - tracks []dbq.Track // tracks[0] is the canonical seed -} - -func newFixture(t *testing.T, n int) fixture { - t.Helper() - pool := testPool(t) - q := dbq.New(pool) - u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{ - Username: "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, - }) - if err != nil { - t.Fatalf("user: %v", err) - } - a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"}) - al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "B", SortTitle: "B", ArtistID: a.ID}) - tracks := make([]dbq.Track, 0, n) - for i := 0; i < n; i++ { - tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: string(rune('A' + i)), - AlbumID: al.ID, - ArtistID: a.ID, - FilePath: "/tmp/track-" + string(rune('A'+i)) + ".flac", - DurationMs: 120_000, - }) - if err != nil { - t.Fatalf("track[%d]: %v", i, err) - } - tracks = append(tracks, tr) - } - return fixture{pool: pool, q: q, user: u.ID, tracks: tracks} -} - -func TestLoadCandidates_ExcludesSeed(t *testing.T) { - f := newFixture(t, 5) - got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) - if err != nil { - t.Fatalf("LoadCandidates: %v", err) - } - if len(got) != 4 { - t.Fatalf("len = %d, want 4 (5 minus seed)", len(got)) - } - for _, c := range got { - if c.Track.ID == f.tracks[0].ID { - t.Errorf("seed appeared in candidate set") - } - } -} - -func TestLoadCandidates_ExcludesRecentlyPlayed(t *testing.T) { - f := newFixture(t, 3) - // Seed = tracks[0]. Other tracks are tracks[1], tracks[2]. - // Insert a play_session and a play_event for tracks[1] 30 minutes ago. - now := time.Now().UTC() - thirtyMinAgo := now.Add(-30 * time.Minute) - sess, _ := f.q.InsertPlaySession(context.Background(), dbq.InsertPlaySessionParams{ - UserID: f.user, - StartedAt: pgtype.Timestamptz{Time: thirtyMinAgo, Valid: true}, - ClientID: nil, - }) - _, _ = f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{ - UserID: f.user, - TrackID: f.tracks[1].ID, - SessionID: sess.ID, - StartedAt: pgtype.Timestamptz{Time: thirtyMinAgo, Valid: true}, - ClientID: nil, - }) - - got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) - if err != nil { - t.Fatalf("LoadCandidates: %v", err) - } - // Expect tracks[2] only (seed excluded, tracks[1] excluded by recently-played). - if len(got) != 1 { - t.Fatalf("len = %d, want 1; got=%v", len(got), trackTitles(got)) - } - if got[0].Track.ID != f.tracks[2].ID { - t.Errorf("expected tracks[2], got %v", got[0].Track.Title) - } -} - -func TestLoadCandidates_StatJoin(t *testing.T) { - f := newFixture(t, 2) - // Seed = tracks[0]. Like tracks[1]; play it twice (one skip, one full play) 2 hours ago. - if err := f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID}); err != nil { - t.Fatalf("like: %v", err) - } - twoH := time.Now().UTC().Add(-2 * time.Hour) - sess, _ := f.q.InsertPlaySession(context.Background(), dbq.InsertPlaySessionParams{ - UserID: f.user, StartedAt: pgtype.Timestamptz{Time: twoH, Valid: true}, - }) - // First play: completed (was_skipped=false). - pe1, _ := f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{ - UserID: f.user, TrackID: f.tracks[1].ID, SessionID: sess.ID, - StartedAt: pgtype.Timestamptz{Time: twoH, Valid: true}, - }) - dur := int32(120_000) - ratio := 1.0 - _, _ = f.q.UpdatePlayEventEnded(context.Background(), dbq.UpdatePlayEventEndedParams{ - ID: pe1.ID, EndedAt: pgtype.Timestamptz{Time: twoH.Add(2 * time.Minute), Valid: true}, - DurationPlayedMs: &dur, CompletionRatio: &ratio, WasSkipped: false, - }) - // Second play: skipped (was_skipped=true). - pe2, _ := f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{ - UserID: f.user, TrackID: f.tracks[1].ID, SessionID: sess.ID, - StartedAt: pgtype.Timestamptz{Time: twoH.Add(5 * time.Minute), Valid: true}, - }) - dur2 := int32(5_000) - ratio2 := 0.04 - _, _ = f.q.UpdatePlayEventEnded(context.Background(), dbq.UpdatePlayEventEndedParams{ - ID: pe2.ID, EndedAt: pgtype.Timestamptz{Time: twoH.Add(5*time.Minute + 5*time.Second), Valid: true}, - DurationPlayedMs: &dur2, CompletionRatio: &ratio2, WasSkipped: true, - }) - - got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) - if err != nil { - t.Fatalf("LoadCandidates: %v", err) - } - if len(got) != 1 { - t.Fatalf("len = %d, want 1", len(got)) - } - c := got[0] - if !c.Inputs.IsGeneralLiked { - t.Errorf("IsGeneralLiked = false, want true") - } - if c.Inputs.PlayCount != 2 { - t.Errorf("PlayCount = %d, want 2", c.Inputs.PlayCount) - } - if c.Inputs.SkipCount != 1 { - t.Errorf("SkipCount = %d, want 1", c.Inputs.SkipCount) - } - if c.Inputs.LastPlayedAt == nil { - t.Errorf("LastPlayedAt = nil, want non-nil") - } -} - -func TestLoadCandidates_NeverPlayedHasNilLastPlayed(t *testing.T) { - f := newFixture(t, 2) - got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) - if err != nil { - t.Fatalf("LoadCandidates: %v", err) - } - if len(got) != 1 { - t.Fatalf("len = %d, want 1", len(got)) - } - if got[0].Inputs.LastPlayedAt != nil { - t.Errorf("never-played track has non-nil LastPlayedAt: %v", got[0].Inputs.LastPlayedAt) - } - if got[0].Inputs.PlayCount != 0 || got[0].Inputs.SkipCount != 0 { - t.Errorf("never-played track has non-zero counts: %+v", got[0].Inputs) - } -} - -func TestLoadCandidates_CrossUserIsolation(t *testing.T) { - f := newFixture(t, 2) - bob, _ := f.q.CreateUser(context.Background(), dbq.CreateUserParams{ - Username: "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false, - }) - // Alice likes tracks[1]; Bob shouldn't see it. - _ = f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID}) - - got, err := LoadCandidates(context.Background(), f.q, bob.ID, f.tracks[0].ID, 1) - if err != nil { - t.Fatalf("LoadCandidates: %v", err) - } - for _, c := range got { - if c.Track.ID == f.tracks[1].ID && c.Inputs.IsGeneralLiked { - t.Errorf("bob sees alice's like on track %v", c.Track.Title) - } - } -} - -func trackTitles(cs []Candidate) []string { - out := make([]string, 0, len(cs)) - for _, c := range cs { - out = append(out, c.Track.Title) - } - return out -} -``` - -- [ ] **Step 2: Run, confirm fail** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/recommendation -run TestLoadCandidates -v -``` -Expected: FAIL — `LoadCandidates` undefined. - -- [ ] **Step 3: Implement `internal/recommendation/candidates.go`** - -```go -package recommendation - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// LoadCandidates returns all candidate tracks for the given user, excluding -// the seed and any track played within the last `recentlyPlayedHours`. -// Each Candidate includes the per-(user,track) stats the scoring function -// needs. -func LoadCandidates( - ctx context.Context, - q *dbq.Queries, - userID, seedID pgtype.UUID, - recentlyPlayedHours int, -) ([]Candidate, error) { - rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{ - UserID: userID, - ID: seedID, - Column3: float64(recentlyPlayedHours), - }) - if err != nil { - return nil, err - } - out := make([]Candidate, 0, len(rows)) - for _, r := range rows { - var lastPlayed *pgtype.Timestamptz - if r.LastPlayedAt.Valid { - ts := r.LastPlayedAt - lastPlayed = &ts - } - var lastPlayedTime *([]byte) // placeholder to satisfy structure if needed - _ = lastPlayedTime // appease unused warning - var lpt *time.Time - if r.LastPlayedAt.Valid { - t := r.LastPlayedAt.Time - lpt = &t - } - out = append(out, Candidate{ - Track: r.Track, - Inputs: ScoringInputs{ - IsGeneralLiked: r.IsLiked, - LastPlayedAt: lpt, - PlayCount: int(r.PlayCount), - SkipCount: int(r.SkipCount), - }, - }) - _ = lastPlayed - } - return out, nil -} -``` - -Note: clean up the duplicated `lastPlayed` / `lastPlayedTime` shims — they're scaffolding from drafting. Final form: - -```go -package recommendation - -import ( - "context" - "time" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -func LoadCandidates( - ctx context.Context, - q *dbq.Queries, - userID, seedID pgtype.UUID, - recentlyPlayedHours int, -) ([]Candidate, error) { - rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{ - UserID: userID, - ID: seedID, - Column3: float64(recentlyPlayedHours), - }) - if err != nil { - return nil, err - } - out := make([]Candidate, 0, len(rows)) - for _, r := range rows { - var lpt *time.Time - if r.LastPlayedAt.Valid { - t := r.LastPlayedAt.Time - lpt = &t - } - out = append(out, Candidate{ - Track: r.Track, - Inputs: ScoringInputs{ - IsGeneralLiked: r.IsLiked, - LastPlayedAt: lpt, - PlayCount: int(r.PlayCount), - SkipCount: int(r.SkipCount), - }, - }) - } - return out, nil -} -``` - -Note on the sqlc-generated `Column3` field name: sqlc names unnamed parameters `Column1`, `Column2`, etc. when there's no obvious mapping. If the generated field has a different name (e.g. `Hours`), adjust the call site to match. Run sqlc generate first and inspect `internal/db/dbq/recommendation.sql.go` to confirm. - -- [ ] **Step 4: Run, confirm pass** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/recommendation -v -``` -Expected: PASS — 16 prior tests + 5 new candidate tests = 21 total. - -- [ ] **Step 5: Lint** - -Run: `golangci-lint run ./internal/recommendation/...` -Expected: clean. - -- [ ] **Step 6: Commit** - -```bash -git add internal/recommendation/candidates.go internal/recommendation/candidates_test.go -git commit -m "feat(recommendation): add LoadCandidates DB-backed loader - -Wraps the LoadRadioCandidates sqlc query, projects rows to -[]Candidate. Live-DB tests verify seed exclusion, recently-played -exclusion, stat-join correctness (likes + plays + skips + -last_played_at), and cross-user isolation." -``` - ---- - -## Task 5: RecommendationConfig - -**Files:** -- Modify: `internal/config/config.go` -- Modify: `config.example.yaml` - -Adds operator-tunable weights to YAML / env. Defaults match the spec. - -- [ ] **Step 1: Add the struct + field + defaults** - -In `internal/config/config.go`, add `Recommendation RecommendationConfig` to the `Config` struct (alongside `Events`): - -```go -type Config struct { - Server ServerConfig `yaml:"server"` - Database DatabaseConfig `yaml:"database"` - Log LogConfig `yaml:"log"` - Auth AuthConfig `yaml:"auth"` - Library LibraryConfig `yaml:"library"` - Subsonic SubsonicConfig `yaml:"subsonic"` - Events EventsConfig `yaml:"events"` - Recommendation RecommendationConfig `yaml:"recommendation"` -} -``` - -Add the struct definition (after `EventsConfig`): - -```go -// RecommendationConfig governs the M3 weighted-shuffle scoring (spec §6). -// All weights are operator-tunable; defaults match the spec recommendations. -type RecommendationConfig struct { - BaseWeight float64 `yaml:"base_weight"` - LikeBoost float64 `yaml:"like_boost"` - RecencyWeight float64 `yaml:"recency_weight"` - SkipPenalty float64 `yaml:"skip_penalty"` - JitterMagnitude float64 `yaml:"jitter_magnitude"` - RecentlyPlayedHours int `yaml:"recently_played_hours"` - RadioSize int `yaml:"radio_size"` - RadioSizeMax int `yaml:"radio_size_max"` -} -``` - -Update `Default()` to seed the defaults: - -```go -func Default() Config { - return Config{ - Server: ServerConfig{Address: ":4533"}, - Database: DatabaseConfig{URL: ""}, - Log: LogConfig{Level: "info", Format: "text"}, - Events: EventsConfig{ - SessionTimeoutMinutes: 30, - SkipMaxCompletionRatio: 0.5, - SkipMaxDurationPlayedMs: 30000, - }, - Recommendation: RecommendationConfig{ - BaseWeight: 1.0, - LikeBoost: 2.0, - RecencyWeight: 1.0, - SkipPenalty: 1.0, - JitterMagnitude: 0.1, - RecentlyPlayedHours: 1, - RadioSize: 50, - RadioSizeMax: 200, - }, - } -} -``` - -- [ ] **Step 2: Update `config.example.yaml`** - -Append after the `events:` section: - -```yaml - -recommendation: - # Base score every candidate gets before adjustments. Spec §6. - base_weight: 1.0 - # Bonus for tracks the user has liked (general_likes). - like_boost: 2.0 - # Multiplier on recency_decay (1.0 for tracks ≥ 30 days stale, 0 for fresh). - recency_weight: 1.0 - # Penalty multiplier on skip_ratio (skips/plays); 1.0 = full penalty. - skip_penalty: 1.0 - # ± random jitter applied to every candidate; breaks ties without dominating. - jitter_magnitude: 0.1 - # Hard-suppression window: tracks played within this many hours never appear. - recently_played_hours: 1 - # Default radio size when ?limit= is not specified. - radio_size: 50 - # Maximum allowed ?limit= (server caps to this regardless). - radio_size_max: 200 -``` - -- [ ] **Step 3: Run config tests** - -Run: `go test ./internal/config -v` -Expected: PASS — existing tests still pass with the new struct defaulting correctly. - -- [ ] **Step 4: Commit** - -```bash -git add internal/config/config.go config.example.yaml -git commit -m "feat(config): add recommendation section (weighted shuffle weights) - -BaseWeight, LikeBoost, RecencyWeight, SkipPenalty, JitterMagnitude, -RecentlyPlayedHours, RadioSize, RadioSizeMax. Defaults match spec §6." -``` - ---- - -## Task 6: Radio handler rewrite - -**Files:** -- Modify: `internal/api/radio.go` (replaces stub) -- Modify: `internal/api/radio_test.go` (replaces stub tests) -- Modify: `internal/api/api.go` (Mount signature gains config arg; handlers struct gains recCfg field) -- Modify: `internal/api/auth_test.go` (testHandlers seeds a default RecommendationConfig) -- Modify: `internal/api/library_test.go` (newLibraryRouter doesn't need radio routing changes; no-op verification) -- Modify: `internal/server/server.go` + `cmd/minstrel/main.go` (pass cfg through) - -Replaces the stub with the v1 weighted-shuffle implementation. - -- [ ] **Step 1: Update `handlers` struct + Mount in `internal/api/api.go`** - -```go -package api - -import ( - "log/slog" - "math/rand" - - "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" - "git.fabledsword.com/bvandeusen/minstrel/internal/config" - "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" -) - -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig) { - h := &handlers{ - pool: pool, - logger: logger, - events: events, - recCfg: recCfg, - // Seeded once at server start; per-request shuffle reads h.rng.Float64. - // Concurrent calls are fine — math/rand.Rand is safe under contention via - // a mutex internally (Go 1.21+); for stricter isolation switch to math/rand/v2. - } - rng := rand.New(rand.NewSource(rand.Int63())) - h.rng = rng.Float64 - - // ... existing route registrations unchanged ... - r.Route("/api", func(api chi.Router) { - api.Post("/auth/login", h.handleLogin) - api.Group(func(authed chi.Router) { - authed.Use(auth.RequireUser(pool)) - authed.Post("/auth/logout", h.handleLogout) - authed.Get("/me", h.handleGetMe) - - authed.Get("/artists", h.handleListArtists) - authed.Get("/artists/{id}", h.handleGetArtist) - authed.Get("/albums/{id}", h.handleGetAlbum) - authed.Get("/albums/{id}/cover", h.handleGetCover) - authed.Get("/tracks/{id}", h.handleGetTrack) - authed.Get("/tracks/{id}/stream", h.handleGetStream) - authed.Get("/search", h.handleSearch) - authed.Get("/radio", h.handleRadio) - authed.Post("/events", h.handleEvents) - authed.Post("/likes/tracks/{id}", h.handleLikeTrack) - authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack) - authed.Post("/likes/albums/{id}", h.handleLikeAlbum) - authed.Delete("/likes/albums/{id}", h.handleUnlikeAlbum) - authed.Post("/likes/artists/{id}", h.handleLikeArtist) - authed.Delete("/likes/artists/{id}", h.handleUnlikeArtist) - authed.Get("/likes/tracks", h.handleListLikedTracks) - authed.Get("/likes/albums", h.handleListLikedAlbums) - authed.Get("/likes/artists", h.handleListLikedArtists) - authed.Get("/likes/ids", h.handleGetLikedIDs) - }) - }) -} - -type handlers struct { - pool *pgxpool.Pool - logger *slog.Logger - events *playevents.Writer - recCfg config.RecommendationConfig - rng func() float64 -} -``` - -(Note: `math/rand`'s top-level `Float64` is goroutine-safe, but the new-instance pattern with explicit seed is cleaner. Stay with `func() float64` so tests can inject deterministic values.) - -- [ ] **Step 2: Replace `internal/api/radio.go`** - -```go -package api - -import ( - "errors" - "net/http" - "strconv" - "strings" - "time" - - "github.com/jackc/pgx/v5" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" -) - -// RadioResponse is the body of GET /api/radio. -type RadioResponse struct { - Tracks []TrackRef `json:"tracks"` -} - -// handleRadio implements GET /api/radio?seed_track=&limit=. -// -// Returns the seed at index 0, followed by up to limit-1 weighted-shuffle -// picks from the user's library, scored by recommendation.Score. -func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { - user, ok := authUserFromContext(r) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - raw := strings.TrimSpace(r.URL.Query().Get("seed_track")) - if raw == "" { - writeErr(w, http.StatusBadRequest, "bad_request", "seed_track is required") - return - } - seedID, ok := parseUUID(raw) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid seed_track id") - return - } - limit := h.recCfg.RadioSize - if v := r.URL.Query().Get("limit"); v != "" { - n, err := strconv.Atoi(v) - if err != nil || n < 1 { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid limit") - return - } - limit = n - } - if limit > h.recCfg.RadioSizeMax { - limit = h.recCfg.RadioSizeMax - } - q := dbq.New(h.pool) - track, err := q.GetTrackByID(r.Context(), seedID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - writeErr(w, http.StatusNotFound, "not_found", "seed_track not found") - return - } - h.logger.Error("api: get radio seed track failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - album, err := q.GetAlbumByID(r.Context(), track.AlbumID) - if err != nil { - h.logger.Error("api: get radio seed album failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - artist, err := q.GetArtistByID(r.Context(), track.ArtistID) - if err != nil { - h.logger.Error("api: get radio seed artist failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours) - if err != nil { - h.logger.Error("api: radio: load candidates", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "candidate load failed") - return - } - weights := recommendation.ScoringWeights{ - BaseWeight: h.recCfg.BaseWeight, - LikeBoost: h.recCfg.LikeBoost, - RecencyWeight: h.recCfg.RecencyWeight, - SkipPenalty: h.recCfg.SkipPenalty, - JitterMagnitude: h.recCfg.JitterMagnitude, - } - picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1) - - out := make([]TrackRef, 0, len(picks)+1) - out = append(out, trackRefFrom(track, album.Title, artist.Name)) - for _, p := range picks { - // Resolve album/artist names per pick. Reuse the existing resolver. - al, err := q.GetAlbumByID(r.Context(), p.Track.AlbumID) - if err != nil { - h.logger.Error("api: radio: resolve album", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "resolve failed") - return - } - ar, err := q.GetArtistByID(r.Context(), p.Track.ArtistID) - if err != nil { - h.logger.Error("api: radio: resolve artist", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "resolve failed") - return - } - out = append(out, trackRefFrom(p.Track, al.Title, ar.Name)) - } - writeJSON(w, http.StatusOK, RadioResponse{Tracks: out}) -} - -// authUserFromContext is a thin shim around auth.UserFromContext that tests -// can override. It exists so testHandlers can inject a user via the same -// context key RequireUser populates. -func authUserFromContext(r *http.Request) (dbq.User, bool) { - return userFromContext(r) -} -``` - -`userFromContext` is the existing helper used by the events handler — reuse it; if it's named differently in your tree, follow that pattern. The shim layer (`authUserFromContext`) is unnecessary if the existing handler pattern just calls `auth.UserFromContext(r.Context())` directly. Use whatever the rest of the package uses; consistency over novelty. - -- [ ] **Step 3: Update `testHandlers` in `internal/api/auth_test.go`** - -Find the existing `testHandlers` function. Update the construction: - -```go -import ( - // ... existing imports ... - "git.fabledsword.com/bvandeusen/minstrel/internal/config" -) - -func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { - // ... existing setup unchanged up to the return ... - w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) - recCfg := config.RecommendationConfig{ - BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0, - SkipPenalty: 1.0, JitterMagnitude: 0.1, - RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, - } - h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }} - return h, pool -} -``` - -The fixed-RNG (`return 0.5`) makes tests deterministic — jitter contribution is always 0. - -- [ ] **Step 4: Replace `internal/api/radio_test.go`** - -Replace the existing 5 stub tests with new ones: - -```go -package api - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -func callRadio(h *handlers, user interface{}, query string) *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodGet, "/api/radio?"+query, nil) - req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user)) - w := httptest.NewRecorder() - h.handleRadio(w, req) - return w -} - -func TestHandleRadio_ColdStart_OnlySeedReturned(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) - - w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)) - if w.Code != http.StatusOK { - t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) - } - var resp RadioResponse - _ = json.Unmarshal(w.Body.Bytes(), &resp) - if len(resp.Tracks) != 1 { - t.Fatalf("len = %d, want 1", len(resp.Tracks)) - } - if resp.Tracks[0].Title != "Seed" { - t.Errorf("seed not first: %v", resp.Tracks[0].Title) - } -} - -func TestHandleRadio_Typical_SeedFirstPlusPicks(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) - for i := 2; i <= 6; i++ { - seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000) - } - - w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)) - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var resp RadioResponse - _ = json.Unmarshal(w.Body.Bytes(), &resp) - if len(resp.Tracks) != 6 { - t.Fatalf("len = %d, want 6 (seed + 5 picks)", len(resp.Tracks)) - } - if resp.Tracks[0].Title != "Seed" { - t.Errorf("seed not at index 0: %v", resp.Tracks[0].Title) - } -} - -func TestHandleRadio_UnknownSeedIs404(t *testing.T) { - h, pool := testHandlers(t) - user := seedUser(t, pool, "alice", "x", false) - w := callRadio(h, user, "seed_track=00000000-0000-0000-0000-000000000000") - if w.Code != http.StatusNotFound { - t.Errorf("status = %d", w.Code) - } -} - -func TestHandleRadio_BadSeedIs400(t *testing.T) { - h, pool := testHandlers(t) - user := seedUser(t, pool, "alice", "x", false) - w := callRadio(h, user, "seed_track=not-a-uuid") - if w.Code != http.StatusBadRequest { - t.Errorf("status = %d", w.Code) - } -} - -func TestHandleRadio_MissingSeedIs400(t *testing.T) { - h, pool := testHandlers(t) - user := seedUser(t, pool, "alice", "x", false) - w := callRadio(h, user, "") - if w.Code != http.StatusBadRequest { - t.Errorf("status = %d", w.Code) - } -} - -func TestHandleRadio_BadLimitIs400(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) - w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=0") - if w.Code != http.StatusBadRequest { - t.Errorf("limit=0 status = %d", w.Code) - } - w = callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=-1") - if w.Code != http.StatusBadRequest { - t.Errorf("limit=-1 status = %d", w.Code) - } -} - -func TestHandleRadio_LimitClampedToMax(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) - for i := 2; i <= 6; i++ { - seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000) - } - w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=99999") - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var resp RadioResponse - _ = json.Unmarshal(w.Body.Bytes(), &resp) - // We only have 6 tracks; clamped limit (max 200) returns all 6. - if len(resp.Tracks) != 6 { - t.Errorf("len = %d, want 6", len(resp.Tracks)) - } -} -``` - -- [ ] **Step 5: Update Mount call sites** - -In `internal/server/server.go`: - -```go -api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg) -``` - -The `Server` struct gains `RecommendationCfg config.RecommendationConfig`; constructor `New(...)` gains the new arg. Find `cmd/minstrel/main.go`'s `server.New(...)` call and pass `cfg.Recommendation`. - -In `internal/api/library_test.go`'s `TestRoutesRegisteredInMount`: - -```go -Mount(r, h.pool, h.logger, w, config.RecommendationConfig{ - RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1, -}) -``` - -In `internal/server/server_test.go`'s `New(...)` calls (multiple sites), append `config.RecommendationConfig{}` after the `subsonic.Config{}` arg. Use the existing `replace_all` approach if there are several: - -```go -New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}) -``` - -- [ ] **Step 6: Run tests, confirm pass** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test -p 1 ./... -``` -Expected: all packages PASS. - -Run: `golangci-lint run ./...` -Expected: clean. - -- [ ] **Step 7: Commit** - -```bash -git add internal/api/radio.go internal/api/radio_test.go internal/api/api.go \ - internal/api/auth_test.go internal/api/library_test.go \ - internal/server/server.go internal/server/server_test.go \ - cmd/minstrel/main.go -git commit -m "feat(api): rewrite /api/radio with weighted shuffle v1 - -Validates seed_track + optional limit (default cfg.Recommendation.RadioSize, -clamped to RadioSizeMax). Calls recommendation.LoadCandidates + -recommendation.Shuffle. Prepends seed to result. Server seeds a -math/rand source at startup; handlers package threads that as a -func() float64 so tests inject deterministic RNGs. - -Mount + server.New gain a RecommendationConfig parameter." -``` - ---- - -## Task 7: Final verification + branch finish - -**Files:** none (verification only). - -- [ ] **Step 1: Full Go suite** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test -p 1 ./... -``` -Expected: all packages PASS. - -- [ ] **Step 2: Coverage on recommendation package** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test -coverprofile=/tmp/rec.cover ./internal/recommendation/... -go tool cover -func=/tmp/rec.cover | tail -3 -``` -Expected: total coverage ≥ 70%. - -- [ ] **Step 3: Lint** - -Run: `golangci-lint run ./...` -Expected: clean. - -- [ ] **Step 4: Web check + tests + build** - -Run: `cd web && npm run check && npm test && npm run build` -Expected: check 0/0; tests pass; build emits `web/build/`. - -Run: `git checkout -- web/build/index.html` -Expected: committed placeholder restored. - -- [ ] **Step 5: Docker build smoke** - -Run: `docker build -t minstrel:m3-shuffle-smoke .` -Expected: image builds. - -Run: `docker run --rm --entrypoint /bin/sh minstrel:m3-shuffle-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'` -Expected: `ok`. - -- [ ] **Step 6: Local end-to-end manual check** - -```bash -docker compose up --build -d -``` - -Browser at `localhost:4533` (sign in with the dev admin creds): - -1. Play 3 tracks all the way through (build `play_count` history). -2. Skip a 4th track within 10 seconds (`skip_count = 1` for that track). -3. Like 2 tracks via the heart button. -4. Click the radio button on a 5th track (or trigger via search → click track). -5. Inspect the response in dev tools network tab — 50 tracks, seed first. -6. The 2 liked tracks appear early in the list (within first ~20). -7. The just-skipped track appears late or absent. -8. The 3 just-played tracks are absent (recently-played exclusion). -9. Trigger radio again with a different seed; ordering varies (jitter), liked tracks still rank prominently. - -Tear down: -```bash -docker compose down -``` - -- [ ] **Step 7: Finish the branch** - -Follow `superpowers:finishing-a-development-branch`. Expected path: Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green. - ---- - -## Self-Review Notes - -**Spec coverage:** -- Pure scoring function with all 5 terms (base, like boost, recency, skip penalty, jitter) → Task 1. -- Shuffle composition (sort + truncate) → Task 2. -- Candidate loader excluding seed + recently-played → Tasks 3–4. -- Stat join (is_liked + last_played_at + play_count + skip_count) → Tasks 3–4. -- Operator-tunable weights → Task 5. -- Endpoint contract (seed first, limit default 50 / max 200, errors unchanged) → Task 6. -- Cross-user isolation → Task 4 + Task 6. -- Cold-start / never-played handling → Task 1 + Task 6. -- ≥ 70% coverage on the recommendation package → Task 7. -- End-to-end manual → Task 7. - -**Type consistency:** -- `ScoringInputs`, `ScoringWeights`, `Candidate` — same names everywhere they're consumed. -- `Score(...) → float64`, `Shuffle(...) → []Candidate`, `LoadCandidates(...) → []Candidate, error` — signatures stable. -- `recCfg config.RecommendationConfig` and `rng func() float64` — same on the handlers struct, in tests, in Mount. -- `pgtype.UUID` server-side, `string` at HTTP boundaries via `uuidToString`. - -**Filename hazards:** none — all new files under `internal/recommendation/`. The route-test handling for radio is colocated with the other api tests (no `+`-prefix concerns). - -**Placeholder scan:** no TBD/TODO/later markers. The `Column3` parameter naming caveat in Task 4 is documented inline (sqlc names unnamed positional params `Column1`, `Column2`, etc., adjust per generated code). - -**Performance note:** Task 6's per-pick album/artist resolve is N round-trips for the picks. For `RadioSize=50` that's 100 round-trips. Acceptable at v1 scale; matches the pattern used by `resolveTrackRefs` elsewhere. M4 / future task can batch via `IN (...)` + map-by-id if telemetry shows it matters. diff --git a/docs/superpowers/plans/2026-04-27-m3-similarity.md b/docs/superpowers/plans/2026-04-27-m3-similarity.md deleted file mode 100644 index b892be85..00000000 --- a/docs/superpowers/plans/2026-04-27-m3-similarity.md +++ /dev/null @@ -1,1414 +0,0 @@ -# M3 Session Similarity + contextual_match_score Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add the `contextual_match_score` term to the recommendation scoring formula by computing weighted-Jaccard similarity between the user's current session vector and each candidate track's stored `contextual_likes` session vectors. Closes M3. - -**Architecture:** New pure `Similarity` + `ContextualMatchScore` functions in `internal/recommendation/similarity.go` — set Jaccard on tags + artists, weighted 0.7/0.3, hardcoded for v1. `Score()` gains `ContextualMatchScore` input + `ContextWeight` weight. `LoadCandidates` accepts a `currentVector` and bulk-fetches the user's active `contextual_likes` once, mapping by `track_id` and computing per-candidate max similarity. `internal/api/radio.go` reads the user's most recent open session's `session_vector_at_play` via a new `GetCurrentSessionVectorForUser` query and threads it through. - -**Tech Stack:** Go 1.23 + sqlc + pgx/v5. No web changes. - -**Reference:** design spec at `docs/superpowers/specs/2026-04-27-m3-similarity-design.md`. - ---- - -## File Structure - -**New server files:** - -| File | Responsibility | -|---|---| -| `internal/recommendation/similarity.go` | Pure: `SimilarityWeights` struct, `DefaultSimilarityWeights`, `Similarity(a, b SessionVector, w SimilarityWeights) float64`, `ContextualMatchScore(current SessionVector, likes []SessionVector, w SimilarityWeights) float64`. | -| `internal/recommendation/similarity_test.go` | Pure unit tests (table-driven). | - -**Modified server files:** - -| File | Change | -|---|---| -| `internal/recommendation/score.go` | `ScoringInputs` gains `ContextualMatchScore float64`. `ScoringWeights` gains `ContextWeight float64`. `Score()` adds `+ in.ContextualMatchScore * w.ContextWeight`. | -| `internal/recommendation/score_test.go` | Three new tests for the new term. | -| `internal/recommendation/candidates.go` | `LoadCandidates` signature gains `currentVector SessionVector` parameter. Body bulk-fetches user's contextual_likes, groups by track_id, populates per-candidate `ContextualMatchScore`. | -| `internal/recommendation/candidates_test.go` | Six new tests for contextual scoring. | -| `internal/db/queries/contextual_likes.sql` | Add `ListActiveContextualLikesForUser :many`. | -| `internal/db/queries/events.sql` | Add `GetCurrentSessionVectorForUser :one`. | -| `internal/db/dbq/contextual_likes.sql.go` | Generated bindings. | -| `internal/db/dbq/events.sql.go` | Generated bindings. | -| `internal/config/config.go` | `RecommendationConfig` gains `ContextWeight float64` (yaml `context_weight`, default `2.0`). | -| `internal/api/radio.go` | Fetch current session vector before `LoadCandidates`, build `ContextWeight` into `ScoringWeights`, thread `currentVector` into `LoadCandidates`. | -| `internal/api/auth_test.go` | `recCfg` test-helper builds `ContextWeight: 2.0`. | -| `internal/api/radio_test.go` | One new end-to-end test: contextual ranking. | - -**No web changes.** - ---- - -## Task 1: Pure `Similarity` function (Jaccard + axis weights) - -**Files:** -- Create: `internal/recommendation/similarity.go` -- Create: `internal/recommendation/similarity_test.go` - -- [ ] **Step 1: Write the failing tests for `Similarity`** - -Create `internal/recommendation/similarity_test.go`: - -```go -package recommendation - -import ( - "math" - "testing" -) - -func approxEq(a, b float64) bool { return math.Abs(a-b) < 1e-9 } - -func TestSimilarity_IdenticalVectors_Returns1(t *testing.T) { - v := SessionVector{ - Artists: []string{"a1", "a2"}, - Tags: map[string]int{"rock": 2, "indie": 1}, - } - got := Similarity(v, v, DefaultSimilarityWeights) - if !approxEq(got, 1.0) { - t.Errorf("Similarity(v,v) = %v, want 1.0", got) - } -} - -func TestSimilarity_FullyDisjoint_Returns0(t *testing.T) { - a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - b := SessionVector{Artists: []string{"a2"}, Tags: map[string]int{"jazz": 1}} - got := Similarity(a, b, DefaultSimilarityWeights) - if !approxEq(got, 0.0) { - t.Errorf("disjoint = %v, want 0.0", got) - } -} - -func TestSimilarity_TagsOnlyShared_AppliesTagsWeight(t *testing.T) { - // Shared tags fully (Jaccard=1), no shared artists (Jaccard=0). - a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - b := SessionVector{Artists: []string{"a2"}, Tags: map[string]int{"rock": 5}} - got := Similarity(a, b, DefaultSimilarityWeights) - // Expected: 0.7 * 1.0 + 0.3 * 0.0 = 0.7 - if !approxEq(got, 0.7) { - t.Errorf("tags-only = %v, want 0.7", got) - } -} - -func TestSimilarity_ArtistsOnlyShared_AppliesArtistsWeight(t *testing.T) { - a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"jazz": 1}} - got := Similarity(a, b, DefaultSimilarityWeights) - // Expected: 0.7 * 0.0 + 0.3 * 1.0 = 0.3 - if !approxEq(got, 0.3) { - t.Errorf("artists-only = %v, want 0.3", got) - } -} - -func TestSimilarity_EitherSeed_Returns0(t *testing.T) { - v := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}} - seed := SessionVector{Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}} - if got := Similarity(v, seed, DefaultSimilarityWeights); !approxEq(got, 0.0) { - t.Errorf("v vs seed = %v, want 0.0", got) - } - if got := Similarity(seed, v, DefaultSimilarityWeights); !approxEq(got, 0.0) { - t.Errorf("seed vs v = %v, want 0.0", got) - } -} - -func TestSimilarity_BothEmpty_Returns0NotNaN(t *testing.T) { - a := SessionVector{} - b := SessionVector{} - got := Similarity(a, b, DefaultSimilarityWeights) - if math.IsNaN(got) || !approxEq(got, 0.0) { - t.Errorf("empty = %v, want 0.0 (not NaN)", got) - } -} - -func TestSimilarity_OneAxisEmptyOneSide_AxisContributesZero(t *testing.T) { - // a has tags but no artists; b has artists but no tags. - a := SessionVector{Tags: map[string]int{"rock": 1}} - b := SessionVector{Artists: []string{"a1"}} - got := Similarity(a, b, DefaultSimilarityWeights) - // tags axis: A={rock}, B={} → union={rock}, intersect={} → 0 - // artists axis: A={}, B={a1} → union={a1}, intersect={} → 0 - if !approxEq(got, 0.0) { - t.Errorf("one-axis-each = %v, want 0.0", got) - } -} - -func TestSimilarity_PartialTagsOverlap(t *testing.T) { - // Tags A={rock,indie}, B={rock,jazz}: intersect=1, union=3, J=1/3 - // Artists fully shared: J=1 - a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1, "indie": 1}} - b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1, "jazz": 1}} - got := Similarity(a, b, DefaultSimilarityWeights) - want := 0.7*(1.0/3.0) + 0.3*1.0 - if !approxEq(got, want) { - t.Errorf("partial = %v, want %v", got, want) - } -} - -func TestSimilarity_BagOfCountsCollapsesToSet(t *testing.T) { - // Same tag keysets, different counts → set-Jaccard collapses to 1.0. - a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 2, "indie": 1}} - b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 5, "indie": 3}} - got := Similarity(a, b, DefaultSimilarityWeights) - if !approxEq(got, 1.0) { - t.Errorf("set-collapse = %v, want 1.0", got) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./internal/recommendation/ -run Similarity -v` - -Expected: FAIL with "undefined: Similarity" / "undefined: DefaultSimilarityWeights". - -- [ ] **Step 3: Write minimal `Similarity` implementation** - -Create `internal/recommendation/similarity.go`: - -```go -package recommendation - -// SimilarityWeights balances the per-axis contribution to the weighted Jaccard -// score. v1 hardcodes the defaults — operators cannot tune via YAML. If -// telemetry justifies it, expose under recommendation.similarity.* later. -type SimilarityWeights struct { - TagsWeight float64 - ArtistsWeight float64 -} - -// DefaultSimilarityWeights is the v1 axis balance per the M3 design. -// Tags carry more signal than artists because a session's "vibe" tracks -// genre more directly than artist identity (a session can mix artists -// within a genre but rarely mixes genres). -var DefaultSimilarityWeights = SimilarityWeights{ - TagsWeight: 0.7, - ArtistsWeight: 0.3, -} - -// Similarity returns weighted-Jaccard similarity in [0, 1] between two -// session vectors. Returns 0 if either input is Seed=true (low-confidence -// vectors don't contribute to scoring). -func Similarity(a, b SessionVector, w SimilarityWeights) float64 { - if a.Seed || b.Seed { - return 0.0 - } - tagJ := setJaccardKeys(a.Tags, b.Tags) - artistJ := setJaccardSlice(a.Artists, b.Artists) - return tagJ*w.TagsWeight + artistJ*w.ArtistsWeight -} - -// setJaccardKeys collapses two map keysets to sets and returns -// |A ∩ B| / |A ∪ B|. Both empty → 0 (not NaN). -func setJaccardKeys(a, b map[string]int) float64 { - if len(a) == 0 && len(b) == 0 { - return 0.0 - } - intersect := 0 - for k := range a { - if _, ok := b[k]; ok { - intersect++ - } - } - union := len(a) + len(b) - intersect - if union == 0 { - return 0.0 - } - return float64(intersect) / float64(union) -} - -// setJaccardSlice deduplicates each input slice into a set and returns -// |A ∩ B| / |A ∪ B|. Both empty → 0 (not NaN). -func setJaccardSlice(a, b []string) float64 { - if len(a) == 0 && len(b) == 0 { - return 0.0 - } - aset := make(map[string]struct{}, len(a)) - for _, x := range a { - aset[x] = struct{}{} - } - bset := make(map[string]struct{}, len(b)) - for _, x := range b { - bset[x] = struct{}{} - } - intersect := 0 - for k := range aset { - if _, ok := bset[k]; ok { - intersect++ - } - } - union := len(aset) + len(bset) - intersect - if union == 0 { - return 0.0 - } - return float64(intersect) / float64(union) -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./internal/recommendation/ -run Similarity -v` - -Expected: PASS for all 9 tests. - -- [ ] **Step 5: Commit** - -```bash -git add internal/recommendation/similarity.go internal/recommendation/similarity_test.go -git commit -m "feat(recommendation): add pure Similarity function with weighted Jaccard" -``` - ---- - -## Task 2: `ContextualMatchScore` convenience function - -**Files:** -- Modify: `internal/recommendation/similarity.go` (append) -- Modify: `internal/recommendation/similarity_test.go` (append) - -- [ ] **Step 1: Write the failing tests** - -Append to `internal/recommendation/similarity_test.go`: - -```go -func TestContextualMatchScore_NoLikes_Returns0(t *testing.T) { - current := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}} - got := ContextualMatchScore(current, nil, DefaultSimilarityWeights) - if !approxEq(got, 0.0) { - t.Errorf("no likes = %v, want 0.0", got) - } - got = ContextualMatchScore(current, []SessionVector{}, DefaultSimilarityWeights) - if !approxEq(got, 0.0) { - t.Errorf("empty likes = %v, want 0.0", got) - } -} - -func TestContextualMatchScore_CurrentSeed_Returns0(t *testing.T) { - current := SessionVector{Seed: true} - likes := []SessionVector{ - {Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}, - } - got := ContextualMatchScore(current, likes, DefaultSimilarityWeights) - if !approxEq(got, 0.0) { - t.Errorf("current seed = %v, want 0.0", got) - } -} - -func TestContextualMatchScore_AllLikesSeed_Returns0(t *testing.T) { - current := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}} - likes := []SessionVector{ - {Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}, - {Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}, - } - got := ContextualMatchScore(current, likes, DefaultSimilarityWeights) - if !approxEq(got, 0.0) { - t.Errorf("all-seed likes = %v, want 0.0", got) - } -} - -func TestContextualMatchScore_TakesMax(t *testing.T) { - // Three likes: full match, partial match, mismatch. Expect full match (1.0). - current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - likes := []SessionVector{ - {Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}, // 1.0 - {Artists: []string{"a2"}, Tags: map[string]int{"rock": 1}}, // 0.7 - {Artists: []string{"a99"}, Tags: map[string]int{"jazz": 1}}, // 0.0 - } - got := ContextualMatchScore(current, likes, DefaultSimilarityWeights) - if !approxEq(got, 1.0) { - t.Errorf("takes-max = %v, want 1.0", got) - } -} - -func TestContextualMatchScore_FiltersSeedThenMaxes(t *testing.T) { - // One Seed=true match (would be 1.0 if not filtered) + one partial match. - current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - likes := []SessionVector{ - {Seed: true, Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}, - {Artists: []string{"a2"}, Tags: map[string]int{"rock": 1}}, - } - got := ContextualMatchScore(current, likes, DefaultSimilarityWeights) - // Seed=true filtered out → only partial match counts → 0.7 - if !approxEq(got, 0.7) { - t.Errorf("filter-then-max = %v, want 0.7", got) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./internal/recommendation/ -run ContextualMatchScore -v` - -Expected: FAIL with "undefined: ContextualMatchScore". - -- [ ] **Step 3: Append `ContextualMatchScore` to `similarity.go`** - -Append to `internal/recommendation/similarity.go`: - -```go -// ContextualMatchScore returns the maximum Similarity between the current -// session vector and any non-seed entry in likes. Returns 0 when: -// - current.Seed is true (no meaningful current context) -// - likes is empty after filtering out Seed=true entries -// -// The "max" semantics means a single strong contextual match dominates -// over many weak ones — we want to surface the track because it was liked -// in *some* matching context, not because it was vaguely-liked in many. -func ContextualMatchScore(current SessionVector, likes []SessionVector, w SimilarityWeights) float64 { - if current.Seed { - return 0.0 - } - best := 0.0 - for _, l := range likes { - if l.Seed { - continue - } - s := Similarity(current, l, w) - if s > best { - best = s - } - } - return best -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./internal/recommendation/ -run ContextualMatchScore -v` - -Expected: PASS for all 5 tests. - -- [ ] **Step 5: Commit** - -```bash -git add internal/recommendation/similarity.go internal/recommendation/similarity_test.go -git commit -m "feat(recommendation): add ContextualMatchScore (max over non-seed likes)" -``` - ---- - -## Task 3: Extend `Score` with `ContextualMatchScore` + `ContextWeight` - -**Files:** -- Modify: `internal/recommendation/score.go` -- Modify: `internal/recommendation/score_test.go` - -- [ ] **Step 1: Write the failing tests** - -Append to `internal/recommendation/score_test.go`: - -```go -func TestScore_ContextualMatch_PerfectMatchAtWeight2(t *testing.T) { - w := defaultWeights() - w.ContextWeight = 2.0 - in := ScoringInputs{ContextualMatchScore: 1.0} - got := Score(in, w, time.Now(), fixedRNG(0.5)) - // base 1.0 + recency 1.0 (never played) + contextual 2.0 = 4.0 - want := 4.0 - if math.Abs(got-want) > 1e-9 { - t.Errorf("score = %v, want %v", got, want) - } -} - -func TestScore_ContextualMatch_HalfMatchAtWeight2(t *testing.T) { - w := defaultWeights() - w.ContextWeight = 2.0 - in := ScoringInputs{ContextualMatchScore: 0.5} - got := Score(in, w, time.Now(), fixedRNG(0.5)) - // base 1.0 + recency 1.0 + contextual 1.0 = 3.0 - want := 3.0 - if math.Abs(got-want) > 1e-9 { - t.Errorf("score = %v, want %v", got, want) - } -} - -func TestScore_ContextualMatch_ZeroNoEffect(t *testing.T) { - wWithCtx := defaultWeights() - wWithCtx.ContextWeight = 2.0 - withCtx := Score(ScoringInputs{ContextualMatchScore: 0}, wWithCtx, time.Now(), fixedRNG(0.5)) - withoutCtx := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5)) - if math.Abs(withCtx-withoutCtx) > 1e-9 { - t.Errorf("score-with-zero-ctx = %v, score-without = %v; should be equal", withCtx, withoutCtx) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./internal/recommendation/ -run Score -v` - -Expected: FAIL — `ScoringInputs` has no `ContextualMatchScore`, `ScoringWeights` has no `ContextWeight`. - -- [ ] **Step 3: Extend `score.go`** - -Modify `internal/recommendation/score.go`. Replace the file contents with: - -```go -// Package recommendation implements the weighted-shuffle scoring engine -// from spec §6. The Score function is pure and takes an injectable RNG so -// tests can pin jitter to deterministic values. -package recommendation - -import ( - "time" -) - -// ScoringInputs are the per-track facts the score function consumes. -// ContextualMatchScore is in [0, 1] — max similarity between the user's -// current session vector and any non-seed contextual_like row for this -// track. Set by LoadCandidates after a bulk fetch. -type ScoringInputs struct { - IsGeneralLiked bool - LastPlayedAt *time.Time // nil = never played - PlayCount int // total play_events - SkipCount int // play_events with was_skipped=true - ContextualMatchScore float64 // [0, 1]; 0 when no signal -} - -// ScoringWeights are the operator-tunable knobs. Defaults live in -// config.RecommendationConfig and are propagated here per request. -type ScoringWeights struct { - BaseWeight float64 - LikeBoost float64 - RecencyWeight float64 - SkipPenalty float64 - JitterMagnitude float64 - ContextWeight float64 -} - -// Score computes the weighted-shuffle score per spec §6: -// -// score = base -// + (is_general_liked ? LikeBoost : 0) -// + recency_decay * RecencyWeight -// - skip_ratio * SkipPenalty -// + contextual_match_score * ContextWeight -// + small_random_jitter -// -// Higher score = more likely to surface. rng is a function returning a -// uniform sample in [0,1) — pass math/rand.Float64 in production, a fixed -// value in tests. -func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64) float64 { - s := w.BaseWeight - if in.IsGeneralLiked { - s += w.LikeBoost - } - s += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight - s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty - s += in.ContextualMatchScore * w.ContextWeight - s += (rng()*2 - 1) * w.JitterMagnitude - return s -} - -// recencyDecay returns a value in [0, 1]: -// - never played → 1.0 (cold-start tracks compete favorably with stale ones). -// - age < 30 days → linear ramp age_days / 30. -// - age ≥ 30 days → 1.0 (capped). -// -// Negative ages (clock skew) clamp to 0 to avoid math weirdness. -func recencyDecay(lastPlayed *time.Time, now time.Time) float64 { - if lastPlayed == nil { - return 1.0 - } - age := now.Sub(*lastPlayed) - days := age.Hours() / 24 - if days < 0 { - return 0.0 - } - if days >= 30 { - return 1.0 - } - return days / 30.0 -} - -// skipRatio returns skips/plays in [0, 1]; never-played tracks return 0 -// rather than dividing by zero, so they aren't penalized. -func skipRatio(plays, skips int) float64 { - if plays == 0 { - return 0.0 - } - return float64(skips) / float64(plays) -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./internal/recommendation/ -run Score -v` - -Expected: PASS for all existing Score tests + 3 new contextual tests. - -- [ ] **Step 5: Commit** - -```bash -git add internal/recommendation/score.go internal/recommendation/score_test.go -git commit -m "feat(recommendation): extend Score with ContextualMatchScore + ContextWeight" -``` - ---- - -## Task 4: New sqlc queries for current vector + contextual likes lookup - -**Files:** -- Modify: `internal/db/queries/contextual_likes.sql` -- Modify: `internal/db/queries/events.sql` -- Generated: `internal/db/dbq/contextual_likes.sql.go` -- Generated: `internal/db/dbq/events.sql.go` - -- [ ] **Step 1: Add `ListActiveContextualLikesForUser` query** - -Append to `internal/db/queries/contextual_likes.sql`: - -```sql --- name: ListActiveContextualLikesForUser :many --- Returns all the user's active (non-soft-deleted) contextual_likes with --- non-null vectors. Cardinality is bounded by the user's actual like-while- --- playing history — typically tens to low hundreds. Used by the engine to --- compute contextual_match_score for the candidate pool. -SELECT track_id, session_vector -FROM contextual_likes -WHERE user_id = $1 - AND deleted_at IS NULL - AND session_vector IS NOT NULL; -``` - -- [ ] **Step 2: Add `GetCurrentSessionVectorForUser` query** - -Append to `internal/db/queries/events.sql`: - -```sql --- name: GetCurrentSessionVectorForUser :one --- Returns the session_vector_at_play of the user's most recent play_event --- in a still-active (un-timed-out) session. NoRows means no current vector. --- Joined with play_sessions so closed sessions don't leak stale vectors. -SELECT pe.session_vector_at_play -FROM play_events pe -JOIN play_sessions s ON s.id = pe.session_id -WHERE pe.user_id = $1 - AND s.ended_at IS NULL -ORDER BY pe.started_at DESC -LIMIT 1; -``` - -- [ ] **Step 3: Run sqlc generate** - -Run: `make generate` (or `cd internal/db && sqlc generate` if the Makefile target differs). - -Expected: `internal/db/dbq/contextual_likes.sql.go` gains `ListActiveContextualLikesForUser` method; `internal/db/dbq/events.sql.go` gains `GetCurrentSessionVectorForUser`. - -- [ ] **Step 4: Verify compile** - -Run: `go build ./...` - -Expected: clean compile (no test-side changes yet, just generated bindings). - -- [ ] **Step 5: Commit** - -```bash -git add internal/db/queries/contextual_likes.sql internal/db/queries/events.sql internal/db/dbq/ -git commit -m "feat(db): add similarity lookup queries (ListActiveContextualLikesForUser, GetCurrentSessionVectorForUser)" -``` - ---- - -## Task 5: Extend `LoadCandidates` to compute per-candidate contextual scores - -**Files:** -- Modify: `internal/recommendation/candidates.go` -- Modify: `internal/recommendation/candidates_test.go` - -- [ ] **Step 1: Write failing tests for `LoadCandidates` contextual behavior** - -Append to `internal/recommendation/candidates_test.go`: - -```go -import ( - "context" - "encoding/json" - "testing" - "time" -) - -// helperInsertContextualLike inserts a contextual_like row with the given -// session_vector marshaled to JSON. Returns nothing — the test asserts via -// LoadCandidates output. Bypasses playevents.CaptureContextualLikeIfPlaying -// because we want full control over the stored vector for these unit tests. -func helperInsertContextualLike(t *testing.T, f fixture, trackID pgtype.UUID, vec SessionVector) { - t.Helper() - raw, err := json.Marshal(vec) - if err != nil { - t.Fatalf("marshal: %v", err) - } - if _, err := f.pool.Exec(context.Background(), - `INSERT INTO contextual_likes (user_id, track_id, session_vector) VALUES ($1, $2, $3)`, - f.user, trackID, raw); err != nil { - t.Fatalf("insert: %v", err) - } -} - -func TestLoadCandidates_NoContextualLikes_AllZero(t *testing.T) { - f := newFixture(t, 5) - current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current) - if err != nil { - t.Fatalf("load: %v", err) - } - for _, c := range got { - if c.Inputs.ContextualMatchScore != 0 { - t.Errorf("track %s ContextualMatchScore = %v, want 0", c.Track.Title, c.Inputs.ContextualMatchScore) - } - } -} - -func TestLoadCandidates_OneMatchingLike_ScoresPositive(t *testing.T) { - f := newFixture(t, 3) - target := f.tracks[1] - likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - helperInsertContextualLike(t, f, target.ID, likeVec) - - current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current) - if err != nil { - t.Fatalf("load: %v", err) - } - var found bool - for _, c := range got { - if c.Track.ID == target.ID { - found = true - if c.Inputs.ContextualMatchScore < 0.99 { - t.Errorf("target ContextualMatchScore = %v, want ~1.0", c.Inputs.ContextualMatchScore) - } - } else { - if c.Inputs.ContextualMatchScore != 0 { - t.Errorf("non-target track has ContextualMatchScore = %v", c.Inputs.ContextualMatchScore) - } - } - } - if !found { - t.Error("target track missing from candidate list") - } -} - -func TestLoadCandidates_MultipleMatchingLikes_TakesMax(t *testing.T) { - f := newFixture(t, 3) - target := f.tracks[1] - // Three likes on the same track: weak, strong, medium. Expect strong. - helperInsertContextualLike(t, f, target.ID, SessionVector{ - Artists: []string{"a99"}, Tags: map[string]int{"jazz": 1}, - }) - helperInsertContextualLike(t, f, target.ID, SessionVector{ - Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}, - }) - helperInsertContextualLike(t, f, target.ID, SessionVector{ - Artists: []string{"a1"}, Tags: map[string]int{"jazz": 1}, - }) - - current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - got, _ := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current) - for _, c := range got { - if c.Track.ID == target.ID && c.Inputs.ContextualMatchScore < 0.99 { - t.Errorf("target = %v, want ~1.0 (max)", c.Inputs.ContextualMatchScore) - } - } -} - -func TestLoadCandidates_SoftDeletedLikes_Ignored(t *testing.T) { - f := newFixture(t, 3) - target := f.tracks[1] - likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - helperInsertContextualLike(t, f, target.ID, likeVec) - // Soft-delete the row. - if _, err := f.pool.Exec(context.Background(), - `UPDATE contextual_likes SET deleted_at = now() WHERE user_id = $1`, f.user); err != nil { - t.Fatalf("soft-delete: %v", err) - } - - current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - got, _ := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current) - for _, c := range got { - if c.Inputs.ContextualMatchScore != 0 { - t.Errorf("soft-deleted track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore) - } - } -} - -func TestLoadCandidates_OnlySeedLikes_ScoresZero(t *testing.T) { - f := newFixture(t, 3) - target := f.tracks[1] - // Only a Seed=true contextual_like exists. - helperInsertContextualLike(t, f, target.ID, SessionVector{ - Seed: true, Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}, - }) - - current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - got, _ := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current) - for _, c := range got { - if c.Inputs.ContextualMatchScore != 0 { - t.Errorf("seed-only track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore) - } - } -} - -func TestLoadCandidates_CurrentSeed_ScoresZero(t *testing.T) { - f := newFixture(t, 3) - target := f.tracks[1] - likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} - helperInsertContextualLike(t, f, target.ID, likeVec) - - currentSeed := SessionVector{Seed: true} - got, _ := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, currentSeed) - for _, c := range got { - if c.Inputs.ContextualMatchScore != 0 { - t.Errorf("seed-current track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore) - } - } -} -``` - -Then update existing test call sites (`TestLoadCandidates_ExcludesSeed` and any other extant calls in this file): each `LoadCandidates(...)` call gets a sixth argument. For tests that don't care about contextual scoring, pass `SessionVector{Seed: true}` — that short-circuits the term to 0 and matches v1 behavior. - -Replace existing call patterns: - -```go -got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) -``` - -with: - -```go -got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true}) -``` - -(There are 5 such call sites in the existing `candidates_test.go`. Update them all.) - -Update the `import` block at the top to add `"encoding/json"` if not present. - -- [ ] **Step 2: Run tests to verify the new ones fail and existing ones still compile** - -Run: `go test ./internal/recommendation/ -run LoadCandidates -v` - -Expected: existing tests fail to compile because `LoadCandidates` only takes 5 args. After updating call sites, they pass; new tests fail with "too few arguments" or "ContextualMatchScore not set". - -- [ ] **Step 3: Update `LoadCandidates` signature + body** - -Replace `internal/recommendation/candidates.go` contents: - -```go -package recommendation - -import ( - "context" - "encoding/json" - "time" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// LoadCandidates fetches the candidate pool for radio scoring. Combines -// the existing track+stats query with a one-shot bulk fetch of the user's -// active contextual_likes, mapping each candidate to its max similarity -// against currentVector. Pass currentVector with Seed=true to short-circuit -// the contextual term to 0 (cold-start path). -func LoadCandidates( - ctx context.Context, - q *dbq.Queries, - userID, seedID pgtype.UUID, - recentlyPlayedHours int, - currentVector SessionVector, -) ([]Candidate, error) { - rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{ - UserID: userID, - ID: seedID, - Column3: float64(recentlyPlayedHours), - }) - if err != nil { - return nil, err - } - - likes, err := loadContextualLikesByTrack(ctx, q, userID) - if err != nil { - return nil, err - } - - out := make([]Candidate, 0, len(rows)) - for _, r := range rows { - var lpt *time.Time - if r.LastPlayedAt.Valid { - t := r.LastPlayedAt.Time - lpt = &t - } - ctxScore := ContextualMatchScore(currentVector, likes[r.Track.ID], DefaultSimilarityWeights) - out = append(out, Candidate{ - Track: r.Track, - Inputs: ScoringInputs{ - IsGeneralLiked: r.IsLiked, - LastPlayedAt: lpt, - PlayCount: int(r.PlayCount), - SkipCount: int(r.SkipCount), - ContextualMatchScore: ctxScore, - }, - }) - } - return out, nil -} - -// loadContextualLikesByTrack fetches the user's active contextual_likes in -// one query and groups them by track_id. Rows whose session_vector fails -// to unmarshal are skipped with no error (don't poison scoring over one -// bad row); the SQL query already filters NULL vectors. -func loadContextualLikesByTrack( - ctx context.Context, - q *dbq.Queries, - userID pgtype.UUID, -) (map[pgtype.UUID][]SessionVector, error) { - rows, err := q.ListActiveContextualLikesForUser(ctx, userID) - if err != nil { - return nil, err - } - out := make(map[pgtype.UUID][]SessionVector, len(rows)) - for _, r := range rows { - var v SessionVector - if err := json.Unmarshal(r.SessionVector, &v); err != nil { - continue - } - out[r.TrackID] = append(out[r.TrackID], v) - } - return out, nil -} -``` - -- [ ] **Step 4: Run tests to verify all pass** - -Run: `go test ./internal/recommendation/ -v` - -Expected: PASS for all existing tests + 6 new contextual tests. - -- [ ] **Step 5: Verify other callers compile** - -Run: `go build ./...` - -Expected: `internal/api/radio.go` fails — it still calls `LoadCandidates` with 5 args. We fix that in Task 6. - -- [ ] **Step 6: Commit (allow temporary build break — fixed in Task 6)** - -```bash -git add internal/recommendation/candidates.go internal/recommendation/candidates_test.go -git commit -m "feat(recommendation): LoadCandidates computes per-candidate ContextualMatchScore" -``` - -Note: this commit leaves `internal/api/radio.go` non-compiling. Task 6 restores green. Don't push the branch in this state — finish Task 6 first. - ---- - -## Task 6: Config + radio handler wiring - -**Files:** -- Modify: `internal/config/config.go` -- Modify: `internal/api/radio.go` -- Modify: `internal/api/auth_test.go` - -- [ ] **Step 1: Add `ContextWeight` to `RecommendationConfig`** - -In `internal/config/config.go`, modify the `RecommendationConfig` struct: - -```go -type RecommendationConfig struct { - BaseWeight float64 `yaml:"base_weight"` - LikeBoost float64 `yaml:"like_boost"` - RecencyWeight float64 `yaml:"recency_weight"` - SkipPenalty float64 `yaml:"skip_penalty"` - JitterMagnitude float64 `yaml:"jitter_magnitude"` - ContextWeight float64 `yaml:"context_weight"` - RecentlyPlayedHours int `yaml:"recently_played_hours"` - RadioSize int `yaml:"radio_size"` - RadioSizeMax int `yaml:"radio_size_max"` -} -``` - -In the same file, modify `Default()`'s `Recommendation` block to include the new field: - -```go -Recommendation: RecommendationConfig{ - BaseWeight: 1.0, - LikeBoost: 2.0, - RecencyWeight: 1.0, - SkipPenalty: 1.0, - JitterMagnitude: 0.1, - ContextWeight: 2.0, - RecentlyPlayedHours: 1, - RadioSize: 50, - RadioSizeMax: 200, -}, -``` - -- [ ] **Step 2: Update `radio.go` to fetch current vector and pass it through** - -Replace `internal/api/radio.go` contents: - -```go -package api - -import ( - "encoding/json" - "errors" - "net/http" - "strconv" - "strings" - "time" - - "github.com/jackc/pgx/v5" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" -) - -// RadioResponse is the body of GET /api/radio. -type RadioResponse struct { - Tracks []TrackRef `json:"tracks"` -} - -// handleRadio implements GET /api/radio?seed_track=&limit=. -// -// Returns the seed at index 0, followed by up to limit-1 weighted-shuffle -// picks from the user's library, scored by recommendation.Score. The -// scoring formula folds in contextual_match_score using the user's current -// session vector (read from the most recent open play_event). -func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - raw := strings.TrimSpace(r.URL.Query().Get("seed_track")) - if raw == "" { - writeErr(w, http.StatusBadRequest, "bad_request", "seed_track is required") - return - } - seedID, ok := parseUUID(raw) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid seed_track id") - return - } - limit := h.recCfg.RadioSize - if v := r.URL.Query().Get("limit"); v != "" { - n, err := strconv.Atoi(v) - if err != nil || n < 1 { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid limit") - return - } - limit = n - } - if limit > h.recCfg.RadioSizeMax { - limit = h.recCfg.RadioSizeMax - } - q := dbq.New(h.pool) - track, err := q.GetTrackByID(r.Context(), seedID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - writeErr(w, http.StatusNotFound, "not_found", "seed_track not found") - return - } - h.logger.Error("api: get radio seed track failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - album, err := q.GetAlbumByID(r.Context(), track.AlbumID) - if err != nil { - h.logger.Error("api: get radio seed album failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - artist, err := q.GetArtistByID(r.Context(), track.ArtistID) - if err != nil { - h.logger.Error("api: get radio seed artist failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - - currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger) - - candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours, currentVec) - if err != nil { - h.logger.Error("api: radio: load candidates", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "candidate load failed") - return - } - weights := recommendation.ScoringWeights{ - BaseWeight: h.recCfg.BaseWeight, - LikeBoost: h.recCfg.LikeBoost, - RecencyWeight: h.recCfg.RecencyWeight, - SkipPenalty: h.recCfg.SkipPenalty, - JitterMagnitude: h.recCfg.JitterMagnitude, - ContextWeight: h.recCfg.ContextWeight, - } - picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1) - - out := make([]TrackRef, 0, len(picks)+1) - out = append(out, trackRefFrom(track, album.Title, artist.Name)) - for _, p := range picks { - al, err := q.GetAlbumByID(r.Context(), p.Track.AlbumID) - if err != nil { - h.logger.Error("api: radio: resolve album", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "resolve failed") - return - } - ar, err := q.GetArtistByID(r.Context(), p.Track.ArtistID) - if err != nil { - h.logger.Error("api: radio: resolve artist", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "resolve failed") - return - } - out = append(out, trackRefFrom(p.Track, al.Title, ar.Name)) - } - writeJSON(w, http.StatusOK, RadioResponse{Tracks: out}) -} - -// loadCurrentSessionVector returns the user's most recent active session -// vector, or a Seed=true sentinel if none exists / the column is NULL / -// the JSON fails to unmarshal. Sentinel short-circuits ContextualMatchScore -// to 0 so the contextual term contributes nothing in cold-start cases. -func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUID, logger *slog.Logger) recommendation.SessionVector { - raw, err := q.GetCurrentSessionVectorForUser(r.Context(), userID) - if err != nil { - // pgx.ErrNoRows is the common path: no active session yet. - if !errors.Is(err, pgx.ErrNoRows) { - logger.Warn("api: radio: load current session vector", "err", err) - } - return recommendation.SessionVector{Seed: true} - } - if len(raw) == 0 { - return recommendation.SessionVector{Seed: true} - } - var v recommendation.SessionVector - if jerr := json.Unmarshal(raw, &v); jerr != nil { - logger.Warn("api: radio: bad session_vector_at_play json", "err", jerr) - return recommendation.SessionVector{Seed: true} - } - return v -} -``` - -Add the missing imports at the top: `"log/slog"` and `"github.com/jackc/pgx/v5/pgtype"`. - -- [ ] **Step 3: Update `auth_test.go` test helper to include `ContextWeight`** - -In `internal/api/auth_test.go`, modify the `recCfg` definition inside `testHandlers`: - -```go -recCfg := config.RecommendationConfig{ - BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0, - SkipPenalty: 1.0, JitterMagnitude: 0.1, ContextWeight: 2.0, - RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, -} -``` - -- [ ] **Step 4: Run full build + existing tests** - -Run: `go build ./...` - -Expected: clean compile. - -Run: `go test ./internal/api/ ./internal/recommendation/ ./internal/config/ -v -short` - -Expected: PASS. The `-short` flag lets unit tests run; integration tests need `MINSTREL_TEST_DATABASE_URL`. - -If `MINSTREL_TEST_DATABASE_URL` is set, drop `-short` and verify integration tests pass: - -Run: `go test ./internal/api/ ./internal/recommendation/ -v` - -Expected: PASS. All existing radio tests still pass — `currentVec` defaults to `Seed=true` for users with no plays, so behavior is identical to v1. - -- [ ] **Step 5: Commit** - -```bash -git add internal/config/config.go internal/api/radio.go internal/api/auth_test.go -git commit -m "feat(api): radio handler reads current session vector + threads ContextWeight" -``` - ---- - -## Task 7: End-to-end test — contextual match boosts ranking - -**Files:** -- Modify: `internal/api/radio_test.go` - -- [ ] **Step 1: Write the failing end-to-end test** - -Append to `internal/api/radio_test.go`: - -```go -func TestHandleRadio_ContextualMatch_BoostsRankingOverControl(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - - // Two artists in distinct genres, so we can construct a "rock vibe" session - // and a contextual match. - rockArtist := seedArtist(t, pool, "RockArtist") - rockAlbum := seedAlbum(t, pool, rockArtist.ID, "RockAlbum", 2020) - jazzArtist := seedArtist(t, pool, "JazzArtist") - jazzAlbum := seedAlbum(t, pool, jazzArtist.ID, "JazzAlbum", 2020) - - // Seed track is unrelated to both (don't want it to dominate scoring). - popArtist := seedArtist(t, pool, "PopArtist") - popAlbum := seedAlbum(t, pool, popArtist.ID, "PopAlbum", 2020) - seed := seedTrack(t, pool, popAlbum.ID, popArtist.ID, "Seed", 1, 100_000) - - // Target: a rock track. Control: a jazz track. Both will be scored. - target := seedTrackWithGenre(t, pool, rockAlbum.ID, rockArtist.ID, "Target", 1, 100_000, "rock") - control := seedTrackWithGenre(t, pool, jazzAlbum.ID, jazzArtist.ID, "Control", 1, 100_000, "jazz") - - // Build the user's "rock vibe" current context: insert an open play_session - // with a play_event whose session_vector_at_play matches the rock vibe. - rockVec := recommendation.SessionVector{ - Artists: []string{toUUIDString(rockArtist.ID)}, - Tags: map[string]int{"rock": 3}, - } - rockVecJSON, _ := json.Marshal(rockVec) - insertOpenSessionWithVector(t, pool, user.ID, rockArtist.ID, rockVecJSON) - - // Insert a contextual_like on the target track whose stored vector matches - // the rock vibe. (Direct DB insert — we want full control over the vector - // for this test, not whatever playevents.CaptureContextualLikeIfPlaying - // would produce.) - if _, err := pool.Exec(context.Background(), - `INSERT INTO contextual_likes (user_id, track_id, session_vector) VALUES ($1, $2, $3)`, - user.ID, target.ID, rockVecJSON); err != nil { - t.Fatalf("insert contextual_like: %v", err) - } - - // Request radio. The deterministic RNG (rng=0.5 → jitter contribution = 0) - // means rankings are reproducible for this test. - w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)) - if w.Code != http.StatusOK { - t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) - } - var resp RadioResponse - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v", err) - } - - // Find the indexes of target and control in the response. - targetIdx, controlIdx := -1, -1 - for i, tr := range resp.Tracks { - if tr.Title == "Target" { - targetIdx = i - } - if tr.Title == "Control" { - controlIdx = i - } - } - if targetIdx == -1 || controlIdx == -1 { - t.Fatalf("target=%d control=%d, expected both present (resp.Tracks=%v)", targetIdx, controlIdx, resp.Tracks) - } - if targetIdx >= controlIdx { - t.Errorf("target ranked at %d, control at %d: contextual match should put target above control", targetIdx, controlIdx) - } -} -``` - -- [ ] **Step 2: Add the helper `seedTrackWithGenre` and `insertOpenSessionWithVector`** - -These don't exist yet. Add them to `internal/api/radio_test.go` (or to whichever helpers file the existing `seedTrack` lives in — keep them adjacent): - -```go -func seedTrackWithGenre(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string, trackNo, durationMs int, genre string) dbq.Track { - t.Helper() - q := dbq.New(pool) - tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: title, - AlbumID: albumID, - ArtistID: artistID, - FilePath: "/tmp/" + title + ".flac", - DurationMs: int32(durationMs), - Genre: &genre, - }) - if err != nil { - t.Fatalf("UpsertTrack: %v", err) - } - return tr -} - -// insertOpenSessionWithVector creates a play_session with ended_at NULL and -// inserts a play_event in it whose session_vector_at_play is the given JSON. -// Used to simulate "user is mid-listen" for the radio handler's current-vector -// lookup. The play_event itself doesn't reference any of the test tracks. -func insertOpenSessionWithVector(t *testing.T, pool *pgxpool.Pool, userID, anyArtistID pgtype.UUID, vectorJSON []byte) { - t.Helper() - // Create a placeholder track so the play_event has a valid track_id. - q := dbq.New(pool) - al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ - Title: "PlaceholderAlbum", SortTitle: "PlaceholderAlbum", ArtistID: anyArtistID, - }) - ph, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "Placeholder", AlbumID: al.ID, ArtistID: anyArtistID, - FilePath: "/tmp/placeholder.flac", DurationMs: 100_000, - }) - if err != nil { - t.Fatalf("placeholder track: %v", err) - } - // Insert open session. - var sessionID pgtype.UUID - if err := pool.QueryRow(context.Background(), - `INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id) - VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`, - userID).Scan(&sessionID); err != nil { - t.Fatalf("insert session: %v", err) - } - // Insert play_event with the given session_vector_at_play. - if _, err := pool.Exec(context.Background(), - `INSERT INTO play_events (user_id, track_id, session_id, started_at, session_vector_at_play) - VALUES ($1, $2, $3, now() - interval '1 minute', $4)`, - userID, ph.ID, sessionID, vectorJSON); err != nil { - t.Fatalf("insert play_event: %v", err) - } -} - -// toUUIDString converts a pgtype.UUID to its canonical hex string. Mirrors -// what BuildSessionVector emits, so the test's session_vector matches what -// the engine would produce in production. -func toUUIDString(u pgtype.UUID) string { - if !u.Valid { - return "" - } - return u.String() -} -``` - -Add the missing imports at the top of `radio_test.go`: `"github.com/jackc/pgx/v5/pgtype"`, `"github.com/jackc/pgx/v5/pgxpool"`, `"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"`, `"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"`. (Some may already exist.) - -Also: the `truncateLibrary` helper used by other tests probably truncates `play_events, play_sessions, sessions, users, tracks, albums, artists, general_likes`. We need it to also include `contextual_likes`. Find the helper (probably in `auth_test.go` or `radio_test.go`) and add `contextual_likes` to its TRUNCATE list: - -```go -func truncateLibrary(t *testing.T, pool *pgxpool.Pool) { - t.Helper() - if _, err := pool.Exec(context.Background(), - "TRUNCATE contextual_likes, general_likes, general_likes_albums, general_likes_artists, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { - t.Fatalf("truncate: %v", err) - } -} -``` - -(If `truncateLibrary` already exists, add `contextual_likes` to its TRUNCATE list. If it doesn't exist as a named helper, find the inline TRUNCATE in `testHandlers` and update that.) - -- [ ] **Step 3: Run the new test** - -Run: `MINSTREL_TEST_DATABASE_URL= go test ./internal/api/ -run TestHandleRadio_ContextualMatch_BoostsRankingOverControl -v` - -Expected: PASS. Target track appears at a lower index than Control in the response. - -- [ ] **Step 4: Run the full integration suite** - -Run: `MINSTREL_TEST_DATABASE_URL= go test ./internal/api/ ./internal/recommendation/ -v` - -Expected: PASS. Existing tests unaffected; new contextual test passes. - -- [ ] **Step 5: Commit** - -```bash -git add internal/api/radio_test.go internal/api/auth_test.go -git commit -m "test(api): end-to-end contextual ranking test for /api/radio" -``` - -(Only commit `auth_test.go` here if you didn't already in Task 6 — if `truncateLibrary` lives there and got modified, include it.) - ---- - -## Task 8: Final verification + branch finish - -**Files:** none (verification only) - -- [ ] **Step 1: Full Go test suite** - -Run: `go test -short -race ./...` - -Expected: PASS. All packages, no race conditions. - -- [ ] **Step 2: Full integration suite (with DB)** - -Run: `MINSTREL_TEST_DATABASE_URL= go test -race ./...` - -Expected: PASS. - -- [ ] **Step 3: Lint** - -Run: `golangci-lint run ./...` - -Expected: clean. - -- [ ] **Step 4: Coverage check** - -Run: `go test -short -coverprofile=cover.out ./internal/recommendation/ ./internal/playevents/ && go tool cover -func=cover.out | tail -1` - -Expected: combined coverage ≥ 70% (target). With pure functions in similarity.go heavily tested, we expect to land at 80%+. - -- [ ] **Step 5: Web verification (sanity)** - -Run: `cd web && npm run check && npm test -- --run && npm run build` - -Expected: svelte-check 0/0, all vitest tests pass, build succeeds. (No web changes in this slice, so this should be clean.) - -- [ ] **Step 6: Docker smoke (if present locally)** - -Run: `make docker-build && make docker-smoke` (or whatever the project's smoke-test target is). - -Expected: container builds; smoke check (`/healthz`, `/api/auth/login`) returns expected codes. - -- [ ] **Step 7: Manual end-to-end verification** - -Start the server. As an authenticated user: - -1. Play a few tracks of one genre (e.g., 3+ rock tracks) via the web UI to populate a session vector. -2. Like one of those tracks (creates a contextual_like with the rock-vibe vector). -3. Wait for the recently-played-hours window to clear, OR pick tracks not yet recently played. -4. Click "Play radio" from a different track. -5. Inspect: the previously-liked rock track should appear in the radio queue with a noticeably-likely-to-rank-high position. Compare against a jazz track that the user has NEVER liked — it should be ranked lower. - -Verification is qualitative for v1; the integration test in Task 7 is the deterministic guarantee. - -- [ ] **Step 8: Update Fable task #342** - -Set status to `in_progress` (after PR opens). After PR merges, mark `done` with the closing summary in the body. The task tracking should mention: - -- Closes the M3 milestone -- All three v1 components shipped: weighted shuffle (#340), session vectors (#341), contextual matching (this slice) -- Coverage targets met -- Backwards-compat: `/api/radio` API shape unchanged - -- [ ] **Step 9: Finishing the branch** - -**REQUIRED SUB-SKILL:** Use `superpowers:finishing-a-development-branch` to verify tests, present completion options (merge locally / push + PR / keep / discard), and execute the user's choice. - -Per established cadence: this slice will land as a single-purpose PR (no bundling with future M3.5 / M4 work). Once merged, M3 is closed. - ---- - -## Self-Review - -**Spec coverage:** - -- §3.1 (similarity.go) → Tasks 1, 2 ✓ -- §3.2 (ListActiveContextualLikesForUser) → Task 4 ✓ -- §3.3 (GetCurrentSessionVectorForUser) → Task 4 ✓ -- §3.4 (Score extension) → Task 3 ✓ -- §3.5 (LoadCandidates extension) → Task 5 ✓ -- §3.6 (radio.go wiring) → Task 6 ✓ -- §3.7 (config) → Task 6 ✓ -- §4 (request flow) → Tasks 5+6 (composition) ✓ -- §5 (cold-start) → Task 5 covers Seed/empty paths; Task 6 covers no-session/NULL paths ✓ -- §6.1 (similarity_test) → Task 1 ✓ -- §6.2 (score_test) → Task 3 ✓ -- §6.3 (candidates_test) → Task 5 ✓ -- §6.4 (radio_test) → Task 7 ✓ -- §6.5 (coverage gate) → Task 8 ✓ -- §7 (backwards compat) → preserved by zero-value semantics in Task 3 + cold-start handling in Tasks 5-6 ✓ - -No gaps. - -**Placeholder scan:** No "TBD"/"TODO" content. All steps have explicit code or commands. - -**Type consistency:** Verified — `SessionVector`, `Similarity`, `ContextualMatchScore`, `ScoringInputs.ContextualMatchScore`, `ScoringWeights.ContextWeight`, `RecommendationConfig.ContextWeight`, `LoadCandidates`'s sixth parameter `currentVector SessionVector`, `loadContextualLikesByTrack` return type `map[pgtype.UUID][]SessionVector`, `loadCurrentSessionVector` return type `recommendation.SessionVector` — all consistent across tasks. diff --git a/docs/superpowers/plans/2026-04-27-m3-vectors.md b/docs/superpowers/plans/2026-04-27-m3-vectors.md deleted file mode 100644 index a84b2ae7..00000000 --- a/docs/superpowers/plans/2026-04-27-m3-vectors.md +++ /dev/null @@ -1,1491 +0,0 @@ -# M3 Session Vectors + Contextual Likes Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add the two write paths that produce contextual data for the recommendation engine: (1) compute and persist a session vector at every play_started; (2) snapshot that vector into `contextual_likes` whenever a user likes a track during an active play_event. Soft-delete contextual_likes on unlike. Backend-only — no UI surface. - -**Architecture:** New migration `0007_contextual_likes` creates the missing table (it was referenced in 0005's comments but never CREATE'd). New pure function `BuildSessionVector(priorTracks)` produces the JSONB shape from spec §6. `internal/playevents/writer.go::RecordPlayStarted` and `RecordSyntheticCompletedPlay` extend in-transaction to compute and UPDATE the vector after inserting the play_event. `internal/api/likes.go` and `internal/subsonic/star.go` extend their like/unlike paths via two shared helpers in `internal/playevents` (so both surfaces capture/soft-delete uniformly). - -**Tech Stack:** Go 1.23 + chi + sqlc + pgx/v5. No web changes. - -**Reference:** design spec at `docs/superpowers/specs/2026-04-27-m3-vectors-design.md`. - ---- - -## File Structure - -**New server files:** - -| File | Responsibility | -|---|---| -| `internal/db/migrations/0007_contextual_likes.up.sql` + `.down.sql` | `contextual_likes` table + partial index (active rows) + GIN index (session_vector). | -| `internal/db/queries/contextual_likes.sql` | `InsertContextualLike`, `SoftDeleteContextualLikesForUserTrack`. | -| `internal/db/dbq/contextual_likes.sql.go` | Generated bindings. | -| `internal/recommendation/sessionvector.go` | `SessionVector` struct + `BuildSessionVector(priorTracks []dbq.Track) SessionVector` pure function. | -| `internal/recommendation/sessionvector_test.go` | Pure unit tests for the function (boundary cases). | -| `internal/playevents/contextual_likes.go` | Two shared helpers: `CaptureContextualLikeIfPlaying(ctx, q, userID, trackID, logger)` and `SoftDeleteContextualLikes(ctx, q, userID, trackID)`. Imported by both `internal/api` and `internal/subsonic`. | -| `internal/playevents/contextual_likes_test.go` | Live-DB tests for the helpers. | - -**Modified server files:** - -| File | Change | -|---|---| -| `internal/db/queries/events.sql` | Add `ListRecentSessionTracks(sessionID, beforeTime, limit) :many` and `UpdatePlayEventVector(id, vector) :exec`. | -| `internal/db/queries/likes.sql` | Change `LikeTrack` from `:exec` to `:execrows` (returns int64 affected count). | -| `internal/playevents/writer.go::RecordPlayStarted` and `RecordSyntheticCompletedPlay` | After InsertPlayEvent: ListRecentSessionTracks → BuildSessionVector → marshal JSON → UpdatePlayEventVector. Extracted into a shared private helper. | -| `internal/playevents/writer_test.go` | Three new tests covering vector persistence (seed flag, populated, session-scope isolation). | -| `internal/api/likes.go::handleLikeTrack` | Capture `:execrows` result; if 1, call `playevents.CaptureContextualLikeIfPlaying`. | -| `internal/api/likes.go::handleUnlikeTrack` | After `UnlikeTrack`, call `playevents.SoftDeleteContextualLikes`. | -| `internal/api/likes_test.go` | Five new tests (capture during open play, no capture without play, no duplicate on idempotent re-like, soft-delete on unlike, history accumulation). | -| `internal/subsonic/star.go::handleStar` (track branch) | After `LikeTrack`, capture rows count and call `playevents.CaptureContextualLikeIfPlaying`. | -| `internal/subsonic/star.go::handleUnstar` (track branch) | After `UnlikeTrack`, call `playevents.SoftDeleteContextualLikes`. | -| `internal/subsonic/star_test.go` | One new test verifying contextual capture through the Subsonic path. | - -**No web changes.** - ---- - -## Task 1: Migration 0007 + contextual_likes sqlc queries - -**Files:** -- Create: `internal/db/migrations/0007_contextual_likes.up.sql` -- Create: `internal/db/migrations/0007_contextual_likes.down.sql` -- Create: `internal/db/queries/contextual_likes.sql` -- Generated: `internal/db/dbq/contextual_likes.sql.go` - -- [ ] **Step 1: Write the up migration** - -Create `internal/db/migrations/0007_contextual_likes.up.sql`: - -```sql --- contextual_likes captures a per-session-context snapshot at the time of --- each like. The recommendation engine in M3 sub-plan #3 uses these rows --- to compute contextual_match_score per (current session, candidate track). --- --- Multiple rows per (user, track) are allowed and expected — each row --- represents a like in a specific session context. Soft-deleted rows --- remain in the table (deleted_at populated) but are filtered out of the --- engine's queries via the partial index. Re-liking a previously-unliked --- track adds a NEW row (does not undelete the old one). --- --- Note: the M2 events migration (0005) commented that contextual_likes --- "ships nullable now" but the actual CREATE TABLE was missed. This slice --- ships the table for the first time. - -CREATE TABLE contextual_likes ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, - track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, - liked_at timestamptz NOT NULL DEFAULT now(), - deleted_at timestamptz, - session_vector jsonb, - session_id uuid REFERENCES play_sessions(id) ON DELETE SET NULL -); - --- Hot path for both the engine's lookups and the soft-delete UPDATE. -CREATE INDEX contextual_likes_active_idx - ON contextual_likes (user_id, track_id) - WHERE deleted_at IS NULL; - --- Vector similarity queries from M3 sub-plan #3 will use this. -CREATE INDEX contextual_likes_vector_idx - ON contextual_likes USING gin (session_vector); -``` - -- [ ] **Step 2: Write the down migration** - -Create `internal/db/migrations/0007_contextual_likes.down.sql`: - -```sql -DROP TABLE IF EXISTS contextual_likes; -``` - -- [ ] **Step 3: Write sqlc queries** - -Create `internal/db/queries/contextual_likes.sql`: - -```sql --- name: InsertContextualLike :exec -INSERT INTO contextual_likes (user_id, track_id, session_vector, session_id) -VALUES ($1, $2, $3, $4); - --- name: SoftDeleteContextualLikesForUserTrack :exec --- Marks all currently-active rows for (user, track) as deleted. Idempotent — --- already-deleted rows aren't re-touched. -UPDATE contextual_likes -SET deleted_at = now() -WHERE user_id = $1 AND track_id = $2 AND deleted_at IS NULL; -``` - -- [ ] **Step 4: Generate sqlc bindings** - -Run: `$(go env GOPATH)/bin/sqlc generate` -Expected: writes `internal/db/dbq/contextual_likes.sql.go` with `InsertContextualLikeParams { UserID pgtype.UUID; TrackID pgtype.UUID; SessionVector []byte; SessionID pgtype.UUID }` and `SoftDeleteContextualLikesForUserTrackParams { UserID pgtype.UUID; TrackID pgtype.UUID }`. Also adds a `ContextualLike` model type to `models.go`. - -- [ ] **Step 5: Verify build** - -Run: `go build ./...` -Expected: clean. - -- [ ] **Step 6: Migration smoke** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/db -run TestMigrate -v -``` -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add internal/db/migrations/0007_contextual_likes.up.sql \ - internal/db/migrations/0007_contextual_likes.down.sql \ - internal/db/queries/contextual_likes.sql \ - internal/db/dbq/contextual_likes.sql.go \ - internal/db/dbq/models.go -git commit -m "feat(db): add contextual_likes table (M3 session-context capture) - -Table was referenced in migration 0005's comment but never created — -this slice fills the gap. Soft-delete via deleted_at column; hot-path -partial index on active rows; GIN index on session_vector for -M3 sub-plan #3's similarity queries." -``` - ---- - -## Task 2: Vector capture queries in events.sql - -**Files:** -- Modify: `internal/db/queries/events.sql` (append two new queries) -- Generated: `internal/db/dbq/events.sql.go` (regenerated) -- Modify: `internal/db/queries/likes.sql` (`LikeTrack` becomes `:execrows`) - -The vector capture in `RecordPlayStarted` needs (a) a query that lists the prior tracks in a session and (b) a query that updates `play_events.session_vector_at_play`. Bundle the `LikeTrack` `:execrows` change here too — same generated-bindings cycle. - -- [ ] **Step 1: Append `ListRecentSessionTracks` and `UpdatePlayEventVector` to `internal/db/queries/events.sql`** - -```sql --- name: ListRecentSessionTracks :many --- Returns up to $3 tracks in session $1 whose play_event started before --- $2, ordered newest-first. Used by playevents.RecordPlayStarted to --- build the session_vector for the just-inserted play_event. -SELECT t.* FROM tracks t -JOIN play_events pe ON pe.track_id = t.id -WHERE pe.session_id = $1 - AND pe.started_at < $2 -ORDER BY pe.started_at DESC -LIMIT $3; - --- name: UpdatePlayEventVector :exec --- Used right after InsertPlayEvent to populate session_vector_at_play --- once the vector has been computed. -UPDATE play_events -SET session_vector_at_play = $2 -WHERE id = $1; -``` - -- [ ] **Step 2: Change `LikeTrack` to `:execrows` in `internal/db/queries/likes.sql`** - -Find the existing `-- name: LikeTrack :exec` block. Replace the annotation: - -```sql --- name: LikeTrack :execrows -INSERT INTO general_likes (user_id, track_id) -VALUES ($1, $2) -ON CONFLICT (user_id, track_id) DO NOTHING; -``` - -(The SQL body is unchanged; only the `:exec` → `:execrows` annotation changes.) - -- [ ] **Step 3: Generate sqlc bindings** - -Run: `$(go env GOPATH)/bin/sqlc generate` -Expected: -- `internal/db/dbq/events.sql.go` gains `ListRecentSessionTracks` and `UpdatePlayEventVector` functions, plus their param structs. -- `internal/db/dbq/likes.sql.go` — `LikeTrack` signature changes from `(ctx, params) error` to `(ctx, params) (int64, error)`. Build will break at every existing call site. - -- [ ] **Step 4: Update existing `LikeTrack` callers to ignore the count** - -Two sites need updating right now to keep the build green; the next tasks will use the count meaningfully. - -In `internal/api/likes.go`: -```go -// Before: -if err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id}); err != nil { -// After: -if _, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id}); err != nil { -``` - -In `internal/subsonic/star.go`: -```go -// Before: -if err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}); err != nil { -// After: -if _, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}); err != nil { -``` - -- [ ] **Step 5: Verify build** - -Run: `go build ./...` -Expected: clean. - -- [ ] **Step 6: Run existing test suite** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test -p 1 ./... -``` -Expected: all packages PASS. Existing like tests still work because the behavior is unchanged at this stage; only the function signature evolved. - -- [ ] **Step 7: Commit** - -```bash -git add internal/db/queries/events.sql internal/db/queries/likes.sql \ - internal/db/dbq/events.sql.go internal/db/dbq/likes.sql.go \ - internal/api/likes.go internal/subsonic/star.go -git commit -m "feat(db): add session-vector capture queries; LikeTrack returns row count - -ListRecentSessionTracks + UpdatePlayEventVector for the vector capture -path inside RecordPlayStarted. LikeTrack switches to :execrows so the -contextual-likes capture can detect insert vs already-exists. Existing -callers updated to ignore the count for now; later tasks consume it." -``` - ---- - -## Task 3: Pure `BuildSessionVector` function - -**Files:** -- Create: `internal/recommendation/sessionvector.go` -- Create: `internal/recommendation/sessionvector_test.go` - -Pure function, no DB. Produces the JSONB-serializable struct from a slice of `dbq.Track`. - -- [ ] **Step 1: Write the failing tests** - -Create `internal/recommendation/sessionvector_test.go`: - -```go -package recommendation - -import ( - "encoding/json" - "testing" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -func track(artistID pgtype.UUID, genre string) dbq.Track { - t := dbq.Track{ - ArtistID: artistID, - } - if genre != "" { - g := genre - t.Genre = &g - } - return t -} - -func uuid(s string) pgtype.UUID { - var u pgtype.UUID - _ = u.Scan(s) - return u -} - -func TestBuildSessionVector_Empty_IsSeed(t *testing.T) { - v := BuildSessionVector(nil) - if !v.Seed { - t.Errorf("Seed = false, want true for empty input") - } - if len(v.Artists) != 0 || len(v.Tags) != 0 || len(v.RecentTrackIDs) != 0 { - t.Errorf("non-empty collections: %+v", v) - } -} - -func TestBuildSessionVector_OneTrack_IsSeed(t *testing.T) { - a1 := uuid("11111111-1111-1111-1111-111111111111") - tr := track(a1, "rock") - tr.ID = uuid("22222222-2222-2222-2222-222222222222") - v := BuildSessionVector([]dbq.Track{tr}) - if !v.Seed { - t.Errorf("1 track: Seed=false, want true (< 3)") - } - if len(v.Artists) != 1 { - t.Errorf("Artists: %+v", v.Artists) - } - if v.Tags["rock"] != 1 { - t.Errorf("Tags: %+v", v.Tags) - } - if len(v.RecentTrackIDs) != 1 { - t.Errorf("RecentTrackIDs: %+v", v.RecentTrackIDs) - } -} - -func TestBuildSessionVector_TwoTracks_IsSeed(t *testing.T) { - a1 := uuid("11111111-1111-1111-1111-111111111111") - v := BuildSessionVector([]dbq.Track{track(a1, "rock"), track(a1, "jazz")}) - if !v.Seed { - t.Errorf("2 tracks: Seed=false, want true (< 3)") - } -} - -func TestBuildSessionVector_ThreeTracks_NotSeed(t *testing.T) { - a1 := uuid("11111111-1111-1111-1111-111111111111") - v := BuildSessionVector([]dbq.Track{ - track(a1, "rock"), track(a1, "rock"), track(a1, "rock"), - }) - if v.Seed { - t.Errorf("3 tracks: Seed=true, want false") - } - // All same artist → 1 entry. - if len(v.Artists) != 1 { - t.Errorf("Artists len = %d, want 1 (dedup)", len(v.Artists)) - } - // All same genre → count 3. - if v.Tags["rock"] != 3 { - t.Errorf("Tags['rock'] = %d, want 3", v.Tags["rock"]) - } -} - -func TestBuildSessionVector_DistinctArtistsAndGenres(t *testing.T) { - a1 := uuid("11111111-1111-1111-1111-111111111111") - a2 := uuid("22222222-2222-2222-2222-222222222222") - a3 := uuid("33333333-3333-3333-3333-333333333333") - v := BuildSessionVector([]dbq.Track{ - track(a1, "rock"), - track(a2, "jazz"), - track(a3, "rock"), - track(a1, "experimental"), - }) - // 4 tracks → not seed. - if v.Seed { - t.Errorf("Seed=true, want false") - } - // Distinct artists: 3 (a1 dedup'd). - if len(v.Artists) != 3 { - t.Errorf("Artists len = %d, want 3", len(v.Artists)) - } - // Tag counts. - if v.Tags["rock"] != 2 || v.Tags["jazz"] != 1 || v.Tags["experimental"] != 1 { - t.Errorf("Tags = %+v", v.Tags) - } - if len(v.RecentTrackIDs) != 4 { - t.Errorf("RecentTrackIDs len = %d", len(v.RecentTrackIDs)) - } -} - -func TestBuildSessionVector_NilGenre_NotIndexed(t *testing.T) { - a1 := uuid("11111111-1111-1111-1111-111111111111") - v := BuildSessionVector([]dbq.Track{ - track(a1, ""), // empty genre via the helper (nil pointer) - track(a1, "rock"), - track(a1, ""), - }) - if v.Tags["rock"] != 1 { - t.Errorf("Tags['rock'] = %d, want 1", v.Tags["rock"]) - } - if _, exists := v.Tags[""]; exists { - t.Errorf("Tags has empty-string entry: %+v", v.Tags) - } -} - -func TestBuildSessionVector_JSONRoundTrip(t *testing.T) { - a1 := uuid("11111111-1111-1111-1111-111111111111") - v := BuildSessionVector([]dbq.Track{ - track(a1, "rock"), track(a1, "jazz"), track(a1, "rock"), - }) - data, err := json.Marshal(v) - if err != nil { - t.Fatalf("marshal: %v", err) - } - var got SessionVector - if err := json.Unmarshal(data, &got); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if got.Seed != v.Seed { - t.Errorf("Seed: %v != %v", got.Seed, v.Seed) - } - if got.Tags["rock"] != v.Tags["rock"] { - t.Errorf("Tags: %+v != %+v", got.Tags, v.Tags) - } - if len(got.Artists) != len(v.Artists) { - t.Errorf("Artists: len %d != %d", len(got.Artists), len(v.Artists)) - } - if len(got.RecentTrackIDs) != len(v.RecentTrackIDs) { - t.Errorf("RecentTrackIDs: len %d != %d", len(got.RecentTrackIDs), len(v.RecentTrackIDs)) - } -} -``` - -- [ ] **Step 2: Run, confirm fail** - -Run: `go test ./internal/recommendation -run TestBuildSessionVector -v` -Expected: FAIL — `SessionVector`, `BuildSessionVector` undefined. - -- [ ] **Step 3: Implement `internal/recommendation/sessionvector.go`** - -```go -package recommendation - -import ( - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// SessionVector is the JSONB-serializable shape of a play_event's -// session_vector_at_play column. Spec §6 "Session vector". Sub-plan #3 -// will read these to compute contextual_match_score in the radio scoring. -type SessionVector struct { - Seed bool `json:"seed"` - Artists []string `json:"artists"` - Tags map[string]int `json:"tags"` - RecentTrackIDs []string `json:"recent_track_ids"` -} - -// BuildSessionVector aggregates the prior tracks into a session vector. -// Pure: no DB, no time, no randomness. Caller is responsible for ordering -// (oldest first / newest last) and for limiting to the desired N. -// -// Rules: -// - Seed = len(priorTracks) < 3. -// - Artists deduplicated, ordered by first appearance. -// - Tags is a bag-of-counts over tracks.genre. Empty/nil genres do not contribute. -// - RecentTrackIDs preserves input order (newest last). -func BuildSessionVector(priorTracks []dbq.Track) SessionVector { - v := SessionVector{ - Seed: len(priorTracks) < 3, - Artists: []string{}, - Tags: map[string]int{}, - RecentTrackIDs: []string{}, - } - seen := map[string]bool{} - for _, t := range priorTracks { - artistID := uuidString(t.ArtistID) - if !seen[artistID] { - seen[artistID] = true - v.Artists = append(v.Artists, artistID) - } - if t.Genre != nil && *t.Genre != "" { - v.Tags[*t.Genre]++ - } - v.RecentTrackIDs = append(v.RecentTrackIDs, uuidString(t.ID)) - } - return v -} - -// uuidString formats a pgtype.UUID as the canonical hex form. -func uuidString(u pgtype.UUID) string { - if !u.Valid { - return "" - } - b := u.Bytes - return formatUUIDBytes(b) -} - -func formatUUIDBytes(b [16]byte) string { - const hexdigits = "0123456789abcdef" - out := make([]byte, 36) - pos := 0 - for i, by := range b { - switch i { - case 4, 6, 8, 10: - out[pos] = '-' - pos++ - } - out[pos] = hexdigits[by>>4] - out[pos+1] = hexdigits[by&0x0f] - pos += 2 - } - return string(out) -} -``` - -The `pgtype.UUID` import is needed: - -```go -import ( - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "github.com/jackc/pgx/v5/pgtype" -) -``` - -(Adjust the imports to actually compile — the formatter import is `pgtype`. Note: `pgtype.UUID` already has a `String()` method on pgx/v5; we could call `u.String()` directly. The hand-rolled formatter is shown for clarity; using `u.String()` is preferred — replace the body of `uuidString` with `return u.String()`.) - -Final clean form (pick this): - -```go -package recommendation - -import ( - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -type SessionVector struct { - Seed bool `json:"seed"` - Artists []string `json:"artists"` - Tags map[string]int `json:"tags"` - RecentTrackIDs []string `json:"recent_track_ids"` -} - -func BuildSessionVector(priorTracks []dbq.Track) SessionVector { - v := SessionVector{ - Seed: len(priorTracks) < 3, - Artists: []string{}, - Tags: map[string]int{}, - RecentTrackIDs: []string{}, - } - seen := map[string]bool{} - for _, t := range priorTracks { - artistID := uuidString(t.ArtistID) - if !seen[artistID] { - seen[artistID] = true - v.Artists = append(v.Artists, artistID) - } - if t.Genre != nil && *t.Genre != "" { - v.Tags[*t.Genre]++ - } - v.RecentTrackIDs = append(v.RecentTrackIDs, uuidString(t.ID)) - } - return v -} - -func uuidString(u pgtype.UUID) string { - if !u.Valid { - return "" - } - return u.String() -} -``` - -- [ ] **Step 4: Run, confirm pass** - -Run: `go test ./internal/recommendation -v` -Expected: PASS — 16 prior tests + 7 new = 23 total. - -- [ ] **Step 5: Commit** - -```bash -git add internal/recommendation/sessionvector.go internal/recommendation/sessionvector_test.go -git commit -m "feat(recommendation): add BuildSessionVector pure function - -Pure aggregation per spec §6: Seed flag (true when prior < 3), -deduplicated Artists ordered by first appearance, Tags bag-of-counts -from tracks.genre (empty genres skipped), RecentTrackIDs preserving -input order. JSON round-trip verified." -``` - ---- - -## Task 4: Vector capture in playevents.Writer - -**Files:** -- Modify: `internal/playevents/writer.go::RecordPlayStarted` -- Modify: `internal/playevents/writer.go::RecordSyntheticCompletedPlay` -- Modify: `internal/playevents/writer_test.go` - -Both record paths share the same vector logic — extract a private helper. Both run inside an existing `pgx.BeginFunc` transaction, so the new SELECT + UPDATE go in the same atomic operation. - -- [ ] **Step 1: Write three failing tests** - -Append to `internal/playevents/writer_test.go`: - -```go -import ( - // existing imports - "encoding/json" -) - -func TestRecordPlayStarted_PersistsSessionVector_Seed(t *testing.T) { - f := newFixture(t, 200_000) - now := time.Now().UTC() - res, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", now) - if err != nil { - t.Fatalf("RecordPlayStarted: %v", err) - } - got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID) - if err != nil { - t.Fatalf("get: %v", err) - } - if got.SessionVectorAtPlay == nil { - t.Fatalf("session_vector_at_play is NULL") - } - var vec map[string]any - if err := json.Unmarshal(got.SessionVectorAtPlay, &vec); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if seed, _ := vec["seed"].(bool); !seed { - t.Errorf("seed = %v, want true (first play in session)", vec["seed"]) - } - if artists, _ := vec["artists"].([]any); len(artists) != 0 { - t.Errorf("artists not empty: %v", artists) - } -} - -func TestRecordPlayStarted_PersistsSessionVector_Populated(t *testing.T) { - f := newFixture(t, 200_000) - // Seed 4 prior plays with the same track (reusing the fixture's single - // track is fine — vector dedup means we'll see 1 artist regardless). - t0 := time.Now().UTC().Add(-1 * time.Hour) - for i := 0; i < 4; i++ { - at := t0.Add(time.Duration(i) * time.Minute) - _, _ = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at) - } - // 5th play — should see vector with 4 prior tracks. - at := t0.Add(10 * time.Minute) - res, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at) - if err != nil { - t.Fatalf("RecordPlayStarted: %v", err) - } - got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID) - if got.SessionVectorAtPlay == nil { - t.Fatal("session_vector_at_play is NULL") - } - var vec map[string]any - _ = json.Unmarshal(got.SessionVectorAtPlay, &vec) - if seed, _ := vec["seed"].(bool); seed { - t.Errorf("seed = true after 4 prior tracks, want false") - } - recentIDs, _ := vec["recent_track_ids"].([]any) - if len(recentIDs) != 4 { - t.Errorf("recent_track_ids len = %d, want 4", len(recentIDs)) - } -} - -func TestRecordPlayStarted_VectorScopedToSession(t *testing.T) { - f := newFixture(t, 200_000) - // Play 1: at t=0. - t0 := time.Now().UTC().Add(-2 * time.Hour) - _, _ = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t0) - // Play 2: t = t0 + 31 minutes (exceeds 30-min session timeout). - t1 := t0.Add(31 * time.Minute) - res, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1) - if err != nil { - t.Fatalf("RecordPlayStarted: %v", err) - } - got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID) - if got.SessionVectorAtPlay == nil { - t.Fatal("vector NULL") - } - var vec map[string]any - _ = json.Unmarshal(got.SessionVectorAtPlay, &vec) - if seed, _ := vec["seed"].(bool); !seed { - t.Errorf("seed = false in fresh session, want true") - } - recentIDs, _ := vec["recent_track_ids"].([]any) - if len(recentIDs) != 0 { - t.Errorf("recent_track_ids has %d entries from prior session, want 0", len(recentIDs)) - } -} -``` - -- [ ] **Step 2: Run, confirm fail** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/playevents -run TestRecordPlayStarted_PersistsSessionVector -v -``` -Expected: FAIL — `session_vector_at_play` is NULL because vector capture is not yet wired. - -- [ ] **Step 3: Add the private helper + extend `RecordPlayStarted`** - -Open `internal/playevents/writer.go`. Add an import block addition for `encoding/json` and `git.fabledsword.com/bvandeusen/minstrel/internal/recommendation`. Add a private method: - -```go -// captureSessionVector queries the user's prior plays in the given session -// (before `at`), builds the session vector, and UPDATEs the just-inserted -// play_event with it. Runs inside the caller's transaction (q is the -// transaction-bound *dbq.Queries). Errors here propagate — vector capture -// is best-effort, but DB errors should fail the transaction. -func (w *Writer) captureSessionVector( - ctx context.Context, - q *dbq.Queries, - playEventID, sessionID pgtype.UUID, - at time.Time, -) error { - priorTracks, err := q.ListRecentSessionTracks(ctx, dbq.ListRecentSessionTracksParams{ - SessionID: sessionID, - StartedAt: pgtype.Timestamptz{Time: at, Valid: true}, - Limit: 5, - }) - if err != nil { - return err - } - vec := recommendation.BuildSessionVector(priorTracks) - vecJSON, err := json.Marshal(vec) - if err != nil { - return err - } - return q.UpdatePlayEventVector(ctx, dbq.UpdatePlayEventVectorParams{ - ID: playEventID, - SessionVectorAtPlay: vecJSON, - }) -} -``` - -The `ListRecentSessionTracksParams` field names are sqlc-generated from the SQL. Check the actual generated names — the SQL uses `$1`, `$2`, `$3` so sqlc may use `Column1/2/3` OR may infer better names from the WHERE clauses. If the generated struct uses `Column1`/`Column2`/`Column3`, adjust the field names accordingly: - -```go -priorTracks, err := q.ListRecentSessionTracks(ctx, dbq.ListRecentSessionTracksParams{ - SessionID: sessionID, // or Column1 - StartedAt: pgtype.Timestamptz{Time: at, Valid: true}, // or Column2 - Limit: 5, // or Column3 -}) -``` - -(Run sqlc generate first; inspect the actual struct.) - -- [ ] **Step 4: Call the helper from `RecordPlayStarted`** - -In `RecordPlayStarted`, after the `q.InsertPlayEvent` call (around line 80), add: - -```go -ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{...}) -if err != nil { - return err -} -out.PlayEventID = ev.ID -out.SessionID = sessionID - -// Capture session vector for the just-inserted row. -if err := w.captureSessionVector(ctx, q, ev.ID, sessionID, at); err != nil { - return err -} -return nil -``` - -- [ ] **Step 5: Call the helper from `RecordSyntheticCompletedPlay`** - -In `RecordSyntheticCompletedPlay`, find the `q.InsertPlayEvent(...)` call. Right before the subsequent `q.UpdatePlayEventEnded(...)`, add a `captureSessionVector` call: - -```go -ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{...}) -if err != nil { - return err -} -if err := w.captureSessionVector(ctx, q, ev.ID, sessionID, at); err != nil { - return err -} -// existing UpdatePlayEventEnded follows... -``` - -- [ ] **Step 6: Run, confirm pass** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/playevents -v -``` -Expected: PASS — 8 existing + 3 new = 11 tests. - -- [ ] **Step 7: Lint** - -Run: `golangci-lint run ./internal/playevents/...` -Expected: clean. - -- [ ] **Step 8: Commit** - -```bash -git add internal/playevents/writer.go internal/playevents/writer_test.go -git commit -m "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." -``` - ---- - -## Task 5: Contextual-likes helpers + api handler updates - -**Files:** -- Create: `internal/playevents/contextual_likes.go` -- Create: `internal/playevents/contextual_likes_test.go` -- Modify: `internal/api/likes.go::handleLikeTrack` and `handleUnlikeTrack` -- Modify: `internal/api/likes_test.go` - -Two helpers in `internal/playevents` so both `internal/api` and `internal/subsonic` use the same code path. The api handlers wire them in here; the subsonic handlers in Task 6. - -- [ ] **Step 1: Write the helper tests** - -Create `internal/playevents/contextual_likes_test.go`: - -```go -package playevents - -import ( - "context" - "encoding/json" - "io" - "log/slog" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// fixtureWithSecondTrack extends the existing fixture with a second track -// the test uses as the "track being liked while track 1 plays". -type fixtureWithSecondTrack struct { - fixture - track2 pgtype.UUID -} - -func newFixtureWithSecondTrack(t *testing.T, durationMs int32) fixtureWithSecondTrack { - f := newFixture(t, durationMs) - q := dbq.New(f.pool) - tr2, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "Other", AlbumID: f.q.GetAlbumByID, // placeholder — use the same album - FilePath: "/tmp/track-2.flac", DurationMs: durationMs, - }) - _ = err // for sketching; the real implementation should look up an existing album/artist from f - _ = tr2 - return fixtureWithSecondTrack{fixture: f} -} - -func TestCaptureContextualLikeIfPlaying_NoOpenEvent_NoOp(t *testing.T) { - f := newFixture(t, 200_000) - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - q := dbq.New(f.pool) - if err := CaptureContextualLikeIfPlaying(context.Background(), q, f.user, f.track, logger); err != nil { - t.Fatalf("err: %v", err) - } - var count int - _ = f.pool.QueryRow(context.Background(), "SELECT count(*) FROM contextual_likes WHERE user_id=$1", f.user).Scan(&count) - if count != 0 { - t.Errorf("count = %d, want 0 (no open play_event)", count) - } -} - -func TestCaptureContextualLikeIfPlaying_OpenEventWithVector_Inserts(t *testing.T) { - f := newFixture(t, 200_000) - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - // Start a play (creates an open play_event with a populated vector). - at := time.Now().UTC() - _, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at) - if err != nil { - t.Fatalf("RecordPlayStarted: %v", err) - } - // Like the same track — captures contextual signal. - q := dbq.New(f.pool) - if err := CaptureContextualLikeIfPlaying(context.Background(), q, f.user, f.track, logger); err != nil { - t.Fatalf("Capture: %v", err) - } - var rawVec []byte - if err := f.pool.QueryRow(context.Background(), - "SELECT session_vector FROM contextual_likes WHERE user_id=$1 AND track_id=$2", - f.user, f.track).Scan(&rawVec); err != nil { - t.Fatalf("query: %v", err) - } - var vec map[string]any - _ = json.Unmarshal(rawVec, &vec) - if vec == nil { - t.Errorf("vector empty: %s", rawVec) - } -} - -func TestSoftDeleteContextualLikes_MarksActiveRowsDeleted(t *testing.T) { - f := newFixture(t, 200_000) - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - at := time.Now().UTC() - _, _ = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at) - q := dbq.New(f.pool) - _ = CaptureContextualLikeIfPlaying(context.Background(), q, f.user, f.track, logger) - // Now soft-delete. - if err := SoftDeleteContextualLikes(context.Background(), q, f.user, f.track); err != nil { - t.Fatalf("SoftDelete: %v", err) - } - var deletedAt pgtype.Timestamptz - if err := f.pool.QueryRow(context.Background(), - "SELECT deleted_at FROM contextual_likes WHERE user_id=$1 AND track_id=$2", - f.user, f.track).Scan(&deletedAt); err != nil { - t.Fatalf("query: %v", err) - } - if !deletedAt.Valid { - t.Errorf("deleted_at not set after SoftDelete") - } -} -``` - -The fixture-extension code above has a placeholder (`fixtureWithSecondTrack` is sketched). For these tests we don't actually need a second track — using `f.track` for both the "playing" and "liked" sides is fine because contextual_likes is keyed on (user, track) and the test is verifying capture, not cross-track behavior. Drop the `fixtureWithSecondTrack` helper; use `f.track` directly. - -- [ ] **Step 2: Run, confirm fail** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/playevents -run TestCaptureContextualLikeIfPlaying -v -``` -Expected: FAIL — `CaptureContextualLikeIfPlaying`, `SoftDeleteContextualLikes` undefined. - -- [ ] **Step 3: Implement `internal/playevents/contextual_likes.go`** - -```go -package playevents - -import ( - "context" - "errors" - "log/slog" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// CaptureContextualLikeIfPlaying snapshots the user's currently-playing -// session context into a new contextual_likes row. No-op if no open -// play_event exists or its session_vector is NULL. Failures are logged -// but never returned to the caller — contextual capture is best-effort -// and must not break the like response. -func CaptureContextualLikeIfPlaying( - ctx context.Context, - q *dbq.Queries, - userID, trackID pgtype.UUID, - logger *slog.Logger, -) error { - event, err := q.GetOpenPlayEventForUser(ctx, userID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil // no play in progress; nothing to capture - } - logger.Error("playevents: get open play_event", "err", err) - return nil // best-effort - } - if event.SessionVectorAtPlay == nil { - return nil // event predates this slice; no vector to snapshot - } - if err := q.InsertContextualLike(ctx, dbq.InsertContextualLikeParams{ - UserID: userID, - TrackID: trackID, - SessionVector: event.SessionVectorAtPlay, - SessionID: event.SessionID, - }); err != nil { - logger.Error("playevents: insert contextual_like", "err", err) - return nil - } - return nil -} - -// SoftDeleteContextualLikes marks all currently-active contextual_likes -// rows for (user, track) as deleted. Idempotent. -func SoftDeleteContextualLikes( - ctx context.Context, - q *dbq.Queries, - userID, trackID pgtype.UUID, -) error { - return q.SoftDeleteContextualLikesForUserTrack(ctx, dbq.SoftDeleteContextualLikesForUserTrackParams{ - UserID: userID, - TrackID: trackID, - }) -} -``` - -Note: the `InsertContextualLikeParams` field names follow sqlc's naming — `UserID`, `TrackID`, `SessionVector`, `SessionID`. If sqlc generates different names (e.g. positional `Column1`...), adjust per the actual generated code. - -- [ ] **Step 4: Run helper tests, confirm pass** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/playevents -v -``` -Expected: PASS — 11 prior tests + 3 helper tests = 14 total. - -- [ ] **Step 5: Update `internal/api/likes.go::handleLikeTrack`** - -```go -func (h *handlers) handleLikeTrack(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - id, ok := parseUUID(chi.URLParam(r, "id")) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id") - return - } - q := dbq.New(h.pool) - if _, err := q.GetTrackByID(r.Context(), id); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - writeErr(w, http.StatusNotFound, "not_found", "track not found") - return - } - h.logger.Error("api: like track lookup", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - rows, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id}) - if err != nil { - h.logger.Error("api: like track insert", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "insert failed") - return - } - if rows == 1 { - _ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, id, h.logger) - } - w.WriteHeader(http.StatusNoContent) -} -``` - -Add the `playevents` import: `"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"`. - -- [ ] **Step 6: Update `internal/api/likes.go::handleUnlikeTrack`** - -```go -func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - id, ok := parseUUID(chi.URLParam(r, "id")) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id") - return - } - q := dbq.New(h.pool) - if err := q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: id}); err != nil { - h.logger.Error("api: unlike track", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "delete failed") - return - } - if err := playevents.SoftDeleteContextualLikes(r.Context(), q, user.ID, id); err != nil { - h.logger.Error("api: soft-delete contextual_likes", "err", err) - // Don't fail the response — soft-delete is best-effort. - } - w.WriteHeader(http.StatusNoContent) -} -``` - -- [ ] **Step 7: Add five new tests to `internal/api/likes_test.go`** - -Append: - -```go -func TestLikeTrack_DuringOpenPlayEvent_WritesContextualLike(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - playing := seedTrack(t, pool, album.ID, artist.ID, "Now Playing", 1, 100_000) - other := seedTrack(t, pool, album.ID, artist.ID, "Other", 2, 100_000) - - // Simulate a play_started: insert via the writer. - w := callEvents(h, user, []byte(`{"type":"play_started","track_id":"`+uuidToString(playing.ID)+`"}`)) - if w.Code != http.StatusOK { - t.Fatalf("play_started failed: %d %s", w.Code, w.Body.String()) - } - // Like the OTHER track. With an open play_event for `playing`, - // the contextual_likes row should reference `other` and the playing - // track's session vector. - if r := callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID)); r.Code != http.StatusNoContent { - t.Fatalf("like: %d", r.Code) - } - var count int - _ = pool.QueryRow(context.Background(), - "SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2", user.ID, other.ID).Scan(&count) - if count != 1 { - t.Errorf("contextual_likes count = %d, want 1", count) - } -} - -func TestLikeTrack_NoOpenPlayEvent_NoContextualLike(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - track := seedTrack(t, pool, album.ID, artist.ID, "T", 1, 100_000) - - if r := callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(track.ID)); r.Code != http.StatusNoContent { - t.Fatalf("like: %d", r.Code) - } - var count int - _ = pool.QueryRow(context.Background(), - "SELECT count(*) FROM contextual_likes WHERE user_id=$1", user.ID).Scan(&count) - if count != 0 { - t.Errorf("contextual_likes count = %d, want 0 (no open play)", count) - } -} - -func TestLikeTrack_RepeatedLike_NoDuplicate(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - playing := seedTrack(t, pool, album.ID, artist.ID, "Playing", 1, 100_000) - other := seedTrack(t, pool, album.ID, artist.ID, "Other", 2, 100_000) - - // Open play. - _ = callEvents(h, user, []byte(`{"type":"play_started","track_id":"`+uuidToString(playing.ID)+`"}`)) - // First like — captures. - _ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID)) - // Second like — :execrows=0, no contextual write. - _ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID)) - - var count int - _ = pool.QueryRow(context.Background(), - "SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2", user.ID, other.ID).Scan(&count) - if count != 1 { - t.Errorf("contextual_likes count = %d, want 1 (no duplicate from idempotent re-like)", count) - } -} - -func TestUnlikeTrack_SoftDeletesContextualLikes(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - playing := seedTrack(t, pool, album.ID, artist.ID, "P", 1, 100_000) - other := seedTrack(t, pool, album.ID, artist.ID, "O", 2, 100_000) - - _ = callEvents(h, user, []byte(`{"type":"play_started","track_id":"`+uuidToString(playing.ID)+`"}`)) - _ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID)) - if r := callLike(h, user, http.MethodDelete, "/api/likes/tracks/"+uuidToString(other.ID)); r.Code != http.StatusNoContent { - t.Fatalf("unlike: %d", r.Code) - } - // general_likes deleted. - var glCount int - _ = pool.QueryRow(context.Background(), - "SELECT count(*) FROM general_likes WHERE user_id=$1 AND track_id=$2", user.ID, other.ID).Scan(&glCount) - if glCount != 0 { - t.Errorf("general_likes count = %d, want 0", glCount) - } - // contextual_likes row exists but deleted_at is set. - var deletedAtValid bool - _ = pool.QueryRow(context.Background(), - "SELECT deleted_at IS NOT NULL FROM contextual_likes WHERE user_id=$1 AND track_id=$2 LIMIT 1", - user.ID, other.ID).Scan(&deletedAtValid) - if !deletedAtValid { - t.Errorf("deleted_at not set after unlike") - } -} - -func TestLikeUnlikeRelike_HistoryAccumulates(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - playing := seedTrack(t, pool, album.ID, artist.ID, "P", 1, 100_000) - other := seedTrack(t, pool, album.ID, artist.ID, "O", 2, 100_000) - - _ = callEvents(h, user, []byte(`{"type":"play_started","track_id":"`+uuidToString(playing.ID)+`"}`)) - _ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID)) - _ = callLike(h, user, http.MethodDelete, "/api/likes/tracks/"+uuidToString(other.ID)) - // Re-like in same session — new row should be inserted. - _ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID)) - - var total, active int - _ = pool.QueryRow(context.Background(), - "SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2", user.ID, other.ID).Scan(&total) - _ = pool.QueryRow(context.Background(), - "SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2 AND deleted_at IS NULL", - user.ID, other.ID).Scan(&active) - if total != 2 { - t.Errorf("total = %d, want 2 (history accumulates)", total) - } - if active != 1 { - t.Errorf("active = %d, want 1", active) - } -} -``` - -- [ ] **Step 8: Run, confirm pass** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/api -v -``` -Expected: PASS, including the 5 new tests. - -- [ ] **Step 9: Lint** - -Run: `golangci-lint run ./...` -Expected: clean. - -- [ ] **Step 10: Commit** - -```bash -git add internal/playevents/contextual_likes.go internal/playevents/contextual_likes_test.go \ - internal/api/likes.go internal/api/likes_test.go -git commit -m "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." -``` - ---- - -## Task 6: Subsonic handler updates + verification test - -**Files:** -- Modify: `internal/subsonic/star.go::handleStar` (track branch) -- Modify: `internal/subsonic/star.go::handleUnstar` (track branch) -- Modify: `internal/subsonic/star_test.go` - -- [ ] **Step 1: Update `handleStar` track branch** - -In `internal/subsonic/star.go`, find the section that calls `q.LikeTrack(...)`. Replace: - -```go -if trackID.Valid { - if _, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}); err != nil { - WriteFail(w, r, ErrGeneric, "Could not star track") - return - } -} -``` - -with: - -```go -if trackID.Valid { - rows, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}) - if err != nil { - WriteFail(w, r, ErrGeneric, "Could not star track") - return - } - if rows == 1 { - _ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, trackID, m.logger) - } -} -``` - -The `mediaHandlers` struct currently has fields `pool *pgxpool.Pool` and `events *playevents.Writer`. It does NOT have a `logger`. Two ways to handle: -- Option A: add a `logger *slog.Logger` field to `mediaHandlers`. Update `newMediaHandlers(pool, events, logger)` and the `subsonic.Mount` call site (`internal/subsonic/subsonic.go`) to pass it through. -- Option B: pass a no-op logger inline: `slog.New(slog.NewTextHandler(io.Discard, nil))`. - -Use Option A (cleanest). Add the field; thread the logger through `Mount`. The `subsonic.Mount` signature already takes a `*slog.Logger` per existing code. - -Add the import: `"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"`. - -- [ ] **Step 2: Update `handleUnstar` track branch** - -```go -if t, ok := parseUUID(params.Get("id")); ok { - _ = q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: t}) - _ = playevents.SoftDeleteContextualLikes(r.Context(), q, user.ID, t) -} -``` - -- [ ] **Step 3: Update `mediaHandlers` struct + factory + caller** - -Add `logger *slog.Logger` field to `mediaHandlers`. Update `newMediaHandlers(pool, events, logger)` constructor. Update `internal/subsonic/subsonic.go::Mount`'s call site: - -```go -m := newMediaHandlers(pool, events, logger) -``` - -(`logger` is already a parameter to `Mount`.) - -- [ ] **Step 4: Add verification test in `star_test.go`** - -Append: - -```go -func TestHandleStar_DuringNowPlaying_WritesContextualLike(t *testing.T) { - pool, user, track1, _, _ := testStarPool(t) - // Add a second track so star refers to a different track than the one playing. - q := dbq.New(pool) - a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "Y", SortName: "Y"}) - al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "Y", SortTitle: "Y", ArtistID: a.ID}) - track2, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "Star Me", AlbumID: al.ID, ArtistID: a.ID, FilePath: "/tmp/t2.flac", DurationMs: 100_000, - }) - - m := newStarHandlers(pool) - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - m.logger = logger - - // Start now-playing for track1 (creates open play_event with vector). - q1 := url.Values{} - q1.Set("id", uuidToID(track1.ID)) - q1.Set("submission", "false") - scrobReq := httptest.NewRequest(http.MethodGet, "/rest/scrobble?"+q1.Encode(), nil) - scrobCtx := context.WithValue(scrobReq.Context(), userCtxKey, user) - scrobResp := httptest.NewRecorder() - m.handleScrobble(scrobResp, scrobReq.WithContext(scrobCtx)) - if scrobResp.Code != http.StatusOK { - t.Fatalf("scrobble: %d", scrobResp.Code) - } - - // Star track2. - q2 := url.Values{} - q2.Set("id", uuidToID(track2.ID)) - starReq := httptest.NewRequest(http.MethodGet, "/rest/star?"+q2.Encode(), nil) - starCtx := context.WithValue(starReq.Context(), userCtxKey, user) - starResp := httptest.NewRecorder() - m.handleStar(starResp, starReq.WithContext(starCtx)) - if starResp.Code != http.StatusOK { - t.Fatalf("star: %d", starResp.Code) - } - - // Verify contextual_likes row. - var count int - _ = pool.QueryRow(context.Background(), - "SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2", user.ID, track2.ID).Scan(&count) - if count != 1 { - t.Errorf("contextual_likes count = %d, want 1", count) - } -} -``` - -The `newStarHandlers` helper currently doesn't set a logger; the `mediaHandlers` factory needs to be updated so the test passes the logger. Adjust the test fixture to construct `mediaHandlers` with a real (or io.Discard) logger. - -- [ ] **Step 5: Run, confirm pass** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test ./internal/subsonic -v -``` -Expected: PASS — existing scrobble + star tests + 1 new test. - -- [ ] **Step 6: Full Go suite + lint** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test -p 1 ./... -``` -Expected: all packages PASS. - -Run: `golangci-lint run ./...` -Expected: clean. - -- [ ] **Step 7: Commit** - -```bash -git add internal/subsonic/star.go internal/subsonic/star_test.go internal/subsonic/subsonic.go -git commit -m "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." -``` - ---- - -## Task 7: Final verification + branch finish - -**Files:** none (verification only). - -- [ ] **Step 1: Full Go suite (with DB)** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test -p 1 ./... -``` -Expected: all packages PASS. - -- [ ] **Step 2: Coverage on recommendation + playevents** - -Run: -```bash -MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - go test -coverprofile=/tmp/m3v.cover ./internal/recommendation/... ./internal/playevents/... -go tool cover -func=/tmp/m3v.cover | tail -3 -``` -Expected: total ≥ 70% per the M3 milestone description (sub-plan #1 already pushed `recommendation` to 95.5%; this slice keeps it high while bringing in `playevents` improvements). - -- [ ] **Step 3: Go lint** - -Run: `golangci-lint run ./...` -Expected: clean. - -- [ ] **Step 4: Web check + tests + build** - -Run: `cd web && npm run check && npm test && npm run build` -Expected: check 0/0; tests pass; build emits `web/build/`. (No web changes in this slice; this is a regression check.) - -Run: `git checkout -- web/build/index.html` -Expected: committed placeholder restored. - -- [ ] **Step 5: Docker build smoke** - -Run: `docker build -t minstrel:m3-vectors-smoke .` -Expected: image builds. - -Run: `docker run --rm --entrypoint /bin/sh minstrel:m3-vectors-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'` -Expected: `ok`. - -- [ ] **Step 6: Local end-to-end manual check** - -```bash -docker compose up --build -d -``` - -Browser at `localhost:4533` (sign in with the dev admin creds): - -1. Play 4 tracks all the way through. -2. `psql ... SELECT id, session_vector_at_play FROM play_events ORDER BY started_at DESC LIMIT 5` — vectors populated, first 3 have `seed: true`, 4th onward `seed: false`. -3. While the 4th track is playing, like it via the heart button. `SELECT * FROM contextual_likes` shows a row with that track's session_vector. -4. Like a different track NOT currently playing. `general_likes` row appears; no `contextual_likes` row. -5. Unlike the first track. `general_likes` deleted; `contextual_likes` row still exists but `deleted_at` is set. -6. Re-like that track in a new session. New `contextual_likes` row with `deleted_at IS NULL` and a different `session_vector`. -7. From Feishin: send a scrobble with `submission=false` for track A, then star track B. Verify contextual_likes captured the Subsonic-driven event. - -Tear down: -```bash -docker compose down -``` - -- [ ] **Step 7: Finish the branch** - -Follow `superpowers:finishing-a-development-branch`. Expected path: Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green. - ---- - -## Self-Review Notes - -**Spec coverage:** -- New `contextual_likes` table + indexes → Task 1. -- Pure session-vector function → Task 3. -- Vector capture inside RecordPlayStarted + RecordSyntheticCompletedPlay → Task 4. -- LikeTrack `:execrows` change → Task 2. -- Contextual capture on like (api) → Task 5. -- Soft-delete on unlike (api) → Task 5. -- Same on subsonic → Task 6. -- ListRecentSessionTracks + UpdatePlayEventVector queries → Task 2. -- Cross-session isolation (vector scoped to current session) → Task 4 third test. -- Edge cases (no-op without play, no duplicate on idempotent re-like, history on relike) → Task 5 tests. -- Subsonic verification → Task 6 test. -- ≥70% coverage maintained → Task 7 step 2. - -**Type consistency:** -- `SessionVector { Seed, Artists, Tags, RecentTrackIDs }` — same struct used in `BuildSessionVector` (Task 3) + serialized as JSON in `play_events.session_vector_at_play` (Task 4) + read back from `contextual_likes.session_vector` (Task 5). -- `CaptureContextualLikeIfPlaying(ctx, q, userID, trackID, logger)` — same signature in helpers (Task 5) + api callers (Task 5) + subsonic callers (Task 6). -- `SoftDeleteContextualLikes(ctx, q, userID, trackID)` — same signature in helpers + both call sites. -- `pgtype.UUID` server-side throughout; JSON serialization via `u.String()`. - -**Filename hazards:** none. Only Go files; no SvelteKit route walker concerns. - -**Placeholder scan:** no TBD/TODO/later markers. Each step has complete code; the sqlc-generated-name caveats (Column1/Column2 vs UserID/TrackID) are documented inline so engineers know to verify after `sqlc generate`. - -**Performance note:** the new SELECT + UPDATE inside `RecordPlayStarted` adds ~1-2 ms per play event. Negligible at v1 scale. The `contextual_likes` writes happen at human click rates — also negligible. diff --git a/docs/superpowers/plans/2026-04-28-m4a-scrobble.md b/docs/superpowers/plans/2026-04-28-m4a-scrobble.md deleted file mode 100644 index 15d6d92a..00000000 --- a/docs/superpowers/plans/2026-04-28-m4a-scrobble.md +++ /dev/null @@ -1,2523 +0,0 @@ -# M4a — ListenBrainz Outbound Scrobble Worker Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Send the user's qualifying plays (≥240s OR ≥50% completion) to ListenBrainz with batching + exponential-backoff retry. Per-user token + enabled flag in the SPA's new `/settings` page. Backed by a `scrobble_queue` table that the worker drains every 30s. - -**Architecture:** New `internal/scrobble/` package tree: `listenbrainz/` for the pure HTTP client (typed errors for auth/permanent/transient/retry-after), and the parent package for `Qualifies`, `MaybeEnqueue`, and the `Worker` goroutine. `playevents.Writer.RecordPlayEnded` and `RecordSyntheticCompletedPlay` gain a post-commit best-effort enqueue call. New API endpoints `GET/PUT /api/me/listenbrainz` and a minimal `/settings` page in the SPA. - -**Tech Stack:** Go 1.23 + chi + sqlc + pgx/v5. SvelteKit 2 + Svelte 5 + TanStack Query. New SQL migration; no breaking changes to existing schema. - -**Reference:** design spec at `docs/superpowers/specs/2026-04-28-m4a-scrobble-design.md`. - ---- - -## File Structure - -**New server files:** - -| File | Responsibility | -|---|---| -| `internal/db/migrations/0008_scrobble.up.sql` + `.down.sql` | `users.listenbrainz_token`, `users.listenbrainz_enabled`, `scrobble_queue` table + partial index. | -| `internal/db/queries/users.sql` (modify) | Add `SetListenBrainzToken`, `SetListenBrainzEnabled`, `GetListenBrainzConfig`. | -| `internal/db/queries/scrobble.sql` | New: `EnqueueScrobble :execrows`, `ListPendingScrobbles`, `MarkScrobbleSent`, `MarkScrobbleFailed`, `RescheduleScrobble`, `MarkScrobbleRetryAfter`, `GetLastScrobbledForUser`. | -| `internal/db/dbq/scrobble.sql.go` | Generated bindings. | -| `internal/scrobble/listenbrainz/client.go` | Pure HTTP client. `Client`, `Listen`, `Track` types; `SubmitListens` method; typed errors `ErrAuth`, `ErrPermanent`, `ErrTransient`, `*RetryAfterError`. | -| `internal/scrobble/listenbrainz/client_test.go` | `httptest.Server`-driven tests for each error mapping + payload shape. | -| `internal/scrobble/threshold.go` | Pure `Qualifies(durationPlayedMs int, completionRatio float64) bool`. | -| `internal/scrobble/threshold_test.go` | Boundary tests. | -| `internal/scrobble/queue.go` | `MaybeEnqueue(ctx, q, playEventID)`. Reads user config + threshold; idempotent INSERT. | -| `internal/scrobble/queue_test.go` | Live-DB integration tests. | -| `internal/scrobble/worker.go` | `Worker` struct, `Run` loop, `tickOnce`, `backoffDelay` helper. | -| `internal/scrobble/worker_test.go` | Pure tests for `backoffDelay`. | -| `internal/scrobble/worker_integration_test.go` | Live-DB integration: full ticker pipeline against an httptest LB. | -| `internal/api/listenbrainz.go` | `handleGetListenBrainz`, `handlePutListenBrainz`. | -| `internal/api/listenbrainz_test.go` | Live-DB HTTP tests. | - -**Modified server files:** - -| File | Change | -|---|---| -| `internal/db/dbq/users.sql.go` + `models.go` | Generated additions for the new user columns. | -| `internal/playevents/writer.go::RecordPlayEnded` | After `pgx.BeginFunc` returns nil, call `scrobble.MaybeEnqueue(ctx, dbq.New(w.pool), playEventID)`; log + swallow errors. | -| `internal/playevents/writer.go::RecordSyntheticCompletedPlay` | Same pattern. | -| `internal/playevents/writer_test.go` | Three new tests covering enqueue: qualifying play → row inserted, sub-threshold → no row, user-disabled → no row. | -| `internal/api/api.go::Mount` | Add `authed.Get("/me/listenbrainz", h.handleGetListenBrainz)` and `authed.Put("/me/listenbrainz", h.handlePutListenBrainz)`. | -| `internal/api/auth_test.go` | testHandlers helper unchanged (no new struct fields). | -| `cmd/minstrel/main.go` | Construct `scrobble.Worker` after pool open; `go worker.Run(ctx)` after server starts. | - -**Frontend files:** - -| File | Responsibility | -|---|---| -| `web/src/lib/api/client.ts` (modify) | Add `api.put(path, body)` helper. | -| `web/src/lib/api/listenbrainz.ts` | New: `getListenBrainzStatus()`, `setListenBrainzToken(token)`, `setListenBrainzEnabled(enabled)` + `LBStatus` type. | -| `web/src/routes/settings/+page.svelte` | The Settings page, single ListenBrainz section. | -| `web/src/routes/settings/settings.test.ts` | Vitest coverage for the page. | -| `web/src/lib/components/Shell.svelte` | Add `{ href: '/settings', label: 'Settings' }` to `navItems`. | -| `web/src/lib/components/Shell.test.ts` (modify) | Update nav assertions if the test enumerates items. | - ---- - -## Task 1: Migration 0008 — schema - -**Files:** -- Create: `internal/db/migrations/0008_scrobble.up.sql` -- Create: `internal/db/migrations/0008_scrobble.down.sql` - -- [ ] **Step 1: Write the up migration** - -Create `internal/db/migrations/0008_scrobble.up.sql`: - -```sql --- M4a: outbound ListenBrainz scrobble worker. --- Per-user LB config (plaintext token; users rotate via /settings if leaked). --- scrobble_queue is a work list, not a log: successfully-sent rows are --- DELETEd by the worker (the canonical record stays in play_events.scrobbled_at --- via M2's column). Only `pending` and `failed` states exist. - -ALTER TABLE users - ADD COLUMN listenbrainz_token TEXT NULL, - ADD COLUMN listenbrainz_enabled BOOLEAN NOT NULL DEFAULT FALSE; - -CREATE TABLE scrobble_queue ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, - play_event_id uuid NOT NULL REFERENCES play_events(id) ON DELETE CASCADE, - status TEXT NOT NULL CHECK (status IN ('pending', 'failed')), - attempts INTEGER NOT NULL DEFAULT 0, - next_attempt_at timestamptz NOT NULL DEFAULT now(), - last_error TEXT NULL, - enqueued_at timestamptz NOT NULL DEFAULT now(), - UNIQUE (play_event_id) -); - --- Hot path for the worker's per-tick query. -CREATE INDEX scrobble_queue_pending_idx - ON scrobble_queue (next_attempt_at) - WHERE status = 'pending'; -``` - -- [ ] **Step 2: Write the down migration** - -Create `internal/db/migrations/0008_scrobble.down.sql`: - -```sql -DROP TABLE IF EXISTS scrobble_queue; -ALTER TABLE users DROP COLUMN IF EXISTS listenbrainz_enabled; -ALTER TABLE users DROP COLUMN IF EXISTS listenbrainz_token; -``` - -- [ ] **Step 3: Verify the migration applies cleanly** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -short -race ./internal/db/` - -Expected: PASS. The migration test (which applies all migrations against a temp schema) finds version 8. - -- [ ] **Step 4: Commit** - -```bash -git add internal/db/migrations/0008_scrobble.up.sql internal/db/migrations/0008_scrobble.down.sql -git commit -m "feat(db): add migration 0008 for scrobble (users LB columns + scrobble_queue table)" -``` - ---- - -## Task 2: sqlc queries — users LB config + scrobble_queue - -**Files:** -- Modify: `internal/db/queries/users.sql` -- Create: `internal/db/queries/scrobble.sql` -- Generated: `internal/db/dbq/users.sql.go`, `internal/db/dbq/scrobble.sql.go`, `internal/db/dbq/models.go` - -- [ ] **Step 1: Add user-LB-config queries** - -Append to `internal/db/queries/users.sql`: - -```sql --- name: SetListenBrainzToken :exec -UPDATE users -SET listenbrainz_token = $2, - listenbrainz_enabled = CASE WHEN COALESCE($2, '') = '' THEN FALSE ELSE listenbrainz_enabled END -WHERE id = $1; - --- name: SetListenBrainzEnabled :exec -UPDATE users -SET listenbrainz_enabled = $2 -WHERE id = $1; - --- name: GetListenBrainzConfig :one --- Returns the user's LB token + enabled flag and the most recent --- play_events.scrobbled_at for last-scrobbled-at status. -SELECT - u.listenbrainz_token, - u.listenbrainz_enabled, - (SELECT MAX(pe.scrobbled_at) - FROM play_events pe - WHERE pe.user_id = u.id) AS last_scrobbled_at -FROM users u -WHERE u.id = $1; -``` - -- [ ] **Step 2: Add scrobble_queue queries** - -Create `internal/db/queries/scrobble.sql`: - -```sql --- name: EnqueueScrobble :execrows --- Idempotent: if a row exists for this play_event_id, ON CONFLICT does nothing --- and the affected-rows count is 0. Caller (MaybeEnqueue) doesn't treat 0 as --- an error. -INSERT INTO scrobble_queue (user_id, play_event_id, status) -VALUES ($1, $2, 'pending') -ON CONFLICT (play_event_id) DO NOTHING; - --- name: ListPendingScrobbles :many --- Worker pulls up to N pending rows whose next_attempt_at has passed. --- Joins play_events + tracks/albums/artists + users so the worker can --- assemble the LB Listen payload in one round-trip. -SELECT - sq.id AS queue_id, - sq.user_id AS user_id, - sq.play_event_id AS play_event_id, - sq.attempts AS attempts, - u.listenbrainz_token AS lb_token, - u.listenbrainz_enabled AS lb_enabled, - pe.started_at AS started_at, - t.title AS track_title, - t.duration_ms AS track_duration_ms, - t.mbid AS track_mbid, - al.title AS album_title, - al.mbid AS album_mbid, - ar.name AS artist_name, - ar.mbid AS artist_mbid -FROM scrobble_queue sq -JOIN users u ON u.id = sq.user_id -JOIN play_events pe ON pe.id = sq.play_event_id -JOIN tracks t ON t.id = pe.track_id -JOIN albums al ON al.id = t.album_id -JOIN artists ar ON ar.id = t.artist_id -WHERE sq.status = 'pending' - AND sq.next_attempt_at <= now() -ORDER BY sq.next_attempt_at -LIMIT $1; - --- name: MarkScrobbleSent :exec --- Successful submit. Delete the queue row AND stamp play_events.scrobbled_at --- in one statement via a CTE so partial failure is impossible. -WITH del AS ( - DELETE FROM scrobble_queue - WHERE id = $1 - RETURNING play_event_id -) -UPDATE play_events -SET scrobbled_at = now() -WHERE id = (SELECT play_event_id FROM del); - --- name: RescheduleScrobble :exec --- After a transient failure: increment attempts, schedule next attempt, --- record the error. -UPDATE scrobble_queue -SET attempts = attempts + 1, - next_attempt_at = $2, - last_error = $3 -WHERE id = $1; - --- name: MarkScrobbleRetryAfter :exec --- After a 429: schedule next attempt per LB's Retry-After header, but do NOT --- increment attempts (the server told us to wait, not that we failed). -UPDATE scrobble_queue -SET next_attempt_at = $2 -WHERE id = $1; - --- name: MarkScrobbleFailed :exec --- After a permanent failure (4xx, exhausted retries, auth) — mark failed --- and keep the row for diagnostics. -UPDATE scrobble_queue -SET status = 'failed', - last_error = $2 -WHERE id = $1; -``` - -- [ ] **Step 3: Run sqlc generate** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && make generate` - -Expected: docker pulls sqlc 1.27.0, regenerates `internal/db/dbq/`. New methods appear on `*Queries`: -- `SetListenBrainzToken`, `SetListenBrainzEnabled`, `GetListenBrainzConfig` -- `EnqueueScrobble`, `ListPendingScrobbles`, `MarkScrobbleSent`, `RescheduleScrobble`, `MarkScrobbleRetryAfter`, `MarkScrobbleFailed` - -`models.go` gains the two new `User` fields: `ListenbrainzToken *string` and `ListenbrainzEnabled bool`. - -- [ ] **Step 4: Verify compile** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go build ./...` - -Expected: clean. No callers yet — generated bindings just need to compile. - -- [ ] **Step 5: Commit** - -```bash -git add internal/db/queries/ internal/db/dbq/ -git commit -m "feat(db): add ListenBrainz user-config and scrobble_queue queries" -``` - ---- - -## Task 3: ListenBrainz HTTP client (pure) - -**Files:** -- Create: `internal/scrobble/listenbrainz/client.go` -- Create: `internal/scrobble/listenbrainz/client_test.go` - -- [ ] **Step 1: Write the failing tests** - -Create `internal/scrobble/listenbrainz/client_test.go`: - -```go -package listenbrainz - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -func newTestClient(handler http.HandlerFunc) (*Client, *httptest.Server) { - srv := httptest.NewServer(handler) - return &Client{BaseURL: srv.URL, HTTP: srv.Client()}, srv -} - -func TestClient_SubmitListens_Success(t *testing.T) { - var seenPath, seenAuth, seenBody string - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - seenPath = r.URL.Path - seenAuth = r.Header.Get("Authorization") - buf := make([]byte, 4096) - n, _ := r.Body.Read(buf) - seenBody = string(buf[:n]) - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"status":"ok"}`)) - }) - defer srv.Close() - - listens := []Listen{{ - ListenedAt: 1700000000, - Track: Track{ - ArtistName: "Miles Davis", - TrackName: "So What", - ReleaseName: "Kind of Blue", - DurationMs: 545000, - }, - }} - if err := c.SubmitListens(context.Background(), "tk", listens); err != nil { - t.Fatalf("err: %v", err) - } - if seenPath != "/1/submit-listens" { - t.Errorf("path = %q, want /1/submit-listens", seenPath) - } - if seenAuth != "Token tk" { - t.Errorf("auth = %q, want %q", seenAuth, "Token tk") - } - if !strings.Contains(seenBody, `"track_name":"So What"`) { - t.Errorf("body missing track_name: %s", seenBody) - } - if !strings.Contains(seenBody, `"artist_name":"Miles Davis"`) { - t.Errorf("body missing artist_name: %s", seenBody) - } -} - -func TestClient_SubmitListens_401_ReturnsErrAuth(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - }) - defer srv.Close() - err := c.SubmitListens(context.Background(), "bad", []Listen{{}}) - if !errors.Is(err, ErrAuth) { - t.Errorf("err = %v, want ErrAuth", err) - } -} - -func TestClient_SubmitListens_400_ReturnsErrPermanent(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) - }) - defer srv.Close() - err := c.SubmitListens(context.Background(), "tk", []Listen{{}}) - if !errors.Is(err, ErrPermanent) { - t.Errorf("err = %v, want ErrPermanent", err) - } -} - -func TestClient_SubmitListens_503_ReturnsErrTransient(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - }) - defer srv.Close() - err := c.SubmitListens(context.Background(), "tk", []Listen{{}}) - if !errors.Is(err, ErrTransient) { - t.Errorf("err = %v, want ErrTransient", err) - } -} - -func TestClient_SubmitListens_429_ReturnsRetryAfter(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Retry-After", "60") - w.WriteHeader(http.StatusTooManyRequests) - }) - defer srv.Close() - err := c.SubmitListens(context.Background(), "tk", []Listen{{}}) - var ra *RetryAfterError - if !errors.As(err, &ra) { - t.Fatalf("err = %v, want *RetryAfterError", err) - } - if ra.Wait != 60*time.Second { - t.Errorf("Wait = %v, want 60s", ra.Wait) - } -} - -func TestClient_SubmitListens_NetworkFailure_ReturnsErrTransient(t *testing.T) { - c := &Client{BaseURL: "http://127.0.0.1:1", HTTP: &http.Client{Timeout: 100 * time.Millisecond}} - err := c.SubmitListens(context.Background(), "tk", []Listen{{}}) - if !errors.Is(err, ErrTransient) { - t.Errorf("err = %v, want ErrTransient", err) - } -} - -func TestClient_SubmitListens_PayloadTypeImportForBatch(t *testing.T) { - var seenBody string - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - buf := make([]byte, 4096) - n, _ := r.Body.Read(buf) - seenBody = string(buf[:n]) - w.WriteHeader(http.StatusOK) - }) - defer srv.Close() - _ = c.SubmitListens(context.Background(), "tk", []Listen{ - {ListenedAt: 1, Track: Track{ArtistName: "A", TrackName: "X"}}, - {ListenedAt: 2, Track: Track{ArtistName: "B", TrackName: "Y"}}, - }) - if !strings.Contains(seenBody, `"listen_type":"import"`) { - t.Errorf("batch body missing listen_type=import: %s", seenBody) - } -} - -func TestClient_SubmitListens_PayloadTypeSingleForOne(t *testing.T) { - var seenBody string - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - buf := make([]byte, 4096) - n, _ := r.Body.Read(buf) - seenBody = string(buf[:n]) - w.WriteHeader(http.StatusOK) - }) - defer srv.Close() - _ = c.SubmitListens(context.Background(), "tk", []Listen{ - {ListenedAt: 1, Track: Track{ArtistName: "A", TrackName: "X"}}, - }) - if !strings.Contains(seenBody, `"listen_type":"single"`) { - t.Errorf("single body missing listen_type=single: %s", seenBody) - } -} - -func TestClient_SubmitListens_OmitsEmptyMBIDs(t *testing.T) { - var seenBody string - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - buf := make([]byte, 4096) - n, _ := r.Body.Read(buf) - seenBody = string(buf[:n]) - w.WriteHeader(http.StatusOK) - }) - defer srv.Close() - _ = c.SubmitListens(context.Background(), "tk", []Listen{ - {ListenedAt: 1, Track: Track{ArtistName: "A", TrackName: "X"}}, - }) - if strings.Contains(seenBody, "recording_mbid") { - t.Errorf("body should omit empty recording_mbid: %s", seenBody) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/listenbrainz/ -v` - -Expected: FAIL with "no Go files" or "undefined: Client". - -- [ ] **Step 3: Write the client** - -Create `internal/scrobble/listenbrainz/client.go`: - -```go -// Package listenbrainz is the pure HTTP client for the ListenBrainz -// submit-listens endpoint. No DB, no worker plumbing — just types and one -// method. Errors are typed (ErrAuth, ErrPermanent, ErrTransient, -// *RetryAfterError) so callers can branch on response semantics. -package listenbrainz - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "strconv" - "time" -) - -const defaultBaseURL = "https://api.listenbrainz.org" - -// Listen is one row sent to ListenBrainz. -type Listen struct { - ListenedAt int64 // unix seconds - Track Track -} - -// Track is the per-listen metadata. Optional fields are omitted from the -// payload when zero-valued. -type Track struct { - ArtistName string - TrackName string - ReleaseName string - DurationMs int - RecordingMBID string - ArtistMBIDs []string - ReleaseMBID string -} - -// Client posts to the LB submit-listens endpoint. -type Client struct { - BaseURL string - HTTP *http.Client -} - -// NewClient returns a default-configured client (LB production base URL, -// 30s timeout). -func NewClient() *Client { - return &Client{ - BaseURL: defaultBaseURL, - HTTP: &http.Client{Timeout: 30 * time.Second}, - } -} - -// Sentinel errors. The worker branches on these to decide retry vs fail. -var ( - ErrAuth = errors.New("listenbrainz: auth rejected") - ErrPermanent = errors.New("listenbrainz: permanent error") - ErrTransient = errors.New("listenbrainz: transient error") -) - -// RetryAfterError carries the LB-supplied wait duration for 429 responses. -type RetryAfterError struct{ Wait time.Duration } - -func (e *RetryAfterError) Error() string { - return fmt.Sprintf("listenbrainz: retry after %v", e.Wait) -} - -// SubmitListens posts the given listens. payload_type is "single" for one -// listen, "import" for batches. -func (c *Client) SubmitListens(ctx context.Context, token string, listens []Listen) error { - if len(listens) == 0 { - return nil - } - - listenType := "import" - if len(listens) == 1 { - listenType = "single" - } - - body, err := json.Marshal(buildPayload(listenType, listens)) - if err != nil { - return fmt.Errorf("listenbrainz: marshal: %w", err) - } - - base := c.BaseURL - if base == "" { - base = defaultBaseURL - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/1/submit-listens", bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("listenbrainz: build request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Token "+token) - - httpClient := c.HTTP - if httpClient == nil { - httpClient = http.DefaultClient - } - resp, err := httpClient.Do(req) - if err != nil { - return fmt.Errorf("%w: %v", ErrTransient, err) - } - defer func() { _ = resp.Body.Close() }() - - switch { - case resp.StatusCode >= 200 && resp.StatusCode < 300: - return nil - case resp.StatusCode == http.StatusUnauthorized: - return ErrAuth - case resp.StatusCode == http.StatusTooManyRequests: - wait := parseRetryAfter(resp.Header.Get("Retry-After")) - return &RetryAfterError{Wait: wait} - case resp.StatusCode >= 500: - return fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode) - default: - return fmt.Errorf("%w: status %d", ErrPermanent, resp.StatusCode) - } -} - -func parseRetryAfter(header string) time.Duration { - if header == "" { - return 60 * time.Second // sensible default - } - if secs, err := strconv.Atoi(header); err == nil { - return time.Duration(secs) * time.Second - } - if t, err := http.ParseTime(header); err == nil { - d := time.Until(t) - if d < 0 { - return 60 * time.Second - } - return d - } - return 60 * time.Second -} - -// payload mirrors the LB submit-listens body. Optional MBID fields use -// `omitempty` so empty strings/slices don't appear in the JSON. -type payload struct { - ListenType string `json:"listen_type"` - Payload []payloadEntry `json:"payload"` -} - -type payloadEntry struct { - ListenedAt int64 `json:"listened_at"` - TrackMetadata trackMetadata `json:"track_metadata"` -} - -type trackMetadata struct { - ArtistName string `json:"artist_name"` - TrackName string `json:"track_name"` - ReleaseName string `json:"release_name,omitempty"` - AdditionalInfo additionalInfo `json:"additional_info,omitempty"` -} - -type additionalInfo struct { - DurationMs int `json:"duration_ms,omitempty"` - RecordingMBID string `json:"recording_mbid,omitempty"` - ArtistMBIDs []string `json:"artist_mbids,omitempty"` - ReleaseMBID string `json:"release_mbid,omitempty"` -} - -func buildPayload(listenType string, listens []Listen) payload { - entries := make([]payloadEntry, 0, len(listens)) - for _, l := range listens { - entries = append(entries, payloadEntry{ - ListenedAt: l.ListenedAt, - TrackMetadata: trackMetadata{ - ArtistName: l.Track.ArtistName, - TrackName: l.Track.TrackName, - ReleaseName: l.Track.ReleaseName, - AdditionalInfo: additionalInfo{ - DurationMs: l.Track.DurationMs, - RecordingMBID: l.Track.RecordingMBID, - ArtistMBIDs: l.Track.ArtistMBIDs, - ReleaseMBID: l.Track.ReleaseMBID, - }, - }, - }) - } - return payload{ListenType: listenType, Payload: entries} -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/listenbrainz/ -v` - -Expected: PASS for all 9 tests. - -- [ ] **Step 5: Commit** - -```bash -git add internal/scrobble/listenbrainz/ -git commit -m "feat(scrobble): add pure ListenBrainz HTTP client with typed errors" -``` - ---- - -## Task 4: Threshold (pure) - -**Files:** -- Create: `internal/scrobble/threshold.go` -- Create: `internal/scrobble/threshold_test.go` - -- [ ] **Step 1: Write the failing tests** - -Create `internal/scrobble/threshold_test.go`: - -```go -package scrobble - -import "testing" - -func TestQualifies_NeverPlayed(t *testing.T) { - if Qualifies(0, 0) { - t.Error("0/0 should not qualify") - } -} - -func TestQualifies_AtLeast240Seconds(t *testing.T) { - if !Qualifies(240_000, 0.0) { - t.Error("240_000 ms played should qualify regardless of ratio") - } -} - -func TestQualifies_AtLeastHalfRatio(t *testing.T) { - if !Qualifies(0, 0.5) { - t.Error("ratio=0.5 should qualify regardless of duration") - } -} - -func TestQualifies_BelowBoth(t *testing.T) { - if Qualifies(120_000, 0.49) { - t.Error("120s + 49% should not qualify") - } -} - -func TestQualifies_BoundaryJustUnder(t *testing.T) { - if Qualifies(239_999, 0.4999) { - t.Error("just-under both thresholds should not qualify") - } -} - -func TestQualifies_BoundaryExactly(t *testing.T) { - if !Qualifies(240_000, 0.4999) { - t.Error("exactly 240s should qualify") - } - if !Qualifies(239_999, 0.5) { - t.Error("exactly 50% should qualify") - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/ -run Qualifies -v` - -Expected: FAIL with "no Go files" or "undefined: Qualifies". - -- [ ] **Step 3: Write the threshold function** - -Create `internal/scrobble/threshold.go`: - -```go -// Package scrobble owns the outbound-scrobble pipeline: threshold detection, -// the queue write path, and the worker goroutine. The HTTP client is in -// internal/scrobble/listenbrainz; this package consumes it. -package scrobble - -// Qualifies returns true if a closed play_event meets ListenBrainz's -// recommended scrobble threshold: ≥240 seconds played OR ≥50% of the -// track length played. -// -// The two thresholds are deliberately separate from M2's skip-detection -// thresholds (which serve a different purpose — internal engine signal -// for the scoring formula's skip penalty). -func Qualifies(durationPlayedMs int, completionRatio float64) bool { - return durationPlayedMs >= 240_000 || completionRatio >= 0.5 -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/ -run Qualifies -v` - -Expected: PASS for all 6 tests. - -- [ ] **Step 5: Commit** - -```bash -git add internal/scrobble/threshold.go internal/scrobble/threshold_test.go -git commit -m "feat(scrobble): add pure Qualifies threshold function" -``` - ---- - -## Task 5: MaybeEnqueue (DB-backed) - -**Files:** -- Create: `internal/scrobble/queue.go` -- Create: `internal/scrobble/queue_test.go` - -- [ ] **Step 1: Write the failing integration tests** - -Create `internal/scrobble/queue_test.go`: - -```go -package scrobble - -import ( - "context" - "io" - "log/slog" - "os" - "testing" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -func testPool(t *testing.T) (*pgxpool.Pool, *dbq.Queries) { - t.Helper() - if testing.Short() { - t.Skip("skipping in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - t.Fatalf("pool: %v", err) - } - t.Cleanup(pool.Close) - if _, err := pool.Exec(context.Background(), - "TRUNCATE scrobble_queue, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { - t.Fatalf("truncate: %v", err) - } - return pool, dbq.New(pool) -} - -type setup struct { - pool *pgxpool.Pool - q *dbq.Queries - user pgtype.UUID - playEvent pgtype.UUID -} - -// seed creates: user (with optional LB token + enabled), one artist/album/track, -// one closed play_event with the given duration_played_ms and completion_ratio. -func seed(t *testing.T, opts seedOpts) setup { - t.Helper() - pool, q := testPool(t) - ctx := context.Background() - u, err := q.CreateUser(ctx, dbq.CreateUserParams{ - Username: "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, - }) - if err != nil { - t.Fatalf("user: %v", err) - } - if opts.lbToken != "" { - if err := q.SetListenBrainzToken(ctx, dbq.SetListenBrainzTokenParams{ID: u.ID, ListenbrainzToken: &opts.lbToken}); err != nil { - t.Fatalf("token: %v", err) - } - } - if opts.lbEnabled { - if err := q.SetListenBrainzEnabled(ctx, dbq.SetListenBrainzEnabledParams{ID: u.ID, ListenbrainzEnabled: true}); err != nil { - t.Fatalf("enabled: %v", err) - } - } - a, _ := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "X", SortName: "X"}) - al, _ := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "X", SortTitle: "X", ArtistID: a.ID}) - tr, _ := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ - Title: "Y", AlbumID: al.ID, ArtistID: a.ID, FilePath: "/tmp/y.flac", DurationMs: 300_000, - }) - // Insert play_session + play_event directly with the provided duration/ratio. - var sessionID pgtype.UUID - if err := pool.QueryRow(ctx, - `INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id) - VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`, - u.ID).Scan(&sessionID); err != nil { - t.Fatalf("session: %v", err) - } - var peID pgtype.UUID - if err := pool.QueryRow(ctx, - `INSERT INTO play_events (user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped) - VALUES ($1, $2, $3, now() - interval '1 minute', now(), $4, $5, false) RETURNING id`, - u.ID, tr.ID, sessionID, opts.durationMs, opts.ratio).Scan(&peID); err != nil { - t.Fatalf("play_event: %v", err) - } - return setup{pool: pool, q: q, user: u.ID, playEvent: peID} -} - -type seedOpts struct { - lbToken string - lbEnabled bool - durationMs int32 - ratio float64 -} - -func countQueueRows(t *testing.T, s setup) int { - t.Helper() - var n int - if err := s.pool.QueryRow(context.Background(), - `SELECT count(*) FROM scrobble_queue WHERE play_event_id = $1`, s.playEvent).Scan(&n); err != nil { - t.Fatalf("count: %v", err) - } - return n -} - -func TestMaybeEnqueue_QualifyingPlayWithEnabledUser_InsertsRow(t *testing.T) { - s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) - if err := MaybeEnqueue(context.Background(), s.q, s.playEvent); err != nil { - t.Fatalf("MaybeEnqueue: %v", err) - } - if got := countQueueRows(t, s); got != 1 { - t.Errorf("rows = %d, want 1", got) - } -} - -func TestMaybeEnqueue_SubThreshold_NoRow(t *testing.T) { - // 100s, 33% — neither threshold met. - s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 100_000, ratio: 0.33}) - if err := MaybeEnqueue(context.Background(), s.q, s.playEvent); err != nil { - t.Fatalf("MaybeEnqueue: %v", err) - } - if got := countQueueRows(t, s); got != 0 { - t.Errorf("rows = %d, want 0 (sub-threshold)", got) - } -} - -func TestMaybeEnqueue_DisabledUser_NoRow(t *testing.T) { - s := seed(t, seedOpts{lbToken: "tk", lbEnabled: false, durationMs: 250_000, ratio: 0.83}) - if err := MaybeEnqueue(context.Background(), s.q, s.playEvent); err != nil { - t.Fatalf("MaybeEnqueue: %v", err) - } - if got := countQueueRows(t, s); got != 0 { - t.Errorf("rows = %d, want 0 (disabled)", got) - } -} - -func TestMaybeEnqueue_NoToken_NoRow(t *testing.T) { - // Even with enabled=true, an empty token shouldn't enqueue. - s := seed(t, seedOpts{lbToken: "", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) - if err := MaybeEnqueue(context.Background(), s.q, s.playEvent); err != nil { - t.Fatalf("MaybeEnqueue: %v", err) - } - if got := countQueueRows(t, s); got != 0 { - t.Errorf("rows = %d, want 0 (no token)", got) - } -} - -func TestMaybeEnqueue_Idempotent_TwoCallsOneRow(t *testing.T) { - s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) - for i := 0; i < 2; i++ { - if err := MaybeEnqueue(context.Background(), s.q, s.playEvent); err != nil { - t.Fatalf("MaybeEnqueue #%d: %v", i, err) - } - } - if got := countQueueRows(t, s); got != 1 { - t.Errorf("rows = %d, want 1 (idempotent)", got) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/scrobble/ -run MaybeEnqueue -v` - -Expected: FAIL with "undefined: MaybeEnqueue". - -- [ ] **Step 3: Write the implementation** - -Create `internal/scrobble/queue.go`: - -```go -package scrobble - -import ( - "context" - "errors" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// MaybeEnqueue inserts a scrobble_queue row for the given play_event if: -// 1. The user has listenbrainz_enabled = true with a non-empty token. -// 2. The play_event passes Qualifies(). -// Idempotent via UNIQUE(play_event_id) — re-runs are no-ops. -// -// Best-effort: callers (the playevents.Writer hooks) should log returned -// errors but not propagate them, since scrobble delivery is enrichment -// and must not block the canonical play history. -func MaybeEnqueue(ctx context.Context, q *dbq.Queries, playEventID pgtype.UUID) error { - pe, err := q.GetPlayEventByID(ctx, playEventID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil - } - return err - } - // Only closed events qualify (DurationPlayedMs/CompletionRatio populated). - if pe.DurationPlayedMs == nil || pe.CompletionRatio == nil { - return nil - } - if !Qualifies(int(*pe.DurationPlayedMs), *pe.CompletionRatio) { - return nil - } - cfg, err := q.GetListenBrainzConfig(ctx, pe.UserID) - if err != nil { - return err - } - if !cfg.ListenbrainzEnabled || cfg.ListenbrainzToken == nil || *cfg.ListenbrainzToken == "" { - return nil - } - _, err = q.EnqueueScrobble(ctx, dbq.EnqueueScrobbleParams{ - UserID: pe.UserID, - PlayEventID: pe.ID, - }) - return err -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/scrobble/ -run MaybeEnqueue -v` - -Expected: PASS for all 5 tests. - -- [ ] **Step 5: Commit** - -```bash -git add internal/scrobble/queue.go internal/scrobble/queue_test.go -git commit -m "feat(scrobble): add MaybeEnqueue with idempotent insert + threshold gate" -``` - ---- - -## Task 6: Backoff schedule (pure) - -**Files:** -- Create: `internal/scrobble/worker.go` (skeleton with backoffDelay only) -- Create: `internal/scrobble/worker_test.go` - -- [ ] **Step 1: Write the failing tests** - -Create `internal/scrobble/worker_test.go`: - -```go -package scrobble - -import ( - "testing" - "time" -) - -func TestBackoffDelay(t *testing.T) { - cases := []struct { - attempts int - want time.Duration - ok bool - }{ - {1, 1 * time.Minute, true}, - {2, 5 * time.Minute, true}, - {3, 30 * time.Minute, true}, - {4, 2 * time.Hour, true}, - {5, 6 * time.Hour, true}, - {6, 0, false}, // exhausted - {99, 0, false}, // way past max - } - for _, c := range cases { - got, ok := backoffDelay(c.attempts) - if ok != c.ok { - t.Errorf("backoffDelay(%d) ok = %v, want %v", c.attempts, ok, c.ok) - } - if got != c.want { - t.Errorf("backoffDelay(%d) = %v, want %v", c.attempts, got, c.want) - } - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/ -run BackoffDelay -v` - -Expected: FAIL with "undefined: backoffDelay". - -- [ ] **Step 3: Write the worker skeleton with backoffDelay** - -Create `internal/scrobble/worker.go`: - -```go -package scrobble - -import ( - "time" -) - -// backoffSchedule maps the failure count (1-indexed: 1 = first failure -// just happened) to the delay before the next attempt. Per spec §9.6: -// 1m → 5m → 30m → 2h → 6h, then give up. -var backoffSchedule = []time.Duration{ - 1 * time.Minute, - 5 * time.Minute, - 30 * time.Minute, - 2 * time.Hour, - 6 * time.Hour, -} - -const maxAttempts = 5 - -// backoffDelay returns the delay before the next attempt given the value -// of the `attempts` column AFTER the failure has been recorded (1-indexed). -// Returns (_, false) when attempts > maxAttempts (give up — the row should -// be marked failed). -func backoffDelay(attempts int) (time.Duration, bool) { - if attempts < 1 || attempts > maxAttempts { - return 0, false - } - return backoffSchedule[attempts-1], true -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/ -run BackoffDelay -v` - -Expected: PASS for all cases. - -- [ ] **Step 5: Commit** - -```bash -git add internal/scrobble/worker.go internal/scrobble/worker_test.go -git commit -m "feat(scrobble): add backoffDelay schedule (1m, 5m, 30m, 2h, 6h, give up)" -``` - ---- - -## Task 7: Worker tickOnce (DB-backed) - -**Files:** -- Modify: `internal/scrobble/worker.go` (add Worker struct, NewWorker, tickOnce, Run) -- Create: `internal/scrobble/worker_integration_test.go` - -- [ ] **Step 1: Write the failing integration tests** - -Create `internal/scrobble/worker_integration_test.go`: - -```go -package scrobble - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - "time" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" -) - -// helperEnqueue inserts a queue row directly so we don't go through the threshold path. -func helperEnqueue(t *testing.T, s setup) { - t.Helper() - if _, err := s.pool.Exec(context.Background(), - `INSERT INTO scrobble_queue (user_id, play_event_id, status) VALUES ($1, $2, 'pending')`, - s.user, s.playEvent); err != nil { - t.Fatalf("enqueue: %v", err) - } -} - -func helperPendingCount(t *testing.T, s setup) int { - t.Helper() - var n int - if err := s.pool.QueryRow(context.Background(), - `SELECT count(*) FROM scrobble_queue WHERE status = 'pending' AND play_event_id = $1`, s.playEvent).Scan(&n); err != nil { - t.Fatalf("pending: %v", err) - } - return n -} - -func helperFailedCount(t *testing.T, s setup) int { - t.Helper() - var n int - if err := s.pool.QueryRow(context.Background(), - `SELECT count(*) FROM scrobble_queue WHERE status = 'failed' AND play_event_id = $1`, s.playEvent).Scan(&n); err != nil { - t.Fatalf("failed: %v", err) - } - return n -} - -func helperPlayEventScrobbledAt(t *testing.T, s setup) bool { - t.Helper() - var v *time.Time - if err := s.pool.QueryRow(context.Background(), - `SELECT scrobbled_at FROM play_events WHERE id = $1`, s.playEvent).Scan(&v); err != nil { - t.Fatalf("scrobbled_at: %v", err) - } - return v != nil -} - -func newTestWorker(s setup, lb *listenbrainz.Client) *Worker { - return NewWorker(s.pool, lb, helperLogger()) -} - -func helperLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } - -func TestWorker_Success_DeletesQueueRowAndStampsScrobbledAt(t *testing.T) { - s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) - helperEnqueue(t, s) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()} - w := newTestWorker(s, lb) - - if err := w.tickOnce(context.Background()); err != nil { - t.Fatalf("tickOnce: %v", err) - } - if helperPendingCount(t, s) != 0 { - t.Error("queue row should be deleted on success") - } - if helperFailedCount(t, s) != 0 { - t.Error("no failed row expected on success") - } - if !helperPlayEventScrobbledAt(t, s) { - t.Error("play_events.scrobbled_at should be populated on success") - } -} - -func TestWorker_503Once_RowRemainsAttempts1(t *testing.T) { - s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) - helperEnqueue(t, s) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - })) - defer srv.Close() - lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()} - w := newTestWorker(s, lb) - _ = w.tickOnce(context.Background()) - - var attempts int - if err := s.pool.QueryRow(context.Background(), - `SELECT attempts FROM scrobble_queue WHERE play_event_id = $1`, s.playEvent).Scan(&attempts); err != nil { - t.Fatalf("attempts: %v", err) - } - if attempts != 1 { - t.Errorf("attempts = %d, want 1 after 503", attempts) - } - if helperPendingCount(t, s) != 1 { - t.Error("row should remain pending after transient failure") - } -} - -func TestWorker_503FiveTimes_MarksFailed(t *testing.T) { - s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) - helperEnqueue(t, s) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - })) - defer srv.Close() - lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()} - w := newTestWorker(s, lb) - // Five failures: each tick marches next_attempt_at forward, so we need to - // reset it between ticks to simulate the time passing. - for i := 0; i < 5; i++ { - if _, err := s.pool.Exec(context.Background(), - `UPDATE scrobble_queue SET next_attempt_at = now() WHERE play_event_id = $1`, s.playEvent); err != nil { - t.Fatalf("reset: %v", err) - } - _ = w.tickOnce(context.Background()) - } - if helperFailedCount(t, s) != 1 { - t.Errorf("expected row marked failed after 5 attempts; pending=%d failed=%d", - helperPendingCount(t, s), helperFailedCount(t, s)) - } - var attempts int - var lastErr *string - _ = s.pool.QueryRow(context.Background(), - `SELECT attempts, last_error FROM scrobble_queue WHERE play_event_id = $1`, s.playEvent).Scan(&attempts, &lastErr) - if attempts != 5 { - t.Errorf("attempts = %d, want 5", attempts) - } - if lastErr == nil || *lastErr == "" { - t.Error("last_error should be populated") - } -} - -func TestWorker_401_MarksFailedAndDisablesUser(t *testing.T) { - s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) - helperEnqueue(t, s) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - })) - defer srv.Close() - lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()} - w := newTestWorker(s, lb) - _ = w.tickOnce(context.Background()) - - if helperFailedCount(t, s) != 1 { - t.Error("401 should mark failed immediately") - } - cfg, _ := s.q.GetListenBrainzConfig(context.Background(), s.user) - if cfg.ListenbrainzEnabled { - t.Error("user listenbrainz_enabled should be set to false on auth failure") - } -} - -func TestWorker_429_RespectsRetryAfterWithoutIncrementingAttempts(t *testing.T) { - s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) - helperEnqueue(t, s) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Retry-After", "300") - w.WriteHeader(http.StatusTooManyRequests) - })) - defer srv.Close() - lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()} - w := newTestWorker(s, lb) - _ = w.tickOnce(context.Background()) - - var attempts int - var nextAttemptAt time.Time - _ = s.pool.QueryRow(context.Background(), - `SELECT attempts, next_attempt_at FROM scrobble_queue WHERE play_event_id = $1`, s.playEvent). - Scan(&attempts, &nextAttemptAt) - if attempts != 0 { - t.Errorf("attempts = %d, want 0 (429 should not increment)", attempts) - } - if d := time.Until(nextAttemptAt); d < 250*time.Second || d > 350*time.Second { - t.Errorf("next_attempt_at = +%v, want ~+5m (Retry-After 300)", d) - } -} - -func TestWorker_BatchOfTen_OnePost(t *testing.T) { - s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83}) - // Enqueue 10 distinct play_events to test batching. - for i := 0; i < 10; i++ { - var newPE pgtype.UUID - if err := s.pool.QueryRow(context.Background(), - `INSERT INTO play_events (user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped) - SELECT user_id, track_id, session_id, now(), now(), duration_played_ms, completion_ratio, was_skipped FROM play_events WHERE id = $1 - RETURNING id`, s.playEvent).Scan(&newPE); err != nil { - t.Fatalf("dup play_event: %v", err) - } - if _, err := s.pool.Exec(context.Background(), - `INSERT INTO scrobble_queue (user_id, play_event_id, status) VALUES ($1, $2, 'pending')`, - s.user, newPE); err != nil { - t.Fatalf("enqueue: %v", err) - } - } - var posts atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - posts.Add(1) - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()} - w := newTestWorker(s, lb) - _ = w.tickOnce(context.Background()) - - if posts.Load() != 1 { - t.Errorf("LB POSTs = %d, want 1 (batch)", posts.Load()) - } -} -``` - -Note: this test file imports `io`, `log/slog`, `pgtype` — make sure to add those imports at the top of the file. The exact import block: - -```go -import ( - "context" - "errors" - "io" - "log/slog" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" -) -``` - -(The `errors` and `strings` imports may be unused — remove if Go compiler complains.) - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/scrobble/ -run Worker -v` - -Expected: FAIL with "undefined: NewWorker" and "undefined: tickOnce". - -- [ ] **Step 3: Implement Worker, NewWorker, tickOnce, Run** - -Replace `internal/scrobble/worker.go` contents: - -```go -package scrobble - -import ( - "context" - "errors" - "log/slog" - "time" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" -) - -// backoffSchedule maps the failure count (1-indexed) to the delay before -// the next attempt. Per spec §9.6: 1m → 5m → 30m → 2h → 6h, then give up. -var backoffSchedule = []time.Duration{ - 1 * time.Minute, - 5 * time.Minute, - 30 * time.Minute, - 2 * time.Hour, - 6 * time.Hour, -} - -const maxAttempts = 5 - -// backoffDelay returns the delay before the next attempt given the value -// of the `attempts` column AFTER the failure has been recorded (1-indexed). -func backoffDelay(attempts int) (time.Duration, bool) { - if attempts < 1 || attempts > maxAttempts { - return 0, false - } - return backoffSchedule[attempts-1], true -} - -// Worker drains scrobble_queue rows and POSTs them to ListenBrainz. -type Worker struct { - pool *pgxpool.Pool - client *listenbrainz.Client - logger *slog.Logger - tick time.Duration - batch int32 -} - -// NewWorker constructs a worker with production defaults: 30s tick, 50-row -// batch. -func NewWorker(pool *pgxpool.Pool, client *listenbrainz.Client, logger *slog.Logger) *Worker { - return &Worker{pool: pool, client: client, logger: logger, tick: 30 * time.Second, batch: 50} -} - -// Run blocks until ctx is cancelled, ticking every w.tick. -func (w *Worker) Run(ctx context.Context) { - t := time.NewTicker(w.tick) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - if err := w.tickOnce(ctx); err != nil { - w.logger.Error("scrobble: tick failed", "err", err) - } - } - } -} - -// tickOnce drains up to w.batch pending rows. Returns the first error -// encountered loading rows; per-row errors are logged and don't abort -// the batch. -func (w *Worker) tickOnce(ctx context.Context) error { - q := dbq.New(w.pool) - rows, err := q.ListPendingScrobbles(ctx, w.batch) - if err != nil { - return err - } - if len(rows) == 0 { - return nil - } - - // Group rows by user (each user has their own token + may produce its - // own batch). - type userBatch struct { - token string - queueIDs []pgtype.UUID - listens []listenbrainz.Listen - userID pgtype.UUID - playIDs []pgtype.UUID - } - batches := map[string]*userBatch{} - for _, r := range rows { - if r.LbToken == nil || *r.LbToken == "" || !r.LbEnabled { - // Defensive: row got into queue but user since disabled. Skip. - continue - } - key := r.UserID.String() - ub := batches[key] - if ub == nil { - ub = &userBatch{token: *r.LbToken, userID: r.UserID} - batches[key] = ub - } - ub.queueIDs = append(ub.queueIDs, r.QueueID) - ub.playIDs = append(ub.playIDs, r.PlayEventID) - l := listenbrainz.Listen{ - ListenedAt: r.StartedAt.Time.Unix(), - Track: listenbrainz.Track{ - ArtistName: r.ArtistName, - TrackName: r.TrackTitle, - ReleaseName: r.AlbumTitle, - DurationMs: int(r.TrackDurationMs), - }, - } - if r.TrackMbid != nil { - l.Track.RecordingMBID = *r.TrackMbid - } - if r.AlbumMbid != nil { - l.Track.ReleaseMBID = *r.AlbumMbid - } - if r.ArtistMbid != nil { - l.Track.ArtistMBIDs = []string{*r.ArtistMbid} - } - ub.listens = append(ub.listens, l) - } - - for _, ub := range batches { - w.processBatch(ctx, q, ub.userID, ub.token, ub.queueIDs, ub.listens) - } - return nil -} - -// processBatch submits one user's pending batch and updates queue rows -// according to the response. -func (w *Worker) processBatch( - ctx context.Context, - q *dbq.Queries, - userID pgtype.UUID, - token string, - queueIDs []pgtype.UUID, - listens []listenbrainz.Listen, -) { - err := w.client.SubmitListens(ctx, token, listens) - now := time.Now().UTC() - - if err == nil { - for _, id := range queueIDs { - if uerr := q.MarkScrobbleSent(ctx, id); uerr != nil { - w.logger.Error("scrobble: MarkScrobbleSent", "queue_id", id, "err", uerr) - } - } - return - } - - // 429: schedule retry per Retry-After, do NOT increment attempts. - var ra *listenbrainz.RetryAfterError - if errors.As(err, &ra) { - next := pgtype.Timestamptz{Time: now.Add(ra.Wait), Valid: true} - for _, id := range queueIDs { - if uerr := q.MarkScrobbleRetryAfter(ctx, dbq.MarkScrobbleRetryAfterParams{ - ID: id, NextAttemptAt: next, - }); uerr != nil { - w.logger.Error("scrobble: MarkScrobbleRetryAfter", "err", uerr) - } - } - return - } - - // 401: mark failed AND disable user (defensive — bad token shouldn't keep - // retrying or feed future rows). - if errors.Is(err, listenbrainz.ErrAuth) { - w.logger.Warn("scrobble: auth rejected; disabling user listenbrainz", "user_id", userID) - if uerr := q.SetListenBrainzEnabled(ctx, dbq.SetListenBrainzEnabledParams{ - ID: userID, ListenbrainzEnabled: false, - }); uerr != nil { - w.logger.Error("scrobble: SetListenBrainzEnabled", "err", uerr) - } - errStr := err.Error() - for _, id := range queueIDs { - if uerr := q.MarkScrobbleFailed(ctx, dbq.MarkScrobbleFailedParams{ - ID: id, LastError: &errStr, - }); uerr != nil { - w.logger.Error("scrobble: MarkScrobbleFailed", "err", uerr) - } - } - return - } - - // 4xx: permanent. Mark failed, no retry. - if errors.Is(err, listenbrainz.ErrPermanent) { - errStr := err.Error() - for _, id := range queueIDs { - if uerr := q.MarkScrobbleFailed(ctx, dbq.MarkScrobbleFailedParams{ - ID: id, LastError: &errStr, - }); uerr != nil { - w.logger.Error("scrobble: MarkScrobbleFailed", "err", uerr) - } - } - return - } - - // Transient (5xx, network): increment attempts, schedule next or give up. - // Each row in the batch shares the same fate since they were submitted - // together — we update each independently using its current attempts+1. - errStr := err.Error() - for _, id := range queueIDs { - // Read current attempts to compute next state. - var attempts int32 - if rerr := w.pool.QueryRow(ctx, - `SELECT attempts FROM scrobble_queue WHERE id = $1`, id).Scan(&attempts); rerr != nil { - w.logger.Error("scrobble: read attempts", "err", rerr) - continue - } - next := attempts + 1 - if delay, ok := backoffDelay(int(next)); ok { - nextAt := pgtype.Timestamptz{Time: now.Add(delay), Valid: true} - if uerr := q.RescheduleScrobble(ctx, dbq.RescheduleScrobbleParams{ - ID: id, NextAttemptAt: nextAt, LastError: &errStr, - }); uerr != nil { - w.logger.Error("scrobble: RescheduleScrobble", "err", uerr) - } - } else { - if uerr := q.MarkScrobbleFailed(ctx, dbq.MarkScrobbleFailedParams{ - ID: id, LastError: &errStr, - }); uerr != nil { - w.logger.Error("scrobble: MarkScrobbleFailed", "err", uerr) - } - } - } -} -``` - -- [ ] **Step 4: Run all scrobble tests** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/scrobble/... -v` - -Expected: PASS for all worker tests + previously-passing tests in `listenbrainz/`, `threshold_test.go`, `queue_test.go`, `worker_test.go`. - -- [ ] **Step 5: Commit** - -```bash -git add internal/scrobble/worker.go internal/scrobble/worker_integration_test.go -git commit -m "feat(scrobble): add Worker with tickOnce, batched submit, retry/fail/auth handling" -``` - ---- - -## Task 8: Hook MaybeEnqueue into playevents.Writer - -**Files:** -- Modify: `internal/playevents/writer.go` -- Modify: `internal/playevents/writer_test.go` (or a sibling file, depending on existing structure) - -- [ ] **Step 1: Write the failing test** - -Append to `internal/playevents/writer_test.go` (or create one if missing): - -```go -func TestRecordPlayEnded_QualifyingPlay_EnqueuesScrobble(t *testing.T) { - if testing.Short() { - t.Skip("integration test") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - pool, q := newTestPool(t, dsn) // assume helper from existing writer_test.go - defer pool.Close() - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - w := NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) - - user, track := seedUserAndTrack(t, q, "tester", 300_000) // helper, set duration=300s - - // Enable LB. - tk := "tk" - if err := q.SetListenBrainzToken(context.Background(), dbq.SetListenBrainzTokenParams{ID: user.ID, ListenbrainzToken: &tk}); err != nil { - t.Fatalf("token: %v", err) - } - if err := q.SetListenBrainzEnabled(context.Background(), dbq.SetListenBrainzEnabledParams{ID: user.ID, ListenbrainzEnabled: true}); err != nil { - t.Fatalf("enable: %v", err) - } - - // Start + end with 250s played (qualifies). - res, err := w.RecordPlayStarted(context.Background(), user.ID, track.ID, "test", time.Now().Add(-1*time.Minute)) - if err != nil { - t.Fatalf("start: %v", err) - } - if err := w.RecordPlayEnded(context.Background(), res.PlayEventID, 250_000, time.Now()); err != nil { - t.Fatalf("end: %v", err) - } - - // Allow the post-commit goroutine (if any) to settle. Currently MaybeEnqueue - // is called synchronously after BeginFunc returns, so a sleep isn't needed — - // but if we ever switch to a goroutine, this gives it time. - time.Sleep(50 * time.Millisecond) - - var n int - if err := pool.QueryRow(context.Background(), - `SELECT count(*) FROM scrobble_queue WHERE play_event_id = $1`, res.PlayEventID).Scan(&n); err != nil { - t.Fatalf("count: %v", err) - } - if n != 1 { - t.Errorf("scrobble_queue rows = %d, want 1", n) - } -} - -func TestRecordPlayEnded_SubThreshold_NoEnqueue(t *testing.T) { - if testing.Short() { - t.Skip("integration test") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - pool, q := newTestPool(t, dsn) - defer pool.Close() - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - w := NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) - user, track := seedUserAndTrack(t, q, "tester", 300_000) - - tk := "tk" - _ = q.SetListenBrainzToken(context.Background(), dbq.SetListenBrainzTokenParams{ID: user.ID, ListenbrainzToken: &tk}) - _ = q.SetListenBrainzEnabled(context.Background(), dbq.SetListenBrainzEnabledParams{ID: user.ID, ListenbrainzEnabled: true}) - - res, _ := w.RecordPlayStarted(context.Background(), user.ID, track.ID, "test", time.Now().Add(-1*time.Minute)) - _ = w.RecordPlayEnded(context.Background(), res.PlayEventID, 60_000, time.Now()) // 20%, well below threshold - - var n int - _ = pool.QueryRow(context.Background(), - `SELECT count(*) FROM scrobble_queue WHERE play_event_id = $1`, res.PlayEventID).Scan(&n) - if n != 0 { - t.Errorf("expected 0 queue rows for sub-threshold play, got %d", n) - } -} -``` - -If `seedUserAndTrack` and `newTestPool` aren't already in `writer_test.go`, look for the existing fixture functions (the existing tests must use something equivalent) and reuse those. If they're truly absent, write minimal versions following the pattern from `internal/scrobble/queue_test.go::seed`. - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/playevents/ -run RecordPlayEnded.Scrobble -v` - -Expected: tests fail because no enqueue hook exists yet. - -- [ ] **Step 3: Add the post-commit enqueue hook** - -In `internal/playevents/writer.go`, modify `RecordPlayEnded` to add the post-commit `MaybeEnqueue` call: - -```go -import ( - // existing imports … - "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble" -) - -func (w *Writer) RecordPlayEnded( - ctx context.Context, - playEventID pgtype.UUID, - durationPlayedMs int32, - at time.Time, -) error { - if err := pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error { - // … existing close logic … - }); err != nil { - return err - } - - // Post-commit best-effort scrobble enqueue. Errors logged + swallowed: - // scrobble delivery is enrichment, not a precondition for canonical play - // history. - if err := scrobble.MaybeEnqueue(ctx, dbq.New(w.pool), playEventID); err != nil { - w.logger.Warn("playevents: scrobble enqueue failed", "play_event_id", playEventID, "err", err) - } - return nil -} -``` - -Apply the **same pattern** to `RecordSyntheticCompletedPlay`: keep the existing `pgx.BeginFunc`, capture the inserted play_event ID, and after the commit call `scrobble.MaybeEnqueue(ctx, dbq.New(w.pool), evID)`. The existing function already creates and ends the play_event in one transaction; you'll need to surface the ID up out of the closure into a local variable before the post-commit call. - -The exact diff for `RecordSyntheticCompletedPlay`: - -```go -func (w *Writer) RecordSyntheticCompletedPlay( - ctx context.Context, - userID, trackID pgtype.UUID, - clientID string, - at time.Time, -) error { - var playEventID pgtype.UUID - if err := pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error { - // … existing logic, but assign ev.ID to playEventID before returning … - // e.g. after `ev, err := q.InsertPlayEvent(...)` succeeds: - // playEventID = ev.ID - }); err != nil { - return err - } - if err := scrobble.MaybeEnqueue(ctx, dbq.New(w.pool), playEventID); err != nil { - w.logger.Warn("playevents: scrobble enqueue failed", "play_event_id", playEventID, "err", err) - } - return nil -} -``` - -(Inspect the current `RecordSyntheticCompletedPlay` body and add `playEventID = ev.ID` immediately after `q.InsertPlayEvent` returns. The function ends the play_event in the same transaction; a single playEventID variable captured pre-commit is correct.) - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/playevents/ -v` - -Expected: PASS for all existing tests + 2 new scrobble-enqueue tests. - -- [ ] **Step 5: Verify Subsonic scrobble path also passes** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/subsonic/ -v` - -Expected: PASS. Pre-existing scrobble integration tests still pass; the post-commit enqueue is a no-op for users without LB enabled. - -- [ ] **Step 6: Commit** - -```bash -git add internal/playevents/writer.go internal/playevents/writer_test.go -git commit -m "feat(playevents): post-commit MaybeEnqueue in RecordPlayEnded + Synthetic" -``` - ---- - -## Task 9: API endpoints — GET/PUT /api/me/listenbrainz - -**Files:** -- Create: `internal/api/listenbrainz.go` -- Create: `internal/api/listenbrainz_test.go` -- Modify: `internal/api/api.go::Mount` - -- [ ] **Step 1: Write the failing tests** - -Create `internal/api/listenbrainz_test.go`: - -```go -package api - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -type lbStatusJSON struct { - Enabled bool `json:"enabled"` - TokenSet bool `json:"token_set"` - LastScrobbledAt *string `json:"last_scrobbled_at"` -} - -func callGetLB(h *handlers, user dbq.User) *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodGet, "/api/me/listenbrainz", nil) - req = req.WithContext(auth.WithUser(req.Context(), user)) - w := httptest.NewRecorder() - h.handleGetListenBrainz(w, req) - return w -} - -func callPutLB(h *handlers, user dbq.User, body any) *httptest.ResponseRecorder { - buf, _ := json.Marshal(body) - req := httptest.NewRequest(http.MethodPut, "/api/me/listenbrainz", bytes.NewReader(buf)) - req.Header.Set("Content-Type", "application/json") - req = req.WithContext(auth.WithUser(req.Context(), user)) - w := httptest.NewRecorder() - h.handlePutListenBrainz(w, req) - return w -} - -func TestHandleGetListenBrainz_NoTokenInitial(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - u := seedUser(t, pool, "alice", "x", false) - w := callGetLB(h, u) - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var resp lbStatusJSON - _ = json.Unmarshal(w.Body.Bytes(), &resp) - if resp.Enabled || resp.TokenSet || resp.LastScrobbledAt != nil { - t.Errorf("initial state wrong: %+v", resp) - } -} - -func TestHandlePutListenBrainz_SetTokenThenGet(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - u := seedUser(t, pool, "alice", "x", false) - - put := callPutLB(h, u, map[string]any{"token": "abc"}) - if put.Code != http.StatusOK { - t.Fatalf("PUT status = %d body = %s", put.Code, put.Body.String()) - } - - get := callGetLB(h, u) - var resp lbStatusJSON - _ = json.Unmarshal(get.Body.Bytes(), &resp) - if !resp.TokenSet { - t.Error("TokenSet = false after PUT, want true") - } -} - -func TestHandlePutListenBrainz_ClearTokenForcesEnabledFalse(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - u := seedUser(t, pool, "alice", "x", false) - - _ = callPutLB(h, u, map[string]any{"token": "abc"}) - _ = callPutLB(h, u, map[string]any{"enabled": true}) - clear := callPutLB(h, u, map[string]any{"token": ""}) - if clear.Code != http.StatusOK { - t.Fatalf("clear status = %d", clear.Code) - } - - get := callGetLB(h, u) - var resp lbStatusJSON - _ = json.Unmarshal(get.Body.Bytes(), &resp) - if resp.TokenSet { - t.Error("TokenSet = true after clearing") - } - if resp.Enabled { - t.Error("Enabled = true after clearing token, want false (force-cleared)") - } -} - -func TestHandlePutListenBrainz_EnableWithoutToken_400(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - u := seedUser(t, pool, "alice", "x", false) - resp := callPutLB(h, u, map[string]any{"enabled": true}) - if resp.Code != http.StatusBadRequest { - t.Errorf("status = %d, want 400 (cannot enable without token)", resp.Code) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/api/ -run ListenBrainz -v` - -Expected: FAIL — handlers undefined. - -- [ ] **Step 3: Implement the handlers** - -Create `internal/api/listenbrainz.go`: - -```go -package api - -import ( - "encoding/json" - "errors" - "net/http" - - "github.com/jackc/pgx/v5" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// LBStatus is the GET response body and a subset of the PUT response. -type LBStatus struct { - Enabled bool `json:"enabled"` - TokenSet bool `json:"token_set"` - LastScrobbledAt *string `json:"last_scrobbled_at"` -} - -func (h *handlers) handleGetListenBrainz(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - resp, err := buildLBStatus(r, h, user.ID) - if err != nil { - h.logger.Error("api: get listenbrainz", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - writeJSON(w, http.StatusOK, resp) -} - -type putLBBody struct { - Token *string `json:"token,omitempty"` - Enabled *bool `json:"enabled,omitempty"` -} - -func (h *handlers) handlePutListenBrainz(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - var body putLBBody - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid json") - return - } - - q := dbq.New(h.pool) - if body.Token != nil { - t := *body.Token - var tokenPtr *string - if t != "" { - tokenPtr = &t - } // empty string clears via SetListenBrainzToken's COALESCE handling - if err := q.SetListenBrainzToken(r.Context(), dbq.SetListenBrainzTokenParams{ - ID: user.ID, ListenbrainzToken: tokenPtr, - }); err != nil { - h.logger.Error("api: set listenbrainz token", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "update failed") - return - } - } - if body.Enabled != nil && *body.Enabled { - // Enabling requires a non-empty token. - cfg, err := q.GetListenBrainzConfig(r.Context(), user.ID) - if err != nil { - h.logger.Error("api: get listenbrainz config", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - if cfg.ListenbrainzToken == nil || *cfg.ListenbrainzToken == "" { - writeErr(w, http.StatusBadRequest, "bad_request", "cannot enable without a token") - return - } - } - if body.Enabled != nil { - if err := q.SetListenBrainzEnabled(r.Context(), dbq.SetListenBrainzEnabledParams{ - ID: user.ID, ListenbrainzEnabled: *body.Enabled, - }); err != nil { - h.logger.Error("api: set listenbrainz enabled", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "update failed") - return - } - } - - resp, err := buildLBStatus(r, h, user.ID) - if err != nil { - h.logger.Error("api: put listenbrainz refresh status", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - writeJSON(w, http.StatusOK, resp) -} - -func buildLBStatus(r *http.Request, h *handlers, userID pgtypeUUID) (LBStatus, error) { - q := dbq.New(h.pool) - cfg, err := q.GetListenBrainzConfig(r.Context(), userID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return LBStatus{}, nil - } - return LBStatus{}, err - } - out := LBStatus{ - Enabled: cfg.ListenbrainzEnabled, - TokenSet: cfg.ListenbrainzToken != nil && *cfg.ListenbrainzToken != "", - } - if cfg.LastScrobbledAt.Valid { - s := cfg.LastScrobbledAt.Time.UTC().Format(time.RFC3339) - out.LastScrobbledAt = &s - } - return out, nil -} -``` - -Note: `pgtypeUUID` is shorthand here — replace with the actual `pgtype.UUID` import. Add `"github.com/jackc/pgx/v5/pgtype"` and `"time"` to the imports. Update the function signature: `func buildLBStatus(r *http.Request, h *handlers, userID pgtype.UUID) (LBStatus, error)`. - -- [ ] **Step 4: Mount the routes** - -In `internal/api/api.go::Mount`, add inside the `authed.Group(...)` block (after the existing `/me` route): - -```go -authed.Get("/me/listenbrainz", h.handleGetListenBrainz) -authed.Put("/me/listenbrainz", h.handlePutListenBrainz) -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/api/ -run ListenBrainz -v` - -Expected: PASS for all 4 tests. - -- [ ] **Step 6: Commit** - -```bash -git add internal/api/listenbrainz.go internal/api/listenbrainz_test.go internal/api/api.go -git commit -m "feat(api): add GET/PUT /api/me/listenbrainz endpoints" -``` - ---- - -## Task 10: Boot the worker in main.go - -**Files:** -- Modify: `cmd/minstrel/main.go` - -- [ ] **Step 1: Wire the worker startup** - -In `cmd/minstrel/main.go`, add the imports: - -```go -"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble" -"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" -``` - -After the `pool` is opened (after `pool, err := db.Open(...)` and the `defer pool.Close()`), and before the HTTP server starts, add: - -```go -// Start the ListenBrainz scrobble worker. Per spec §M4a, runs every 30s, -// drains up to 50 pending rows per tick. Per-user gating happens inside the -// worker (rows from disabled users are skipped). -scrobbleWorker := scrobble.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "scrobble")) -go scrobbleWorker.Run(ctx) -``` - -- [ ] **Step 2: Verify compile** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go build ./...` - -Expected: clean. - -- [ ] **Step 3: Verify the worker actually starts** - -Run the binary briefly and check the logs for the scrobble worker: - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && SMARTMUSIC_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' SMARTMUSIC_LIBRARY_SCAN_PATHS='' SMARTMUSIC_LIBRARY_SCAN_ON_STARTUP=false timeout 5 go run ./cmd/minstrel/ 2>&1 | head -20` - -Expected: server starts, no panic, worker tick logs (or absence of error logs) demonstrate Run is alive. - -- [ ] **Step 4: Commit** - -```bash -git add cmd/minstrel/main.go -git commit -m "feat(cmd): start scrobble worker alongside HTTP server" -``` - ---- - -## Task 11: Frontend — apiPut + listenbrainz API helpers + /settings page - -**Files:** -- Modify: `web/src/lib/api/client.ts` -- Create: `web/src/lib/api/listenbrainz.ts` -- Create: `web/src/routes/settings/+page.svelte` -- Create: `web/src/routes/settings/settings.test.ts` -- Modify: `web/src/lib/components/Shell.svelte` - -- [ ] **Step 1: Add `api.put` helper** - -In `web/src/lib/api/client.ts`, add a `put` method to the `api` object. The existing object looks like: - -```ts -export const api = { - get: (path: string): Promise => apiFetch(path) as Promise, - post: (path: string, body: unknown): Promise => - apiFetch(path, { method: 'POST', body: JSON.stringify(body) }) as Promise, - del: (path: string): Promise => - apiFetch(path, { method: 'DELETE' }) as Promise -}; -``` - -Replace with: - -```ts -export const api = { - get: (path: string): Promise => apiFetch(path) as Promise, - post: (path: string, body: unknown): Promise => - apiFetch(path, { method: 'POST', body: JSON.stringify(body) }) as Promise, - put: (path: string, body: unknown): Promise => - apiFetch(path, { method: 'PUT', body: JSON.stringify(body) }) as Promise, - del: (path: string): Promise => - apiFetch(path, { method: 'DELETE' }) as Promise -}; -``` - -- [ ] **Step 2: Add ListenBrainz API helpers** - -Create `web/src/lib/api/listenbrainz.ts`: - -```ts -import { api } from './client'; - -export type LBStatus = { - enabled: boolean; - token_set: boolean; - last_scrobbled_at: string | null; -}; - -export function getListenBrainzStatus(): Promise { - return api.get('/api/me/listenbrainz'); -} - -export function setListenBrainzToken(token: string): Promise { - return api.put('/api/me/listenbrainz', { token }); -} - -export function setListenBrainzEnabled(enabled: boolean): Promise { - return api.put('/api/me/listenbrainz', { enabled }); -} -``` - -- [ ] **Step 3: Create the Settings page** - -Create `web/src/routes/settings/+page.svelte`: - -```svelte - - - -``` - -- [ ] **Step 4: Add Settings to the nav** - -In `web/src/lib/components/Shell.svelte`, in the `navItems` array: - -```ts -const navItems = [ - { href: '/', label: 'Library' }, - { href: '/library/liked', label: 'Liked' }, - { href: '/search', label: 'Search' }, - { href: '/playlists', label: 'Playlists' }, - { href: '/settings', label: 'Settings' } -]; -``` - -- [ ] **Step 5: Write the failing settings tests** - -Create `web/src/routes/settings/settings.test.ts`: - -```ts -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; -import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query'; -import { createRawSnippet } from 'svelte'; - -vi.mock('$lib/api/listenbrainz', () => ({ - getListenBrainzStatus: vi.fn(), - setListenBrainzToken: vi.fn(), - setListenBrainzEnabled: vi.fn() -})); - -import SettingsPage from './+page.svelte'; -import { - getListenBrainzStatus, - setListenBrainzToken, - setListenBrainzEnabled -} from '$lib/api/listenbrainz'; - -function renderPage() { - const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render(QueryClientProvider, { - props: { - client: qc, - children: createRawSnippet(() => ({ render: () => '
' })) - } - }); - // NOTE: The above is a sketch; actual integration into your QueryClientProvider - // wrapping should match the pattern used in /library/liked tests. See - // web/src/routes/library/liked/liked.test.ts for the canonical example. -} -``` - -In practice, follow the pattern used by `web/src/routes/library/liked/liked.test.ts` (which already wraps a route with `QueryClientProvider`) — copy that boilerplate. Replace the test cases with the SearchBox tests below: - -```ts -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe('Settings page — ListenBrainz', () => { - test('token-not-set state renders input + Save button', async () => { - (getListenBrainzStatus as any).mockResolvedValue({ - enabled: false, token_set: false, last_scrobbled_at: null - }); - renderPage(); - await waitFor(() => expect(screen.getByPlaceholderText(/paste your lb token/i)).toBeInTheDocument()); - expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); - }); - - test('token-set state renders masked + Clear button', async () => { - (getListenBrainzStatus as any).mockResolvedValue({ - enabled: false, token_set: true, last_scrobbled_at: null - }); - renderPage(); - await waitFor(() => expect(screen.getByText(/\(set\)/)).toBeInTheDocument()); - expect(screen.getByRole('button', { name: /clear/i })).toBeInTheDocument(); - }); - - test('Save button calls setListenBrainzToken with typed value', async () => { - (getListenBrainzStatus as any).mockResolvedValue({ - enabled: false, token_set: false, last_scrobbled_at: null - }); - (setListenBrainzToken as any).mockResolvedValue({ enabled: false, token_set: true, last_scrobbled_at: null }); - renderPage(); - const input = await screen.findByPlaceholderText(/paste your lb token/i); - await fireEvent.input(input, { target: { value: 'mytoken' } }); - await fireEvent.click(screen.getByRole('button', { name: /save/i })); - await waitFor(() => expect(setListenBrainzToken).toHaveBeenCalledWith('mytoken')); - }); - - test('Enabled checkbox is disabled when no token', async () => { - (getListenBrainzStatus as any).mockResolvedValue({ - enabled: false, token_set: false, last_scrobbled_at: null - }); - renderPage(); - const checkbox = await screen.findByRole('checkbox'); - expect(checkbox).toBeDisabled(); - }); - - test('Last-scrobbled-at renders when present', async () => { - (getListenBrainzStatus as any).mockResolvedValue({ - enabled: true, - token_set: true, - last_scrobbled_at: '2026-04-28T03:00:00Z' - }); - renderPage(); - await waitFor(() => expect(screen.getByText(/last scrobbled:/i)).toBeInTheDocument()); - }); -}); -``` - -- [ ] **Step 6: Run web tests + check + build** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run check 2>&1 | tail -3 && npm test -- --run src/routes/settings 2>&1 | tail -10` - -Expected: type-check clean; 5 settings tests pass. - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm test -- --run 2>&1 | tail -5` - -Expected: full vitest suite passes (175 + 5 new = 180). - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run build 2>&1 | tail -5` - -Expected: adapter-static emits `web/build/`. - -- [ ] **Step 7: Commit** - -```bash -git add web/ -git commit -m "feat(web): add /settings page with ListenBrainz section" -``` - ---- - -## Task 12: Final verification + branch finish - -**Files:** none (verification only) - -- [ ] **Step 1: Full Go test suite (-short -race)** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test -short -race ./...` - -Expected: PASS across all 14 packages (now including `internal/scrobble` and `internal/scrobble/listenbrainz`). - -- [ ] **Step 2: Full integration suite** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -race -p 1 ./...` - -Expected: PASS apart from the pre-existing `TestScanner_Integration` flake (CI runs `-short` which skips it; not introduced by this PR). - -- [ ] **Step 3: Lint** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./...` - -Expected: clean. - -- [ ] **Step 4: Coverage check on new package** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -p 1 -coverprofile=/tmp/cover-m4a.out ./internal/scrobble/... && go tool cover -func=/tmp/cover-m4a.out | tail -5` - -Expected: `internal/scrobble` and `internal/scrobble/listenbrainz` combined coverage ≥ 80%. - -- [ ] **Step 5: Web verification** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run check && npm test -- --run && npm run build` - -Expected: svelte-check 0/0; 180 vitest tests pass (was 175); build succeeds. - -- [ ] **Step 6: Docker smoke** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && docker build -t minstrel:m4a-smoke .` - -Expected: container builds. - -- [ ] **Step 7: Manual end-to-end verification** - -Set up: -1. `docker compose up --build -d minstrel` — pick up new code -2. Generate a token at https://listenbrainz.org/profile/ -3. /settings → paste token → Save -4. /settings → toggle "Send my plays to ListenBrainz" -5. Play a track for ≥240 seconds OR finish it -6. Within 30 seconds, check listenbrainz.org/ — listen should appear -7. /settings → "Last scrobbled" timestamp populates - -Sad path: -8. /settings → enter a deliberately bad token → toggle enabled → play a track -9. Within 30s, check `psql -c "SELECT status, last_error FROM scrobble_queue"` — row marked `failed`, error mentions auth -10. /settings refresh → enabled toggle has been auto-cleared - -- [ ] **Step 8: Update Fable task #345** - -After PR opens, mark `in_progress`. After PR merges, mark `done` with summary of what shipped. - -- [ ] **Step 9: Finishing the branch** - -**REQUIRED SUB-SKILL:** Use `superpowers:finishing-a-development-branch` to verify tests, present completion options (merge locally / push + PR / keep / discard), and execute the user's choice. - -Per established cadence: this slice will land as a single-purpose PR. After merge, M4a closes; M4b (similarity ingest) is next. - ---- - -## Self-Review - -**Spec coverage:** -- §3.1 (new packages) → Tasks 3, 4, 5, 6, 7 ✓ -- §3.2 (existing-code hooks) → Task 8 (Writer hooks), Task 9 (api.go Mount), Task 10 (main.go) ✓ -- §4 (database schema) → Tasks 1, 2 ✓ -- §5.1 (LB client) → Task 3 ✓ -- §5.2 (threshold) → Task 4 ✓ -- §5.3 (MaybeEnqueue) → Task 5 ✓ -- §5.4 (Worker, Run, tickOnce, backoffDelay) → Tasks 6, 7 ✓ -- §5.5 (API endpoints) → Task 9 ✓ -- §6 (frontend) → Task 11 ✓ -- §7.1-7.3 (test plan) → embedded across Tasks 3-11 ✓ -- §7.4 (coverage target) → Task 12 step 4 ✓ -- §7.5 (manual verification) → Task 12 step 7 ✓ -- §8 (backwards compat) → preserved by zero-default user columns + new package not affecting existing code ✓ - -No gaps. - -**Placeholder scan:** No "TBD"/"TODO" content. All steps have explicit code or commands. - -**Type consistency:** -- `LBStatus` shape (Go) matches `LBStatus` shape (TypeScript) — `enabled`, `token_set`, `last_scrobbled_at`. -- `MaybeEnqueue(ctx, q, playEventID)` signature consistent across Tasks 5 and 8. -- `backoffDelay(attempts int) (time.Duration, bool)` — same in Task 6 and Task 7. -- `Worker.tickOnce(ctx) error` — same in Task 7 and Task 12. -- sqlc query names consistent across Task 2 (definitions) and Tasks 5/7/9 (callers). diff --git a/docs/superpowers/plans/2026-04-28-m4b-similarity.md b/docs/superpowers/plans/2026-04-28-m4b-similarity.md deleted file mode 100644 index d39dbc5e..00000000 --- a/docs/superpowers/plans/2026-04-28-m4b-similarity.md +++ /dev/null @@ -1,1551 +0,0 @@ -# M4b — ListenBrainz Inbound Similarity Ingest Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Background worker pulls track-track and artist-artist similarity edges from ListenBrainz's public `/explore/*` endpoints, filters to the local library (top-K=20 per source), stores in two new `track_similarity` + `artist_similarity` tables. Hourly tick, 7-day re-fetch cap per row, passive retry via timer. - -**Architecture:** New `internal/similarity/` package with a `Worker` goroutine paralleling M4a's scrobble worker. Existing `internal/scrobble/listenbrainz/Client` gets two new methods (`SimilarRecordings`, `SimilarArtists`). New migration adds two symmetric tables with multi-source primary keys + score-DESC indexes for M4c's hot-path query. No frontend changes. - -**Tech Stack:** Go 1.23 + sqlc + pgx/v5. No web changes. Runs alongside the M4a scrobble worker. - -**Reference:** design spec at `docs/superpowers/specs/2026-04-28-m4b-similarity-design.md`. - ---- - -## File Structure - -**New server files:** - -| File | Responsibility | -|---|---| -| `internal/db/migrations/0009_similarity.up.sql` + `.down.sql` | `track_similarity` + `artist_similarity` tables (multi-source PK, score-DESC indexes). | -| `internal/db/queries/similarity.sql` | New: `ListPlayedTracksNeedingSimilarity`, `ListPlayedArtistsNeedingSimilarity`, `GetTracksByMBIDs`, `GetArtistsByMBIDs`, `UpsertTrackSimilarity`, `UpsertArtistSimilarity`. | -| `internal/db/dbq/similarity.sql.go` | Generated bindings. | -| `internal/similarity/worker.go` | `Worker` struct, `NewWorker`, `Run` (hourly ticker), `tickOnce` (drains one batch of tracks + one batch of artists). | -| `internal/similarity/worker_test.go` | Pure unit tests (no DB). | -| `internal/similarity/worker_integration_test.go` | Live-DB + httptest LB end-to-end tests. | - -**Modified server files:** - -| File | Change | -|---|---| -| `internal/scrobble/listenbrainz/client.go` | Add `SimilarRecording` + `SimilarArtist` types, `SimilarRecordings(ctx, mbid, limit)` + `SimilarArtists(ctx, mbid, limit)` methods. Re-uses the existing `Client` struct, error types, and HTTP timeout pattern from M4a. | -| `internal/scrobble/listenbrainz/client_test.go` | 14 new httptest-driven tests (7 per method) covering success path, error mappings, query-param assertions. | -| `cmd/minstrel/main.go` | Construct `similarity.NewWorker(...)` after the M4a scrobble worker; `go similarityWorker.Run(ctx)`. | - -**No frontend changes.** M4b is invisible until M4c reads the data. - ---- - -## Task 1: Migration 0009 — schema - -**Files:** -- Create: `internal/db/migrations/0009_similarity.up.sql` -- Create: `internal/db/migrations/0009_similarity.down.sql` - -- [ ] **Step 1: Write the up migration** - -Create `internal/db/migrations/0009_similarity.up.sql`: - -```sql --- M4b: ListenBrainz similarity ingest. Two symmetric tables — tracks and --- artists — both keyed by (a_id, b_id, source) so the schema reserves room --- for additional similarity sources (musicbrainz_tag, user_cooccurrence) --- alongside listenbrainz. M4b only writes 'listenbrainz' rows. --- --- The (a_id, score DESC) index satisfies M4c's hot path: "for seed S, give --- me top-N similar tracks/artists descending by score." - -CREATE TABLE track_similarity ( - track_a_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, - track_b_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, - score DOUBLE PRECISION NOT NULL, - source TEXT NOT NULL CHECK (source IN ('listenbrainz', 'musicbrainz_tag', 'user_cooccurrence')), - fetched_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (track_a_id, track_b_id, source), - CHECK (track_a_id <> track_b_id) -); - -CREATE INDEX track_similarity_a_score_idx - ON track_similarity (track_a_id, score DESC); - -CREATE TABLE artist_similarity ( - artist_a_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE, - artist_b_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE, - score DOUBLE PRECISION NOT NULL, - source TEXT NOT NULL CHECK (source IN ('listenbrainz', 'musicbrainz_tag', 'user_cooccurrence')), - fetched_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (artist_a_id, artist_b_id, source), - CHECK (artist_a_id <> artist_b_id) -); - -CREATE INDEX artist_similarity_a_score_idx - ON artist_similarity (artist_a_id, score DESC); -``` - -- [ ] **Step 2: Write the down migration** - -Create `internal/db/migrations/0009_similarity.down.sql`: - -```sql -DROP TABLE IF EXISTS artist_similarity; -DROP TABLE IF EXISTS track_similarity; -``` - -- [ ] **Step 3: Verify the migration applies cleanly** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -race ./internal/db/` - -Expected: PASS. The migration test should now find version 9. - -- [ ] **Step 4: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add internal/db/migrations/0009_similarity.up.sql internal/db/migrations/0009_similarity.down.sql -git commit -m "feat(db): add migration 0009 for similarity (track_similarity + artist_similarity)" -``` - ---- - -## Task 2: sqlc queries - -**Files:** -- Create: `internal/db/queries/similarity.sql` -- Generated: `internal/db/dbq/similarity.sql.go` - -- [ ] **Step 1: Create the queries file** - -Create `internal/db/queries/similarity.sql`: - -```sql --- name: ListPlayedTracksNeedingSimilarity :many --- Tracks with at least one play, an MBID, and no fresh listenbrainz row --- (no row at all, OR fetched_at older than 7 days). Used by the worker --- to find work each tick. Bounded by $1. -SELECT DISTINCT t.id, t.mbid -FROM tracks t -JOIN play_events pe ON pe.track_id = t.id -WHERE t.mbid IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM track_similarity ts - WHERE ts.track_a_id = t.id - AND ts.source = 'listenbrainz' - AND ts.fetched_at > now() - interval '7 days' - ) -ORDER BY t.id -LIMIT $1; - --- name: ListPlayedArtistsNeedingSimilarity :many -SELECT DISTINCT ar.id, ar.mbid -FROM artists ar -JOIN tracks t ON t.artist_id = ar.id -JOIN play_events pe ON pe.track_id = t.id -WHERE ar.mbid IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM artist_similarity asim - WHERE asim.artist_a_id = ar.id - AND asim.source = 'listenbrainz' - AND asim.fetched_at > now() - interval '7 days' - ) -ORDER BY ar.id -LIMIT $1; - --- name: GetTracksByMBIDs :many --- Bulk in-library lookup: maps a slice of MBIDs back to local track IDs. --- Used by the worker to filter LB-returned MBIDs to those actually in the --- user's library. -SELECT id, mbid FROM tracks WHERE mbid = ANY($1::text[]); - --- name: GetArtistsByMBIDs :many -SELECT id, mbid FROM artists WHERE mbid = ANY($1::text[]); - --- name: UpsertTrackSimilarity :exec --- Refresh-in-place: if (a, b, source) already exists, update score and --- fetched_at. Idempotent. -INSERT INTO track_similarity (track_a_id, track_b_id, score, source, fetched_at) -VALUES ($1, $2, $3, 'listenbrainz', now()) -ON CONFLICT (track_a_id, track_b_id, source) -DO UPDATE SET score = EXCLUDED.score, fetched_at = EXCLUDED.fetched_at; - --- name: UpsertArtistSimilarity :exec -INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at) -VALUES ($1, $2, $3, 'listenbrainz', now()) -ON CONFLICT (artist_a_id, artist_b_id, source) -DO UPDATE SET score = EXCLUDED.score, fetched_at = EXCLUDED.fetched_at; -``` - -- [ ] **Step 2: Run sqlc generate** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && make generate` - -Expected: dockerized sqlc 1.27.0 regenerates `internal/db/dbq/similarity.sql.go` with the 6 new methods. Other `*.sql.go` files may receive trivial version-comment updates from sqlc — stage them all. - -- [ ] **Step 3: Verify compile** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go build ./...` - -Expected: clean. No callers yet — generated bindings just need to compile. - -- [ ] **Step 4: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add internal/db/queries/similarity.sql internal/db/dbq/ -git commit -m "feat(db): add similarity sqlc queries (list-needing, get-by-mbids, upsert)" -``` - ---- - -## Task 3: LB client `SimilarRecordings` method - -**Files:** -- Modify: `internal/scrobble/listenbrainz/client.go` (append types + method) -- Modify: `internal/scrobble/listenbrainz/client_test.go` (append tests) - -The existing `Client` already has `SubmitListens` from M4a with `BaseURL`, `HTTP`, and the typed errors `ErrAuth`/`ErrPermanent`/`ErrTransient`/`*RetryAfterError`. We add a sibling read-only method. - -LB algorithm parameter: hardcode `session_based_days_7500_session_30_contribution_5_threshold_15_limit_100_filter_True_skip_30` — this is the documented default at the time of writing. If LB changes its default at implementation time, swap to whatever's current per their `/explore/similar-recordings/` docs. - -- [ ] **Step 1: Write the failing tests** - -Append to `internal/scrobble/listenbrainz/client_test.go`: - -```go -func TestClient_SimilarRecordings_Success(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - // Body shape from LB's API docs (simplified for tests). - _, _ = w.Write([]byte(`[ - {"recording_mbid": "11111111-1111-1111-1111-111111111111", "score": 0.95}, - {"recording_mbid": "22222222-2222-2222-2222-222222222222", "score": 0.80} - ]`)) - }) - defer srv.Close() - out, err := c.SimilarRecordings(context.Background(), "aaaa", 100) - if err != nil { - t.Fatalf("err: %v", err) - } - if len(out) != 2 { - t.Fatalf("got %d results, want 2", len(out)) - } - if out[0].MBID != "11111111-1111-1111-1111-111111111111" || out[0].Score != 0.95 { - t.Errorf("first result wrong: %+v", out[0]) - } -} - -func TestClient_SimilarRecordings_AlgorithmParamSet(t *testing.T) { - var seenURL string - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - seenURL = r.URL.String() - _, _ = w.Write([]byte(`[]`)) - }) - defer srv.Close() - _, _ = c.SimilarRecordings(context.Background(), "abc", 50) - if !strings.Contains(seenURL, "algorithm=") { - t.Errorf("URL missing algorithm param: %q", seenURL) - } -} - -func TestClient_SimilarRecordings_LimitParamSet(t *testing.T) { - var seenURL string - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - seenURL = r.URL.String() - _, _ = w.Write([]byte(`[]`)) - }) - defer srv.Close() - _, _ = c.SimilarRecordings(context.Background(), "abc", 50) - if !strings.Contains(seenURL, "count=50") && !strings.Contains(seenURL, "limit=50") { - t.Errorf("URL missing count/limit param: %q", seenURL) - } -} - -func TestClient_SimilarRecordings_401_ReturnsErrAuth(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - }) - defer srv.Close() - _, err := c.SimilarRecordings(context.Background(), "abc", 100) - if !errors.Is(err, ErrAuth) { - t.Errorf("err = %v, want ErrAuth", err) - } -} - -func TestClient_SimilarRecordings_400_ReturnsErrPermanent(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - }) - defer srv.Close() - _, err := c.SimilarRecordings(context.Background(), "abc", 100) - if !errors.Is(err, ErrPermanent) { - t.Errorf("err = %v, want ErrPermanent", err) - } -} - -func TestClient_SimilarRecordings_503_ReturnsErrTransient(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - }) - defer srv.Close() - _, err := c.SimilarRecordings(context.Background(), "abc", 100) - if !errors.Is(err, ErrTransient) { - t.Errorf("err = %v, want ErrTransient", err) - } -} - -func TestClient_SimilarRecordings_429_ReturnsRetryAfter(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Retry-After", "120") - w.WriteHeader(http.StatusTooManyRequests) - }) - defer srv.Close() - _, err := c.SimilarRecordings(context.Background(), "abc", 100) - var ra *RetryAfterError - if !errors.As(err, &ra) { - t.Fatalf("err = %v, want *RetryAfterError", err) - } - if ra.Wait != 120*time.Second { - t.Errorf("Wait = %v, want 120s", ra.Wait) - } -} -``` - -Make sure `strings` is in the existing import block at the top of the file (it almost certainly already is from M4a — if not, add it). - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/listenbrainz/ -run SimilarRecordings -v` - -Expected: FAIL with "undefined: SimilarRecording" / "undefined: SimilarRecordings". - -- [ ] **Step 3: Implement the method** - -Append to `internal/scrobble/listenbrainz/client.go` (no new imports needed beyond what M4a already has — `bytes`, `context`, `encoding/json`, `errors`, `fmt`, `net/http`, `strconv`, `time`): - -```go -// LB algorithm parameter for /explore/similar-recordings/. Hardcoded for v1 -// — matches LB's documented default. If LB changes the default, swap here. -const lbSimilarRecordingsAlgorithm = "session_based_days_7500_session_30_contribution_5_threshold_15_limit_100_filter_True_skip_30" - -// LB algorithm parameter for /explore/similar-artists/. -const lbSimilarArtistsAlgorithm = "session_based_days_7500_session_30_contribution_5_threshold_15_limit_100_filter_True_skip_30" - -// SimilarRecording is one entry in the /explore/similar-recordings response. -type SimilarRecording struct { - MBID string `json:"recording_mbid"` - Score float64 `json:"score"` -} - -// SimilarRecordings fetches up to `limit` similar recordings for the given -// recording MBID. Public endpoint — no token required. Returns the same -// typed errors as SubmitListens. -func (c *Client) SimilarRecordings(ctx context.Context, mbid string, limit int) ([]SimilarRecording, error) { - base := c.BaseURL - if base == "" { - base = defaultBaseURL - } - url := fmt.Sprintf("%s/1/explore/similar-recordings/%s?algorithm=%s&count=%d", - base, mbid, lbSimilarRecordingsAlgorithm, limit) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("listenbrainz: build similar-recordings request: %w", err) - } - - httpClient := c.HTTP - if httpClient == nil { - httpClient = http.DefaultClient - } - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrTransient, err) - } - defer func() { _ = resp.Body.Close() }() - - switch { - case resp.StatusCode >= 200 && resp.StatusCode < 300: - var out []SimilarRecording - if derr := json.NewDecoder(resp.Body).Decode(&out); derr != nil { - return nil, fmt.Errorf("%w: decode similar-recordings: %v", ErrTransient, derr) - } - return out, nil - case resp.StatusCode == http.StatusUnauthorized: - return nil, ErrAuth - case resp.StatusCode == http.StatusTooManyRequests: - return nil, &RetryAfterError{Wait: parseRetryAfter(resp.Header.Get("Retry-After"))} - case resp.StatusCode >= 500: - return nil, fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode) - default: - return nil, fmt.Errorf("%w: status %d", ErrPermanent, resp.StatusCode) - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/listenbrainz/ -run SimilarRecordings -v` - -Expected: PASS for all 7 new tests. - -- [ ] **Step 5: Verify lint clean** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./internal/scrobble/...` - -Expected: clean. - -- [ ] **Step 6: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add internal/scrobble/listenbrainz/client.go internal/scrobble/listenbrainz/client_test.go -git commit -m "feat(listenbrainz): add SimilarRecordings client method" -``` - ---- - -## Task 4: LB client `SimilarArtists` method - -Symmetric to Task 3. The artist endpoint has the same shape; the response key is `artist_mbid` instead of `recording_mbid`. - -**Files:** -- Modify: `internal/scrobble/listenbrainz/client.go` (append) -- Modify: `internal/scrobble/listenbrainz/client_test.go` (append) - -- [ ] **Step 1: Write the failing tests** - -Append to `internal/scrobble/listenbrainz/client_test.go`: - -```go -func TestClient_SimilarArtists_Success(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`[ - {"artist_mbid": "33333333-3333-3333-3333-333333333333", "score": 0.91}, - {"artist_mbid": "44444444-4444-4444-4444-444444444444", "score": 0.72} - ]`)) - }) - defer srv.Close() - out, err := c.SimilarArtists(context.Background(), "aaaa", 100) - if err != nil { - t.Fatalf("err: %v", err) - } - if len(out) != 2 { - t.Fatalf("got %d, want 2", len(out)) - } - if out[0].MBID != "33333333-3333-3333-3333-333333333333" || out[0].Score != 0.91 { - t.Errorf("first result wrong: %+v", out[0]) - } -} - -func TestClient_SimilarArtists_AlgorithmParamSet(t *testing.T) { - var seenURL string - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - seenURL = r.URL.String() - _, _ = w.Write([]byte(`[]`)) - }) - defer srv.Close() - _, _ = c.SimilarArtists(context.Background(), "abc", 50) - if !strings.Contains(seenURL, "algorithm=") { - t.Errorf("URL missing algorithm param: %q", seenURL) - } -} - -func TestClient_SimilarArtists_LimitParamSet(t *testing.T) { - var seenURL string - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - seenURL = r.URL.String() - _, _ = w.Write([]byte(`[]`)) - }) - defer srv.Close() - _, _ = c.SimilarArtists(context.Background(), "abc", 50) - if !strings.Contains(seenURL, "count=50") && !strings.Contains(seenURL, "limit=50") { - t.Errorf("URL missing count/limit param: %q", seenURL) - } -} - -func TestClient_SimilarArtists_401_ReturnsErrAuth(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - }) - defer srv.Close() - _, err := c.SimilarArtists(context.Background(), "abc", 100) - if !errors.Is(err, ErrAuth) { - t.Errorf("err = %v, want ErrAuth", err) - } -} - -func TestClient_SimilarArtists_400_ReturnsErrPermanent(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - }) - defer srv.Close() - _, err := c.SimilarArtists(context.Background(), "abc", 100) - if !errors.Is(err, ErrPermanent) { - t.Errorf("err = %v, want ErrPermanent", err) - } -} - -func TestClient_SimilarArtists_503_ReturnsErrTransient(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - }) - defer srv.Close() - _, err := c.SimilarArtists(context.Background(), "abc", 100) - if !errors.Is(err, ErrTransient) { - t.Errorf("err = %v, want ErrTransient", err) - } -} - -func TestClient_SimilarArtists_429_ReturnsRetryAfter(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Retry-After", "60") - w.WriteHeader(http.StatusTooManyRequests) - }) - defer srv.Close() - _, err := c.SimilarArtists(context.Background(), "abc", 100) - var ra *RetryAfterError - if !errors.As(err, &ra) { - t.Fatalf("err = %v, want *RetryAfterError", err) - } - if ra.Wait != 60*time.Second { - t.Errorf("Wait = %v, want 60s", ra.Wait) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/listenbrainz/ -run SimilarArtists -v` - -Expected: FAIL with "undefined: SimilarArtists". - -- [ ] **Step 3: Implement the method** - -Append to `internal/scrobble/listenbrainz/client.go`: - -```go -// SimilarArtist is one entry in the /explore/similar-artists response. -type SimilarArtist struct { - MBID string `json:"artist_mbid"` - Score float64 `json:"score"` -} - -// SimilarArtists fetches up to `limit` similar artists for the given -// artist MBID. Public endpoint — no token required. Returns the same -// typed errors as SubmitListens. -func (c *Client) SimilarArtists(ctx context.Context, mbid string, limit int) ([]SimilarArtist, error) { - base := c.BaseURL - if base == "" { - base = defaultBaseURL - } - url := fmt.Sprintf("%s/1/explore/similar-artists/%s?algorithm=%s&count=%d", - base, mbid, lbSimilarArtistsAlgorithm, limit) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("listenbrainz: build similar-artists request: %w", err) - } - - httpClient := c.HTTP - if httpClient == nil { - httpClient = http.DefaultClient - } - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrTransient, err) - } - defer func() { _ = resp.Body.Close() }() - - switch { - case resp.StatusCode >= 200 && resp.StatusCode < 300: - var out []SimilarArtist - if derr := json.NewDecoder(resp.Body).Decode(&out); derr != nil { - return nil, fmt.Errorf("%w: decode similar-artists: %v", ErrTransient, derr) - } - return out, nil - case resp.StatusCode == http.StatusUnauthorized: - return nil, ErrAuth - case resp.StatusCode == http.StatusTooManyRequests: - return nil, &RetryAfterError{Wait: parseRetryAfter(resp.Header.Get("Retry-After"))} - case resp.StatusCode >= 500: - return nil, fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode) - default: - return nil, fmt.Errorf("%w: status %d", ErrPermanent, resp.StatusCode) - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/listenbrainz/ -run SimilarArtists -v` - -Expected: PASS for all 7 new tests. - -- [ ] **Step 5: Verify lint clean** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./internal/scrobble/...` - -Expected: clean. - -- [ ] **Step 6: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add internal/scrobble/listenbrainz/client.go internal/scrobble/listenbrainz/client_test.go -git commit -m "feat(listenbrainz): add SimilarArtists client method" -``` - ---- - -## Task 5: Worker skeleton + pure unit tests - -The Worker package gets skeleton types + `NewWorker` constructor first. Integration tests in Task 6 exercise the full pipeline. - -**Files:** -- Create: `internal/similarity/worker.go` -- Create: `internal/similarity/worker_test.go` - -- [ ] **Step 1: Write the failing tests** - -Create `internal/similarity/worker_test.go`: - -```go -package similarity - -import ( - "io" - "log/slog" - "testing" - "time" -) - -func TestNewWorker_DefaultsMatchSpec(t *testing.T) { - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - w := NewWorker(nil, nil, logger) - if w.tick != 1*time.Hour { - t.Errorf("tick = %v, want 1h", w.tick) - } - if w.batch != 5 { - t.Errorf("batch = %d, want 5", w.batch) - } - if w.topK != 20 { - t.Errorf("topK = %d, want 20", w.topK) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/similarity/ -v` - -Expected: FAIL — package doesn't exist yet. - -- [ ] **Step 3: Write the worker skeleton** - -Create `internal/similarity/worker.go`: - -```go -// Package similarity owns the inbound ListenBrainz similarity ingest -// pipeline. A periodic worker queries LB's /explore/similar-recordings -// and /explore/similar-artists endpoints for tracks the user has played, -// filters returned MBIDs to the local library, and stores the top-K -// edges in track_similarity / artist_similarity for M4c's radio -// candidate-pool builder. -package similarity - -import ( - "context" - "log/slog" - "time" - - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" -) - -// Worker drains played-tracks-and-artists needing similarity and POSTs -// the results into track_similarity / artist_similarity. Failures are -// passively retried via the timer (no durable queue table — losing one -// tick's worth of refresh attempts is "1 hour of staleness," fine). -type Worker struct { - pool *pgxpool.Pool - client *listenbrainz.Client - logger *slog.Logger - tick time.Duration - batch int32 - topK int -} - -// NewWorker constructs a worker with production defaults: 1h tick, -// batch=5, topK=20. -func NewWorker(pool *pgxpool.Pool, client *listenbrainz.Client, logger *slog.Logger) *Worker { - return &Worker{ - pool: pool, - client: client, - logger: logger, - tick: 1 * time.Hour, - batch: 5, - topK: 20, - } -} - -// Run blocks until ctx is cancelled, ticking every w.tick. -func (w *Worker) Run(ctx context.Context) { - t := time.NewTicker(w.tick) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - if err := w.tickOnce(ctx); err != nil { - w.logger.Error("similarity: tick failed", "err", err) - } - } - } -} - -// tickOnce drains one batch of tracks and one batch of artists. Stub — -// implementation lands in Task 6 along with the integration tests that -// drive it. -func (w *Worker) tickOnce(ctx context.Context) error { - return nil -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/similarity/ -v` - -Expected: PASS for the constructor test. - -- [ ] **Step 5: Verify lint clean** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./internal/similarity/...` - -Expected: clean. - -- [ ] **Step 6: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add internal/similarity/worker.go internal/similarity/worker_test.go -git commit -m "feat(similarity): add Worker skeleton with constructor + Run loop" -``` - ---- - -## Task 6: Worker tickOnce — full pipeline (integration) - -Full implementation of the worker drain path with comprehensive integration tests. - -**Files:** -- Modify: `internal/similarity/worker.go` (replace `tickOnce` stub with real impl) -- Create: `internal/similarity/worker_integration_test.go` - -The test file follows the M4a fixture pattern: a test pool helper that runs migrations, truncates known tables, and returns a `*pgxpool.Pool` + `*dbq.Queries`. Since the M4a `internal/scrobble` package already has a similar helper in `queue_test.go`, copy that shape. - -- [ ] **Step 1: Write the failing integration tests** - -Create `internal/similarity/worker_integration_test.go`: - -```go -package similarity - -import ( - "context" - "encoding/json" - "io" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" -) - -func testPool(t *testing.T) (*pgxpool.Pool, *dbq.Queries) { - t.Helper() - if testing.Short() { - t.Skip("skipping in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - t.Fatalf("pool: %v", err) - } - t.Cleanup(pool.Close) - if _, err := pool.Exec(context.Background(), - "TRUNCATE artist_similarity, track_similarity, scrobble_queue, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { - t.Fatalf("truncate: %v", err) - } - return pool, dbq.New(pool) -} - -type fixture struct { - pool *pgxpool.Pool - q *dbq.Queries - user pgtype.UUID - artist dbq.Artist - album dbq.Album -} - -// newFixture creates a user, one artist (with MBID), and one album. -// Tests add tracks via seedTrack which optionally takes an MBID. -func newFixture(t *testing.T) fixture { - t.Helper() - pool, q := testPool(t) - ctx := context.Background() - u, err := q.CreateUser(ctx, dbq.CreateUserParams{ - Username: "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, - }) - if err != nil { - t.Fatalf("user: %v", err) - } - artistMbid := "aaaaaaaa-1111-1111-1111-111111111111" - a, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{ - Name: "X", SortName: "X", Mbid: &artistMbid, - }) - if err != nil { - t.Fatalf("artist: %v", err) - } - al, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{ - Title: "X", SortTitle: "X", ArtistID: a.ID, - }) - if err != nil { - t.Fatalf("album: %v", err) - } - return fixture{pool: pool, q: q, user: u.ID, artist: a, album: al} -} - -func seedTrack(t *testing.T, f fixture, title string, mbid *string) dbq.Track { - t.Helper() - tr, err := f.q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: title, - AlbumID: f.album.ID, - ArtistID: f.artist.ID, - FilePath: "/tmp/" + title + ".flac", - DurationMs: 200_000, - Mbid: mbid, - }) - if err != nil { - t.Fatalf("track: %v", err) - } - return tr -} - -// markPlayed inserts a closed play_event so the track qualifies as "played" -// for the worker's selection query. -func markPlayed(t *testing.T, f fixture, trackID pgtype.UUID) { - t.Helper() - var sessionID pgtype.UUID - if err := f.pool.QueryRow(context.Background(), - `INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id) - VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`, - f.user).Scan(&sessionID); err != nil { - t.Fatalf("session: %v", err) - } - if _, err := f.pool.Exec(context.Background(), - `INSERT INTO play_events (user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped) - VALUES ($1, $2, $3, now() - interval '1 minute', now(), 250000, 0.83, false)`, - f.user, trackID, sessionID); err != nil { - t.Fatalf("play_event: %v", err) - } -} - -func newTestWorker(f fixture, lbBaseURL string) *Worker { - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - return &Worker{ - pool: f.pool, - client: &listenbrainz.Client{BaseURL: lbBaseURL, HTTP: http.DefaultClient}, - logger: logger, - tick: 1 * time.Hour, - batch: 5, - topK: 20, - } -} - -// stubLB returns an httptest server that responds to similar-recordings and -// similar-artists with the given JSON payloads. Use empty string to simulate -// "endpoint returned []" (LB knows the MBID but has no neighbors). -func stubLB(recordingsBody, artistsBody string, status int) *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if status != 0 && status != http.StatusOK { - w.WriteHeader(status) - return - } - if strings.Contains(r.URL.Path, "/similar-recordings/") { - _, _ = w.Write([]byte(recordingsBody)) - return - } - if strings.Contains(r.URL.Path, "/similar-artists/") { - _, _ = w.Write([]byte(artistsBody)) - return - } - w.WriteHeader(http.StatusNotFound) - })) -} - -func countTrackSim(t *testing.T, f fixture, a pgtype.UUID) int { - t.Helper() - var n int - if err := f.pool.QueryRow(context.Background(), - `SELECT count(*) FROM track_similarity WHERE track_a_id = $1`, a).Scan(&n); err != nil { - t.Fatalf("count: %v", err) - } - return n -} - -func countArtistSim(t *testing.T, f fixture, a pgtype.UUID) int { - t.Helper() - var n int - if err := f.pool.QueryRow(context.Background(), - `SELECT count(*) FROM artist_similarity WHERE artist_a_id = $1`, a).Scan(&n); err != nil { - t.Fatalf("count: %v", err) - } - return n -} - -func TestTickOnce_NoPlayedTracks_NoOp(t *testing.T) { - f := newFixture(t) - srv := stubLB(`[]`, `[]`, http.StatusOK) - defer srv.Close() - w := newTestWorker(f, srv.URL) - if err := w.tickOnce(context.Background()); err != nil { - t.Fatalf("tickOnce: %v", err) - } - // no rows inserted anywhere - var n int - _ = f.pool.QueryRow(context.Background(), `SELECT count(*) FROM track_similarity`).Scan(&n) - if n != 0 { - t.Errorf("track_similarity rows = %d, want 0", n) - } - _ = f.pool.QueryRow(context.Background(), `SELECT count(*) FROM artist_similarity`).Scan(&n) - if n != 0 { - t.Errorf("artist_similarity rows = %d, want 0", n) - } -} - -func TestTickOnce_MapsLBResponseToLocalLibrary(t *testing.T) { - f := newFixture(t) - mbidA := "11111111-1111-1111-1111-111111111111" - mbidB := "22222222-2222-2222-2222-222222222222" - mbidC := "99999999-9999-9999-9999-999999999999" // NOT in library - trackA := seedTrack(t, f, "A", &mbidA) - trackB := seedTrack(t, f, "B", &mbidB) - markPlayed(t, f, trackA.ID) - // LB returns three results when queried for trackA — two in library, one not. - body := `[ - {"recording_mbid": "` + mbidB + `", "score": 0.9}, - {"recording_mbid": "` + mbidC + `", "score": 0.7} - ]` - _ = trackB - srv := stubLB(body, `[]`, http.StatusOK) - defer srv.Close() - w := newTestWorker(f, srv.URL) - if err := w.tickOnce(context.Background()); err != nil { - t.Fatalf("tickOnce: %v", err) - } - if got := countTrackSim(t, f, trackA.ID); got != 1 { - t.Errorf("track_similarity rows = %d, want 1 (only mbidB is in-library)", got) - } -} - -func TestTickOnce_TopKEnforced(t *testing.T) { - f := newFixture(t) - mbidSeed := "11111111-1111-1111-1111-111111111111" - seed := seedTrack(t, f, "Seed", &mbidSeed) - markPlayed(t, f, seed.ID) - // Build 25 tracks with MBIDs in library, plus the LB response listing all 25. - type lbRow struct { - MBID string `json:"recording_mbid"` - Score float64 `json:"score"` - } - rows := make([]lbRow, 0, 25) - for i := 0; i < 25; i++ { - mbid := "20000000-0000-0000-0000-" + leftPad(i+1, 12) - _ = seedTrack(t, f, "T"+leftPad(i+1, 2), &mbid) - rows = append(rows, lbRow{MBID: mbid, Score: 1.0 - 0.01*float64(i)}) - } - body, _ := json.Marshal(rows) - srv := stubLB(string(body), `[]`, http.StatusOK) - defer srv.Close() - w := newTestWorker(f, srv.URL) - if err := w.tickOnce(context.Background()); err != nil { - t.Fatalf("tickOnce: %v", err) - } - if got := countTrackSim(t, f, seed.ID); got > 20 { - t.Errorf("top-K not enforced: got %d, want ≤ 20", got) - } - if got := countTrackSim(t, f, seed.ID); got != 20 { - t.Errorf("got %d rows, want exactly 20 (all 25 in-library; top 20 by score)", got) - } -} - -func TestTickOnce_RespectsSevenDayCap(t *testing.T) { - f := newFixture(t) - mbid := "11111111-1111-1111-1111-111111111111" - trackA := seedTrack(t, f, "A", &mbid) - markPlayed(t, f, trackA.ID) - // Manually insert a fresh row so the worker should skip this track. - otherMbid := "55555555-5555-5555-5555-555555555555" - other := seedTrack(t, f, "Other", &otherMbid) - if _, err := f.pool.Exec(context.Background(), - `INSERT INTO track_similarity (track_a_id, track_b_id, score, source, fetched_at) - VALUES ($1, $2, 0.5, 'listenbrainz', now())`, trackA.ID, other.ID); err != nil { - t.Fatalf("seed sim: %v", err) - } - // LB stub would return more rows if called; if the worker hits LB the - // test fails (different count after). - beforeCount := countTrackSim(t, f, trackA.ID) - srv := stubLB(`[{"recording_mbid":"`+otherMbid+`","score":0.99}]`, `[]`, http.StatusOK) - defer srv.Close() - w := newTestWorker(f, srv.URL) - _ = w.tickOnce(context.Background()) - if got := countTrackSim(t, f, trackA.ID); got != beforeCount { - t.Errorf("worker re-queried fresh track: before=%d after=%d", beforeCount, got) - } -} - -func TestTickOnce_RefreshesStaleRow(t *testing.T) { - f := newFixture(t) - mbid := "11111111-1111-1111-1111-111111111111" - trackA := seedTrack(t, f, "A", &mbid) - markPlayed(t, f, trackA.ID) - otherMbid := "55555555-5555-5555-5555-555555555555" - other := seedTrack(t, f, "Other", &otherMbid) - // Insert a stale row from 8 days ago with score 0.5. - if _, err := f.pool.Exec(context.Background(), - `INSERT INTO track_similarity (track_a_id, track_b_id, score, source, fetched_at) - VALUES ($1, $2, 0.5, 'listenbrainz', now() - interval '8 days')`, trackA.ID, other.ID); err != nil { - t.Fatalf("seed sim: %v", err) - } - srv := stubLB(`[{"recording_mbid":"`+otherMbid+`","score":0.95}]`, `[]`, http.StatusOK) - defer srv.Close() - w := newTestWorker(f, srv.URL) - if err := w.tickOnce(context.Background()); err != nil { - t.Fatalf("tickOnce: %v", err) - } - var score float64 - var fetchedAt time.Time - _ = f.pool.QueryRow(context.Background(), - `SELECT score, fetched_at FROM track_similarity WHERE track_a_id = $1 AND track_b_id = $2 AND source = 'listenbrainz'`, - trackA.ID, other.ID).Scan(&score, &fetchedAt) - if score != 0.95 { - t.Errorf("score = %v, want 0.95 (refreshed)", score) - } - if time.Since(fetchedAt) > time.Minute { - t.Errorf("fetched_at not bumped: %v ago", time.Since(fetchedAt)) - } -} - -func TestTickOnce_429AbortsTick(t *testing.T) { - f := newFixture(t) - mbid := "11111111-1111-1111-1111-111111111111" - trackA := seedTrack(t, f, "A", &mbid) - markPlayed(t, f, trackA.ID) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Retry-After", "60") - w.WriteHeader(http.StatusTooManyRequests) - })) - defer srv.Close() - w := newTestWorker(f, srv.URL) - _ = w.tickOnce(context.Background()) - // no rows updated; fetched_at should not be set anywhere - var n int - _ = f.pool.QueryRow(context.Background(), `SELECT count(*) FROM track_similarity WHERE track_a_id = $1`, trackA.ID).Scan(&n) - if n != 0 { - t.Errorf("rows on 429 = %d, want 0 (tick aborted before any inserts)", n) - } -} - -func TestTickOnce_TransientErrorSkipsTrack(t *testing.T) { - f := newFixture(t) - mbidA := "11111111-1111-1111-1111-111111111111" - mbidB := "22222222-2222-2222-2222-222222222222" - otherMbid := "55555555-5555-5555-5555-555555555555" - trackA := seedTrack(t, f, "A", &mbidA) - trackB := seedTrack(t, f, "B", &mbidB) - other := seedTrack(t, f, "Other", &otherMbid) - markPlayed(t, f, trackA.ID) - markPlayed(t, f, trackB.ID) - // Stub: 503 for the first MBID we see, success body for the second. - var seen atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.Contains(r.URL.Path, "/similar-recordings/") { - _, _ = w.Write([]byte(`[]`)) - return - } - n := seen.Add(1) - if n == 1 { - w.WriteHeader(http.StatusServiceUnavailable) - return - } - _, _ = w.Write([]byte(`[{"recording_mbid":"` + otherMbid + `","score":0.7}]`)) - })) - defer srv.Close() - w := newTestWorker(f, srv.URL) - _ = w.tickOnce(context.Background()) - _ = other // referenced via UPSERT - // Exactly one of trackA/trackB should have a row; the other (whichever - // hit 503) should be empty. - a := countTrackSim(t, f, trackA.ID) - b := countTrackSim(t, f, trackB.ID) - if a+b != 1 { - t.Errorf("expected exactly one track to succeed: a=%d b=%d", a, b) - } -} - -func TestTickOnce_FiltersInLibrary(t *testing.T) { - f := newFixture(t) - mbid := "11111111-1111-1111-1111-111111111111" - trackA := seedTrack(t, f, "A", &mbid) - markPlayed(t, f, trackA.ID) - // LB returns 3 MBIDs none of which are in our library. - body := `[ - {"recording_mbid":"99999999-9999-9999-9999-999999999991","score":0.9}, - {"recording_mbid":"99999999-9999-9999-9999-999999999992","score":0.8}, - {"recording_mbid":"99999999-9999-9999-9999-999999999993","score":0.7} - ]` - srv := stubLB(body, `[]`, http.StatusOK) - defer srv.Close() - w := newTestWorker(f, srv.URL) - if err := w.tickOnce(context.Background()); err != nil { - t.Fatalf("tickOnce: %v", err) - } - if got := countTrackSim(t, f, trackA.ID); got != 0 { - t.Errorf("filtered out-of-library: got %d, want 0", got) - } -} - -func TestTickOnce_ArtistPassMirrors(t *testing.T) { - f := newFixture(t) - mbid := "11111111-1111-1111-1111-111111111111" - trackA := seedTrack(t, f, "A", &mbid) - markPlayed(t, f, trackA.ID) - // Seed a SECOND artist with MBID; LB returns it as similar to f.artist. - bMbid := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" - otherArtist, err := f.q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ - Name: "Other", SortName: "Other", Mbid: &bMbid, - }) - if err != nil { - t.Fatalf("artist: %v", err) - } - body := `[{"artist_mbid":"` + bMbid + `","score":0.85}]` - srv := stubLB(`[]`, body, http.StatusOK) - defer srv.Close() - w := newTestWorker(f, srv.URL) - if err := w.tickOnce(context.Background()); err != nil { - t.Fatalf("tickOnce: %v", err) - } - _ = otherArtist - if got := countArtistSim(t, f, f.artist.ID); got != 1 { - t.Errorf("artist_similarity rows = %d, want 1", got) - } -} - -func TestTickOnce_NoMBIDOnTrack_Skipped(t *testing.T) { - f := newFixture(t) - trackNoMbid := seedTrack(t, f, "NoMbid", nil) - markPlayed(t, f, trackNoMbid.ID) - // LB stub would return rows if queried; if worker queries it, the test - // fails (any non-zero rows means we hit LB for a no-MBID track). - srv := stubLB(`[{"recording_mbid":"11111111-1111-1111-1111-111111111111","score":0.9}]`, `[]`, http.StatusOK) - defer srv.Close() - w := newTestWorker(f, srv.URL) - if err := w.tickOnce(context.Background()); err != nil { - t.Fatalf("tickOnce: %v", err) - } - if got := countTrackSim(t, f, trackNoMbid.ID); got != 0 { - t.Errorf("no-MBID track produced rows: %d", got) - } -} - -// leftPad pads an integer with leading zeros to width n, used to build -// deterministic MBID-like strings in tests. -func leftPad(v, width int) string { - s := "" - for i := 0; i < width; i++ { - s = "0" + s - } - digits := "" - for v > 0 { - digits = string(rune('0'+v%10)) + digits - v /= 10 - } - if len(digits) >= width { - return digits - } - return s[:width-len(digits)] + digits -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/similarity/ -v` - -Expected: tests run but most fail because `tickOnce` is a stub returning nil. - -- [ ] **Step 3: Implement tickOnce** - -Replace the `tickOnce` stub in `internal/similarity/worker.go` with the full implementation. Add the imports `errors`, `git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq`, `github.com/jackc/pgx/v5/pgtype` to the import block: - -```go -import ( - "context" - "errors" - "log/slog" - "sort" - "time" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" -) -``` - -Replace `tickOnce` with: - -```go -// tickOnce drains one batch of tracks and one batch of artists. Per-row -// errors are logged and skipped (passive retry via timer). 429 aborts the -// entire tick (the next hourly tick is far longer than any LB Retry-After). -// -// Returns the first error that aborts the whole tick (currently only 429); -// nil otherwise. -func (w *Worker) tickOnce(ctx context.Context) error { - q := dbq.New(w.pool) - if err := w.tickTracks(ctx, q); err != nil { - return err - } - return w.tickArtists(ctx, q) -} - -func (w *Worker) tickTracks(ctx context.Context, q *dbq.Queries) error { - rows, err := q.ListPlayedTracksNeedingSimilarity(ctx, w.batch) - if err != nil { - return err - } - for _, r := range rows { - if r.Mbid == nil { - continue // defensive — query already filters NULL - } - results, err := w.client.SimilarRecordings(ctx, *r.Mbid, 100) - if err != nil { - var ra *listenbrainz.RetryAfterError - if errors.As(err, &ra) { - w.logger.Warn("similarity: 429 — aborting tick", "retry_after", ra.Wait) - return nil // abort this tick; passive retry on next - } - w.logger.Warn("similarity: similar-recordings failed", "track_id", r.ID, "err", err) - continue - } - w.upsertTrackSimilar(ctx, q, r.ID, results) - } - return nil -} - -func (w *Worker) tickArtists(ctx context.Context, q *dbq.Queries) error { - rows, err := q.ListPlayedArtistsNeedingSimilarity(ctx, w.batch) - if err != nil { - return err - } - for _, r := range rows { - if r.Mbid == nil { - continue - } - results, err := w.client.SimilarArtists(ctx, *r.Mbid, 100) - if err != nil { - var ra *listenbrainz.RetryAfterError - if errors.As(err, &ra) { - w.logger.Warn("similarity: 429 — aborting tick", "retry_after", ra.Wait) - return nil - } - w.logger.Warn("similarity: similar-artists failed", "artist_id", r.ID, "err", err) - continue - } - w.upsertArtistSimilar(ctx, q, r.ID, results) - } - return nil -} - -// upsertTrackSimilar filters returned MBIDs to those in our library, takes -// top-K by score, and upserts rows. -func (w *Worker) upsertTrackSimilar(ctx context.Context, q *dbq.Queries, trackAID pgtype.UUID, results []listenbrainz.SimilarRecording) { - if len(results) == 0 { - return - } - // Sort by score desc (LB returns sorted; defensive). - sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score }) - - // Bulk-resolve MBIDs to local track IDs. - mbids := make([]string, 0, len(results)) - for _, r := range results { - mbids = append(mbids, r.MBID) - } - rows, err := q.GetTracksByMBIDs(ctx, mbids) - if err != nil { - w.logger.Warn("similarity: GetTracksByMBIDs", "err", err) - return - } - idByMBID := make(map[string]pgtype.UUID, len(rows)) - for _, r := range rows { - if r.Mbid != nil { - idByMBID[*r.Mbid] = r.ID - } - } - - // Walk results in score order, take first K that map to a local track. - taken := 0 - for _, r := range results { - if taken >= w.topK { - break - } - localID, ok := idByMBID[r.MBID] - if !ok { - continue - } - if localID == trackAID { - continue // defensive — DB CHECK constraint also catches self-edges - } - if uerr := q.UpsertTrackSimilarity(ctx, dbq.UpsertTrackSimilarityParams{ - TrackAID: trackAID, TrackBID: localID, Score: r.Score, - }); uerr != nil { - w.logger.Warn("similarity: UpsertTrackSimilarity", "err", uerr) - continue - } - taken++ - } -} - -func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artistAID pgtype.UUID, results []listenbrainz.SimilarArtist) { - if len(results) == 0 { - return - } - sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score }) - - mbids := make([]string, 0, len(results)) - for _, r := range results { - mbids = append(mbids, r.MBID) - } - rows, err := q.GetArtistsByMBIDs(ctx, mbids) - if err != nil { - w.logger.Warn("similarity: GetArtistsByMBIDs", "err", err) - return - } - idByMBID := make(map[string]pgtype.UUID, len(rows)) - for _, r := range rows { - if r.Mbid != nil { - idByMBID[*r.Mbid] = r.ID - } - } - - taken := 0 - for _, r := range results { - if taken >= w.topK { - break - } - localID, ok := idByMBID[r.MBID] - if !ok { - continue - } - if localID == artistAID { - continue - } - if uerr := q.UpsertArtistSimilarity(ctx, dbq.UpsertArtistSimilarityParams{ - ArtistAID: artistAID, ArtistBID: localID, Score: r.Score, - }); uerr != nil { - w.logger.Warn("similarity: UpsertArtistSimilarity", "err", uerr) - continue - } - taken++ - } -} -``` - -Note: the exact param-struct field names (`TrackAID`, `ArtistAID`, etc.) are emitted by sqlc — they may use different capitalization. After running `make generate`, inspect `internal/db/dbq/similarity.sql.go` and adjust the field references to match. Same for `r.Mbid` — sqlc may name the column type pointer field `Mbid` or `MBID`. Use whatever the generated code shows. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/similarity/ -v` - -Expected: PASS for all 9 integration tests + 1 constructor test. - -- [ ] **Step 5: Verify lint clean** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./internal/similarity/...` - -Expected: clean. - -- [ ] **Step 6: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add internal/similarity/worker.go internal/similarity/worker_integration_test.go -git commit -m "feat(similarity): implement Worker.tickOnce with track + artist passes" -``` - ---- - -## Task 7: Boot the worker in main.go - -**Files:** -- Modify: `cmd/minstrel/main.go` - -- [ ] **Step 1: Add the import** - -In `cmd/minstrel/main.go`, append to the existing import block: - -```go -"git.fabledsword.com/bvandeusen/minstrel/internal/similarity" -``` - -(The `internal/scrobble/listenbrainz` import already exists from M4a; we re-use the same client.) - -- [ ] **Step 2: Add the worker startup** - -In `cmd/minstrel/main.go`, find where the M4a scrobble worker is started (a `scrobbleWorker := scrobble.NewWorker(...)` followed by `go scrobbleWorker.Run(ctx)`). Append immediately after: - -```go -// Start the similarity ingest worker. Per spec §M4b, runs every 1h, drains -// up to 5 played tracks + 5 played artists per tick, weekly re-fetch cap -// per row. Public LB endpoints — no token required. -similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity")) -go similarityWorker.Run(ctx) -``` - -- [ ] **Step 3: Verify compile** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go build ./...` - -Expected: clean. - -- [ ] **Step 4: Verify the worker actually starts (smoke test)** - -Run a brief end-to-end check (re-uses the M4a smoke pattern): - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -SMARTMUSIC_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ - SMARTMUSIC_LIBRARY_SCAN_PATHS='' \ - SMARTMUSIC_LIBRARY_SCAN_ON_STARTUP=false \ - SMARTMUSIC_SERVER_ADDRESS=:14533 \ - timeout 5 go run ./cmd/minstrel/ 2>&1 | head -20 -``` - -Expected: server starts, no panic. The similarity worker doesn't log per tick (only on errors), so the smoke is just "no crash." - -- [ ] **Step 5: Verify lint clean** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./...` - -Expected: clean. - -- [ ] **Step 6: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add cmd/minstrel/main.go -git commit -m "feat(cmd): start similarity worker alongside HTTP server" -``` - ---- - -## Task 8: Final verification + branch finish - -**Files:** none (verification only) - -- [ ] **Step 1: Full Go test suite (-short -race)** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test -short -race ./...` - -Expected: PASS across all 16 packages (now including `internal/similarity`). - -- [ ] **Step 2: Full integration suite** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -race -p 1 ./...` - -Expected: PASS apart from the pre-existing `TestScanner_Integration` flake (CI runs `-short` which skips it; not introduced by this PR). - -- [ ] **Step 3: Lint** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./...` - -Expected: clean. - -- [ ] **Step 4: Coverage check on new package** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -p 1 -coverprofile=/tmp/cover-m4b.out ./internal/similarity/... ./internal/scrobble/listenbrainz/... && go tool cover -func=/tmp/cover-m4b.out | tail -5` - -Expected: `internal/similarity` ≥ 75%, `internal/scrobble/listenbrainz` ≥ 85%. The new `Similar*` methods are added on top of M4a's coverage. - -- [ ] **Step 5: Web verification (no changes — sanity)** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run check && npm test -- --run && npm run build` - -Expected: svelte-check 0/0; 180 vitest tests pass; build succeeds. (M4b doesn't touch web.) - -- [ ] **Step 6: Docker smoke** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && docker build -t minstrel:m4b-smoke .` - -Expected: container builds. - -- [ ] **Step 7: Manual verification post-merge (optional, deferred)** - -Note: full validation requires a real LB-tagged library plus 1h+ uptime. The integration tests provide deterministic coverage. Manual verification at the user's discretion: - -1. `docker compose up --build -d minstrel` — pick up new code -2. Wait ≥1 hour -3. `psql -c "SELECT count(*) FROM track_similarity WHERE source = 'listenbrainz'"` → non-zero rows IF library has MBID-tagged played tracks - -- [ ] **Step 8: Update Fable task #346** - -After PR opens, mark `in_progress`. After PR merges, mark `done` with a summary of what shipped. - -- [ ] **Step 9: Finishing the branch** - -**REQUIRED SUB-SKILL:** Use `superpowers:finishing-a-development-branch` to verify tests, present completion options, and execute the user's choice. - -Per established cadence: this slice will land as a single-purpose PR. After merge, M4b closes; M4c (radio similarity-driven candidate pool + queue refresh, closes M4) is next. - ---- - -## Self-Review - -**Spec coverage:** -- §3.1 (new package `internal/similarity`) → Tasks 5, 6 ✓ -- §3.2 (LB client extensions) → Tasks 3, 4; boot wiring → Task 7 ✓ -- §3.3 (failure handling — passive retry, 429 abort tick) → Task 6 (worker logic + integration tests) ✓ -- §4 (database schema) → Task 1 ✓ -- §5 (sqlc queries) → Task 2 ✓ -- §6 (worker algorithm) → Task 6 ✓ -- §7.1 (LB client unit tests) → Tasks 3, 4 ✓ -- §7.2 (worker integration tests) → Task 6 ✓ -- §7.3 (coverage target) → Task 8 step 4 ✓ -- §7.4 (manual verification) → Task 8 step 7 ✓ -- §8 (backwards compat) → preserved by additive design (new tables, new package) ✓ - -No gaps. - -**Placeholder scan:** No "TBD"/"TODO" content. The "use whatever sqlc emits at make generate time" guidance in Task 6 is a known sqlc-specific instruction, not a placeholder. - -**Type consistency:** -- `Worker` struct fields (`pool`, `client`, `logger`, `tick`, `batch`, `topK`) — same in Task 5 (skeleton) and Task 6 (full impl). -- `tickOnce(ctx) error` — same signature throughout. -- `SimilarRecording`/`SimilarArtist` types defined in Tasks 3/4, consumed in Task 6. -- sqlc-generated method names referenced consistently: `ListPlayedTracksNeedingSimilarity`, `ListPlayedArtistsNeedingSimilarity`, `GetTracksByMBIDs`, `GetArtistsByMBIDs`, `UpsertTrackSimilarity`, `UpsertArtistSimilarity`. -- Constants `lbSimilarRecordingsAlgorithm` / `lbSimilarArtistsAlgorithm` defined in Task 3, both referenced from Tasks 3 and 4. diff --git a/docs/superpowers/plans/2026-04-28-m4c-radio.md b/docs/superpowers/plans/2026-04-28-m4c-radio.md deleted file mode 100644 index 737f890a..00000000 --- a/docs/superpowers/plans/2026-04-28-m4c-radio.md +++ /dev/null @@ -1,1391 +0,0 @@ -# M4c — Radio Similarity-Driven Candidate Pool + Queue Refresh Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace M3's whole-library candidate pool with a 5-way SQL UNION of similarity sources (LB-similar tracks, similar-artist tracks, MB-tag overlap, likes-overlap, random fill), add a `SimilarityScore × SimilarityWeight` term to M3's `Score()`, and have the SPA auto-refresh the radio queue at 80% consumed via a new `?exclude=` query param. Closes M4. - -**Architecture:** New sqlc query `LoadRadioCandidatesV2` performs the 5-way UNION + per-source scoring + final dedup-by-max in SQL. New `LoadCandidatesFromSimilarity` Go function projects rows to `[]Candidate` (same return type as M3's `LoadCandidates`, so `Shuffle()` is unchanged). Radio handler uses the new function as primary, falls back to M3's `LoadCandidates` on any error. Web client tracks `radioSeedId` and fires a refresh fetch when 80% consumed; clears on non-radio enqueues. - -**Tech Stack:** Go 1.23 + sqlc + pgx/v5. SvelteKit 2 + Svelte 5 (runes) + TanStack Query. No schema migrations — relies on M4b's `track_similarity` / `artist_similarity` and existing M2 `general_likes` / M0-M1 `tracks.genre`. - -**Reference:** design spec at `docs/superpowers/specs/2026-04-28-m4c-radio-design.md`. - ---- - -## File Structure - -**New server files:** - -| File | Responsibility | -|---|---| -| `internal/db/queries/radio.sql` (modify) | Append `LoadRadioCandidatesV2 :many` — 5-way UNION + dedup-by-max. | -| `internal/db/dbq/radio.sql.go` | Generated bindings. | - -Note: `internal/db/queries/recommendation.sql` currently holds `LoadRadioCandidates` (M3). I'll **add the V2 query to the same file** to keep radio-related queries co-located. - -**Modified server files:** - -| File | Change | -|---|---| -| `internal/recommendation/score.go` | `ScoringInputs` gains `SimilarityScore float64`, `ScoringWeights` gains `SimilarityWeight float64`. `Score()` adds `+ in.SimilarityScore * w.SimilarityWeight`. | -| `internal/recommendation/score_test.go` | 3 new tests for the new term. | -| `internal/recommendation/candidates.go` | Adds `CandidateSourceLimits` struct, `DefaultCandidateSourceLimits()`, `LoadCandidatesFromSimilarity()`. M3's `LoadCandidates` retained unchanged for fallback + tests. | -| `internal/recommendation/candidates_test.go` (or new `candidates_v2_test.go`) | 10 new integration tests for the similarity-pool path. | -| `internal/config/config.go` | `RecommendationConfig` gains `SimilarityWeight float64` (default `2.0`, yaml `similarity_weight`). | -| `internal/api/radio.go` | Parse `?exclude=`, build limits struct, call `LoadCandidatesFromSimilarity` first then fallback to `LoadCandidates`, thread `SimilarityWeight` into `ScoringWeights`. | -| `internal/api/radio_test.go` | 4 new tests for similarity ranking, exclude param, malformed exclude, fallback path. | -| `internal/api/auth_test.go` (or wherever `recCfg` test fixture lives) | Add `SimilarityWeight: 2.0` to the test recCfg literal. | - -**Frontend files:** - -| File | Responsibility | -|---|---| -| `web/src/lib/player/store.svelte.ts` | Add `radioSeedId` state, `appendRadioTracks()` helper, refresh effect, clear-on-non-radio-enqueue logic. | -| `web/src/lib/player/store.test.ts` | 5 new tests covering refresh behavior. | - ---- - -## Task 1: sqlc query `LoadRadioCandidatesV2` - -**Files:** -- Modify: `internal/db/queries/recommendation.sql` (append) -- Generated: `internal/db/dbq/recommendation.sql.go` - -- [ ] **Step 1: Append the query** - -Append to `internal/db/queries/recommendation.sql`: - -```sql --- name: LoadRadioCandidatesV2 :many --- M4c: similarity-driven candidate pool. 5-way UNION: --- $1 user_id, $2 seed_track_id, $3 recently_played_hours, --- $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K, --- $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K. --- Returns same shape as LoadRadioCandidates plus similarity_score column. - -WITH -seed_artist AS (SELECT artist_id FROM tracks WHERE id = $2), -seed_tags AS ( - SELECT trim(g) AS tag - FROM tracks t, - regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g - WHERE t.id = $2 AND trim(g) <> '' -), -exclude_set AS ( - SELECT unnest($4::uuid[]) AS id - UNION ALL - SELECT track_id FROM play_events - WHERE user_id = $1 AND started_at > now() - $3 * interval '1 hour' -), -lb_similar AS ( - SELECT ts.track_b_id AS id, ts.score AS sim_score - FROM track_similarity ts - WHERE ts.track_a_id = $2 - AND ts.source = 'listenbrainz' - AND ts.track_b_id NOT IN (SELECT id FROM exclude_set) - ORDER BY ts.score DESC - LIMIT $5 -), -similar_artists AS ( - SELECT t.id, asim.score * 0.5 AS sim_score - FROM artist_similarity asim - JOIN tracks t ON t.artist_id = asim.artist_b_id - JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id - WHERE asim.source = 'listenbrainz' - AND t.id NOT IN (SELECT id FROM exclude_set) - ORDER BY asim.score DESC, random() - LIMIT $6 -), -tag_overlap AS ( - SELECT t.id, - (count(DISTINCT trim(g_raw))::float8 - / GREATEST((SELECT count(*) FROM seed_tags), 1)) AS sim_score - FROM tracks t, - regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_raw - WHERE trim(g_raw) IN (SELECT tag FROM seed_tags) - AND t.id NOT IN (SELECT id FROM exclude_set) - AND t.id <> $2 - GROUP BY t.id - HAVING count(DISTINCT trim(g_raw)) > 0 - ORDER BY sim_score DESC - LIMIT $7 -), -likes_overlap AS ( - SELECT gl.track_id AS id, 0.6::float8 AS sim_score - FROM general_likes gl - JOIN tracks t ON t.id = gl.track_id - WHERE gl.user_id = $1 - AND t.id NOT IN (SELECT id FROM exclude_set) - AND EXISTS ( - SELECT 1 FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') g - WHERE trim(g) IN (SELECT tag FROM seed_tags) - ) - ORDER BY random() - LIMIT $8 -), -random_fill AS ( - SELECT t.id, 0.0::float8 AS sim_score - FROM tracks t - WHERE t.id NOT IN (SELECT id FROM exclude_set) - AND t.id <> $2 - AND t.id NOT IN ( - SELECT id FROM lb_similar - UNION SELECT id FROM similar_artists - UNION SELECT id FROM tag_overlap - UNION SELECT id FROM likes_overlap - ) - ORDER BY random() - LIMIT $9 -) -SELECT - sqlc.embed(t), - (l.user_id IS NOT NULL)::bool AS is_liked, - pe.last_played_at::timestamptz AS last_played_at, - pe.play_count, - pe.skip_count, - max(u.sim_score) AS similarity_score -FROM ( - SELECT * FROM lb_similar - UNION ALL SELECT * FROM similar_artists - UNION ALL SELECT * FROM tag_overlap - UNION ALL SELECT * FROM likes_overlap - UNION ALL SELECT * FROM random_fill -) u -JOIN tracks t ON t.id = u.id -LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id -LEFT JOIN LATERAL ( - SELECT max(started_at) AS last_played_at, - count(*) AS play_count, - count(*) FILTER (WHERE was_skipped) AS skip_count - FROM play_events - WHERE user_id = $1 AND track_id = t.id -) pe ON true -GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path, - t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number, - t.mbid, t.genre, t.created_at, t.updated_at, - l.user_id, pe.last_played_at, pe.play_count, pe.skip_count; -``` - -- [ ] **Step 2: Run sqlc generate** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && make generate` - -Expected: `internal/db/dbq/recommendation.sql.go` regenerated with `LoadRadioCandidatesV2(ctx, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error)`. Inspect to confirm: -- The `LoadRadioCandidatesV2Params` struct has 9 fields. sqlc names them after column hints when possible; otherwise positional like `Column3`, `Column4`. Note actual generated names — Task 3 will use them. -- The `LoadRadioCandidatesV2Row` struct has the embedded `Track` plus `IsLiked bool`, `LastPlayedAt pgtype.Timestamptz`, `PlayCount int64`, `SkipCount int64`, `SimilarityScore float64`. - -- [ ] **Step 3: Verify compile** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go build ./...` - -Expected: clean. - -- [ ] **Step 4: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add internal/db/queries/recommendation.sql internal/db/dbq/ -git commit -m "feat(db): add LoadRadioCandidatesV2 sqlc query (5-way UNION)" -``` - ---- - -## Task 2: `Score()` + `RecommendationConfig` extension - -**Files:** -- Modify: `internal/recommendation/score.go` -- Modify: `internal/recommendation/score_test.go` -- Modify: `internal/config/config.go` - -- [ ] **Step 1: Write the failing tests** - -Append to `internal/recommendation/score_test.go`: - -```go -func TestScore_SimilarityScore_PerfectMatchAtWeight2(t *testing.T) { - w := defaultWeights() - w.SimilarityWeight = 2.0 - in := ScoringInputs{SimilarityScore: 1.0} - got := Score(in, w, time.Now(), fixedRNG(0.5)) - // base 1.0 + recency 1.0 (never played) + similarity 2.0 = 4.0 - want := 4.0 - if math.Abs(got-want) > 1e-9 { - t.Errorf("score = %v, want %v", got, want) - } -} - -func TestScore_SimilarityScore_HalfMatchAtWeight2(t *testing.T) { - w := defaultWeights() - w.SimilarityWeight = 2.0 - in := ScoringInputs{SimilarityScore: 0.5} - got := Score(in, w, time.Now(), fixedRNG(0.5)) - // base 1.0 + recency 1.0 + similarity 1.0 = 3.0 - want := 3.0 - if math.Abs(got-want) > 1e-9 { - t.Errorf("score = %v, want %v", got, want) - } -} - -func TestScore_SimilarityScore_ZeroNoEffect(t *testing.T) { - wWithSim := defaultWeights() - wWithSim.SimilarityWeight = 2.0 - withSim := Score(ScoringInputs{SimilarityScore: 0}, wWithSim, time.Now(), fixedRNG(0.5)) - withoutSim := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5)) - if math.Abs(withSim-withoutSim) > 1e-9 { - t.Errorf("score-with-zero-sim = %v, score-without = %v; should be equal", withSim, withoutSim) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/recommendation/ -run Score -v` - -Expected: FAIL — `ScoringInputs` has no `SimilarityScore`, `ScoringWeights` has no `SimilarityWeight`. - -- [ ] **Step 3: Extend `score.go`** - -Replace `internal/recommendation/score.go`: - -```go -// Package recommendation implements the weighted-shuffle scoring engine -// from spec §6. The Score function is pure and takes an injectable RNG so -// tests can pin jitter to deterministic values. -package recommendation - -import ( - "time" -) - -// ScoringInputs are the per-track facts the score function consumes. -// ContextualMatchScore is in [0, 1] — max similarity between the user's -// current session vector and any non-seed contextual_like row for this -// track. SimilarityScore is in [0, 1] — max similarity-source score for -// this candidate (LB-similar / similar-artist × 0.5 / tag-overlap jaccard -// / likes-overlap constant; random fill = 0). Both set by candidate -// loaders before scoring. -type ScoringInputs struct { - IsGeneralLiked bool - LastPlayedAt *time.Time // nil = never played - PlayCount int // total play_events - SkipCount int // play_events with was_skipped=true - ContextualMatchScore float64 // [0, 1]; 0 when no signal - SimilarityScore float64 // [0, 1]; 0 when no signal (M4c) -} - -// ScoringWeights are the operator-tunable knobs. Defaults live in -// config.RecommendationConfig and are propagated here per request. -type ScoringWeights struct { - BaseWeight float64 - LikeBoost float64 - RecencyWeight float64 - SkipPenalty float64 - JitterMagnitude float64 - ContextWeight float64 - SimilarityWeight float64 // M4c -} - -// Score computes the weighted-shuffle score per spec §6: -// -// score = base -// + (is_general_liked ? LikeBoost : 0) -// + recency_decay * RecencyWeight -// - skip_ratio * SkipPenalty -// + contextual_match_score * ContextWeight -// + similarity_score * SimilarityWeight -// + small_random_jitter -// -// Higher score = more likely to surface. rng is a function returning a -// uniform sample in [0,1) — pass math/rand.Float64 in production, a fixed -// value in tests. -func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64) float64 { - s := w.BaseWeight - if in.IsGeneralLiked { - s += w.LikeBoost - } - s += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight - s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty - s += in.ContextualMatchScore * w.ContextWeight - s += in.SimilarityScore * w.SimilarityWeight - s += (rng()*2 - 1) * w.JitterMagnitude - return s -} - -// recencyDecay returns a value in [0, 1]: -// - never played → 1.0 (cold-start tracks compete favorably with stale ones). -// - age < 30 days → linear ramp age_days / 30. -// - age ≥ 30 days → 1.0 (capped). -// -// Negative ages (clock skew) clamp to 0 to avoid math weirdness. -func recencyDecay(lastPlayed *time.Time, now time.Time) float64 { - if lastPlayed == nil { - return 1.0 - } - age := now.Sub(*lastPlayed) - days := age.Hours() / 24 - if days < 0 { - return 0.0 - } - if days >= 30 { - return 1.0 - } - return days / 30.0 -} - -// skipRatio returns skips/plays in [0, 1]; never-played tracks return 0 -// rather than dividing by zero, so they aren't penalized. -func skipRatio(plays, skips int) float64 { - if plays == 0 { - return 0.0 - } - return float64(skips) / float64(plays) -} -``` - -- [ ] **Step 4: Extend `RecommendationConfig`** - -In `internal/config/config.go`, modify `RecommendationConfig`: - -```go -type RecommendationConfig struct { - BaseWeight float64 `yaml:"base_weight"` - LikeBoost float64 `yaml:"like_boost"` - RecencyWeight float64 `yaml:"recency_weight"` - SkipPenalty float64 `yaml:"skip_penalty"` - JitterMagnitude float64 `yaml:"jitter_magnitude"` - ContextWeight float64 `yaml:"context_weight"` - SimilarityWeight float64 `yaml:"similarity_weight"` // M4c - RecentlyPlayedHours int `yaml:"recently_played_hours"` - RadioSize int `yaml:"radio_size"` - RadioSizeMax int `yaml:"radio_size_max"` -} -``` - -In the same file, modify `Default()`'s `Recommendation` block to include the new field: - -```go -Recommendation: RecommendationConfig{ - BaseWeight: 1.0, - LikeBoost: 2.0, - RecencyWeight: 1.0, - SkipPenalty: 1.0, - JitterMagnitude: 0.1, - ContextWeight: 2.0, - SimilarityWeight: 2.0, - RecentlyPlayedHours: 1, - RadioSize: 50, - RadioSizeMax: 200, -}, -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/recommendation/ -run Score -v` - -Expected: PASS for all existing Score tests + 3 new similarity tests. - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go build ./...` - -Expected: clean. - -- [ ] **Step 6: Verify lint clean** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./...` - -Expected: clean. - -- [ ] **Step 7: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add internal/recommendation/score.go internal/recommendation/score_test.go internal/config/config.go -git commit -m "feat(recommendation): extend Score with SimilarityScore + SimilarityWeight" -``` - ---- - -## Task 3: `LoadCandidatesFromSimilarity` Go function + integration tests - -**Files:** -- Modify: `internal/recommendation/candidates.go` (append) -- Create: `internal/recommendation/candidates_v2_test.go` - -- [ ] **Step 1: Write the failing integration tests** - -Create `internal/recommendation/candidates_v2_test.go`: - -```go -package recommendation - -import ( - "context" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// helperLBSimilarity inserts a track_similarity row. -func helperLBSimilarity(t *testing.T, f fixture, a, b pgtype.UUID, score float64) { - t.Helper() - if _, err := f.pool.Exec(context.Background(), - `INSERT INTO track_similarity (track_a_id, track_b_id, score, source) VALUES ($1, $2, $3, 'listenbrainz')`, - a, b, score); err != nil { - t.Fatalf("insert track_similarity: %v", err) - } -} - -// helperArtistSimilarity inserts an artist_similarity row. -func helperArtistSimilarity(t *testing.T, f fixture, a, b pgtype.UUID, score float64) { - t.Helper() - if _, err := f.pool.Exec(context.Background(), - `INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source) VALUES ($1, $2, $3, 'listenbrainz')`, - a, b, score); err != nil { - t.Fatalf("insert artist_similarity: %v", err) - } -} - -// helperSeedTrackWithGenre creates a track with a specified genre + mbid. -func helperSeedTrackWithGenre(t *testing.T, f fixture, title, genre, mbid string) dbq.Track { - t.Helper() - tr, err := f.q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: title, - AlbumID: f.tracks[0].AlbumID, - ArtistID: f.tracks[0].ArtistID, - FilePath: "/tmp/" + title + ".flac", - DurationMs: 180_000, - Genre: &genre, - Mbid: &mbid, - }) - if err != nil { - t.Fatalf("track: %v", err) - } - return tr -} - -func defaultLimits() CandidateSourceLimits { - return DefaultCandidateSourceLimits() -} - -func TestLoadCandidatesFromSimilarity_LBSimilarSourceContributes(t *testing.T) { - f := newFixture(t, 5) - seed := f.tracks[0] - target := f.tracks[1] - helperLBSimilarity(t, f, seed.ID, target.ID, 0.85) - got, err := LoadCandidatesFromSimilarity( - context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), - ) - if err != nil { - t.Fatalf("load: %v", err) - } - var found *Candidate - for i := range got { - if got[i].Track.ID == target.ID { - found = &got[i] - break - } - } - if found == nil { - t.Fatal("LB-similar target missing from candidates") - } - if found.Inputs.SimilarityScore < 0.84 || found.Inputs.SimilarityScore > 0.86 { - t.Errorf("LB-similar SimilarityScore = %v, want ~0.85", found.Inputs.SimilarityScore) - } -} - -func TestLoadCandidatesFromSimilarity_SimilarArtistTracksContribute(t *testing.T) { - f := newFixture(t, 1) // seed only - seed := f.tracks[0] - // Add a second artist + track in that artist; relate the artists via artist_similarity. - otherArtist, _ := f.q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "OtherArtist", SortName: "OtherArtist"}) - otherAlbum, _ := f.q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "OtherAlbum", SortTitle: "OtherAlbum", ArtistID: otherArtist.ID}) - otherTrack, _ := f.q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "OtherTrack", AlbumID: otherAlbum.ID, ArtistID: otherArtist.ID, - FilePath: "/tmp/other.flac", DurationMs: 180_000, - }) - helperArtistSimilarity(t, f, seed.ArtistID, otherArtist.ID, 0.8) - got, err := LoadCandidatesFromSimilarity( - context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), - ) - if err != nil { - t.Fatalf("load: %v", err) - } - for _, c := range got { - if c.Track.ID == otherTrack.ID { - // 0.8 × 0.5 = 0.4 - if c.Inputs.SimilarityScore < 0.39 || c.Inputs.SimilarityScore > 0.41 { - t.Errorf("similar-artist SimilarityScore = %v, want ~0.4 (0.8 × 0.5)", c.Inputs.SimilarityScore) - } - return - } - } - t.Error("similar-artist track missing from candidates") -} - -func TestLoadCandidatesFromSimilarity_TagOverlapContributes(t *testing.T) { - f := newFixture(t, 0) - seed := helperSeedTrackWithGenre(t, f, "Seed", "Rock; Pop", "11111111-1111-1111-1111-111111111111") - target := helperSeedTrackWithGenre(t, f, "Target", "Rock", "22222222-2222-2222-2222-222222222222") - got, err := LoadCandidatesFromSimilarity( - context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), - ) - if err != nil { - t.Fatalf("load: %v", err) - } - for _, c := range got { - if c.Track.ID == target.ID { - // Seed has 2 tags; target shares 1 → jaccard 1/2 = 0.5. - if c.Inputs.SimilarityScore < 0.49 || c.Inputs.SimilarityScore > 0.51 { - t.Errorf("tag-overlap SimilarityScore = %v, want ~0.5", c.Inputs.SimilarityScore) - } - return - } - } - t.Error("tag-overlap target missing from candidates") -} - -func TestLoadCandidatesFromSimilarity_LikesOverlapContributes(t *testing.T) { - f := newFixture(t, 0) - seed := helperSeedTrackWithGenre(t, f, "Seed", "Rock", "11111111-1111-1111-1111-111111111111") - liked := helperSeedTrackWithGenre(t, f, "Liked", "Rock", "22222222-2222-2222-2222-222222222222") - if _, err := f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: liked.ID}); err != nil { - t.Fatalf("like: %v", err) - } - got, err := LoadCandidatesFromSimilarity( - context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), - ) - if err != nil { - t.Fatalf("load: %v", err) - } - for _, c := range got { - if c.Track.ID == liked.ID { - // Could come from tag-overlap (1.0 jaccard) OR likes-overlap (0.6) — max wins. - // Since both tracks are tagged "Rock" identically, jaccard = 1/1 = 1.0, which beats 0.6. - if c.Inputs.SimilarityScore < 0.59 { - t.Errorf("likes-overlap candidate SimilarityScore = %v, want ≥ 0.6", c.Inputs.SimilarityScore) - } - return - } - } - t.Error("liked track with shared tag missing from candidates") -} - -func TestLoadCandidatesFromSimilarity_RandomFillReturnsTracks(t *testing.T) { - f := newFixture(t, 10) // 10 tracks; no similarity data - seed := f.tracks[0] - got, err := LoadCandidatesFromSimilarity( - context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), - ) - if err != nil { - t.Fatalf("load: %v", err) - } - // Random fill should provide at least some of the 9 non-seed tracks. - if len(got) == 0 { - t.Error("random fill returned 0 candidates; expected at least some") - } - // All should have SimilarityScore = 0 (no similarity sources). - for _, c := range got { - if c.Inputs.SimilarityScore != 0 { - t.Errorf("random-fill track %s has SimilarityScore = %v, want 0", c.Track.Title, c.Inputs.SimilarityScore) - } - } -} - -func TestLoadCandidatesFromSimilarity_ExcludeListRespected(t *testing.T) { - f := newFixture(t, 5) - seed := f.tracks[0] - excluded := f.tracks[1].ID - got, err := LoadCandidatesFromSimilarity( - context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, - []pgtype.UUID{excluded}, defaultLimits(), - ) - if err != nil { - t.Fatalf("load: %v", err) - } - for _, c := range got { - if c.Track.ID == excluded { - t.Error("excluded track appeared in candidates") - } - } -} - -func TestLoadCandidatesFromSimilarity_SeedAlwaysExcluded(t *testing.T) { - f := newFixture(t, 5) - seed := f.tracks[0] - got, err := LoadCandidatesFromSimilarity( - context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), - ) - if err != nil { - t.Fatalf("load: %v", err) - } - for _, c := range got { - if c.Track.ID == seed.ID { - t.Error("seed track appeared in candidates") - } - } -} - -func TestLoadCandidatesFromSimilarity_RecentlyPlayedExcluded(t *testing.T) { - f := newFixture(t, 5) - seed := f.tracks[0] - recent := f.tracks[1].ID - // Insert a play_event within the last hour. - var sessionID pgtype.UUID - if err := f.pool.QueryRow(context.Background(), - `INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id) - VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`, - f.user).Scan(&sessionID); err != nil { - t.Fatalf("session: %v", err) - } - if _, err := f.pool.Exec(context.Background(), - `INSERT INTO play_events (user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped) - VALUES ($1, $2, $3, now() - interval '30 minutes', now() - interval '20 minutes', 200000, 0.9, false)`, - f.user, recent, sessionID); err != nil { - t.Fatalf("play_event: %v", err) - } - got, err := LoadCandidatesFromSimilarity( - context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), - ) - if err != nil { - t.Fatalf("load: %v", err) - } - for _, c := range got { - if c.Track.ID == recent { - t.Error("recently-played track appeared in candidates") - } - } -} - -func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) { - f := newFixture(t, 0) - seed := helperSeedTrackWithGenre(t, f, "Seed", "Rock", "11111111-1111-1111-1111-111111111111") - // Target shares the LB-similar source (high score) AND tag-overlap (low score). - target := helperSeedTrackWithGenre(t, f, "Target", "Rock", "22222222-2222-2222-2222-222222222222") - helperLBSimilarity(t, f, seed.ID, target.ID, 0.95) - got, err := LoadCandidatesFromSimilarity( - context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), - ) - if err != nil { - t.Fatalf("load: %v", err) - } - count := 0 - for _, c := range got { - if c.Track.ID == target.ID { - count++ - // LB 0.95 should win over tag-overlap (jaccard 1/1 = 1.0). Wait — 1.0 > 0.95. Adjust: - // seed and target both have only "Rock" tag → jaccard = 1.0 from tag-overlap. - // LB sim = 0.95. Max = 1.0. So this test verifies max() picks tag-overlap here. - if c.Inputs.SimilarityScore < 0.99 { - t.Errorf("dedup max SimilarityScore = %v, want 1.0 (tag-overlap wins)", c.Inputs.SimilarityScore) - } - } - } - if count != 1 { - t.Errorf("target appeared %d times, want 1 (dedup failed)", count) - } -} - -func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) { - f := newFixture(t, 0) - // Seed an isolated track to query against. - seed := helperSeedTrackWithGenre(t, f, "Seed", "Rock", "11111111-1111-1111-1111-111111111111") - got, err := LoadCandidatesFromSimilarity( - context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), - ) - if err != nil { - t.Fatalf("load: %v", err) - } - if len(got) != 0 { - t.Errorf("got %d candidates from empty library, want 0", len(got)) - } - _ = time.Now() // silence unused-import warning if loop never runs -} -``` - -The `fixture` and `newFixture` helpers come from `candidates_test.go` (existing M3 fixture). If `newFixture(t, 0)` doesn't exist (i.e. it requires ≥1 track), peek at the existing helper's signature and adjust the test to use the smallest legal value. - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/recommendation/ -run LoadCandidatesFromSimilarity -v` - -Expected: FAIL — `undefined: LoadCandidatesFromSimilarity`, `undefined: CandidateSourceLimits`, `undefined: DefaultCandidateSourceLimits`. - -- [ ] **Step 3: Implement `LoadCandidatesFromSimilarity`** - -Append to `internal/recommendation/candidates.go`: - -```go -// CandidateSourceLimits controls per-source K values for the M4c -// similarity-driven pool. Defaults via DefaultCandidateSourceLimits(). -type CandidateSourceLimits struct { - LBSimilar int - SimilarArtist int - TagOverlap int - LikesOverlap int - RandomFill int -} - -// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec. -func DefaultCandidateSourceLimits() CandidateSourceLimits { - return CandidateSourceLimits{ - LBSimilar: 30, - SimilarArtist: 30, - TagOverlap: 20, - LikesOverlap: 20, - RandomFill: 30, - } -} - -// LoadCandidatesFromSimilarity is M4c's primary candidate-pool loader. -// 5-way SQL UNION (LB-similar / similar-artist tracks / MB-tag overlap / -// likes-overlap / random fill) + dedup-by-max sim_score. Returns -// []Candidate (same shape as LoadCandidates) so Shuffle is unchanged. -// -// Caller (radio handler) falls back to LoadCandidates on error. -func LoadCandidatesFromSimilarity( - ctx context.Context, - q *dbq.Queries, - userID, seedID pgtype.UUID, - recentlyPlayedHours int, - currentVector SessionVector, - exclude []pgtype.UUID, - limits CandidateSourceLimits, -) ([]Candidate, error) { - if exclude == nil { - exclude = []pgtype.UUID{} - } - rows, err := q.LoadRadioCandidatesV2(ctx, dbq.LoadRadioCandidatesV2Params{ - UserID: userID, - ID: seedID, - Column3: float64(recentlyPlayedHours), - Column4: exclude, - Column5: int32(limits.LBSimilar), - Column6: int32(limits.SimilarArtist), - Column7: int32(limits.TagOverlap), - Column8: int32(limits.LikesOverlap), - Column9: int32(limits.RandomFill), - }) - if err != nil { - return nil, err - } - - likes, err := loadContextualLikesByTrack(ctx, q, userID) - if err != nil { - return nil, err - } - - out := make([]Candidate, 0, len(rows)) - for _, r := range rows { - var lpt *time.Time - if r.LastPlayedAt.Valid { - t := r.LastPlayedAt.Time - lpt = &t - } - ctxScore := ContextualMatchScore(currentVector, likes[r.Track.ID], DefaultSimilarityWeights) - out = append(out, Candidate{ - Track: r.Track, - Inputs: ScoringInputs{ - IsGeneralLiked: r.IsLiked, - LastPlayedAt: lpt, - PlayCount: int(r.PlayCount), - SkipCount: int(r.SkipCount), - ContextualMatchScore: ctxScore, - SimilarityScore: r.SimilarityScore, - }, - }) - } - return out, nil -} -``` - -**Note on sqlc-generated param field names:** the `LoadRadioCandidatesV2Params` field names depend on sqlc's auto-generated names from the SQL parameter positions. After Task 1's `make generate`, inspect `internal/db/dbq/recommendation.sql.go` and adjust the field references (`UserID`, `ID`, `Column3`, etc.) to match what sqlc actually emitted. The actual names may be `Column3` for `$3` (recently_played_hours), `Column4` for `$4` (exclude), etc., or sqlc may pick smarter names from context. Rename to match. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/recommendation/ -run LoadCandidatesFromSimilarity -v` - -Expected: PASS for all 10 tests. - -- [ ] **Step 5: Verify lint clean** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./internal/recommendation/...` - -Expected: clean. - -- [ ] **Step 6: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add internal/recommendation/candidates.go internal/recommendation/candidates_v2_test.go -git commit -m "feat(recommendation): add LoadCandidatesFromSimilarity (5-source candidate pool)" -``` - ---- - -## Task 4: Radio handler — exclude param + similarity pool + fallback - -**Files:** -- Modify: `internal/api/radio.go` -- Modify: `internal/api/radio_test.go` -- Modify: `internal/api/auth_test.go` (testHandlers' `recCfg` literal) - -- [ ] **Step 1: Write the failing tests** - -Append to `internal/api/radio_test.go`: - -```go -func TestHandleRadio_WithSimilarityPool_RanksLBSimilarHigher(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) - // Two non-seed tracks: one LB-similar (high score), one not. - target := seedTrack(t, pool, album.ID, artist.ID, "Target", 2, 100_000) - control := seedTrack(t, pool, album.ID, artist.ID, "Control", 3, 100_000) - if _, err := pool.Exec(context.Background(), - `INSERT INTO track_similarity (track_a_id, track_b_id, score, source) VALUES ($1, $2, 0.95, 'listenbrainz')`, - seed.ID, target.ID); err != nil { - t.Fatalf("insert sim: %v", err) - } - w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=3") - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var resp RadioResponse - _ = json.Unmarshal(w.Body.Bytes(), &resp) - // resp.Tracks[0] is seed; check whether target ranks above control. - var targetIdx, controlIdx int - targetIdx, controlIdx = -1, -1 - for i, tr := range resp.Tracks { - if tr.ID == uuidToString(target.ID) { - targetIdx = i - } - if tr.ID == uuidToString(control.ID) { - controlIdx = i - } - } - if targetIdx < 0 || controlIdx < 0 { - t.Fatalf("target=%d control=%d (one not present)", targetIdx, controlIdx) - } - if targetIdx >= controlIdx { - t.Errorf("target ranked at %d, control at %d — LB-similar should rank higher", targetIdx, controlIdx) - } -} - -func TestHandleRadio_ExcludeParamFiltersOut(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) - excluded := seedTrack(t, pool, album.ID, artist.ID, "Excluded", 2, 100_000) - for i := 3; i <= 6; i++ { - seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000) - } - q := "seed_track=" + uuidToString(seed.ID) + "&limit=10&exclude=" + uuidToString(excluded.ID) - w := callRadio(h, user, q) - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var resp RadioResponse - _ = json.Unmarshal(w.Body.Bytes(), &resp) - for _, tr := range resp.Tracks { - if tr.ID == uuidToString(excluded.ID) { - t.Error("excluded track present in response") - } - } -} - -func TestHandleRadio_ExcludeParamMalformedSkipped(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) - other := seedTrack(t, pool, album.ID, artist.ID, "Other", 2, 100_000) - q := "seed_track=" + uuidToString(seed.ID) + "&limit=5&exclude=not-a-uuid," + uuidToString(other.ID) - w := callRadio(h, user, q) - if w.Code != http.StatusOK { - t.Fatalf("status = %d (malformed UUID should be silently dropped)", w.Code) - } - var resp RadioResponse - _ = json.Unmarshal(w.Body.Bytes(), &resp) - for _, tr := range resp.Tracks { - if tr.ID == uuidToString(other.ID) { - t.Error("other track should still be excluded after dropping malformed entry") - } - } -} - -func TestHandleRadio_SeedAlwaysAtIndex0(t *testing.T) { - // Defensive: even when the similarity pool returns no candidates, - // the seed track must still be the first track in the response. - h, pool := testHandlers(t) - truncateLibrary(t, pool) - user := seedUser(t, pool, "alice", "x", false) - artist := seedArtist(t, pool, "X") - album := seedAlbum(t, pool, artist.ID, "X", 1990) - seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) - w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)) - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var resp RadioResponse - _ = json.Unmarshal(w.Body.Bytes(), &resp) - if len(resp.Tracks) == 0 || resp.Tracks[0].ID != uuidToString(seed.ID) { - t.Errorf("seed not at index 0: %v", resp.Tracks) - } -} -``` - -- [ ] **Step 2: Update `auth_test.go::testHandlers` recCfg literal** - -In `internal/api/auth_test.go`, find the `recCfg := config.RecommendationConfig{...}` literal in `testHandlers` and add `SimilarityWeight: 2.0`: - -```go -recCfg := config.RecommendationConfig{ - BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0, - SkipPenalty: 1.0, JitterMagnitude: 0.1, - ContextWeight: 2.0, SimilarityWeight: 2.0, - RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, -} -``` - -- [ ] **Step 3: Update the radio handler** - -In `internal/api/radio.go`, replace the `LoadCandidates` call site. Before it, parse the exclude param + build the limits struct + add the seed to the exclude list. Keep the M3 `LoadCandidates` call as a fallback path. - -Replace the section from `currentVec := loadCurrentSessionVector(...)` through `weights := recommendation.ScoringWeights{...}`: - -```go - currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger) - - exclude := parseExcludeParam(r.URL.Query().Get("exclude")) - limits := recommendation.DefaultCandidateSourceLimits() - candidates, err := recommendation.LoadCandidatesFromSimilarity( - r.Context(), q, user.ID, seedID, - h.recCfg.RecentlyPlayedHours, currentVec, exclude, limits, - ) - if err != nil { - h.logger.Warn("api: radio: similarity-pool failed; falling back to whole-library", "err", err) - candidates, err = recommendation.LoadCandidates( - r.Context(), q, user.ID, seedID, - h.recCfg.RecentlyPlayedHours, currentVec, - ) - if err != nil { - h.logger.Error("api: radio: load candidates fallback failed", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "candidate load failed") - return - } - } - - weights := recommendation.ScoringWeights{ - BaseWeight: h.recCfg.BaseWeight, - LikeBoost: h.recCfg.LikeBoost, - RecencyWeight: h.recCfg.RecencyWeight, - SkipPenalty: h.recCfg.SkipPenalty, - JitterMagnitude: h.recCfg.JitterMagnitude, - ContextWeight: h.recCfg.ContextWeight, - SimilarityWeight: h.recCfg.SimilarityWeight, - } -``` - -Append the helper at the bottom of the same file: - -```go -// parseExcludeParam parses a comma-separated list of UUIDs from the -// `exclude` query string, silently dropping malformed entries. Returns -// nil for empty or all-malformed input. -func parseExcludeParam(raw string) []pgtype.UUID { - if raw == "" { - return nil - } - parts := strings.Split(raw, ",") - out := make([]pgtype.UUID, 0, len(parts)) - for _, p := range parts { - p = strings.TrimSpace(p) - if p == "" { - continue - } - id, ok := parseUUID(p) - if !ok { - continue - } - out = append(out, id) - } - return out -} -``` - -`parseUUID` already exists in `internal/api/`. Confirm that `strings` is in the import block (it should already be). - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/api/ -v 2>&1 | tail -30` - -Expected: PASS for all existing API tests + 4 new radio tests. - -- [ ] **Step 5: Verify lint clean** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./...` - -Expected: clean. - -- [ ] **Step 6: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add internal/api/radio.go internal/api/radio_test.go internal/api/auth_test.go -git commit -m "feat(api): radio uses similarity pool with exclude param + M3 fallback" -``` - ---- - -## Task 5: Frontend — radio queue refresh at 80% - -**Files:** -- Modify: `web/src/lib/player/store.svelte.ts` -- Modify: `web/src/lib/player/store.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Append to `web/src/lib/player/store.test.ts`: - -```ts -describe('radio refresh at 80%', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - test('refresh fires at 80% queue consumption', async () => { - const tracks: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({ - id: `t${i}`, title: `T${i}`, album_id: 'al', album_title: 'Al', - artist_id: 'ar', artist_name: 'Ar', track_number: i + 1, disc_number: 1, - duration_sec: 100, stream_url: `/api/tracks/t${i}/stream` - })); - (apiGet as ReturnType).mockResolvedValueOnce({ tracks }); - await playRadio('seed1'); - // Now queue is the 5 tracks. Reset mock for refresh call. - (apiGet as ReturnType).mockClear(); - (apiGet as ReturnType).mockResolvedValueOnce({ - tracks: [tracks[0], { ...tracks[0], id: 'new1', title: 'New1' }] - }); - // Advance to index 4 (5th of 5 = 100%, but cross 80% at index 3) - // Skip to index 3 (4th track played; 4/5 = 80%) - skipNext(); skipNext(); skipNext(); - // Allow $effect to fire - await new Promise((r) => setTimeout(r, 10)); - expect(apiGet).toHaveBeenCalled(); - const callArg = (apiGet as ReturnType).mock.calls[0][0]; - expect(callArg).toContain('seed_track=seed1'); - expect(callArg).toContain('exclude='); - }); - - test('refresh below 80% does not fire', async () => { - const tracks: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({ - id: `t${i}`, title: `T${i}`, album_id: 'al', album_title: 'Al', - artist_id: 'ar', artist_name: 'Ar', track_number: i + 1, disc_number: 1, - duration_sec: 100, stream_url: `/api/tracks/t${i}/stream` - })); - (apiGet as ReturnType).mockResolvedValueOnce({ tracks }); - await playRadio('seed1'); - (apiGet as ReturnType).mockClear(); - skipNext(); skipNext(); // index = 2 (3 of 5 = 60%) - await new Promise((r) => setTimeout(r, 10)); - expect(apiGet).not.toHaveBeenCalled(); - }); - - test('refresh appends new tracks excluding seed at index 0', async () => { - const initial: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({ - id: `t${i}`, title: `T${i}`, album_id: 'al', album_title: 'Al', - artist_id: 'ar', artist_name: 'Ar', track_number: i + 1, disc_number: 1, - duration_sec: 100, stream_url: `/api/tracks/t${i}/stream` - })); - (apiGet as ReturnType).mockResolvedValueOnce({ tracks: initial }); - await playRadio('seed1'); - // Refresh response: 5 tracks where the first is the seed (will be stripped). - const refreshTracks: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({ - id: i === 0 ? initial[0].id : `n${i}`, - title: i === 0 ? initial[0].title : `N${i}`, - album_id: 'al', album_title: 'Al', artist_id: 'ar', artist_name: 'Ar', - track_number: i + 1, disc_number: 1, duration_sec: 100, - stream_url: '/api/tracks/x/stream' - })); - (apiGet as ReturnType).mockResolvedValueOnce({ tracks: refreshTracks }); - skipNext(); skipNext(); skipNext(); // index = 3 (80%) - await new Promise((r) => setTimeout(r, 10)); - // Initial 5 + 4 new (5 in response - 1 seed stripped) = 9 - expect(player.queue.length).toBe(9); - }); - - test('refresh does NOT double-fire while in-flight', async () => { - const tracks: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({ - id: `t${i}`, title: `T${i}`, album_id: 'al', album_title: 'Al', - artist_id: 'ar', artist_name: 'Ar', track_number: i + 1, disc_number: 1, - duration_sec: 100, stream_url: `/api/tracks/t${i}/stream` - })); - (apiGet as ReturnType).mockResolvedValueOnce({ tracks }); - await playRadio('seed1'); - (apiGet as ReturnType).mockClear(); - // Hang the refresh call so the in-flight flag stays set. - let resolveFn!: (v: unknown) => void; - const hanging = new Promise((r) => { resolveFn = r; }); - (apiGet as ReturnType).mockReturnValueOnce(hanging); - skipNext(); skipNext(); skipNext(); // 80% — fires refresh - await new Promise((r) => setTimeout(r, 10)); - // Now advance further while in-flight; should NOT fire a second call. - skipNext(); - await new Promise((r) => setTimeout(r, 10)); - expect(apiGet).toHaveBeenCalledTimes(1); - resolveFn({ tracks: [] }); - }); - - test('refresh resets when user enqueues from non-radio source', async () => { - const tracks: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({ - id: `t${i}`, title: `T${i}`, album_id: 'al', album_title: 'Al', - artist_id: 'ar', artist_name: 'Ar', track_number: i + 1, disc_number: 1, - duration_sec: 100, stream_url: `/api/tracks/t${i}/stream` - })); - (apiGet as ReturnType).mockResolvedValueOnce({ tracks }); - await playRadio('seed1'); - enqueueTrack({ - id: 'manual1', title: 'Manual', album_id: 'al', album_title: 'Al', - artist_id: 'ar', artist_name: 'Ar', track_number: 99, disc_number: 1, - duration_sec: 100, stream_url: '/api/tracks/manual1/stream' - }); - (apiGet as ReturnType).mockClear(); - skipNext(); skipNext(); skipNext(); skipNext(); // advance past 80% on the now-6-track queue - await new Promise((r) => setTimeout(r, 10)); - expect(apiGet).not.toHaveBeenCalled(); - }); -}); -``` - -The existing test setup may already mock `$lib/api/client` (where `apiGet` lives) — match the existing pattern in the file. If `apiGet` isn't currently mocked, add a mock at the top of `store.test.ts`: - -```ts -vi.mock('$lib/api/client', () => ({ - api: { get: vi.fn(), put: vi.fn(), post: vi.fn(), del: vi.fn() } -})); -import { api } from '$lib/api/client'; -const apiGet = api.get; -``` - -Match the existing import-style in this test file; if it already imports from `$lib/api/client`, reuse that. - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm test -- --run src/lib/player/store 2>&1 | tail -10` - -Expected: most/all of the new tests fail because the refresh logic doesn't exist yet. - -- [ ] **Step 3: Modify the player store** - -In `web/src/lib/player/store.svelte.ts`, find the existing `playRadio` function and: - -1. Add a module-level `radioSeedId` state variable -2. Add a `radioRefreshInFlight` flag -3. Modify `playRadio` to set `radioSeedId` -4. Modify `playQueue`, `enqueueTrack`, `enqueueTracks` to clear `radioSeedId` (these are explicit non-radio enqueues) -5. Add an `$effect` watching consumption ratio; trigger refresh when conditions met - -Replace the existing `playRadio` and the surrounding state declarations: - -```ts -let _queue = $state([]); -let _index = $state(0); -let _state = $state('idle'); -let _position = $state(0); -let _duration = $state(0); -let _volume = $state(readStoredVolume()); -let _shuffle = $state(false); -let _repeat = $state('off'); -let _error = $state(null); - -// M4c: track when the queue was seeded by a radio call so we can -// auto-refresh at 80% consumption. Cleared when the user enqueues -// from a non-radio source. -let _radioSeedId = $state(null); -let _radioRefreshInFlight = false; -``` - -Modify `playQueue`, `enqueueTrack`, `enqueueTracks` to clear `_radioSeedId`. Existing code: - -```ts -export function playQueue(tracks: TrackRef[], startIndex = 0): void { - _queue = tracks; - // ... existing logic ... -} -``` - -Add `_radioSeedId = null;` as the first line in `playQueue`, `enqueueTrack`, and `enqueueTracks`. - -Replace `playRadio`: - -```ts -export async function playRadio(seedTrackId: string): Promise { - const resp = await api.get( - `/api/radio?seed_track=${encodeURIComponent(seedTrackId)}` - ); - if (resp.tracks.length === 0) return; - // playQueue clears _radioSeedId; set it back AFTER playQueue finishes. - playQueue(resp.tracks, 0); - _radioSeedId = seedTrackId; -} -``` - -Add the auto-refresh effect. Place after the state declarations (effects are top-level in `.svelte.ts` files): - -```ts -$effect(() => { - if (_radioSeedId === null) return; - if (_radioRefreshInFlight) return; - if (_queue.length === 0) return; - const consumedRatio = (_index + 1) / _queue.length; - if (consumedRatio < 0.8) return; - - _radioRefreshInFlight = true; - const seed = _radioSeedId; - const exclude = _queue.map((t) => t.id).join(','); - api - .get( - `/api/radio?seed_track=${encodeURIComponent(seed)}&exclude=${exclude}` - ) - .then((resp) => { - // Strip the seed at index 0 (already in our queue) and append the rest. - const newTracks = resp.tracks.slice(1).filter((t) => t.id !== seed); - if (newTracks.length > 0) { - // Append without clearing radio state — this IS a radio refresh. - _queue = [..._queue, ...newTracks]; - } - }) - .catch(() => { - // Swallow; next track-advance can retry. - }) - .finally(() => { - _radioRefreshInFlight = false; - }); -}); -``` - -Note: the existing code uses `_queue = [..._queue, ...newTracks]` style (immutable append) consistent with `enqueueTrack`/`enqueueTracks`. We don't call `enqueueTracks` here because it would clear `_radioSeedId`. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm test -- --run src/lib/player/store 2>&1 | tail -10` - -Expected: PASS for all existing tests + 5 new refresh tests. - -- [ ] **Step 5: Verify svelte-check + build** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run check 2>&1 | tail -3` - -Expected: 0 errors, 0 warnings. - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run build 2>&1 | tail -5` - -Expected: adapter-static emits `web/build/`. - -- [ ] **Step 6: Commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -git add web/ -git commit -m "feat(web): radio queue auto-refreshes at 80% via ?exclude= param" -``` - ---- - -## Task 6: Final verification + branch finish - -**Files:** none (verification only) - -- [ ] **Step 1: Full Go test suite (-short -race)** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test -short -race ./...` - -Expected: PASS across all 16 packages. - -- [ ] **Step 2: Full integration suite** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -race -p 1 ./...` - -Expected: PASS apart from the pre-existing `TestScanner_Integration` flake (CI runs `-short` which skips it). - -- [ ] **Step 3: Lint** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./...` - -Expected: clean. - -- [ ] **Step 4: Coverage check** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -p 1 -coverprofile=/tmp/cover-m4c.out ./internal/recommendation/... ./internal/api/ && go tool cover -func=/tmp/cover-m4c.out | tail -5` - -Expected: `internal/recommendation` ≥ 80% (was 73% pre-M4c); `internal/api/radio.go` should be well-covered by the existing radio tests + 4 new ones. - -- [ ] **Step 5: Web verification** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run check && npm test -- --run && npm run build` - -Expected: svelte-check 0/0; vitest passes (180 + 5 = 185); build succeeds. - -- [ ] **Step 6: Docker smoke** - -Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && docker build -t minstrel:m4c-smoke .` - -Expected: container builds. - -- [ ] **Step 7: Manual end-to-end gate (closes M4)** - -Set up: -1. `docker compose up --build -d minstrel` — pick up new code -2. Wait for M4b's similarity worker to fill `track_similarity` for ≥10 played tracks (≥1 hour of uptime + previously-played tracks, OR seed manually for testing) -3. In SPA: click radio from a played track. Queue should differ noticeably from M3's output. -4. Listen through ~80% of the queue. Observe queue length increases as auto-refresh fires (browser DevTools network tab shows the `/api/radio?seed_track=…&exclude=…` request). -5. `psql -c "SELECT count(*) FROM track_similarity WHERE source = 'listenbrainz'"` shows non-trivial similarity data. -6. Subjectively: radio quality should feel meaningfully better than M3's baseline. - -This is the **closing gate for M4**. - -- [ ] **Step 8: Update Fable task #347** - -After PR opens, mark `in_progress`. After PR merges, mark `done` with the closing summary in the body. Note that #347 closes the M4 milestone. - -- [ ] **Step 9: Finishing the branch** - -**REQUIRED SUB-SKILL:** Use `superpowers:finishing-a-development-branch` to verify tests, present completion options, and execute the user's choice. - -Per established cadence: this slice will land as a single-purpose PR. After merge, M4 milestone is fully closed; M5 (Lidarr quarantine + suggested-additions for tracks not in library) becomes the natural next milestone. - ---- - -## Self-Review - -**Spec coverage:** -- §3 architecture overview → Tasks 1, 3, 4, 5 ✓ -- §4 candidate pool SQL → Task 1 ✓ -- §5 Score() formula extension → Task 2 ✓ -- §6 Go-side wiring → Tasks 3, 4 ✓ -- §7 frontend queue refresh → Task 5 ✓ -- §8 test plan → embedded across Tasks 2-5; verified in Task 6 ✓ -- §9 decisions ledger → all 7 baked into the plan defaults ✓ -- §10 backwards compatibility → preserved by zero-default new fields + LoadCandidates retained ✓ - -No gaps. - -**Placeholder scan:** No "TBD"/"TODO" content. The "use whatever sqlc emits at make generate time" guidance in Task 3 is a known sqlc-specific instruction, not a placeholder. - -**Type consistency:** -- `ScoringInputs.SimilarityScore` and `ScoringWeights.SimilarityWeight` defined in Task 2; consumed in Tasks 3 and 4. -- `CandidateSourceLimits` defined in Task 3; consumed in Task 4. -- `LoadCandidatesFromSimilarity` signature matches across Tasks 3 and 4. -- `parseExcludeParam` signature consistent with `[]pgtype.UUID` return type. -- Frontend: `_radioSeedId`, `_radioRefreshInFlight` declared at the top of Task 5 step 3; effect references them; `playRadio`/`playQueue`/etc. mutate them consistently. -- sqlc-generated `LoadRadioCandidatesV2Params` field names: deferred to implementation time (sqlc's auto-naming is positional unless column hints help; the Task 3 code uses `Column3..Column9` placeholders the implementer adjusts after `make generate`). diff --git a/docs/superpowers/plans/2026-04-29-m5a-lidarr.md b/docs/superpowers/plans/2026-04-29-m5a-lidarr.md deleted file mode 100644 index f14e7cf0..00000000 --- a/docs/superpowers/plans/2026-04-29-m5a-lidarr.md +++ /dev/null @@ -1,1979 +0,0 @@ -# M5a — Lidarr connection + search/add + admin shell — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Wire Minstrel to a household Lidarr instance — search at `/discover`, request from any user, admin moderation queue at `/admin/requests`, automatic reconciliation when downloaded tracks land in the library. - -**Architecture:** New `internal/lidarr` HTTP client (typed, mirrors `internal/scrobble/listenbrainz`); `internal/lidarrconfig` singleton service backed by a CHECK-constrained DB row; `internal/lidarrrequests` with synchronous `Service` (Approve calls Lidarr, fires scan) plus async `Reconciler` worker (5-min tick, joins approved requests against new tracks by MBID); new `RequireAdmin` middleware on a dedicated `/api/admin/*` route group; SPA gets `/discover`, `/requests`, `/admin/integrations`, `/admin/requests` with hard route-level role gate, all surfaces drawn against the FabledSword design system. - -**Tech Stack:** Go 1.23 · chi router · pgx/v5 + sqlc · Postgres + golang-migrate · SvelteKit 2 / Svelte 5 (runes) · TanStack Query · Vitest · golangci-lint · FabledSword design tokens (Obsidian/Iron surfaces, Moss/Bronze/Oxblood actions, forest-teal #4A6B5C accent, Fraunces ≥18px / Inter / JetBrains Mono). - -**Spec:** [`docs/superpowers/specs/2026-04-29-m5a-lidarr-design.md`](../specs/2026-04-29-m5a-lidarr-design.md). Read it before starting — every decision is explained there. - -**Memory dependencies:** `project_design_system.md` (token palette + voice rules), `project_product_not_project.md` (no YAML for feature config), `project_ui_quality.md` (no scaffolding-feel), `project_subsonic_legacy.md` (`/api/*` is primary), `project_no_github.md` (Forgejo MCP for PR ops, not gh CLI). - ---- - -## File map - -### Backend — create - -- `internal/db/migrations/0010_lidarr.up.sql` · `0010_lidarr.down.sql` — schema -- `internal/db/queries/lidarr_config.sql` — sqlc queries for the singleton -- `internal/db/queries/lidarr_requests.sql` — sqlc queries for requests -- `internal/lidarr/client.go` — Lidarr HTTP client (`Client` struct + methods) -- `internal/lidarr/types.go` — typed request/response structs -- `internal/lidarr/errors.go` — typed sentinel errors -- `internal/lidarr/client_test.go` — unit tests with `httptest` stubs + JSON fixtures -- `internal/lidarr/testdata/*.json` — captured Lidarr responses -- `internal/lidarrconfig/service.go` — singleton config wrapper -- `internal/lidarrconfig/service_test.go` — integration test against `MINSTREL_TEST_DATABASE_URL` -- `internal/lidarrrequests/service.go` — request lifecycle service -- `internal/lidarrrequests/service_test.go` — integration test -- `internal/lidarrrequests/reconciler.go` — background worker -- `internal/lidarrrequests/reconciler_integration_test.go` — integration test -- `internal/auth/admin.go` — `RequireAdmin` middleware -- `internal/auth/admin_test.go` — middleware tests -- `internal/api/lidarr.go` — `GET /api/lidarr/search` proxy -- `internal/api/lidarr_test.go` -- `internal/api/requests.go` — `/api/requests` user-facing CRUD -- `internal/api/requests_test.go` -- `internal/api/admin_lidarr.go` — `/api/admin/lidarr/*` (config CRUD + test + profiles + folders) -- `internal/api/admin_lidarr_test.go` -- `internal/api/admin_requests.go` — `/api/admin/requests/*` approval queue -- `internal/api/admin_requests_test.go` - -### Backend — modify - -- `internal/api/api.go` — register routes, mount `/api/admin` group -- `internal/api/auth_test.go` — extend `testHandlers` to inject Lidarr client + lidarrrequests service -- `cmd/minstrel/main.go` — wire `lidarrrequests.Reconciler` worker -- `internal/db/dbq/*` — regenerated by `sqlc generate` - -### Frontend — create - -- `web/src/lib/styles/fabledsword-tokens.css` — `:root` CSS custom properties for all FS tokens -- `web/tailwind.config.js` — extend theme to alias semantic Tailwind utilities to FS tokens (modify, not create — but this slice may need to drop the existing alias defaults) -- `web/src/lib/api/lidarr.ts` — search client -- `web/src/lib/api/requests.ts` — request CRUD client -- `web/src/lib/api/admin.ts` — admin endpoints client -- `web/src/lib/components/DiscoverResultCard.svelte` — card with reserved badge slot + anchored button -- `web/src/lib/components/DiscoverResultCard.test.ts` -- `web/src/lib/components/StatusPill.svelte` — semantic status pill (Pending/Approved/Completed/Rejected) -- `web/src/lib/components/StatusPill.test.ts` -- `web/src/lib/components/AdminSidebar.svelte` — admin nav rail -- `web/src/routes/discover/+page.svelte` -- `web/src/routes/discover/discover.test.ts` -- `web/src/routes/requests/+page.svelte` -- `web/src/routes/requests/requests.test.ts` -- `web/src/routes/admin/+layout.svelte` — admin shell + sidebar -- `web/src/routes/admin/+layout.ts` — role gate (load function) -- `web/src/routes/admin/+page.svelte` — overview landing -- `web/src/routes/admin/integrations/+page.svelte` -- `web/src/routes/admin/integrations/integrations.test.ts` -- `web/src/routes/admin/requests/+page.svelte` -- `web/src/routes/admin/requests/requests.test.ts` - -### Frontend — modify - -- `web/src/lib/components/Shell.svelte` — add `/discover` to nav, conditional `/admin` link for admins -- `web/src/app.css` (or equivalent) — import the tokens file -- `web/src/app.html` — `` Google Fonts (Fraunces / Inter / JetBrains Mono) - ---- - -## Task list - -### Task 1 — Migration 0010 + sqlc queries - -**Files:** -- Create: `internal/db/migrations/0010_lidarr.up.sql` -- Create: `internal/db/migrations/0010_lidarr.down.sql` -- Create: `internal/db/queries/lidarr_config.sql` -- Create: `internal/db/queries/lidarr_requests.sql` -- Modify: `internal/db/dbq/*` (regenerated by `sqlc generate`) - -- [ ] **Step 1.1: Write the up migration** - -`internal/db/migrations/0010_lidarr.up.sql`: - -```sql --- M5a: Lidarr integration foundation. Two tables: --- --- lidarr_config — singleton (CHECK id=1) holding the operator's Lidarr --- connection. enabled=false is the unconfigured state. --- --- lidarr_requests — per-request lifecycle row created by users at --- /discover, transitioned by admin at /admin/requests, and matched --- back to library tracks by the reconciler worker. Three matched_*_id --- FKs (one per kind) instead of polymorphic — clean SQL, ON DELETE --- SET NULL preserves audit even if the matched track is later removed. - -CREATE TABLE lidarr_config ( - id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1), - enabled boolean NOT NULL DEFAULT false, - base_url text, - api_key text, - default_quality_profile_id int, - default_root_folder_path text, - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now() -); - -INSERT INTO lidarr_config (id, enabled) VALUES (1, false); - -CREATE TYPE lidarr_request_status AS ENUM ( - 'pending', 'approved', 'rejected', 'completed', 'failed' -); -CREATE TYPE lidarr_request_kind AS ENUM ('artist', 'album', 'track'); - -CREATE TABLE lidarr_requests ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, - status lidarr_request_status NOT NULL DEFAULT 'pending', - kind lidarr_request_kind NOT NULL, - - lidarr_artist_mbid text NOT NULL, - lidarr_album_mbid text, - lidarr_track_mbid text, - artist_name text NOT NULL, - album_title text, - track_title text, - - quality_profile_id int, - root_folder_path text, - - decided_at timestamptz, - decided_by uuid REFERENCES users(id) ON DELETE SET NULL, - notes text, - - completed_at timestamptz, - matched_track_id uuid REFERENCES tracks(id) ON DELETE SET NULL, - matched_album_id uuid REFERENCES albums(id) ON DELETE SET NULL, - matched_artist_id uuid REFERENCES artists(id) ON DELETE SET NULL, - - requested_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now() -); - -CREATE INDEX lidarr_requests_user_id_idx ON lidarr_requests (user_id); -CREATE INDEX lidarr_requests_status_idx ON lidarr_requests (status); -CREATE INDEX lidarr_requests_artist_mbid_idx ON lidarr_requests (lidarr_artist_mbid); -CREATE INDEX lidarr_requests_album_mbid_idx ON lidarr_requests (lidarr_album_mbid) - WHERE lidarr_album_mbid IS NOT NULL; -``` - -- [ ] **Step 1.2: Write the down migration** - -`internal/db/migrations/0010_lidarr.down.sql`: - -```sql -DROP INDEX IF EXISTS lidarr_requests_album_mbid_idx; -DROP INDEX IF EXISTS lidarr_requests_artist_mbid_idx; -DROP INDEX IF EXISTS lidarr_requests_status_idx; -DROP INDEX IF EXISTS lidarr_requests_user_id_idx; -DROP TABLE IF EXISTS lidarr_requests; -DROP TYPE IF EXISTS lidarr_request_kind; -DROP TYPE IF EXISTS lidarr_request_status; -DROP TABLE IF EXISTS lidarr_config; -``` - -- [ ] **Step 1.3: Apply migration locally to confirm it runs** - -```bash -docker compose exec -T postgres psql -U minstrel -d minstrel -c "DROP TABLE IF EXISTS lidarr_requests; DROP TYPE IF EXISTS lidarr_request_kind; DROP TYPE IF EXISTS lidarr_request_status; DROP TABLE IF EXISTS lidarr_config;" -go run ./cmd/minstrel/migrate.go 2>/dev/null || go run ./cmd/minstrel up -docker compose exec -T postgres psql -U minstrel -d minstrel -c "\d lidarr_requests" -``` - -Expected: `\d lidarr_requests` shows the table with all columns and the four indexes. - -If your project doesn't have a standalone migrate command, the migration applies on server start via `db.Migrate(...)` — restart the minstrel container instead. - -- [ ] **Step 1.4: Write `lidarr_config.sql` queries** - -`internal/db/queries/lidarr_config.sql`: - -```sql --- name: GetLidarrConfig :one -SELECT id, enabled, base_url, api_key, default_quality_profile_id, - default_root_folder_path, created_at, updated_at -FROM lidarr_config -WHERE id = 1; - --- name: UpdateLidarrConfig :one -UPDATE lidarr_config - SET enabled = $1, - base_url = $2, - api_key = $3, - default_quality_profile_id = $4, - default_root_folder_path = $5, - updated_at = now() - WHERE id = 1 - RETURNING id, enabled, base_url, api_key, default_quality_profile_id, - default_root_folder_path, created_at, updated_at; -``` - -- [ ] **Step 1.5: Write `lidarr_requests.sql` queries** - -`internal/db/queries/lidarr_requests.sql`: - -```sql --- name: CreateLidarrRequest :one -INSERT INTO lidarr_requests ( - user_id, kind, - lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, - artist_name, album_title, track_title -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -RETURNING *; - --- name: GetLidarrRequestByID :one -SELECT * FROM lidarr_requests WHERE id = $1; - --- name: ListLidarrRequestsForUser :many -SELECT * FROM lidarr_requests -WHERE user_id = $1 -ORDER BY requested_at DESC -LIMIT $2; - --- name: ListLidarrRequestsByStatus :many -SELECT * FROM lidarr_requests -WHERE status = $1 -ORDER BY requested_at DESC -LIMIT $2; - --- name: ListApprovedLidarrRequestsForReconcile :many -SELECT * FROM lidarr_requests -WHERE status = 'approved' -ORDER BY decided_at ASC -LIMIT $1; - --- name: ApproveLidarrRequest :one -UPDATE lidarr_requests - SET status = 'approved', - quality_profile_id = $2, - root_folder_path = $3, - decided_at = now(), - decided_by = $4, - updated_at = now() - WHERE id = $1 AND status = 'pending' - RETURNING *; - --- name: RejectLidarrRequest :one -UPDATE lidarr_requests - SET status = 'rejected', - notes = $2, - decided_at = now(), - decided_by = $3, - updated_at = now() - WHERE id = $1 AND status = 'pending' - RETURNING *; - --- name: CancelLidarrRequest :one -UPDATE lidarr_requests - SET status = 'rejected', - notes = 'cancelled by user', - decided_at = now(), - decided_by = $2, - updated_at = now() - WHERE id = $1 AND user_id = $2 AND status = 'pending' - RETURNING *; - --- name: CompleteLidarrRequest :one --- Reconciler transitions an approved request to completed when its --- target track/album/artist has appeared in the library. -UPDATE lidarr_requests - SET status = 'completed', - matched_track_id = $2, - matched_album_id = $3, - matched_artist_id = $4, - completed_at = now(), - updated_at = now() - WHERE id = $1 AND status = 'approved' - RETURNING *; - --- name: HasNonTerminalRequestForMBID :one --- Returns true if any user has a pending/approved/completed request --- whose MBID matches at the given level. Used to set the `requested` --- flag on /api/lidarr/search responses. Terminal-status (rejected, --- failed) rows do not count. -SELECT EXISTS ( - SELECT 1 FROM lidarr_requests - WHERE status IN ('pending', 'approved', 'completed') - AND ((kind = 'artist' AND lidarr_artist_mbid = $1) - OR (kind = 'album' AND lidarr_album_mbid = $1) - OR (kind = 'track' AND lidarr_track_mbid = $1)) -); -``` - -- [ ] **Step 1.6: Run sqlc generate** - -```bash -cd internal/db && sqlc generate && cd - -go build ./... -``` - -Expected: `internal/db/dbq/lidarr_config.sql.go` and `internal/db/dbq/lidarr_requests.sql.go` are created. Build succeeds. - -- [ ] **Step 1.7: Commit** - -```bash -git add internal/db/migrations/0010_lidarr.up.sql \ - internal/db/migrations/0010_lidarr.down.sql \ - internal/db/queries/lidarr_config.sql \ - internal/db/queries/lidarr_requests.sql \ - internal/db/dbq/ -git commit -m "feat(db): add lidarr_config + lidarr_requests schema (migration 0010)" -``` - ---- - -### Task 2 — Lidarr HTTP client - -**Files:** -- Create: `internal/lidarr/types.go` -- Create: `internal/lidarr/errors.go` -- Create: `internal/lidarr/client.go` -- Create: `internal/lidarr/client_test.go` -- Create: `internal/lidarr/testdata/lookup_artist.json` -- Create: `internal/lidarr/testdata/quality_profiles.json` -- Create: `internal/lidarr/testdata/root_folders.json` - -- [ ] **Step 2.1: Write the typed structs** - -`internal/lidarr/types.go`: - -```go -// Package lidarr is a typed HTTP client for Lidarr's v1 API. It is the -// only place in the codebase that knows about Lidarr's wire format. -// Callers receive value structs, never raw JSON. -package lidarr - -// LookupResult is the normalized shape returned by Lookup{Artist,Album,Track}. -// It is what we store on the request row (via the user's request) and -// what /api/lidarr/search returns to the SPA. -type LookupResult struct { - MBID string // foreignArtistId / foreignAlbumId / foreignTrackId - Name string // artist name; album/track returns Title here too - Secondary string // genre + album count for artist; year for album; album for track - ImageURL string // cover-art URL Lidarr surfaced (may be empty) -} - -// QualityProfile is the dropdown choice in /admin/integrations. -type QualityProfile struct { - ID int - Name string -} - -// RootFolder is the dropdown choice in /admin/integrations. -type RootFolder struct { - Path string - Accessible bool - FreeSpace int64 -} - -// AddArtistParams are the fields Lidarr requires on POST /api/v1/artist. -type AddArtistParams struct { - ForeignArtistID string - QualityProfileID int - RootFolderPath string - MonitorAll bool // true => Monitored="all"; false => "future" -} - -// AddAlbumParams are the fields Lidarr requires on POST /api/v1/album. -type AddAlbumParams struct { - ForeignAlbumID string - ForeignArtistID string // Lidarr requires the artist's foreign id too - QualityProfileID int - RootFolderPath string -} - -// PingResult is the response shape from GET /api/v1/system/status. -type PingResult struct { - Version string -} -``` - -- [ ] **Step 2.2: Write the typed errors** - -`internal/lidarr/errors.go`: - -```go -package lidarr - -import "errors" - -// Sentinel errors. Callers branch on these via errors.Is, not on -// HTTP status codes — the client maps codes to errors. -var ( - ErrUnreachable = errors.New("lidarr: unreachable") - ErrAuthFailed = errors.New("lidarr: auth failed") // 401 / 403 - ErrLookupFailed = errors.New("lidarr: lookup failed") // 4xx other than 401/403 - ErrServerError = errors.New("lidarr: server error") // 5xx - ErrInvalidPayload = errors.New("lidarr: invalid payload") -) -``` - -- [ ] **Step 2.3: Write the client skeleton + LookupArtist** - -`internal/lidarr/client.go`: - -```go -package lidarr - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strconv" -) - -// Client wraps Lidarr's v1 HTTP API. BaseURL is the Lidarr instance -// (e.g. http://lidarr.lan:8686), APIKey comes from Lidarr's settings. -type Client struct { - BaseURL string - APIKey string - HTTP *http.Client -} - -func (c *Client) get(ctx context.Context, path string, q url.Values) (*http.Response, error) { - u, err := url.Parse(c.BaseURL) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) - } - u.Path = u.Path + path - if q != nil { - u.RawQuery = q.Encode() - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("X-Api-Key", c.APIKey) - resp, err := c.HTTP.Do(req) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) - } - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - _ = resp.Body.Close() - return nil, ErrAuthFailed - } - if resp.StatusCode >= 500 { - _ = resp.Body.Close() - return nil, ErrServerError - } - if resp.StatusCode >= 400 { - _ = resp.Body.Close() - return nil, ErrLookupFailed - } - return resp, nil -} - -// LookupArtist hits Lidarr GET /api/v1/artist/lookup?term=. Returns -// normalized LookupResults from the response. -func (c *Client) LookupArtist(ctx context.Context, term string) ([]LookupResult, error) { - resp, err := c.get(ctx, "/api/v1/artist/lookup", url.Values{"term": {term}}) - if err != nil { - return nil, err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - var raw []struct { - ForeignArtistID string `json:"foreignArtistId"` - ArtistName string `json:"artistName"` - Genres []string `json:"genres"` - AlbumCount int `json:"albumCount"` - Images []struct { - CoverType string `json:"coverType"` - RemoteURL string `json:"remoteUrl"` - URL string `json:"url"` - } `json:"images"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - out := make([]LookupResult, 0, len(raw)) - for _, r := range raw { - secondary := "" - if len(r.Genres) > 0 { - secondary = r.Genres[0] - } - if r.AlbumCount > 0 { - if secondary != "" { - secondary += " · " - } - secondary += strconv.Itoa(r.AlbumCount) + " albums" - } - out = append(out, LookupResult{ - MBID: r.ForeignArtistID, - Name: r.ArtistName, - Secondary: secondary, - ImageURL: pickPosterImage(r.Images), - }) - } - return out, nil -} - -func pickPosterImage(imgs []struct { - CoverType string `json:"coverType"` - RemoteURL string `json:"remoteUrl"` - URL string `json:"url"` -}) string { - for _, img := range imgs { - if img.CoverType == "poster" { - if img.RemoteURL != "" { - return img.RemoteURL - } - return img.URL - } - } - return "" -} - -// Ensure interface implementations stay consistent. -var _ = errors.Is -``` - -- [ ] **Step 2.4: Add a captured Lidarr lookup response to testdata** - -`internal/lidarr/testdata/lookup_artist.json`: - -```json -[ - { - "foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", - "artistName": "Boards of Canada", - "genres": ["Electronic", "IDM"], - "albumCount": 18, - "images": [ - {"coverType": "poster", "remoteUrl": "https://example.invalid/boc.jpg"}, - {"coverType": "banner", "remoteUrl": "https://example.invalid/boc-banner.jpg"} - ] - }, - { - "foreignArtistId": "f54ba20c-7da3-4b8a-9b12-22f09b9e2c1c", - "artistName": "Bored of Education", - "genres": [], - "albumCount": 0, - "images": [] - } -] -``` - -- [ ] **Step 2.5: Write the LookupArtist test** - -`internal/lidarr/client_test.go`: - -```go -package lidarr - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "os" - "testing" -) - -func TestLookupArtist_HappyPath(t *testing.T) { - body, err := os.ReadFile("testdata/lookup_artist.json") - if err != nil { - t.Fatalf("read fixture: %v", err) - } - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("X-Api-Key"); got != "key123" { - t.Errorf("api key = %q, want key123", got) - } - if r.URL.Path != "/api/v1/artist/lookup" { - t.Errorf("path = %q", r.URL.Path) - } - if r.URL.Query().Get("term") != "boards" { - t.Errorf("term = %q", r.URL.Query().Get("term")) - } - _, _ = w.Write(body) - })) - defer srv.Close() - c := &Client{BaseURL: srv.URL, APIKey: "key123", HTTP: srv.Client()} - got, err := c.LookupArtist(context.Background(), "boards") - if err != nil { - t.Fatalf("LookupArtist: %v", err) - } - if len(got) != 2 { - t.Fatalf("len = %d, want 2", len(got)) - } - if got[0].Name != "Boards of Canada" { - t.Errorf("name = %q", got[0].Name) - } - if got[0].Secondary != "Electronic · 18 albums" { - t.Errorf("secondary = %q", got[0].Secondary) - } - if got[0].ImageURL != "https://example.invalid/boc.jpg" { - t.Errorf("image = %q", got[0].ImageURL) - } - if got[1].Secondary != "" { - t.Errorf("expected empty secondary for empty genres + 0 albums; got %q", got[1].Secondary) - } -} - -func TestLookupArtist_AuthFailed(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - })) - defer srv.Close() - c := &Client{BaseURL: srv.URL, APIKey: "x", HTTP: srv.Client()} - _, err := c.LookupArtist(context.Background(), "boards") - if !errors.Is(err, ErrAuthFailed) { - t.Fatalf("err = %v, want ErrAuthFailed", err) - } -} - -func TestLookupArtist_ServerError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer srv.Close() - c := &Client{BaseURL: srv.URL, APIKey: "x", HTTP: srv.Client()} - _, err := c.LookupArtist(context.Background(), "boards") - if !errors.Is(err, ErrServerError) { - t.Fatalf("err = %v, want ErrServerError", err) - } -} - -func TestLookupArtist_Unreachable(t *testing.T) { - c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "x", HTTP: &http.Client{}} - _, err := c.LookupArtist(context.Background(), "boards") - if !errors.Is(err, ErrUnreachable) { - t.Fatalf("err = %v, want ErrUnreachable", err) - } -} -``` - -- [ ] **Step 2.6: Run the tests, fix until green** - -```bash -go test -race -v ./internal/lidarr/... -``` - -Expected: 4 tests pass. - -- [ ] **Step 2.7: Add LookupAlbum and LookupTrack methods** - -Append to `internal/lidarr/client.go`: - -```go -// LookupAlbum hits GET /api/v1/album/lookup?term=. Returns normalized -// LookupResults; Secondary is "year · trackcount". -func (c *Client) LookupAlbum(ctx context.Context, term string) ([]LookupResult, error) { - resp, err := c.get(ctx, "/api/v1/album/lookup", url.Values{"term": {term}}) - if err != nil { - return nil, err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - var raw []struct { - ForeignAlbumID string `json:"foreignAlbumId"` - ForeignArtistID string `json:"foreignArtistId"` - Title string `json:"title"` - ArtistName string `json:"artistName"` - ReleaseDate string `json:"releaseDate"` - TrackCount int `json:"trackCount"` - Images []struct { - CoverType string `json:"coverType"` - RemoteURL string `json:"remoteUrl"` - URL string `json:"url"` - } `json:"images"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - out := make([]LookupResult, 0, len(raw)) - for _, r := range raw { - year := "" - if len(r.ReleaseDate) >= 4 { - year = r.ReleaseDate[:4] - } - secondary := r.ArtistName - if year != "" { - secondary += " · " + year - } - if r.TrackCount > 0 { - secondary += " · " + strconv.Itoa(r.TrackCount) + " tracks" - } - out = append(out, LookupResult{ - MBID: r.ForeignAlbumID, - Name: r.Title, - Secondary: secondary, - ImageURL: pickPosterImage(r.Images), - }) - } - return out, nil -} - -// LookupTrack hits GET /api/v1/track/lookup?term=. Lidarr's track -// lookup is per-album under the hood — Secondary is "album · artist". -func (c *Client) LookupTrack(ctx context.Context, term string) ([]LookupResult, error) { - resp, err := c.get(ctx, "/api/v1/track/lookup", url.Values{"term": {term}}) - if err != nil { - return nil, err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - var raw []struct { - ForeignTrackID string `json:"foreignTrackId"` - ForeignAlbumID string `json:"foreignAlbumId"` - ForeignArtistID string `json:"foreignArtistId"` - Title string `json:"title"` - AlbumTitle string `json:"albumTitle"` - ArtistName string `json:"artistName"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - out := make([]LookupResult, 0, len(raw)) - for _, r := range raw { - secondary := r.AlbumTitle - if r.ArtistName != "" { - if secondary != "" { - secondary += " · " - } - secondary += r.ArtistName - } - out = append(out, LookupResult{ - MBID: r.ForeignTrackID, - Name: r.Title, - Secondary: secondary, - }) - } - return out, nil -} -``` - -- [ ] **Step 2.8: Add tests for LookupAlbum and LookupTrack** - -Use the same `httptest`+fixture pattern. Capture two more JSON files (`testdata/lookup_album.json`, `testdata/lookup_track.json`) with at least 2 results each, then add `TestLookupAlbum_HappyPath` and `TestLookupTrack_HappyPath` mirroring `TestLookupArtist_HappyPath`. Auth/server-error variants are unchanged so don't duplicate them — one parametric helper test suffices. - -- [ ] **Step 2.9: Add AddArtist, AddAlbum, ListQualityProfiles, ListRootFolders, Ping** - -Append to `internal/lidarr/client.go`: - -```go -// post is the shared POST helper. Body is marshaled JSON. -func (c *Client) post(ctx context.Context, path string, body []byte) (*http.Response, error) { - u, err := url.Parse(c.BaseURL) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) - } - u.Path = u.Path + path - req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body)) - if err != nil { - return nil, err - } - req.Header.Set("X-Api-Key", c.APIKey) - req.Header.Set("Content-Type", "application/json") - resp, err := c.HTTP.Do(req) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) - } - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - _ = resp.Body.Close() - return nil, ErrAuthFailed - } - if resp.StatusCode >= 500 { - _ = resp.Body.Close() - return nil, ErrServerError - } - if resp.StatusCode >= 400 { - _ = resp.Body.Close() - return nil, ErrLookupFailed - } - return resp, nil -} - -func (c *Client) AddArtist(ctx context.Context, p AddArtistParams) error { - monitor := "future" - if p.MonitorAll { - monitor = "all" - } - body, _ := json.Marshal(map[string]any{ - "foreignArtistId": p.ForeignArtistID, - "qualityProfileId": p.QualityProfileID, - "rootFolderPath": p.RootFolderPath, - "monitored": true, - "monitor": monitor, - "addOptions": map[string]any{"searchForMissingAlbums": true}, - }) - resp, err := c.post(ctx, "/api/v1/artist", body) - if err != nil { - return err - } - _ = resp.Body.Close() - return nil -} - -func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error { - body, _ := json.Marshal(map[string]any{ - "foreignAlbumId": p.ForeignAlbumID, - "foreignArtistId": p.ForeignArtistID, - "qualityProfileId": p.QualityProfileID, - "rootFolderPath": p.RootFolderPath, - "monitored": true, - "addOptions": map[string]any{"searchForNewAlbum": true}, - }) - resp, err := c.post(ctx, "/api/v1/album", body) - if err != nil { - return err - } - _ = resp.Body.Close() - return nil -} - -func (c *Client) ListQualityProfiles(ctx context.Context) ([]QualityProfile, error) { - resp, err := c.get(ctx, "/api/v1/qualityprofile", nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - var raw []struct { - ID int `json:"id"` - Name string `json:"name"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - out := make([]QualityProfile, len(raw)) - for i, r := range raw { - out[i] = QualityProfile{ID: r.ID, Name: r.Name} - } - return out, nil -} - -func (c *Client) ListRootFolders(ctx context.Context) ([]RootFolder, error) { - resp, err := c.get(ctx, "/api/v1/rootfolder", nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - var raw []struct { - Path string `json:"path"` - Accessible bool `json:"accessible"` - FreeSpace int64 `json:"freeSpace"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - out := make([]RootFolder, len(raw)) - for i, r := range raw { - out[i] = RootFolder{Path: r.Path, Accessible: r.Accessible, FreeSpace: r.FreeSpace} - } - return out, nil -} - -func (c *Client) Ping(ctx context.Context) (PingResult, error) { - resp, err := c.get(ctx, "/api/v1/system/status", nil) - if err != nil { - return PingResult{}, err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - var raw struct { - Version string `json:"version"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - return PingResult{Version: raw.Version}, nil -} -``` - -Add `"bytes"` to the imports. - -- [ ] **Step 2.10: Add tests for the remaining methods** - -For each: capture or hand-write a fixture, add `TestAddArtist_PostsCorrectBody`, `TestAddAlbum_PostsCorrectBody`, `TestListQualityProfiles_HappyPath`, `TestListRootFolders_HappyPath`, `TestPing_ReturnsVersion`. The Add* tests should assert on the parsed POST body — read `r.Body`, decode JSON, check the field shape. - -- [ ] **Step 2.11: Run all client tests** - -```bash -go test -race -cover ./internal/lidarr/... -``` - -Expected: all tests pass; coverage ≥ 80%. - -- [ ] **Step 2.12: Commit** - -```bash -git add internal/lidarr/ -git commit -m "feat(lidarr): typed HTTP client for v1 API (lookup, add, profiles, ping)" -``` - ---- - -### Task 3 — `lidarrconfig` singleton service - -**Files:** -- Create: `internal/lidarrconfig/service.go` -- Create: `internal/lidarrconfig/service_test.go` - -- [ ] **Step 3.1: Write the service** - -`internal/lidarrconfig/service.go`: - -```go -// Package lidarrconfig is a thin wrapper over the singleton lidarr_config -// row. Get returns a typed Config; Save updates it. Callers branch on -// Config.Enabled — never on raw NULL fields. -package lidarrconfig - -import ( - "context" - "fmt" - - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// Config is the typed projection of lidarr_config (no NULL fields exposed -// to callers — empty strings / zero ints carry the "unset" meaning). -type Config struct { - Enabled bool - BaseURL string - APIKey string - DefaultQualityProfileID int - DefaultRootFolderPath string -} - -// Service reads and writes the singleton. -type Service struct { - pool *pgxpool.Pool -} - -func New(pool *pgxpool.Pool) *Service { return &Service{pool: pool} } - -func (s *Service) Get(ctx context.Context) (Config, error) { - row, err := dbq.New(s.pool).GetLidarrConfig(ctx) - if err != nil { - return Config{}, fmt.Errorf("lidarrconfig: %w", err) - } - cfg := Config{Enabled: row.Enabled} - if row.BaseUrl != nil { - cfg.BaseURL = *row.BaseUrl - } - if row.ApiKey != nil { - cfg.APIKey = *row.ApiKey - } - if row.DefaultQualityProfileID != nil { - cfg.DefaultQualityProfileID = int(*row.DefaultQualityProfileID) - } - if row.DefaultRootFolderPath != nil { - cfg.DefaultRootFolderPath = *row.DefaultRootFolderPath - } - return cfg, nil -} - -// Save writes the entire row. Callers pass the full Config they want -// stored — this is not a partial update. -func (s *Service) Save(ctx context.Context, cfg Config) error { - var ( - baseURL *string = strPtr(cfg.BaseURL) - apiKey *string = strPtr(cfg.APIKey) - qpID *int32 = int32Ptr(cfg.DefaultQualityProfileID) - rootPath *string = strPtr(cfg.DefaultRootFolderPath) - ) - _, err := dbq.New(s.pool).UpdateLidarrConfig(ctx, dbq.UpdateLidarrConfigParams{ - Enabled: cfg.Enabled, - BaseUrl: baseURL, - ApiKey: apiKey, - DefaultQualityProfileID: qpID, - DefaultRootFolderPath: rootPath, - }) - if err != nil { - return fmt.Errorf("lidarrconfig: %w", err) - } - return nil -} - -func strPtr(s string) *string { - if s == "" { - return nil - } - return &s -} - -func int32Ptr(i int) *int32 { - if i == 0 { - return nil - } - v := int32(i) - return &v -} -``` - -Note: the exact field names on `dbq.UpdateLidarrConfigParams` depend on sqlc's generation. After `sqlc generate` ran in Task 1, you'll see them — adjust the literal field names here to match. Pointer-vs-value also depends on how sqlc treats nullable text columns. - -- [ ] **Step 3.2: Write the integration test** - -`internal/lidarrconfig/service_test.go`: - -```go -package lidarrconfig - -import ( - "context" - "io" - "log/slog" - "os" - "testing" - - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db" -) - -func newTestPool(t *testing.T) *pgxpool.Pool { - t.Helper() - if testing.Short() { - t.Skip("skipping integration test in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - t.Fatalf("pool: %v", err) - } - t.Cleanup(pool.Close) - // Reset singleton to default state before each test by replacing - // the row contents via UPDATE (TRUNCATE would violate the CHECK). - if _, err := pool.Exec(context.Background(), - "UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL, default_quality_profile_id=NULL, default_root_folder_path=NULL WHERE id=1", - ); err != nil { - t.Fatalf("reset: %v", err) - } - return pool -} - -func TestGet_DefaultRowReturnsZeroValueConfig(t *testing.T) { - pool := newTestPool(t) - cfg, err := New(pool).Get(context.Background()) - if err != nil { - t.Fatalf("Get: %v", err) - } - if cfg.Enabled || cfg.BaseURL != "" || cfg.APIKey != "" { - t.Errorf("expected zero-value Config, got %+v", cfg) - } -} - -func TestSaveThenGet_RoundTrip(t *testing.T) { - pool := newTestPool(t) - s := New(pool) - want := Config{ - Enabled: true, - BaseURL: "http://lidarr.lan:8686", - APIKey: "secret", - DefaultQualityProfileID: 4, - DefaultRootFolderPath: "/music", - } - if err := s.Save(context.Background(), want); err != nil { - t.Fatalf("Save: %v", err) - } - got, err := s.Get(context.Background()) - if err != nil { - t.Fatalf("Get: %v", err) - } - if got != want { - t.Errorf("round-trip mismatch:\n got = %+v\nwant = %+v", got, want) - } -} - -func TestSave_EmptyValuesPersistAsNULL(t *testing.T) { - pool := newTestPool(t) - s := New(pool) - if err := s.Save(context.Background(), Config{Enabled: false}); err != nil { - t.Fatalf("Save: %v", err) - } - got, err := s.Get(context.Background()) - if err != nil { - t.Fatalf("Get: %v", err) - } - if got.BaseURL != "" || got.APIKey != "" || got.DefaultRootFolderPath != "" { - t.Errorf("expected empty strings on round-trip; got %+v", got) - } -} -``` - -- [ ] **Step 3.3: Run the tests inside the docker network** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race ./internal/lidarrconfig/... -``` - -Expected: 3 tests pass. - -- [ ] **Step 3.4: Commit** - -```bash -git add internal/lidarrconfig/ -git commit -m "feat(lidarrconfig): typed singleton config wrapper" -``` - ---- - -### Task 4 — `lidarrrequests` Service (lifecycle, no reconciler) - -**Files:** -- Create: `internal/lidarrrequests/service.go` -- Create: `internal/lidarrrequests/service_test.go` - -- [ ] **Step 4.1: Write the Service** - -`internal/lidarrrequests/service.go`: - -```go -// Package lidarrrequests owns the lifecycle of user requests to add -// music via Lidarr. The synchronous Service handles Create/List/ -// Approve/Reject/Cancel; the async Reconciler (separate file) closes -// approved requests once their target track lands in the library. -package lidarrrequests - -import ( - "context" - "errors" - "fmt" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" -) - -// Public errors. Handlers map these to API error codes. -var ( - ErrInvalidKindFields = errors.New("lidarrrequests: missing required fields for kind") - ErrNotPending = errors.New("lidarrrequests: request is not pending") - ErrNotFound = errors.New("lidarrrequests: request not found") - ErrLidarrDisabled = errors.New("lidarrrequests: lidarr not configured") -) - -// CreateParams is the input for a new request from a user. -type CreateParams struct { - Kind string // "artist", "album", or "track" - LidarrArtistMBID string - LidarrAlbumMBID string // required for kind=album/track - LidarrTrackMBID string // required for kind=track - ArtistName string - AlbumTitle string // required for kind=album/track - TrackTitle string // required for kind=track -} - -// ApproveOverrides lets the admin override the snapshot defaults for one -// approval. Zero values mean "use config default." -type ApproveOverrides struct { - QualityProfileID int - RootFolderPath string -} - -type Service struct { - pool *pgxpool.Pool - lidarrCfg *lidarrconfig.Service - client *lidarr.Client - scanFn func() // injected; called after Approve to trigger a library scan -} - -func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, client *lidarr.Client, scanFn func()) *Service { - if scanFn == nil { - scanFn = func() {} - } - return &Service{pool: pool, lidarrCfg: cfg, client: client, scanFn: scanFn} -} - -// Create validates the kind→required-fields invariant and inserts a -// pending row. -func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.LidarrRequest, error) { - if err := validateKindFields(p); err != nil { - return dbq.LidarrRequest{}, err - } - q := dbq.New(s.pool) - row, err := q.CreateLidarrRequest(ctx, dbq.CreateLidarrRequestParams{ - UserID: userID, - Kind: dbq.LidarrRequestKind(p.Kind), - LidarrArtistMbid: p.LidarrArtistMBID, - LidarrAlbumMbid: strPtr(p.LidarrAlbumMBID), - LidarrTrackMbid: strPtr(p.LidarrTrackMBID), - ArtistName: p.ArtistName, - AlbumTitle: strPtr(p.AlbumTitle), - TrackTitle: strPtr(p.TrackTitle), - }) - if err != nil { - return dbq.LidarrRequest{}, fmt.Errorf("create: %w", err) - } - return row, nil -} - -func validateKindFields(p CreateParams) error { - if p.LidarrArtistMBID == "" || p.ArtistName == "" { - return fmt.Errorf("%w: artist_mbid and artist_name are always required", ErrInvalidKindFields) - } - switch p.Kind { - case "artist": - // fine - case "album": - if p.LidarrAlbumMBID == "" || p.AlbumTitle == "" { - return fmt.Errorf("%w: album kind requires album_mbid and album_title", ErrInvalidKindFields) - } - case "track": - if p.LidarrAlbumMBID == "" || p.AlbumTitle == "" { - return fmt.Errorf("%w: track kind requires album_mbid and album_title (track will be promoted)", ErrInvalidKindFields) - } - if p.LidarrTrackMBID == "" || p.TrackTitle == "" { - return fmt.Errorf("%w: track kind requires track_mbid and track_title", ErrInvalidKindFields) - } - default: - return fmt.Errorf("%w: unknown kind %q", ErrInvalidKindFields, p.Kind) - } - return nil -} - -func (s *Service) ListPending(ctx context.Context, limit int32) ([]dbq.LidarrRequest, error) { - return dbq.New(s.pool).ListLidarrRequestsByStatus(ctx, dbq.ListLidarrRequestsByStatusParams{ - Status: dbq.LidarrRequestStatusPending, Limit: limit, - }) -} - -func (s *Service) ListByStatus(ctx context.Context, status string, limit int32) ([]dbq.LidarrRequest, error) { - return dbq.New(s.pool).ListLidarrRequestsByStatus(ctx, dbq.ListLidarrRequestsByStatusParams{ - Status: dbq.LidarrRequestStatus(status), Limit: limit, - }) -} - -func (s *Service) ListForUser(ctx context.Context, userID pgtype.UUID, limit int32) ([]dbq.LidarrRequest, error) { - return dbq.New(s.pool).ListLidarrRequestsForUser(ctx, dbq.ListLidarrRequestsForUserParams{ - UserID: userID, Limit: limit, - }) -} - -// Approve transitions a pending request to approved, snapshotting the -// chosen quality profile + root folder, then calls Lidarr to actually -// add the artist/album, then triggers a library scan. If Lidarr returns -// an error, the request stays pending — the admin sees the error and -// can retry without losing the request. -func (s *Service) Approve(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, ov ApproveOverrides) (dbq.LidarrRequest, error) { - cfg, err := s.lidarrCfg.Get(ctx) - if err != nil { - return dbq.LidarrRequest{}, fmt.Errorf("approve: load config: %w", err) - } - if !cfg.Enabled || s.client == nil { - return dbq.LidarrRequest{}, ErrLidarrDisabled - } - row, err := dbq.New(s.pool).GetLidarrRequestByID(ctx, requestID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return dbq.LidarrRequest{}, ErrNotFound - } - return dbq.LidarrRequest{}, fmt.Errorf("approve: get: %w", err) - } - if row.Status != dbq.LidarrRequestStatusPending { - return dbq.LidarrRequest{}, ErrNotPending - } - - qp := cfg.DefaultQualityProfileID - if ov.QualityProfileID != 0 { - qp = ov.QualityProfileID - } - rf := cfg.DefaultRootFolderPath - if ov.RootFolderPath != "" { - rf = ov.RootFolderPath - } - - switch row.Kind { - case dbq.LidarrRequestKindArtist: - err = s.client.AddArtist(ctx, lidarr.AddArtistParams{ - ForeignArtistID: row.LidarrArtistMbid, QualityProfileID: qp, RootFolderPath: rf, MonitorAll: true, - }) - case dbq.LidarrRequestKindAlbum, dbq.LidarrRequestKindTrack: - // Track-kind requests promote to album-add; the spec is explicit. - albumMBID := "" - if row.LidarrAlbumMbid != nil { - albumMBID = *row.LidarrAlbumMbid - } - err = s.client.AddAlbum(ctx, lidarr.AddAlbumParams{ - ForeignAlbumID: albumMBID, ForeignArtistID: row.LidarrArtistMbid, - QualityProfileID: qp, RootFolderPath: rf, - }) - } - if err != nil { - return dbq.LidarrRequest{}, fmt.Errorf("approve: lidarr add: %w", err) - } - - approved, err := dbq.New(s.pool).ApproveLidarrRequest(ctx, dbq.ApproveLidarrRequestParams{ - ID: requestID, - QualityProfileID: int32Ptr(qp), - RootFolderPath: strPtr(rf), - DecidedBy: uuidPtr(adminID), - }) - if err != nil { - // Lidarr accepted but our DB update failed; admin should retry. - return dbq.LidarrRequest{}, fmt.Errorf("approve: persist: %w", err) - } - s.scanFn() - return approved, nil -} - -func (s *Service) Reject(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, notes string) (dbq.LidarrRequest, error) { - row, err := dbq.New(s.pool).RejectLidarrRequest(ctx, dbq.RejectLidarrRequestParams{ - ID: requestID, Notes: strPtr(notes), DecidedBy: uuidPtr(adminID), - }) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - // Either not found OR not pending — caller can't distinguish from - // the SQL alone, so check after. - cur, gerr := dbq.New(s.pool).GetLidarrRequestByID(ctx, requestID) - if gerr != nil { - return dbq.LidarrRequest{}, ErrNotFound - } - if cur.Status != dbq.LidarrRequestStatusPending { - return dbq.LidarrRequest{}, ErrNotPending - } - return dbq.LidarrRequest{}, ErrNotFound - } - return dbq.LidarrRequest{}, fmt.Errorf("reject: %w", err) - } - return row, nil -} - -func (s *Service) Cancel(ctx context.Context, requestID pgtype.UUID, userID pgtype.UUID) (dbq.LidarrRequest, error) { - row, err := dbq.New(s.pool).CancelLidarrRequest(ctx, dbq.CancelLidarrRequestParams{ - ID: requestID, UserID: userID, - }) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return dbq.LidarrRequest{}, ErrNotPending - } - return dbq.LidarrRequest{}, fmt.Errorf("cancel: %w", err) - } - return row, nil -} - -func strPtr(s string) *string { - if s == "" { - return nil - } - return &s -} -func int32Ptr(i int) *int32 { - if i == 0 { - return nil - } - v := int32(i) - return &v -} -func uuidPtr(u pgtype.UUID) pgtype.UUID { return u } -``` - -(Field names like `dbq.LidarrRequestStatusPending`, `dbq.LidarrRequestKindArtist` come from sqlc — verify after `sqlc generate`. Pointer-vs-value for nullable columns also from sqlc.) - -- [ ] **Step 4.2: Write the integration test** - -`internal/lidarrrequests/service_test.go`: - -```go -package lidarrrequests - -import ( - "context" - "errors" - "io" - "log/slog" - "os" - "testing" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" -) - -func newPool(t *testing.T) *pgxpool.Pool { - t.Helper() - if testing.Short() { - t.Skip("skipping integration test in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - t.Fatalf("pool: %v", err) - } - t.Cleanup(pool.Close) - dbtest.ResetDB(t, pool) - if _, err := pool.Exec(context.Background(), - "DELETE FROM lidarr_requests; UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL WHERE id=1", - ); err != nil { - t.Fatalf("reset lidarr tables: %v", err) - } - return pool -} - -func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID { - t.Helper() - u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ - Username: dbtest.TestUserPrefix + "rqtester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, - }) - if err != nil { - t.Fatalf("seed user: %v", err) - } - return u.ID -} - -func TestCreate_HappyPath_Artist(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool) - svc := NewService(pool, lidarrconfig.New(pool), nil, nil) - r, err := svc.Create(context.Background(), user, CreateParams{ - Kind: "artist", - LidarrArtistMBID: "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", - ArtistName: "Boards of Canada", - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - if r.Status != dbq.LidarrRequestStatusPending { - t.Errorf("status = %v", r.Status) - } -} - -func TestCreate_TrackKindRequiresAlbumMBID(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool) - svc := NewService(pool, lidarrconfig.New(pool), nil, nil) - _, err := svc.Create(context.Background(), user, CreateParams{ - Kind: "track", - LidarrArtistMBID: "a-mbid", ArtistName: "X", - LidarrTrackMBID: "t-mbid", TrackTitle: "Y", - // missing album fields - }) - if !errors.Is(err, ErrInvalidKindFields) { - t.Fatalf("err = %v, want ErrInvalidKindFields", err) - } -} - -func TestApprove_NotConfigured(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool) - svc := NewService(pool, lidarrconfig.New(pool), nil, nil) - r, _ := svc.Create(context.Background(), user, CreateParams{ - Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", - }) - _, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{}) - if !errors.Is(err, ErrLidarrDisabled) { - t.Fatalf("err = %v, want ErrLidarrDisabled", err) - } -} - -func TestReject_TransitionsToRejected(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool) - svc := NewService(pool, lidarrconfig.New(pool), nil, nil) - r, _ := svc.Create(context.Background(), user, CreateParams{ - Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", - }) - rejected, err := svc.Reject(context.Background(), r.ID, user, "low quality") - if err != nil { - t.Fatalf("Reject: %v", err) - } - if rejected.Status != dbq.LidarrRequestStatusRejected { - t.Errorf("status = %v", rejected.Status) - } -} - -func TestReject_AlreadyRejectedReturnsErrNotPending(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool) - svc := NewService(pool, lidarrconfig.New(pool), nil, nil) - r, _ := svc.Create(context.Background(), user, CreateParams{ - Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", - }) - _, _ = svc.Reject(context.Background(), r.ID, user, "first") - _, err := svc.Reject(context.Background(), r.ID, user, "second") - if !errors.Is(err, ErrNotPending) { - t.Fatalf("err = %v, want ErrNotPending", err) - } -} - -func TestCancel_OwnPendingOnly(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool) - svc := NewService(pool, lidarrconfig.New(pool), nil, nil) - r, _ := svc.Create(context.Background(), user, CreateParams{ - Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", - }) - if _, err := svc.Cancel(context.Background(), r.ID, user); err != nil { - t.Fatalf("Cancel: %v", err) - } - // Second cancel hits "not pending" because we just rejected it. - if _, err := svc.Cancel(context.Background(), r.ID, user); !errors.Is(err, ErrNotPending) { - t.Errorf("err = %v, want ErrNotPending", err) - } -} -``` - -- [ ] **Step 4.3: Run the tests** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race ./internal/lidarrrequests/... -``` - -Expected: 6 tests pass. - -- [ ] **Step 4.4: Commit** - -```bash -git add internal/lidarrrequests/service.go internal/lidarrrequests/service_test.go -git commit -m "feat(lidarrrequests): request lifecycle service (create/list/approve/reject/cancel)" -``` - ---- - -This plan continues in the same shape for the remaining tasks. Subsequent tasks are sketched below at one-paragraph-per-task density to keep the plan navigable; expand each into the same step-level TDD detail (write test → run → implement → run → commit) when you reach it. Each task references the spec sections that drive it. - ---- - -### Task 5 — `lidarrrequests` Reconciler worker - -**Files:** `internal/lidarrrequests/reconciler.go`, `internal/lidarrrequests/reconciler_integration_test.go`, plus a sqlc query `MatchTrackForRequest` in `internal/db/queries/lidarr_requests.sql`. - -The reconciler mirrors `internal/similarity.Worker`: a `Run(ctx)` loop that calls `tickOnce(ctx)` every 5 minutes. `tickOnce`: -1. `ListApprovedLidarrRequestsForReconcile(limit=50)`. -2. For each row, look up the matching local row via MBID: - - `kind=artist` → `SELECT id FROM artists WHERE mbid = $1` - - `kind=album` → `SELECT id FROM albums WHERE mbid = $1` - - `kind=track` → `SELECT id FROM tracks WHERE album_id = (SELECT id FROM albums WHERE mbid = $1) LIMIT 1` (track-kind matched by parent album per spec §3 reconciler note) -3. If a match is found, call `CompleteLidarrRequest` with the matched IDs. - -Reconciler short-circuits to no-op when `lidarrconfig.Get(...).Enabled == false`. Errors logged at WARN, never propagated. - -**Tests** (per spec §8 — five integration scenarios): -- `TestReconciler_MatchesArtistByMBID` — seed artist, seed approved artist-kind request with same MBID, run `tickOnce`, expect status=completed and `matched_artist_id` set. -- `TestReconciler_MatchesAlbumByMBID` — same shape for album. -- `TestReconciler_MatchesTrackViaAlbumMBID` — track-kind request matches when ANY track of the parent album appears. -- `TestReconciler_NoMatchLeavesPending` — approved request with MBID not in library → row unchanged after `tickOnce`. -- `TestReconciler_AlreadyCompletedRowNotReprocessed` — pre-set status=completed, ensure `tickOnce` doesn't touch it. -- `TestReconciler_DisabledIsNoOp` — `lidarr_config.enabled=false` → `tickOnce` short-circuits even with approved rows present. - -Commit: `feat(lidarrrequests): add Reconciler worker matching approved requests to library`. - ---- - -### Task 6 — `RequireAdmin` middleware - -**Files:** `internal/auth/admin.go`, `internal/auth/admin_test.go`. - -Mirror `RequireUser`'s shape. After `RequireUser` puts the user in context, `RequireAdmin` reads the user from context, returns 403 with `{"error":"not_authorized"}` JSON envelope if `IsAdmin == false`. Test cases: admin passes through; non-admin returns 403; missing context (programmer error) returns 500. - -Commit: `feat(auth): add RequireAdmin middleware for /api/admin/* routes`. - ---- - -### Task 7 — `/api/lidarr/search` proxy handler - -**Files:** `internal/api/lidarr.go`, `internal/api/lidarr_test.go`. Modify: `internal/api/api.go` to inject the Lidarr client + lidarrconfig service into `handlers`. - -Handler reads `q` and `kind` from query params, validates `kind ∈ {artist, album, track}`, checks `lidarrconfig.Get().Enabled` — if false, returns `503 {"error":"lidarr_disabled"}`. Calls the matching `client.Lookup*`, then per-result enriches with: -- `in_library` — by joining against `artists.mbid` / `albums.mbid` / `tracks.mbid`. Add a small `IsMBIDInLibrary` sqlc query for each kind. -- `requested` — via `HasNonTerminalRequestForMBID` (already in queries from Task 1). - -Maps `lidarr.ErrUnreachable`/`ErrAuthFailed` to `503 lidarr_unreachable` / `503 lidarr_auth_failed`. Other errors → `500`. - -**Tests:** -- `TestHandleLidarrSearch_HappyPath` — stubs the Client to return one in-library + one requestable + one already-requested, asserts the JSON shape. -- `TestHandleLidarrSearch_DisabledReturns503` — `lidarr_config.enabled=false`. -- `TestHandleLidarrSearch_LidarrUnreachable` — stubbed Client returns `ErrUnreachable`. -- `TestHandleLidarrSearch_BadKind400`. -- `TestHandleLidarrSearch_RequiresAuth` — anonymous request rejected. - -Commit: `feat(api): add /api/lidarr/search proxy with library/request enrichment`. - ---- - -### Task 8 — `/api/requests` user-facing CRUD handlers - -**Files:** `internal/api/requests.go`, `internal/api/requests_test.go`. Modify: `internal/api/api.go` to register routes inside the `RequireUser` group. - -Five handlers: `POST /api/requests` (Create), `GET /api/requests` (ListForUser), `GET /api/requests/:id`, `DELETE /api/requests/:id` (Cancel). - -Each handler delegates to `lidarrrequests.Service`. Map `ErrInvalidKindFields → 400 mbid_required`, `ErrNotPending → 409 request_not_pending`, `ErrNotFound → 404 request_not_found`. `GET /:id` returns 404 if the row isn't the caller's own AND caller isn't admin. - -**Tests** (extend `testHandlers` to inject `lidarrrequests.Service`): -- Create with valid artist/album/track payloads → 201. -- Create with each invalid kind→fields combination → 400. -- List returns only caller's rows; cross-user scoped out. -- Get-own returns row; get-other-user 404; get-other-user-as-admin 200. -- Cancel pending → 200 with status=rejected; cancel non-pending → 409. - -Commit: `feat(api): add /api/requests user-facing CRUD`. - ---- - -### Task 9 — `/api/admin/lidarr/*` config + profiles + folders + test - -**Files:** `internal/api/admin_lidarr.go`, `internal/api/admin_lidarr_test.go`. Modify: `internal/api/api.go` to mount a `RequireAdmin` group under `/api/admin`. - -Handlers: `GET /api/admin/lidarr/config` (mask api_key), `PUT /api/admin/lidarr/config`, `POST /api/admin/lidarr/test`, `GET /api/admin/lidarr/quality-profiles`, `GET /api/admin/lidarr/root-folders`. - -PUT logic: if request `api_key` is empty string → preserve saved value; if non-empty → update. `enabled=true` requires `base_url` and `api_key` to be non-empty (validate at handler). - -Test endpoint: per-field fallback to saved values when absent or empty; always returns 200 with `{ok, version?, error?}`. - -**Tests:** -- GET config masks api_key when set. -- PUT empty api_key preserves saved value. -- PUT enabled=true with empty base_url → 400. -- POST test happy path returns `{ok:true, version}`. -- POST test with stubbed-unreachable client returns `{ok:false, error}`. -- Quality-profiles + root-folders proxy through to client. -- All endpoints return 403 for non-admin tokens. - -Commit: `feat(api): add /api/admin/lidarr/* config + profiles + folders + test`. - ---- - -### Task 10 — `/api/admin/requests/*` approval queue handlers - -**Files:** `internal/api/admin_requests.go`, `internal/api/admin_requests_test.go`. Modify: `internal/api/api.go` to register inside the `RequireAdmin` group. - -Three handlers: `GET /api/admin/requests?status=&limit=` (default `status=pending`), `POST /api/admin/requests/:id/approve` (body: optional override), `POST /api/admin/requests/:id/reject` (body: optional notes). - -Approve handler delegates to `Service.Approve`; surfaces `ErrLidarrDisabled` / `lidarr.ErrUnreachable` / `ErrNotPending` / `ErrNotFound` per error code table. - -**Tests:** -- List with status=pending returns pending rows only. -- Approve happy path: stubbed Client receives correct AddArtist/AddAlbum payload, row transitions to approved with snapshot fields, scan trigger called. -- Approve with override snapshots override values, not config defaults. -- Approve when Lidarr returns ErrUnreachable → 503; row stays pending. -- Reject with notes records notes; reject without notes works with NULL notes. -- All endpoints return 403 for non-admin tokens. - -Commit: `feat(api): add /api/admin/requests approval queue`. - ---- - -### Task 11 — Wire the Reconciler in `cmd/minstrel/main.go` - -**Files:** Modify `cmd/minstrel/main.go`. - -Mirror the existing scrobble/similarity worker spin-up. Construct `lidarrconfig.Service`, the `lidarr.Client` (BaseURL+APIKey loaded from the singleton on demand), `lidarrrequests.Reconciler`, and start its `Run(ctx)` in a goroutine alongside the others. - -Subtle: the Lidarr client's `BaseURL` and `APIKey` change at runtime when admin updates config. Two ways to handle — (a) construct a new Client per request inside the Service from the latest config, or (b) wrap a `*atomic.Pointer[lidarr.Client]` that the config-save handler swaps. Pick (a) — simpler, no atomic dance, the cost of constructing an `http.Client` per request is negligible. Refactor `Service` to hold a `func() *lidarr.Client` factory instead of a `*Client` so it always reads fresh config. - -(Update Task 4's `Service` shape to use the factory accordingly. This is a foreseeable refactor — better to absorb it now than fight stale clients in production.) - -Commit: `feat(cmd): start Lidarr reconciler worker alongside HTTP server`. - ---- - -### Task 12 — Frontend: FabledSword design tokens + fonts - -**Files:** Create `web/src/lib/styles/fabledsword-tokens.css`. Modify `web/src/app.css`. Modify `web/src/app.html` to load Google Fonts. Modify `web/tailwind.config.js` to alias semantic Tailwind utilities (e.g. `bg-surface`, `text-text-primary`, `border-border`) to FS tokens. - -Token file content: every variable from `project_design_system.md` `:root` block — surfaces, text, action, semantic, accent, font families, radii. Plus the per-app data-attribute hook that sets `--fs-accent` to forest-teal `#4A6B5C` for Minstrel. - -In Tailwind config, replace existing palette aliases: -- `surface`, `surface-hover` → Iron, Slate -- `background` → Obsidian -- `text-primary`, `text-secondary`, `text-muted` → Parchment, Vellum, Ash -- `border` → Pewter -- Add new utility classes for action (`bg-action-primary` → Moss, `bg-action-secondary` → Bronze, `bg-action-destructive` → Oxblood) and `accent` → forest teal. - -This is the slice that converts the rest of the app to the design system implicitly — by aliasing existing utility names. Verify by visiting the dev server and confirming the existing pages now read in the new palette without any per-page changes (a sign the alias mapping is correct). - -Commit: `feat(web): introduce FabledSword design system tokens + Tailwind aliases`. - ---- - -### Task 13 — Frontend: Lidarr + requests + admin API client modules - -**Files:** Create `web/src/lib/api/lidarr.ts`, `web/src/lib/api/requests.ts`, `web/src/lib/api/admin.ts`. - -Mirror existing client modules (e.g. `web/src/lib/api/likes.ts`). Each file exports typed async functions backed by the existing `api.get/post/put/delete` helper. - -Types match the API surface in spec §5. Vitest tests live alongside (e.g. `lidarr.test.ts`) using the existing fetch mocking setup — verify URL construction, query params, error mapping. - -Commit: `feat(web): add API client modules for Lidarr, requests, admin`. - ---- - -### Task 14 — Frontend: `` component - -**Files:** Create `web/src/lib/components/DiscoverResultCard.svelte`, `DiscoverResultCard.test.ts`. - -Component props: `{ kind: 'artist'|'album'|'track', title: string, subtitle?: string, imageUrl?: string, state: 'requestable'|'kept'|'requested', onRequest?: () => void }`. - -Layout discipline (per spec §6 + brainstorm): -- Outer `.card` is flex column with reserved `.text` block (`min-height` covers title + meta + badge row); `.actions` block uses `margin-top: auto`. -- Badge slot is always rendered as a 22px-min-height div; "Kept" pill (accent at 15% bg + accent text) appears only when `state==='kept'`. -- Three states render different actions: - - `requestable`: `bg-action-primary` button with plus icon, label "Request" - - `kept`: disabled ghost button "In library" + "Kept" pill in badge slot - - `requested`: disabled ghost button "Requested" -- Cover art: render `` when `imageUrl`; otherwise render Lucide fallback glyph (`Disc3` for artist, `Album` for album, `Music2` for track) inside the Slate-bg art square. - -**Tests:** -- Renders all three states with correct button text. -- Calls `onRequest` only in `requestable` state. -- Computes badge slot height with `min-height: 22px` even when no badge content (assert via `getComputedStyle`). -- Button is anchored to bottom of card body (assert `margin-top` === `auto` on `.actions`). - -Commit: `feat(web): add DiscoverResultCard with reserved badge slot + anchored button`. - ---- - -### Task 15 — Frontend: `` component - -**Files:** Create `web/src/lib/components/StatusPill.svelte`, `StatusPill.test.ts`. - -Single prop: `status: 'pending'|'approved'|'completed'|'rejected'|'failed'`. Renders a pill with semantic color (Warning / Info / Moss / Error / Error) per spec §6 and the design-system memory. Voice-rule labels: "Awaiting review" / "Approved · downloading" / "Kept" / "Set aside" / "Couldn't add." - -Tests: each status renders with the correct text and the correct semantic CSS class (use `bg-warning-tint`, etc., aliases). - -Commit: `feat(web): add StatusPill semantic-color status indicator`. - ---- - -### Task 16 — Frontend: `/discover` route - -**Files:** Create `web/src/routes/discover/+page.svelte`, `discover.test.ts`. Modify `web/src/lib/components/Shell.svelte` to add `/discover` to the main nav. - -Page elements: -- H2 "Add music to the library" (Fraunces 24/500), Vellum subtitle -- Search input (Obsidian inset, focus ring forest-teal) -- Tabs (Artists / Albums / Tracks) — active tab gets 2px forest-teal bottom border -- Card grid using `` -- Track-kind confirm modal: opens on Request click with a track-state result; "Requesting *Track X* will add the album *Album Y*. Continue?" — Confirm = Moss, Cancel = Bronze. Modal dismissed = no-op. - -Debounce search input by 250ms before querying. - -**Tests:** -- Debounced query fires correct API call with kind selector. -- Tab switch refetches with new `kind`. -- Track-kind result triggers modal; confirm triggers API call; cancel does not. -- Requestable card with `onRequest` flips to `requested` state on success. -- Empty results state shows "Nothing to add for that search yet." (voice-rule copy). - -Commit: `feat(web): add /discover route with search + request flow`. - ---- - -### Task 17 — Frontend: `/requests` user request history - -**Files:** Create `web/src/routes/requests/+page.svelte`, `requests.test.ts`. Modify `Shell.svelte` to add `/requests` link in the main nav (visible to all authed users). - -Page renders the caller's requests as rows (mirrors the mockup at `.superpowers/brainstorm/.../user-requests.html`). Each row: -- 56px album-art square (Slate fallback) -- Kind pill + StatusPill -- Title + meta line -- Per-status actions: Cancel button on pending; "Listen" link (forest-teal text) on completed (navigates to `/tracks/` if set, else fallthrough to album/artist) - -**Tests:** -- Renders one row per request from the API. -- Pending row exposes Cancel; Cancel calls API and removes row. -- Completed row renders "Listen" link with correct href. -- Rejected row renders admin notes if present, hides "Cancel" / "Listen." -- Empty list shows "Nothing requested yet." (voice-rule copy). - -Commit: `feat(web): add /requests user-facing request history`. - ---- - -### Task 18 — Frontend: `/admin/*` layout + role gate - -**Files:** Create `web/src/routes/admin/+layout.svelte`, `web/src/routes/admin/+layout.ts`, `web/src/routes/admin/+page.svelte` (Overview landing). - -`+layout.ts` exports a `load` function that checks `currentUser.is_admin`; if false, throws a SvelteKit `redirect(302, '/')`. Redirect happens before layout/child renders — exactly the hard route gate the operator specified. - -`+layout.svelte` renders the admin shell: -- Page header: "Admin" wordmark in Fraunces, the FabledSword small mark in Oxblood at top-left -- 220px sidebar `` component (separate file `web/src/lib/components/AdminSidebar.svelte`) with nav items: Overview / Integrations / **Requests** / Quarantine (placeholder, dimmed) / Users (placeholder, dimmed) / Library (placeholder, dimmed) -- Active nav item: 12% accent-tinted bg + 2px forest-teal left strip -- Main content area: `` for children - -`+page.svelte` (Overview): plain landing with two callout cards — "Pending requests: N" and "Lidarr: connected/unset" — each linking to its sub-page. Functional, not decorative. - -**Tests** (browser-mode, since SvelteKit `load` requires it): -- Non-admin user redirected to `/` before layout renders. -- Admin user lands on `/admin` and sees sidebar with Overview active. - -Commit: `feat(web): add /admin layout with role-gated load + sidebar`. - ---- - -### Task 19 — Frontend: `/admin/integrations` Lidarr panel - -**Files:** Create `web/src/routes/admin/integrations/+page.svelte`, `integrations.test.ts`. - -Page elements (matches mockup `admin-integrations.html`): -- Page header with status pill ("Lidarr · connected" Moss-tinted; "unset" Pewter ghost when not configured) -- Form section "Lidarr" with rows: - - Base URL — text input (Obsidian inset, JetBrains Mono for the URL value) - - API key — password input (masked) - - Default quality profile — `` populated from `GET /api/admin/lidarr/root-folders` -- Action row: Save changes (Moss + check icon), Test connection (Pewter ghost + refresh icon), Disconnect (Oxblood + trash icon, right-aligned) -- Disconnect requires a typed-confirm modal ("Type DISCONNECT to remove the Lidarr connection") because it sets `enabled=false` and clears `api_key`. - -Disabled section "MusicBrainz overrides" with `unset` foreshadows future integrations. Visually present, not implemented. - -**Tests:** -- Save changes calls PUT with form values. -- Empty api_key field on Save preserves saved value (sends empty string per spec). -- Test connection populates Lidarr's reported version on success. -- Disconnect requires modal confirmation; cancelling modal does not clear config. -- Quality-profile / root-folder dropdowns populated from API. - -Commit: `feat(web): add /admin/integrations Lidarr connection panel`. - ---- - -### Task 20 — Frontend: `/admin/requests` approval queue + override modal - -**Files:** Create `web/src/routes/admin/requests/+page.svelte`, `requests.test.ts`. Reuse ``. - -Page elements (matches mockup `admin-requests.html`): -- Tabs: Pending (default) / Approved / Completed / Rejected — each shows count from API as accent-tinted pill -- Request rows with action cluster: Override (Pewter ghost), Approve (Moss + check icon), Reject (Bronze + ✕ icon) -- Track-kind row's meta line spells out "Approving will add the album *X*" -- Override modal: collapsed-by-default form with Quality profile dropdown (populated via the admin endpoint) + Root folder dropdown; "Use defaults" leaves both empty (server uses snapshot defaults) - -**Tests:** -- Tab switch refetches with `?status=`. -- Approve fires POST with optional override values. -- Reject opens a notes input (textarea) above a Confirm button; Confirm sends notes. -- Approve with override modal returns chosen values to handler. -- Toast on Lidarr-unreachable error. - -Commit: `feat(web): add /admin/requests approval queue with override modal`. - ---- - -### Task 21 — Final verification + branch finish - -- [ ] **Step 21.1: Full Go test sweep** - -```bash -go test -short -race ./... -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race -p 1 ./... -``` - -Expected: short suite + integration suite both green. - -- [ ] **Step 21.2: Lint clean** - -```bash -golangci-lint run ./... -``` - -- [ ] **Step 21.3: Coverage check on new packages** - -```bash -go test -race -coverprofile=/tmp/cov.out ./internal/lidarr/... ./internal/lidarrconfig/... ./internal/lidarrrequests/... -go tool cover -func=/tmp/cov.out | tail -1 -``` - -Expected: combined ≥ 80% per spec §8. - -- [ ] **Step 21.4: Frontend full check** - -```bash -cd web && npm run check && npm test && npm run build -``` - -Expected: 0 errors, all vitest tests pass, build succeeds. - -- [ ] **Step 21.5: Manual smoke** - -- Set Lidarr config in `/admin/integrations` (use real Lidarr or stub). -- Search at `/discover`, request an artist. -- Approve from `/admin/requests`. -- Verify request shows up at `/requests` as Approved → wait for next library scan → status flips to Kept. -- Cancel a pending request from `/requests`. -- Verify non-admin is redirected when navigating to `/admin/*`. - -- [ ] **Step 21.6: Use `superpowers:finishing-a-development-branch`** - -Verify tests are still green, then run the skill to present finish options (merge / PR / keep / discard). Default for this slice is "create a PR to main" matching the established cadence. - ---- - -## Self-review checklist (run before declaring the plan ready) - -**Spec coverage** — every spec section maps to a task: -- §3 Architecture: Tasks 2 (client), 3 (config), 4 (service), 5 (reconciler), 6 (middleware), 11 (wiring) -- §4 Schema: Task 1 -- §5 API surface: Tasks 7 (search), 8 (requests CRUD), 9 (admin lidarr), 10 (admin requests) -- §6 UI surfaces: Tasks 12 (tokens), 13 (api), 14 (DiscoverResultCard), 15 (StatusPill), 16 (/discover), 17 (/requests), 18 (/admin layout), 19 (/admin/integrations), 20 (/admin/requests) -- §7 Error handling: distributed across Tasks 7-10 (each handler maps Service errors to API codes) -- §8 Testing: every Task includes tests; Task 21 verifies coverage targets -- §9 Decisions ledger: not directly implemented but referenced in commit messages -- §10 Out of scope: explicitly excluded — no quarantine, no suggested-additions, no webhook -- §11 Open questions: cover-art proxy + debounce/cache deferred to plan time → debounce at 250ms in Task 16; cover-art direct fetch (no proxy) for v1 - -**Placeholder scan:** the per-task detail level drops after Task 4 (each becomes one paragraph) — this is intentional for plan navigability, not a placeholder. When a subagent picks up Task 5+ they expand the paragraph into the same step-level TDD detail using Tasks 1-4 as templates, and reference the spec for any ambiguity. No "TBD" or "TODO" remains. - -**Type consistency:** -- Method names match across plan: `Service.Create/ListPending/ListByStatus/ListForUser/Approve/Reject/Cancel`, `Reconciler.Run/tickOnce`, `Client.LookupArtist/LookupAlbum/LookupTrack/AddArtist/AddAlbum/ListQualityProfiles/ListRootFolders/Ping` -- API paths match spec §5 -- Component names: ``, ``, `` — used consistently -- DB field names: `lidarr_artist_mbid`, `lidarr_album_mbid`, `lidarr_track_mbid`, `quality_profile_id`, `root_folder_path`, `matched_track_id`, etc. — consistent - -Plan is complete. diff --git a/docs/superpowers/plans/2026-04-30-m5b-quarantine.md b/docs/superpowers/plans/2026-04-30-m5b-quarantine.md deleted file mode 100644 index 358cf510..00000000 --- a/docs/superpowers/plans/2026-04-30-m5b-quarantine.md +++ /dev/null @@ -1,2916 +0,0 @@ -# M5b — Quarantine workflow + admin resolution UI — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Wire per-user track-level quarantine into Minstrel — flag affordance via a kebab `` on every track row + the player, soft-hide enforcement on user-context `/api/*` reads, dedicated `/library/hidden` for the user, aggregated admin queue at `/admin/quarantine` with Resolve / Delete file / Delete via Lidarr actions, audit log for admin actions. - -**Architecture:** New `internal/lidarrquarantine` package (Service, no background worker) backed by two tables (`lidarr_quarantine` per-user complaints + `lidarr_quarantine_actions` audit log). Lidarr HTTP client gains `LookupArtistByMBID`, `LookupAlbumByMBID`, `DeleteAlbum` (always called with `deleteFiles=true` and `addImportListExclusion=true`). `internal/library` gains `DeleteTrackFile`. Existing read queries that return tracks in user-context get `*ForUser` variants that join against `lidarr_quarantine`; Subsonic queries are untouched. SPA gets a `` overflow component (mounted in `TrackRow` and `PlayerBar`) opening a ``, plus `/library/hidden` and `/admin/quarantine` routes. - -**Tech Stack:** Go 1.23 · chi router · pgx/v5 + sqlc · Postgres + golang-migrate · SvelteKit 2 / Svelte 5 (runes) · TanStack Query · Vitest · golangci-lint · FabledSword design tokens (existing M5a infrastructure). - -**Spec:** [`docs/superpowers/specs/2026-04-30-m5b-quarantine-design.md`](../specs/2026-04-30-m5b-quarantine-design.md). Read it before starting — every decision is explained there. - -**Memory dependencies:** `project_design_system.md` (FabledSword token palette + voice rules), `project_subsonic_legacy.md` (`/rest/*` does not honor quarantine), `project_no_github.md` (Forgejo MCP for PR ops, not gh CLI), `project_git_workflow.md` (commit on `dev`; PR to `main` separately). - ---- - -## File map - -### Backend — create - -- `internal/db/migrations/0011_lidarr_quarantine.up.sql` · `0011_lidarr_quarantine.down.sql` — schema -- `internal/db/queries/lidarr_quarantine.sql` — sqlc queries for both tables -- `internal/lidarrquarantine/service.go` — `Service` (Flag/Unflag/ListMine/ListAdminQueue/Resolve/DeleteFile/DeleteViaLidarr) -- `internal/lidarrquarantine/service_test.go` — integration tests -- `internal/lidarr/lookup_mbid.go` — `LookupArtistByMBID`, `LookupAlbumByMBID` (split from `client.go` to keep that file from growing) -- `internal/lidarr/delete.go` — `DeleteAlbum` HTTP method + `DELETE` helper -- `internal/lidarr/delete_test.go` — tests for the new methods -- `internal/lidarr/testdata/album_lookup_by_mbid.json`, `artist_lookup_by_mbid.json` — captured fixtures -- `internal/library/delete.go` — `DeleteTrackFile` -- `internal/library/delete_test.go` — tests -- `internal/api/quarantine.go` — `/api/quarantine/*` user-facing handlers -- `internal/api/quarantine_test.go` -- `internal/api/admin_quarantine.go` — `/api/admin/quarantine/*` admin handlers -- `internal/api/admin_quarantine_test.go` - -### Backend — modify - -- `internal/db/queries/tracks.sql` — add `ListTracksByAlbumForUser`, `SearchTracksForUser`, `CountTracksMatchingForUser` (filtered variants) -- `internal/db/queries/recommendation.sql` — extend `LoadRadioCandidates` and `LoadRadioCandidatesV2` to also exclude quarantined tracks -- `internal/api/api.go` — register routes, mount `/api/admin/quarantine` group, route the modified user-context endpoints to the `*ForUser` queries -- `internal/api/auth_test.go` — extend `testHandlers` to inject `lidarrquarantine.Service` -- `internal/api/albums.go` (or wherever album-detail composes its track list) — switch to `ListTracksByAlbumForUser` when user context is present -- `internal/api/search.go` — switch to `SearchTracksForUser` -- `internal/api/radio.go` (or wherever radio handlers live) — pass through the existing user_id parameter to the now-quarantine-aware query -- `cmd/minstrel/main.go` — construct `lidarrquarantine.Service` and inject -- `internal/db/dbq/*` — regenerated by `sqlc generate` - -### Frontend — create - -- `web/src/lib/api/quarantine.ts` — user-facing client (Flag/Unflag/ListMine) -- `web/src/lib/api/quarantine.test.ts` -- `web/src/lib/components/TrackMenu.svelte` — kebab overflow menu -- `web/src/lib/components/TrackMenu.test.ts` -- `web/src/lib/components/FlagPopover.svelte` — reason + notes form -- `web/src/lib/components/FlagPopover.test.ts` -- `web/src/lib/components/QuarantineRow.svelte` — shared row used by both `/library/hidden` and `/admin/quarantine` -- `web/src/lib/components/QuarantineRow.test.ts` -- `web/src/routes/library/hidden/+page.svelte` -- `web/src/routes/library/hidden/hidden.test.ts` -- `web/src/routes/admin/quarantine/+page.svelte` -- `web/src/routes/admin/quarantine/quarantine.test.ts` - -### Frontend — modify - -- `web/src/lib/api/admin.ts` — add `listAdminQuarantine`, `resolveQuarantine`, `deleteQuarantineFile`, `deleteQuarantineViaLidarr`, `listQuarantineActions` plus query factories -- `web/src/lib/api/queries.ts` — add `qk.myQuarantine`, `qk.adminQuarantine`, `qk.adminQuarantineActions` -- `web/src/lib/api/types.ts` — add `LidarrQuarantineReason`, `LidarrQuarantineRow`, `AdminQuarantineRow`, `LidarrQuarantineActionRow`, `LidarrQuarantineAction` enums -- `web/src/lib/components/Shell.svelte` — add `Hidden` to the main nav after `Liked` -- `web/src/lib/components/Shell.test.ts` — assert the new nav order -- `web/src/lib/components/AdminSidebar.svelte` — promote `Quarantine` from `placeholder: true` to a real link -- `web/src/lib/components/AdminSidebar.test.ts` — update tests; quarantine is now a link -- `web/src/lib/components/TrackRow.svelte` — mount `` next to `` -- `web/src/lib/components/TrackRow.test.ts` — extend to cover the menu -- `web/src/lib/components/PlayerBar.svelte` — mount `` in the right cluster -- `web/src/lib/components/PlayerBar.test.ts` — extend to cover the menu - ---- - -## Task list - -### Task 1 — Migration 0011 + sqlc queries - -**Files:** -- Create: `internal/db/migrations/0011_lidarr_quarantine.up.sql` -- Create: `internal/db/migrations/0011_lidarr_quarantine.down.sql` -- Create: `internal/db/queries/lidarr_quarantine.sql` -- Modify: `internal/db/dbq/*` (regenerated by `sqlc generate`) - -- [ ] **Step 1.1: Write the up migration** - -`internal/db/migrations/0011_lidarr_quarantine.up.sql`: - -```sql --- M5b: per-user track quarantines + admin action audit log. --- --- lidarr_quarantine — one row per (user, track) complaint. PK matches --- the general_likes pattern. Re-flagging the same track upserts. Deleted --- on user resolution (un-hide), admin Resolve, or any of the deletes. --- --- lidarr_quarantine_actions — audit log of admin destructive actions. --- Snapshot text columns let the log stay readable after the underlying --- track/album rows are gone. - -CREATE TYPE lidarr_quarantine_reason AS ENUM ( - 'bad_rip', 'wrong_file', 'wrong_tags', 'duplicate', 'other' -); - -CREATE TABLE lidarr_quarantine ( - user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, - track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, - reason lidarr_quarantine_reason NOT NULL, - notes text, - created_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, track_id) -); - -CREATE INDEX lidarr_quarantine_track_idx ON lidarr_quarantine (track_id); -CREATE INDEX lidarr_quarantine_user_idx ON lidarr_quarantine (user_id, created_at DESC); - -CREATE TYPE lidarr_quarantine_action AS ENUM ( - 'resolved', 'deleted_file', 'deleted_via_lidarr' -); - -CREATE TABLE lidarr_quarantine_actions ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - track_id uuid NOT NULL, - track_title text NOT NULL, - artist_name text NOT NULL, - album_title text, - action lidarr_quarantine_action NOT NULL, - admin_id uuid REFERENCES users(id) ON DELETE SET NULL, - lidarr_album_mbid text, - affected_users int NOT NULL, - created_at timestamptz NOT NULL DEFAULT now() -); - -CREATE INDEX lidarr_quarantine_actions_track_idx ON lidarr_quarantine_actions (track_id); -CREATE INDEX lidarr_quarantine_actions_created_idx ON lidarr_quarantine_actions (created_at DESC); -``` - -- [ ] **Step 1.2: Write the down migration** - -`internal/db/migrations/0011_lidarr_quarantine.down.sql`: - -```sql -DROP INDEX IF EXISTS lidarr_quarantine_actions_created_idx; -DROP INDEX IF EXISTS lidarr_quarantine_actions_track_idx; -DROP TABLE IF EXISTS lidarr_quarantine_actions; -DROP TYPE IF EXISTS lidarr_quarantine_action; -DROP INDEX IF EXISTS lidarr_quarantine_user_idx; -DROP INDEX IF EXISTS lidarr_quarantine_track_idx; -DROP TABLE IF EXISTS lidarr_quarantine; -DROP TYPE IF EXISTS lidarr_quarantine_reason; -``` - -- [ ] **Step 1.3: Apply migration locally to confirm it runs** - -```bash -docker compose up -d postgres -docker compose exec -T postgres psql -U minstrel -d minstrel -c "DROP TABLE IF EXISTS lidarr_quarantine_actions; DROP TYPE IF EXISTS lidarr_quarantine_action; DROP TABLE IF EXISTS lidarr_quarantine; DROP TYPE IF EXISTS lidarr_quarantine_reason;" -go run ./cmd/minstrel up 2>/dev/null || true # apply via server start instead -docker compose exec -T postgres psql -U minstrel -d minstrel -c "\d lidarr_quarantine" -docker compose exec -T postgres psql -U minstrel -d minstrel -c "\d lidarr_quarantine_actions" -``` - -Expected: both `\d` commands print the table with columns and indexes. - -If the project doesn't have a standalone migrate command, the migration applies on server start via `db.Migrate(...)` — restart the minstrel container instead. - -- [ ] **Step 1.4: Write the queries** - -`internal/db/queries/lidarr_quarantine.sql`: - -```sql --- name: UpsertQuarantine :one --- Insert a new quarantine row, or update reason/notes if the user has --- already flagged this track. -INSERT INTO lidarr_quarantine (user_id, track_id, reason, notes) -VALUES ($1, $2, $3, $4) -ON CONFLICT (user_id, track_id) DO UPDATE SET - reason = EXCLUDED.reason, - notes = EXCLUDED.notes, - created_at = now() -RETURNING user_id, track_id, reason, notes, created_at; - --- name: DeleteQuarantine :one --- Removes the caller's row. Returns the deleted row so the handler can --- distinguish "no row existed" (zero rows -> ErrNoRows) from success. -DELETE FROM lidarr_quarantine - WHERE user_id = $1 AND track_id = $2 - RETURNING user_id, track_id, reason, notes, created_at; - --- name: ListQuarantineForUser :many --- Caller's own quarantines joined with track + album + artist for full --- detail. Drives /library/hidden. -SELECT - sqlc.embed(q), - sqlc.embed(t), - sqlc.embed(al), - sqlc.embed(ar) -FROM lidarr_quarantine q -JOIN tracks t ON t.id = q.track_id -JOIN albums al ON al.id = t.album_id -JOIN artists ar ON ar.id = t.artist_id -WHERE q.user_id = $1 -ORDER BY q.created_at DESC; - --- name: ListAdminQuarantineQueue :many --- Aggregated admin queue. One row per track. The handler post-processes --- the rows it gets from this query plus a per-track ListQuarantineReports --- call to materialize reason_counts and the per-user reports list. -SELECT - t.id AS track_id, - t.title AS track_title, - ar.name AS artist_name, - al.title AS album_title, - al.id AS album_id, - al.mbid AS lidarr_album_mbid, - count(q.user_id)::int AS report_count, - max(q.created_at) AS latest_at -FROM lidarr_quarantine q -JOIN tracks t ON t.id = q.track_id -JOIN albums al ON al.id = t.album_id -JOIN artists ar ON ar.id = t.artist_id -GROUP BY t.id, ar.name, al.title, al.id, al.mbid -ORDER BY max(q.created_at) DESC; - --- name: ListQuarantineReportsForTrack :many --- Per-user reports for a single track. Returned by ListAdminQuarantineQueue --- post-processing and exposed expandable in the SPA admin queue rows. -SELECT - q.user_id, - u.username, - q.reason, - q.notes, - q.created_at -FROM lidarr_quarantine q -JOIN users u ON u.id = q.user_id -WHERE q.track_id = $1 -ORDER BY q.created_at DESC; - --- name: DeleteQuarantineForTrack :exec --- Clears all per-user rows for a given track. Used by Resolve and the --- two delete actions. Caller writes the audit row separately before --- this fires (so we can capture the affected_users count). -DELETE FROM lidarr_quarantine WHERE track_id = $1; - --- name: CountQuarantineForTrack :one --- Reads affected_users for the audit row before the delete fires. -SELECT count(*)::int FROM lidarr_quarantine WHERE track_id = $1; - --- name: WriteQuarantineAction :one --- Audit row for an admin destructive action. -INSERT INTO lidarr_quarantine_actions ( - track_id, track_title, artist_name, album_title, - action, admin_id, lidarr_album_mbid, affected_users -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -RETURNING *; - --- name: ListQuarantineActions :many -SELECT * FROM lidarr_quarantine_actions -ORDER BY created_at DESC -LIMIT $1; -``` - -- [ ] **Step 1.5: Run sqlc generate** - -```bash -cd internal/db && sqlc generate && cd - -go build ./... -``` - -Expected: clean build. New types `LidarrQuarantine`, `LidarrQuarantineAction`, `ListAdminQuarantineQueueRow`, `ListQuarantineForUserRow`, `ListQuarantineReportsForTrackRow` etc. surface in `internal/db/dbq/`. - -- [ ] **Step 1.6: Commit** - -```bash -git add internal/db/migrations/0011_lidarr_quarantine.up.sql \ - internal/db/migrations/0011_lidarr_quarantine.down.sql \ - internal/db/queries/lidarr_quarantine.sql \ - internal/db/dbq/ -git commit -m "feat(db): add lidarr_quarantine + actions schema (migration 0011)" -``` - ---- - -### Task 2 — Lidarr HTTP client extensions - -**Files:** -- Create: `internal/lidarr/lookup_mbid.go` -- Create: `internal/lidarr/delete.go` -- Create: `internal/lidarr/delete_test.go` -- Create: `internal/lidarr/testdata/album_lookup_by_mbid.json` -- Create: `internal/lidarr/testdata/artist_lookup_by_mbid.json` -- Modify: `internal/lidarr/types.go` — add `LidarrArtist`, `LidarrAlbum` - -The existing M5a client lives in `internal/lidarr/client.go`. To keep it from sprawling, the M5b additions land in two new files: `lookup_mbid.go` for the GET-by-MBID methods and `delete.go` for the DELETE method + a tiny `del()` HTTP helper. - -- [ ] **Step 2.1: Add the typed structs** - -`internal/lidarr/types.go` (modify) — append the two structs at the bottom of the file: - -```go -// LidarrArtist is the subset of Lidarr's artist resource used by M5b -// admin actions. The "id" field is Lidarr's internal numeric ID — needed -// for DELETE /api/v1/artist/{id} calls. -type LidarrArtist struct { - ID int `json:"id"` - ForeignArtistID string `json:"foreignArtistId"` // MBID - ArtistName string `json:"artistName"` -} - -// LidarrAlbum is the subset of Lidarr's album resource used by M5b -// admin actions. -type LidarrAlbum struct { - ID int `json:"id"` - ForeignAlbumID string `json:"foreignAlbumId"` // MBID - Title string `json:"title"` - ArtistID int `json:"artistId"` -} -``` - -- [ ] **Step 2.2: Add a sentinel error for not-found** - -`internal/lidarr/errors.go` (modify) — append: - -```go -// ErrNotFound is returned by LookupArtistByMBID and LookupAlbumByMBID -// when Lidarr returns 200 with an empty array — i.e., the MBID isn't in -// Lidarr's monitored set. Distinguished from network/auth errors so admin -// handlers can surface it as `lidarr_album_lookup_failed` (502) instead -// of `lidarr_unreachable` (503). -var ErrNotFound = errors.New("lidarr: not found") -``` - -If `errors.go` doesn't already import `"errors"`, add it. - -- [ ] **Step 2.3: Write `lookup_mbid.go`** - -```go -package lidarr - -import ( - "context" - "encoding/json" - "fmt" - "net/url" -) - -// LookupArtistByMBID returns the artist Lidarr has indexed under that -// MBID. Returns ErrNotFound if Lidarr returns an empty array. -func (c *Client) LookupArtistByMBID(ctx context.Context, mbid string) (LidarrArtist, error) { - if mbid == "" { - return LidarrArtist{}, fmt.Errorf("lidarr: empty mbid") - } - q := url.Values{"mbId": []string{mbid}} - resp, err := c.get(ctx, "/api/v1/artist", q) - if err != nil { - return LidarrArtist{}, err - } - defer resp.Body.Close() - - var rows []LidarrArtist - if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil { - return LidarrArtist{}, fmt.Errorf("lidarr: decode artist: %w", err) - } - if len(rows) == 0 { - return LidarrArtist{}, ErrNotFound - } - return rows[0], nil -} - -// LookupAlbumByMBID returns the album Lidarr has indexed under that -// MBID. Returns ErrNotFound on empty result. -func (c *Client) LookupAlbumByMBID(ctx context.Context, mbid string) (LidarrAlbum, error) { - if mbid == "" { - return LidarrAlbum{}, fmt.Errorf("lidarr: empty mbid") - } - q := url.Values{"foreignAlbumId": []string{mbid}} - resp, err := c.get(ctx, "/api/v1/album", q) - if err != nil { - return LidarrAlbum{}, err - } - defer resp.Body.Close() - - var rows []LidarrAlbum - if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil { - return LidarrAlbum{}, fmt.Errorf("lidarr: decode album: %w", err) - } - if len(rows) == 0 { - return LidarrAlbum{}, ErrNotFound - } - return rows[0], nil -} -``` - -- [ ] **Step 2.4: Write `delete.go`** - -```go -package lidarr - -import ( - "context" - "fmt" - "net/http" - "net/url" - "strconv" -) - -// del issues a DELETE against the given path with optional query params. -// Mirrors the existing get/post helpers in client.go; consolidating the -// auth header + base-URL handling behavior in one place. -func (c *Client) del(ctx context.Context, path string, q url.Values) (*http.Response, error) { - u, err := c.url(path) - if err != nil { - return nil, err - } - if q != nil { - u.RawQuery = q.Encode() - } - req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u.String(), nil) - if err != nil { - return nil, fmt.Errorf("lidarr: build DELETE: %w", err) - } - req.Header.Set("X-Api-Key", c.APIKey) - - resp, err := c.HTTP.Do(req) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) - } - if resp.StatusCode == http.StatusUnauthorized { - resp.Body.Close() - return nil, ErrAuthFailed - } - if resp.StatusCode >= 400 { - resp.Body.Close() - return nil, fmt.Errorf("%w: status %d", ErrLookupFailed, resp.StatusCode) - } - return resp, nil -} - -// DeleteAlbum removes an album from Lidarr's library. -// - deleteFiles=true also removes the audio files from disk. -// - addImportListExclusion=true tells Lidarr to never re-add this album -// via import-list scans. -// -// M5b's admin "delete via Lidarr" action always passes both `true`. -func (c *Client) DeleteAlbum(ctx context.Context, lidarrAlbumID int, deleteFiles, addImportListExclusion bool) error { - if lidarrAlbumID == 0 { - return fmt.Errorf("lidarr: zero album id") - } - q := url.Values{ - "deleteFiles": []string{strconv.FormatBool(deleteFiles)}, - "addImportListExclusion": []string{strconv.FormatBool(addImportListExclusion)}, - } - resp, err := c.del(ctx, "/api/v1/album/"+strconv.Itoa(lidarrAlbumID), q) - if err != nil { - return err - } - resp.Body.Close() - return nil -} -``` - -If `client.go` doesn't already export a `url(path)` helper, look at how `get(ctx, path, q)` builds its URL and either factor out the helper or inline the logic here. (M5a's `client.go` has `c.url(path)` at the top of the file — check before duplicating.) - -- [ ] **Step 2.5: Capture fixtures** - -`internal/lidarr/testdata/album_lookup_by_mbid.json` — a single-element JSON array matching what Lidarr returns for `GET /api/v1/album?foreignAlbumId=`: - -```json -[ - { - "id": 42, - "foreignAlbumId": "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d", - "title": "Music Has The Right To Children", - "artistId": 7 - } -] -``` - -`internal/lidarr/testdata/artist_lookup_by_mbid.json` — same shape: - -```json -[ - { - "id": 7, - "foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", - "artistName": "Boards of Canada" - } -] -``` - -- [ ] **Step 2.6: Write `delete_test.go` (covers all three new methods)** - -```go -package lidarr - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "os" - "testing" -) - -func TestLookupAlbumByMBID_HappyPath(t *testing.T) { - body, err := os.ReadFile("testdata/album_lookup_by_mbid.json") - if err != nil { - t.Fatalf("read fixture: %v", err) - } - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/album" { - t.Errorf("path = %q, want /api/v1/album", r.URL.Path) - } - if got := r.URL.Query().Get("foreignAlbumId"); got != "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d" { - t.Errorf("foreignAlbumId = %q", got) - } - if got := r.Header.Get("X-Api-Key"); got != "test-key" { - t.Errorf("X-Api-Key = %q, want test-key", got) - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(body) - }) - defer srv.Close() - - got, err := c.LookupAlbumByMBID(context.Background(), "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d") - if err != nil { - t.Fatalf("LookupAlbumByMBID: %v", err) - } - if got.ID != 42 || got.Title != "Music Has The Right To Children" { - t.Errorf("got = %+v", got) - } -} - -func TestLookupAlbumByMBID_EmptyArrayReturnsErrNotFound(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("[]")) - }) - defer srv.Close() - - _, err := c.LookupAlbumByMBID(context.Background(), "x") - if !errors.Is(err, ErrNotFound) { - t.Errorf("err = %v, want ErrNotFound", err) - } -} - -func TestLookupAlbumByMBID_AuthFailed(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - }) - defer srv.Close() - - _, err := c.LookupAlbumByMBID(context.Background(), "x") - if !errors.Is(err, ErrAuthFailed) { - t.Errorf("err = %v, want ErrAuthFailed", err) - } -} - -func TestLookupArtistByMBID_HappyPath(t *testing.T) { - body, err := os.ReadFile("testdata/artist_lookup_by_mbid.json") - if err != nil { - t.Fatalf("read fixture: %v", err) - } - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/artist" { - t.Errorf("path = %q, want /api/v1/artist", r.URL.Path) - } - if got := r.URL.Query().Get("mbId"); got != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" { - t.Errorf("mbId = %q", got) - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(body) - }) - defer srv.Close() - - got, err := c.LookupArtistByMBID(context.Background(), "069b64b6-7884-4f6a-94cc-e4c1d6c87a01") - if err != nil { - t.Fatalf("LookupArtistByMBID: %v", err) - } - if got.ID != 7 || got.ArtistName != "Boards of Canada" { - t.Errorf("got = %+v", got) - } -} - -func TestDeleteAlbum_PassesBothFlags(t *testing.T) { - var captured *http.Request - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - captured = r - w.WriteHeader(http.StatusOK) - }) - defer srv.Close() - - if err := c.DeleteAlbum(context.Background(), 42, true, true); err != nil { - t.Fatalf("DeleteAlbum: %v", err) - } - if captured == nil || captured.Method != http.MethodDelete { - t.Fatalf("method = %v, want DELETE", captured) - } - if captured.URL.Path != "/api/v1/album/42" { - t.Errorf("path = %q", captured.URL.Path) - } - if got := captured.URL.Query().Get("deleteFiles"); got != "true" { - t.Errorf("deleteFiles = %q", got) - } - if got := captured.URL.Query().Get("addImportListExclusion"); got != "true" { - t.Errorf("addImportListExclusion = %q", got) - } -} - -func TestDeleteAlbum_5xxReturnsErrLookupFailed(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - }) - defer srv.Close() - - err := c.DeleteAlbum(context.Background(), 42, true, true) - if !errors.Is(err, ErrLookupFailed) { - t.Errorf("err = %v, want ErrLookupFailed", err) - } -} - -func TestDeleteAlbum_NetworkErrorReturnsErrUnreachable(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - srv.Close() // server is closed; client should fail to connect - c := NewClient(srv.URL, "test-key") - - err := c.DeleteAlbum(context.Background(), 42, true, true) - if !errors.Is(err, ErrUnreachable) { - t.Errorf("err = %v, want ErrUnreachable", err) - } -} -``` - -`newTestClient` is the existing helper in `client_test.go` (M5a). Reuse it. - -- [ ] **Step 2.7: Run tests + build** - -```bash -go test ./internal/lidarr/... -count=1 -go build ./... -``` - -Expected: all green. - -- [ ] **Step 2.8: Commit** - -```bash -git add internal/lidarr/ -git commit -m "feat(lidarr): LookupArtistByMBID, LookupAlbumByMBID, DeleteAlbum" -``` - ---- - -### Task 3 — `internal/library` `DeleteTrackFile` - -**Files:** -- Create: `internal/library/delete.go` -- Create: `internal/library/delete_test.go` - -The admin "Delete file" action removes the file from disk and the row from `tracks`. The album/artist rows stay. Other tracks may reference them; the admin only nuked one track. - -- [ ] **Step 3.1: Write `delete.go`** - -```go -package library - -import ( - "context" - "errors" - "fmt" - "io/fs" - "os" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// ErrTrackNotFound is returned when DeleteTrackFile is called with an id -// that has no row in tracks. -var ErrTrackNotFound = errors.New("library: track not found") - -// DeleteTrackFile removes a track file from disk and its row from the -// tracks table. Album and artist rows are left untouched. -// -// Steps: -// 1. Look up the track to get its file_path. -// 2. Remove the file from disk. fs.ErrNotExist is OK — already gone. -// 3. Delete the tracks row. -// -// Order matters: file first, then DB. If the file delete fails (permission, -// I/O error), we leave the DB row alone so the admin can retry. -func DeleteTrackFile(ctx context.Context, pool *pgxpool.Pool, trackID pgtype.UUID) error { - q := dbq.New(pool) - track, err := q.GetTrackByID(ctx, trackID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrTrackNotFound - } - return fmt.Errorf("get track: %w", err) - } - - if err := os.Remove(track.FilePath); err != nil && !errors.Is(err, fs.ErrNotExist) { - return fmt.Errorf("remove file: %w", err) - } - - if _, err := pool.Exec(ctx, "DELETE FROM tracks WHERE id = $1", trackID); err != nil { - return fmt.Errorf("delete row: %w", err) - } - return nil -} -``` - -- [ ] **Step 3.2: Write `delete_test.go`** - -```go -package library - -import ( - "context" - "errors" - "io" - "log/slog" - "os" - "path/filepath" - "testing" - - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -func TestDeleteTrackFile_HappyPath(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - t.Fatalf("pool: %v", err) - } - t.Cleanup(pool.Close) - - if _, err := pool.Exec(context.Background(), - "TRUNCATE tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { - t.Fatalf("truncate: %v", err) - } - q := dbq.New(pool) - artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "X", SortName: "X"}) - album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "A", SortTitle: "A", ArtistID: artist.ID}) - - // Create a real on-disk file the test can prove is removed. - dir := t.TempDir() - path := filepath.Join(dir, "track.mp3") - if err := os.WriteFile(path, []byte("payload"), 0o644); err != nil { - t.Fatalf("write file: %v", err) - } - track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "T", AlbumID: album.ID, ArtistID: artist.ID, - DurationMs: 1000, FilePath: path, FileSize: 7, FileFormat: "mp3", - }) - if err != nil { - t.Fatalf("upsert: %v", err) - } - - if err := DeleteTrackFile(context.Background(), pool, track.ID); err != nil { - t.Fatalf("DeleteTrackFile: %v", err) - } - - // File gone. - if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { - t.Errorf("file still exists: %v", err) - } - // Row gone. - if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { - t.Errorf("track row still exists") - } - // Album row preserved. - if _, err := q.GetAlbumByID(context.Background(), album.ID); err != nil { - t.Errorf("album row vanished: %v", err) - } -} - -func TestDeleteTrackFile_FileAlreadyGoneSucceeds(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, _ := pgxpool.New(context.Background(), dsn) - t.Cleanup(pool.Close) - if _, err := pool.Exec(context.Background(), - "TRUNCATE tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { - t.Fatalf("truncate: %v", err) - } - q := dbq.New(pool) - artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "X", SortName: "X"}) - album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "A", SortTitle: "A", ArtistID: artist.ID}) - - track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "T", AlbumID: album.ID, ArtistID: artist.ID, - DurationMs: 1000, FilePath: "/no/such/file/anywhere.mp3", FileSize: 0, FileFormat: "mp3", - }) - - if err := DeleteTrackFile(context.Background(), pool, track.ID); err != nil { - t.Fatalf("DeleteTrackFile with missing file: %v", err) - } - if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { - t.Errorf("track row still exists") - } -} - -func TestDeleteTrackFile_NotFoundReturnsErr(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, _ := pgxpool.New(context.Background(), dsn) - t.Cleanup(pool.Close) - - var bogus pgxUUID - bogus.Set([16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}) - err := DeleteTrackFile(context.Background(), pool, bogus.UUID) - if !errors.Is(err, ErrTrackNotFound) { - t.Errorf("err = %v, want ErrTrackNotFound", err) - } -} - -// pgxUUID is a tiny shim for the test — the existing scanner_test.go in -// this package uses raw byte arrays to build a synthetic pgtype.UUID. If -// the convention changes, mirror whatever helper that test uses. -type pgxUUID struct { - UUID interface { - // satisfied by pgtype.UUID - } -} - -func (u *pgxUUID) Set(b [16]byte) { - // Replace this body with whatever the existing tests use to construct - // a pgtype.UUID from raw bytes. If unsure, copy from - // internal/lidarrrequests/service_test.go's TestApprove_NotFound. - panic("replace with the project's pgtype.UUID construction helper") -} -``` - -The `pgxUUID` shim above is a placeholder — when implementing, look at `internal/lidarrrequests/service_test.go:TestApprove_NotFound` which constructs a synthetic UUID with `bogus.Bytes = [16]byte{...}; bogus.Valid = true`. Use that pattern instead. - -- [ ] **Step 3.3: Run tests** - -```bash -docker compose up -d postgres -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race ./internal/library/... -run TestDeleteTrackFile -``` - -Expected: all three subtests pass. - -- [ ] **Step 3.4: Commit** - -```bash -git add internal/library/delete.go internal/library/delete_test.go -git commit -m "feat(library): DeleteTrackFile (rm file + tracks row, album/artist preserved)" -``` - ---- - -### Task 4 — `lidarrquarantine.Service` — Flag/Unflag/ListMine/ListAdminQueue - -**Files:** -- Create: `internal/lidarrquarantine/service.go` -- Create: `internal/lidarrquarantine/service_test.go` - -Read the M5a `internal/lidarrrequests/service.go` first — it's the closest analog. Same shape (`Service` struct, factory function, integration tests gated on `MINSTREL_TEST_DATABASE_URL`, `dbtest.ResetDB` for isolation). Mirror it. - -- [ ] **Step 4.1: Write the package skeleton + read paths** - -`internal/lidarrquarantine/service.go`: - -```go -// Package lidarrquarantine owns the per-user track quarantine workflow. -// Users flag a track as broken (Flag/Unflag), the SPA hides the track -// from their views, and admins resolve the resulting reports via the -// Service's admin actions (Resolve / DeleteFile / DeleteViaLidarr). -package lidarrquarantine - -import ( - "context" - "errors" - "fmt" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" - "git.fabledsword.com/bvandeusen/minstrel/internal/library" -) - -// Public errors. Handlers map these to API codes. -var ( - ErrBadReason = errors.New("lidarrquarantine: invalid reason") - ErrTrackNotFound = errors.New("lidarrquarantine: track not found") - ErrQuarantineNotFound = errors.New("lidarrquarantine: quarantine row not found") - ErrAlbumMBIDMissing = errors.New("lidarrquarantine: track has no parent album mbid") - ErrLidarrAlbumNotFound = errors.New("lidarrquarantine: lidarr has no album for that mbid") - ErrLidarrDisabled = errors.New("lidarrquarantine: lidarr is not configured") -) - -// Service is the lifecycle owner. clientFn is a per-call factory so config -// changes in lidarrconfig take effect immediately. clientFn returns nil -// when Lidarr is disabled. -type Service struct { - pool *pgxpool.Pool - lidarrCfg *lidarrconfig.Service - clientFn func() *lidarr.Client -} - -func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client) *Service { - if clientFn == nil { - clientFn = func() *lidarr.Client { return nil } - } - return &Service{pool: pool, lidarrCfg: cfg, clientFn: clientFn} -} - -// Flag inserts or updates a quarantine row for the caller. Re-flagging -// the same (user, track) overwrites reason+notes. -func (s *Service) Flag(ctx context.Context, userID, trackID pgtype.UUID, reason string, notes string) (dbq.LidarrQuarantine, error) { - if !validReason(reason) { - return dbq.LidarrQuarantine{}, ErrBadReason - } - var notesPtr *string - if notes != "" { - notesPtr = ¬es - } - row, err := dbq.New(s.pool).UpsertQuarantine(ctx, dbq.UpsertQuarantineParams{ - UserID: userID, - TrackID: trackID, - Reason: dbq.LidarrQuarantineReason(reason), - Notes: notesPtr, - }) - if err != nil { - // ON CONFLICT path can't trip ErrNoRows; only an FK violation does - // (track_id doesn't exist). Surface that as ErrTrackNotFound. - return dbq.LidarrQuarantine{}, fmt.Errorf("upsert: %w", err) - } - return row, nil -} - -// Unflag removes the caller's row. Returns ErrQuarantineNotFound if -// no row exists. -func (s *Service) Unflag(ctx context.Context, userID, trackID pgtype.UUID) error { - _, err := dbq.New(s.pool).DeleteQuarantine(ctx, dbq.DeleteQuarantineParams{ - UserID: userID, TrackID: trackID, - }) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrQuarantineNotFound - } - return fmt.Errorf("delete: %w", err) - } - return nil -} - -// ListMine returns the caller's quarantines with track/album/artist -// detail. Drives /library/hidden. -func (s *Service) ListMine(ctx context.Context, userID pgtype.UUID) ([]dbq.ListQuarantineForUserRow, error) { - return dbq.New(s.pool).ListQuarantineForUser(ctx, userID) -} - -// AdminQueueRow is the assembled aggregated row served by the admin -// queue endpoint. The handler post-processes the SQL results to attach -// reason_counts and per-user reports. -type AdminQueueRow struct { - TrackID pgtype.UUID - TrackTitle string - ArtistName string - AlbumTitle *string - AlbumID pgtype.UUID - LidarrAlbumMBID *string - ReportCount int32 - LatestAt pgtype.Timestamptz - ReasonCounts map[string]int - Reports []UserReport -} - -type UserReport struct { - UserID pgtype.UUID - Username string - Reason string - Notes *string - CreatedAt pgtype.Timestamptz -} - -// ListAdminQueue returns the aggregated admin queue. One row per track. -func (s *Service) ListAdminQueue(ctx context.Context) ([]AdminQueueRow, error) { - q := dbq.New(s.pool) - aggregated, err := q.ListAdminQuarantineQueue(ctx) - if err != nil { - return nil, fmt.Errorf("aggregate: %w", err) - } - out := make([]AdminQueueRow, 0, len(aggregated)) - for _, r := range aggregated { - reports, err := q.ListQuarantineReportsForTrack(ctx, r.TrackID) - if err != nil { - return nil, fmt.Errorf("reports for track %v: %w", r.TrackID, err) - } - rc := make(map[string]int, len(reports)) - userReports := make([]UserReport, 0, len(reports)) - for _, rep := range reports { - rc[string(rep.Reason)]++ - userReports = append(userReports, UserReport{ - UserID: rep.UserID, - Username: rep.Username, - Reason: string(rep.Reason), - Notes: rep.Notes, - CreatedAt: rep.CreatedAt, - }) - } - out = append(out, AdminQueueRow{ - TrackID: r.TrackID, - TrackTitle: r.TrackTitle, - ArtistName: r.ArtistName, - AlbumTitle: r.AlbumTitle, - AlbumID: r.AlbumID, - LidarrAlbumMBID: r.LidarrAlbumMbid, - ReportCount: r.ReportCount, - LatestAt: r.LatestAt, - ReasonCounts: rc, - Reports: userReports, - }) - } - return out, nil -} - -func validReason(r string) bool { - switch r { - case "bad_rip", "wrong_file", "wrong_tags", "duplicate", "other": - return true - } - return false -} -``` - -(Admin actions Resolve / DeleteFile / DeleteViaLidarr land in Task 5.) - -- [ ] **Step 4.2: Write the integration tests for the read paths** - -`internal/lidarrquarantine/service_test.go`: - -```go -package lidarrquarantine - -import ( - "context" - "errors" - "io" - "log/slog" - "os" - "path/filepath" - "testing" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" -) - -func newPool(t *testing.T) *pgxpool.Pool { - t.Helper() - if testing.Short() { - t.Skip("skipping integration test in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - t.Fatalf("pool: %v", err) - } - t.Cleanup(pool.Close) - dbtest.ResetDB(t, pool) - if _, err := pool.Exec(context.Background(), - "DELETE FROM lidarr_quarantine; DELETE FROM lidarr_quarantine_actions;"); err != nil { - t.Fatalf("reset quarantine tables: %v", err) - } - return pool -} - -func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User { - t.Helper() - u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ - Username: dbtest.TestUserPrefix + name, PasswordHash: "x", - ApiToken: name + "-token", IsAdmin: false, - }) - if err != nil { - t.Fatalf("seed user %s: %v", name, err) - } - return u -} - -func seedTrack(t *testing.T, pool *pgxpool.Pool, title, mbid string) (dbq.Track, dbq.Album, dbq.Artist) { - t.Helper() - q := dbq.New(pool) - artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ - Name: "Test Artist", SortName: "Test Artist", - }) - if err != nil { - t.Fatalf("artist: %v", err) - } - albumMBID := mbid + "-album" - album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ - Title: "Test Album", SortTitle: "Test Album", - ArtistID: artist.ID, Mbid: &albumMBID, - }) - if err != nil { - t.Fatalf("album: %v", err) - } - track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: title, AlbumID: album.ID, ArtistID: artist.ID, - DurationMs: 1000, FilePath: filepath.Join(t.TempDir(), title+".mp3"), - FileSize: 100, FileFormat: "mp3", - }) - if err != nil { - t.Fatalf("track: %v", err) - } - return track, album, artist -} - -func TestFlag_HappyPath(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - track, _, _ := seedTrack(t, pool, "Bad Track", "abc") - - svc := NewService(pool, lidarrconfig.New(pool), nil) - row, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "crackly") - if err != nil { - t.Fatalf("Flag: %v", err) - } - if string(row.Reason) != "bad_rip" { - t.Errorf("reason = %v", row.Reason) - } - if row.Notes == nil || *row.Notes != "crackly" { - t.Errorf("notes = %v", row.Notes) - } -} - -func TestFlag_UpsertOnSecondFlag(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - track, _, _ := seedTrack(t, pool, "T", "x") - - svc := NewService(pool, lidarrconfig.New(pool), nil) - if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "first"); err != nil { - t.Fatalf("first flag: %v", err) - } - row, err := svc.Flag(context.Background(), user.ID, track.ID, "wrong_tags", "") - if err != nil { - t.Fatalf("second flag: %v", err) - } - if string(row.Reason) != "wrong_tags" { - t.Errorf("reason = %v", row.Reason) - } - if row.Notes != nil { - t.Errorf("notes = %v, want nil after empty notes upsert", row.Notes) - } -} - -func TestFlag_BadReasonRejected(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - track, _, _ := seedTrack(t, pool, "T", "x") - - svc := NewService(pool, lidarrconfig.New(pool), nil) - _, err := svc.Flag(context.Background(), user.ID, track.ID, "garbage", "") - if !errors.Is(err, ErrBadReason) { - t.Errorf("err = %v, want ErrBadReason", err) - } -} - -func TestUnflag_DeletesRow(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - track, _, _ := seedTrack(t, pool, "T", "x") - - svc := NewService(pool, lidarrconfig.New(pool), nil) - _, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "") - if err := svc.Unflag(context.Background(), user.ID, track.ID); err != nil { - t.Fatalf("Unflag: %v", err) - } - if err := svc.Unflag(context.Background(), user.ID, track.ID); !errors.Is(err, ErrQuarantineNotFound) { - t.Errorf("second Unflag err = %v, want ErrQuarantineNotFound", err) - } -} - -func TestListMine_OrderedNewestFirst(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - t1, _, _ := seedTrack(t, pool, "T1", "x") - t2, _, _ := seedTrack(t, pool, "T2", "y") - - svc := NewService(pool, lidarrconfig.New(pool), nil) - _, _ = svc.Flag(context.Background(), user.ID, t1.ID, "bad_rip", "") - _, _ = svc.Flag(context.Background(), user.ID, t2.ID, "duplicate", "") - - rows, err := svc.ListMine(context.Background(), user.ID) - if err != nil { - t.Fatalf("ListMine: %v", err) - } - if len(rows) != 2 { - t.Fatalf("len = %d, want 2", len(rows)) - } - // T2 was flagged second — newest first. - if rows[0].LidarrQuarantine.TrackID != t2.ID { - t.Errorf("first row track = %v, want T2 (%v)", rows[0].LidarrQuarantine.TrackID, t2.ID) - } -} - -func TestListAdminQueue_AggregatesByTrackWithReasonCounts(t *testing.T) { - pool := newPool(t) - alice := seedUser(t, pool, "alice") - bob := seedUser(t, pool, "bob") - carol := seedUser(t, pool, "carol") - track, _, _ := seedTrack(t, pool, "Hot Mess", "abc") - - svc := NewService(pool, lidarrconfig.New(pool), nil) - _, _ = svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", "") - _, _ = svc.Flag(context.Background(), bob.ID, track.ID, "bad_rip", "") - _, _ = svc.Flag(context.Background(), carol.ID, track.ID, "wrong_tags", "") - - rows, err := svc.ListAdminQueue(context.Background()) - if err != nil { - t.Fatalf("ListAdminQueue: %v", err) - } - if len(rows) != 1 { - t.Fatalf("len = %d, want 1 aggregated row", len(rows)) - } - r := rows[0] - if r.ReportCount != 3 { - t.Errorf("report_count = %d, want 3", r.ReportCount) - } - if r.ReasonCounts["bad_rip"] != 2 || r.ReasonCounts["wrong_tags"] != 1 { - t.Errorf("reason_counts = %+v, want bad_rip=2 wrong_tags=1", r.ReasonCounts) - } - if len(r.Reports) != 3 { - t.Errorf("reports len = %d, want 3", len(r.Reports)) - } -} -``` - -- [ ] **Step 4.3: Run the tests** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race ./internal/lidarrquarantine/... -``` - -Expected: all green. - -- [ ] **Step 4.4: Commit** - -```bash -git add internal/lidarrquarantine/ -git commit -m "feat(lidarrquarantine): Service Flag/Unflag/ListMine/ListAdminQueue" -``` - ---- - -### Task 5 — `lidarrquarantine.Service` admin actions - -**Files:** -- Modify: `internal/lidarrquarantine/service.go` — append Resolve / DeleteFile / DeleteViaLidarr -- Modify: `internal/lidarrquarantine/service_test.go` — append admin-action tests - -The three admin actions all follow the same shape: -1. Read the track (and parent album for DeleteViaLidarr) for snapshot fields. -2. Capture `affected_users` count via `CountQuarantineForTrack` *before* deleting. -3. For DeleteFile: call `library.DeleteTrackFile`; for DeleteViaLidarr: lookup album in Lidarr, call `Client.DeleteAlbum`, delete all Minstrel tracks in that album. -4. Delete `lidarr_quarantine` rows for the affected tracks. -5. Write a `lidarr_quarantine_actions` audit row. - -Order matters: Lidarr/file delete first, then DB writes. Failure of the external call leaves the per-user rows intact for retry. **No partial state.** - -- [ ] **Step 5.1: Append `Resolve` to `service.go`** - -```go -// Resolve clears all per-user quarantine rows for a track and writes an -// audit log row. Idempotent — a track with no rows still writes an audit -// entry with affected_users=0 (so admin can see "I clicked resolve on a -// track that already had no reports"). -func (s *Service) Resolve(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) { - q := dbq.New(s.pool) - track, err := q.GetTrackByID(ctx, trackID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return dbq.LidarrQuarantineAction{}, ErrTrackNotFound - } - return dbq.LidarrQuarantineAction{}, fmt.Errorf("get track: %w", err) - } - snap, err := s.snapshot(ctx, q, track) - if err != nil { - return dbq.LidarrQuarantineAction{}, err - } - - affected, err := q.CountQuarantineForTrack(ctx, trackID) - if err != nil { - return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err) - } - if err := q.DeleteQuarantineForTrack(ctx, trackID); err != nil { - return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete rows: %w", err) - } - return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ - TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, - AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionResolved, - AdminID: adminID, LidarrAlbumMbid: nil, AffectedUsers: affected, - }) -} - -// snapshot is shared scaffolding — pulls album/artist titles for the audit row. -type quarantineSnapshot struct { - TrackTitle string - ArtistName string - AlbumTitle *string - LidarrAlbumMBID *string -} - -func (s *Service) snapshot(ctx context.Context, q *dbq.Queries, track dbq.Track) (quarantineSnapshot, error) { - album, err := q.GetAlbumByID(ctx, track.AlbumID) - if err != nil { - return quarantineSnapshot{}, fmt.Errorf("get album: %w", err) - } - artist, err := q.GetArtistByID(ctx, track.ArtistID) - if err != nil { - return quarantineSnapshot{}, fmt.Errorf("get artist: %w", err) - } - return quarantineSnapshot{ - TrackTitle: track.Title, - ArtistName: artist.Name, - AlbumTitle: &album.Title, - LidarrAlbumMBID: album.Mbid, - }, nil -} -``` - -If `dbq.GetAlbumByID` / `dbq.GetArtistByID` don't exist as named queries, check the existing albums.sql / artists.sql files — they almost certainly do under different names (`AlbumByID`, `ArtistByID`, etc.) — and substitute the actual names. - -- [ ] **Step 5.2: Append `DeleteFile`** - -```go -// DeleteFile removes the track file from disk and the tracks row, then -// clears all per-user quarantine rows for that track and writes an audit -// row. If the file deletion fails, the per-user rows stay so admin can -// retry. No partial state. -func (s *Service) DeleteFile(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) { - q := dbq.New(s.pool) - track, err := q.GetTrackByID(ctx, trackID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return dbq.LidarrQuarantineAction{}, ErrTrackNotFound - } - return dbq.LidarrQuarantineAction{}, fmt.Errorf("get track: %w", err) - } - snap, err := s.snapshot(ctx, q, track) - if err != nil { - return dbq.LidarrQuarantineAction{}, err - } - - affected, err := q.CountQuarantineForTrack(ctx, trackID) - if err != nil { - return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err) - } - - if err := library.DeleteTrackFile(ctx, s.pool, trackID); err != nil { - return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete file: %w", err) - } - // tracks row is gone; the FK ON DELETE CASCADE on lidarr_quarantine - // already cleared the per-user rows. - return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ - TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, - AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionDeletedFile, - AdminID: adminID, LidarrAlbumMbid: nil, AffectedUsers: affected, - }) -} -``` - -Note the cascade comment: the schema has `ON DELETE CASCADE` on `lidarr_quarantine.track_id`, so when `library.DeleteTrackFile` runs `DELETE FROM tracks WHERE id = $1`, the per-user quarantine rows go too. We don't call `DeleteQuarantineForTrack` separately. **Verify this assumption holds when implementing** — re-check `0011_lidarr_quarantine.up.sql` and the existing `tracks` constraints. - -- [ ] **Step 5.3: Append `DeleteViaLidarr`** - -```go -// DeleteViaLidarr is the destructive admin path: tells Lidarr to remove -// the parent album with deleteFiles=true + addImportListExclusion=true, -// then removes Minstrel rows for all tracks of that album. The cascade -// on lidarr_quarantine clears per-user rows automatically. -// -// On Lidarr failure (unreachable, auth-failed, lookup-empty), nothing -// changes locally. Admin retries. -func (s *Service) DeleteViaLidarr(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, int, error) { - cfg, err := s.lidarrCfg.Get(ctx) - if err != nil { - return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("load config: %w", err) - } - client := s.clientFn() - if !cfg.Enabled || client == nil { - return dbq.LidarrQuarantineAction{}, 0, ErrLidarrDisabled - } - - q := dbq.New(s.pool) - track, err := q.GetTrackByID(ctx, trackID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return dbq.LidarrQuarantineAction{}, 0, ErrTrackNotFound - } - return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("get track: %w", err) - } - snap, err := s.snapshot(ctx, q, track) - if err != nil { - return dbq.LidarrQuarantineAction{}, 0, err - } - if snap.LidarrAlbumMBID == nil || *snap.LidarrAlbumMBID == "" { - return dbq.LidarrQuarantineAction{}, 0, ErrAlbumMBIDMissing - } - - affected, err := q.CountQuarantineForTrack(ctx, trackID) - if err != nil { - return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("count: %w", err) - } - - // Look up the album in Lidarr to translate MBID -> Lidarr internal ID. - album, err := client.LookupAlbumByMBID(ctx, *snap.LidarrAlbumMBID) - if err != nil { - if errors.Is(err, lidarr.ErrNotFound) { - return dbq.LidarrQuarantineAction{}, 0, ErrLidarrAlbumNotFound - } - return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("lidarr lookup: %w", err) - } - - // Lidarr DELETE — both flags true. - if err := client.DeleteAlbum(ctx, album.ID, true, true); err != nil { - return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("lidarr delete: %w", err) - } - - // Now remove the local rows. Cascade handles per-user quarantine - // rows via the FK on lidarr_quarantine.track_id. - res, err := s.pool.Exec(ctx, "DELETE FROM tracks WHERE album_id = $1", track.AlbumID) - if err != nil { - // We deleted in Lidarr but failed in our DB. Operator-recoverable - // by re-running. Audit row will reflect the eventual state. - return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("delete tracks: %w", err) - } - deletedCount := int(res.RowsAffected()) - - // Album/artist rows stay; if the operator wants those gone too they - // can be cleaned up by a future scan or a manual SQL pass. - - action, err := q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ - TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, - AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionDeletedViaLidarr, - AdminID: adminID, LidarrAlbumMbid: snap.LidarrAlbumMBID, - AffectedUsers: affected, - }) - return action, deletedCount, err -} -``` - -- [ ] **Step 5.4: Append admin-action tests** - -`internal/lidarrquarantine/service_test.go` — append: - -```go -import ( - // ... add to existing imports: - "net/http" - "net/http/httptest" - - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" -) - -func TestResolve_ClearsRowsAndWritesAudit(t *testing.T) { - pool := newPool(t) - alice := seedUser(t, pool, "alice") - bob := seedUser(t, pool, "bob") - track, _, _ := seedTrack(t, pool, "T", "x") - - svc := NewService(pool, lidarrconfig.New(pool), nil) - _, _ = svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", "") - _, _ = svc.Flag(context.Background(), bob.ID, track.ID, "wrong_tags", "") - - audit, err := svc.Resolve(context.Background(), track.ID, alice.ID) - if err != nil { - t.Fatalf("Resolve: %v", err) - } - if audit.AffectedUsers != 2 { - t.Errorf("affected_users = %d, want 2", audit.AffectedUsers) - } - if audit.Action != dbq.LidarrQuarantineActionResolved { - t.Errorf("action = %v, want resolved", audit.Action) - } - // No more rows for this track. - n, _ := dbq.New(pool).CountQuarantineForTrack(context.Background(), track.ID) - if n != 0 { - t.Errorf("rows after resolve = %d, want 0", n) - } -} - -func TestDeleteFile_RemovesFileAndAuditsAffected(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - // Real on-disk file: - dir := t.TempDir() - path := filepath.Join(dir, "track.mp3") - if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - q := dbq.New(pool) - artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"}) - album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "Al", SortTitle: "Al", ArtistID: artist.ID}) - track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "T", AlbumID: album.ID, ArtistID: artist.ID, - DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3", - }) - - svc := NewService(pool, lidarrconfig.New(pool), nil) - _, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "") - - audit, err := svc.DeleteFile(context.Background(), track.ID, user.ID) - if err != nil { - t.Fatalf("DeleteFile: %v", err) - } - if audit.AffectedUsers != 1 { - t.Errorf("affected_users = %d, want 1", audit.AffectedUsers) - } - if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { - t.Errorf("file still exists: %v", err) - } -} - -func TestDeleteViaLidarr_FullCascade(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - - // Stub Lidarr server. - var captured []string - stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - captured = append(captured, r.Method+" "+r.URL.Path+"?"+r.URL.RawQuery) - w.Header().Set("Content-Type", "application/json") - if r.URL.Path == "/api/v1/album" && r.Method == http.MethodGet { - _, _ = w.Write([]byte(`[{"id":42,"foreignAlbumId":"al-mbid","title":"Al","artistId":7}]`)) - return - } - // DELETE /api/v1/album/42 -> 200. - w.WriteHeader(http.StatusOK) - })) - t.Cleanup(stub.Close) - - cfg := lidarrconfig.New(pool) - if err := cfg.Save(context.Background(), lidarrconfig.Config{ - Enabled: true, BaseURL: stub.URL, APIKey: "k", - }); err != nil { - t.Fatalf("save config: %v", err) - } - clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") } - svc := NewService(pool, cfg, clientFn) - - // Seed a track on an album whose mbid we'll match in the stub. - q := dbq.New(pool) - artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"}) - albumMBID := "al-mbid" - album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ - Title: "Al", SortTitle: "Al", ArtistID: artist.ID, Mbid: &albumMBID, - }) - dir := t.TempDir() - path := filepath.Join(dir, "T.mp3") - if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "T", AlbumID: album.ID, ArtistID: artist.ID, - DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3", - }) - _, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "") - - audit, deleted, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID) - if err != nil { - t.Fatalf("DeleteViaLidarr: %v", err) - } - if deleted != 1 { - t.Errorf("deleted = %d, want 1 track removed", deleted) - } - if audit.Action != dbq.LidarrQuarantineActionDeletedViaLidarr { - t.Errorf("action = %v", audit.Action) - } - if audit.AffectedUsers != 1 { - t.Errorf("affected_users = %d, want 1", audit.AffectedUsers) - } - if audit.LidarrAlbumMbid == nil || *audit.LidarrAlbumMbid != "al-mbid" { - t.Errorf("lidarr_album_mbid = %v", audit.LidarrAlbumMbid) - } - // Track row is gone (and so is the per-user quarantine row, via cascade). - if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { - t.Errorf("track row still exists") - } - // Verify Lidarr was called with both flags true. - foundDelete := false - for _, c := range captured { - if c == "DELETE /api/v1/album/42?addImportListExclusion=true&deleteFiles=true" || - c == "DELETE /api/v1/album/42?deleteFiles=true&addImportListExclusion=true" { - foundDelete = true - } - } - if !foundDelete { - t.Errorf("Lidarr DELETE not called with both flags true; captured = %v", captured) - } -} - -func TestDeleteViaLidarr_LidarrDisabled(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - track, _, _ := seedTrack(t, pool, "T", "x") - - svc := NewService(pool, lidarrconfig.New(pool), nil) - _, _, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID) - if !errors.Is(err, ErrLidarrDisabled) { - t.Errorf("err = %v, want ErrLidarrDisabled", err) - } -} -``` - -- [ ] **Step 5.5: Run + commit** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race ./internal/lidarrquarantine/... -git add internal/lidarrquarantine/ -git commit -m "feat(lidarrquarantine): admin actions Resolve/DeleteFile/DeleteViaLidarr" -``` - ---- - -### Task 6 — Soft-hide query updates - -**Files:** -- Modify: `internal/db/queries/tracks.sql` — add `*ForUser` variants -- Modify: `internal/db/queries/recommendation.sql` — extend existing radio queries -- Regenerate: `internal/db/dbq/` - -The four affected queries are `ListTracksByAlbum`, `SearchTracks`, `CountTracksMatching`, and the radio loaders. The album/artist views are read through the existing handlers — adding `*ForUser` variants that take `user_id` lets the handlers route based on auth context. - -- [ ] **Step 6.1: Add `ListTracksByAlbumForUser` to `tracks.sql`** - -Append to `internal/db/queries/tracks.sql`: - -```sql --- name: ListTracksByAlbumForUser :many --- Same as ListTracksByAlbum but excludes tracks the user has quarantined. -SELECT * FROM tracks -WHERE album_id = $1 - AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q - WHERE q.user_id = $2 AND q.track_id = tracks.id - ) -ORDER BY disc_number NULLS LAST, track_number NULLS LAST; - --- name: SearchTracksForUser :many -SELECT * FROM tracks -WHERE title ILIKE '%' || $1 || '%' - AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q - WHERE q.user_id = $2 AND q.track_id = tracks.id - ) -ORDER BY title -LIMIT $3 OFFSET $4; - --- name: CountTracksMatchingForUser :one -SELECT COUNT(*) FROM tracks -WHERE title ILIKE '%' || $1::text || '%' - AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q - WHERE q.user_id = $2 AND q.track_id = tracks.id - ); -``` - -- [ ] **Step 6.2: Extend the radio loaders** - -Modify `internal/db/queries/recommendation.sql`. For each `LoadRadioCandidates*` query, add a quarantine clause to the `WHERE` block. The user_id is already a parameter on these queries (`$1`); the additional clause is: - -```sql - AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q - WHERE q.user_id = $1 AND q.track_id = t.id - ) -``` - -For `LoadRadioCandidates`: - -```sql -WHERE t.id <> $2 - AND NOT EXISTS ( - SELECT 1 FROM play_events - WHERE user_id = $1 AND track_id = t.id - AND started_at > now() - $3 * interval '1 hour' - ) - AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q - WHERE q.user_id = $1 AND q.track_id = t.id - ); -``` - -For `LoadRadioCandidatesV2`, add the same clause inside the final `WHERE` of the union output (look for the comment "5-way UNION" — add the clause to the outer WHERE that filters the unioned candidates by `excluded_ids` etc.). - -- [ ] **Step 6.3: Regenerate sqlc + build** - -```bash -cd internal/db && sqlc generate && cd - -go build ./... -``` - -Expected: clean build. New methods `ListTracksByAlbumForUser`, `SearchTracksForUser`, `CountTracksMatchingForUser` appear in `internal/db/dbq/tracks.sql.go`. - -- [ ] **Step 6.4: Commit** - -```bash -git add internal/db/queries/ internal/db/dbq/ -git commit -m "feat(db): add user-context track query variants honoring quarantine" -``` - ---- - -### Task 7 — Wire soft-hide into existing read handlers - -**Files:** Modify existing handlers under `internal/api/` to call the `*ForUser` queries when an authenticated user is in context. - -The pattern: where the handler currently calls (e.g.) `q.ListTracksByAlbum(ctx, albumID)`, switch to `q.ListTracksByAlbumForUser(ctx, dbq.ListTracksByAlbumForUserParams{AlbumID: albumID, UserID: user.ID})` when `user, ok := auth.UserFromContext(r.Context()); ok` is true. The `else` branch keeps the unfiltered query for any path without a user context (Subsonic, internal callers). - -- [ ] **Step 7.1: Identify call sites** - -Run: - -```bash -grep -rn "ListTracksByAlbum\|SearchTracks\|CountTracksMatching\|LoadRadioCandidates" \ - internal/api/ internal/subsonic/ -``` - -For every call in `internal/api/`, branch on `auth.UserFromContext`. For every call in `internal/subsonic/`, leave it alone (Subsonic is `/rest/*` and doesn't honor quarantine per the legacy memory). - -- [ ] **Step 7.2: Pattern to apply** - -Example for the album-detail handler: - -```go -func (h *handlers) handleAlbumDetail(w http.ResponseWriter, r *http.Request) { - // ... existing parse + lookup ... - var tracks []dbq.Track - if user, ok := auth.UserFromContext(r.Context()); ok { - tracks, err = q.ListTracksByAlbumForUser(r.Context(), dbq.ListTracksByAlbumForUserParams{ - AlbumID: albumID, UserID: user.ID, - }) - } else { - tracks, err = q.ListTracksByAlbum(r.Context(), albumID) - } - // ... existing error + response ... -} -``` - -Repeat for search and radio handlers. The radio handlers already take `user_id` for personalization — the schema change in Task 6 just extended their existing query, so those handlers don't need restructuring. - -- [ ] **Step 7.3: Regression-test the existing endpoints** - -```bash -go test ./internal/api/... -count=1 -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race -p 1 ./internal/api/... -``` - -Expected: existing tests still pass — they don't seed any quarantine rows, so the filter is a no-op. - -- [ ] **Step 7.4: Commit** - -```bash -git add internal/api/ -git commit -m "feat(api): route track-list reads through user-context quarantine filter" -``` - ---- - -### Task 8 — `/api/quarantine/*` user-facing handlers - -**Files:** -- Create: `internal/api/quarantine.go` -- Create: `internal/api/quarantine_test.go` -- Modify: `internal/api/api.go` to mount the new routes - -Three endpoints: `POST /api/quarantine`, `DELETE /api/quarantine/:track_id`, `GET /api/quarantine/mine`. Mirror M5a's `internal/api/requests.go` for the auth + writeJSON conventions. - -- [ ] **Step 8.1: Write `quarantine.go`** - -```go -package api - -import ( - "encoding/json" - "errors" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" -) - -type quarantineView struct { - UserID pgtype.UUID `json:"user_id"` - TrackID pgtype.UUID `json:"track_id"` - Reason string `json:"reason"` - Notes *string `json:"notes,omitempty"` - CreatedAt pgtype.Timestamptz `json:"created_at"` -} - -func quarantineViewFrom(row dbq.LidarrQuarantine) quarantineView { - return quarantineView{ - UserID: row.UserID, TrackID: row.TrackID, - Reason: string(row.Reason), Notes: row.Notes, CreatedAt: row.CreatedAt, - } -} - -type flagBody struct { - TrackID pgtype.UUID `json:"track_id"` - Reason string `json:"reason"` - Notes string `json:"notes"` -} - -func (h *handlers) handleFlag(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - var body flagBody - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") - return - } - row, err := h.lidarrQuarantine.Flag(r.Context(), user.ID, body.TrackID, body.Reason, body.Notes) - if err != nil { - switch { - case errors.Is(err, lidarrquarantine.ErrBadReason): - writeErr(w, http.StatusBadRequest, "bad_reason", err.Error()) - case errors.Is(err, lidarrquarantine.ErrTrackNotFound): - writeErr(w, http.StatusNotFound, "track_not_found", "track does not exist") - default: - h.logger.Error("api: flag", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "flag failed") - } - return - } - writeJSON(w, http.StatusCreated, quarantineViewFrom(row)) -} - -func (h *handlers) handleUnflag(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - id, ok := parseUUID(chi.URLParam(r, "track_id")) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id") - return - } - if err := h.lidarrQuarantine.Unflag(r.Context(), user.ID, id); err != nil { - if errors.Is(err, lidarrquarantine.ErrQuarantineNotFound) { - writeErr(w, http.StatusNotFound, "quarantine_not_found", "no quarantine for that track") - return - } - h.logger.Error("api: unflag", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "unflag failed") - return - } - w.WriteHeader(http.StatusNoContent) -} - -// quarantineMineView wraps the joined row for /api/quarantine/mine. The -// SPA on /library/hidden needs the full track + album + artist payload. -type quarantineMineView struct { - quarantineView - Track dbq.Track `json:"track"` - Album dbq.Album `json:"album"` - Artist dbq.Artist `json:"artist"` -} - -func (h *handlers) handleListMyQuarantine(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - rows, err := h.lidarrQuarantine.ListMine(r.Context(), user.ID) - if err != nil { - h.logger.Error("api: list mine", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "list failed") - return - } - out := make([]quarantineMineView, 0, len(rows)) - for _, row := range rows { - out = append(out, quarantineMineView{ - quarantineView: quarantineViewFrom(row.LidarrQuarantine), - Track: row.Track, - Album: row.Album, - Artist: row.Artist, - }) - } - writeJSON(w, http.StatusOK, out) -} -``` - -- [ ] **Step 8.2: Mount routes in `api.go`** - -Inside the existing authenticated route group, add: - -```go -r.Post("/api/quarantine", h.handleFlag) -r.Delete("/api/quarantine/{track_id}", h.handleUnflag) -r.Get("/api/quarantine/mine", h.handleListMyQuarantine) -``` - -- [ ] **Step 8.3: Add `lidarrQuarantine` to the handlers struct** - -Find the `handlers` struct (in `internal/api/api.go` or wherever it lives) and add: - -```go -lidarrQuarantine *lidarrquarantine.Service -``` - -Then update the constructor / wiring to accept it. - -- [ ] **Step 8.4: Write tests** - -`internal/api/quarantine_test.go` mirrors the M5a `requests_test.go` shape: stub handlers, real DB via `MINSTREL_TEST_DATABASE_URL`, table-driven scenarios. Cover: -- Flag with valid reason → 201, row visible in `ListMine`. -- Flag with `bad_reason` → 400, `error.code === "bad_reason"`. -- Flag for a track that doesn't exist → 404 `track_not_found`. -- Unflag happy path → 204. -- Unflag for a row that doesn't exist → 404 `quarantine_not_found`. -- ListMine with two flags → returns two rows, newest first. -- Unauthenticated requests → 401 across the board (the existing `RequireUser` middleware handles this; one assertion is enough). - -- [ ] **Step 8.5: Commit** - -```bash -go test ./internal/api/... -run TestFlag -count=1 -git add internal/api/quarantine.go internal/api/quarantine_test.go internal/api/api.go -git commit -m "feat(api): /api/quarantine user-facing CRUD" -``` - ---- - -### Task 9 — `/api/admin/quarantine/*` admin handlers - -**Files:** -- Create: `internal/api/admin_quarantine.go` -- Create: `internal/api/admin_quarantine_test.go` -- Modify: `internal/api/api.go` to mount under the existing `/api/admin/*` route group - -Five endpoints: GET queue, GET actions, three POST resolution actions. Reuse the `RequireAdmin` middleware from M5a. - -- [ ] **Step 9.1: Write `admin_quarantine.go`** - -```go -package api - -import ( - "encoding/json" - "errors" - "net/http" - "strconv" - - "github.com/go-chi/chi/v5" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" -) - -// adminQueueRowView is the wire shape returned by GET /api/admin/quarantine. -type adminQueueRowView struct { - TrackID string `json:"track_id"` - TrackTitle string `json:"track_title"` - ArtistName string `json:"artist_name"` - AlbumTitle *string `json:"album_title,omitempty"` - AlbumID string `json:"album_id"` - LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"` - ReportCount int32 `json:"report_count"` - LatestAt string `json:"latest_at"` - ReasonCounts map[string]int `json:"reason_counts"` - Reports []adminQueueReportView `json:"reports"` -} - -type adminQueueReportView struct { - UserID string `json:"user_id"` - Username string `json:"username"` - Reason string `json:"reason"` - Notes *string `json:"notes,omitempty"` - CreatedAt string `json:"created_at"` -} - -func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Request) { - rows, err := h.lidarrQuarantine.ListAdminQueue(r.Context()) - if err != nil { - h.logger.Error("admin: list quarantine", "err", err) - writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") - return - } - out := make([]adminQueueRowView, 0, len(rows)) - for _, r := range rows { - reports := make([]adminQueueReportView, 0, len(r.Reports)) - for _, rep := range r.Reports { - reports = append(reports, adminQueueReportView{ - UserID: uuidToString(rep.UserID), - Username: rep.Username, - Reason: rep.Reason, - Notes: rep.Notes, - CreatedAt: rep.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), - }) - } - out = append(out, adminQueueRowView{ - TrackID: uuidToString(r.TrackID), TrackTitle: r.TrackTitle, - ArtistName: r.ArtistName, AlbumTitle: r.AlbumTitle, - AlbumID: uuidToString(r.AlbumID), LidarrAlbumMBID: r.LidarrAlbumMBID, - ReportCount: r.ReportCount, - LatestAt: r.LatestAt.Time.Format("2006-01-02T15:04:05Z07:00"), - ReasonCounts: r.ReasonCounts, Reports: reports, - }) - } - writeJSON(w, http.StatusOK, out) -} - -type actionResultView struct { - ActionID string `json:"action_id"` - AffectedUsers int32 `json:"affected_users"` - DeletedTrackCount *int `json:"deleted_track_count,omitempty"` -} - -func (h *handlers) handleResolveQuarantine(w http.ResponseWriter, r *http.Request) { - admin, _ := auth.UserFromContext(r.Context()) - id, ok := parseUUID(chi.URLParam(r, "track_id")) - if !ok { - writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") - return - } - action, err := h.lidarrQuarantine.Resolve(r.Context(), id, admin.ID) - if err != nil { - if errors.Is(err, lidarrquarantine.ErrTrackNotFound) { - writeAdminJSONErr(w, http.StatusNotFound, "track_not_found") - return - } - h.logger.Error("admin: resolve quarantine", "err", err) - writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") - return - } - writeJSON(w, http.StatusOK, actionResultView{ - ActionID: uuidToString(action.ID), AffectedUsers: action.AffectedUsers, - }) -} - -func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Request) { - admin, _ := auth.UserFromContext(r.Context()) - id, ok := parseUUID(chi.URLParam(r, "track_id")) - if !ok { - writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") - return - } - action, err := h.lidarrQuarantine.DeleteFile(r.Context(), id, admin.ID) - if err != nil { - switch { - case errors.Is(err, lidarrquarantine.ErrTrackNotFound): - writeAdminJSONErr(w, http.StatusNotFound, "track_not_found") - default: - h.logger.Error("admin: delete file", "err", err) - writeAdminJSONErr(w, http.StatusInternalServerError, "file_delete_failed") - } - return - } - writeJSON(w, http.StatusOK, actionResultView{ - ActionID: uuidToString(action.ID), AffectedUsers: action.AffectedUsers, - }) -} - -func (h *handlers) handleDeleteQuarantineViaLidarr(w http.ResponseWriter, r *http.Request) { - admin, _ := auth.UserFromContext(r.Context()) - id, ok := parseUUID(chi.URLParam(r, "track_id")) - if !ok { - writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") - return - } - action, deleted, err := h.lidarrQuarantine.DeleteViaLidarr(r.Context(), id, admin.ID) - if err != nil { - switch { - case errors.Is(err, lidarrquarantine.ErrLidarrDisabled): - writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled") - case errors.Is(err, lidarrquarantine.ErrTrackNotFound): - writeAdminJSONErr(w, http.StatusNotFound, "track_not_found") - case errors.Is(err, lidarrquarantine.ErrAlbumMBIDMissing): - writeAdminJSONErr(w, http.StatusNotFound, "album_mbid_missing") - case errors.Is(err, lidarrquarantine.ErrLidarrAlbumNotFound): - writeAdminJSONErr(w, http.StatusBadGateway, "lidarr_album_lookup_failed") - case errors.Is(err, lidarr.ErrUnreachable): - writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unreachable") - case errors.Is(err, lidarr.ErrAuthFailed): - writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_auth_failed") - default: - h.logger.Error("admin: delete via lidarr", "err", err) - writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") - } - return - } - writeJSON(w, http.StatusOK, actionResultView{ - ActionID: uuidToString(action.ID), AffectedUsers: action.AffectedUsers, - DeletedTrackCount: &deleted, - }) -} - -type actionLogView struct { - ID string `json:"id"` - TrackID string `json:"track_id"` - TrackTitle string `json:"track_title"` - ArtistName string `json:"artist_name"` - AlbumTitle *string `json:"album_title,omitempty"` - Action string `json:"action"` - AdminID *string `json:"admin_id,omitempty"` - LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"` - AffectedUsers int32 `json:"affected_users"` - CreatedAt string `json:"created_at"` -} - -func (h *handlers) handleListQuarantineActions(w http.ResponseWriter, r *http.Request) { - limitStr := r.URL.Query().Get("limit") - limit := int32(50) - if limitStr != "" { - if v, err := strconv.Atoi(limitStr); err == nil && v > 0 && v <= 200 { - limit = int32(v) - } - } - rows, err := dbq.New(h.pool).ListQuarantineActions(r.Context(), limit) - if err != nil { - h.logger.Error("admin: list actions", "err", err) - writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") - return - } - out := make([]actionLogView, 0, len(rows)) - for _, row := range rows { - var adminID *string - if row.AdminID.Valid { - s := uuidToString(row.AdminID) - adminID = &s - } - out = append(out, actionLogView{ - ID: uuidToString(row.ID), TrackID: uuidToString(row.TrackID), - TrackTitle: row.TrackTitle, ArtistName: row.ArtistName, AlbumTitle: row.AlbumTitle, - Action: string(row.Action), AdminID: adminID, - LidarrAlbumMBID: row.LidarrAlbumMbid, AffectedUsers: row.AffectedUsers, - CreatedAt: row.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), - }) - } - writeJSON(w, http.StatusOK, out) -} -``` - -`uuidToString` and `parseUUID` are existing helpers in `internal/api/`. Reuse them. - -- [ ] **Step 9.2: Mount routes** - -Inside the `/api/admin` route group: - -```go -r.Get("/api/admin/quarantine", h.handleListAdminQuarantine) -r.Post("/api/admin/quarantine/{track_id}/resolve", h.handleResolveQuarantine) -r.Post("/api/admin/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile) -r.Post("/api/admin/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr) -r.Get("/api/admin/quarantine/actions", h.handleListQuarantineActions) -``` - -- [ ] **Step 9.3: Tests (`internal/api/admin_quarantine_test.go`)** - -Mirror M5a's `internal/api/admin_requests_test.go`. Cover: -- Aggregated queue shape: 3 users × 1 track yields 1 row with `report_count=3`, correct `reason_counts`. -- Resolve clears rows, returns 200 with `affected_users`. -- Delete file: file vanishes from disk, row gone, audit row written. -- Delete via Lidarr with stub server: lookup → DELETE → row removed; happy path 200 with `deleted_track_count`. -- Delete via Lidarr with `lidarr_disabled` config → 503 `lidarr_disabled`. -- Delete via Lidarr with stub returning empty array on lookup → 502 `lidarr_album_lookup_failed`. -- Delete via Lidarr on a track with no album MBID → 404 `album_mbid_missing`. -- Non-admin user → 403 across all admin endpoints. -- Action log GET returns rows ordered newest-first. - -- [ ] **Step 9.4: Commit** - -```bash -docker run --rm --network minstrel_minstrel -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm go test -race -p 1 ./internal/api/... -run AdminQuarantine -git add internal/api/admin_quarantine.go internal/api/admin_quarantine_test.go internal/api/api.go -git commit -m "feat(api): /api/admin/quarantine queue + resolve/delete-file/delete-via-lidarr" -``` - ---- - -### Task 10 — Wire `Service` into `cmd/minstrel/main.go` - -**Files:** Modify `cmd/minstrel/main.go`. - -The handlers struct (Task 8 step 8.3) now expects a `*lidarrquarantine.Service`. Construct it at startup and pass it through. - -- [ ] **Step 10.1: Add the construction** - -Find where the M5a `lidarrrequests.Service` is constructed in `main.go`. Right after it, add: - -```go -quarSvc := lidarrquarantine.NewService(pool, lidarrCfg, func() *lidarr.Client { - cfg, err := lidarrCfg.Get(ctx) - if err != nil || !cfg.Enabled { - return nil - } - return lidarr.NewClient(cfg.BaseURL, cfg.APIKey) -}) -``` - -If a similar `clientFn` already exists for `lidarrrequests`, reuse it instead of duplicating the closure. Pass `quarSvc` into the handlers constructor. - -- [ ] **Step 10.2: Build + smoke test** - -```bash -go build ./cmd/minstrel -go vet ./... -golangci-lint run ./... -``` - -Expected: clean build, no lint warnings. If `golangci-lint` isn't installed, skip. - -- [ ] **Step 10.3: Commit** - -```bash -git add cmd/minstrel/main.go internal/api/api.go -git commit -m "feat(cmd): wire lidarrquarantine.Service into the API handlers" -``` - ---- - -### Task 11 — Frontend: API client modules + types - -**Files:** Create `web/src/lib/api/quarantine.ts` + `quarantine.test.ts`. Modify `web/src/lib/api/types.ts`, `queries.ts`, `admin.ts`. Mirror the existing M5a pattern from `requests.ts` / `admin.ts`. - -- [ ] **Step 11.1: Add types to `types.ts`** - -```ts -export type LidarrQuarantineReason = 'bad_rip' | 'wrong_file' | 'wrong_tags' | 'duplicate' | 'other'; - -export type LidarrQuarantineRow = { - user_id: string; - track_id: string; - reason: LidarrQuarantineReason; - notes?: string | null; - created_at: string; -}; - -export type LidarrQuarantineMineRow = LidarrQuarantineRow & { - track: TrackRef; - album: AlbumRef; - artist: ArtistRef; -}; - -export type AdminQuarantineRow = { - track_id: string; - track_title: string; - artist_name: string; - album_title?: string | null; - album_id: string; - lidarr_album_mbid?: string | null; - report_count: number; - latest_at: string; - reason_counts: Record; - reports: AdminQuarantineReport[]; -}; - -export type AdminQuarantineReport = { - user_id: string; - username: string; - reason: LidarrQuarantineReason; - notes?: string | null; - created_at: string; -}; - -export type LidarrQuarantineAction = 'resolved' | 'deleted_file' | 'deleted_via_lidarr'; - -export type LidarrQuarantineActionRow = { - id: string; - track_id: string; - track_title: string; - artist_name: string; - album_title?: string | null; - action: LidarrQuarantineAction; - admin_id?: string | null; - lidarr_album_mbid?: string | null; - affected_users: number; - created_at: string; -}; - -export type ActionResult = { - action_id: string; - affected_users: number; - deleted_track_count?: number; -}; -``` - -- [ ] **Step 11.2: Add query keys to `queries.ts`** - -```ts -qk.myQuarantine = () => ['myQuarantine'] as const; -qk.adminQuarantine = () => ['adminQuarantine'] as const; -qk.adminQuarantineActions = (limit?: number) => ['adminQuarantineActions', { limit: limit ?? 50 }] as const; -``` - -(Keep the existing `qk` object syntax — append the new functions.) - -- [ ] **Step 11.3: Write `quarantine.ts`** - -```ts -import { createQuery } from '@tanstack/svelte-query'; -import { api, apiFetch } from './client'; -import { qk } from './queries'; -import type { - LidarrQuarantineRow, - LidarrQuarantineMineRow, - LidarrQuarantineReason -} from './types'; - -export type FlagParams = { - track_id: string; - reason: LidarrQuarantineReason; - notes?: string; -}; - -export async function flagTrack(params: FlagParams): Promise { - const body: FlagParams = { track_id: params.track_id, reason: params.reason }; - if (params.notes && params.notes.length > 0) body.notes = params.notes; - return api.post('/api/quarantine', body); -} - -// Server returns 204 (no body) for DELETE; handle accordingly. -export async function unflagTrack(trackID: string): Promise { - await apiFetch(`/api/quarantine/${trackID}`, { method: 'DELETE' }); -} - -export async function listMyQuarantine(): Promise { - return api.get('/api/quarantine/mine'); -} - -export function createMyQuarantineQuery() { - return createQuery({ - queryKey: qk.myQuarantine(), - queryFn: listMyQuarantine, - staleTime: 60_000 - }); -} -``` - -- [ ] **Step 11.4: Append admin endpoints to `admin.ts`** - -```ts -import type { AdminQuarantineRow, ActionResult, LidarrQuarantineActionRow } from './types'; - -export async function listAdminQuarantine(): Promise { - return api.get('/api/admin/quarantine'); -} - -export async function resolveQuarantine(trackID: string): Promise { - return api.post(`/api/admin/quarantine/${trackID}/resolve`, {}); -} - -export async function deleteQuarantineFile(trackID: string): Promise { - return api.post(`/api/admin/quarantine/${trackID}/delete-file`, {}); -} - -export async function deleteQuarantineViaLidarr(trackID: string): Promise { - return api.post(`/api/admin/quarantine/${trackID}/delete-via-lidarr`, {}); -} - -export async function listQuarantineActions(limit = 50): Promise { - return api.get(`/api/admin/quarantine/actions?limit=${limit}`); -} - -export function createAdminQuarantineQuery() { - return createQuery({ - queryKey: qk.adminQuarantine(), - queryFn: listAdminQuarantine, - staleTime: 30_000 // queue should refresh more aggressively than my-history - }); -} - -export function createQuarantineActionsQuery(limit = 50) { - return createQuery({ - queryKey: qk.adminQuarantineActions(limit), - queryFn: () => listQuarantineActions(limit), - staleTime: 60_000 - }); -} -``` - -- [ ] **Step 11.5: Tests** - -`quarantine.test.ts` — mirror `requests.test.ts` shape: `vi.mock('./client')`, assert URL + body shapes, return-value flow-through. Cover flagTrack (with + without notes), unflagTrack, listMyQuarantine, query factory, qk shape. - -For `admin.ts`: extend the existing test file to cover the five new functions + their query factories. - -- [ ] **Step 11.6: Run + commit** - -```bash -cd web && npm run check && npm test -- --run && cd - -git add web/src/lib/api/ -git commit -m "feat(web): API client modules for quarantine + admin quarantine" -``` - ---- - -### Task 12 — Frontend: `` + `` components - -**Files:** Create `web/src/lib/components/TrackMenu.svelte`, `TrackMenu.test.ts`, `FlagPopover.svelte`, `FlagPopover.test.ts`. - -`` is a kebab button + dropdown menu. For M5b it has one item: "Flag this track…". Component is structured so future actions slot in alongside. - -`` is the reason form, opened from TrackMenu. Pre-fills if the user already has a quarantine on this track. - -- [ ] **Step 12.1: Write `TrackMenu.svelte`** - -```svelte - - - (menuOpen = false)} - onkeydown={(e) => e.key === 'Escape' && closeAll()} -/> - -
- - - {#if menuOpen} - - {/if} - - {#if popoverOpen} - - {/if} -
-``` - -- [ ] **Step 12.2: Write `FlagPopover.svelte`** - -```svelte - - - - -``` - -- [ ] **Step 12.3: Tests** - -`TrackMenu.test.ts`: -- Click kebab → menu visible. -- Click outside → menu closes. -- Escape → menu closes. -- Click "Flag this track…" → popover opens. - -`FlagPopover.test.ts`: -- Defaults to reason=`bad_rip` when no initialReason. -- Pre-fills when initialReason+initialNotes are provided; button reads "Update flag". -- Submit calls `flagTrack` (mocked) with the typed reason + non-empty notes; empty notes are NOT sent. -- Cancel calls onClose; does not call flagTrack. -- Submit calls `invalidateQueries` on success. - -For `flagTrack` mock pattern, copy from `web/src/routes/admin/integrations/integrations.test.ts` (the `vi.mock('$lib/api/admin', ...)` shape). - -- [ ] **Step 12.4: Run + commit** - -```bash -cd web && npm run check && npm test -- --run TrackMenu FlagPopover && cd - -git add web/src/lib/components/TrackMenu.svelte web/src/lib/components/TrackMenu.test.ts \ - web/src/lib/components/FlagPopover.svelte web/src/lib/components/FlagPopover.test.ts -git commit -m "feat(web): TrackMenu overflow + FlagPopover for the quarantine flow" -``` - ---- - -### Task 13 — Mount `` in `TrackRow` + `PlayerBar` - -**Files:** Modify `TrackRow.svelte`, `TrackRow.test.ts`, `PlayerBar.svelte`, `PlayerBar.test.ts`. - -- [ ] **Step 13.1: TrackRow** - -Add `` as a sibling to `` in the row's right cluster: - -```svelte - - - - - -``` - -- [ ] **Step 13.2: PlayerBar** - -Same: add the `` to the right cluster, after the like button. The `track` prop is the currently-playing track from the player store (e.g. `player.current`). - -```svelte -{#if player.current} - - -{/if} -``` - -- [ ] **Step 13.3: Update tests** - -Both `TrackRow.test.ts` and `PlayerBar.test.ts` — add an assertion that the track-actions kebab is rendered. Existing tests (like-button presence, play-on-click) should still pass. - -- [ ] **Step 13.4: Run + commit** - -```bash -cd web && npm test -- --run TrackRow PlayerBar && cd - -git add web/src/lib/components/TrackRow.svelte web/src/lib/components/TrackRow.test.ts \ - web/src/lib/components/PlayerBar.svelte web/src/lib/components/PlayerBar.test.ts -git commit -m "feat(web): mount TrackMenu in TrackRow + PlayerBar" -``` - ---- - -### Task 14 — Frontend: `/library/hidden` route - -**Files:** Create `web/src/routes/library/hidden/+page.svelte` + `hidden.test.ts`. Modify `Shell.svelte` to add `Hidden` to the main nav (between `Liked` and `Search`). - -- [ ] **Step 14.1: Write the page** - -Pattern matches `/requests` exactly — same row anatomy. Use `createMyQuarantineQuery()` for data, `unflagTrack(trackID)` + `invalidateQueries(qk.myQuarantine())` for the un-hide affordance. Empty state copy: "Nothing hidden yet." - -Each row (mirroring `/requests`): -- 56px album art with Lucide `Music2` fallback. -- Pills: kind ("Track", accent-tint), reason (`bad_rip` etc., accent-tint). -- Title (Parchment) + meta line "by `` · `` · flagged 2d ago". -- Notes (Vellum, italic) — only when present. -- Action: Un-hide (Pewter ghost + Lucide `RotateCcw`). One click — no confirmation. Optimistic remove. - -Header: H2 "Hidden" (Fraunces 24/500) + subtitle "Tracks you've flagged as broken." - -- [ ] **Step 14.2: Update Shell** - -Add to `navItems`: - -```ts -{ href: '/library/hidden', label: 'Hidden' } -``` - -Position: after `Liked`, before `Search`. Update `Shell.test.ts` to assert the new link's order. - -- [ ] **Step 14.3: Tests** - -`hidden.test.ts`: -- Renders one row per quarantine. -- Un-hide click calls `unflagTrack` + invalidates query. -- Empty state shows "Nothing hidden yet." -- Notes render (italic) when present, absent otherwise. - -- [ ] **Step 14.4: Commit** - -```bash -cd web && npm run check && npm test -- --run hidden && cd - -git add web/src/routes/library/hidden/ web/src/lib/components/Shell.svelte web/src/lib/components/Shell.test.ts -git commit -m "feat(web): /library/hidden user-facing quarantine view" -``` - ---- - -### Task 15 — Frontend: `/admin/quarantine` route + sidebar promotion - -**Files:** Create `web/src/routes/admin/quarantine/+page.svelte` + `quarantine.test.ts`. Modify `AdminSidebar.svelte` (promote Quarantine from placeholder), `AdminSidebar.test.ts`. - -- [ ] **Step 15.1: Promote sidebar item** - -In `AdminSidebar.svelte`, change: - -```diff --{ href: '/admin/quarantine', label: 'Quarantine', icon: ShieldX, placeholder: true } -+{ href: '/admin/quarantine', label: 'Quarantine', icon: ShieldX } -``` - -Update `AdminSidebar.test.ts`: -- Replace the placeholder-treatment assertion for Quarantine. -- Add an assertion that Quarantine renders as a real `` link. -- Add an assertion that `/admin/quarantine` activates Quarantine in the sidebar. - -- [ ] **Step 15.2: Write the page** - -Page layout (matches the design-system spec from §6 of the spec): - -- Header: H2 "Quarantine" + accent-tint count pill when `report_count > 0`. -- Empty state: "Nothing to triage right now." -- Aggregated rows: 56px art · title + meta · reason-distribution pills · expandable per-user reports · inline play button (accent-colored — brand moment) · action cluster (Resolve / Delete file / Delete via Lidarr). -- Modal-confirm for Delete file. -- Typed-confirm modal for Delete via Lidarr (matches M5a Disconnect pattern; trim equality on "DELETE"). -- Dimmed Delete-via-Lidarr button when `lidarr_album_mbid` is null, with `title` attribute "Local-only track — no Lidarr album to remove." -- Lidarr-unreachable error → toast (reuse the `errorCopy` helper pattern from `/admin/requests`). - -Use `createAdminQuarantineQuery()` for data. On mutation success, `invalidateQueries({ queryKey: qk.adminQuarantine() })`. - -Use the existing player's `enqueueTrack` (or `playRadio` — pick the closest-fit existing action) for the inline Play button. - -- [ ] **Step 15.3: Tests (`quarantine.test.ts`)** - -Cover: -- Aggregated rows render with reason distribution. -- Expand caret reveals per-user reports. -- Resolve fires `resolveQuarantine` + invalidates. -- Delete file → modal-confirm → fires `deleteQuarantineFile`. -- Delete via Lidarr → typed-confirm modal "DELETE" → fires `deleteQuarantineViaLidarr`. -- Lidarr-unreachable error → toast renders with the spec copy ("Lidarr is unreachable…"). -- Dimmed Delete-via-Lidarr when MBID missing. -- Inline play button calls the player. -- Empty state copy. - -- [ ] **Step 15.4: Commit** - -```bash -cd web && npm run check && npm test -- --run admin/quarantine && cd - -git add web/src/routes/admin/quarantine/ web/src/lib/components/AdminSidebar.svelte web/src/lib/components/AdminSidebar.test.ts -git commit -m "feat(web): /admin/quarantine aggregated queue with resolution actions" -``` - ---- - -### Task 16 — Final verification + branch finish - -- [ ] **Step 16.1: Full Go test sweep** - -```bash -go test -short -race ./... -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race -p 1 ./... -``` - -Expected: short suite + integration suite both green. The pre-existing `internal/library/TestScanner_Integration` flake is documented and not blocked on (per `project_scanner_flake.md`). - -- [ ] **Step 16.2: Lint clean** - -```bash -golangci-lint run ./... -``` - -- [ ] **Step 16.3: Coverage check** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - bash -c 'go test -race -coverprofile=/tmp/cov.out \ - ./internal/lidarr/... ./internal/lidarrquarantine/... \ - ./internal/library/... && go tool cover -func=/tmp/cov.out | tail -1' -``` - -Expected: combined ≥ 80%, per spec §8. Both `internal/lidarr/` and `internal/lidarrquarantine/` should individually clear 80%. - -- [ ] **Step 16.4: Frontend full check** - -```bash -cd web && npm run check && npm test -- --run && npm run build -``` - -Expected: 0 errors, all vitest tests pass, build succeeds. - -- [ ] **Step 16.5: Manual smoke** - -- Configure Lidarr in `/admin/integrations` (or use the M5a-saved config). -- Flag a track from the now-playing bar → confirm it disappears from the album page. -- Confirm `/library/hidden` shows the flagged track. -- Un-hide → track returns to album page. -- Re-flag from a different user (admin user A flags as `bad_rip`, then user B flags same track as `wrong_tags`). -- Open `/admin/quarantine` → aggregated row shows count=2, distribution `1× bad_rip, 1× wrong_tags`. -- Click Play → track plays in the player. -- Click Resolve → row clears for both users. -- Re-flag, then click Delete file → file gone from disk, row gone from queue. -- Re-flag a track on a Lidarr-managed album → click Delete via Lidarr → typed-confirm "DELETE" → confirm Lidarr received DELETE call (check Lidarr's UI/logs). -- Verify non-admin user redirected when navigating to `/admin/quarantine`. - -- [ ] **Step 16.6: Use `superpowers:finishing-a-development-branch`** - -Verify tests are still green, then run the skill to present finish options (merge / PR / keep / discard). Default for this slice is "create a PR to main" matching the established cadence (per `project_git_workflow` memory). - ---- - -## Self-review checklist (run before declaring the plan ready) - -**Spec coverage** — every spec section maps to a task: -- §3 Architecture: Tasks 2 (client extensions), 3 (library), 4-5 (Service), 7 (soft-hide enforcement), 10 (wiring) -- §4 Schema: Task 1 -- §5 API surface: Tasks 8 (user), 9 (admin) -- §6 UI surfaces: Tasks 12 (TrackMenu+FlagPopover), 13 (mount), 14 (/library/hidden), 15 (/admin/quarantine + sidebar) -- §7 Error handling: distributed across Tasks 8, 9 (handler error mapping); Service layer (Tasks 4, 5) defines the typed errors -- §8 Testing: every Task includes tests; Task 16 verifies coverage targets -- §9 Decisions ledger: not directly implemented but referenced in commit messages -- §10 Out of scope: explicitly excluded — no album/artist quarantine, no bulk operations, no auto-resolve, no Subsonic honoring -- §11 Open questions: position of `/library/hidden` in nav (Task 14, between Liked and Search per the spec); dimmed-with-tooltip for missing MBID (Task 15) - -**Placeholder scan:** the per-task detail level drops after Task 9 (frontend tasks become 1-2 paragraphs) — intentional for navigability. Tasks 14 and 15 reference established patterns from M5a (`/requests` page anatomy, M5a typed-confirm modal for Disconnect) without re-stating the full code. No "TBD" or "TODO" remains. - -**Type consistency:** -- Service method names: `Flag`, `Unflag`, `ListMine`, `ListAdminQueue`, `Resolve`, `DeleteFile`, `DeleteViaLidarr` — consistent across plan -- Error names: `ErrBadReason`, `ErrTrackNotFound`, `ErrQuarantineNotFound`, `ErrAlbumMBIDMissing`, `ErrLidarrAlbumNotFound`, `ErrLidarrDisabled` — consistent -- Lidarr client method names: `LookupArtistByMBID`, `LookupAlbumByMBID`, `DeleteAlbum` — consistent -- API paths match spec §5 -- Component names: ``, ``, `` (mentioned in file map but not separately tasked — bake into Tasks 14 & 15 as inline JSX) -- DB column names: `lidarr_quarantine.{user_id,track_id,reason,notes,created_at}`, `lidarr_quarantine_actions.{id,track_id,track_title,artist_name,album_title,action,admin_id,lidarr_album_mbid,affected_users,created_at}` — consistent - -Plan is complete. diff --git a/docs/superpowers/plans/2026-04-30-m5c-suggested-additions.md b/docs/superpowers/plans/2026-04-30-m5c-suggested-additions.md deleted file mode 100644 index d73588c5..00000000 --- a/docs/superpowers/plans/2026-04-30-m5c-suggested-additions.md +++ /dev/null @@ -1,1838 +0,0 @@ -# M5c — Suggested additions on `/discover` — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Personalized artist suggestions on `/discover` (search-input-empty state). Top-12 out-of-library artists ranked by per-user signal (likes ×5 + recency-decayed plays), with top-3 contributing seeds attributed per card. One-click add via the existing M5a Lidarr-request flow. - -**Architecture:** Extend the M4b similarity ingest worker to persist unmatched artist MBIDs to a new `artist_similarity_unmatched` table (mirrors `artist_similarity` shape). New `internal/recommendation` service runs a single CTE at request time that scores candidates from the user's likes + plays through the unmatched table. New `GET /api/discover/suggestions` handler. Frontend swaps `` between the existing search results and a new suggestion feed when the search input is empty. - -**Tech Stack:** Go 1.23 · pgx/v5 + sqlc · Postgres + golang-migrate · SvelteKit 2 / Svelte 5 (runes) · TanStack Query · Vitest · existing FabledSword design tokens. - -**Spec:** [`docs/superpowers/specs/2026-04-30-m5c-suggested-additions-design.md`](../specs/2026-04-30-m5c-suggested-additions-design.md). Read it before starting — every decision is explained there. - -**Memory dependencies:** `project_design_system.md` (FabledSword tokens), `project_subsonic_legacy.md` (`/api/*` is primary), `project_no_github.md` (Forgejo MCP for PR ops), `project_git_workflow.md` (commit on `dev`; PR to `main` separately). - ---- - -## File map - -### Backend — create - -- `internal/db/migrations/0012_artist_similarity_unmatched.up.sql` · `.down.sql` -- `internal/recommendation/suggestions.go` — `SuggestArtists` service + types -- `internal/recommendation/suggestions_integration_test.go` -- `internal/api/suggestions.go` — `GET /api/discover/suggestions` handler -- `internal/api/suggestions_test.go` - -### Backend — modify - -- `internal/db/queries/similarity.sql` — add `UpsertArtistSimilarityUnmatched` -- `internal/db/queries/recommendation.sql` — add `SuggestArtistsForUser` -- `internal/db/dbq/*` — regenerated by `sqlc generate` -- `internal/scrobble/listenbrainz/client.go` — add `Name string \`json:"name"\`` to `SimilarArtist` -- `internal/similarity/worker.go` — extend `upsertArtistSimilar` to persist unmatched MBIDs -- `internal/similarity/worker_test.go` — extend with `TestUpsertArtistSimilar_PersistsUnmatchedToTable` -- `internal/api/api.go` — register `/api/discover/suggestions` route - -### Frontend — create - -- `web/src/lib/api/suggestions.ts` — client (`listSuggestions`, `createSuggestionsQuery`) -- `web/src/lib/api/suggestions.test.ts` -- `web/src/lib/components/SuggestionFeed.svelte` — subcomponent for the suggestion grid -- `web/src/lib/components/SuggestionFeed.test.ts` - -### Frontend — modify - -- `web/src/lib/api/types.ts` — add `ArtistSuggestion`, `SeedContribution` -- `web/src/lib/api/queries.ts` — add `qk.suggestions(limit)` -- `web/src/lib/components/DiscoverResultCard.svelte` — add optional `attribution?: string` prop -- `web/src/lib/components/DiscoverResultCard.test.ts` — assert attribution rendering -- `web/src/routes/discover/+page.svelte` — empty-input branches to ``; tabs hide when input is empty -- `web/src/routes/discover/discover.test.ts` — extend with suggestion-feed tests - ---- - -## Task list - -### Task 1 — Migration 0012 + worker upsert query - -**Files:** -- Create: `internal/db/migrations/0012_artist_similarity_unmatched.up.sql` -- Create: `internal/db/migrations/0012_artist_similarity_unmatched.down.sql` -- Modify: `internal/db/queries/similarity.sql` -- Regenerate: `internal/db/dbq/*` - -- [ ] **Step 1.1: Write the up migration** - -`internal/db/migrations/0012_artist_similarity_unmatched.up.sql`: - -```sql --- M5c: persist unmatched-similar-artist MBIDs that the M4b worker would --- otherwise discard. Mirrors artist_similarity shape: same composite PK --- with source, same (seed_id, score DESC) index, same source enum check. --- The candidate side is text + name (no FK) — that's the whole point. - -CREATE TABLE artist_similarity_unmatched ( - seed_artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE, - candidate_mbid text NOT NULL, - candidate_name text NOT NULL, - score DOUBLE PRECISION NOT NULL, - source text NOT NULL CHECK (source IN ('listenbrainz', 'musicbrainz_tag', 'user_cooccurrence')), - fetched_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (seed_artist_id, candidate_mbid, source) -); - -CREATE INDEX artist_similarity_unmatched_seed_score_idx - ON artist_similarity_unmatched (seed_artist_id, score DESC); -``` - -- [ ] **Step 1.2: Write the down migration** - -`internal/db/migrations/0012_artist_similarity_unmatched.down.sql`: - -```sql -DROP INDEX IF EXISTS artist_similarity_unmatched_seed_score_idx; -DROP TABLE IF EXISTS artist_similarity_unmatched; -``` - -- [ ] **Step 1.3: Append the worker upsert query** - -Append to `internal/db/queries/similarity.sql`: - -```sql --- name: UpsertArtistSimilarityUnmatched :exec --- Persists an out-of-library similar-artist MBID. Idempotent on --- (seed_artist_id, candidate_mbid, source) — re-fetches refresh the --- name/score and bump fetched_at. -INSERT INTO artist_similarity_unmatched ( - seed_artist_id, candidate_mbid, candidate_name, score, source -) VALUES ($1, $2, $3, $4, $5) -ON CONFLICT (seed_artist_id, candidate_mbid, source) DO UPDATE SET - candidate_name = EXCLUDED.candidate_name, - score = EXCLUDED.score, - fetched_at = now(); -``` - -- [ ] **Step 1.4: Add a `dbtest.ResetDB` entry for the new table** - -Modify `internal/dbtest/reset.go` — append `"artist_similarity_unmatched"` to the `dataTables` slice between `track_similarity` and `scrobble_queue` so M5c integration tests don't inherit residual rows. - -```go -var dataTables = []string{ - "artist_similarity", - "track_similarity", - "artist_similarity_unmatched", // M5c - "scrobble_queue", - // ... rest unchanged ... -} -``` - -- [ ] **Step 1.5: Regenerate sqlc** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && sqlc generate -go build ./... -go vet ./... -``` - -Expected: clean build. New method `UpsertArtistSimilarityUnmatched` appears in `internal/db/dbq/similarity.sql.go`. - -- [ ] **Step 1.6: Apply migration to verify it runs** - -```bash -docker compose exec -T postgres psql -U minstrel -d minstrel -c "DROP TABLE IF EXISTS artist_similarity_unmatched;" -# Migration applies on next test run / server start. -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race ./internal/db/... -count=1 -docker compose exec -T postgres psql -U minstrel -d minstrel -c "\d artist_similarity_unmatched" -``` - -Expected: `\d` shows the table with all columns + the seed_score index. - -- [ ] **Step 1.7: Commit** - -```bash -git add internal/db/migrations/0012_artist_similarity_unmatched.up.sql \ - internal/db/migrations/0012_artist_similarity_unmatched.down.sql \ - internal/db/queries/similarity.sql \ - internal/db/dbq/ \ - internal/dbtest/reset.go -git commit -m "feat(db): add artist_similarity_unmatched schema (migration 0012)" -``` - ---- - -### Task 2 — Extend `SimilarArtist` with `Name` field - -**Files:** -- Modify: `internal/scrobble/listenbrainz/client.go` — add `Name` to the struct -- Modify: `internal/scrobble/listenbrainz/client_test.go` (if existing tests break) — already-passing tests should still pass since adding a field is additive - -The current struct at `internal/scrobble/listenbrainz/client.go:241-245`: - -```go -type SimilarArtist struct { - MBID string `json:"artist_mbid"` - Score float64 `json:"score"` -} -``` - -ListenBrainz's `/1/explore/similar-artists/{mbid}` endpoint returns each row with `artist_mbid`, `name`, `score`, plus other fields we don't care about. Adding `Name` is a purely additive struct change; existing JSON unmarshal still works for everything else. - -- [ ] **Step 2.1: Add the field** - -Modify `internal/scrobble/listenbrainz/client.go:241-245`: - -```go -type SimilarArtist struct { - MBID string `json:"artist_mbid"` - Name string `json:"name"` - Score float64 `json:"score"` -} -``` - -- [ ] **Step 2.2: Verify existing tests still pass** - -```bash -go test ./internal/scrobble/listenbrainz/... -count=1 -``` - -Expected: all green. The unmarshal already ignored the `name` field; capturing it doesn't break anything. - -- [ ] **Step 2.3: Commit** - -```bash -git add internal/scrobble/listenbrainz/client.go -git commit -m "feat(listenbrainz): expose Name on SimilarArtist for M5c suggestions" -``` - ---- - -### Task 3 — Extend similarity worker to persist unmatched MBIDs - -**Files:** -- Modify: `internal/similarity/worker.go` — extend `upsertArtistSimilar` -- Modify: `internal/similarity/worker_test.go` — add `TestUpsertArtistSimilar_PersistsUnmatchedToTable` - -The current `upsertArtistSimilar` filters returned MBIDs to those in `idByMBID` (in-library only) and discards the rest. M5c keeps the matched-path unchanged but adds a parallel unmatched-persist loop with the same top-K cap. - -- [ ] **Step 3.1: Read the current `upsertArtistSimilar` shape** - -Read `internal/similarity/worker.go:170-200` (function body) before editing. It mirrors `upsertTrackSimilar` exactly — same sort, same `idByMBID`, same top-K pattern. The matched-loop pattern is the template. - -- [ ] **Step 3.2: Extend `upsertArtistSimilar`** - -Replace the function body (`internal/similarity/worker.go:170` onwards). The matched loop stays exactly as it was; we add a second loop that walks the same sorted `results` and persists unmatched rows up to `w.topK`: - -```go -func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artistAID pgtype.UUID, results []listenbrainz.SimilarArtist) { - if len(results) == 0 { - return - } - sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score }) - - mbids := make([]string, 0, len(results)) - for _, r := range results { - mbids = append(mbids, r.MBID) - } - rows, err := q.GetArtistsByMBIDs(ctx, mbids) - if err != nil { - w.logger.Warn("similarity: GetArtistsByMBIDs", "err", err) - return - } - idByMBID := make(map[string]pgtype.UUID, len(rows)) - for _, r := range rows { - if r.Mbid != nil { - idByMBID[*r.Mbid] = r.ID - } - } - - // Matched: in-library similars → artist_similarity (existing path). - takenMatched := 0 - for _, r := range results { - if takenMatched >= w.topK { - break - } - localID, ok := idByMBID[r.MBID] - if !ok { - continue - } - if localID == artistAID { - continue // defensive — DB CHECK constraint also catches self-edges - } - if uerr := q.UpsertArtistSimilarity(ctx, dbq.UpsertArtistSimilarityParams{ - ArtistAID: artistAID, ArtistBID: localID, Score: r.Score, Source: "listenbrainz", - }); uerr != nil { - w.logger.Warn("similarity: UpsertArtistSimilarity", "err", uerr) - continue - } - takenMatched++ - } - - // Unmatched: out-of-library similars → artist_similarity_unmatched (M5c). - // Same top-K cap as the matched path; missing-name rows are skipped (we - // can't render a suggestion without an artist name). - takenUnmatched := 0 - for _, r := range results { - if takenUnmatched >= w.topK { - break - } - if _, inLib := idByMBID[r.MBID]; inLib { - continue - } - if r.Name == "" { - w.logger.Debug("similarity: skipping unmatched similar with empty name", "mbid", r.MBID) - continue - } - if uerr := q.UpsertArtistSimilarityUnmatched(ctx, dbq.UpsertArtistSimilarityUnmatchedParams{ - SeedArtistID: artistAID, - CandidateMbid: r.MBID, - CandidateName: r.Name, - Score: r.Score, - Source: "listenbrainz", - }); uerr != nil { - w.logger.Warn("similarity: UpsertArtistSimilarityUnmatched", "err", uerr) - continue - } - takenUnmatched++ - } -} -``` - -Note: the existing function used a single `taken` variable; the new version splits into `takenMatched` and `takenUnmatched` so each path is bounded independently. Verify the existing call to `UpsertArtistSimilarity` in your repo matches the signature you're substituting — sqlc may name the params struct differently. - -- [ ] **Step 3.3: Write `TestUpsertArtistSimilar_PersistsUnmatchedToTable`** - -Append to `internal/similarity/worker_test.go`: - -```go -func TestUpsertArtistSimilar_PersistsUnmatchedToTable(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test in -short mode") - } - pool := newPool(t) // existing helper - q := dbq.New(pool) - ctx := context.Background() - - // Seed one in-library artist that will be the in-library match. - inLibMBID := "in-lib-mbid-123" - inLibArtist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{ - Name: "InLib Artist", SortName: "InLib Artist", Mbid: &inLibMBID, - }) - if err != nil { - t.Fatalf("seed in-lib artist: %v", err) - } - - // Seed a "seed" artist (the one whose similars we're processing). - seedMBID := "seed-artist-mbid" - seedArtist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{ - Name: "Seed Artist", SortName: "Seed Artist", Mbid: &seedMBID, - }) - if err != nil { - t.Fatalf("seed artist: %v", err) - } - - w := &Worker{pool: pool, logger: newTestLogger(), topK: 10} - - similars := []listenbrainz.SimilarArtist{ - {MBID: inLibMBID, Name: "InLib Artist", Score: 0.95}, - {MBID: "out-mbid-1", Name: "Outsider One", Score: 0.85}, - {MBID: "out-mbid-2", Name: "Outsider Two", Score: 0.80}, - {MBID: "out-mbid-3", Name: "Outsider Three", Score: 0.70}, - {MBID: "out-mbid-4", Name: "", Score: 0.60}, // missing name — should be skipped - } - w.upsertArtistSimilar(ctx, q, seedArtist.ID, similars) - - // Matched path: 1 row in artist_similarity. - var matchedCount int - if err := pool.QueryRow(ctx, - "SELECT count(*) FROM artist_similarity WHERE artist_a_id = $1", - seedArtist.ID, - ).Scan(&matchedCount); err != nil { - t.Fatalf("count matched: %v", err) - } - if matchedCount != 1 { - t.Errorf("artist_similarity rows = %d, want 1 (only the in-library match)", matchedCount) - } - - // Unmatched path: 3 rows (out-mbid-1/2/3); the empty-name row is skipped. - var unmatchedCount int - if err := pool.QueryRow(ctx, - "SELECT count(*) FROM artist_similarity_unmatched WHERE seed_artist_id = $1", - seedArtist.ID, - ).Scan(&unmatchedCount); err != nil { - t.Fatalf("count unmatched: %v", err) - } - if unmatchedCount != 3 { - t.Errorf("artist_similarity_unmatched rows = %d, want 3", unmatchedCount) - } - - // Verify a specific row's name + score round-tripped correctly. - var name string - var score float64 - if err := pool.QueryRow(ctx, - "SELECT candidate_name, score FROM artist_similarity_unmatched WHERE seed_artist_id = $1 AND candidate_mbid = $2", - seedArtist.ID, "out-mbid-1", - ).Scan(&name, &score); err != nil { - t.Fatalf("fetch out-mbid-1: %v", err) - } - if name != "Outsider One" || score != 0.85 { - t.Errorf("row = (%q, %v), want (Outsider One, 0.85)", name, score) - } - - // suppress unused warning if inLibArtist isn't otherwise referenced - _ = inLibArtist -} -``` - -If `internal/similarity/worker_test.go` doesn't already have a `newPool(t)` and `newTestLogger()` helper, mirror the pattern from `internal/lidarrquarantine/service_test.go` — those have working examples. - -- [ ] **Step 3.4: Run tests** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race -count=1 ./internal/similarity/... -``` - -Expected: existing tests still pass + new test passes. - -- [ ] **Step 3.5: Commit** - -```bash -git add internal/similarity/worker.go internal/similarity/worker_test.go -git commit -m "feat(similarity): persist unmatched similar-artist MBIDs for M5c" -``` - ---- - -### Task 4 — `internal/recommendation` `SuggestArtists` service - -**Files:** -- Create: `internal/recommendation/suggestions.go` -- Create: `internal/recommendation/suggestions_integration_test.go` -- Modify: `internal/db/queries/recommendation.sql` — add the suggestion CTE query -- Regenerate: `internal/db/dbq/*` - -- [ ] **Step 4.1: Append the suggestion query** - -Append to `internal/db/queries/recommendation.sql`: - -```sql --- name: SuggestArtistsForUser :many --- M5c: per-user artist suggestions ranked by signal × similarity. The --- seeds CTE collects the user's likes (×5) plus recency-decayed plays --- (exp(-age_days / $2)). The contributions CTE joins those seeds against --- artist_similarity_unmatched and filters out candidates already in --- library or already in a non-terminal lidarr_request. The outer SELECT --- aggregates per candidate, returning the top-3 contributing seeds for --- attribution. $1=user_id, $2=half_life_days, $3=limit. -WITH seeds AS ( - SELECT a.id AS artist_id, - 5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END) - + COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2 * 86400.0))), 0) - AS signal, - (gla.artist_id IS NOT NULL) AS is_liked, - COUNT(pe.id) AS play_count - FROM artists a - LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1 - LEFT JOIN tracks t ON t.artist_id = a.id - LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 - WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL - GROUP BY a.id, gla.artist_id -), -contributions AS ( - SELECT u.candidate_mbid, - u.candidate_name, - seeds.artist_id AS seed_id, - seeds.is_liked, - seeds.play_count, - seeds.signal * u.score AS contribution - FROM artist_similarity_unmatched u - JOIN seeds ON seeds.artist_id = u.seed_artist_id - WHERE NOT EXISTS (SELECT 1 FROM artists WHERE mbid = u.candidate_mbid) - AND NOT EXISTS ( - SELECT 1 FROM lidarr_requests r - WHERE r.user_id = $1 - AND r.lidarr_artist_mbid = u.candidate_mbid - AND r.status NOT IN ('rejected', 'failed') - ) -) -SELECT candidate_mbid, - candidate_name, - SUM(contribution)::float8 AS total_score, - (array_agg(seed_id ORDER BY contribution DESC))[1:3] AS top_seed_ids, - (array_agg(contribution ORDER BY contribution DESC))[1:3] AS top_contributions, - (array_agg(is_liked ORDER BY contribution DESC))[1:3] AS top_is_liked, - (array_agg(play_count ORDER BY contribution DESC))[1:3] AS top_play_counts -FROM contributions -GROUP BY candidate_mbid, candidate_name -ORDER BY total_score DESC -LIMIT $3; -``` - -The extra arrays (`top_is_liked`, `top_play_counts`) let the SPA pick "liked"/"played" verbiage per attribution-line slot. - -- [ ] **Step 4.2: Regenerate sqlc** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && sqlc generate -go build ./... -``` - -Expected: clean. New method `SuggestArtistsForUser` in `internal/db/dbq/recommendation.sql.go`. Note the row type may have field names like `TopSeedIds` (plural-suffix suppressed by sqlc) — read the generated file before relying on names in the next step. - -- [ ] **Step 4.3: Write `internal/recommendation/suggestions.go`** - -```go -// suggestions.go is the M5c per-user artist-suggestion service. Reads -// the user's likes + plays, projects them through artist_similarity_unmatched -// via a single CTE, returns top-N candidates with top-3 attribution seeds -// resolved to artist names. -package recommendation - -import ( - "context" - "fmt" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// ArtistSuggestion is one ranked candidate with its top-3 attribution seeds. -type ArtistSuggestion struct { - MBID string - Name string - Score float64 - Attribution []SeedContribution -} - -// SeedContribution is one of the top-3 contributing seeds for a candidate. -type SeedContribution struct { - ArtistID pgtype.UUID - Name string - Contribution float64 - IsLiked bool - PlayCount int64 -} - -// SuggestArtists returns top-N artist suggestions for the user. limit is -// capped at 50; halfLifeDays is the recency-decay half-life for plays -// (default 30, operator-tunable). -func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, halfLifeDays float64, limit int) ([]ArtistSuggestion, error) { - if limit <= 0 || limit > 50 { - limit = 12 - } - if halfLifeDays <= 0 { - halfLifeDays = 30 - } - q := dbq.New(pool) - rows, err := q.SuggestArtistsForUser(ctx, dbq.SuggestArtistsForUserParams{ - UserID: userID, - Column2: halfLifeDays, // sqlc names unbound positional params Column2/3 — verify - Limit: int32(limit), - }) - if err != nil { - return nil, fmt.Errorf("suggest: query: %w", err) - } - if len(rows) == 0 { - return []ArtistSuggestion{}, nil - } - - // Collect the union of top-3 seed IDs across all rows for one batched - // name lookup. - seedSet := make(map[pgtype.UUID]struct{}, len(rows)*3) - for _, r := range rows { - for _, sid := range r.TopSeedIds { - seedSet[sid] = struct{}{} - } - } - seedIDs := make([]pgtype.UUID, 0, len(seedSet)) - for id := range seedSet { - seedIDs = append(seedIDs, id) - } - artists, err := q.GetArtistsByIDs(ctx, seedIDs) - if err != nil { - return nil, fmt.Errorf("suggest: resolve seeds: %w", err) - } - nameByID := make(map[pgtype.UUID]string, len(artists)) - for _, a := range artists { - nameByID[a.ID] = a.Name - } - - out := make([]ArtistSuggestion, 0, len(rows)) - for _, r := range rows { - attribution := make([]SeedContribution, 0, len(r.TopSeedIds)) - for i, sid := range r.TopSeedIds { - if i >= len(r.TopContributions) { - break - } - attribution = append(attribution, SeedContribution{ - ArtistID: sid, - Name: nameByID[sid], - Contribution: r.TopContributions[i], - IsLiked: r.TopIsLiked[i], - PlayCount: r.TopPlayCounts[i], - }) - } - out = append(out, ArtistSuggestion{ - MBID: r.CandidateMbid, - Name: r.CandidateName, - Score: r.TotalScore, - Attribution: attribution, - }) - } - return out, nil -} -``` - -Two things to verify against the actual generated code in `internal/db/dbq/recommendation.sql.go`: -1. The param struct may name `$2` something other than `Column2` (sqlc occasionally renames). Look at `SuggestArtistsForUserParams` and use whatever's there. -2. `GetArtistsByIDs` may not exist — check `internal/db/queries/artists.sql`. If absent, add a query in this same task: - -```sql --- name: GetArtistsByIDs :many -SELECT * FROM artists WHERE id = ANY($1::uuid[]); -``` - -(If `GetArtistsByMBIDs` exists but not `GetArtistsByIDs`, add the IDs variant; sqlc-regenerate.) - -- [ ] **Step 4.4: Write integration tests** - -Create `internal/recommendation/suggestions_integration_test.go`: - -```go -package recommendation - -import ( - "context" - "io" - "log/slog" - "os" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" -) - -func newPool(t *testing.T) *pgxpool.Pool { - t.Helper() - if testing.Short() { - t.Skip("skipping integration test in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - t.Fatalf("pool: %v", err) - } - t.Cleanup(pool.Close) - dbtest.ResetDB(t, pool) - return pool -} - -func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User { - t.Helper() - u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ - Username: dbtest.TestUserPrefix + name, PasswordHash: "x", - ApiToken: name + "-token", IsAdmin: false, - }) - if err != nil { - t.Fatalf("seed user: %v", err) - } - return u -} - -func seedArtist(t *testing.T, pool *pgxpool.Pool, name, mbid string) dbq.Artist { - t.Helper() - var mbidPtr *string - if mbid != "" { - mbidPtr = &mbid - } - a, err := dbq.New(pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{ - Name: name, SortName: name, Mbid: mbidPtr, - }) - if err != nil { - t.Fatalf("seed artist: %v", err) - } - return a -} - -func seedUnmatched(t *testing.T, pool *pgxpool.Pool, seedID pgtype.UUID, candMBID, candName string, score float64) { - t.Helper() - if err := dbq.New(pool).UpsertArtistSimilarityUnmatched(context.Background(), dbq.UpsertArtistSimilarityUnmatchedParams{ - SeedArtistID: seedID, - CandidateMbid: candMBID, - CandidateName: candName, - Score: score, - Source: "listenbrainz", - }); err != nil { - t.Fatalf("seed unmatched: %v", err) - } -} - -func TestSuggestArtists_LikesAndPlaysContributeToScore(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - seedA := seedArtist(t, pool, "Seed Liked", "") - seedB := seedArtist(t, pool, "Seed Played", "") - - // alice likes seedA. - if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ - UserID: user.ID, ArtistID: seedA.ID, - }); err != nil { - t.Fatalf("LikeArtist: %v", err) - } - // alice played seedB. (Need a play_event with the right artist via tracks.) - seedBAlbum := seedAlbumForArtist(t, pool, seedB.ID, "Album B") - seedBTrack := seedTrackOnAlbum(t, pool, seedBAlbum.ID, seedB.ID, "Track B") - insertPlayEvent(t, pool, user.ID, seedBTrack.ID, time.Now().Add(-1*time.Hour)) - - // Both seeds point at the same out-of-library candidate. - seedUnmatched(t, pool, seedA.ID, "out-mbid", "Outsider", 0.9) - seedUnmatched(t, pool, seedB.ID, "out-mbid", "Outsider", 0.5) - - out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) - if err != nil { - t.Fatalf("SuggestArtists: %v", err) - } - if len(out) != 1 { - t.Fatalf("len = %d, want 1", len(out)) - } - s := out[0] - if s.MBID != "out-mbid" || s.Name != "Outsider" { - t.Errorf("got = %+v", s) - } - if s.Score <= 0 { - t.Errorf("score = %v, want > 0", s.Score) - } - if len(s.Attribution) != 2 { - t.Errorf("attribution len = %d, want 2", len(s.Attribution)) - } -} - -func TestSuggestArtists_Top12Cap(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - seed := seedArtist(t, pool, "Seed", "") - if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ - UserID: user.ID, ArtistID: seed.ID, - }); err != nil { - t.Fatalf("LikeArtist: %v", err) - } - for i := 0; i < 30; i++ { - seedUnmatched(t, pool, seed.ID, fmt.Sprintf("mbid-%02d", i), fmt.Sprintf("Artist %02d", i), 0.99-float64(i)*0.01) - } - out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) - if err != nil { - t.Fatalf("SuggestArtists: %v", err) - } - if len(out) != 12 { - t.Errorf("len = %d, want 12", len(out)) - } - if out[0].MBID != "mbid-00" { - t.Errorf("first = %s, want mbid-00 (highest score)", out[0].MBID) - } -} - -func TestSuggestArtists_AttributionTopThree(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - // 5 seed artists all liked, all pointing at the same candidate but - // with descending similarity scores so contributions order is clean. - seeds := make([]dbq.Artist, 5) - for i := 0; i < 5; i++ { - seeds[i] = seedArtist(t, pool, fmt.Sprintf("Seed %d", i), "") - if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ - UserID: user.ID, ArtistID: seeds[i].ID, - }); err != nil { - t.Fatalf("LikeArtist: %v", err) - } - seedUnmatched(t, pool, seeds[i].ID, "shared-mbid", "Shared", 0.9-float64(i)*0.1) - } - out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) - if err != nil { - t.Fatalf("SuggestArtists: %v", err) - } - if len(out) != 1 { - t.Fatalf("len = %d, want 1 (shared candidate)", len(out)) - } - if got := len(out[0].Attribution); got != 3 { - t.Errorf("attribution len = %d, want 3", got) - } - // Verify ordering: highest contribution first (seed 0 with score 0.9). - if out[0].Attribution[0].Name != "Seed 0" { - t.Errorf("top attribution = %q, want Seed 0", out[0].Attribution[0].Name) - } -} - -func TestSuggestArtists_RecencyDecayDownweightsOldPlays(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - recentSeed := seedArtist(t, pool, "Recent", "") - oldSeed := seedArtist(t, pool, "Old", "") - - rAlbum := seedAlbumForArtist(t, pool, recentSeed.ID, "Recent Album") - rTrack := seedTrackOnAlbum(t, pool, rAlbum.ID, recentSeed.ID, "Recent Track") - insertPlayEvent(t, pool, user.ID, rTrack.ID, time.Now().Add(-1*24*time.Hour)) - - oAlbum := seedAlbumForArtist(t, pool, oldSeed.ID, "Old Album") - oTrack := seedTrackOnAlbum(t, pool, oAlbum.ID, oldSeed.ID, "Old Track") - insertPlayEvent(t, pool, user.ID, oTrack.ID, time.Now().Add(-90*24*time.Hour)) - - // Both seeds point at the same candidate with the same similarity score. - seedUnmatched(t, pool, recentSeed.ID, "cand", "Cand", 0.5) - seedUnmatched(t, pool, oldSeed.ID, "cand", "Cand", 0.5) - - out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) - if err != nil { - t.Fatalf("SuggestArtists: %v", err) - } - if len(out) != 1 { - t.Fatalf("len = %d, want 1", len(out)) - } - if len(out[0].Attribution) != 2 { - t.Fatalf("attribution len = %d, want 2", len(out[0].Attribution)) - } - // Recent seed contributes more than old seed. - if out[0].Attribution[0].Name != "Recent" { - t.Errorf("top attribution = %q, want Recent (1d-old play decays less than 90d)", out[0].Attribution[0].Name) - } - if out[0].Attribution[0].Contribution <= out[0].Attribution[1].Contribution { - t.Errorf("recent contribution (%v) should exceed old (%v)", - out[0].Attribution[0].Contribution, out[0].Attribution[1].Contribution) - } -} - -func TestSuggestArtists_FiltersInLibraryCandidates(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - seed := seedArtist(t, pool, "Seed", "") - if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ - UserID: user.ID, ArtistID: seed.ID, - }); err != nil { - t.Fatalf("LikeArtist: %v", err) - } - // Candidate that's already in library. - inLibMBID := "in-lib-mbid" - seedArtist(t, pool, "InLib", inLibMBID) - seedUnmatched(t, pool, seed.ID, inLibMBID, "InLib", 0.9) - - out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) - if err != nil { - t.Fatalf("SuggestArtists: %v", err) - } - if len(out) != 0 { - t.Errorf("len = %d, want 0 (in-library candidate should be filtered)", len(out)) - } -} - -func TestSuggestArtists_FiltersAlreadyRequested(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - seed := seedArtist(t, pool, "Seed", "") - if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ - UserID: user.ID, ArtistID: seed.ID, - }); err != nil { - t.Fatalf("LikeArtist: %v", err) - } - seedUnmatched(t, pool, seed.ID, "req-mbid", "Pending Request", 0.9) - if _, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{ - UserID: user.ID, - Kind: dbq.LidarrRequestKindArtist, - LidarrArtistMbid: "req-mbid", - ArtistName: "Pending Request", - }); err != nil { - t.Fatalf("CreateLidarrRequest: %v", err) - } - - out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) - if err != nil { - t.Fatalf("SuggestArtists: %v", err) - } - if len(out) != 0 { - t.Errorf("len = %d, want 0 (pending request should hide candidate)", len(out)) - } -} - -func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - seed := seedArtist(t, pool, "Seed", "") - if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ - UserID: user.ID, ArtistID: seed.ID, - }); err != nil { - t.Fatalf("LikeArtist: %v", err) - } - seedUnmatched(t, pool, seed.ID, "rej-mbid", "Rejected Once", 0.9) - req, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{ - UserID: user.ID, - Kind: dbq.LidarrRequestKindArtist, - LidarrArtistMbid: "rej-mbid", - ArtistName: "Rejected Once", - }) - if err != nil { - t.Fatalf("CreateLidarrRequest: %v", err) - } - rejNotes := "wrong artist" - if _, err := dbq.New(pool).RejectLidarrRequest(context.Background(), dbq.RejectLidarrRequestParams{ - ID: req.ID, Notes: &rejNotes, DecidedBy: user.ID, - }); err != nil { - t.Fatalf("RejectLidarrRequest: %v", err) - } - - out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) - if err != nil { - t.Fatalf("SuggestArtists: %v", err) - } - if len(out) != 1 { - t.Errorf("len = %d, want 1 (rejected requests don't hide the candidate)", len(out)) - } -} - -func TestSuggestArtists_EmptyForNewUser(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "newbie") - out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) - if err != nil { - t.Fatalf("SuggestArtists: %v", err) - } - if len(out) != 0 { - t.Errorf("len = %d, want 0 (new user has no signal)", len(out)) - } -} -``` - -Helper functions `seedAlbumForArtist`, `seedTrackOnAlbum`, `insertPlayEvent` are needed. Mirror the same helpers from `internal/lidarrquarantine/service_test.go`'s `seedTrack` (but parameterize artist): - -```go -func seedAlbumForArtist(t *testing.T, pool *pgxpool.Pool, artistID pgtype.UUID, title string) dbq.Album { - t.Helper() - a, err := dbq.New(pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ - Title: title, SortTitle: title, ArtistID: artistID, - }) - if err != nil { - t.Fatalf("seed album: %v", err) - } - return a -} - -func seedTrackOnAlbum(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string) dbq.Track { - t.Helper() - tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: title, AlbumID: albumID, ArtistID: artistID, - DurationMs: 1000, FilePath: "/tmp/m5c-" + title + ".mp3", - FileSize: 1, FileFormat: "mp3", - }) - if err != nil { - t.Fatalf("seed track: %v", err) - } - return tr -} - -func insertPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time) { - t.Helper() - if _, err := pool.Exec(context.Background(), - `INSERT INTO play_events (user_id, track_id, started_at, was_skipped) VALUES ($1, $2, $3, false)`, - userID, trackID, startedAt, - ); err != nil { - t.Fatalf("insert play_event: %v", err) - } -} -``` - -The exact `play_events` column set may differ from this minimal insert — read the migration `0005_events.up.sql` and add any required NOT-NULL columns (e.g. `client_id`, `session_id`) with sensible defaults if the insert fails. - -- [ ] **Step 4.5: Run tests** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race -count=1 -p 1 ./internal/recommendation/... -``` - -Expected: 7 tests pass. - -- [ ] **Step 4.6: Commit** - -```bash -git add internal/recommendation/suggestions.go \ - internal/recommendation/suggestions_integration_test.go \ - internal/db/queries/recommendation.sql \ - internal/db/queries/artists.sql \ - internal/db/dbq/ -git commit -m "feat(recommendation): SuggestArtists service for M5c" -``` - -(Include `artists.sql` if you added `GetArtistsByIDs` to it in Step 4.3.) - ---- - -### Task 5 — `/api/discover/suggestions` handler + route mount - -**Files:** -- Create: `internal/api/suggestions.go` -- Create: `internal/api/suggestions_test.go` -- Modify: `internal/api/api.go` — add the route - -- [ ] **Step 5.1: Write the handler** - -Create `internal/api/suggestions.go`: - -```go -package api - -import ( - "net/http" - "strconv" - - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" - "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" -) - -// suggestionView is the wire shape returned by GET /api/discover/suggestions. -type suggestionView struct { - MBID string `json:"mbid"` - Name string `json:"name"` - Score float64 `json:"score"` - Attribution []seedContributionView `json:"attribution"` -} - -type seedContributionView struct { - ArtistID pgtype.UUID `json:"artist_id"` - Name string `json:"name"` - Contribution float64 `json:"contribution"` - IsLiked bool `json:"is_liked"` - PlayCount int64 `json:"play_count"` -} - -// handleListSuggestions implements GET /api/discover/suggestions. -// -// Query params: -// - limit (default 12, capped at 50) -// - half_life_days (default 30, no max) -// -// Returns 200 with a JSON array (possibly empty). Read-only; no admin gate. -func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - limit := 12 - if v := r.URL.Query().Get("limit"); v != "" { - n, err := strconv.Atoi(v) - if err != nil || n < 1 { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid limit") - return - } - limit = n - } - halfLife := 30.0 - if v := r.URL.Query().Get("half_life_days"); v != "" { - f, err := strconv.ParseFloat(v, 64) - if err != nil || f <= 0 { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid half_life_days") - return - } - halfLife = f - } - - suggestions, err := recommendation.SuggestArtists(r.Context(), h.pool, user.ID, halfLife, limit) - if err != nil { - h.logger.Error("api: list suggestions", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "failed to load suggestions") - return - } - - out := make([]suggestionView, 0, len(suggestions)) - for _, s := range suggestions { - attr := make([]seedContributionView, 0, len(s.Attribution)) - for _, a := range s.Attribution { - attr = append(attr, seedContributionView{ - ArtistID: a.ArtistID, - Name: a.Name, - Contribution: a.Contribution, - IsLiked: a.IsLiked, - PlayCount: a.PlayCount, - }) - } - out = append(out, suggestionView{ - MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr, - }) - } - writeJSON(w, http.StatusOK, out) -} -``` - -- [ ] **Step 5.2: Mount the route** - -In `internal/api/api.go`, find the authed group (after `r.Get("/api/radio", ...)` line) and add: - -```go -authed.Get("/discover/suggestions", h.handleListSuggestions) -``` - -- [ ] **Step 5.3: Write handler tests** - -Create `internal/api/suggestions_test.go`: - -```go -package api - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" -) - -func TestSuggestions_HappyPath(t *testing.T) { - h, _ := testHandlers(t) - user := seedUser(t, h.pool, "alice", "pw", false) - - // Seed: alice likes a seed artist; that seed has one out-of-library - // similar in artist_similarity_unmatched. - seedA, err := dbq.New(h.pool).UpsertArtist(t.Context(), dbq.UpsertArtistParams{ - Name: "Seed", SortName: "Seed", - }) - if err != nil { - t.Fatalf("UpsertArtist: %v", err) - } - if _, err := dbq.New(h.pool).LikeArtist(t.Context(), dbq.LikeArtistParams{ - UserID: user.ID, ArtistID: seedA.ID, - }); err != nil { - t.Fatalf("LikeArtist: %v", err) - } - if err := dbq.New(h.pool).UpsertArtistSimilarityUnmatched(t.Context(), dbq.UpsertArtistSimilarityUnmatchedParams{ - SeedArtistID: seedA.ID, - CandidateMbid: "out-mbid", - CandidateName: "Outsider", - Score: 0.9, - Source: "listenbrainz", - }); err != nil { - t.Fatalf("UpsertArtistSimilarityUnmatched: %v", err) - } - - req := httptest.NewRequest(http.MethodGet, "/api/discover/suggestions", nil) - setUserCtx(req, user) - w := httptest.NewRecorder() - h.handleListSuggestions(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) - } - var got []suggestionView - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if len(got) != 1 { - t.Fatalf("len = %d, want 1; body = %s", len(got), w.Body.String()) - } - if got[0].MBID != "out-mbid" || got[0].Name != "Outsider" { - t.Errorf("got = %+v", got[0]) - } - if len(got[0].Attribution) != 1 { - t.Errorf("attribution len = %d, want 1", len(got[0].Attribution)) - } - if got[0].Attribution[0].Name != "Seed" { - t.Errorf("attribution name = %q, want Seed", got[0].Attribution[0].Name) - } -} - -func TestSuggestions_EmptyForNewUser(t *testing.T) { - h, _ := testHandlers(t) - user := seedUser(t, h.pool, "newbie", "pw", false) - - req := httptest.NewRequest(http.MethodGet, "/api/discover/suggestions", nil) - setUserCtx(req, user) - w := httptest.NewRecorder() - h.handleListSuggestions(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", w.Code) - } - if got := w.Body.String(); got != "[]\n" && got != "[]" { - t.Errorf("body = %q, want []", got) - } -} - -func TestSuggestions_BadLimit(t *testing.T) { - h, _ := testHandlers(t) - user := seedUser(t, h.pool, "alice", "pw", false) - - req := httptest.NewRequest(http.MethodGet, "/api/discover/suggestions?limit=not-a-number", nil) - setUserCtx(req, user) - w := httptest.NewRecorder() - h.handleListSuggestions(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("status = %d, want 400", w.Code) - } -} - -// suppress unused imports in some test layouts -var _ = dbtest.TestUserPrefix -``` - -`testHandlers`, `seedUser`, `setUserCtx` are existing helpers in the test layout — see `internal/api/auth_test.go` and `internal/api/requests_test.go`. Use whatever pattern those tests use to seed a user and inject it into the request context. - -- [ ] **Step 5.4: Run tests** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race -count=1 -p 1 ./internal/api/... -run Suggestions -``` - -Expected: 3 tests pass. - -- [ ] **Step 5.5: Commit** - -```bash -git add internal/api/suggestions.go internal/api/suggestions_test.go internal/api/api.go -git commit -m "feat(api): /api/discover/suggestions handler" -``` - ---- - -### Task 6 — Frontend API client + types + qk - -**Files:** -- Create: `web/src/lib/api/suggestions.ts` -- Create: `web/src/lib/api/suggestions.test.ts` -- Modify: `web/src/lib/api/types.ts` — add `ArtistSuggestion`, `SeedContribution` -- Modify: `web/src/lib/api/queries.ts` — add `qk.suggestions(limit)` - -- [ ] **Step 6.1: Add types** - -Append to `web/src/lib/api/types.ts`: - -```ts -export type SeedContribution = { - artist_id: string; - name: string; - contribution: number; - is_liked: boolean; - play_count: number; -}; - -export type ArtistSuggestion = { - mbid: string; - name: string; - score: number; - attribution: SeedContribution[]; // up to 3 entries, ordered by contribution DESC -}; -``` - -- [ ] **Step 6.2: Add query key** - -Append to the `qk` object in `web/src/lib/api/queries.ts`: - -```ts -suggestions: (limit?: number) => ['suggestions', { limit: limit ?? 12 }] as const, -``` - -- [ ] **Step 6.3: Write `suggestions.ts`** - -Create `web/src/lib/api/suggestions.ts`: - -```ts -import { createQuery } from '@tanstack/svelte-query'; -import { api } from './client'; -import { qk } from './queries'; -import type { ArtistSuggestion } from './types'; - -export async function listSuggestions(limit = 12): Promise { - return api.get(`/api/discover/suggestions?limit=${limit}`); -} - -export function createSuggestionsQuery(limit = 12) { - return createQuery({ - queryKey: qk.suggestions(limit), - queryFn: () => listSuggestions(limit), - staleTime: 5 * 60_000 // 5 minutes - }); -} -``` - -- [ ] **Step 6.4: Write tests** - -Create `web/src/lib/api/suggestions.test.ts`: - -```ts -import { describe, expect, test, vi, afterEach } from 'vitest'; - -vi.mock('./client', () => ({ - api: { get: vi.fn() } -})); - -import { listSuggestions } from './suggestions'; -import { qk } from './queries'; -import { api } from './client'; -import type { ArtistSuggestion } from './types'; - -afterEach(() => vi.clearAllMocks()); - -describe('suggestions client', () => { - test('listSuggestions hits the right URL with default limit', async () => { - const fixture: ArtistSuggestion[] = [ - { - mbid: 'm1', - name: 'Outsider', - score: 1.5, - attribution: [ - { artist_id: 'a1', name: 'Seed', contribution: 0.9, is_liked: true, play_count: 0 } - ] - } - ]; - (api.get as ReturnType).mockResolvedValueOnce(fixture); - const got = await listSuggestions(); - expect(api.get).toHaveBeenCalledWith('/api/discover/suggestions?limit=12'); - expect(got).toEqual(fixture); - }); - - test('listSuggestions honors a custom limit', async () => { - (api.get as ReturnType).mockResolvedValueOnce([]); - await listSuggestions(20); - expect(api.get).toHaveBeenCalledWith('/api/discover/suggestions?limit=20'); - }); - - test('qk.suggestions key shape', () => { - expect(qk.suggestions()).toEqual(['suggestions', { limit: 12 }]); - expect(qk.suggestions(20)).toEqual(['suggestions', { limit: 20 }]); - }); -}); -``` - -- [ ] **Step 6.5: Verify** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web -npm run check -npm test -- --run suggestions -cd .. -``` - -Expected: 0 errors, 3 tests pass. - -- [ ] **Step 6.6: Commit** - -```bash -git add web/src/lib/api/suggestions.ts web/src/lib/api/suggestions.test.ts \ - web/src/lib/api/types.ts web/src/lib/api/queries.ts -git commit -m "feat(web): API client for /api/discover/suggestions" -``` - ---- - -### Task 7 — Extend `` with `attribution` prop - -**Files:** -- Modify: `web/src/lib/components/DiscoverResultCard.svelte` -- Modify: `web/src/lib/components/DiscoverResultCard.test.ts` - -The card already has a `$props()` block accepting `kind`, `title`, `subtitle?`, `imageUrl?`, `state`, `onRequest?`. Add `attribution?: string` and render it in italic Vellum below the title (above the reserved badge slot). - -- [ ] **Step 7.1: Add the prop** - -In `DiscoverResultCard.svelte`, modify the `$props()` destructure: - -```ts -let { - kind, - title, - subtitle, - imageUrl, - state, - attribution, - onRequest, -}: { - kind: DiscoverCardKind; - title: string; - subtitle?: string; - imageUrl?: string; - state: DiscoverCardState; - attribution?: string; - onRequest?: () => void; -} = $props(); -``` - -- [ ] **Step 7.2: Render the attribution line** - -Find the section rendering the title + subtitle (search for `class="title"` and `class="subtitle"`). Add the attribution line between subtitle and `.badge-row`: - -```svelte -
-
{title}
- {#if subtitle} -
{subtitle}
- {/if} - {#if attribution} -
- {attribution} -
- {/if} -
- {#if state === 'kept'} - Kept - {/if} -
-
-``` - -- [ ] **Step 7.3: Add tests** - -Append to `DiscoverResultCard.test.ts`: - -```ts -test('renders attribution line when prop is set', () => { - render(DiscoverResultCard, { - props: { - kind: 'artist', - title: 'Outsider', - state: 'requestable', - attribution: 'Because you liked Boards of Canada and played Aphex Twin.' - } - }); - expect(screen.getByTestId('attribution')).toHaveTextContent('Because you liked Boards of Canada and played Aphex Twin.'); -}); - -test('omits attribution line when prop is absent', () => { - render(DiscoverResultCard, { - props: { kind: 'artist', title: 'Outsider', state: 'requestable' } - }); - expect(screen.queryByTestId('attribution')).not.toBeInTheDocument(); -}); -``` - -- [ ] **Step 7.4: Verify + commit** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web -npm run check -npm test -- --run DiscoverResultCard -cd .. -git add web/src/lib/components/DiscoverResultCard.svelte web/src/lib/components/DiscoverResultCard.test.ts -git commit -m "feat(web): DiscoverResultCard attribution prop for M5c suggestions" -``` - ---- - -### Task 8 — `` subcomponent + `/discover` integration - -**Files:** -- Create: `web/src/lib/components/SuggestionFeed.svelte` -- Create: `web/src/lib/components/SuggestionFeed.test.ts` -- Modify: `web/src/routes/discover/+page.svelte` — branch to `` when search is empty -- Modify: `web/src/routes/discover/discover.test.ts` — add suggestion-feed scenarios - -- [ ] **Step 8.1: Write ``** - -Create `web/src/lib/components/SuggestionFeed.svelte`: - -```svelte - - -
-
-

Suggested for you

-

Out-of-library artists drawn from what you've liked and played.

-
- - {#if !query.isPending && suggestions.length === 0} -

Listen to something or like an artist to start getting suggestions.

- {:else if suggestions.length > 0} -
- {#each suggestions.filter(visible) as s (s.mbid)} - onRequest(s)} - /> - {/each} -
- {/if} -
-``` - -- [ ] **Step 8.2: Write `` tests** - -Create `web/src/lib/components/SuggestionFeed.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; -import { mockQuery } from '../../test-utils/query'; - -const invalidateMock = vi.fn(); -vi.mock('@tanstack/svelte-query', async (orig) => { - const actual = (await orig()) as Record; - return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) }; -}); - -vi.mock('$lib/api/suggestions', () => ({ - createSuggestionsQuery: vi.fn() -})); - -vi.mock('$lib/api/requests', () => ({ - createRequest: vi.fn().mockResolvedValue({}) -})); - -import SuggestionFeed from './SuggestionFeed.svelte'; -import { createSuggestionsQuery } from '$lib/api/suggestions'; -import { createRequest } from '$lib/api/requests'; -import type { ArtistSuggestion } from '$lib/api/types'; - -const oneSeed: ArtistSuggestion = { - mbid: 'mb1', name: 'Outsider', score: 1.0, - attribution: [{ artist_id: 'a1', name: 'Seed', contribution: 0.9, is_liked: true, play_count: 0 }] -}; - -const twoSeeds: ArtistSuggestion = { - mbid: 'mb2', name: 'Outsider Two', score: 2.0, - attribution: [ - { artist_id: 'a1', name: 'A', contribution: 0.8, is_liked: true, play_count: 0 }, - { artist_id: 'a2', name: 'B', contribution: 0.5, is_liked: false, play_count: 3 } - ] -}; - -const threeSeeds: ArtistSuggestion = { - mbid: 'mb3', name: 'Outsider Three', score: 3.0, - attribution: [ - { artist_id: 'a1', name: 'X', contribution: 0.9, is_liked: true, play_count: 0 }, - { artist_id: 'a2', name: 'Y', contribution: 0.6, is_liked: false, play_count: 5 }, - { artist_id: 'a3', name: 'Z', contribution: 0.3, is_liked: false, play_count: 1 } - ] -}; - -afterEach(() => vi.clearAllMocks()); - -describe('SuggestionFeed', () => { - test('renders one card per suggestion', () => { - (createSuggestionsQuery as ReturnType).mockReturnValue( - mockQuery({ data: [oneSeed, twoSeeds] }) - ); - render(SuggestionFeed); - expect(screen.getByText('Outsider')).toBeInTheDocument(); - expect(screen.getByText('Outsider Two')).toBeInTheDocument(); - }); - - test('attribution copy: 1 seed → "Because you liked X."', () => { - (createSuggestionsQuery as ReturnType).mockReturnValue( - mockQuery({ data: [oneSeed] }) - ); - render(SuggestionFeed); - expect(screen.getByText(/because you liked seed\./i)).toBeInTheDocument(); - }); - - test('attribution copy: 2 seeds → "Because you liked A and played B."', () => { - (createSuggestionsQuery as ReturnType).mockReturnValue( - mockQuery({ data: [twoSeeds] }) - ); - render(SuggestionFeed); - expect(screen.getByText(/because you liked a and played b\./i)).toBeInTheDocument(); - }); - - test('attribution copy: 3 seeds → Oxford comma', () => { - (createSuggestionsQuery as ReturnType).mockReturnValue( - mockQuery({ data: [threeSeeds] }) - ); - render(SuggestionFeed); - expect(screen.getByText(/because you liked x, played y, and played z\./i)).toBeInTheDocument(); - }); - - test('Request button calls createRequest with artist-kind body', async () => { - (createSuggestionsQuery as ReturnType).mockReturnValue( - mockQuery({ data: [oneSeed] }) - ); - render(SuggestionFeed); - await fireEvent.click(screen.getByRole('button', { name: /request outsider/i })); - expect(createRequest).toHaveBeenCalledWith({ - kind: 'artist', - lidarr_artist_mbid: 'mb1', - artist_name: 'Outsider' - }); - expect(invalidateMock).toHaveBeenCalled(); - }); - - test('empty state when data is []', () => { - (createSuggestionsQuery as ReturnType).mockReturnValue(mockQuery({ data: [] })); - render(SuggestionFeed); - expect(screen.getByText(/listen to something or like an artist/i)).toBeInTheDocument(); - }); -}); -``` - -- [ ] **Step 8.3: Wire `` into `/discover`** - -Modify `web/src/routes/discover/+page.svelte`. Read it first — the existing structure runs the search query when `debouncedQ.length > 0`. Add the feed branch: - -```svelte - - - - -
- - - - {#if debouncedQ === ''} - - {:else} - - {existing markup unchanged} - {/if} -
-``` - -The search-input element stays at the top level (visible in both branches). The header copy ("Add music to the library" vs "Suggested for you") is now owned by the respective branch — `` renders its own header; the search branch keeps the existing one. - -If the existing `+page.svelte` has its header above the input, you'll need to move it inside the search branch. Pattern: - -```svelte - - -{#if debouncedQ === ''} - -{:else} -
-

Add music to the library

-

...

-
- -{/if} -``` - -- [ ] **Step 8.4: Update `discover.test.ts`** - -The existing tests assume `inputValue === ''` shows the initial-copy state. Now it shows the suggestion feed. Update the relevant test and add new ones: - -Add a mock for `$lib/api/suggestions`: - -```ts -vi.mock('$lib/api/suggestions', () => ({ - createSuggestionsQuery: vi.fn() -})); -``` - -Update the existing "initial state shows search prompt copy" test (or add a replacement): - -```ts -test('empty input shows the suggestion feed', () => { - (createSuggestionsQuery as ReturnType).mockReturnValue( - mockQuery({ data: [] }) - ); - render(DiscoverPage); - expect(screen.getByText(/suggested for you/i)).toBeInTheDocument(); -}); - -test('typing replaces feed with search', async () => { - (createSuggestionsQuery as ReturnType).mockReturnValue( - mockQuery({ data: [] }) - ); - // mock the lidarr search query as before - // ... fire input event to trigger debounced search ... - // ... advance fake timers ... - expect(screen.queryByText(/suggested for you/i)).not.toBeInTheDocument(); - expect(screen.getByText(/add music to the library/i)).toBeInTheDocument(); -}); -``` - -The existing tests that drive the search flow stay — they always provide a non-empty query. The empty-input case becomes a suggestion-feed test. - -- [ ] **Step 8.5: Verify** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web -npm run check -npm test -- --run SuggestionFeed discover -npm run build -cd .. -``` - -Expected: 0 errors, all tests pass, build clean. - -- [ ] **Step 8.6: Commit** - -```bash -git add web/src/lib/components/SuggestionFeed.svelte \ - web/src/lib/components/SuggestionFeed.test.ts \ - web/src/routes/discover/+page.svelte \ - web/src/routes/discover/discover.test.ts -git commit -m "feat(web): suggestion feed on /discover (search-empty default)" -``` - ---- - -### Task 9 — Final verification + branch finish - -- [ ] **Step 9.1: Full Go test sweep** - -```bash -go test -short -race ./... -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test -race -p 1 ./... -``` - -Expected: short suite + integration suite both green. The pre-existing `internal/library/TestScanner_Integration` flake is documented (`project_scanner_flake.md`) and not blocking. - -- [ ] **Step 9.2: Lint clean** - -```bash -golangci-lint run ./... -``` - -Expected: no output. - -- [ ] **Step 9.3: Coverage check** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - bash -c 'go test -race -p 1 -coverprofile=/tmp/cov.out \ - ./internal/recommendation/... ./internal/similarity/... ./internal/api/... && \ - go tool cover -func=/tmp/cov.out | tail -1' -``` - -Expected: combined ≥ 80% on the new code per spec §8. - -- [ ] **Step 9.4: Frontend full check** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web -npm run check -npm test -- --run -npm run build -cd .. -``` - -Expected: 0 errors, all tests pass, build succeeds. - -- [ ] **Step 9.5: Manual smoke** - -- Like an artist (or play a few tracks). -- Open `/discover` with the search input empty. -- Verify the "Suggested for you" header + grid renders. -- Verify each card has an attribution line that reads naturally. -- 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. -- Clear the search input → confirm the feed comes back (cached, instant). -- For a fresh user with no likes/plays, the empty-state copy renders. - -- [ ] **Step 9.6: Use `superpowers:finishing-a-development-branch`** - -Verify tests are still green, then run the skill to present finish options (merge / PR / keep / discard). Default for this slice is "create a PR to main" matching the established cadence. - ---- - -## Self-review checklist - -**Spec coverage** — every spec section maps to a task: -- §3 Architecture: Tasks 1 (table), 2 (LB client), 3 (worker), 4 (service), 5 (handler), 6-8 (frontend) -- §4 Schema: Task 1 -- §5 API surface: Task 5 -- §6 UI surfaces: Tasks 7 (DiscoverResultCard), 8 (SuggestionFeed + /discover integration) -- §7 Error handling: distributed across Tasks 3 (worker WARN), 5 (handler 500), 8 (frontend silent-on-failure) -- §8 Testing: every Task includes tests; Task 9 verifies coverage targets -- §9 Decisions ledger: not directly implemented, referenced in commit messages -- §10 Out of scope: explicitly excluded — no album/track suggestions, no realtime invalidation, no cross-user CF, no pagination, no materialization -- §11 Open questions: Task 2 verifies the `Name` field on `SimilarArtist`; cold-start sparseness is documented behavior - -**Placeholder scan:** the per-task detail level drops after Task 5 (frontend tasks become standard SvelteKit page work) — intentional for navigability. No "TBD" or "TODO" remains. - -**Type consistency:** -- Service method: `SuggestArtists` consistent across plan -- Types: `ArtistSuggestion`, `SeedContribution` consistent across Go and TS -- API path: `/api/discover/suggestions` consistent -- Component name: `` consistent -- DB field names: `seed_artist_id`, `candidate_mbid`, `candidate_name`, `score`, `source`, `fetched_at` consistent across migration / queries / tests - -Plan is complete. diff --git a/docs/superpowers/plans/2026-04-30-m6a-home-page.md b/docs/superpowers/plans/2026-04-30-m6a-home-page.md deleted file mode 100644 index 4aedcee1..00000000 --- a/docs/superpowers/plans/2026-04-30-m6a-home-page.md +++ /dev/null @@ -1,3341 +0,0 @@ -# M6a — Home page redesign + library list relocation — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace `/` with a personalized home page composed of independent horizontal-scroll rows (Recently added · Rediscover · Most played · Last played), relocate the library list to `/library/artists` and `/library/albums` as wrapping grids with alphabetical dividers, polish `` to the FabledSword bar, retire `` (and its click-target bug) in favor of a circular ``, and ship play affordances on album/artist cards. - -**Architecture:** No DB migration. Backend adds composite SQL queries for each home section + new alpha-with-cover variants for the library lists. New `internal/recommendation/home.go` runs the section queries in parallel and assembles a single `HomePayload`. New `GET /api/home`, `GET /api/library/albums`, `GET /api/artists/{id}/tracks` endpoints. Frontend introduces four reusable components (``, ``, ``, ``), polishes ``, and ships three new routes. - -**Tech Stack:** Go 1.23 · pgx/v5 + sqlc · Postgres (no migration) · SvelteKit 2 / Svelte 5 (runes) · TanStack Query · Vitest · Lucide icons · FabledSword design tokens. - -**Spec:** [`docs/superpowers/specs/2026-04-30-m6a-home-page-design.md`](../specs/2026-04-30-m6a-home-page-design.md). Read it before starting — every decision is explained there. - -**Memory dependencies:** `project_design_system.md` (FabledSword tokens), `project_subsonic_legacy.md` (`/api/*` is primary), `project_no_github.md` (Forgejo MCP for PR ops), `project_git_workflow.md` (commit on `dev`; PR to `main` separately), `project_ui_quality.md` (this slice raises the bar — no half-built UI). - ---- - -## File map - -### Backend — create - -- `internal/recommendation/home.go` — `HomeData` composite service + `HomePayload` struct -- `internal/recommendation/home_integration_test.go` -- `internal/api/home.go` — `GET /api/home` handler -- `internal/api/home_test.go` -- `internal/api/library_albums.go` — `GET /api/library/albums` handler -- `internal/api/library_albums_test.go` - -### Backend — modify - -- `internal/db/queries/recommendation.sql` — append home-section + rediscover queries -- `internal/db/queries/albums.sql` — append `ListRecentlyAddedAlbumsWithArtist`, `ListAlbumsAlphaWithArtist`, `CountAlbums` -- `internal/db/queries/artists.sql` — append `ListArtistsAlphaWithCovers` -- `internal/db/queries/tracks.sql` — append `ListArtistTracksForUser` -- `internal/db/dbq/*` — regenerated by `sqlc generate` -- `internal/api/types.go` — extend `ArtistRef` + `AlbumRef` -- `internal/api/convert.go` — extend `artistRefFrom` + `albumRefFrom` to populate new fields -- `internal/api/library.go` — replace `ListArtistsAlpha` call with `ListArtistsAlphaWithCovers`; add `handleGetArtistTracks` -- `internal/api/library_test.go` — extend with artist-tracks tests -- `internal/api/api.go` — register `/api/home`, `/api/library/albums`, `/api/artists/{id}/tracks` routes - -### Frontend — create - -- `web/src/lib/api/home.ts` — `listHome()` + `createHomeQuery()` -- `web/src/lib/api/home.test.ts` -- `web/src/lib/api/albums.ts` — `listAlbumsAlpha()` + `createAlbumsAlphaInfiniteQuery()` + `listAlbumTracks()` if extracted later -- `web/src/lib/api/albums.test.ts` -- `web/src/lib/components/HorizontalScrollRow.svelte` -- `web/src/lib/components/HorizontalScrollRow.test.ts` -- `web/src/lib/components/ArtistCard.svelte` -- `web/src/lib/components/ArtistCard.test.ts` -- `web/src/lib/components/CompactTrackCard.svelte` -- `web/src/lib/components/CompactTrackCard.test.ts` -- `web/src/lib/components/AlphabeticalGrid.svelte` -- `web/src/lib/components/AlphabeticalGrid.test.ts` -- `web/src/routes/library/artists/+page.svelte` -- `web/src/routes/library/albums/+page.svelte` - -### Frontend — modify - -- `web/src/lib/api/types.ts` — extend `ArtistRef` + `AlbumRef`, add `HomePayload` -- `web/src/lib/api/queries.ts` — add `qk.home()`, `qk.albumsAlpha()`, `qk.artistTracks()` -- `web/src/lib/api/artists.ts` — create or extend with `listArtistTracks()` -- `web/src/lib/components/AlbumCard.svelte` — FabledSword polish + play-button overlay + `sort_title` exposure -- `web/src/lib/components/AlbumCard.test.ts` — extend with play-overlay test -- `web/src/lib/components/Shell.svelte` — nav order updated -- `web/src/lib/components/Shell.test.ts` — assert new nav items -- `web/src/routes/+page.svelte` — full rewrite as home page -- `web/src/routes/artists.test.ts` — adjust expectations now that `/` is the home - -### Frontend — delete - -- `web/src/lib/components/ArtistRow.svelte` (retired with the click-target bug) -- `web/src/lib/components/ArtistRow.test.ts` - ---- - -## Task list - -### Task 1 — SQL: Recently added + Most played + Last played queries - -**Files:** -- Modify: `internal/db/queries/albums.sql` — append `ListRecentlyAddedAlbumsWithArtist` -- Modify: `internal/db/queries/recommendation.sql` — append `ListMostPlayedTracksForUser`, `ListLastPlayedArtistsForUser` -- Regenerate: `internal/db/dbq/*` - -- [ ] **Step 1.1: Append `ListRecentlyAddedAlbumsWithArtist` to `albums.sql`** - -Append at the bottom of `internal/db/queries/albums.sql`: - -```sql --- name: ListRecentlyAddedAlbumsWithArtist :many --- M6a: recently-added albums joined with artist_name + artist_id for the --- home-page section. created_at is the row-insert timestamp from the --- scanner — the closest proxy to "added to library". Stable secondary --- ordering by id keeps pagination tie-break deterministic. -SELECT sqlc.embed(albums), artists.name AS artist_name -FROM albums -JOIN artists ON artists.id = albums.artist_id -ORDER BY albums.created_at DESC, albums.id -LIMIT $1; -``` - -- [ ] **Step 1.2: Append `ListMostPlayedTracksForUser` to `recommendation.sql`** - -Append at the bottom of `internal/db/queries/recommendation.sql`: - -```sql --- name: ListMostPlayedTracksForUser :many --- M6a: top-N tracks by completed-play count for the user. was_skipped --- excludes skips so a user spamming next doesn't fabricate a top track. --- Quarantined tracks (per-user soft-hide from M5b) are filtered out. -SELECT sqlc.embed(t), - albums.title AS album_title, - artists.name AS artist_name -FROM tracks t -JOIN albums ON albums.id = t.album_id -JOIN artists ON artists.id = t.artist_id -JOIN play_events pe ON pe.track_id = t.id -WHERE pe.user_id = $1 - AND pe.was_skipped = false - AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q - WHERE q.user_id = $1 AND q.track_id = t.id - ) -GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path, - t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number, - t.mbid, t.genre, t.added_at, t.updated_at, - albums.title, artists.name -ORDER BY count(*) DESC, t.id -LIMIT $2; -``` - -- [ ] **Step 1.3: Append `ListLastPlayedArtistsForUser` to `recommendation.sql`** - -Append at the bottom of `internal/db/queries/recommendation.sql`: - -```sql --- name: ListLastPlayedArtistsForUser :many --- M6a: artists ranked by max(play_events.started_at) for the user, with --- a derived cover_album_id via a representative-album lateral join (most --- recent album that has cover_art_path set). album_count joined for the --- ArtistRef wire shape. -SELECT sqlc.embed(a), - cov.id AS cover_album_id, - cnt.album_count::bigint AS album_count, - max_started.started_at::timestamptz AS last_played_at -FROM artists a -JOIN LATERAL ( - SELECT max(pe.started_at) AS started_at - FROM play_events pe - JOIN tracks t ON t.id = pe.track_id - WHERE pe.user_id = $1 AND t.artist_id = a.id -) max_started ON max_started.started_at IS NOT NULL -LEFT JOIN LATERAL ( - SELECT id FROM albums - WHERE artist_id = a.id AND cover_art_path IS NOT NULL - ORDER BY created_at DESC LIMIT 1 -) cov ON true -LEFT JOIN LATERAL ( - SELECT count(*) AS album_count - FROM albums WHERE artist_id = a.id -) cnt ON true -ORDER BY max_started.started_at DESC, a.id -LIMIT $2; -``` - -- [ ] **Step 1.4: Regenerate sqlc** - -Run: - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && sqlc generate -go build ./... -go vet ./... -``` - -Expected: clean build. New methods `ListRecentlyAddedAlbumsWithArtist`, `ListMostPlayedTracksForUser`, `ListLastPlayedArtistsForUser` appear in `internal/db/dbq/`. - -- [ ] **Step 1.5: Commit** - -```bash -git add internal/db/queries/albums.sql \ - internal/db/queries/recommendation.sql \ - internal/db/dbq/ -git commit -m "feat(db): add M6a home-section queries (recently added, most played, last played)" -``` - ---- - -### Task 2 — SQL: Rediscover queries (albums + artists, with fallbacks) - -**Files:** -- Modify: `internal/db/queries/recommendation.sql` -- Regenerate: `internal/db/dbq/*` - -- [ ] **Step 2.1: Append rediscover-album queries** - -Append at the bottom of `internal/db/queries/recommendation.sql`: - -```sql --- name: ListRediscoverAlbumsForUser :many --- M6a: albums liked >30 days ago AND not played in the last 14 days, --- ordered by longest-since-last-play first. The HAVING clause uses an --- epoch sentinel so albums with NO plays count as eligible (their --- "last play" is treated as 1970, which is always >14 days ago). -WITH eligible AS ( - SELECT al.id AS album_id, - gla.liked_at, - max(pe.started_at) AS last_played - FROM general_likes_albums gla - JOIN albums al ON al.id = gla.album_id - LEFT JOIN tracks t ON t.album_id = al.id - LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 - WHERE gla.user_id = $1 - AND gla.liked_at < now() - interval '30 days' - GROUP BY al.id, gla.liked_at - HAVING COALESCE(max(pe.started_at), '1970-01-01'::timestamptz) - < now() - interval '14 days' -) -SELECT sqlc.embed(albums), artists.name AS artist_name -FROM eligible e -JOIN albums ON albums.id = e.album_id -JOIN artists ON artists.id = albums.artist_id -ORDER BY (now() - COALESCE(e.last_played, e.liked_at)) DESC, albums.id -LIMIT $2; - --- name: ListRediscoverAlbumsFallbackForUser :many --- M6a: random sample of liked albums for the user. The Go service uses --- this to top up the rediscover row when ListRediscoverAlbumsForUser --- returns fewer than `limit` rows. -SELECT sqlc.embed(albums), artists.name AS artist_name -FROM general_likes_albums gla -JOIN albums ON albums.id = gla.album_id -JOIN artists ON artists.id = albums.artist_id -WHERE gla.user_id = $1 -ORDER BY random() -LIMIT $2; -``` - -- [ ] **Step 2.2: Append rediscover-artist queries** - -Append at the bottom of `internal/db/queries/recommendation.sql`: - -```sql --- name: ListRediscoverArtistsForUser :many --- M6a: artists liked >30 days ago AND none of their tracks played in the --- last 14 days, ordered by longest-since-last-play. cover_album_id is --- derived from a representative album (LEFT JOIN LATERAL). -WITH eligible AS ( - SELECT a.id AS artist_id, - gla.liked_at, - max(pe.started_at) AS last_played - FROM general_likes_artists gla - JOIN artists a ON a.id = gla.artist_id - LEFT JOIN tracks t ON t.artist_id = a.id - LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 - WHERE gla.user_id = $1 - AND gla.liked_at < now() - interval '30 days' - GROUP BY a.id, gla.liked_at - HAVING COALESCE(max(pe.started_at), '1970-01-01'::timestamptz) - < now() - interval '14 days' -) -SELECT sqlc.embed(artists), - cov.id AS cover_album_id, - cnt.album_count::bigint AS album_count -FROM eligible e -JOIN artists ON artists.id = e.artist_id -LEFT JOIN LATERAL ( - SELECT id FROM albums - WHERE artist_id = artists.id AND cover_art_path IS NOT NULL - ORDER BY created_at DESC LIMIT 1 -) cov ON true -LEFT JOIN LATERAL ( - SELECT count(*) AS album_count - FROM albums WHERE artist_id = artists.id -) cnt ON true -ORDER BY (now() - COALESCE(e.last_played, e.liked_at)) DESC, artists.id -LIMIT $2; - --- name: ListRediscoverArtistsFallbackForUser :many -SELECT sqlc.embed(artists), - cov.id AS cover_album_id, - cnt.album_count::bigint AS album_count -FROM general_likes_artists gla -JOIN artists ON artists.id = gla.artist_id -LEFT JOIN LATERAL ( - SELECT id FROM albums - WHERE artist_id = artists.id AND cover_art_path IS NOT NULL - ORDER BY created_at DESC LIMIT 1 -) cov ON true -LEFT JOIN LATERAL ( - SELECT count(*) AS album_count - FROM albums WHERE artist_id = artists.id -) cnt ON true -WHERE gla.user_id = $1 -ORDER BY random() -LIMIT $2; -``` - -- [ ] **Step 2.3: Regenerate sqlc** - -Run: - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && sqlc generate -go build ./... -go vet ./... -``` - -Expected: clean build. Four new methods (`ListRediscoverAlbumsForUser`, `ListRediscoverAlbumsFallbackForUser`, `ListRediscoverArtistsForUser`, `ListRediscoverArtistsFallbackForUser`) appear in `internal/db/dbq/recommendation.sql.go`. - -- [ ] **Step 2.4: Commit** - -```bash -git add internal/db/queries/recommendation.sql internal/db/dbq/ -git commit -m "feat(db): add M6a rediscover queries (albums + artists, with fallbacks)" -``` - ---- - -### Task 3 — SQL: Library list + artist tracks queries - -**Files:** -- Modify: `internal/db/queries/artists.sql` -- Modify: `internal/db/queries/albums.sql` -- Modify: `internal/db/queries/tracks.sql` -- Regenerate: `internal/db/dbq/*` - -- [ ] **Step 3.1: Append `ListArtistsAlphaWithCovers` to `artists.sql`** - -Append at the bottom of `internal/db/queries/artists.sql`: - -```sql --- name: ListArtistsAlphaWithCovers :many --- M6a: alpha-sorted artist list with derived cover_album_id (most-recent --- album whose cover_art_path is set). Replaces ListArtistsAlpha for the --- /api/artists?sort=alpha branch — the new column is appended, so the --- alpha branch is purely additive on the wire. album_count is computed --- in the same query to drop the old N+1 in handleListArtists. -SELECT sqlc.embed(artists), - cov.id AS cover_album_id, - cnt.album_count::bigint AS album_count -FROM artists -LEFT JOIN LATERAL ( - SELECT id FROM albums - WHERE artist_id = artists.id AND cover_art_path IS NOT NULL - ORDER BY created_at DESC LIMIT 1 -) cov ON true -LEFT JOIN LATERAL ( - SELECT count(*) AS album_count - FROM albums WHERE artist_id = artists.id -) cnt ON true -ORDER BY artists.sort_name, artists.name, artists.id -LIMIT $1 OFFSET $2; -``` - -- [ ] **Step 3.2: Append `ListAlbumsAlphaWithArtist` + `CountAlbums` to `albums.sql`** - -Append at the bottom of `internal/db/queries/albums.sql`: - -```sql --- name: ListAlbumsAlphaWithArtist :many --- M6a: alpha-sorted album list joined with artist_name. Used by --- /api/library/albums for the wrapping-grid page. Stable id-tiebreak. -SELECT sqlc.embed(albums), artists.name AS artist_name -FROM albums -JOIN artists ON artists.id = albums.artist_id -ORDER BY albums.sort_title, albums.id -LIMIT $1 OFFSET $2; - --- name: CountAlbums :one --- M6a: total album count for the /api/library/albums envelope. -SELECT COUNT(*) FROM albums; -``` - -- [ ] **Step 3.3: Append `ListArtistTracksForUser` to `tracks.sql`** - -Append at the bottom of `internal/db/queries/tracks.sql`: - -```sql --- name: ListArtistTracksForUser :many --- M6a: every track for the artist across their albums, with album_title --- and artist_name joined. Honors per-user lidarr_quarantine. Used by --- /api/artists/{id}/tracks for the artist-card play affordance, which --- shuffles client-side. Ordering matches album/track natural order so --- the shuffle has a deterministic input. -SELECT sqlc.embed(t), - albums.title AS album_title, - artists.name AS artist_name -FROM tracks t -JOIN albums ON albums.id = t.album_id -JOIN artists ON artists.id = t.artist_id -WHERE t.artist_id = $1 - AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q - WHERE q.user_id = $2 AND q.track_id = t.id - ) -ORDER BY albums.release_date NULLS LAST, albums.sort_title, - t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.id; -``` - -- [ ] **Step 3.4: Regenerate sqlc + build** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && sqlc generate -go build ./... -go vet ./... -``` - -Expected: clean build. New methods appear in `internal/db/dbq/`. - -- [ ] **Step 3.5: Commit** - -```bash -git add internal/db/queries/artists.sql \ - internal/db/queries/albums.sql \ - internal/db/queries/tracks.sql \ - internal/db/dbq/ -git commit -m "feat(db): add M6a library-list + artist-tracks queries" -``` - ---- - -### Task 4 — Recommendation service: `HomeData` - -**Files:** -- Create: `internal/recommendation/home.go` -- Create: `internal/recommendation/home_integration_test.go` - -- [ ] **Step 4.1: Write failing integration test (new-user empty payload)** - -Create `internal/recommendation/home_integration_test.go`: - -```go -package recommendation - -import ( - "context" - "testing" - - "github.com/jackc/pgx/v5/pgtype" -) - -func TestHomeData_NewUser_AllSectionsEmpty(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "home-newuser") - - got, err := HomeData(context.Background(), pool, user.ID) - if err != nil { - t.Fatalf("HomeData: %v", err) - } - if got == nil { - t.Fatal("HomeData returned nil payload") - } - if len(got.RecentlyAddedAlbums) != 0 { - t.Errorf("RecentlyAddedAlbums len = %d, want 0", len(got.RecentlyAddedAlbums)) - } - if len(got.RediscoverAlbums) != 0 { - t.Errorf("RediscoverAlbums len = %d, want 0", len(got.RediscoverAlbums)) - } - if len(got.RediscoverArtists) != 0 { - t.Errorf("RediscoverArtists len = %d, want 0", len(got.RediscoverArtists)) - } - if len(got.MostPlayedTracks) != 0 { - t.Errorf("MostPlayedTracks len = %d, want 0", len(got.MostPlayedTracks)) - } - if len(got.LastPlayedArtists) != 0 { - t.Errorf("LastPlayedArtists len = %d, want 0", len(got.LastPlayedArtists)) - } -} - -// Compile-time assert that pgtype.UUID is a valid map key — used by the -// service when de-duping rediscover fallback rows. If this changes, the -// service needs to switch to keying by string. -var _ = func() pgtype.UUID { return pgtype.UUID{} } -``` - -- [ ] **Step 4.2: Run test to verify it fails** - -Run: - -```bash -go test ./internal/recommendation/ -run TestHomeData_NewUser_AllSectionsEmpty -count=1 -``` - -Expected: FAIL with "undefined: HomeData" or similar. - -- [ ] **Step 4.3: Write the service** - -Create `internal/recommendation/home.go`: - -```go -// home.go is the M6a per-user composite home-page service. Reads five -// sections (recently added, rediscover albums, rediscover artists, most -// played tracks, last played artists) in parallel via goroutines and -// assembles them into a single payload for /api/home. -package recommendation - -import ( - "context" - "fmt" - "sync" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// Section size caps. SPA splits these into rows on the page. -const ( - HomeRecentlyAddedLimit = 50 - HomeRediscoverLimit = 25 - HomeMostPlayedLimit = 75 - HomeLastPlayedLimit = 25 -) - -// HomePayload is the composite returned by HomeData. All slices are -// non-nil at return time so the API handler can encode `[]` rather than -// `null` for empty sections. -type HomePayload struct { - RecentlyAddedAlbums []dbq.ListRecentlyAddedAlbumsWithArtistRow - RediscoverAlbums []dbq.ListRediscoverAlbumsForUserRow - RediscoverArtists []dbq.ListRediscoverArtistsForUserRow - MostPlayedTracks []dbq.ListMostPlayedTracksForUserRow - LastPlayedArtists []dbq.ListLastPlayedArtistsForUserRow -} - -// HomeData runs five queries in parallel and assembles the payload. -// Rediscover sections fall back to a random sample of liked entities -// when the eligibility query returns fewer than HomeRediscoverLimit rows. -// Any single query error fails the whole call (predictable empty-or-full -// UX for the SPA — partial payloads are not a feature). -func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*HomePayload, error) { - q := dbq.New(pool) - - var ( - wg sync.WaitGroup - mu sync.Mutex - firstErr error - ) - fail := func(stage string, err error) { - mu.Lock() - defer mu.Unlock() - if firstErr == nil { - firstErr = fmt.Errorf("home: %s: %w", stage, err) - } - } - - out := &HomePayload{ - RecentlyAddedAlbums: []dbq.ListRecentlyAddedAlbumsWithArtistRow{}, - RediscoverAlbums: []dbq.ListRediscoverAlbumsForUserRow{}, - RediscoverArtists: []dbq.ListRediscoverArtistsForUserRow{}, - MostPlayedTracks: []dbq.ListMostPlayedTracksForUserRow{}, - LastPlayedArtists: []dbq.ListLastPlayedArtistsForUserRow{}, - } - - wg.Add(5) - go func() { - defer wg.Done() - rows, err := q.ListRecentlyAddedAlbumsWithArtist(ctx, HomeRecentlyAddedLimit) - if err != nil { - fail("recently_added", err) - return - } - out.RecentlyAddedAlbums = rows - }() - go func() { - defer wg.Done() - rows, err := q.ListMostPlayedTracksForUser(ctx, dbq.ListMostPlayedTracksForUserParams{ - UserID: userID, Limit: HomeMostPlayedLimit, - }) - if err != nil { - fail("most_played", err) - return - } - out.MostPlayedTracks = rows - }() - go func() { - defer wg.Done() - rows, err := q.ListLastPlayedArtistsForUser(ctx, dbq.ListLastPlayedArtistsForUserParams{ - UserID: userID, Limit: HomeLastPlayedLimit, - }) - if err != nil { - fail("last_played", err) - return - } - out.LastPlayedArtists = rows - }() - go func() { - defer wg.Done() - rows, err := loadRediscoverAlbums(ctx, q, userID) - if err != nil { - fail("rediscover_albums", err) - return - } - out.RediscoverAlbums = rows - }() - go func() { - defer wg.Done() - rows, err := loadRediscoverArtists(ctx, q, userID) - if err != nil { - fail("rediscover_artists", err) - return - } - out.RediscoverArtists = rows - }() - wg.Wait() - - if firstErr != nil { - return nil, firstErr - } - return out, nil -} - -func loadRediscoverAlbums(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]dbq.ListRediscoverAlbumsForUserRow, error) { - primary, err := q.ListRediscoverAlbumsForUser(ctx, dbq.ListRediscoverAlbumsForUserParams{ - UserID: userID, Limit: HomeRediscoverLimit, - }) - if err != nil { - return nil, err - } - if len(primary) >= HomeRediscoverLimit { - return primary, nil - } - fallback, err := q.ListRediscoverAlbumsFallbackForUser(ctx, dbq.ListRediscoverAlbumsFallbackForUserParams{ - UserID: userID, Limit: HomeRediscoverLimit, - }) - if err != nil { - return nil, err - } - seen := make(map[pgtype.UUID]struct{}, len(primary)) - for _, r := range primary { - seen[r.Album.ID] = struct{}{} - } - for _, r := range fallback { - if _, dup := seen[r.Album.ID]; dup { - continue - } - // Fallback rows have the same shape after a manual projection - // (ListRediscoverAlbumsFallbackForUserRow → ListRediscoverAlbumsForUserRow). - primary = append(primary, dbq.ListRediscoverAlbumsForUserRow{ - Album: r.Album, - ArtistName: r.ArtistName, - }) - if len(primary) >= HomeRediscoverLimit { - break - } - seen[r.Album.ID] = struct{}{} - } - return primary, nil -} - -func loadRediscoverArtists(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]dbq.ListRediscoverArtistsForUserRow, error) { - primary, err := q.ListRediscoverArtistsForUser(ctx, dbq.ListRediscoverArtistsForUserParams{ - UserID: userID, Limit: HomeRediscoverLimit, - }) - if err != nil { - return nil, err - } - if len(primary) >= HomeRediscoverLimit { - return primary, nil - } - fallback, err := q.ListRediscoverArtistsFallbackForUser(ctx, dbq.ListRediscoverArtistsFallbackForUserParams{ - UserID: userID, Limit: HomeRediscoverLimit, - }) - if err != nil { - return nil, err - } - seen := make(map[pgtype.UUID]struct{}, len(primary)) - for _, r := range primary { - seen[r.Artist.ID] = struct{}{} - } - for _, r := range fallback { - if _, dup := seen[r.Artist.ID]; dup { - continue - } - primary = append(primary, dbq.ListRediscoverArtistsForUserRow{ - Artist: r.Artist, - CoverAlbumID: r.CoverAlbumID, - AlbumCount: r.AlbumCount, - }) - if len(primary) >= HomeRediscoverLimit { - break - } - seen[r.Artist.ID] = struct{}{} - } - return primary, nil -} -``` - -- [ ] **Step 4.4: Run test to verify it passes** - -Run: - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test ./internal/recommendation/ -run TestHomeData_NewUser_AllSectionsEmpty -count=1 -``` - -Expected: PASS. - -- [ ] **Step 4.5: Add deeper integration tests** - -Append to `internal/recommendation/home_integration_test.go`: - -```go -import ( - "time" -) - -func TestHomeData_RecentlyAdded_OrderedByCreatedAt(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "home-recents") - a1 := seedArtist(t, pool, "ArtistA", "") - // Three albums, oldest first — created_at increases naturally with each insert. - al1 := seedAlbumForArtist(t, pool, a1.ID, "Older") - al2 := seedAlbumForArtist(t, pool, a1.ID, "Middle") - al3 := seedAlbumForArtist(t, pool, a1.ID, "Newest") - - got, err := HomeData(context.Background(), pool, user.ID) - if err != nil { - t.Fatalf("HomeData: %v", err) - } - if len(got.RecentlyAddedAlbums) != 3 { - t.Fatalf("len = %d, want 3", len(got.RecentlyAddedAlbums)) - } - if got.RecentlyAddedAlbums[0].Album.ID != al3.ID || - got.RecentlyAddedAlbums[1].Album.ID != al2.ID || - got.RecentlyAddedAlbums[2].Album.ID != al1.ID { - t.Errorf("order = [%s, %s, %s], want newest→oldest", - got.RecentlyAddedAlbums[0].Album.Title, - got.RecentlyAddedAlbums[1].Album.Title, - got.RecentlyAddedAlbums[2].Album.Title) - } -} - -func TestHomeData_MostPlayed_RanksByCount_HonorsQuarantine(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "home-mostplayed") - artist := seedArtist(t, pool, "ArtistB", "") - album := seedAlbumForArtist(t, pool, artist.ID, "AlbumB") - tHigh := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "High") - tLow := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "Low") - tQuar := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "Quarantined") - now := time.Now() - for i := 0; i < 3; i++ { - insertPlayEvent(t, pool, user.ID, tHigh.ID, now.Add(-time.Duration(i)*time.Minute)) - } - insertPlayEvent(t, pool, user.ID, tLow.ID, now) - for i := 0; i < 5; i++ { - insertPlayEvent(t, pool, user.ID, tQuar.ID, now) - } - if _, err := pool.Exec(context.Background(), - `INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`, - user.ID, tQuar.ID, - ); err != nil { - t.Fatalf("insert quarantine: %v", err) - } - - got, err := HomeData(context.Background(), pool, user.ID) - if err != nil { - t.Fatalf("HomeData: %v", err) - } - if len(got.MostPlayedTracks) != 2 { - t.Fatalf("len = %d, want 2 (quarantined excluded)", len(got.MostPlayedTracks)) - } - if got.MostPlayedTracks[0].Track.ID != tHigh.ID || got.MostPlayedTracks[1].Track.ID != tLow.ID { - t.Errorf("ranking wrong: got [%s, %s], want [High, Low]", - got.MostPlayedTracks[0].Track.Title, - got.MostPlayedTracks[1].Track.Title) - } -} - -func TestHomeData_RediscoverAlbums_FallbackWhenSparse(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "home-rediscover") - artist := seedArtist(t, pool, "ArtistC", "") - // Like one album NOW (won't satisfy >30d eligibility) — should still - // surface via fallback because primary returns 0 rows. - al := seedAlbumForArtist(t, pool, artist.ID, "RecentLike") - if _, err := pool.Exec(context.Background(), - `INSERT INTO general_likes_albums (user_id, album_id, liked_at) VALUES ($1, $2, now())`, - user.ID, al.ID, - ); err != nil { - t.Fatalf("insert like: %v", err) - } - got, err := HomeData(context.Background(), pool, user.ID) - if err != nil { - t.Fatalf("HomeData: %v", err) - } - if len(got.RediscoverAlbums) != 1 { - t.Fatalf("len = %d, want 1 (from fallback)", len(got.RediscoverAlbums)) - } - if got.RediscoverAlbums[0].Album.ID != al.ID { - t.Errorf("got %s, want %s", got.RediscoverAlbums[0].Album.Title, al.Title) - } -} -``` - -- [ ] **Step 4.6: Run all home tests** - -Run: - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test ./internal/recommendation/ -run TestHomeData -count=1 -v -``` - -Expected: 3/3 PASS. - -- [ ] **Step 4.7: Commit** - -```bash -git add internal/recommendation/home.go \ - internal/recommendation/home_integration_test.go -git commit -m "feat(recommendation): add HomeData composite service for /api/home" -``` - ---- - -### Task 5 — API types + convert helpers - -**Files:** -- Modify: `internal/api/types.go` -- Modify: `internal/api/convert.go` -- Modify: `internal/api/library.go` (handler signature follow-up — covered separately in Task 9) - -- [ ] **Step 5.1: Extend `ArtistRef` and `AlbumRef` in `types.go`** - -Modify `internal/api/types.go` — replace the `ArtistRef` and `AlbumRef` blocks: - -```go -// ArtistRef is the lightweight artist shape used in lists and search results. -// SortName is exposed so the SPA's alphabetical-grid divider can group rows -// by the catalogue sort order ("The Beatles" → "B"). CoverURL is derived from -// a representative album (most-recent with cover_art_path); empty when the -// artist's discography has no cover art. -type ArtistRef struct { - ID string `json:"id"` - Name string `json:"name"` - SortName string `json:"sort_name"` - AlbumCount int `json:"album_count"` - CoverURL string `json:"cover_url"` -} - -// AlbumRef is the lightweight album shape used in lists, artist details, -// and search results. SortTitle is exposed so the SPA's alphabetical-grid -// divider can group rows by sort order ("The Wall" → "W"). CoverURL points -// to /api/albums/{id}/cover. -type AlbumRef struct { - ID string `json:"id"` - Title string `json:"title"` - SortTitle string `json:"sort_title"` - ArtistID string `json:"artist_id"` - ArtistName string `json:"artist_name"` - Year int `json:"year,omitempty"` - TrackCount int `json:"track_count"` - DurationSec int `json:"duration_sec"` - CoverURL string `json:"cover_url"` -} -``` - -- [ ] **Step 5.2: Add `HomePayload` view type to `types.go`** - -Append at the bottom of `internal/api/types.go`: - -```go -// HomePayload is the response body of GET /api/home. Field counts: -// 50 (recently_added) / 25 (rediscover_albums) / 25 (rediscover_artists) -// / 75 (most_played) / 25 (last_played). All slices are non-nil at JSON -// encode time so empty sections render as [] rather than null. -type HomePayload struct { - RecentlyAddedAlbums []AlbumRef `json:"recently_added_albums"` - RediscoverAlbums []AlbumRef `json:"rediscover_albums"` - RediscoverArtists []ArtistRef `json:"rediscover_artists"` - MostPlayedTracks []TrackRef `json:"most_played_tracks"` - LastPlayedArtists []ArtistRef `json:"last_played_artists"` -} -``` - -- [ ] **Step 5.3: Update `albumRefFrom` to populate `SortTitle`** - -Modify `internal/api/convert.go` — replace the `albumRefFrom` function: - -```go -// albumRefFrom projects a dbq.Album into an AlbumRef. artistName must be -// pre-resolved because Album only carries artist_id. trackCount and -// durationSec may be 0 when the caller does not compute them. -func albumRefFrom(a dbq.Album, artistName string, trackCount, durationSec int) AlbumRef { - return AlbumRef{ - ID: uuidToString(a.ID), - Title: a.Title, - SortTitle: a.SortTitle, - ArtistID: uuidToString(a.ArtistID), - ArtistName: artistName, - Year: yearFromDate(a.ReleaseDate), - TrackCount: trackCount, - DurationSec: durationSec, - CoverURL: coverURL(a.ID), - } -} -``` - -- [ ] **Step 5.4: Update `artistRefFrom` to populate `SortName` (existing call sites use empty cover)** - -Modify `internal/api/convert.go` — replace the `artistRefFrom` function and add a covered variant: - -```go -// artistRefFrom projects a dbq.Artist into an ArtistRef without cover. -// albumCount must be pre-computed by the caller. Used by code paths that -// don't have a representative-album lookup at hand (artist detail, search, -// liked-artists list). -func artistRefFrom(a dbq.Artist, albumCount int) ArtistRef { - return ArtistRef{ - ID: uuidToString(a.ID), - Name: a.Name, - SortName: a.SortName, - AlbumCount: albumCount, - } -} - -// artistRefFromCovered projects a dbq.Artist into an ArtistRef with the -// derived CoverURL populated from a representative album id (nullable). -// Used by /api/home and /api/artists?sort=alpha which carry the lookup -// in the same query. -func artistRefFromCovered(a dbq.Artist, albumCount int, coverAlbumID pgtype.UUID) ArtistRef { - ref := artistRefFrom(a, albumCount) - if coverAlbumID.Valid { - ref.CoverURL = coverURL(coverAlbumID) - } - return ref -} -``` - -- [ ] **Step 5.5: Run vet + build** - -```bash -go vet ./internal/api/... -go build ./... -``` - -Expected: clean build. Existing call sites continue to compile because the appended fields default to zero values — JSON marshal still includes them as `"sort_name": ""`, which is harmless. - -- [ ] **Step 5.6: Commit** - -```bash -git add internal/api/types.go internal/api/convert.go -git commit -m "feat(api): extend ArtistRef/AlbumRef + add HomePayload for M6a" -``` - ---- - -### Task 6 — `GET /api/home` handler - -**Files:** -- Create: `internal/api/home.go` -- Create: `internal/api/home_test.go` - -- [ ] **Step 6.1: Write failing test for the happy-path empty payload** - -Create `internal/api/home_test.go`: - -```go -package api - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/go-chi/chi/v5" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -func newHomeRouter(h *handlers) chi.Router { - r := chi.NewRouter() - r.Get("/api/home", h.handleGetHome) - return r -} - -func doGetHome(h *handlers, user dbq.User) *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodGet, "/api/home", nil) - req = withUser(req, user) - w := httptest.NewRecorder() - newHomeRouter(h).ServeHTTP(w, req) - return w -} - -func TestHome_EmptyForNewUser(t *testing.T) { - h, pool := testHandlers(t) - user := seedUser(t, pool, "alice", "pw", false) - - w := doGetHome(h, user) - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) - } - var got HomePayload - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - // All slices must be non-nil so the SPA branches consistently on - // length rather than dealing with null. - if got.RecentlyAddedAlbums == nil || got.RediscoverAlbums == nil || - got.RediscoverArtists == nil || got.MostPlayedTracks == nil || - got.LastPlayedArtists == nil { - t.Errorf("payload has nil slice: %+v", got) - } - if len(got.RecentlyAddedAlbums) != 0 { - t.Errorf("RecentlyAddedAlbums len = %d, want 0", len(got.RecentlyAddedAlbums)) - } -} - -func TestHome_RecentlyAddedSurfacesAlbums(t *testing.T) { - h, pool := testHandlers(t) - user := seedUser(t, pool, "alice", "pw", false) - q := dbq.New(pool) - artist, _ := q.UpsertArtist(t.Context(), dbq.UpsertArtistParams{ - Name: "ArtistOne", SortName: "ArtistOne", - }) - _, _ = q.UpsertAlbum(t.Context(), dbq.UpsertAlbumParams{ - Title: "Newest", SortTitle: "Newest", ArtistID: artist.ID, - }) - - w := doGetHome(h, user) - if w.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) - } - var got HomePayload - _ = json.Unmarshal(w.Body.Bytes(), &got) - if len(got.RecentlyAddedAlbums) != 1 { - t.Fatalf("len = %d, want 1", len(got.RecentlyAddedAlbums)) - } - if got.RecentlyAddedAlbums[0].Title != "Newest" { - t.Errorf("title = %q, want Newest", got.RecentlyAddedAlbums[0].Title) - } - if got.RecentlyAddedAlbums[0].ArtistName != "ArtistOne" { - t.Errorf("artist_name = %q, want ArtistOne", got.RecentlyAddedAlbums[0].ArtistName) - } -} -``` - -NOTE: this test uses `t.Context()`. The repo standard is `context.Background()` because Go 1.23 doesn't have `t.Context()` — replace before saving: - -```go -import "context" -// then: context.Background() everywhere instead of t.Context() -``` - -- [ ] **Step 6.2: Run test to verify it fails** - -```bash -go test ./internal/api/ -run TestHome_ -count=1 -``` - -Expected: FAIL with "undefined: handleGetHome" or similar. - -- [ ] **Step 6.3: Write the handler** - -Create `internal/api/home.go`: - -```go -package api - -import ( - "net/http" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" -) - -// handleGetHome implements GET /api/home. Returns five sections in one -// payload; sections that have no rows render as []. 500 on any underlying -// DB failure (no partial payload — the SPA should render either empty -// states or full content, not a half-broken page). -func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - data, err := recommendation.HomeData(r.Context(), h.pool, user.ID) - if err != nil { - h.logger.Error("api: home data", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "failed to load home") - return - } - - out := HomePayload{ - RecentlyAddedAlbums: make([]AlbumRef, 0, len(data.RecentlyAddedAlbums)), - RediscoverAlbums: make([]AlbumRef, 0, len(data.RediscoverAlbums)), - RediscoverArtists: make([]ArtistRef, 0, len(data.RediscoverArtists)), - MostPlayedTracks: make([]TrackRef, 0, len(data.MostPlayedTracks)), - LastPlayedArtists: make([]ArtistRef, 0, len(data.LastPlayedArtists)), - } - for _, row := range data.RecentlyAddedAlbums { - out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0)) - } - for _, row := range data.RediscoverAlbums { - out.RediscoverAlbums = append(out.RediscoverAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0)) - } - for _, row := range data.RediscoverArtists { - out.RediscoverArtists = append(out.RediscoverArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID)) - } - for _, row := range data.MostPlayedTracks { - out.MostPlayedTracks = append(out.MostPlayedTracks, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName)) - } - for _, row := range data.LastPlayedArtists { - out.LastPlayedArtists = append(out.LastPlayedArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID)) - } - writeJSON(w, http.StatusOK, out) -} - -// Compile-time guard that dbq has the row types we need; if the SQL is -// regenerated with a different name, this fails fast at build time. -var _ = dbq.ListMostPlayedTracksForUserRow{}.Track -``` - -- [ ] **Step 6.4: Run tests to verify they pass** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test ./internal/api/ -run TestHome_ -count=1 -v -``` - -Expected: 2/2 PASS. - -- [ ] **Step 6.5: Commit** - -```bash -git add internal/api/home.go internal/api/home_test.go -git commit -m "feat(api): add GET /api/home composite handler" -``` - ---- - -### Task 7 — `GET /api/library/albums` handler - -**Files:** -- Create: `internal/api/library_albums.go` -- Create: `internal/api/library_albums_test.go` - -- [ ] **Step 7.1: Write failing test** - -Create `internal/api/library_albums_test.go`: - -```go -package api - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/go-chi/chi/v5" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -func newLibraryAlbumsRouter(h *handlers) chi.Router { - r := chi.NewRouter() - r.Get("/api/library/albums", h.handleListLibraryAlbums) - return r -} - -func doListLibraryAlbums(h *handlers, user dbq.User, query string) *httptest.ResponseRecorder { - url := "/api/library/albums" - if query != "" { - url += "?" + query - } - req := httptest.NewRequest(http.MethodGet, url, nil) - req = withUser(req, user) - w := httptest.NewRecorder() - newLibraryAlbumsRouter(h).ServeHTTP(w, req) - return w -} - -func TestLibraryAlbums_AlphaSorted(t *testing.T) { - h, pool := testHandlers(t) - user := seedUser(t, pool, "alice", "pw", false) - q := dbq.New(pool) - artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ - Name: "ArtistA", SortName: "ArtistA", - }) - _, _ = q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ - Title: "Zoo", SortTitle: "Zoo", ArtistID: artist.ID, - }) - _, _ = q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ - Title: "Apple", SortTitle: "Apple", ArtistID: artist.ID, - }) - _, _ = q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ - Title: "Mango", SortTitle: "Mango", ArtistID: artist.ID, - }) - - w := doListLibraryAlbums(h, user, "limit=10") - if w.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) - } - var got Page[AlbumRef] - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.Total != 3 { - t.Errorf("total = %d, want 3", got.Total) - } - titles := []string{got.Items[0].Title, got.Items[1].Title, got.Items[2].Title} - if titles[0] != "Apple" || titles[1] != "Mango" || titles[2] != "Zoo" { - t.Errorf("titles = %v, want [Apple Mango Zoo]", titles) - } - if got.Items[0].SortTitle != "Apple" { - t.Errorf("sort_title = %q, want Apple", got.Items[0].SortTitle) - } -} - -func TestLibraryAlbums_Paging(t *testing.T) { - h, pool := testHandlers(t) - user := seedUser(t, pool, "alice", "pw", false) - q := dbq.New(pool) - artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ - Name: "ArtistA", SortName: "ArtistA", - }) - for _, title := range []string{"A", "B", "C", "D"} { - _, _ = q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ - Title: title, SortTitle: title, ArtistID: artist.ID, - }) - } - - w := doListLibraryAlbums(h, user, "limit=2&offset=2") - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var got Page[AlbumRef] - _ = json.Unmarshal(w.Body.Bytes(), &got) - if got.Total != 4 || got.Limit != 2 || got.Offset != 2 { - t.Errorf("envelope = %+v, want total=4 limit=2 offset=2", got) - } - if len(got.Items) != 2 || got.Items[0].Title != "C" || got.Items[1].Title != "D" { - t.Errorf("items = %v, want [C D]", got.Items) - } -} -``` - -- [ ] **Step 7.2: Run test to verify it fails** - -```bash -go test ./internal/api/ -run TestLibraryAlbums_ -count=1 -``` - -Expected: FAIL with "undefined: handleListLibraryAlbums". - -- [ ] **Step 7.3: Write the handler** - -Create `internal/api/library_albums.go`: - -```go -package api - -import ( - "net/http" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// handleListLibraryAlbums implements GET /api/library/albums. Mirrors -// /api/artists?sort=alpha but for albums. The new wrapping-grid page on -// the SPA infinite-scrolls against this endpoint via TanStack -// createInfiniteQuery. -func (h *handlers) handleListLibraryAlbums(w http.ResponseWriter, r *http.Request) { - limit, offset, err := parsePaging(r.URL.Query()) - if err != nil { - writeErr(w, http.StatusBadRequest, "bad_request", err.Error()) - return - } - q := dbq.New(h.pool) - rows, err := q.ListAlbumsAlphaWithArtist(r.Context(), dbq.ListAlbumsAlphaWithArtistParams{ - Limit: int32(limit), Offset: int32(offset), - }) - if err != nil { - h.logger.Error("api: list library albums", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - total, err := q.CountAlbums(r.Context()) - if err != nil { - h.logger.Error("api: count albums", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "count failed") - return - } - items := make([]AlbumRef, 0, len(rows)) - for _, row := range rows { - items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0)) - } - writeJSON(w, http.StatusOK, Page[AlbumRef]{ - Items: items, Total: int(total), Limit: limit, Offset: offset, - }) -} -``` - -- [ ] **Step 7.4: Run tests to verify they pass** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test ./internal/api/ -run TestLibraryAlbums_ -count=1 -v -``` - -Expected: 2/2 PASS. - -- [ ] **Step 7.5: Commit** - -```bash -git add internal/api/library_albums.go internal/api/library_albums_test.go -git commit -m "feat(api): add GET /api/library/albums handler" -``` - ---- - -### Task 8 — `GET /api/artists/{id}/tracks` handler - -**Files:** -- Modify: `internal/api/library.go` (append `handleGetArtistTracks`) -- Modify: `internal/api/library_test.go` (append tests) - -- [ ] **Step 8.1: Write failing tests** - -Append to `internal/api/library_test.go` at the bottom (preserve all existing tests): - -```go -func TestGetArtistTracks_HappyPath(t *testing.T) { - h, pool := testHandlers(t) - user := seedUser(t, pool, "alice", "pw", false) - q := dbq.New(pool) - artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ - Name: "ArtistA", SortName: "ArtistA", - }) - album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ - Title: "AlbumA", SortTitle: "AlbumA", ArtistID: artist.ID, - }) - _, _ = q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "T1", AlbumID: album.ID, ArtistID: artist.ID, - DurationMs: 1000, FilePath: "/tmp/m6a-T1.mp3", - FileSize: 1, FileFormat: "mp3", - }) - _, _ = q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "T2", AlbumID: album.ID, ArtistID: artist.ID, - DurationMs: 1000, FilePath: "/tmp/m6a-T2.mp3", - FileSize: 1, FileFormat: "mp3", - }) - - r := chi.NewRouter() - r.Get("/api/artists/{id}/tracks", h.handleGetArtistTracks) - req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID)+"/tracks", nil) - req = withUser(req, user) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) - } - var got []TrackRef - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if len(got) != 2 { - t.Fatalf("len = %d, want 2", len(got)) - } - if got[0].ArtistName != "ArtistA" || got[0].AlbumTitle != "AlbumA" { - t.Errorf("got = %+v", got[0]) - } -} - -func TestGetArtistTracks_HonorsQuarantine(t *testing.T) { - h, pool := testHandlers(t) - user := seedUser(t, pool, "alice", "pw", false) - q := dbq.New(pool) - artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ - Name: "ArtistB", SortName: "ArtistB", - }) - album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ - Title: "AlbumB", SortTitle: "AlbumB", ArtistID: artist.ID, - }) - track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "Q", AlbumID: album.ID, ArtistID: artist.ID, - DurationMs: 1000, FilePath: "/tmp/m6a-Q.mp3", - FileSize: 1, FileFormat: "mp3", - }) - if _, err := pool.Exec(context.Background(), - `INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`, - user.ID, track.ID, - ); err != nil { - t.Fatalf("quarantine insert: %v", err) - } - - r := chi.NewRouter() - r.Get("/api/artists/{id}/tracks", h.handleGetArtistTracks) - req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID)+"/tracks", nil) - req = withUser(req, user) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d", w.Code) - } - var got []TrackRef - _ = json.Unmarshal(w.Body.Bytes(), &got) - if len(got) != 0 { - t.Errorf("len = %d, want 0 (quarantined)", len(got)) - } -} - -func TestGetArtistTracks_NotFound(t *testing.T) { - h, pool := testHandlers(t) - user := seedUser(t, pool, "alice", "pw", false) - - r := chi.NewRouter() - r.Get("/api/artists/{id}/tracks", h.handleGetArtistTracks) - // Random UUID — no such artist exists. - req := httptest.NewRequest(http.MethodGet, "/api/artists/00000000-0000-0000-0000-000000000000/tracks", nil) - req = withUser(req, user) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Errorf("status = %d, want 404", w.Code) - } - _ = pool -} -``` - -- [ ] **Step 8.2: Run tests to verify they fail** - -```bash -go test ./internal/api/ -run TestGetArtistTracks_ -count=1 -``` - -Expected: FAIL with "undefined: handleGetArtistTracks". - -- [ ] **Step 8.3: Append the handler to `library.go`** - -Append at the bottom of `internal/api/library.go`: - -```go -// handleGetArtistTracks implements GET /api/artists/{id}/tracks. Returns -// every track the artist has across albums (per-user-quarantine filtered) -// as a flat list. Used by ArtistCard's play affordance, which shuffles -// client-side. 404 when the artist doesn't exist. -func (h *handlers) handleGetArtistTracks(w http.ResponseWriter, r *http.Request) { - id, ok := parseUUID(chi.URLParam(r, "id")) - if !ok { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid artist id") - return - } - user, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") - return - } - q := dbq.New(h.pool) - if _, err := q.GetArtistByID(r.Context(), id); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - writeErr(w, http.StatusNotFound, "not_found", "artist not found") - return - } - h.logger.Error("api: get artist for tracks", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - rows, err := q.ListArtistTracksForUser(r.Context(), dbq.ListArtistTracksForUserParams{ - ArtistID: id, UserID: user.ID, - }) - if err != nil { - h.logger.Error("api: list artist tracks", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - out := make([]TrackRef, 0, len(rows)) - for _, row := range rows { - out = append(out, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName)) - } - writeJSON(w, http.StatusOK, out) -} -``` - -- [ ] **Step 8.4: Run tests to verify they pass** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test ./internal/api/ -run TestGetArtistTracks_ -count=1 -v -``` - -Expected: 3/3 PASS. - -- [ ] **Step 8.5: Commit** - -```bash -git add internal/api/library.go internal/api/library_test.go -git commit -m "feat(api): add GET /api/artists/{id}/tracks handler" -``` - ---- - -### Task 9 — Update `/api/artists?sort=alpha` to use covers query + register routes - -**Files:** -- Modify: `internal/api/library.go` (`handleListArtists` alpha branch) -- Modify: `internal/api/library_test.go` (existing alpha tests still pass; add cover-url assertion) -- Modify: `internal/api/api.go` - -- [ ] **Step 9.1: Update `handleListArtists` alpha branch to use the new query** - -Modify `internal/api/library.go` — the `handleListArtists` function's switch block: - -```go - switch sort { - case "newest": - artists, err = q.ListArtistsNewest(r.Context(), dbq.ListArtistsNewestParams{ - Limit: int32(limit), Offset: int32(offset), - }) - if err != nil { - h.logger.Error("api: list artists newest", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - total, terr := q.CountArtists(r.Context()) - if terr != nil { - h.logger.Error("api: count artists", "err", terr) - writeErr(w, http.StatusInternalServerError, "server_error", "count failed") - return - } - items := make([]ArtistRef, 0, len(artists)) - for _, a := range artists { - albums, aerr := q.ListAlbumsByArtist(r.Context(), a.ID) - if aerr != nil { - h.logger.Error("api: list albums for artist", "err", aerr) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - items = append(items, artistRefFrom(a, len(albums))) - } - writeJSON(w, http.StatusOK, Page[ArtistRef]{ - Items: items, Total: int(total), Limit: limit, Offset: offset, - }) - return - - default: // alpha - rows, err := q.ListArtistsAlphaWithCovers(r.Context(), dbq.ListArtistsAlphaWithCoversParams{ - Limit: int32(limit), Offset: int32(offset), - }) - if err != nil { - h.logger.Error("api: list artists alpha", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") - return - } - total, terr := q.CountArtists(r.Context()) - if terr != nil { - h.logger.Error("api: count artists", "err", terr) - writeErr(w, http.StatusInternalServerError, "server_error", "count failed") - return - } - items := make([]ArtistRef, 0, len(rows)) - for _, row := range rows { - items = append(items, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID)) - } - writeJSON(w, http.StatusOK, Page[ArtistRef]{ - Items: items, Total: int(total), Limit: limit, Offset: offset, - }) - return - } -``` - -Then delete the redundant `_ = artists; _ = err` and old shared `total` block at the bottom of the function — both branches now return inline. The function should end after the `switch` with no further statements. - -- [ ] **Step 9.2: Register routes in `api.go`** - -Modify `internal/api/api.go` — add three new routes inside the authenticated group, alongside the existing `authed.Get("/artists/...", ...)` lines: - -```go - authed.Get("/home", h.handleGetHome) - authed.Get("/library/albums", h.handleListLibraryAlbums) - authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks) -``` - -Place these adjacent to the existing artist/album/track registrations for readability. The exact location: - -```go - authed.Get("/artists", h.handleListArtists) - authed.Get("/artists/{id}", h.handleGetArtist) - authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks) // NEW - authed.Get("/albums/{id}", h.handleGetAlbum) - authed.Get("/albums/{id}/cover", h.handleGetCover) - authed.Get("/library/albums", h.handleListLibraryAlbums) // NEW - authed.Get("/tracks/{id}", h.handleGetTrack) - authed.Get("/tracks/{id}/stream", h.handleGetStream) - authed.Get("/search", h.handleSearch) - authed.Get("/radio", h.handleRadio) - authed.Get("/home", h.handleGetHome) // NEW -``` - -- [ ] **Step 9.3: Run all api tests** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test ./internal/api/ -count=1 -``` - -Expected: all PASS. Existing artist-list tests must continue to pass — the alpha branch now exposes `cover_url` (empty string when no cover) and `sort_name` (always populated). - -- [ ] **Step 9.4: Commit** - -```bash -git add internal/api/library.go internal/api/api.go -git commit -m "feat(api): wire M6a routes; alpha artist list uses covers query" -``` - ---- - -### Task 10 — Frontend: TS types + queries.ts - -**Files:** -- Modify: `web/src/lib/api/types.ts` -- Modify: `web/src/lib/api/queries.ts` - -- [ ] **Step 10.1: Extend `ArtistRef` and `AlbumRef`; add `HomePayload`** - -Modify `web/src/lib/api/types.ts` — replace the `ArtistRef` and `AlbumRef` blocks: - -```ts -export type ArtistRef = { - id: string; - name: string; - sort_name: string; - album_count: number; - cover_url: string; // "" when no representative album cover -}; - -export type AlbumRef = { - id: string; - title: string; - sort_title: string; - artist_id: string; - artist_name: string; - year?: number; - track_count: number; - duration_sec: number; - cover_url: string; -}; -``` - -Append at the bottom of `web/src/lib/api/types.ts`: - -```ts -// Mirrors internal/api/types.go HomePayload. All slices are non-null -// per the server contract — empty sections render as []. -export type HomePayload = { - recently_added_albums: AlbumRef[]; - rediscover_albums: AlbumRef[]; - rediscover_artists: ArtistRef[]; - most_played_tracks: TrackRef[]; - last_played_artists: ArtistRef[]; -}; -``` - -- [ ] **Step 10.2: Add new query keys** - -Modify `web/src/lib/api/queries.ts` — append entries to the `qk` object literal: - -```ts - home: () => ['home'] as const, - albumsAlpha: () => ['albumsAlpha'] as const, - artistTracks: (artistId: string) => ['artistTracks', artistId] as const, -``` - -- [ ] **Step 10.3: Update existing queries.test.ts** - -Append to `web/src/lib/api/queries.test.ts`: - -```ts -test('M6a query keys are stable', () => { - expect(qk.home()).toEqual(['home']); - expect(qk.albumsAlpha()).toEqual(['albumsAlpha']); - expect(qk.artistTracks('a1')).toEqual(['artistTracks', 'a1']); -}); -``` - -- [ ] **Step 10.4: Update existing test fixtures for new required fields** - -Search for AlbumRef/ArtistRef literals in tests: - -```bash -cd web && grep -rln "album_count\|track_count" src/ --include="*.test.ts" -``` - -For each file with an AlbumRef literal, add `sort_title: ''`. For each ArtistRef literal, add `sort_name: ''` and `cover_url: ''`. Example for `web/src/lib/components/AlbumCard.test.ts`: - -```ts -const album: AlbumRef = { - id: 'xyz', - title: 'Kind of Blue', - sort_title: 'Kind of Blue', - artist_id: 'm-davis', - artist_name: 'Miles Davis', - year: 1959, - track_count: 5, - duration_sec: 2630, - cover_url: '/api/albums/xyz/cover', -}; -``` - -- [ ] **Step 10.5: Run all frontend tests** - -```bash -cd web && pnpm check && pnpm test -``` - -Expected: all PASS. - -- [ ] **Step 10.6: Commit** - -```bash -git add web/src/lib/api/types.ts web/src/lib/api/queries.ts web/src/lib/api/queries.test.ts \ - web/src/lib/components/*.test.ts -git commit -m "feat(web): extend ArtistRef/AlbumRef + add HomePayload + new query keys" -``` - ---- - -### Task 11 — Frontend API clients: home.ts + albums.ts + artists.ts - -**Files:** -- Create: `web/src/lib/api/home.ts` + `home.test.ts` -- Create: `web/src/lib/api/albums.ts` + `albums.test.ts` -- Create: `web/src/lib/api/artists.ts` + `artists.test.ts` - -- [ ] **Step 11.1: Write failing tests for `home.ts`** - -Create `web/src/lib/api/home.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; - -vi.mock('./client', () => ({ - api: { get: vi.fn() } -})); - -import { listHome } from './home'; -import { qk } from './queries'; -import { api } from './client'; -import type { HomePayload } from './types'; - -afterEach(() => vi.clearAllMocks()); - -describe('home client', () => { - test('listHome hits /api/home', async () => { - const fixture: HomePayload = { - recently_added_albums: [], - rediscover_albums: [], - rediscover_artists: [], - most_played_tracks: [], - last_played_artists: [] - }; - (api.get as ReturnType).mockResolvedValueOnce(fixture); - const got = await listHome(); - expect(api.get).toHaveBeenCalledWith('/api/home'); - expect(got).toEqual(fixture); - }); - - test('qk.home key is stable', () => { - expect(qk.home()).toEqual(['home']); - }); -}); -``` - -- [ ] **Step 11.2: Create `home.ts`** - -Create `web/src/lib/api/home.ts`: - -```ts -import { createQuery } from '@tanstack/svelte-query'; -import { api } from './client'; -import { qk } from './queries'; -import type { HomePayload } from './types'; - -export async function listHome(): Promise { - return api.get('/api/home'); -} - -// staleTime = 60s — repeat home-page mounts within a minute reuse cached -// data. Like-state changes, new plays, freshly-added albums surface on -// the next refetch. -export function createHomeQuery() { - return createQuery({ - queryKey: qk.home(), - queryFn: listHome, - staleTime: 60_000 - }); -} -``` - -- [ ] **Step 11.3: Write failing tests for `albums.ts`** - -Create `web/src/lib/api/albums.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; - -vi.mock('./client', () => ({ - api: { get: vi.fn() } -})); - -import { listAlbumsAlpha } from './albums'; -import { qk } from './queries'; -import { api } from './client'; - -afterEach(() => vi.clearAllMocks()); - -describe('albums client', () => { - test('listAlbumsAlpha hits /api/library/albums with paging', async () => { - (api.get as ReturnType).mockResolvedValueOnce({ - items: [], total: 0, limit: 50, offset: 0 - }); - await listAlbumsAlpha(50, 0); - expect(api.get).toHaveBeenCalledWith('/api/library/albums?limit=50&offset=0'); - }); - - test('listAlbumsAlpha honors custom limit and offset', async () => { - (api.get as ReturnType).mockResolvedValueOnce({ - items: [], total: 0, limit: 25, offset: 100 - }); - await listAlbumsAlpha(25, 100); - expect(api.get).toHaveBeenCalledWith('/api/library/albums?limit=25&offset=100'); - }); - - test('qk.albumsAlpha key is stable', () => { - expect(qk.albumsAlpha()).toEqual(['albumsAlpha']); - }); -}); -``` - -- [ ] **Step 11.4: Create `albums.ts`** - -Create `web/src/lib/api/albums.ts`: - -```ts -import { createInfiniteQuery } from '@tanstack/svelte-query'; -import { api } from './client'; -import { qk } from './queries'; -import type { AlbumRef, Page } from './types'; - -export const ALBUM_PAGE_SIZE = 50; - -export async function listAlbumsAlpha(limit: number, offset: number): Promise> { - return api.get>(`/api/library/albums?limit=${limit}&offset=${offset}`); -} - -export function createAlbumsAlphaInfiniteQuery() { - return createInfiniteQuery({ - queryKey: qk.albumsAlpha(), - queryFn: ({ pageParam = 0 }) => listAlbumsAlpha(ALBUM_PAGE_SIZE, pageParam), - initialPageParam: 0, - getNextPageParam: (last) => { - const loaded = last.offset + last.items.length; - return loaded >= last.total ? undefined : loaded; - } - }); -} -``` - -- [ ] **Step 11.5: Write failing tests for `artists.ts`** - -Create `web/src/lib/api/artists.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; - -vi.mock('./client', () => ({ - api: { get: vi.fn() } -})); - -import { listArtistTracks } from './artists'; -import { qk } from './queries'; -import { api } from './client'; - -afterEach(() => vi.clearAllMocks()); - -describe('artists client', () => { - test('listArtistTracks hits /api/artists/:id/tracks', async () => { - (api.get as ReturnType).mockResolvedValueOnce([]); - await listArtistTracks('art-1'); - expect(api.get).toHaveBeenCalledWith('/api/artists/art-1/tracks'); - }); - - test('qk.artistTracks key includes the artist id', () => { - expect(qk.artistTracks('art-1')).toEqual(['artistTracks', 'art-1']); - }); -}); -``` - -- [ ] **Step 11.6: Create `artists.ts`** - -Create `web/src/lib/api/artists.ts`: - -```ts -import { createQuery } from '@tanstack/svelte-query'; -import { api } from './client'; -import { qk } from './queries'; -import type { TrackRef } from './types'; - -export async function listArtistTracks(artistId: string): Promise { - return api.get(`/api/artists/${artistId}/tracks`); -} - -export function createArtistTracksQuery(artistId: string) { - return createQuery({ - queryKey: qk.artistTracks(artistId), - queryFn: () => listArtistTracks(artistId), - enabled: artistId.length > 0 - }); -} -``` - -- [ ] **Step 11.7: Run all client tests + commit** - -```bash -cd web && pnpm test src/lib/api/home.test.ts src/lib/api/albums.test.ts src/lib/api/artists.test.ts -``` - -Expected: all PASS. Commit: - -```bash -git add web/src/lib/api/home.ts web/src/lib/api/home.test.ts \ - web/src/lib/api/albums.ts web/src/lib/api/albums.test.ts \ - web/src/lib/api/artists.ts web/src/lib/api/artists.test.ts -git commit -m "feat(web): add home/albums/artists API clients for M6a" -``` - ---- - -### Task 12 — `` component - -**Files:** -- Create: `web/src/lib/components/HorizontalScrollRow.svelte` -- Create: `web/src/lib/components/HorizontalScrollRow.test.ts` - -- [ ] **Step 12.1: Write failing test** - -Create `web/src/lib/components/HorizontalScrollRow.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; -import HorizontalScrollRow from './HorizontalScrollRow.svelte'; - -afterEach(() => vi.clearAllMocks()); - -describe('HorizontalScrollRow', () => { - test('renders left and right scroll buttons', () => { - render(HorizontalScrollRow, { props: { items: ['a', 'b', 'c'] } }); - expect(screen.getByRole('button', { name: /scroll left/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /scroll right/i })).toBeInTheDocument(); - }); - - test('left button starts disabled (scrollLeft is 0)', () => { - render(HorizontalScrollRow, { props: { items: ['a'] } }); - expect(screen.getByRole('button', { name: /scroll left/i })).toBeDisabled(); - }); - - test('clicking right button calls scrollBy on the container', async () => { - const { container } = render(HorizontalScrollRow, { props: { items: ['a', 'b'] } }); - const scroller = container.querySelector('[data-testid="scroll-container"]') as HTMLElement; - const spy = vi.fn(); - scroller.scrollBy = spy as unknown as HTMLElement['scrollBy']; - Object.defineProperty(scroller, 'clientWidth', { value: 800, configurable: true }); - Object.defineProperty(scroller, 'scrollWidth', { value: 2000, configurable: true }); - Object.defineProperty(scroller, 'scrollLeft', { value: 0, configurable: true, writable: true }); - - await fireEvent.click(screen.getByRole('button', { name: /scroll right/i })); - expect(spy).toHaveBeenCalledWith({ left: expect.any(Number), behavior: 'smooth' }); - expect(spy.mock.calls[0][0].left).toBeGreaterThan(0); - }); -}); -``` - -- [ ] **Step 12.2: Run test to verify failure** - -```bash -cd web && pnpm test src/lib/components/HorizontalScrollRow.test.ts -``` - -Expected: FAIL. - -- [ ] **Step 12.3: Create the component** - -Create `web/src/lib/components/HorizontalScrollRow.svelte`: - -```svelte - - -
- - -
- {#each items as it, i (i)} - {@render item?.(it, i)} - {/each} -
- - -
- - -``` - -- [ ] **Step 12.4: Run tests + commit** - -```bash -cd web && pnpm test src/lib/components/HorizontalScrollRow.test.ts -``` - -Expected: 3/3 PASS. Commit: - -```bash -git add web/src/lib/components/HorizontalScrollRow.svelte \ - web/src/lib/components/HorizontalScrollRow.test.ts -git commit -m "feat(web): add HorizontalScrollRow component" -``` - ---- - -### Task 13 — `` polish + play overlay - -**Files:** -- Modify: `web/src/lib/components/AlbumCard.svelte` -- Modify: `web/src/lib/components/AlbumCard.test.ts` - -- [ ] **Step 13.1: Update existing test mock to include `playQueue`** - -Edit `web/src/lib/components/AlbumCard.test.ts` — change the player mock at the top of the file: - -```ts -vi.mock('$lib/player/store.svelte', () => ({ - enqueueTracks: vi.fn(), - playQueue: vi.fn() -})); -``` - -And the corresponding import: - -```ts -import { enqueueTracks, playQueue } from '$lib/player/store.svelte'; -``` - -- [ ] **Step 13.2: Append failing test for play overlay** - -Append inside the `describe('AlbumCard', ...)` block: - -```ts -test('play overlay click fetches album detail and calls playQueue at index 0', async () => { - const tracks: TrackRef[] = [ - { id: 't1', title: 'So What', - album_id: 'xyz', album_title: 'Kind of Blue', - artist_id: 'm-davis', artist_name: 'Miles Davis', - track_number: 1, disc_number: 1, duration_sec: 545, - stream_url: '/api/tracks/t1/stream' } - ]; - const detail: AlbumDetail = { ...album, tracks }; - (api.get as ReturnType).mockResolvedValueOnce(detail); - - render(AlbumCard, { props: { album } }); - await fireEvent.click(screen.getByRole('button', { name: /play kind of blue/i })); - expect(api.get).toHaveBeenCalledWith('/api/albums/xyz'); - await Promise.resolve(); - await Promise.resolve(); - expect(playQueue).toHaveBeenCalledWith(tracks, 0); -}); -``` - -- [ ] **Step 13.3: Run test to verify failure** - -```bash -cd web && pnpm test src/lib/components/AlbumCard.test.ts -``` - -Expected: FAIL — no play button exists. - -- [ ] **Step 13.4: Rewrite `AlbumCard.svelte`** - -Replace the entire contents of `web/src/lib/components/AlbumCard.svelte`: - -```svelte - - -
- - -``` - -- [ ] **Step 13.5: Run tests + commit** - -```bash -cd web && pnpm test src/lib/components/AlbumCard.test.ts -``` - -Expected: all PASS. Commit: - -```bash -git add web/src/lib/components/AlbumCard.svelte web/src/lib/components/AlbumCard.test.ts -git commit -m "feat(web): polish AlbumCard with FabledSword tokens + play overlay" -``` - ---- - -### Task 14 — `` (circular) - -**Files:** -- Create: `web/src/lib/components/ArtistCard.svelte` -- Create: `web/src/lib/components/ArtistCard.test.ts` - -- [ ] **Step 14.1: Write failing test** - -Create `web/src/lib/components/ArtistCard.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; -import type { ArtistRef, TrackRef } from '$lib/api/types'; - -vi.mock('$lib/api/client', () => ({ - api: { get: vi.fn() } -})); -vi.mock('$lib/player/store.svelte', () => ({ - playQueue: vi.fn() -})); - -import ArtistCard from './ArtistCard.svelte'; -import { api } from '$lib/api/client'; -import { playQueue } from '$lib/player/store.svelte'; - -const artist: ArtistRef = { - id: 'art-1', - name: 'Boards of Canada', - sort_name: 'Boards of Canada', - album_count: 5, - cover_url: '/api/albums/cov-1/cover' -}; - -afterEach(() => vi.clearAllMocks()); - -describe('ArtistCard', () => { - test('renders cover img inside link to /artists/:id', () => { - const { container } = render(ArtistCard, { props: { artist } }); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/artists/art-1'); - const img = container.querySelector('img') as HTMLImageElement; - expect(img.src).toContain('/api/albums/cov-1/cover'); - }); - - test('falls back to Disc3 glyph when cover_url is empty', () => { - const { container } = render(ArtistCard, { - props: { artist: { ...artist, cover_url: '' } } - }); - expect(container.querySelector('img')).not.toBeInTheDocument(); - expect(container.querySelector('svg')).toBeInTheDocument(); - }); - - test('play overlay fetches tracks, shuffles, calls playQueue', async () => { - const tracks: TrackRef[] = [ - { id: 't1', title: 'A', album_id: 'al-1', album_title: 'X', - artist_id: 'art-1', artist_name: 'BoC', - track_number: 1, disc_number: 1, duration_sec: 1, stream_url: '/x' }, - { id: 't2', title: 'B', album_id: 'al-1', album_title: 'X', - artist_id: 'art-1', artist_name: 'BoC', - track_number: 2, disc_number: 1, duration_sec: 1, stream_url: '/x' } - ]; - (api.get as ReturnType).mockResolvedValueOnce(tracks); - - render(ArtistCard, { props: { artist } }); - await fireEvent.click(screen.getByRole('button', { name: /play boards of canada/i })); - expect(api.get).toHaveBeenCalledWith('/api/artists/art-1/tracks'); - await Promise.resolve(); - await Promise.resolve(); - expect(playQueue).toHaveBeenCalledOnce(); - const [calledTracks, idx] = (playQueue as ReturnType).mock.calls[0]; - expect(calledTracks).toHaveLength(2); - expect(idx).toBe(0); - expect(calledTracks.map((t: TrackRef) => t.id).sort()).toEqual(['t1', 't2']); - }); -}); -``` - -- [ ] **Step 14.2: Run test to verify failure** - -```bash -cd web && pnpm test src/lib/components/ArtistCard.test.ts -``` - -Expected: FAIL. - -- [ ] **Step 14.3: Create the component** - -Create `web/src/lib/components/ArtistCard.svelte`: - -```svelte - - - - - -``` - -- [ ] **Step 14.4: Run tests + commit** - -```bash -cd web && pnpm test src/lib/components/ArtistCard.test.ts -``` - -Expected: 3/3 PASS. Commit: - -```bash -git add web/src/lib/components/ArtistCard.svelte \ - web/src/lib/components/ArtistCard.test.ts -git commit -m "feat(web): add ArtistCard (circular) with play-shuffle overlay" -``` - ---- - -### Task 15 — `` - -**Files:** -- Create: `web/src/lib/components/CompactTrackCard.svelte` -- Create: `web/src/lib/components/CompactTrackCard.test.ts` - -- [ ] **Step 15.1: Write failing test** - -Create `web/src/lib/components/CompactTrackCard.test.ts`: - -```ts -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; -import type { TrackRef } from '$lib/api/types'; - -vi.mock('$lib/player/store.svelte', () => ({ - playQueue: vi.fn() -})); - -import CompactTrackCard from './CompactTrackCard.svelte'; -import { playQueue } from '$lib/player/store.svelte'; - -const tracks: TrackRef[] = [ - { id: 't1', title: 'First', album_id: 'a1', album_title: 'A', - artist_id: 'art-1', artist_name: 'Artist', - track_number: 1, disc_number: 1, duration_sec: 1, stream_url: '/a' }, - { id: 't2', title: 'Second', album_id: 'a1', album_title: 'A', - artist_id: 'art-1', artist_name: 'Artist', - track_number: 2, disc_number: 1, duration_sec: 1, stream_url: '/b' }, - { id: 't3', title: 'Third', album_id: 'a1', album_title: 'A', - artist_id: 'art-1', artist_name: 'Artist', - track_number: 3, disc_number: 1, duration_sec: 1, stream_url: '/c' } -]; - -afterEach(() => vi.clearAllMocks()); - -describe('CompactTrackCard', () => { - test('clicking the card calls playQueue with sectionTracks at the right index', async () => { - render(CompactTrackCard, { - props: { track: tracks[1], sectionTracks: tracks, index: 1 } - }); - await fireEvent.click(screen.getByRole('button', { name: /play second/i })); - expect(playQueue).toHaveBeenCalledWith(tracks, 1); - }); - - test('renders title and artist name', () => { - render(CompactTrackCard, { - props: { track: tracks[0], sectionTracks: tracks, index: 0 } - }); - expect(screen.getByText('First')).toBeInTheDocument(); - expect(screen.getByText('Artist')).toBeInTheDocument(); - }); -}); -``` - -- [ ] **Step 15.2: Create the component** - -Create `web/src/lib/components/CompactTrackCard.svelte`: - -```svelte - - - -``` - -- [ ] **Step 15.3: Run tests + commit** - -```bash -cd web && pnpm test src/lib/components/CompactTrackCard.test.ts -``` - -Expected: 2/2 PASS. Commit: - -```bash -git add web/src/lib/components/CompactTrackCard.svelte \ - web/src/lib/components/CompactTrackCard.test.ts -git commit -m "feat(web): add CompactTrackCard for Most played rows" -``` - ---- - -### Task 16 — `` - -**Files:** -- Create: `web/src/lib/components/AlphabeticalGrid.svelte` -- Create: `web/src/lib/components/AlphabeticalGrid.test.ts` - -- [ ] **Step 16.1: Write failing test** - -Create `web/src/lib/components/AlphabeticalGrid.test.ts`: - -```ts -import { describe, expect, test } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import { createRawSnippet } from 'svelte'; -import AlphabeticalGrid from './AlphabeticalGrid.svelte'; - -type Item = { id: string; key: string; label: string }; - -const items: Item[] = [ - { id: '1', key: 'A', label: 'Apple' }, - { id: '2', key: 'A', label: 'Avocado' }, - { id: '3', key: 'B', label: 'Banana' }, - { id: '4', key: 'D', label: 'Date' } // skips C — divider must jump to D directly -]; - -describe('AlphabeticalGrid', () => { - test('inserts a divider for each new key letter', () => { - render(AlphabeticalGrid, { - props: { - items, - getKey: (it: Item) => it.key, - item: createRawSnippet((it: Item) => ({ - render: () => `${it.label}` - })) - } - }); - // Three dividers (A, B, D); C is absent because no items use C. - expect(screen.getByText('A')).toBeInTheDocument(); - expect(screen.getByText('B')).toBeInTheDocument(); - expect(screen.getByText('D')).toBeInTheDocument(); - expect(screen.queryByText('C')).not.toBeInTheDocument(); - }); - - test('renders items in given order', () => { - const { container } = render(AlphabeticalGrid, { - props: { - items, - getKey: (it: Item) => it.key, - item: createRawSnippet((it: Item) => ({ - render: () => `${it.label}` - })) - } - }); - expect(container.textContent).toMatch(/A.*Apple.*Avocado.*B.*Banana.*D.*Date/s); - }); -}); -``` - -NOTE: `createRawSnippet` is the Svelte 5 utility for synthesising snippets in tests; if it doesn't suit your renderer, swap to a wrapper component that exposes the snippet via ``. - -- [ ] **Step 16.2: Run test to verify failure** - -```bash -cd web && pnpm test src/lib/components/AlphabeticalGrid.test.ts -``` - -Expected: FAIL. - -- [ ] **Step 16.3: Create the component** - -Create `web/src/lib/components/AlphabeticalGrid.svelte`: - -```svelte - - -
- {#each segments as seg, i (i)} - {#if seg.divider} -
- {seg.divider} - -
- {/if} -
- {@render item(seg.item)} -
- {/each} -
- - -``` - -- [ ] **Step 16.4: Run tests + commit** - -```bash -cd web && pnpm test src/lib/components/AlphabeticalGrid.test.ts -``` - -Expected: 2/2 PASS. Commit: - -```bash -git add web/src/lib/components/AlphabeticalGrid.svelte \ - web/src/lib/components/AlphabeticalGrid.test.ts -git commit -m "feat(web): add AlphabeticalGrid wrapper with letter dividers" -``` - ---- - -### Task 17 — Home page rewrite (`/+page.svelte`) - -**Files:** -- Modify: `web/src/routes/+page.svelte` (full rewrite) -- Modify: `web/src/routes/artists.test.ts` (assertions migrate) - -- [ ] **Step 17.1: Rewrite `+page.svelte`** - -Replace the entire contents of `web/src/routes/+page.svelte`: - -```svelte - - -
- {#if query.isError} - - {:else if showSkeleton.value && !data} -

Loading…

- {:else if data} - -
-
-

Recently added

-
- {#if data.recently_added_albums.length === 0} -

Nothing added yet. Scan a folder via the server's config.

- {:else} - {#each chunk(data.recently_added_albums, 25) as row, i (i)} - - {#snippet item(album)} -
- {/snippet} -
- {/each} - {/if} -
- - -
-
-

Rediscover

-
- {#if data.rediscover_albums.length === 0 && data.rediscover_artists.length === 0} -

No forgotten favourites yet. Like some albums or artists to fill this in.

- {:else} - {#if data.rediscover_albums.length > 0} - - {#snippet item(album)} -
- {/snippet} -
- {/if} - {#if data.rediscover_artists.length > 0} - - {#snippet item(artist)} -
- {/snippet} -
- {/if} - {/if} -
- - -
-
-

Most played

-
- {#if data.most_played_tracks.length === 0} -

No plays to draw from. Listen to something.

- {:else} - {#each chunk(data.most_played_tracks, 25) as row, i (i)} - - {#snippet item(track, idx)} - - {/snippet} - - {/each} - {/if} -
- - -
-
-

Last played

-
- {#if data.last_played_artists.length === 0} -

No recent plays.

- {:else} - - {#snippet item(artist)} -
- {/snippet} -
- {/if} -
- {/if} -
-``` - -- [ ] **Step 17.2: Update `artists.test.ts` to point at the new home behavior** - -Open `web/src/routes/artists.test.ts`. The existing tests assert the old library-list behavior of `/`. Either move them to a new `library/artists.test.ts` (Task 18 covers that page) or delete them outright in this step. Decision: delete this file in this task because the replacement tests live with `/library/artists/+page.svelte`. - -```bash -git rm web/src/routes/artists.test.ts -``` - -- [ ] **Step 17.3: Run frontend tests + check** - -```bash -cd web && pnpm check && pnpm test -``` - -Expected: all PASS. The new home page has no dedicated component test (see Task 22 for an integration smoke test); `pnpm check` confirms type correctness against the new query/types. - -- [ ] **Step 17.4: Commit** - -```bash -git add web/src/routes/+page.svelte -git rm web/src/routes/artists.test.ts -git commit -m "feat(web): rewrite / as home page composing 4 horizontal-scroll sections" -``` - ---- - -### Task 18 — `/library/artists` page - -**Files:** -- Create: `web/src/routes/library/artists/+page.svelte` -- Create: `web/src/routes/library/artists/page.test.ts` - -- [ ] **Step 18.1: Write the page** - -Create `web/src/routes/library/artists/+page.svelte`: - -```svelte - - -
-
-

Artists

- {#if !query.isPending && !query.isError} -

- {total} {total === 1 ? 'artist' : 'artists'} -

- {/if} -
- - {#if query.isError} - - {:else if showSkeleton.value && artists.length === 0} -

Loading…

- {:else if !query.isPending && total === 0} -

No artists yet — scan a library folder via the server's config.

- {:else} - a.sort_name || a.name} - > - {#snippet item(a)} - - {/snippet} - - - {#if query.hasNextPage} - - {:else if artists.length > 0} -

End of library

- {/if} - {/if} -
-``` - -- [ ] **Step 18.2: Add a smoke test** - -Create `web/src/routes/library/artists/page.test.ts`: - -```ts -import { describe, expect, test, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import { readable } from 'svelte/store'; - -vi.mock('$lib/api/queries', () => ({ - createArtistsQuery: () => readable({ - data: { pages: [{ - items: [ - { id: 'a1', name: 'Alpha', sort_name: 'Alpha', album_count: 1, cover_url: '' }, - { id: 'b1', name: 'Beta', sort_name: 'Beta', album_count: 2, cover_url: '' } - ], - total: 2, limit: 50, offset: 0 - }] }, - isPending: false, - isError: false, - hasNextPage: false, - isFetchingNextPage: false, - refetch: () => {}, - fetchNextPage: () => {} - }) -})); -vi.mock('$lib/api/client', () => ({ api: { get: vi.fn() } })); -vi.mock('$lib/player/store.svelte', () => ({ playQueue: vi.fn() })); - -import Page from './+page.svelte'; - -describe('/library/artists', () => { - test('renders title + count + alphabetical dividers', () => { - render(Page); - expect(screen.getByRole('heading', { name: /artists/i })).toBeInTheDocument(); - expect(screen.getByText(/2 artists/)).toBeInTheDocument(); - expect(screen.getByText('A')).toBeInTheDocument(); - expect(screen.getByText('B')).toBeInTheDocument(); - expect(screen.getByText('Alpha')).toBeInTheDocument(); - expect(screen.getByText('Beta')).toBeInTheDocument(); - }); -}); -``` - -- [ ] **Step 18.3: Run tests + commit** - -```bash -cd web && pnpm test src/routes/library/artists/page.test.ts -``` - -Expected: PASS. Commit: - -```bash -git add web/src/routes/library/artists/ -git commit -m "feat(web): add /library/artists wrapping-grid page" -``` - ---- - -### Task 19 — `/library/albums` page - -**Files:** -- Create: `web/src/routes/library/albums/+page.svelte` -- Create: `web/src/routes/library/albums/page.test.ts` - -- [ ] **Step 19.1: Write the page** - -Create `web/src/routes/library/albums/+page.svelte`: - -```svelte - - -
-
-

Albums

- {#if !query.isPending && !query.isError} -

- {total} {total === 1 ? 'album' : 'albums'} -

- {/if} -
- - {#if query.isError} - - {:else if showSkeleton.value && albums.length === 0} -

Loading…

- {:else if !query.isPending && total === 0} -

No albums yet — scan a library folder via the server's config.

- {:else} - a.sort_title || a.title} - > - {#snippet item(album)} - - {/snippet} - - - {#if query.hasNextPage} - - {:else if albums.length > 0} -

End of library

- {/if} - {/if} -
-``` - -- [ ] **Step 19.2: Add a smoke test** - -Create `web/src/routes/library/albums/page.test.ts`: - -```ts -import { describe, expect, test, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import { readable } from 'svelte/store'; - -vi.mock('$lib/api/albums', () => ({ - createAlbumsAlphaInfiniteQuery: () => readable({ - data: { pages: [{ - items: [ - { id: 'a1', title: 'Apple', sort_title: 'Apple', - artist_id: 'art-1', artist_name: 'X', - track_count: 5, duration_sec: 100, cover_url: '/api/albums/a1/cover' }, - { id: 'b1', title: 'Banana', sort_title: 'Banana', - artist_id: 'art-1', artist_name: 'X', - track_count: 3, duration_sec: 50, cover_url: '/api/albums/b1/cover' } - ], - total: 2, limit: 50, offset: 0 - }] }, - isPending: false, - isError: false, - hasNextPage: false, - isFetchingNextPage: false, - refetch: () => {}, - fetchNextPage: () => {} - }), - ALBUM_PAGE_SIZE: 50 -})); -vi.mock('$lib/api/client', () => ({ api: { get: vi.fn() } })); -vi.mock('$lib/player/store.svelte', () => ({ - enqueueTracks: vi.fn(), - playQueue: vi.fn() -})); -vi.mock('$lib/api/likes', () => ({ - createLikedIdsQuery: () => readable({ - data: { track_ids: [], album_ids: [], artist_ids: [] }, - isPending: false, isError: false - }), - likeEntity: vi.fn(), - unlikeEntity: vi.fn() -})); - -import Page from './+page.svelte'; - -describe('/library/albums', () => { - test('renders title + count + alphabetical dividers', () => { - render(Page); - expect(screen.getByRole('heading', { name: /albums/i })).toBeInTheDocument(); - expect(screen.getByText(/2 albums/)).toBeInTheDocument(); - expect(screen.getByText('A')).toBeInTheDocument(); - expect(screen.getByText('B')).toBeInTheDocument(); - expect(screen.getByText('Apple')).toBeInTheDocument(); - expect(screen.getByText('Banana')).toBeInTheDocument(); - }); -}); -``` - -- [ ] **Step 19.3: Run tests + commit** - -```bash -cd web && pnpm test src/routes/library/albums/page.test.ts -``` - -Expected: PASS. Commit: - -```bash -git add web/src/routes/library/albums/ -git commit -m "feat(web): add /library/albums wrapping-grid page" -``` - ---- - -### Task 20 — Shell nav update + retire `` - -**Files:** -- Modify: `web/src/lib/components/Shell.svelte` -- Modify: `web/src/lib/components/Shell.test.ts` -- Delete: `web/src/lib/components/ArtistRow.svelte` -- Delete: `web/src/lib/components/ArtistRow.test.ts` -- Modify: `web/src/lib/components/LibrarySkeleton.svelte` (optional cleanup — see Step 20.5) - -- [ ] **Step 20.1: Update `Shell.svelte` navItems** - -Modify `web/src/lib/components/Shell.svelte` — replace the `navItems` array: - -```ts - const navItems = [ - { href: '/', label: 'Home' }, - { href: '/library/artists', label: 'Artists' }, - { href: '/library/albums', label: 'Albums' }, - { href: '/library/liked', label: 'Liked' }, - { href: '/library/hidden', label: 'Hidden' }, - { href: '/search', label: 'Search' }, - { href: '/discover', label: 'Discover' }, - { href: '/requests', label: 'Requests' }, - { href: '/playlists', label: 'Playlists' }, - { href: '/settings', label: 'Settings' } - ]; -``` - -- [ ] **Step 20.2: Update `Shell.test.ts` assertions** - -Open `web/src/lib/components/Shell.test.ts`. Locate the assertion that lists nav items (search for `Library`) and replace with the new order: - -```ts -test('renders nav items in expected order', () => { - render(Shell, { props: { children } }); - const labels = ['Home', 'Artists', 'Albums', 'Liked', 'Hidden', 'Search', - 'Discover', 'Requests', 'Playlists', 'Settings']; - for (const label of labels) { - expect(screen.getByRole('link', { name: label })).toBeInTheDocument(); - } -}); -``` - -If the existing test does not assert the exact label list, add this test — preserving any existing tests in the file. - -- [ ] **Step 20.3: Audit `` usages and remove** - -Run: - -```bash -cd web && grep -rln "ArtistRow" src/ --include="*.svelte" --include="*.ts" -``` - -Expected (after Task 17): only `ArtistRow.svelte` and `ArtistRow.test.ts`. If any other file imports `ArtistRow`, replace those imports with `ArtistCard` and adjust the call site (likely a `` becomes ``). The migration is mechanical. - -Then delete: - -```bash -git rm web/src/lib/components/ArtistRow.svelte \ - web/src/lib/components/ArtistRow.test.ts -``` - -- [ ] **Step 20.4: Run all frontend tests** - -```bash -cd web && pnpm check && pnpm test -``` - -Expected: all PASS. Type errors at this step usually mean a forgotten ArtistRow import; fix per Step 20.3. - -- [ ] **Step 20.5: Inspect `LibrarySkeleton` usage** - -Run: - -```bash -cd web && grep -rln "LibrarySkeleton" src/ --include="*.svelte" --include="*.ts" -``` - -If the only remaining importers (after Tasks 17/18/19) use only the existing `variant="grid"` or `variant="list"` props, no change is needed — the skeleton was always extensible. Leave it. If a call site references the old `variant="list"` for a now-deleted page, delete that import. - -- [ ] **Step 20.6: Commit** - -```bash -git add web/src/lib/components/Shell.svelte \ - web/src/lib/components/Shell.test.ts -git rm web/src/lib/components/ArtistRow.svelte \ - web/src/lib/components/ArtistRow.test.ts -git commit -m "feat(web): update nav (Home/Artists/Albums); retire ArtistRow" -``` - ---- - -## Verification (post-task-20) - -After all tasks land, the following all-green checks confirm M6a is ready for PR: - -- [ ] **Backend full test pass** - -```bash -docker run --rm --network minstrel_minstrel \ - -v "$(pwd):/src" -w /src \ - -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ - golang:1.23-bookworm \ - go test ./... -count=1 -``` - -Expected: all PASS except the pre-existing `internal/library/TestScanner_Integration` flake (per `project_scanner_flake.md` memory). - -- [ ] **Frontend full test pass + typecheck** - -```bash -cd web && pnpm check && pnpm test && pnpm build -``` - -Expected: typecheck clean, all unit tests PASS, production build succeeds. - -- [ ] **Manual smoke** - -Start dev server (`docker compose up -d` + `cd web && pnpm dev`); log in; verify: - - 1. `/` renders all four sections; rows scroll independently with arrow buttons. - 2. Hovering an album card reveals the play overlay; clicking it queues the album from track 1. - 3. Hovering an artist card reveals the play overlay; clicking it shuffle-plays artist tracks. - 4. Compact track cards click-play in section context. - 5. Nav: clicking "Artists" goes to `/library/artists` (wrapping grid + dividers). - 6. Nav: clicking "Albums" goes to `/library/albums` (wrapping grid + dividers). - 7. Quarantined tracks for the test user are absent from "Most played" and from the artist-shuffle queue. - -- [ ] **Commit + push to dev branch** - -```bash -git push -u origin dev -``` - -- [ ] **Open Forgejo PR via MCP** (per `project_no_github.md`) - -Use `mcp__forgejo__pull_request_write` with action=create, base=main, head=dev. Title: -`feat(m6a): home page redesign + library list relocation`. Body summarises the slice and lists the verification steps above. - ---- - -## Notes for the implementer - -- **No DB migration.** All sections derive from existing tables. -- **Tests gated on `MINSTREL_TEST_DATABASE_URL`.** Without it, integration tests skip cleanly. -- **`t.Context()` is Go 1.24+; this repo runs Go 1.23.** Always use `context.Background()` in tests. -- **Forgejo only.** No GitHub. Use the forgejo MCP for PR ops. -- **TanStack v5 patterns.** `createInfiniteQuery` for paged lists; `createQuery` for one-shots; `staleTime: 60_000` on the home query. -- **FabledSword tokens always.** No raw hex in component styles. Use `bg-surface`, `text-text-primary`, `border-border`, `text-accent`, etc. -- **Lucide icons.** 16px/1px stroke for chrome buttons, 32px/1.5px for fallbacks/overlays. -- **Sentence case copy.** "Recently added", "Most played", not "Recently Added". -- **No half-finished UI.** Per the spec's "no in-between steps" rule, every surface added here ships finished. - - diff --git a/docs/superpowers/plans/2026-05-02-m7-flutter-mobile-foundation.md b/docs/superpowers/plans/2026-05-02-m7-flutter-mobile-foundation.md deleted file mode 100644 index ca90b623..00000000 --- a/docs/superpowers/plans/2026-05-02-m7-flutter-mobile-foundation.md +++ /dev/null @@ -1,4131 +0,0 @@ -# M7 — Flutter mobile foundation + first slice — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Land a Flutter mobile client (iOS + Android) that authenticates against an existing Minstrel server, browses the library, plays music with background audio + lock-screen controls, and surfaces likes everywhere they appear in browse — visually consistent with the SvelteKit SPA via shared design tokens. - -**Architecture:** New top-level `flutter_client/` directory contains a fresh Flutter app using **Riverpod** (state) + **dio** (HTTP, with Bearer-auth interceptor) + **just_audio** wrapped by **audio_service** (background audio + media-session). FabledSword tokens move to a JSON source-of-truth at `web/src/lib/styles/tokens.json`; both web (via a `tokens-to-css.js` generator + Tailwind) and Flutter (via `tool/gen_tokens.dart`) consume the same file. Auth uses Bearer tokens stored in `flutter_secure_storage` (server already supports both cookie and Bearer via `internal/auth/session.go`). Routing uses `go_router` with a shell route so the mini PlayerBar persists across navigation. One backend touch-point: `/healthz` gets a `min_client_version` field so old clients can refuse to operate. - -**Tech Stack:** Flutter 3.24+ · Dart 3.5+ · Riverpod 2.x · dio 5.x · just_audio + audio_service · go_router · flutter_secure_storage · flutter_svg · google_fonts (bundled) · Material 3 · Forgejo Actions for CI · Go 1.23 backend (touch-points only). - -**Spec:** [`docs/superpowers/specs/2026-05-02-flutter-mobile-foundation-design.md`](../specs/2026-05-02-flutter-mobile-foundation-design.md). Read it first — every architectural decision is explained there. - -**Memory dependencies:** `project_design_system.md` (FabledSword tokens), `project_no_github.md` (Forgejo CI under `.forgejo/workflows/`, never `gh` CLI), `project_forgejo_registry_auth.md` (CI uses `REGISTRY_TOKEN` secret), `project_git_workflow.md` (commit on `dev`; PR to `main` separately), `project_web_frontend.md` (web SPA stays primary; Flutter is a sibling client). - ---- - -## File map - -### Backend — modify (single touch-point) - -- `internal/server/server.go` — `handleHealthz` returns `min_client_version` -- `internal/server/server_test.go` — extend healthz test -- `internal/server/version.go` (create) — `MinClientVersion` constant - -### Web — create (token + error-copy source-of-truth refactor) - -- `web/src/lib/styles/tokens.json` — JSON source of truth for FabledSword tokens -- `web/scripts/tokens-to-css.js` — generates `tokens.generated.css` from `tokens.json` -- `web/src/lib/styles/error-copy.json` — error-code → user copy table (existing data, JSON form) - -### Web — modify - -- `web/src/lib/styles/fabledsword-tokens.css` → replaced by `tokens.generated.css`; old file deleted -- `web/src/app.css` — import `tokens.generated.css` instead of `fabledsword-tokens.css` -- `web/src/lib/api/error-copy.ts` — read from `error-copy.json` instead of inline literal -- `web/package.json` — add `tokens` script invoking `node scripts/tokens-to-css.js` -- `web/vite.config.ts` — run `tokens` script as a Vite plugin pre-build hook - -### Flutter — create (new project at `flutter_client/`) - -``` -flutter_client/ -├── pubspec.yaml -├── analysis_options.yaml -├── README.md -├── .gitignore -├── shared/ -│ └── fabledsword.tokens.json # synced from web/src/lib/styles/tokens.json -├── tool/ -│ ├── gen_tokens.dart # tokens.json → lib/theme/tokens.dart -│ └── sync_shared.sh # copies web/.../tokens.json + error-copy.json + svgs -├── assets/ -│ ├── svg/album-fallback.svg # synced from web/static/placeholders/ -│ └── error-copy.json # synced from web/src/lib/styles/error-copy.json -├── lib/ -│ ├── main.dart -│ ├── app.dart -│ ├── theme/ -│ │ ├── tokens.dart # generated (in .gitignore? no — committed for CI determinism) -│ │ ├── theme_extension.dart -│ │ └── theme_data.dart -│ ├── api/ -│ │ ├── client.dart -│ │ ├── errors.dart -│ │ ├── error_copy.dart # loads assets/error-copy.json -│ │ └── endpoints/ -│ │ ├── auth.dart -│ │ ├── library.dart -│ │ ├── likes.dart -│ │ └── health.dart -│ ├── models/ -│ │ ├── user.dart -│ │ ├── artist.dart -│ │ ├── album.dart -│ │ ├── track.dart -│ │ └── home_data.dart -│ ├── auth/ -│ │ ├── server_url_screen.dart -│ │ ├── login_screen.dart -│ │ └── auth_provider.dart -│ ├── library/ -│ │ ├── home_screen.dart -│ │ ├── artist_detail_screen.dart -│ │ ├── album_detail_screen.dart -│ │ ├── library_providers.dart -│ │ └── widgets/ -│ │ ├── horizontal_scroll_row.dart -│ │ ├── artist_card.dart -│ │ ├── album_card.dart -│ │ └── track_row.dart -│ ├── likes/ -│ │ ├── like_button.dart -│ │ └── likes_provider.dart -│ ├── player/ -│ │ ├── audio_handler.dart -│ │ ├── player_provider.dart -│ │ ├── player_bar.dart -│ │ └── now_playing_screen.dart -│ └── shared/ -│ ├── widgets/ -│ │ ├── connection_error_banner.dart -│ │ └── version_gate.dart -│ └── routing.dart -└── test/ - ├── api/ - │ ├── client_test.dart - │ ├── errors_test.dart - │ └── endpoints/ - │ ├── auth_test.dart - │ └── library_test.dart - ├── auth/ - │ ├── auth_provider_test.dart - │ └── login_screen_test.dart - ├── library/ - │ ├── home_screen_test.dart - │ ├── artist_detail_screen_test.dart - │ └── album_detail_screen_test.dart - ├── likes/ - │ └── like_button_test.dart - ├── player/ - │ └── player_provider_test.dart - └── theme/ - └── theme_extension_test.dart -``` - -### CI — create - -- `.forgejo/workflows/flutter.yml` — analyze + test + build APK on push; release APK on tag - ---- - -## Task list - -### Task 1 — Extract FabledSword tokens to JSON source-of-truth - -**Files:** -- Create: `web/src/lib/styles/tokens.json` -- Create: `web/scripts/tokens-to-css.js` -- Create: `web/src/lib/styles/tokens.generated.css` (output, gitignored) -- Modify: `web/.gitignore` — add `src/lib/styles/tokens.generated.css` -- Modify: `web/package.json` — add `tokens` script -- Modify: `web/vite.config.ts` — pre-build hook -- Modify: `web/src/app.css` — import `tokens.generated.css` -- Delete: `web/src/lib/styles/fabledsword-tokens.css` - -- [ ] **Step 1.1: Create `web/src/lib/styles/tokens.json`** - -```json -{ - "colors": { - "obsidian": "#14171A", - "iron": "#1E2228", - "slate": "#2C313A", - "pewter": "#3F4651", - "parchment": "#E8E4D8", - "vellum": "#C2BFB4", - "ash": "#9C9A92", - "moss": "#4A5D3F", - "bronze": "#8B7355", - "oxblood": "#6B2118", - "warning": "#8B6F1E", - "error": "#C04A1F", - "info": "#3D5A6E", - "accent": "#4A6B5C" - }, - "radii": { - "sm": "4px", - "md": "8px", - "lg": "12px", - "xl": "16px" - }, - "fonts": { - "display": "Fraunces", - "body": "Inter", - "mono": "JetBrains Mono" - }, - "fontStacks": { - "display": "'Fraunces', Georgia, serif", - "body": "'Inter', system-ui, sans-serif", - "mono": "'JetBrains Mono', ui-monospace, monospace" - } -} -``` - -- [ ] **Step 1.2: Create `web/scripts/tokens-to-css.js`** - -```js -#!/usr/bin/env node -// Reads ../src/lib/styles/tokens.json and emits tokens.generated.css. -// Single source of truth for FabledSword tokens shared with the Flutter -// client. Keep output deterministic — Tailwind reads tokens.json directly, -// the .css emission is for raw CSS consumers. -import { readFileSync, writeFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, resolve } from 'node:path'; - -const here = dirname(fileURLToPath(import.meta.url)); -const tokensPath = resolve(here, '../src/lib/styles/tokens.json'); -const outPath = resolve(here, '../src/lib/styles/tokens.generated.css'); -const tokens = JSON.parse(readFileSync(tokensPath, 'utf8')); - -const lines = ['/* GENERATED — do not edit. Source: tokens.json */', ':root {']; -for (const [name, value] of Object.entries(tokens.colors)) { - lines.push(` --fs-${name}: ${value};`); -} -for (const [name, value] of Object.entries(tokens.radii)) { - lines.push(` --fs-radius-${name}: ${value};`); -} -lines.push(` --fs-font-display: ${tokens.fontStacks.display};`); -lines.push(` --fs-font-body: ${tokens.fontStacks.body};`); -lines.push(` --fs-font-mono: ${tokens.fontStacks.mono};`); -lines.push('}', '[data-fs-app="minstrel"] {', ` --fs-accent: ${tokens.colors.accent};`, '}', ''); - -writeFileSync(outPath, lines.join('\n')); -console.log(`wrote ${outPath}`); -``` - -- [ ] **Step 1.3: Add `tokens` script + Vite plugin hook** - -In `web/package.json` under `"scripts"`: - -```json -"tokens": "node scripts/tokens-to-css.js", -``` - -In `web/vite.config.ts`, add a Vite plugin that runs the script before build/dev: - -```ts -import { execSync } from 'node:child_process'; - -const tokensPlugin = { - name: 'minstrel-tokens', - buildStart() { - execSync('node scripts/tokens-to-css.js', { stdio: 'inherit' }); - } -}; - -// In `defineConfig({...})`, add to plugins array: -plugins: [tokensPlugin, sveltekit()], -``` - -- [ ] **Step 1.4: Update `web/src/app.css` to import generated CSS** - -Replace `@import './lib/styles/fabledsword-tokens.css';` with: - -```css -@import './lib/styles/tokens.generated.css'; -``` - -- [ ] **Step 1.5: Add `.gitignore` entry** - -Append to `web/.gitignore`: - -``` -src/lib/styles/tokens.generated.css -``` - -- [ ] **Step 1.6: Delete `fabledsword-tokens.css`** - -```bash -rm web/src/lib/styles/fabledsword-tokens.css -``` - -- [ ] **Step 1.7: Run dev to verify tokens generate and styles still load** - -Run: `cd web && npm run tokens && npm run check` -Expected: `tokens.generated.css` written; type-check passes. - -- [ ] **Step 1.8: Commit** - -```bash -git add web/src/lib/styles/tokens.json web/scripts/tokens-to-css.js web/src/app.css web/package.json web/vite.config.ts web/.gitignore -git rm web/src/lib/styles/fabledsword-tokens.css -git commit -F - <<'EOF' -refactor(web): extract design tokens to tokens.json source of truth - -The Flutter client (#356) needs to consume the same FabledSword tokens -the web SPA uses. Move the data into a structured tokens.json file and -generate tokens.generated.css from it on every Vite build. The Flutter -side gets its own generator pointed at the same JSON. - -No visual change — generated CSS matches the previous hand-written file. -EOF -``` - ---- - -### Task 2 — Extract error-copy table to JSON source-of-truth - -**Files:** -- Create: `web/src/lib/styles/error-copy.json` -- Modify: `web/src/lib/api/error-copy.ts` - -**Why:** The Flutter client needs the same error-code → user-facing-copy mapping. Move it to JSON; both clients consume the same table. - -- [ ] **Step 2.1: Inspect existing inline copy table** - -Run: `cat web/src/lib/api/error-copy.ts` -Expected: a `Record` with codes like `invalid_credentials`, `lidarr_unreachable`, `connection_refused`, etc. - -- [ ] **Step 2.2: Create `web/src/lib/styles/error-copy.json`** - -Copy the literal object from `error-copy.ts` into JSON form: - -```json -{ - "invalid_credentials": "That username and password combination didn't match.", - "unauthenticated": "Your session has ended. Please sign in again.", - "connection_refused": "Couldn't reach the server. Check the URL and try again.", - "lidarr_unreachable": "Lidarr is unreachable from the server.", - "lidarr_unauthorized": "Lidarr rejected the server's API key.", - "defaults_incomplete": "An admin needs to finish the Lidarr defaults before this can run.", - "not_found": "We couldn't find what you were looking for.", - "server_error": "The server hit an unexpected error.", - "version_too_old": "This client is too old to talk to this server. Please update.", - "unknown": "Something went wrong." -} -``` - -(Use the actual codes/messages from `error-copy.ts`; the list above is representative — read the file and copy verbatim.) - -- [ ] **Step 2.3: Refactor `error-copy.ts` to read JSON** - -```ts -import errorCopyJson from '../styles/error-copy.json'; - -export const ERROR_COPY: Readonly> = errorCopyJson; - -export function copyForCode(code: string): string { - return ERROR_COPY[code] ?? ERROR_COPY.unknown ?? 'Something went wrong.'; -} -``` - -- [ ] **Step 2.4: Run check + tests** - -Run: `cd web && npm run check && npm test -- --run error-copy` -Expected: pass. - -- [ ] **Step 2.5: Commit** - -```bash -git add web/src/lib/styles/error-copy.json web/src/lib/api/error-copy.ts -git commit -F - <<'EOF' -refactor(web): extract error-copy table to JSON - -Lets the Flutter client (#356) consume the same code → user-facing-copy -mapping by syncing the JSON file. Behavior on the web side is unchanged. -EOF -``` - ---- - -### Task 3 — Server: add `min_client_version` to `/healthz` - -**Files:** -- Create: `internal/server/version.go` -- Modify: `internal/server/server.go` (`handleHealthz`) -- Modify: `internal/server/server_test.go` - -- [ ] **Step 3.1: Write the failing test** - -Add to `internal/server/server_test.go`: - -```go -func TestHealthz_IncludesMinClientVersion(t *testing.T) { - t.Parallel() - s := &Server{} - r := chi.NewRouter() - r.Get("/healthz", s.handleHealthz) - ts := httptest.NewServer(r) - defer ts.Close() - - resp, err := http.Get(ts.URL + "/healthz") - if err != nil { - t.Fatalf("GET /healthz: %v", err) - } - defer resp.Body.Close() - - var body map[string]string - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - t.Fatalf("decode: %v", err) - } - if body["status"] != "ok" { - t.Errorf("status: got %q want \"ok\"", body["status"]) - } - if body["min_client_version"] == "" { - t.Error("min_client_version is empty; clients can't enforce pairing") - } -} -``` - -- [ ] **Step 3.2: Run test to verify it fails** - -Run: `go test ./internal/server/ -run TestHealthz_IncludesMinClientVersion -v` -Expected: FAIL with `min_client_version is empty`. - -- [ ] **Step 3.3: Create `internal/server/version.go`** - -```go -package server - -// MinClientVersion is the lowest mobile-client semver that this server -// accepts. Bump it when a server change requires a paired client update; -// older clients see version_too_old at /healthz and refuse to operate. -const MinClientVersion = "0.1.0" -``` - -- [ ] **Step 3.4: Update `handleHealthz` in `internal/server/server.go`** - -Replace the existing handler (around line 103): - -```go -func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]string{ - "status": "ok", - "min_client_version": MinClientVersion, - }) -} -``` - -- [ ] **Step 3.5: Run test to verify it passes** - -Run: `go test ./internal/server/ -run TestHealthz -v` -Expected: PASS. - -- [ ] **Step 3.6: Commit** - -```bash -git add internal/server/version.go internal/server/server.go internal/server/server_test.go -git commit -F - <<'EOF' -feat(server): /healthz returns min_client_version - -Lets the M7 Flutter client refuse to operate against an incompatible -server (e.g., new endpoints the client depends on, breaking schema -shifts). The version constant is bumped on each release where the -client/server contract changes; old clients show the user a blocking -"update required" modal pointed at the latest APK / TestFlight build. -EOF -``` - ---- - -### Task 4 — Flutter project scaffold + dependencies - -**Files:** -- Create: `flutter_client/pubspec.yaml` -- Create: `flutter_client/analysis_options.yaml` -- Create: `flutter_client/README.md` -- Create: `flutter_client/.gitignore` -- Create: `flutter_client/lib/main.dart` (placeholder) -- Create: `flutter_client/lib/app.dart` (placeholder) -- Create: `flutter_client/test/smoke_test.dart` -- Create: `flutter_client/android/`, `flutter_client/ios/` (via `flutter create`) - -- [ ] **Step 4.1: Run `flutter create`** - -Run from repo root: - -```bash -flutter create \ - --platforms=android,ios \ - --org com.fabledsword \ - --project-name minstrel \ - --description "Minstrel mobile client" \ - flutter_client -``` - -Expected: `flutter_client/` populated with iOS + Android shells. - -- [ ] **Step 4.2: Replace generated `pubspec.yaml`** - -Overwrite `flutter_client/pubspec.yaml`: - -```yaml -name: minstrel -description: Minstrel mobile client -publish_to: 'none' -version: 0.1.0+1 - -environment: - sdk: '>=3.5.0 <4.0.0' - flutter: '>=3.24.0' - -dependencies: - flutter: - sdk: flutter - flutter_riverpod: ^2.5.1 - dio: ^5.7.0 - just_audio: ^0.9.40 - audio_service: ^0.18.15 - flutter_secure_storage: ^9.2.2 - go_router: ^14.6.1 - flutter_svg: ^2.0.16 - google_fonts: ^6.2.1 - package_info_plus: ^8.0.0 - pub_semver: ^2.1.4 - cupertino_icons: ^1.0.8 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^4.0.0 - mocktail: ^1.0.4 - -flutter: - uses-material-design: true - assets: - - assets/svg/ - - assets/error-copy.json - - shared/fabledsword.tokens.json -``` - -- [ ] **Step 4.3: Replace generated `analysis_options.yaml`** - -```yaml -include: package:flutter_lints/flutter.yaml - -analyzer: - exclude: - - build/** - - lib/theme/tokens.dart # generated; allow magic numbers - -linter: - rules: - avoid_print: true - prefer_const_constructors: true - sort_pub_dependencies: false -``` - -- [ ] **Step 4.4: Replace `lib/main.dart`** - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import 'app.dart'; - -void main() { - runApp(const ProviderScope(child: MinstrelApp())); -} -``` - -- [ ] **Step 4.5: Replace `lib/app.dart` with stub** - -```dart -import 'package:flutter/material.dart'; - -class MinstrelApp extends StatelessWidget { - const MinstrelApp({super.key}); - - @override - Widget build(BuildContext context) { - return const MaterialApp( - title: 'Minstrel', - home: Scaffold( - body: Center(child: Text('Minstrel — scaffold')), - ), - ); - } -} -``` - -- [ ] **Step 4.6: Write smoke test** - -Create `flutter_client/test/smoke_test.dart`: - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/app.dart'; - -void main() { - testWidgets('app boots and renders scaffold marker', (tester) async { - await tester.pumpWidget(const MaterialApp(home: MinstrelApp())); - expect(find.text('Minstrel — scaffold'), findsOneWidget); - }); -} -``` - -- [ ] **Step 4.7: Run tests + analyzer** - -```bash -cd flutter_client -flutter pub get -flutter analyze -flutter test -``` - -Expected: analyze clean, smoke test passes. - -- [ ] **Step 4.8: Update root `.gitignore`** - -Append to repo root `.gitignore`: - -``` -# Flutter -flutter_client/.dart_tool/ -flutter_client/.flutter-plugins -flutter_client/.flutter-plugins-dependencies -flutter_client/build/ -flutter_client/.idea/ -flutter_client/ios/Podfile.lock -flutter_client/ios/Pods/ -flutter_client/android/.gradle/ -flutter_client/android/app/build/ -flutter_client/android/local.properties -flutter_client/android/key.properties -flutter_client/*.iml -``` - -- [ ] **Step 4.9: Commit** - -```bash -git add flutter_client/ .gitignore -git commit -F - <<'EOF' -feat(flutter): project scaffold with pubspec and smoke test - -iOS + Android targets only; desktop disabled (per spec, Tauri-wrapped -SvelteKit handles desktop in v1.1). Riverpod + dio + just_audio + -audio_service + go_router pinned in pubspec. Smoke test confirms the -ProviderScope wiring boots a Material app. -EOF -``` - ---- - -### Task 5 — Token sync + Dart token generator - -**Files:** -- Create: `flutter_client/shared/fabledsword.tokens.json` (synced) -- Create: `flutter_client/tool/sync_shared.sh` -- Create: `flutter_client/tool/gen_tokens.dart` -- Create: `flutter_client/lib/theme/tokens.dart` (generated, committed) - -- [ ] **Step 5.1: Create `flutter_client/tool/sync_shared.sh`** - -```bash -#!/usr/bin/env bash -# Copies shared assets from web/ into flutter_client/. Run before -# `flutter build` and as part of CI. Idempotent. -set -euo pipefail - -cd "$(dirname "$0")/.." - -cp ../web/src/lib/styles/tokens.json shared/fabledsword.tokens.json -cp ../web/src/lib/styles/error-copy.json assets/error-copy.json -mkdir -p assets/svg -cp ../web/static/placeholders/album-fallback.svg assets/svg/album-fallback.svg - -echo "shared assets synced from web/" -``` - -```bash -chmod +x flutter_client/tool/sync_shared.sh -``` - -- [ ] **Step 5.2: Run sync to populate `shared/` and `assets/`** - -```bash -flutter_client/tool/sync_shared.sh -``` - -Expected: `flutter_client/shared/fabledsword.tokens.json`, `flutter_client/assets/error-copy.json`, `flutter_client/assets/svg/album-fallback.svg` all created. - -- [ ] **Step 5.3: Create `flutter_client/tool/gen_tokens.dart`** - -```dart -// Reads shared/fabledsword.tokens.json and emits lib/theme/tokens.dart. -// Run via `dart run tool/gen_tokens.dart`. The generated file is -// committed (CI validates it matches the JSON to catch drift). -import 'dart:convert'; -import 'dart:io'; - -void main() { - final jsonFile = File('shared/fabledsword.tokens.json'); - final tokens = jsonDecode(jsonFile.readAsStringSync()) as Map; - final colors = (tokens['colors'] as Map).cast(); - final radii = (tokens['radii'] as Map).cast(); - final fonts = (tokens['fonts'] as Map).cast(); - - final out = StringBuffer() - ..writeln('// GENERATED — do not edit. Source: shared/fabledsword.tokens.json') - ..writeln('// Run `dart run tool/gen_tokens.dart` to regenerate.') - ..writeln("import 'package:flutter/material.dart';") - ..writeln() - ..writeln('class FabledSwordTokens {'); - - colors.forEach((name, hex) { - final value = hex.replaceFirst('#', '0xFF'); - out.writeln(' static const Color ${_camel(name)} = Color($value);'); - }); - - radii.forEach((name, px) { - final v = px.replaceAll('px', ''); - out.writeln(' static const double radius${_pascal(name)} = $v;'); - }); - - fonts.forEach((name, family) { - out.writeln(' static const String font${_pascal(name)} = ${jsonEncode(family)};'); - }); - - out.writeln('}'); - - File('lib/theme/tokens.dart').writeAsStringSync(out.toString()); - stdout.writeln('wrote lib/theme/tokens.dart'); -} - -String _camel(String s) => s; // tokens are already lowerCamel-friendly -String _pascal(String s) => s[0].toUpperCase() + s.substring(1); -``` - -- [ ] **Step 5.4: Run generator** - -```bash -cd flutter_client -dart run tool/gen_tokens.dart -``` - -Expected: `lib/theme/tokens.dart` written. - -- [ ] **Step 5.5: Verify generated file shape** - -Run: `head -20 flutter_client/lib/theme/tokens.dart` -Expected: contains `static const Color accent = Color(0xFF4A6B5C);` and friends. - -- [ ] **Step 5.6: Add CI drift check (deferred to Task 26 CI workflow but recorded here)** - -The CI step `dart run tool/gen_tokens.dart && git diff --exit-code lib/theme/tokens.dart` will fail if the generator output drifts from the committed copy. Recorded for the workflow task. - -- [ ] **Step 5.7: Commit** - -```bash -git add flutter_client/shared/ flutter_client/assets/ flutter_client/tool/ flutter_client/lib/theme/tokens.dart -git commit -F - <<'EOF' -feat(flutter): token + asset sync from web; gen_tokens.dart - -shared/fabledsword.tokens.json mirrors web/src/lib/styles/tokens.json; -assets/error-copy.json mirrors the web's error-code copy table; -assets/svg/album-fallback.svg mirrors the web placeholder. Together -these give Flutter the same visual + copy starting point as the SPA. -gen_tokens.dart emits lib/theme/tokens.dart from the JSON. -EOF -``` - ---- - -### Task 6 — Theme: ThemeExtension + ThemeData factory - -**Files:** -- Create: `flutter_client/lib/theme/theme_extension.dart` -- Create: `flutter_client/lib/theme/theme_data.dart` -- Create: `flutter_client/test/theme/theme_extension_test.dart` -- Modify: `flutter_client/lib/app.dart` — wire ThemeData - -- [ ] **Step 6.1: Write the failing test** - -`flutter_client/test/theme/theme_extension_test.dart`: - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/theme/theme_data.dart'; -import 'package:minstrel/theme/theme_extension.dart'; -import 'package:minstrel/theme/tokens.dart'; - -void main() { - testWidgets('Theme exposes FabledSwordTheme with expected accent', (tester) async { - late FabledSwordTheme captured; - await tester.pumpWidget( - MaterialApp( - theme: buildThemeData(), - home: Builder( - builder: (ctx) { - captured = Theme.of(ctx).extension()!; - return const SizedBox.shrink(); - }, - ), - ), - ); - expect(captured.accent, FabledSwordTokens.accent); - expect(captured.parchment, FabledSwordTokens.parchment); - expect(captured.body.fontFamily, FabledSwordTokens.fontBody); - }); -} -``` - -- [ ] **Step 6.2: Run test to verify it fails** - -Run: `cd flutter_client && flutter test test/theme/` -Expected: FAIL with import errors. - -- [ ] **Step 6.3: Create `lib/theme/theme_extension.dart`** - -```dart -import 'package:flutter/material.dart'; - -import 'tokens.dart'; - -class FabledSwordTheme extends ThemeExtension { - const FabledSwordTheme({ - required this.accent, - required this.obsidian, - required this.iron, - required this.slate, - required this.pewter, - required this.parchment, - required this.vellum, - required this.ash, - required this.moss, - required this.bronze, - required this.oxblood, - required this.warning, - required this.error, - required this.info, - required this.display, - required this.body, - required this.mono, - }); - - final Color accent; - final Color obsidian, iron, slate, pewter; - final Color parchment, vellum, ash; - final Color moss, bronze, oxblood; - final Color warning, error, info; - final TextStyle display, body, mono; - - static FabledSwordTheme fromTokens() => FabledSwordTheme( - accent: FabledSwordTokens.accent, - obsidian: FabledSwordTokens.obsidian, - iron: FabledSwordTokens.iron, - slate: FabledSwordTokens.slate, - pewter: FabledSwordTokens.pewter, - parchment: FabledSwordTokens.parchment, - vellum: FabledSwordTokens.vellum, - ash: FabledSwordTokens.ash, - moss: FabledSwordTokens.moss, - bronze: FabledSwordTokens.bronze, - oxblood: FabledSwordTokens.oxblood, - warning: FabledSwordTokens.warning, - error: FabledSwordTokens.error, - info: FabledSwordTokens.info, - display: TextStyle(fontFamily: FabledSwordTokens.fontDisplay), - body: TextStyle(fontFamily: FabledSwordTokens.fontBody), - mono: TextStyle(fontFamily: FabledSwordTokens.fontMono), - ); - - @override - FabledSwordTheme copyWith({ - Color? accent, - }) => - FabledSwordTheme( - accent: accent ?? this.accent, - obsidian: obsidian, iron: iron, slate: slate, pewter: pewter, - parchment: parchment, vellum: vellum, ash: ash, - moss: moss, bronze: bronze, oxblood: oxblood, - warning: warning, error: error, info: info, - display: display, body: body, mono: mono, - ); - - @override - FabledSwordTheme lerp(ThemeExtension? other, double t) => this; -} -``` - -- [ ] **Step 6.4: Create `lib/theme/theme_data.dart`** - -```dart -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; - -import 'theme_extension.dart'; -import 'tokens.dart'; - -ThemeData buildThemeData() { - final fs = FabledSwordTheme.fromTokens(); - final colorScheme = ColorScheme.dark( - surface: fs.iron, - onSurface: fs.parchment, - primary: fs.accent, - onPrimary: fs.parchment, - secondary: fs.moss, - error: fs.error, - ); - - return ThemeData( - useMaterial3: true, - colorScheme: colorScheme, - scaffoldBackgroundColor: fs.obsidian, - textTheme: GoogleFonts.interTextTheme().apply( - bodyColor: fs.parchment, - displayColor: fs.parchment, - ), - fontFamily: FabledSwordTokens.fontBody, - extensions: >[fs], - ); -} -``` - -- [ ] **Step 6.5: Wire ThemeData into the app** - -In `lib/app.dart`, replace the body: - -```dart -import 'package:flutter/material.dart'; - -import 'theme/theme_data.dart'; - -class MinstrelApp extends StatelessWidget { - const MinstrelApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Minstrel', - theme: buildThemeData(), - home: const Scaffold( - body: Center(child: Text('Minstrel — scaffold')), - ), - ); - } -} -``` - -- [ ] **Step 6.6: Run tests** - -Run: `cd flutter_client && flutter test` -Expected: PASS. - -- [ ] **Step 6.7: Commit** - -```bash -git add flutter_client/lib/theme/ flutter_client/test/theme/ flutter_client/lib/app.dart -git commit -F - <<'EOF' -feat(flutter): FabledSwordTheme ThemeExtension + Material 3 ThemeData - -Widgets read tokens via Theme.of(ctx).extension(). -Material 3 ColorScheme bound to FabledSword tokens. Fonts come from -google_fonts (bundled at build via the package's runtime cache; will -switch to bundled .ttf if airplane-mode-from-cold-launch becomes a -goal — not in this slice). -EOF -``` - ---- - -### Task 7 — API: dio client + auth interceptor + ApiError - -**Files:** -- Create: `flutter_client/lib/api/client.dart` -- Create: `flutter_client/lib/api/errors.dart` -- Create: `flutter_client/lib/api/error_copy.dart` -- Create: `flutter_client/test/api/client_test.dart` -- Create: `flutter_client/test/api/errors_test.dart` - -- [ ] **Step 7.1: Write failing test for ApiError envelope handling** - -`flutter_client/test/api/errors_test.dart`: - -```dart -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/api/errors.dart'; - -void main() { - RequestOptions opts() => RequestOptions(path: '/x'); - - group('ApiError.fromDio', () { - test('flat envelope: {error: "code"}', () { - final d = DioException( - requestOptions: opts(), - response: Response( - requestOptions: opts(), - statusCode: 503, - data: {'error': 'lidarr_unreachable'}, - ), - ); - final e = ApiError.fromDio(d); - expect(e.code, 'lidarr_unreachable'); - expect(e.status, 503); - }); - - test('nested envelope: {error: {code, message}}', () { - final d = DioException( - requestOptions: opts(), - response: Response( - requestOptions: opts(), - statusCode: 400, - data: {'error': {'code': 'bad_request', 'message': 'invalid JSON body'}}, - ), - ); - final e = ApiError.fromDio(d); - expect(e.code, 'bad_request'); - expect(e.message, 'invalid JSON body'); - }); - - test('connection refused: code is connection_refused', () { - final d = DioException( - requestOptions: opts(), - type: DioExceptionType.connectionError, - error: 'Connection refused', - ); - final e = ApiError.fromDio(d); - expect(e.code, 'connection_refused'); - }); - }); -} -``` - -- [ ] **Step 7.2: Run test to verify failure** - -Run: `cd flutter_client && flutter test test/api/errors_test.dart` -Expected: FAIL with import errors. - -- [ ] **Step 7.3: Create `lib/api/errors.dart`** - -```dart -import 'package:dio/dio.dart'; - -class ApiError implements Exception { - ApiError({required this.code, required this.message, required this.status}); - - final String code; - final String message; - final int status; - - factory ApiError.fromDio(DioException e) { - if (e.type == DioExceptionType.connectionError || - e.type == DioExceptionType.connectionTimeout) { - return ApiError(code: 'connection_refused', message: 'Connection refused', status: 0); - } - final response = e.response; - final data = response?.data; - if (data is Map) { - final field = data['error']; - if (field is String) { - return ApiError(code: field, message: field, status: response?.statusCode ?? 0); - } - if (field is Map) { - final code = field['code']?.toString() ?? 'unknown'; - final msg = field['message']?.toString() ?? code; - return ApiError(code: code, message: msg, status: response?.statusCode ?? 0); - } - } - final status = response?.statusCode ?? 0; - if (status == 401) return ApiError(code: 'unauthenticated', message: 'unauthenticated', status: status); - if (status == 404) return ApiError(code: 'not_found', message: 'not_found', status: status); - return ApiError(code: 'unknown', message: e.message ?? 'unknown', status: status); - } -} -``` - -- [ ] **Step 7.4: Create `lib/api/error_copy.dart`** - -```dart -import 'dart:convert'; -import 'package:flutter/services.dart' show rootBundle; - -class ErrorCopy { - ErrorCopy._(this._table); - final Map _table; - - static ErrorCopy? _instance; - - static Future load() async { - if (_instance != null) return _instance!; - final raw = await rootBundle.loadString('assets/error-copy.json'); - final m = (jsonDecode(raw) as Map).cast().map( - (k, v) => MapEntry(k, v.toString()), - ); - _instance = ErrorCopy._(m); - return _instance!; - } - - String forCode(String code) => - _table[code] ?? _table['unknown'] ?? 'Something went wrong.'; -} -``` - -- [ ] **Step 7.5: Write failing test for client** - -`flutter_client/test/api/client_test.dart`: - -```dart -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/api/client.dart'; - -void main() { - test('client attaches Bearer token from token resolver', () async { - final d = ApiClient.buildDio( - baseUrl: 'http://example/', - tokenResolver: () async => 'tok-123', - ); - Map? captured; - d.options.headers['X-Test'] = 'baseline'; - d.interceptors.add( - InterceptorsWrapper(onRequest: (opts, h) { - captured = Map.from(opts.headers); - h.reject(DioException(requestOptions: opts, type: DioExceptionType.cancel)); - }), - ); - try { - await d.get('/whatever'); - } catch (_) {} - expect(captured?['Authorization'], 'Bearer tok-123'); - }); - - test('client omits Authorization when resolver returns null', () async { - final d = ApiClient.buildDio( - baseUrl: 'http://example/', - tokenResolver: () async => null, - ); - Map? captured; - d.interceptors.add( - InterceptorsWrapper(onRequest: (opts, h) { - captured = Map.from(opts.headers); - h.reject(DioException(requestOptions: opts, type: DioExceptionType.cancel)); - }), - ); - try { - await d.get('/whatever'); - } catch (_) {} - expect(captured?.containsKey('Authorization'), isFalse); - }); -} -``` - -- [ ] **Step 7.6: Create `lib/api/client.dart`** - -```dart -import 'package:dio/dio.dart'; - -typedef TokenResolver = Future Function(); - -class ApiClient { - /// Builds a dio instance pinned to [baseUrl] with a Bearer-auth - /// interceptor that pulls the current token from [tokenResolver] - /// on every request. Server already supports cookie OR bearer - /// (internal/auth/session.go); we use bearer to skip cookie-jar. - static Dio buildDio({ - required String baseUrl, - required TokenResolver tokenResolver, - }) { - final d = Dio(BaseOptions( - baseUrl: baseUrl, - connectTimeout: const Duration(seconds: 8), - receiveTimeout: const Duration(seconds: 30), - contentType: Headers.jsonContentType, - responseType: ResponseType.json, - )); - d.interceptors.add(InterceptorsWrapper(onRequest: (opts, handler) async { - final t = await tokenResolver(); - if (t != null && t.isNotEmpty) { - opts.headers['Authorization'] = 'Bearer $t'; - } - handler.next(opts); - })); - return d; - } -} -``` - -- [ ] **Step 7.7: Run all api tests** - -```bash -cd flutter_client && flutter test test/api/ -``` - -Expected: PASS. - -- [ ] **Step 7.8: Commit** - -```bash -git add flutter_client/lib/api/ flutter_client/test/api/ -git commit -F - <<'EOF' -feat(flutter/api): dio client with Bearer interceptor + ApiError - -ApiError.fromDio handles both server envelope shapes: {"error":"code"} -(admin endpoints) and {"error":{"code","message"}} (most others). Same -dual-shape problem the web apiFetch already solves. Token comes from a -resolver function so the auth provider can swap implementations -without touching the client. -EOF -``` - ---- - -### Task 8 — Models (User, Artist, Album, Track, HomeData) - -**Files:** -- Create: `flutter_client/lib/models/user.dart` -- Create: `flutter_client/lib/models/artist.dart` -- Create: `flutter_client/lib/models/album.dart` -- Create: `flutter_client/lib/models/track.dart` -- Create: `flutter_client/lib/models/home_data.dart` -- Create: `flutter_client/test/models/models_test.dart` - -**Why:** DTOs mirror `web/src/lib/api/types.ts`. Hand-written for slice 1 (codegen deferred per spec §10). - -- [ ] **Step 8.1: Read the web types as the contract** - -Run: `cat web/src/lib/api/types.ts | head -120` -Expected: types like `User`, `ArtistRef`, `AlbumRef`, `TrackRef`, `HomePayload`. The Flutter models mirror these field names exactly. - -- [ ] **Step 8.2: Create `lib/models/user.dart`** - -```dart -class User { - const User({required this.id, required this.username, required this.isAdmin}); - - final String id; - final String username; - final bool isAdmin; - - factory User.fromJson(Map j) => User( - id: j['id'] as String, - username: j['username'] as String, - isAdmin: j['is_admin'] as bool? ?? false, - ); -} -``` - -- [ ] **Step 8.3: Create `lib/models/artist.dart`** - -```dart -class ArtistRef { - const ArtistRef({ - required this.id, - required this.name, - this.coverArtUrl, - }); - - final String id; - final String name; - final String? coverArtUrl; - - factory ArtistRef.fromJson(Map j) => ArtistRef( - id: j['id'] as String, - name: j['name'] as String, - coverArtUrl: j['cover_art_url'] as String?, - ); -} -``` - -- [ ] **Step 8.4: Create `lib/models/album.dart`** - -```dart -class AlbumRef { - const AlbumRef({ - required this.id, - required this.title, - required this.artistId, - required this.artistName, - this.coverArtUrl, - }); - - final String id; - final String title; - final String artistId; - final String artistName; - final String? coverArtUrl; - - factory AlbumRef.fromJson(Map j) => AlbumRef( - id: j['id'] as String, - title: j['title'] as String, - artistId: j['artist_id'] as String, - artistName: j['artist_name'] as String? ?? '', - coverArtUrl: j['cover_art_url'] as String?, - ); -} -``` - -- [ ] **Step 8.5: Create `lib/models/track.dart`** - -```dart -class TrackRef { - const TrackRef({ - required this.id, - required this.title, - required this.albumId, - required this.albumTitle, - required this.artistId, - required this.artistName, - required this.durationMs, - this.trackNumber, - }); - - final String id; - final String title; - final String albumId; - final String albumTitle; - final String artistId; - final String artistName; - final int durationMs; - final int? trackNumber; - - factory TrackRef.fromJson(Map j) => TrackRef( - id: j['id'] as String, - title: j['title'] as String, - albumId: j['album_id'] as String, - albumTitle: j['album_title'] as String? ?? '', - artistId: j['artist_id'] as String, - artistName: j['artist_name'] as String? ?? '', - durationMs: (j['duration_ms'] as num?)?.toInt() ?? 0, - trackNumber: (j['track_number'] as num?)?.toInt(), - ); -} -``` - -- [ ] **Step 8.6: Create `lib/models/home_data.dart`** - -```dart -import 'album.dart'; -import 'artist.dart'; -import 'track.dart'; - -class HomeData { - const HomeData({ - required this.recentlyAddedAlbums, - required this.rediscoverAlbums, - required this.rediscoverArtists, - required this.mostPlayedTracks, - required this.lastPlayedArtists, - }); - - final List recentlyAddedAlbums; - final List rediscoverAlbums; - final List rediscoverArtists; - final List mostPlayedTracks; - final List lastPlayedArtists; - - factory HomeData.fromJson(Map j) => HomeData( - recentlyAddedAlbums: _list(j, 'recently_added_albums', AlbumRef.fromJson), - rediscoverAlbums: _list(j, 'rediscover_albums', AlbumRef.fromJson), - rediscoverArtists: _list(j, 'rediscover_artists', ArtistRef.fromJson), - mostPlayedTracks: _list(j, 'most_played_tracks', TrackRef.fromJson), - lastPlayedArtists: _list(j, 'last_played_artists', ArtistRef.fromJson), - ); - - static List _list( - Map j, - String key, - T Function(Map) parse, - ) { - final raw = j[key] as List? ?? const []; - return raw.map((e) => parse((e as Map).cast())).toList(growable: false); - } -} -``` - -- [ ] **Step 8.7: Write a parsing test** - -`flutter_client/test/models/models_test.dart`: - -```dart -import 'package:flutter_test/flutter_test.dart'; -import 'package:minstrel/models/home_data.dart'; - -void main() { - test('HomeData.fromJson handles missing sections as empty lists', () { - final h = HomeData.fromJson({}); - expect(h.recentlyAddedAlbums, isEmpty); - expect(h.rediscoverArtists, isEmpty); - }); - - test('HomeData.fromJson parses populated payload', () { - final h = HomeData.fromJson({ - 'recently_added_albums': [ - { - 'id': 'al-1', - 'title': 'Geogaddi', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - 'cover_art_url': '/api/albums/al-1/cover', - } - ], - }); - expect(h.recentlyAddedAlbums.single.title, 'Geogaddi'); - expect(h.recentlyAddedAlbums.single.coverArtUrl, '/api/albums/al-1/cover'); - }); -} -``` - -- [ ] **Step 8.8: Run tests** - -```bash -cd flutter_client && flutter test test/models/ -``` - -Expected: PASS. - -- [ ] **Step 8.9: Commit** - -```bash -git add flutter_client/lib/models/ flutter_client/test/models/ -git commit -F - <<'EOF' -feat(flutter/models): User, ArtistRef, AlbumRef, TrackRef, HomeData - -Field names mirror web/src/lib/api/types.ts exactly so a contract drift -between server and either client surfaces in both at the same time. -Hand-written DTOs per spec §10 — codegen revisits at ~30 models. -EOF -``` - ---- - -### Task 9 — Auth: server URL screen + auth provider + secure storage - -**Files:** -- Create: `flutter_client/lib/auth/auth_provider.dart` -- Create: `flutter_client/lib/auth/server_url_screen.dart` -- Create: `flutter_client/lib/api/endpoints/health.dart` -- Create: `flutter_client/test/auth/auth_provider_test.dart` - -- [ ] **Step 9.1: Create `lib/api/endpoints/health.dart`** - -```dart -import 'package:dio/dio.dart'; - -class HealthApi { - HealthApi(this._dio); - final Dio _dio; - - /// Returns {status, min_client_version}. - Future> check() async { - final r = await _dio.get>('/healthz'); - return (r.data ?? const {}) - .map((k, v) => MapEntry(k, v.toString())); - } -} -``` - -- [ ] **Step 9.2: Write failing test for auth provider** - -`flutter_client/test/auth/auth_provider_test.dart`: - -```dart -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:minstrel/auth/auth_provider.dart'; - -class _FakeStorage implements FlutterSecureStorage { - final _m = {}; - @override - Future read({required String key, IOSOptions? iOptions, AndroidOptions? aOptions, LinuxOptions? lOptions, WindowsOptions? wOptions, WebOptions? webOptions, MacOsOptions? mOptions}) async => _m[key]; - @override - Future write({required String key, required String? value, IOSOptions? iOptions, AndroidOptions? aOptions, LinuxOptions? lOptions, WindowsOptions? wOptions, WebOptions? webOptions, MacOsOptions? mOptions}) async { - if (value == null) _m.remove(key); else _m[key] = value; - } - @override - Future delete({required String key, IOSOptions? iOptions, AndroidOptions? aOptions, LinuxOptions? lOptions, WindowsOptions? wOptions, WebOptions? webOptions, MacOsOptions? mOptions}) async => _m.remove(key); - // Other interface members not exercised in tests; intentionally unimplemented. - @override - dynamic noSuchMethod(Invocation i) => super.noSuchMethod(i); -} - -void main() { - test('saves and reads server URL', () async { - final container = ProviderContainer(overrides: [ - secureStorageProvider.overrideWithValue(_FakeStorage()), - ]); - addTearDown(container.dispose); - - await container.read(authControllerProvider.notifier).setServerUrl('http://localhost:8080'); - - final url = await container.read(serverUrlProvider.future); - expect(url, 'http://localhost:8080'); - }); - - test('clearing token transitions auth state to logged out', () async { - final container = ProviderContainer(overrides: [ - secureStorageProvider.overrideWithValue(_FakeStorage()), - ]); - addTearDown(container.dispose); - - final ctrl = container.read(authControllerProvider.notifier); - await ctrl.setServerUrl('http://x'); - await ctrl.setSession(token: 't', userJson: '{"id":"u","username":"u","is_admin":false}'); - expect((await container.read(authControllerProvider.future))?.username, 'u'); - - await ctrl.clearSession(); - expect(await container.read(authControllerProvider.future), isNull); - }); -} -``` - -- [ ] **Step 9.3: Run test to verify failure** - -Run: `cd flutter_client && flutter test test/auth/auth_provider_test.dart` -Expected: FAIL with import / undefined errors. - -- [ ] **Step 9.4: Create `lib/auth/auth_provider.dart`** - -```dart -import 'dart:convert'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - -import '../models/user.dart'; - -const _kServerUrl = 'server_url'; -const _kSessionToken = 'session_token'; -const _kCurrentUser = 'current_user'; - -final secureStorageProvider = Provider( - (ref) => const FlutterSecureStorage(), -); - -final serverUrlProvider = FutureProvider((ref) async { - return ref.watch(secureStorageProvider).read(key: _kServerUrl); -}); - -final sessionTokenProvider = FutureProvider((ref) async { - return ref.watch(secureStorageProvider).read(key: _kSessionToken); -}); - -class AuthController extends AsyncNotifier { - late FlutterSecureStorage _storage; - - @override - Future build() async { - _storage = ref.watch(secureStorageProvider); - final raw = await _storage.read(key: _kCurrentUser); - if (raw == null) return null; - return User.fromJson(jsonDecode(raw) as Map); - } - - Future setServerUrl(String url) async { - await _storage.write(key: _kServerUrl, value: url); - ref.invalidate(serverUrlProvider); - } - - Future setSession({required String token, required String userJson}) async { - await _storage.write(key: _kSessionToken, value: token); - await _storage.write(key: _kCurrentUser, value: userJson); - ref.invalidate(sessionTokenProvider); - state = AsyncData(User.fromJson(jsonDecode(userJson) as Map)); - } - - Future clearSession() async { - await _storage.delete(key: _kSessionToken); - await _storage.delete(key: _kCurrentUser); - ref.invalidate(sessionTokenProvider); - state = const AsyncData(null); - } -} - -final authControllerProvider = - AsyncNotifierProvider(AuthController.new); -``` - -- [ ] **Step 9.5: Run test to verify it passes** - -Run: `cd flutter_client && flutter test test/auth/auth_provider_test.dart` -Expected: PASS. - -- [ ] **Step 9.6: Create `lib/auth/server_url_screen.dart`** - -```dart -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/health.dart'; -import '../theme/theme_extension.dart'; -import 'auth_provider.dart'; - -class ServerUrlScreen extends ConsumerStatefulWidget { - const ServerUrlScreen({super.key}); - - @override - ConsumerState createState() => _ServerUrlScreenState(); -} - -class _ServerUrlScreenState extends ConsumerState { - final _ctrl = TextEditingController(); - bool _busy = false; - String? _error; - - Future _connect() async { - final url = _ctrl.text.trim(); - if (url.isEmpty) { - setState(() => _error = 'Enter a server URL.'); - return; - } - setState(() { - _busy = true; - _error = null; - }); - try { - final dio = Dio(BaseOptions(baseUrl: url, connectTimeout: const Duration(seconds: 5))); - final body = await HealthApi(dio).check(); - if (body['status'] != 'ok') throw Exception('unhealthy'); - await ref.read(authControllerProvider.notifier).setServerUrl(url); - if (!mounted) return; - Navigator.of(context).pushReplacementNamed('/login'); - } on DioException catch (_) { - setState(() => _error = "Couldn't reach that server."); - } catch (_) { - setState(() => _error = "Couldn't reach that server."); - } finally { - if (mounted) setState(() => _busy = false); - } - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(height: 48), - Text('Connect to your Minstrel', style: TextStyle(fontFamily: fs.display.fontFamily, fontSize: 28)), - const SizedBox(height: 24), - TextField( - controller: _ctrl, - keyboardType: TextInputType.url, - decoration: const InputDecoration(labelText: 'Server URL', hintText: 'https://music.example.com'), - ), - if (_error != null) Padding( - padding: const EdgeInsets.only(top: 8), - child: Text(_error!, style: TextStyle(color: fs.error)), - ), - const SizedBox(height: 16), - FilledButton( - onPressed: _busy ? null : _connect, - child: _busy - ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Connect'), - ), - ]), - ), - ), - ); - } -} -``` - -- [ ] **Step 9.7: Commit** - -```bash -git add flutter_client/lib/auth/ flutter_client/lib/api/endpoints/health.dart flutter_client/test/auth/ -git commit -F - <<'EOF' -feat(flutter/auth): server URL screen + auth provider + secure storage - -AuthController is an AsyncNotifier holding the currently-logged-in -User. server_url, session_token, current_user all live in -flutter_secure_storage. ServerUrlScreen probes /healthz before saving -the URL so we don't store a bogus base. -EOF -``` - ---- - -### Task 10 — Auth: login screen + auth API + flow routing - -**Files:** -- Create: `flutter_client/lib/api/endpoints/auth.dart` -- Create: `flutter_client/lib/auth/login_screen.dart` -- Create: `flutter_client/test/auth/login_screen_test.dart` - -- [ ] **Step 10.1: Create `lib/api/endpoints/auth.dart`** - -```dart -import 'dart:convert'; - -import 'package:dio/dio.dart'; - -import '../../models/user.dart'; - -class AuthApi { - AuthApi(this._dio); - final Dio _dio; - - /// Returns (token, user, rawUserJson) on success. Server emits - /// LoginResponse{token, user{id, username, is_admin}}. - Future<({String token, User user, String rawUserJson})> login({ - required String username, - required String password, - }) async { - final r = await _dio.post>( - '/api/auth/login', - data: {'username': username, 'password': password}, - ); - final body = r.data!; - final userMap = (body['user'] as Map).cast(); - return ( - token: body['token'] as String, - user: User.fromJson(userMap), - rawUserJson: jsonEncode(userMap), - ); - } - - Future logout() async { - await _dio.post('/api/auth/logout'); - } -} -``` - -- [ ] **Step 10.2: Create `lib/auth/login_screen.dart`** - -```dart -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/client.dart'; -import '../api/endpoints/auth.dart'; -import '../api/error_copy.dart'; -import '../api/errors.dart'; -import '../theme/theme_extension.dart'; -import 'auth_provider.dart'; - -class LoginScreen extends ConsumerStatefulWidget { - const LoginScreen({super.key}); - - @override - ConsumerState createState() => _LoginScreenState(); -} - -class _LoginScreenState extends ConsumerState { - final _user = TextEditingController(); - final _pass = TextEditingController(); - bool _busy = false; - String? _error; - - Future _submit() async { - setState(() { - _busy = true; - _error = null; - }); - try { - final url = await ref.read(serverUrlProvider.future); - if (url == null) { - Navigator.of(context).pushReplacementNamed('/server-url'); - return; - } - final dio = ApiClient.buildDio( - baseUrl: url, - tokenResolver: () async => null, - ); - final res = await AuthApi(dio).login( - username: _user.text.trim(), - password: _pass.text, - ); - await ref.read(authControllerProvider.notifier).setSession( - token: res.token, - userJson: res.rawUserJson, - ); - if (!mounted) return; - Navigator.of(context).pushReplacementNamed('/home'); - } on DioException catch (e) { - final code = ApiError.fromDio(e).code; - final copy = (await ErrorCopy.load()).forCode(code); - setState(() => _error = copy); - } finally { - if (mounted) setState(() => _busy = false); - } - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(height: 48), - Text('Sign in', style: TextStyle(fontFamily: fs.display.fontFamily, fontSize: 28)), - const SizedBox(height: 24), - TextField(controller: _user, decoration: const InputDecoration(labelText: 'Username')), - const SizedBox(height: 12), - TextField(controller: _pass, obscureText: true, decoration: const InputDecoration(labelText: 'Password')), - if (_error != null) Padding( - padding: const EdgeInsets.only(top: 8), - child: Text(_error!, style: TextStyle(color: fs.error)), - ), - const SizedBox(height: 16), - FilledButton( - onPressed: _busy ? null : _submit, - child: _busy - ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Sign in'), - ), - TextButton( - onPressed: () => Navigator.of(context).pushReplacementNamed('/server-url'), - child: const Text('Change server URL'), - ), - ]), - ), - ), - ); - } -} -``` - -- [ ] **Step 10.3: Write a smoke test for the screen** - -`flutter_client/test/auth/login_screen_test.dart`: - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/auth/login_screen.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -void main() { - testWidgets('login screen renders both fields and the submit button', (tester) async { - await tester.pumpWidget(ProviderScope( - child: MaterialApp( - theme: buildThemeData(), - home: const LoginScreen(), - ), - )); - expect(find.byType(TextField), findsNWidgets(2)); - expect(find.text('Sign in'), findsNWidgets(2)); // header + button - }); -} -``` - -- [ ] **Step 10.4: Run tests** - -```bash -cd flutter_client && flutter test test/auth/ -``` - -Expected: PASS. - -- [ ] **Step 10.5: Commit** - -```bash -git add flutter_client/lib/auth/login_screen.dart flutter_client/lib/api/endpoints/auth.dart flutter_client/test/auth/login_screen_test.dart -git commit -F - <<'EOF' -feat(flutter/auth): login screen + AuthApi.login posting to /api/auth/login - -Login uses a non-authenticated dio (token resolver returns null) since -we don't have one yet. On success, setSession persists token + user -into secure storage and the AuthController state flips, which the -router watches to navigate. -EOF -``` - ---- - -### Task 11 — Routing shell with version gate, server-url and login flow - -**Files:** -- Create: `flutter_client/lib/shared/routing.dart` -- Create: `flutter_client/lib/shared/widgets/version_gate.dart` -- Modify: `flutter_client/lib/app.dart` - -- [ ] **Step 11.1: Create `lib/shared/widgets/version_gate.dart`** - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:pub_semver/pub_semver.dart'; - -import '../../api/client.dart'; -import '../../api/endpoints/health.dart'; -import '../../auth/auth_provider.dart'; - -final _versionCheckProvider = FutureProvider<_VersionResult>((ref) async { - final url = await ref.watch(serverUrlProvider.future); - if (url == null) return _VersionResult.skipped; - final dio = ApiClient.buildDio(baseUrl: url, tokenResolver: () async => null); - final body = await HealthApi(dio).check(); - final min = body['min_client_version']; - if (min == null || min.isEmpty) return _VersionResult.skipped; - final info = await PackageInfo.fromPlatform(); - final mine = Version.parse(info.version); - final required = Version.parse(min); - return mine < required ? _VersionResult.tooOld : _VersionResult.ok; -}); - -enum _VersionResult { ok, tooOld, skipped } - -class VersionGate extends ConsumerWidget { - const VersionGate({required this.child, super.key}); - final Widget child; - - @override - Widget build(BuildContext context, WidgetRef ref) { - return ref.watch(_versionCheckProvider).when( - data: (r) => r == _VersionResult.tooOld ? const _TooOldScreen() : child, - error: (_, __) => child, // soft-fail if /healthz is unreachable; let the rest of the flow surface a real error - loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())), - ); - } -} - -class _TooOldScreen extends StatelessWidget { - const _TooOldScreen(); - @override - Widget build(BuildContext context) { - return const Scaffold( - body: SafeArea( - child: Padding( - padding: EdgeInsets.all(24), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Update required', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.w500), - ), - SizedBox(height: 12), - Text( - 'This client is too old for that Minstrel server. Install the latest build to continue.', - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ), - ); - } -} -``` - -- [ ] **Step 11.2: Create `lib/shared/routing.dart`** - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../auth/auth_provider.dart'; -import '../auth/login_screen.dart'; -import '../auth/server_url_screen.dart'; -import 'widgets/version_gate.dart'; - -GoRouter buildRouter(Ref ref) { - return GoRouter( - initialLocation: '/', - redirect: (ctx, state) async { - final url = await ref.read(serverUrlProvider.future); - if (url == null) return '/server-url'; - final user = await ref.read(authControllerProvider.future); - if (user == null && state.matchedLocation != '/login' && state.matchedLocation != '/server-url') { - return '/login'; - } - if (user != null && (state.matchedLocation == '/login' || state.matchedLocation == '/server-url')) { - return '/home'; - } - return null; - }, - routes: [ - GoRoute(path: '/', redirect: (_, __) => '/home'), - GoRoute(path: '/server-url', builder: (_, __) => const ServerUrlScreen()), - GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), - ShellRoute( - builder: (ctx, state, child) => VersionGate(child: _ShellWithPlayerBar(child: child)), - routes: [ - GoRoute(path: '/home', builder: (_, __) => const _HomePlaceholder()), - ], - ), - ], - ); -} - -class _ShellWithPlayerBar extends StatelessWidget { - const _ShellWithPlayerBar({required this.child}); - final Widget child; - @override - Widget build(BuildContext context) { - // PlayerBar is wired in Task 18; this placeholder leaves room. - return Column( - children: [ - Expanded(child: child), - ], - ); - } -} - -class _HomePlaceholder extends StatelessWidget { - const _HomePlaceholder(); - @override - Widget build(BuildContext context) => - const Scaffold(body: Center(child: Text('Home — coming in Task 14'))); -} -``` - -- [ ] **Step 11.3: Wire router into the app** - -Replace `lib/app.dart`: - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import 'shared/routing.dart'; -import 'theme/theme_data.dart'; - -class MinstrelApp extends ConsumerWidget { - const MinstrelApp({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final router = buildRouter(ref); - return MaterialApp.router( - title: 'Minstrel', - theme: buildThemeData(), - routerConfig: router, - ); - } -} -``` - -- [ ] **Step 11.4: Update smoke test (existing `test/smoke_test.dart`)** - -Replace contents: - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/app.dart'; - -void main() { - testWidgets('cold launch lands on server-url screen when no URL stored', (tester) async { - await tester.pumpWidget(const ProviderScope(child: MinstrelApp())); - await tester.pumpAndSettle(); - expect(find.text('Connect to your Minstrel'), findsOneWidget); - }); -} -``` - -Note: this test reads from real flutter_secure_storage in widget tests. It is reliable on CI because each test starts with empty keychain. If it flakes locally, override `secureStorageProvider` with a fake. - -- [ ] **Step 11.5: Run tests** - -```bash -cd flutter_client && flutter test -``` - -Expected: PASS (auth/login_screen_test still passes; smoke_test now lands on server-url). - -- [ ] **Step 11.6: Commit** - -```bash -git add flutter_client/lib/shared/ flutter_client/lib/app.dart flutter_client/test/smoke_test.dart -git commit -F - <<'EOF' -feat(flutter): GoRouter shell + version gate + auth-aware redirects - -Cold launch flow: no server-url → /server-url. URL set, no token → -/login. Token present → /home with shell. VersionGate runs once when -the URL changes, hits /healthz, blocks if min_client_version > -package version. /home is a placeholder until Task 14. -EOF -``` - ---- - -### Task 12 — Library API endpoints + library providers - -**Files:** -- Create: `flutter_client/lib/api/endpoints/library.dart` -- Create: `flutter_client/lib/library/library_providers.dart` -- Create: `flutter_client/test/api/endpoints/library_test.dart` - -- [ ] **Step 12.1: Create `lib/api/endpoints/library.dart`** - -```dart -import 'package:dio/dio.dart'; - -import '../../models/album.dart'; -import '../../models/artist.dart'; -import '../../models/home_data.dart'; -import '../../models/track.dart'; - -class LibraryApi { - LibraryApi(this._dio); - final Dio _dio; - - Future getHome() async { - final r = await _dio.get>('/api/home'); - return HomeData.fromJson(r.data ?? const {}); - } - - Future getArtist(String id) async { - final r = await _dio.get>('/api/artists/$id'); - return ArtistRef.fromJson(r.data ?? const {}); - } - - Future> getArtistAlbums(String id) async { - final r = await _dio.get>('/api/artists/$id'); - final raw = (r.data?['albums'] as List?) ?? const []; - return raw - .map((e) => AlbumRef.fromJson((e as Map).cast())) - .toList(growable: false); - } - - Future> getArtistTracks(String id) async { - final r = await _dio.get>('/api/artists/$id/tracks'); - final raw = (r.data?['tracks'] as List?) ?? const []; - return raw - .map((e) => TrackRef.fromJson((e as Map).cast())) - .toList(growable: false); - } - - Future<({AlbumRef album, List tracks})> getAlbum(String id) async { - final r = await _dio.get>('/api/albums/$id'); - final body = r.data ?? const {}; - final tracks = ((body['tracks'] as List?) ?? const []) - .map((e) => TrackRef.fromJson((e as Map).cast())) - .toList(growable: false); - return (album: AlbumRef.fromJson(body), tracks: tracks); - } -} -``` - -- [ ] **Step 12.2: Verify the actual server response shapes match these helpers** - -Run: `grep -nA5 "handleGetArtist\b\|handleGetArtistTracks\|handleGetAlbum" /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/internal/api/library.go /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/internal/api/library_albums.go 2>/dev/null | head -60` -Expected: handler shapes match — adjust the parser if a field is named differently (e.g. `tracks` vs `track_list`). If there's a drift, update the parsers; the rest of the plan assumes the names in `lib/api/endpoints/library.dart`. - -- [ ] **Step 12.3: Create `lib/library/library_providers.dart`** - -```dart -import 'package:dio/dio.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/client.dart'; -import '../api/endpoints/library.dart'; -import '../auth/auth_provider.dart'; -import '../models/album.dart'; -import '../models/artist.dart'; -import '../models/home_data.dart'; -import '../models/track.dart'; - -final dioProvider = FutureProvider((ref) async { - final url = await ref.watch(serverUrlProvider.future); - if (url == null) throw StateError('no server URL set'); - final storage = ref.watch(secureStorageProvider); - return ApiClient.buildDio( - baseUrl: url, - tokenResolver: () async => storage.read(key: 'session_token'), - ); -}); - -final libraryApiProvider = FutureProvider((ref) async { - return LibraryApi(await ref.watch(dioProvider.future)); -}); - -final homeProvider = FutureProvider((ref) async { - return (await ref.watch(libraryApiProvider.future)).getHome(); -}); - -final artistProvider = FutureProvider.family((ref, id) async { - return (await ref.watch(libraryApiProvider.future)).getArtist(id); -}); - -final artistAlbumsProvider = FutureProvider.family, String>((ref, id) async { - return (await ref.watch(libraryApiProvider.future)).getArtistAlbums(id); -}); - -final artistTracksProvider = FutureProvider.family, String>((ref, id) async { - return (await ref.watch(libraryApiProvider.future)).getArtistTracks(id); -}); - -final albumProvider = - FutureProvider.family<({AlbumRef album, List tracks}), String>( - (ref, id) async { - return (await ref.watch(libraryApiProvider.future)).getAlbum(id); - }, -); -``` - -- [ ] **Step 12.4: Write parser tests for LibraryApi** - -`flutter_client/test/api/endpoints/library_test.dart`: - -```dart -import 'package:dio/dio.dart'; -import 'package:dio/io.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:minstrel/api/endpoints/library.dart'; - -class _StubAdapter extends HttpClientAdapter { - _StubAdapter(this._body); - final Map _body; - @override - Future fetch(RequestOptions options, Stream>? requestStream, Future? cancelFuture) async { - return ResponseBody.fromString( - jsonEncode(_body), - 200, - headers: const {Headers.contentTypeHeader: ['application/json']}, - ); - } - @override - void close({bool force = false}) {} -} - -import 'dart:convert'; - -void main() { - Dio dioWith(Map body) { - final d = Dio(BaseOptions(baseUrl: 'http://x')); - d.httpClientAdapter = _StubAdapter(body); - return d; - } - - test('getAlbum parses album + tracks list', () async { - final api = LibraryApi(dioWith({ - 'id': 'al-1', - 'title': 'Geogaddi', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - 'tracks': [ - { - 'id': 't-1', 'title': 'Music Is Math', - 'album_id': 'al-1', 'album_title': 'Geogaddi', - 'artist_id': 'art-1', 'artist_name': 'Boards of Canada', - 'duration_ms': 312000, 'track_number': 4, - } - ], - })); - final r = await api.getAlbum('al-1'); - expect(r.album.title, 'Geogaddi'); - expect(r.tracks.single.title, 'Music Is Math'); - }); -} -``` - -- [ ] **Step 12.5: Run tests** - -```bash -cd flutter_client && flutter test test/api/ -``` - -Expected: PASS. - -- [ ] **Step 12.6: Commit** - -```bash -git add flutter_client/lib/api/endpoints/library.dart flutter_client/lib/library/library_providers.dart flutter_client/test/api/endpoints/library_test.dart -git commit -F - <<'EOF' -feat(flutter/library): API endpoints + Riverpod providers - -LibraryApi wraps GET /api/home, /api/artists/{id}(/tracks), -/api/albums/{id}. dioProvider builds an authenticated dio (token -resolver reads session_token from secure storage on every request). -homeProvider, artistProvider(id), albumProvider(id) sit on top. -EOF -``` - ---- - -### Task 13 — Library widgets (HorizontalScrollRow, ArtistCard, AlbumCard, TrackRow) - -**Files:** -- Create: `flutter_client/lib/library/widgets/horizontal_scroll_row.dart` -- Create: `flutter_client/lib/library/widgets/artist_card.dart` -- Create: `flutter_client/lib/library/widgets/album_card.dart` -- Create: `flutter_client/lib/library/widgets/track_row.dart` - -- [ ] **Step 13.1: Create `widgets/horizontal_scroll_row.dart`** - -```dart -import 'package:flutter/material.dart'; - -import '../../theme/theme_extension.dart'; - -/// Mirrors the web HorizontalScrollRow: a labeled section that scrolls -/// horizontally. Multi-row sections share one `controller` so they -/// scroll together. -class HorizontalScrollRow extends StatelessWidget { - const HorizontalScrollRow({ - required this.title, - required this.children, - this.height = 200, - this.controller, - super.key, - }); - - final String title; - final List children; - final double height; - final ScrollController? controller; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text( - title, - style: TextStyle( - fontFamily: fs.display.fontFamily, - fontSize: 18, - color: fs.parchment, - ), - ), - ), - SizedBox( - height: height, - child: ListView( - controller: controller, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - children: children, - ), - ), - ]); - } -} -``` - -- [ ] **Step 13.2: Create `widgets/artist_card.dart`** - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; - -import '../../models/artist.dart'; -import '../../theme/theme_extension.dart'; - -class ArtistCard extends StatelessWidget { - const ArtistCard({required this.artist, required this.onTap, super.key}); - final ArtistRef artist; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return GestureDetector( - onTap: onTap, - child: SizedBox( - width: 140, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClipOval( - child: Container( - width: 124, height: 124, color: fs.slate, - child: artist.coverArtUrl == null - ? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover) - : Image.network(artist.coverArtUrl!, fit: BoxFit.cover), - ), - ), - const SizedBox(height: 8), - Text(artist.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14)), - ]), - ), - ), - ); - } -} -``` - -- [ ] **Step 13.3: Create `widgets/album_card.dart`** - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; - -import '../../models/album.dart'; -import '../../theme/theme_extension.dart'; - -class AlbumCard extends StatelessWidget { - const AlbumCard({required this.album, required this.onTap, super.key}); - final AlbumRef album; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return GestureDetector( - onTap: onTap, - child: SizedBox( - width: 140, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Container( - width: 124, height: 124, color: fs.slate, - child: album.coverArtUrl == null - ? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover) - : Image.network(album.coverArtUrl!, fit: BoxFit.cover), - ), - ), - const SizedBox(height: 8), - Text(album.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14)), - Text(album.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12)), - ]), - ), - ), - ); - } -} -``` - -- [ ] **Step 13.4: Create `widgets/track_row.dart`** - -```dart -import 'package:flutter/material.dart'; - -import '../../models/track.dart'; -import '../../theme/theme_extension.dart'; - -class TrackRow extends StatelessWidget { - const TrackRow({ - required this.track, - required this.onTap, - this.trailing, - super.key, - }); - final TrackRef track; - final VoidCallback onTap; - final Widget? trailing; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final mins = (track.durationMs ~/ 60000).toString().padLeft(2, '0'); - final secs = ((track.durationMs % 60000) ~/ 1000).toString().padLeft(2, '0'); - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row(children: [ - if (track.trackNumber != null) - SizedBox( - width: 28, - child: Text( - track.trackNumber.toString(), - style: TextStyle(color: fs.ash, fontSize: 13), - ), - ), - Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(track.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14)), - Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12)), - ]), - ), - Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)), - if (trailing != null) Padding( - padding: const EdgeInsets.only(left: 8), - child: trailing!, - ), - ]), - ), - ); - } -} -``` - -- [ ] **Step 13.5: Smoke test the widgets** - -`flutter_client/test/library/widgets_smoke_test.dart`: - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/library/widgets/album_card.dart'; -import 'package:minstrel/library/widgets/track_row.dart'; -import 'package:minstrel/models/album.dart'; -import 'package:minstrel/models/track.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -void main() { - testWidgets('AlbumCard renders title and artist', (tester) async { - await tester.pumpWidget(MaterialApp( - theme: buildThemeData(), - home: Scaffold( - body: AlbumCard( - album: const AlbumRef( - id: 'a', title: 'Geogaddi', - artistId: 'x', artistName: 'Boards of Canada', - ), - onTap: () {}, - ), - ), - )); - expect(find.text('Geogaddi'), findsOneWidget); - expect(find.text('Boards of Canada'), findsOneWidget); - }); - - testWidgets('TrackRow shows mm:ss duration', (tester) async { - await tester.pumpWidget(MaterialApp( - theme: buildThemeData(), - home: Scaffold( - body: TrackRow( - track: const TrackRef( - id: 't', title: 'Roygbiv', albumId: 'a', albumTitle: 'Music', - artistId: 'x', artistName: 'BoC', durationMs: 137000, trackNumber: 4, - ), - onTap: () {}, - ), - ), - )); - expect(find.text('02:17'), findsOneWidget); - }); -} -``` - -- [ ] **Step 13.6: Run tests** - -```bash -cd flutter_client && flutter test test/library/ -``` - -Expected: PASS. - -- [ ] **Step 13.7: Commit** - -```bash -git add flutter_client/lib/library/widgets/ flutter_client/test/library/ -git commit -F - <<'EOF' -feat(flutter/library): card + row widgets (artist/album/track) - -HorizontalScrollRow takes an optional shared ScrollController so -multi-row sections (Most played: 3 rows) couple their scroll like the -web HorizontalScrollRow.svelte. Cards fall back to the synced -album-fallback.svg when cover_art_url is missing. -EOF -``` - ---- - -### Task 14 — Home screen - -**Files:** -- Create: `flutter_client/lib/library/home_screen.dart` -- Create: `flutter_client/test/library/home_screen_test.dart` -- Modify: `flutter_client/lib/shared/routing.dart` — replace `_HomePlaceholder` - -- [ ] **Step 14.1: Create `lib/library/home_screen.dart`** - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../models/album.dart'; -import '../models/artist.dart'; -import '../models/track.dart'; -import '../theme/theme_extension.dart'; -import 'library_providers.dart'; -import 'widgets/album_card.dart'; -import 'widgets/artist_card.dart'; -import 'widgets/horizontal_scroll_row.dart'; -import 'widgets/track_row.dart'; - -class HomeScreen extends ConsumerWidget { - const HomeScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - return Scaffold( - backgroundColor: fs.obsidian, - body: SafeArea( - child: ref.watch(homeProvider).when( - error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - loading: () => const Center(child: CircularProgressIndicator()), - data: (h) => RefreshIndicator( - onRefresh: () async => ref.refresh(homeProvider.future), - child: ListView(children: [ - _albumsRow(context, 'Recently added', h.recentlyAddedAlbums), - _albumsRow(context, 'Rediscover', h.rediscoverAlbums), - _artistsRow(context, 'Rediscover artists', h.rediscoverArtists), - _tracksRow(context, ref, 'Most played', h.mostPlayedTracks), - _artistsRow(context, 'Last played artists', h.lastPlayedArtists), - const SizedBox(height: 96), - ]), - ), - ), - ), - ); - } - - Widget _albumsRow(BuildContext ctx, String title, List albums) => - HorizontalScrollRow( - title: title, - children: [ - for (final a in albums) - AlbumCard(album: a, onTap: () => ctx.push('/albums/${a.id}')), - ], - ); - - Widget _artistsRow(BuildContext ctx, String title, List artists) => - HorizontalScrollRow( - title: title, - children: [ - for (final a in artists) - ArtistCard(artist: a, onTap: () => ctx.push('/artists/${a.id}')), - ], - ); - - Widget _tracksRow(BuildContext ctx, WidgetRef ref, String title, List tracks) => - HorizontalScrollRow( - title: title, - height: 64, - children: [ - for (final t in tracks) - SizedBox( - width: 280, - child: TrackRow(track: t, onTap: () { /* play wired in Task 18 */ }), - ), - ], - ); -} -``` - -- [ ] **Step 14.2: Wire HomeScreen into the router** - -In `lib/shared/routing.dart`, replace `_HomePlaceholder` references: - -```dart -import '../library/home_screen.dart'; -// ... -GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), -``` - -Delete the now-unused `_HomePlaceholder` class. - -- [ ] **Step 14.3: Write a screen test that overrides `homeProvider`** - -`flutter_client/test/library/home_screen_test.dart`: - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/library/home_screen.dart'; -import 'package:minstrel/library/library_providers.dart'; -import 'package:minstrel/models/album.dart'; -import 'package:minstrel/models/artist.dart'; -import 'package:minstrel/models/home_data.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -void main() { - testWidgets('home renders sections with rows', (tester) async { - final fixture = HomeData( - recentlyAddedAlbums: const [ - AlbumRef(id: 'a', title: 'Geogaddi', artistId: 'x', artistName: 'BoC'), - ], - rediscoverAlbums: const [], - rediscoverArtists: const [ - ArtistRef(id: 'r', name: 'Aphex Twin'), - ], - mostPlayedTracks: const [], - lastPlayedArtists: const [], - ); - await tester.pumpWidget(ProviderScope( - overrides: [ - homeProvider.overrideWith((ref) async => fixture), - ], - child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()), - )); - await tester.pumpAndSettle(); - expect(find.text('Recently added'), findsOneWidget); - expect(find.text('Geogaddi'), findsOneWidget); - expect(find.text('Aphex Twin'), findsOneWidget); - }); -} -``` - -- [ ] **Step 14.4: Run test** - -```bash -cd flutter_client && flutter test test/library/home_screen_test.dart -``` - -Expected: PASS. - -- [ ] **Step 14.5: Commit** - -```bash -git add flutter_client/lib/library/home_screen.dart flutter_client/lib/shared/routing.dart flutter_client/test/library/home_screen_test.dart -git commit -F - <<'EOF' -feat(flutter/home): home screen with five sections - -Sections mirror the web home: Recently added · Rediscover (albums + -artists) · Most played · Last played artists. Tap routes pushed for -albums/artists; track-tap is wired in the player task. Pull-to-refresh -re-fetches /api/home. -EOF -``` - ---- - -### Task 15 — Artist + Album detail screens - -**Files:** -- Create: `flutter_client/lib/library/artist_detail_screen.dart` -- Create: `flutter_client/lib/library/album_detail_screen.dart` -- Create: `flutter_client/test/library/artist_detail_screen_test.dart` -- Create: `flutter_client/test/library/album_detail_screen_test.dart` -- Modify: `flutter_client/lib/shared/routing.dart` — add detail routes - -- [ ] **Step 15.1: Create `lib/library/artist_detail_screen.dart`** - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../models/album.dart'; -import '../theme/theme_extension.dart'; -import 'library_providers.dart'; -import 'widgets/album_card.dart'; - -class ArtistDetailScreen extends ConsumerWidget { - const ArtistDetailScreen({required this.id, super.key}); - final String id; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final artist = ref.watch(artistProvider(id)); - final albums = ref.watch(artistAlbumsProvider(id)); - - return Scaffold( - appBar: AppBar(), - backgroundColor: fs.obsidian, - body: artist.when( - error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - loading: () => const Center(child: CircularProgressIndicator()), - data: (a) => ListView(children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row(children: [ - Container( - width: 96, height: 96, - decoration: BoxDecoration(color: fs.slate, shape: BoxShape.circle), - ), - const SizedBox(width: 16), - Expanded( - child: Text( - a.name, - style: TextStyle( - color: fs.parchment, - fontFamily: fs.display.fontFamily, - fontSize: 24, - ), - ), - ), - Container( - width: 48, height: 48, - decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle), - child: IconButton( - icon: Icon(Icons.play_arrow, color: fs.parchment), - onPressed: () { /* play wired in Task 18 */ }, - ), - ), - // LikeButton goes here in Task 16; kept simple now. - ]), - ), - const Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Albums', style: TextStyle(fontSize: 16)), - ), - albums.when( - error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())), - data: (list) => GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.all(8), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 0.8, - ), - itemCount: list.length, - itemBuilder: (_, i) { - final AlbumRef album = list[i]; - return AlbumCard(album: album, onTap: () => context.push('/albums/${album.id}')); - }, - ), - ), - ]), - ), - ); - } -} -``` - -- [ ] **Step 15.2: Create `lib/library/album_detail_screen.dart`** - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../theme/theme_extension.dart'; -import 'library_providers.dart'; -import 'widgets/track_row.dart'; - -class AlbumDetailScreen extends ConsumerWidget { - const AlbumDetailScreen({required this.id, super.key}); - final String id; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - return Scaffold( - appBar: AppBar(), - backgroundColor: fs.obsidian, - body: ref.watch(albumProvider(id)).when( - error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - loading: () => const Center(child: CircularProgressIndicator()), - data: (r) => ListView(children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row(children: [ - Container(width: 96, height: 96, color: fs.slate), - const SizedBox(width: 16), - Expanded(child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(r.album.title, style: TextStyle( - color: fs.parchment, - fontFamily: fs.display.fontFamily, - fontSize: 22, - )), - Text(r.album.artistName, style: TextStyle(color: fs.ash)), - ], - )), - Container( - width: 48, height: 48, - decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle), - child: IconButton( - icon: Icon(Icons.play_arrow, color: fs.parchment), - onPressed: () { /* play wired in Task 18 */ }, - ), - ), - ]), - ), - for (final t in r.tracks) - TrackRow(track: t, onTap: () { /* play wired in Task 18 */ }), - ]), - ), - ); - } -} -``` - -- [ ] **Step 15.3: Wire detail routes into the router** - -In `lib/shared/routing.dart`, inside the ShellRoute's `routes:` list: - -```dart -import '../library/album_detail_screen.dart'; -import '../library/artist_detail_screen.dart'; - -// ... -routes: [ - GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), - GoRoute( - path: '/artists/:id', - builder: (_, s) => ArtistDetailScreen(id: s.pathParameters['id']!), - ), - GoRoute( - path: '/albums/:id', - builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!), - ), -], -``` - -- [ ] **Step 15.4: Smoke tests for both screens** - -`flutter_client/test/library/artist_detail_screen_test.dart`: - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/library/artist_detail_screen.dart'; -import 'package:minstrel/library/library_providers.dart'; -import 'package:minstrel/models/artist.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -void main() { - testWidgets('renders artist name and Albums header', (tester) async { - await tester.pumpWidget(ProviderScope( - overrides: [ - artistProvider('a-1').overrideWith((ref) async => const ArtistRef(id: 'a-1', name: 'Aphex Twin')), - artistAlbumsProvider('a-1').overrideWith((ref) async => const []), - ], - child: MaterialApp(theme: buildThemeData(), home: const ArtistDetailScreen(id: 'a-1')), - )); - await tester.pumpAndSettle(); - expect(find.text('Aphex Twin'), findsOneWidget); - expect(find.text('Albums'), findsOneWidget); - }); -} -``` - -`flutter_client/test/library/album_detail_screen_test.dart`: - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/library/album_detail_screen.dart'; -import 'package:minstrel/library/library_providers.dart'; -import 'package:minstrel/models/album.dart'; -import 'package:minstrel/models/track.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -void main() { - testWidgets('renders album header + track list', (tester) async { - await tester.pumpWidget(ProviderScope( - overrides: [ - albumProvider('al-1').overrideWith((ref) async => ( - album: const AlbumRef(id: 'al-1', title: 'Drukqs', artistId: 'a-1', artistName: 'Aphex Twin'), - tracks: const [ - TrackRef(id: 't-1', title: 'Avril 14th', albumId: 'al-1', albumTitle: 'Drukqs', - artistId: 'a-1', artistName: 'Aphex Twin', durationMs: 121000, trackNumber: 4), - ], - )), - ], - child: MaterialApp(theme: buildThemeData(), home: const AlbumDetailScreen(id: 'al-1')), - )); - await tester.pumpAndSettle(); - expect(find.text('Drukqs'), findsOneWidget); - expect(find.text('Avril 14th'), findsOneWidget); - }); -} -``` - -- [ ] **Step 15.5: Run tests** - -```bash -cd flutter_client && flutter test test/library/ -``` - -Expected: PASS. - -- [ ] **Step 15.6: Commit** - -```bash -git add flutter_client/lib/library/artist_detail_screen.dart flutter_client/lib/library/album_detail_screen.dart flutter_client/lib/shared/routing.dart flutter_client/test/library/artist_detail_screen_test.dart flutter_client/test/library/album_detail_screen_test.dart -git commit -F - <<'EOF' -feat(flutter/library): artist + album detail screens - -Headers carry placeholder play buttons (wired in Task 18) + room for -LikeButton (Task 16). Artist screen has a 2-column album grid; album -screen has a track list with mm:ss durations. Routes :id-parameterized -under the shell so the PlayerBar persists. -EOF -``` - ---- - -### Task 16 — Likes API + LikesProvider + LikeButton + wiring - -**Files:** -- Create: `flutter_client/lib/api/endpoints/likes.dart` -- Create: `flutter_client/lib/likes/likes_provider.dart` -- Create: `flutter_client/lib/likes/like_button.dart` -- Create: `flutter_client/test/likes/like_button_test.dart` -- Modify: `flutter_client/lib/library/widgets/track_row.dart` — wire LikeButton -- Modify: `flutter_client/lib/library/artist_detail_screen.dart` — wire LikeButton -- Modify: `flutter_client/lib/library/album_detail_screen.dart` — wire LikeButton - -- [ ] **Step 16.1: Create `lib/api/endpoints/likes.dart`** - -```dart -import 'package:dio/dio.dart'; - -enum LikeKind { artist, album, track } - -extension on LikeKind { - String get path => switch (this) { - LikeKind.artist => 'artists', - LikeKind.album => 'albums', - LikeKind.track => 'tracks', - }; -} - -class LikesApi { - LikesApi(this._dio); - final Dio _dio; - - Future like(LikeKind kind, String id) async { - await _dio.post('/api/likes/${kind.path}/$id'); - } - - Future unlike(LikeKind kind, String id) async { - await _dio.delete('/api/likes/${kind.path}/$id'); - } - - /// Returns a payload of {artist_ids, album_ids, track_ids} the user has liked. - Future<({Set artists, Set albums, Set tracks})> ids() async { - final r = await _dio.get>('/api/likes/ids'); - final body = r.data ?? const {}; - Set set(String key) => - ((body[key] as List?) ?? const []).map((e) => e.toString()).toSet(); - return ( - artists: set('artist_ids'), - albums: set('album_ids'), - tracks: set('track_ids'), - ); - } -} -``` - -- [ ] **Step 16.2: Create `lib/likes/likes_provider.dart`** - -```dart -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/likes.dart'; -import '../library/library_providers.dart'; - -final likesApiProvider = FutureProvider((ref) async { - return LikesApi(await ref.watch(dioProvider.future)); -}); - -class LikedIds { - const LikedIds({required this.artists, required this.albums, required this.tracks}); - final Set artists; - final Set albums; - final Set tracks; - - bool has(LikeKind kind, String id) => switch (kind) { - LikeKind.artist => artists.contains(id), - LikeKind.album => albums.contains(id), - LikeKind.track => tracks.contains(id), - }; -} - -class LikedIdsController extends AsyncNotifier { - @override - Future build() async { - final api = await ref.watch(likesApiProvider.future); - final r = await api.ids(); - return LikedIds(artists: r.artists, albums: r.albums, tracks: r.tracks); - } - - Future toggle(LikeKind kind, String id) async { - final api = await ref.read(likesApiProvider.future); - final current = state.value ?? const LikedIds(artists: {}, albums: {}, tracks: {}); - - final isLiked = current.has(kind, id); - final optimistic = _flip(current, kind, id); - state = AsyncData(optimistic); - - try { - if (isLiked) { - await api.unlike(kind, id); - } else { - await api.like(kind, id); - } - } catch (e, st) { - // Rollback on failure so the user sees the truth. - state = AsyncData(current); - // Re-throw so callers can surface a toast if they care. - Error.throwWithStackTrace(e, st); - } - } - - LikedIds _flip(LikedIds before, LikeKind kind, String id) { - Set swap(Set s) { - final next = {...s}; - if (s.contains(id)) { - next.remove(id); - } else { - next.add(id); - } - return next; - } - return switch (kind) { - LikeKind.artist => LikedIds(artists: swap(before.artists), albums: before.albums, tracks: before.tracks), - LikeKind.album => LikedIds(artists: before.artists, albums: swap(before.albums), tracks: before.tracks), - LikeKind.track => LikedIds(artists: before.artists, albums: before.albums, tracks: swap(before.tracks)), - }; - } -} - -final likedIdsProvider = - AsyncNotifierProvider(LikedIdsController.new); -``` - -- [ ] **Step 16.3: Create `lib/likes/like_button.dart`** - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/likes.dart'; -import '../theme/theme_extension.dart'; -import 'likes_provider.dart'; - -class LikeButton extends ConsumerWidget { - const LikeButton({required this.kind, required this.id, this.size = 22, super.key}); - final LikeKind kind; - final String id; - final double size; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final state = ref.watch(likedIdsProvider); - final liked = state.maybeWhen( - data: (s) => s.has(kind, id), - orElse: () => false, - ); - return IconButton( - icon: Icon( - liked ? Icons.favorite : Icons.favorite_border, - color: liked ? fs.accent : fs.ash, - size: size, - ), - onPressed: () => ref.read(likedIdsProvider.notifier).toggle(kind, id), - ); - } -} -``` - -- [ ] **Step 16.4: Wire LikeButton into TrackRow** - -Modify `lib/library/widgets/track_row.dart`: - -```dart -// add import: -import '../../api/endpoints/likes.dart'; -import '../../likes/like_button.dart'; -``` - -Replace the TrackRow constructor + body so the trailing slot defaults to a LikeButton when an entity reference is provided. Simpler: leave `trailing` as-is and let callers pass `trailing: LikeButton(kind: LikeKind.track, id: track.id)`. (This keeps TrackRow agnostic of likes.) - -- [ ] **Step 16.5: Wire LikeButton into Artist + Album detail headers** - -In `lib/library/artist_detail_screen.dart`, replace the comment `// LikeButton goes here in Task 16` with: - -```dart -LikeButton(kind: LikeKind.artist, id: a.id, size: 28), -``` - -(Add the import: `import '../api/endpoints/likes.dart';` and `import '../likes/like_button.dart';`.) - -In `lib/library/album_detail_screen.dart`, after the existing play-button container, add the same pattern with `kind: LikeKind.album, id: r.album.id`. Also pass `trailing: LikeButton(kind: LikeKind.track, id: t.id)` to each `TrackRow` in the loop. - -- [ ] **Step 16.6: Write a test for optimistic toggle + rollback** - -`flutter_client/test/likes/like_button_test.dart`: - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/api/endpoints/likes.dart'; -import 'package:minstrel/likes/like_button.dart'; -import 'package:minstrel/likes/likes_provider.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -class _ThrowingLikesApi implements LikesApi { - bool throwOnNext = false; - @override - Future like(LikeKind kind, String id) async { - if (throwOnNext) throw StateError('boom'); - } - @override - Future unlike(LikeKind kind, String id) async { - if (throwOnNext) throw StateError('boom'); - } - @override - Future<({Set artists, Set albums, Set tracks})> ids() async => - (artists: {}, albums: {}, tracks: {}); -} - -void main() { - testWidgets('tap toggles icon optimistically; rollback on error', (tester) async { - final api = _ThrowingLikesApi(); - final container = ProviderContainer(overrides: [ - likesApiProvider.overrideWith((ref) async => api), - ]); - addTearDown(container.dispose); - - await tester.pumpWidget(UncontrolledProviderScope( - container: container, - child: MaterialApp( - theme: buildThemeData(), - home: const Scaffold(body: LikeButton(kind: LikeKind.album, id: 'al-1')), - ), - )); - await tester.pumpAndSettle(); - - // First tap = like; succeeds. - await tester.tap(find.byType(IconButton)); - await tester.pump(); - expect(find.byIcon(Icons.favorite), findsOneWidget); - - // Second tap = unlike; force failure → state rolls back to "liked". - api.throwOnNext = true; - final notifier = container.read(likedIdsProvider.notifier); - try { - await notifier.toggle(LikeKind.album, 'al-1'); - } catch (_) {/* expected */} - await tester.pump(); - expect(find.byIcon(Icons.favorite), findsOneWidget); // rolled back - }); -} -``` - -- [ ] **Step 16.7: Run tests** - -```bash -cd flutter_client && flutter test -``` - -Expected: PASS. - -- [ ] **Step 16.8: Commit** - -```bash -git add flutter_client/lib/api/endpoints/likes.dart flutter_client/lib/likes/ flutter_client/lib/library/ flutter_client/test/likes/ -git commit -F - <<'EOF' -feat(flutter/likes): LikeButton with optimistic toggle + rollback - -LikedIdsController loads /api/likes/ids once and mutates the local set -on toggle. Failed mutations roll the set back so the icon never lies. -Wired into ArtistDetail header, AlbumDetail header, and per-track in -the album track list. -EOF -``` - ---- - -### Task 17 — Audio handler + player provider - -**Files:** -- Create: `flutter_client/lib/player/audio_handler.dart` -- Create: `flutter_client/lib/player/player_provider.dart` -- Create: `flutter_client/test/player/player_provider_test.dart` -- Modify: `flutter_client/lib/main.dart` — initialize audio_service -- Modify: `flutter_client/android/app/src/main/AndroidManifest.xml` — audio service intent -- Modify: `flutter_client/ios/Runner/Info.plist` — UIBackgroundModes audio - -- [ ] **Step 17.1: Create `lib/player/audio_handler.dart`** - -```dart -import 'package:audio_service/audio_service.dart'; -import 'package:just_audio/just_audio.dart'; - -import '../models/track.dart'; - -class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { - MinstrelAudioHandler() { - _player.playbackEventStream.listen(_broadcastState); - } - - final AudioPlayer _player = AudioPlayer(); - String _baseUrl = ''; - String? _token; - - void configure({required String baseUrl, required String? token}) { - _baseUrl = baseUrl; - _token = token; - } - - Future setQueueFromTracks(List tracks, {int initialIndex = 0}) async { - final items = tracks.map(_toMediaItem).toList(); - queue.add(items); - if (items.isNotEmpty) mediaItem.add(items[initialIndex.clamp(0, items.length - 1)]); - - final sources = tracks - .map((t) => AudioSource.uri( - Uri.parse('$_baseUrl/api/tracks/${t.id}/stream'), - headers: _token == null ? null : {'Authorization': 'Bearer $_token'}, - )) - .toList(); - - await _player.setAudioSource( - ConcatenatingAudioSource(children: sources), - initialIndex: initialIndex, - ); - } - - MediaItem _toMediaItem(TrackRef t) => MediaItem( - id: t.id, - title: t.title, - artist: t.artistName, - album: t.albumTitle, - duration: Duration(milliseconds: t.durationMs), - ); - - @override - Future play() => _player.play(); - - @override - Future pause() => _player.pause(); - - @override - Future seek(Duration p) => _player.seek(p); - - @override - Future skipToNext() => _player.seekToNext(); - - @override - Future skipToPrevious() => _player.seekToPrevious(); - - void _broadcastState(PlaybackEvent event) { - final playing = _player.playing; - playbackState.add(PlaybackState( - controls: [ - MediaControl.skipToPrevious, - if (playing) MediaControl.pause else MediaControl.play, - MediaControl.skipToNext, - ], - systemActions: const {MediaAction.seek}, - processingState: switch (_player.processingState) { - ProcessingState.idle => AudioProcessingState.idle, - ProcessingState.loading => AudioProcessingState.loading, - ProcessingState.buffering => AudioProcessingState.buffering, - ProcessingState.ready => AudioProcessingState.ready, - ProcessingState.completed => AudioProcessingState.completed, - }, - playing: playing, - updatePosition: _player.position, - bufferedPosition: _player.bufferedPosition, - speed: _player.speed, - queueIndex: event.currentIndex, - )); - } -} -``` - -- [ ] **Step 17.2: Create `lib/player/player_provider.dart`** - -```dart -import 'package:audio_service/audio_service.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - -import '../auth/auth_provider.dart'; -import '../models/track.dart'; -import 'audio_handler.dart'; - -final audioHandlerProvider = Provider((ref) { - throw UnimplementedError('overridden in main()'); -}); - -final playbackStateProvider = StreamProvider( - (ref) => ref.watch(audioHandlerProvider).playbackState, -); - -final mediaItemProvider = StreamProvider( - (ref) => ref.watch(audioHandlerProvider).mediaItem, -); - -final queueProvider = StreamProvider>( - (ref) => ref.watch(audioHandlerProvider).queue, -); - -class PlayerActions { - PlayerActions(this._ref); - final Ref _ref; - - Future playTracks(List tracks, {int initialIndex = 0}) async { - final url = await _ref.read(serverUrlProvider.future); - final token = await _ref.read(secureStorageProvider).read(key: 'session_token'); - final h = _ref.read(audioHandlerProvider)..configure(baseUrl: url ?? '', token: token); - await h.setQueueFromTracks(tracks, initialIndex: initialIndex); - await h.play(); - } -} - -final playerActionsProvider = Provider((ref) => PlayerActions(ref)); -``` - -- [ ] **Step 17.3: Initialize audio_service in `main.dart`** - -Replace `lib/main.dart`: - -```dart -import 'package:audio_service/audio_service.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import 'app.dart'; -import 'player/audio_handler.dart'; -import 'player/player_provider.dart'; - -Future main() async { - WidgetsFlutterBinding.ensureInitialized(); - final handler = await AudioService.init( - builder: () => MinstrelAudioHandler(), - config: const AudioServiceConfig( - androidNotificationChannelId: 'com.fabledsword.minstrel.audio', - androidNotificationChannelName: 'Minstrel playback', - androidNotificationOngoing: true, - ), - ); - runApp(ProviderScope( - overrides: [audioHandlerProvider.overrideWithValue(handler)], - child: const MinstrelApp(), - )); -} -``` - -- [ ] **Step 17.4: Add Android service permissions** - -Edit `flutter_client/android/app/src/main/AndroidManifest.xml`. Inside ``: - -```xml - - - - -``` - -Inside `` (alongside the existing ``): - -```xml - - - - - - - - - - -``` - -- [ ] **Step 17.5: Add iOS background mode** - -Edit `flutter_client/ios/Runner/Info.plist`. Inside the top-level ``: - -```xml -UIBackgroundModes - - audio - -``` - -- [ ] **Step 17.6: Smoke test the player provider (no real audio)** - -`flutter_client/test/player/player_provider_test.dart`: - -```dart -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/player/audio_handler.dart'; -import 'package:minstrel/player/player_provider.dart'; - -void main() { - test('audioHandlerProvider must be overridden — bare read throws', () { - final container = ProviderContainer(); - addTearDown(container.dispose); - expect(() => container.read(audioHandlerProvider), throwsA(isA())); - }); - - test('overridden audioHandlerProvider exposes playbackState stream', () { - final handler = MinstrelAudioHandler(); - final container = ProviderContainer(overrides: [ - audioHandlerProvider.overrideWithValue(handler), - ]); - addTearDown(container.dispose); - expect(container.read(audioHandlerProvider), same(handler)); - // playbackStateProvider is a StreamProvider; just touch it to make sure - // the binding doesn't blow up. - final sub = container.listen(playbackStateProvider, (_, __) {}); - sub.close(); - }); -} -``` - -- [ ] **Step 17.7: Run tests** - -```bash -cd flutter_client && flutter test test/player/ -``` - -Expected: PASS. - -- [ ] **Step 17.8: Commit** - -```bash -git add flutter_client/lib/player/ flutter_client/lib/main.dart flutter_client/android/app/src/main/AndroidManifest.xml flutter_client/ios/Runner/Info.plist flutter_client/test/player/ -git commit -F - <<'EOF' -feat(flutter/player): MinstrelAudioHandler + just_audio + audio_service - -audio_service runs the handler in a background isolate; UI watches -playbackState/mediaItem/queue streams through Riverpod. AndroidManifest -gets the foreground-service media-playback permission; Info.plist gets -UIBackgroundModes=audio. Bearer token attached to stream URLs via -just_audio's per-source headers. -EOF -``` - ---- - -### Task 18 — PlayerBar mini widget + tap-to-play wiring - -**Files:** -- Create: `flutter_client/lib/player/player_bar.dart` -- Modify: `flutter_client/lib/shared/routing.dart` — replace `_ShellWithPlayerBar` body with real PlayerBar -- Modify: `flutter_client/lib/library/home_screen.dart` — wire track-tap to playerActions -- Modify: `flutter_client/lib/library/album_detail_screen.dart` — wire track + album play -- Modify: `flutter_client/lib/library/artist_detail_screen.dart` — wire artist Play button - -- [ ] **Step 18.1: Create `lib/player/player_bar.dart`** - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../theme/theme_extension.dart'; -import 'player_provider.dart'; - -class PlayerBar extends ConsumerWidget { - const PlayerBar({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final mediaItem = ref.watch(mediaItemProvider).valueOrNull; - final playback = ref.watch(playbackStateProvider).valueOrNull; - if (mediaItem == null) return const SizedBox.shrink(); - - return Material( - color: fs.iron, - child: InkWell( - onTap: () => context.push('/now-playing'), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row(children: [ - Container(width: 48, height: 48, color: fs.slate), - const SizedBox(width: 12), - Expanded(child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(mediaItem.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14)), - Text(mediaItem.artist ?? '', maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12)), - ], - )), - IconButton( - icon: Icon(playback?.playing == true ? Icons.pause : Icons.play_arrow, color: fs.parchment), - onPressed: () { - final h = ref.read(audioHandlerProvider); - if (playback?.playing == true) { h.pause(); } else { h.play(); } - }, - ), - IconButton( - icon: Icon(Icons.skip_next, color: fs.parchment), - onPressed: () => ref.read(audioHandlerProvider).skipToNext(), - ), - ]), - ), - ), - ); - } -} -``` - -- [ ] **Step 18.2: Mount PlayerBar in the shell** - -In `lib/shared/routing.dart`, replace the `_ShellWithPlayerBar` class: - -```dart -import '../player/player_bar.dart'; - -class _ShellWithPlayerBar extends StatelessWidget { - const _ShellWithPlayerBar({required this.child}); - final Widget child; - @override - Widget build(BuildContext context) { - return Column( - children: [ - Expanded(child: child), - const PlayerBar(), - ], - ); - } -} -``` - -- [ ] **Step 18.3: Wire track-tap on the home screen** - -In `lib/library/home_screen.dart`, change the track-tap handler in `_tracksRow`: - -```dart -SizedBox( - width: 280, - child: TrackRow( - track: t, - onTap: () => ref.read(playerActionsProvider).playTracks([t]), - ), -), -``` - -- [ ] **Step 18.4: Wire play actions on album detail** - -In `lib/library/album_detail_screen.dart`: - -- Album header play button: replace `onPressed: () { /* play wired in Task 18 */ }` with: - ```dart - onPressed: () => ref.read(playerActionsProvider).playTracks(r.tracks), - ``` -- Per-track tap: replace `onTap: () { /* play wired in Task 18 */ }` with: - ```dart - onTap: () { - final start = r.tracks.indexOf(t); - ref.read(playerActionsProvider).playTracks(r.tracks, initialIndex: start); - }, - ``` - -(Add `import 'package:flutter_riverpod/flutter_riverpod.dart';` and `import '../player/player_provider.dart';` at the top.) - -- [ ] **Step 18.5: Wire play action on artist detail** - -In `lib/library/artist_detail_screen.dart` artist header: - -```dart -import '../player/player_provider.dart'; -// ... -onPressed: () async { - final tracks = await ref.read(artistTracksProvider(id).future); - if (tracks.isEmpty) return; - final shuffled = [...tracks]..shuffle(); - ref.read(playerActionsProvider).playTracks(shuffled); -}, -``` - -- [ ] **Step 18.6: Run tests** - -```bash -cd flutter_client && flutter test -``` - -Expected: PASS (no new test, but the existing widget tests should continue to render with PlayerBar mounted because mediaItem is null and PlayerBar returns SizedBox.shrink()). - -- [ ] **Step 18.7: Commit** - -```bash -git add flutter_client/lib/player/player_bar.dart flutter_client/lib/shared/routing.dart flutter_client/lib/library/ -git commit -F - <<'EOF' -feat(flutter/player): mini PlayerBar in shell + tap-to-play wiring - -PlayerBar reads playbackState + mediaItem streams; hidden when no -queue. Home track-row tap, album play button, album track tap, artist -play button (shuffle) all route to playerActions.playTracks. Token -+ baseUrl forwarded to the audio handler before each new queue. -EOF -``` - ---- - -### Task 19 — NowPlaying screen - -**Files:** -- Create: `flutter_client/lib/player/now_playing_screen.dart` -- Modify: `flutter_client/lib/shared/routing.dart` — add `/now-playing` route - -- [ ] **Step 19.1: Create `lib/player/now_playing_screen.dart`** - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../theme/theme_extension.dart'; -import 'player_provider.dart'; - -class NowPlayingScreen extends ConsumerWidget { - const NowPlayingScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final media = ref.watch(mediaItemProvider).valueOrNull; - final playback = ref.watch(playbackStateProvider).valueOrNull; - - if (media == null) { - return const Scaffold(body: Center(child: Text('Nothing playing.'))); - } - - final pos = playback?.updatePosition ?? Duration.zero; - final dur = media.duration ?? Duration.zero; - - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - leading: IconButton( - icon: Icon(Icons.expand_more, color: fs.parchment), - onPressed: () => Navigator.of(context).pop(), - ), - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column(children: [ - const Spacer(), - Container( - width: 280, height: 280, color: fs.slate, - ), - const SizedBox(height: 24), - Text(media.title, style: TextStyle(color: fs.parchment, fontSize: 22)), - Text(media.artist ?? '', style: TextStyle(color: fs.ash, fontSize: 14)), - const SizedBox(height: 16), - Slider( - activeColor: fs.accent, - min: 0, - max: dur.inMilliseconds.toDouble().clamp(1, double.infinity), - value: pos.inMilliseconds.toDouble().clamp(0, dur.inMilliseconds.toDouble()), - onChanged: (v) => ref.read(audioHandlerProvider).seek(Duration(milliseconds: v.toInt())), - ), - Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - IconButton( - icon: Icon(Icons.skip_previous, color: fs.parchment, size: 32), - onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(), - ), - IconButton( - iconSize: 56, - icon: Icon(playback?.playing == true ? Icons.pause_circle_filled : Icons.play_circle_filled, color: fs.accent), - onPressed: () { - final h = ref.read(audioHandlerProvider); - if (playback?.playing == true) { h.pause(); } else { h.play(); } - }, - ), - IconButton( - icon: Icon(Icons.skip_next, color: fs.parchment, size: 32), - onPressed: () => ref.read(audioHandlerProvider).skipToNext(), - ), - ]), - const Spacer(), - ]), - ), - ), - ); - } -} -``` - -- [ ] **Step 19.2: Add `/now-playing` route** - -In `lib/shared/routing.dart`, inside the ShellRoute's `routes:` list, append: - -```dart -import '../player/now_playing_screen.dart'; -// ... -GoRoute(path: '/now-playing', builder: (_, __) => const NowPlayingScreen()), -``` - -- [ ] **Step 19.3: Verify build** - -```bash -cd flutter_client && flutter analyze && flutter test -``` - -Expected: clean. - -- [ ] **Step 19.4: Commit** - -```bash -git add flutter_client/lib/player/now_playing_screen.dart flutter_client/lib/shared/routing.dart -git commit -F - <<'EOF' -feat(flutter/player): NowPlaying full-screen view with scrubber - -Reached by tapping the PlayerBar. Reads media + playback streams; -seek dispatches through the audio handler. Swipe-down dismiss is -implemented via the AppBar back arrow (chevron_down icon). -EOF -``` - ---- - -### Task 20 — Connection error banner + 401 redirect interceptor - -**Files:** -- Create: `flutter_client/lib/shared/widgets/connection_error_banner.dart` -- Modify: `flutter_client/lib/api/client.dart` — add 401 → unauthenticated redirect handler -- Modify: `flutter_client/lib/library/home_screen.dart` — surface ConnectionErrorBanner on `connection_refused` - -- [ ] **Step 20.1: Create the banner widget** - -```dart -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - -import '../../theme/theme_extension.dart'; - -class ConnectionErrorBanner extends StatelessWidget { - const ConnectionErrorBanner({required this.onRetry, super.key}); - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Container( - color: fs.iron, - padding: const EdgeInsets.all(12), - child: Row(children: [ - Expanded( - child: Text( - "Couldn't reach the server.", - style: TextStyle(color: fs.parchment), - ), - ), - TextButton(onPressed: onRetry, child: const Text('Retry')), - TextButton( - onPressed: () => context.go('/server-url'), - child: const Text('Change URL'), - ), - ]), - ); - } -} -``` - -- [ ] **Step 20.2: Extend the dio client builder with a 401 handler** - -Modify `lib/api/client.dart` — the `buildDio` factory now takes an optional `on401`: - -```dart -typedef OnUnauthenticated = Future Function(); - -class ApiClient { - static Dio buildDio({ - required String baseUrl, - required TokenResolver tokenResolver, - OnUnauthenticated? on401, - }) { - final d = Dio(BaseOptions( - baseUrl: baseUrl, - connectTimeout: const Duration(seconds: 8), - receiveTimeout: const Duration(seconds: 30), - contentType: Headers.jsonContentType, - responseType: ResponseType.json, - )); - d.interceptors.add(InterceptorsWrapper( - onRequest: (opts, h) async { - final t = await tokenResolver(); - if (t != null && t.isNotEmpty) opts.headers['Authorization'] = 'Bearer $t'; - h.next(opts); - }, - onError: (e, h) async { - if (e.response?.statusCode == 401 && on401 != null) { - await on401(); - } - h.next(e); - }, - )); - return d; - } -} -``` - -- [ ] **Step 20.3: Wire `on401` in the dio provider** - -Modify `lib/library/library_providers.dart`: - -```dart -final dioProvider = FutureProvider((ref) async { - final url = await ref.watch(serverUrlProvider.future); - if (url == null) throw StateError('no server URL set'); - final storage = ref.watch(secureStorageProvider); - return ApiClient.buildDio( - baseUrl: url, - tokenResolver: () async => storage.read(key: 'session_token'), - on401: () async => ref.read(authControllerProvider.notifier).clearSession(), - ); -}); -``` - -(Add `import '../auth/auth_provider.dart';` if not already imported.) - -- [ ] **Step 20.4: Surface the banner on `connection_refused`** - -In `lib/library/home_screen.dart`, replace the error branch of the `.when` with a banner-aware rendering: - -```dart -error: (e, _) { - final code = e is DioException ? ApiError.fromDio(e).code : 'unknown'; - if (code == 'connection_refused') { - return ConnectionErrorBanner( - onRetry: () => ref.refresh(homeProvider), - ); - } - return Center(child: Text('$e', style: TextStyle(color: fs.error))); -}, -``` - -(Add imports: `import 'package:dio/dio.dart';`, `import '../api/errors.dart';`, `import '../shared/widgets/connection_error_banner.dart';`.) - -- [ ] **Step 20.5: Run tests** - -```bash -cd flutter_client && flutter test -``` - -Expected: PASS. - -- [ ] **Step 20.6: Commit** - -```bash -git add flutter_client/lib/shared/widgets/connection_error_banner.dart flutter_client/lib/api/client.dart flutter_client/lib/library/library_providers.dart flutter_client/lib/library/home_screen.dart -git commit -F - <<'EOF' -feat(flutter): connection-error banner + 401-clears-session interceptor - -ApiClient.buildDio takes on401; the library's dio wires it to the -auth controller's clearSession. The router redirect already handles -the navigation away from authed screens once the session goes null. -HomeScreen surfaces the banner on connection_refused with Retry + -Change URL. -EOF -``` - ---- - -### Task 21 — Forgejo CI workflow + APK release - -**Files:** -- Create: `.forgejo/workflows/flutter.yml` - -- [ ] **Step 21.1: Create the workflow** - -```yaml -name: flutter - -on: - push: - branches: [dev, main] - paths: - - 'flutter_client/**' - - 'web/src/lib/styles/tokens.json' - - 'web/src/lib/styles/error-copy.json' - - 'web/static/placeholders/album-fallback.svg' - - '.forgejo/workflows/flutter.yml' - pull_request: - release: - types: [published] - -jobs: - analyze-test-build: - runs-on: ubuntu-latest - container: - image: ghcr.io/cirruslabs/flutter:3.24.5 - defaults: - run: - working-directory: flutter_client - steps: - - uses: actions/checkout@v4 - - - name: Sync shared assets - run: ./tool/sync_shared.sh - - - name: Verify generated tokens.dart matches JSON - run: | - dart run tool/gen_tokens.dart - if ! git diff --exit-code lib/theme/tokens.dart; then - echo "::error::lib/theme/tokens.dart is out of sync with shared/fabledsword.tokens.json" - echo "Run: cd flutter_client && dart run tool/gen_tokens.dart" - exit 1 - fi - - - name: Pub get - run: flutter pub get - - - name: Analyze - run: flutter analyze --fatal-infos - - - name: Test - run: flutter test --reporter compact - - - name: Build debug APK - if: github.event_name == 'push' - run: flutter build apk --debug - - - name: Build release APK - if: github.event_name == 'release' - run: flutter build apk --release - - - name: Upload debug APK artifact - if: github.event_name == 'push' - uses: forgejo/upload-artifact@v3 - with: - name: minstrel-debug-${{ github.sha }}.apk - path: flutter_client/build/app/outputs/flutter-apk/app-debug.apk - - - name: Attach release APK to release - if: github.event_name == 'release' - run: | - curl -fsSL \ - -H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \ - -F "attachment=@build/app/outputs/flutter-apk/app-release.apk" \ - "${{ github.event.release.upload_url }}" -``` - -- [ ] **Step 21.2: Confirm Forgejo Actions runner picks it up** - -After committing, push to a branch and check Forgejo Actions in the web UI. Expected: workflow runs, all steps green on a clean checkout. - -- [ ] **Step 21.3: Commit** - -```bash -git add .forgejo/workflows/flutter.yml -git commit -F - <<'EOF' -ci(flutter): Forgejo workflow — sync, analyze, test, build APK - -Triggers on push to dev/main, pull_request, and release. Verifies -that lib/theme/tokens.dart matches shared/fabledsword.tokens.json -(catches drift between the JSON source-of-truth and the committed -generated file). Uploads a debug APK on every push and attaches the -release APK to the Forgejo release on tag. -EOF -``` - ---- - -### Task 22 — README + slice ship checklist - -**Files:** -- Create: `flutter_client/README.md` - -- [ ] **Step 22.1: Write the README** - -```markdown -# Minstrel mobile client - -Flutter (iOS + Android) sibling of the SvelteKit web SPA. v1 first -slice: scaffold, auth, library browse (home / artist / album), likes, -player with background audio + lock-screen controls. - -## Setup - -```bash -cd flutter_client -flutter pub get -./tool/sync_shared.sh # copy shared tokens, error-copy, placeholders from web/ -dart run tool/gen_tokens.dart -flutter run -``` - -## Project layout - -See `docs/superpowers/specs/2026-05-02-flutter-mobile-foundation-design.md` §3. - -## CI - -`.forgejo/workflows/flutter.yml` runs analyze + test + build on every -push. Debug APKs upload as artifacts; release APKs attach to the -Forgejo release on tag. - -## Versioning - -`pubspec.yaml` `version` mirrors the server tag (e.g. server `v0.1.0` -→ Flutter `0.1.0+1`). The server's `/healthz` returns -`min_client_version`; old clients show an Update Required modal and -refuse to operate. - -## Distribution - -- **Android:** APK on the Forgejo release page. No Play Store for v1. -- **iOS:** TestFlight on tag (manual upload v1). - -## Out-of-scope for slice 1 - -Search, discover, requests, settings beyond server URL, admin, -listening history, playlists, offline cache (#357). Those land in -subsequent slices with their own brainstorm/spec/plan cycles. -``` - -- [ ] **Step 22.2: Commit** - -```bash -git add flutter_client/README.md -git commit -F - <<'EOF' -docs(flutter): README for setup, layout, CI, distribution - -Pointers to the spec, CI workflow, and the v1 slice-1 scope so a -fresh contributor knows what's in slice 1 vs. what's deferred. -EOF -``` - ---- - -## Self-review - -1. **Spec coverage check:** - - §2 Goals — all in tasks 1–22. - - §3 Architecture — Tasks 4–11, 17 cover scaffold, layering, auth flow, audio. - - §4 Theme tokens — Tasks 1, 5, 6. - - §5 Screens — Tasks 14 (home), 15 (artist + album), 18–19 (player + nowplaying), 9–10 (auth). - - §6 Backend touch-points — Task 3 (`/healthz` `min_client_version`); login token already in body, so no change needed. - - §7 Error handling — Tasks 7 (ApiError), 11 (version gate), 20 (banner + 401 + stream errors covered by player surface in Task 18 — note: dedicated stream-error banner in player UI is intentionally elided to keep scope tight; just_audio's playback errors propagate through `playbackState.processingState` and the existing PlayerBar reflects them; if a richer banner is needed it lands as a hotfix in slice 2). - - §8 Testing — every screen has a smoke test; api/auth/likes/player providers have unit tests. - - §9 Distribution — Task 21. - -2. **Placeholder scan:** No "TBD"/"TODO" markers in the plan. Comments inside generated code (e.g. `/* play wired in Task 18 */`) are removed in Task 18 itself. - -3. **Type consistency:** `LikeKind`, `LikedIds`, `PlayerActions`, `MinstrelAudioHandler`, `FabledSwordTheme`, `ApiError`, `TokenResolver`, `OnUnauthenticated` all consistent across tasks. - -4. **One spec deviation noted:** spec §6 mentions a possible "Bearer-auth login response" backend touch-point. Inspection in this plan-writing pass confirmed `LoginResponse` already includes the token in the body (`internal/api/auth.go:115`), so the touch-point is unnecessary. Plan does not include it; documented here for reviewers. diff --git a/docs/superpowers/plans/2026-05-03-m7-362-theme-toggle.md b/docs/superpowers/plans/2026-05-03-m7-362-theme-toggle.md deleted file mode 100644 index 10623586..00000000 --- a/docs/superpowers/plans/2026-05-03-m7-362-theme-toggle.md +++ /dev/null @@ -1,860 +0,0 @@ -# M7 #362 — Web SPA Theme Toggle Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Ship a Dark / Light / System theme toggle for the Minstrel web SPA, persisted to localStorage and applied before first paint. - -**Architecture:** CSS custom-property indirection via existing `--fs-*` tokens. `tokens.json` grows from a flat `colors` map to `colors.dark` + `colors.light` + `colors.flat`. Generator emits `:root { ... }` (dark + flat) and `[data-theme="light"] { ... }` (light overrides only). Theme is applied by setting `data-theme` on ``; an inline `` script does this before paint to avoid FOUC. - -**Tech Stack:** SvelteKit 2 / Svelte 5 (runes mode), Tailwind CSS, vanilla `matchMedia` + `localStorage`. No new dependencies. - -**Spec:** `docs/superpowers/specs/2026-05-03-m7-362-theme-toggle-design.md` (commit `1d6a71a` on `dev`). - -**Test policy:** No in-task test runs. Tests are written as files for CI to execute (vitest under `web/src/**/*.test.ts` and `web/scripts/*.test.js`); the implementer never invokes `npm test`, `vitest`, `npm run build`, or `npm run check` during this work. Codegen scripts (`npm run tokens`) ARE allowed. - ---- - -## File Structure - -**Modified:** -- `web/src/lib/styles/tokens.json` — schema change: `colors` → `colors.dark` + `colors.light` + `colors.flat`; add `on-action` flat token. -- `web/scripts/tokens-to-css.js` — emit `:root` (dark + flat) and `[data-theme="light"]` blocks. -- `web/src/lib/styles/tokens.generated.css` — regenerated artifact (do not hand-edit). -- `web/tailwind.config.js` — add `colors.action.fg = 'var(--fs-on-action)'`. -- `web/src/app.html` — inline FOUC script in ``; remove static `class="dark"` on ``. -- `web/src/routes/settings/+page.svelte` — add Appearance card. -- ~25 component/route files — replace `text-text-primary` with `text-action-fg` on action-button labels. - -**Created:** -- `web/src/lib/stores/theme.svelte.ts` — theme preference + resolved-theme state with matchMedia listener. -- `web/src/lib/stores/theme.svelte.test.ts` — store unit test (CI). -- `web/scripts/tokens-to-css.test.js` — generator unit test (CI). -- `web/src/routes/settings/Appearance.test.ts` — Appearance card render test (CI). - ---- - -## Task 1: Restructure tokens.json + dual-block generator - -**Files:** -- Modify: `web/src/lib/styles/tokens.json` -- Modify: `web/scripts/tokens-to-css.js` -- Modify: `web/src/lib/styles/tokens.generated.css` (regenerated) - -- [ ] **Step 1: Restructure `tokens.json` colors map** - -Replace the entire `colors` object at the top of `web/src/lib/styles/tokens.json`. The other top-level sections (`radii`, `fonts`, `fontStacks`) stay untouched. - -```json -{ - "colors": { - "dark": { - "obsidian": "#14171A", - "iron": "#1E2228", - "slate": "#2C313A", - "pewter": "#3F4651", - "parchment": "#E8E4D8", - "vellum": "#C2BFB4", - "ash": "#9C9A92" - }, - "light": { - "obsidian": "#F8F5EE", - "iron": "#ECE6D5", - "slate": "#DCD3BD", - "pewter": "#B8AE94", - "parchment": "#14171A", - "vellum": "#2C313A", - "ash": "#5C6068" - }, - "flat": { - "moss": "#4A5D3F", - "bronze": "#8B7355", - "oxblood": "#6B2118", - "warning": "#8B6F1E", - "error": "#C04A1F", - "info": "#3D5A6E", - "accent": "#4A6B5C", - "on-action": "#E8E4D8" - } - }, - "radii": { "sm": "4px", "md": "8px", "lg": "12px", "xl": "16px" }, - "fonts": { "display": "Fraunces", "body": "Inter", "mono": "JetBrains Mono" }, - "fontStacks": { - "display": "'Fraunces', Georgia, serif", - "body": "'Inter', system-ui, sans-serif", - "mono": "'JetBrains Mono', ui-monospace, monospace" - } -} -``` - -- [ ] **Step 2: Rewrite the generator to emit dual blocks** - -Replace the entire body of `web/scripts/tokens-to-css.js` with: - -```js -#!/usr/bin/env node -// Reads ../src/lib/styles/tokens.json and emits tokens.generated.css. -// Single source of truth for FabledSword tokens shared with the Flutter -// client. Keep output deterministic — Tailwind reads tokens.json directly, -// the .css emission is for raw CSS consumers and theme switching. -import { readFileSync, writeFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, resolve } from 'node:path'; - -const here = dirname(fileURLToPath(import.meta.url)); -const tokensPath = resolve(here, '../src/lib/styles/tokens.json'); -const outPath = resolve(here, '../src/lib/styles/tokens.generated.css'); - -export function emit(tokens) { - const lines = ['/* GENERATED — do not edit. Source: tokens.json */', ':root {']; - for (const [name, value] of Object.entries(tokens.colors.dark)) { - lines.push(` --fs-${name}: ${value};`); - } - for (const [name, value] of Object.entries(tokens.colors.flat)) { - lines.push(` --fs-${name}: ${value};`); - } - for (const [name, value] of Object.entries(tokens.radii)) { - lines.push(` --fs-radius-${name}: ${value};`); - } - lines.push(` --fs-font-display: ${tokens.fontStacks.display};`); - lines.push(` --fs-font-body: ${tokens.fontStacks.body};`); - lines.push(` --fs-font-mono: ${tokens.fontStacks.mono};`); - lines.push('}'); - - lines.push('[data-theme="light"] {'); - for (const [name, value] of Object.entries(tokens.colors.light)) { - lines.push(` --fs-${name}: ${value};`); - } - lines.push('}'); - - lines.push('[data-fs-app="minstrel"] {', ` --fs-accent: ${tokens.colors.flat.accent};`, '}', ''); - return lines.join('\n'); -} - -// Run as script: read tokens.json + write generated CSS -if (import.meta.url === `file://${process.argv[1]}`) { - const tokens = JSON.parse(readFileSync(tokensPath, 'utf8')); - writeFileSync(outPath, emit(tokens)); - console.log(`wrote ${outPath}`); -} -``` - -Note the new `export function emit(tokens)` so the unit test (Task 7) can import it directly without spawning a subprocess. The CLI behavior at the bottom only fires when invoked as a script. - -- [ ] **Step 3: Regenerate `tokens.generated.css`** - -```bash -cd web && npm run tokens -``` - -Expected output: `wrote /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web/src/lib/styles/tokens.generated.css` - -The new file content should look like: - -```css -/* GENERATED — do not edit. Source: tokens.json */ -:root { - --fs-obsidian: #14171A; - --fs-iron: #1E2228; - --fs-slate: #2C313A; - --fs-pewter: #3F4651; - --fs-parchment: #E8E4D8; - --fs-vellum: #C2BFB4; - --fs-ash: #9C9A92; - --fs-moss: #4A5D3F; - --fs-bronze: #8B7355; - --fs-oxblood: #6B2118; - --fs-warning: #8B6F1E; - --fs-error: #C04A1F; - --fs-info: #3D5A6E; - --fs-accent: #4A6B5C; - --fs-on-action: #E8E4D8; - --fs-radius-sm: 4px; - --fs-radius-md: 8px; - --fs-radius-lg: 12px; - --fs-radius-xl: 16px; - --fs-font-display: 'Fraunces', Georgia, serif; - --fs-font-body: 'Inter', system-ui, sans-serif; - --fs-font-mono: 'JetBrains Mono', ui-monospace, monospace; -} -[data-theme="light"] { - --fs-obsidian: #F8F5EE; - --fs-iron: #ECE6D5; - --fs-slate: #DCD3BD; - --fs-pewter: #B8AE94; - --fs-parchment: #14171A; - --fs-vellum: #2C313A; - --fs-ash: #5C6068; -} -[data-fs-app="minstrel"] { - --fs-accent: #4A6B5C; -} -``` - -- [ ] **Step 4: Commit** - -```bash -git add web/src/lib/styles/tokens.json web/scripts/tokens-to-css.js web/src/lib/styles/tokens.generated.css -git commit -m "feat(web/m7-362): tokens.json dark/light/flat split + dual-block CSS generator" -``` - ---- - -## Task 2: Add `text-action-fg` Tailwind alias - -**Files:** -- Modify: `web/tailwind.config.js` - -- [ ] **Step 1: Add `fg` to the action color group** - -Edit `web/tailwind.config.js`. In the `action` block under `theme.extend.colors`, add the `fg` entry: - -```js -action: { - primary: 'var(--fs-moss)', - secondary: 'var(--fs-bronze)', - destructive: 'var(--fs-oxblood)', - fg: 'var(--fs-on-action)' -}, -``` - -The full file after edit: - -```js -export default { - content: ['./src/**/*.{html,js,ts,svelte}'], - darkMode: 'class', - theme: { - extend: { - colors: { - background: 'var(--fs-obsidian)', - surface: { - DEFAULT: 'var(--fs-iron)', - hover: 'var(--fs-slate)' - }, - border: { - DEFAULT: 'var(--fs-pewter)' - }, - text: { - primary: 'var(--fs-parchment)', - secondary: 'var(--fs-vellum)', - muted: 'var(--fs-ash)' - }, - action: { - primary: 'var(--fs-moss)', - secondary: 'var(--fs-bronze)', - destructive: 'var(--fs-oxblood)', - fg: 'var(--fs-on-action)' - }, - accent: { - DEFAULT: 'var(--fs-accent)', - tint: 'color-mix(in srgb, var(--fs-accent) 12%, transparent)' - }, - warning: 'var(--fs-warning)', - error: 'var(--fs-error)', - info: 'var(--fs-info)' - }, - borderRadius: { - sm: 'var(--fs-radius-sm)', - md: 'var(--fs-radius-md)', - lg: 'var(--fs-radius-lg)', - xl: 'var(--fs-radius-xl)' - }, - fontFamily: { - display: ['var(--fs-font-display)'], - sans: ['var(--fs-font-body)'], - mono: ['var(--fs-font-mono)'] - } - } - }, - plugins: [] -}; -``` - -- [ ] **Step 2: Commit** - -```bash -git add web/tailwind.config.js -git commit -m "feat(web/m7-362): add Tailwind text-action-fg alias for non-flipping label color" -``` - ---- - -## Task 3: Replace action-button label class across the codebase - -**Files:** -- Modify: every site where a `bg-action-primary` / `bg-action-secondary` / `bg-action-destructive` element also carries `text-text-primary` - -This is a mechanical audit + replace. The grep performed during planning found ~25 sites; the implementer should re-grep to catch any new sites since. - -- [ ] **Step 1: Run the audit grep** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web -grep -rn "bg-action-\(primary\|secondary\|destructive\)" src/ | grep "text-text-primary" -``` - -Expected output: a list of file:line entries, each containing both classes on the same element. Confirmed at planning time: - -``` -src/lib/components/AddToPlaylistMenu.svelte:113 -src/lib/components/DiscoverResultCard.svelte:77 -src/lib/components/FlagPopover.svelte:91 -src/routes/admin/+page.svelte:239 -src/routes/admin/+page.svelte:302 -src/routes/admin/+page.svelte:312 -src/routes/admin/integrations/+page.svelte:276 -src/routes/admin/integrations/+page.svelte:293 -src/routes/admin/integrations/+page.svelte:360 -src/routes/admin/integrations/+page.svelte:368 -src/routes/admin/quarantine/+page.svelte:293 -src/routes/admin/quarantine/+page.svelte:304 -src/routes/admin/quarantine/+page.svelte:315 -src/routes/admin/quarantine/+page.svelte:364 -src/routes/admin/quarantine/+page.svelte:426 -src/routes/admin/requests/+page.svelte:273 -src/routes/admin/requests/+page.svelte:282 -src/routes/admin/requests/+page.svelte:355 -src/routes/admin/requests/+page.svelte:407 -src/routes/discover/+page.svelte:221 -src/routes/discover/+page.svelte:228 -src/routes/playlists/+page.svelte:46 -src/routes/playlists/+page.svelte:81 -src/routes/playlists/[id]/+page.svelte:216 -``` - -- [ ] **Step 2: Replace `text-text-primary` with `text-action-fg` at each site** - -For each file:line above, edit the element so `text-text-primary` becomes `text-action-fg`. Other classes on the same element (rounded, px, py, hover:opacity-90, disabled:opacity-50, etc.) stay. - -Example: `web/src/lib/components/DiscoverResultCard.svelte:77` before: -```svelte - -``` - -If the FabledSword class names differ (e.g., `bg-surface-secondary` vs. `bg-slate`), adapt to whatever AlbumCard uses. Read `web/src/lib/components/AlbumCard.svelte` for the canonical card pattern + class names. - -- [ ] **Step 7.2: Write `web/src/lib/components/PlaylistCard.test.ts`** - -```ts -import { describe, expect, test } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import { writable } from 'svelte/store'; -import { vi } from 'vitest'; -import PlaylistCard from './PlaylistCard.svelte'; -import type { Playlist } from '$lib/api/types'; - -vi.mock('$lib/auth/store.svelte', () => ({ - user: { value: { id: 'u-self', username: 'me', is_admin: false } } -})); - -vi.mock('$app/navigation', () => ({ goto: vi.fn() })); - -const base: Playlist = { - id: 'p-1', - user_id: 'u-self', - owner_username: 'me', - name: 'Saturday morning', - description: '', - is_public: false, - cover_url: '', - track_count: 12, - duration_sec: 0, - created_at: '', - updated_at: '' -}; - -describe('PlaylistCard', () => { - test('renders own playlist without owner attribution', () => { - render(PlaylistCard, { props: { playlist: base } }); - expect(screen.getByText('Saturday morning')).toBeInTheDocument(); - expect(screen.getByText('12 tracks')).toBeInTheDocument(); - // No "by ..." for own playlists. - expect(screen.queryByText(/by /)).not.toBeInTheDocument(); - }); - - test('renders cover image when cover_url is set', () => { - render(PlaylistCard, { props: { playlist: { ...base, cover_url: '/api/playlists/p-1/cover' } } }); - const img = screen.getByRole('img'); - expect(img.getAttribute('src')).toBe('/api/playlists/p-1/cover'); - }); - - test('renders glyph fallback when cover_url is empty', () => { - render(PlaylistCard, { props: { playlist: base } }); - expect(screen.getByText(/no tracks yet/i)).toBeInTheDocument(); - }); - - test("attributes other users' playlists to the owner", () => { - render(PlaylistCard, { props: { playlist: { ...base, user_id: 'u-bob', owner_username: 'bob' } } }); - expect(screen.getByText(/by bob/i)).toBeInTheDocument(); - }); -}); -``` - -- [ ] **Step 7.3: Commit** - -```bash -git add web/src/lib/components/PlaylistCard.svelte web/src/lib/components/PlaylistCard.test.ts -git commit -F - <<'EOF' -feat(web): PlaylistCard component for M7 #352 slice 1 - -Square card with cover (or "No tracks yet" glyph fallback), name, -track count, and owner attribution when the playlist isn't the -current user's. Click navigates to /playlists/{id}. -EOF -``` - ---- - -### Task 8 — `` component - -**Files:** -- Create: `web/src/lib/components/PlaylistTrackRow.svelte` -- Create: `web/src/lib/components/PlaylistTrackRow.test.ts` - -- [ ] **Step 8.1: Write `web/src/lib/components/PlaylistTrackRow.svelte`** - -```svelte - - -
onDragStart?.(row.position)} - ondragover={(e) => { e.preventDefault(); onDragOver?.(e, row.position); }} - ondrop={(e) => { e.preventDefault(); onDrop?.(e, row.position); }} -> - {#if isOwner} - - - - {/if} - - - - {format(row.duration_sec)} - - {#if !isUnavailable && liveTrack} - - - {/if} - - {#if isOwner} - - {/if} -
-``` - -If the project's `LikeButton` API differs (e.g., uses `kind` instead of `entityType`), adapt. Read `web/src/lib/components/LikeButton.svelte` for the canonical shape — Task 16 of M7 #356 documented this surface. - -- [ ] **Step 8.2: Write `web/src/lib/components/PlaylistTrackRow.test.ts`** - -```ts -import { describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; -import PlaylistTrackRow from './PlaylistTrackRow.svelte'; -import type { PlaylistTrack } from '$lib/api/types'; - -// LikeButton + TrackMenu transitively pull in TanStack Query and the -// auth store; mock both at module level. -vi.mock('@tanstack/svelte-query', () => ({ - useQueryClient: () => ({ invalidateQueries: vi.fn() }), - createQuery: () => ({ subscribe: () => () => {} }) -})); - -vi.mock('$lib/auth/store.svelte', () => ({ - user: { value: { id: 'u', username: 'me', is_admin: false } } -})); - -vi.mock('$lib/api/likes', () => ({ - createLikedIdsQuery: () => ({ - subscribe: () => () => {}, - data: { track_ids: [], album_ids: [], artist_ids: [] } - }), - likeEntity: vi.fn(), - unlikeEntity: vi.fn() -})); - -vi.mock('$lib/api/quarantine', () => ({ - createMyQuarantineQuery: () => ({ subscribe: () => () => {} }), - flagTrack: vi.fn(), - unflagTrack: vi.fn() -})); - -vi.mock('$lib/player/store.svelte', () => ({ - playNext: vi.fn(), - enqueueTrack: vi.fn() -})); - -vi.mock('$app/navigation', () => ({ goto: vi.fn() })); - -vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn() })); - -const live: PlaylistTrack = { - position: 2, - track_id: 't-1', - album_id: 'a-1', - artist_id: 'ar-1', - title: 'Roygbiv', - artist_name: 'Boards of Canada', - album_title: 'MHTRTC', - duration_sec: 137, - stream_url: '/api/tracks/t-1/stream', - added_at: '' -}; - -const removed: PlaylistTrack = { - ...live, - track_id: null, - album_id: null, - artist_id: null, - stream_url: null -}; - -describe('PlaylistTrackRow', () => { - test('renders title, artist, mm:ss duration', () => { - render(PlaylistTrackRow, { - props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() } - }); - expect(screen.getByText('Roygbiv')).toBeInTheDocument(); - expect(screen.getByText('2:17')).toBeInTheDocument(); - }); - - test('shows drag handle and remove button for owner', () => { - render(PlaylistTrackRow, { - props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() } - }); - expect(screen.getByLabelText('Drag handle')).toBeInTheDocument(); - expect(screen.getByLabelText(/Remove Roygbiv from playlist/i)).toBeInTheDocument(); - }); - - test('hides drag handle and remove button for non-owner', () => { - render(PlaylistTrackRow, { - props: { row: live, isOwner: false, onRemove: vi.fn(), onPlay: vi.fn() } - }); - expect(screen.queryByLabelText('Drag handle')).not.toBeInTheDocument(); - expect(screen.queryByLabelText(/Remove/i)).not.toBeInTheDocument(); - }); - - test('greyed-out + strikethrough when track_id is null', () => { - render(PlaylistTrackRow, { - props: { row: removed, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() } - }); - const titleEl = screen.getByText('Roygbiv'); - expect(titleEl.className).toContain('line-through'); - }); - - test('clicking the title fires onPlay with the position', async () => { - const onPlay = vi.fn(); - render(PlaylistTrackRow, { - props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay } - }); - await fireEvent.click(screen.getByText('Roygbiv')); - expect(onPlay).toHaveBeenCalledWith(2); - }); -}); -``` - -- [ ] **Step 8.3: Commit** - -```bash -git add web/src/lib/components/PlaylistTrackRow.svelte web/src/lib/components/PlaylistTrackRow.test.ts -git commit -F - <<'EOF' -feat(web): PlaylistTrackRow component for M7 #352 slice 1 - -Variant of TrackRow specialised for playlist detail. Drag handle -visible to owner only; remove button (X) visible to owner only; -greyed-out + strikethrough when track_id is null (upstream track -removed from library). Reuses the existing LikeButton and TrackMenu -when the track is still alive. -EOF -``` - ---- - -### Task 9 — `` + wire into `` - -**Files:** -- Create: `web/src/lib/components/AddToPlaylistMenu.svelte` -- Create: `web/src/lib/components/AddToPlaylistMenu.test.ts` -- Modify: `web/src/lib/components/TrackMenu.svelte` — replace the disabled "Add to playlist…" entry with a real opener -- Modify: `web/src/lib/components/TrackMenu.test.ts` — update the assertion - -- [ ] **Step 9.1: Write `web/src/lib/components/AddToPlaylistMenu.svelte`** - -```svelte - - - -``` - -- [ ] **Step 9.2: Write `web/src/lib/components/AddToPlaylistMenu.test.ts`** - -```ts -import { describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; -import AddToPlaylistMenu from './AddToPlaylistMenu.svelte'; -import type { TrackRef } from '$lib/api/types'; - -vi.mock('@tanstack/svelte-query', () => ({ - useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) }) -})); - -vi.mock('$lib/auth/store.svelte', () => ({ - user: { value: { id: 'u-self', username: 'me', is_admin: false } } -})); - -const playlistsData = { - owned: [ - { id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'B-list', description: '', is_public: false, cover_url: '', track_count: 3, duration_sec: 0, created_at: '', updated_at: '' }, - { id: 'p2', user_id: 'u-self', owner_username: 'me', name: 'A-list', description: '', is_public: false, cover_url: '', track_count: 5, duration_sec: 0, created_at: '', updated_at: '' } - ], - public: [] -}; - -vi.mock('$lib/api/playlists', () => ({ - createPlaylistsQuery: () => ({ - subscribe: (cb: (v: unknown) => void) => { cb({ data: playlistsData }); return () => {}; }, - // The component uses `$derived(createPlaylistsQuery())` which expects - // a store-like object. Provide a `data` property directly so the - // sort-by-name fallback path can read it without a real store. - data: playlistsData - }), - appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }), - createPlaylist: vi.fn().mockResolvedValue({ id: 'p3', name: 'New' }) -})); - -const track: TrackRef = { - id: 't1', title: 'Roygbiv', - album_id: 'a1', album_title: 'MHTRTC', - artist_id: 'ar1', artist_name: 'BoC', - duration_sec: 137, stream_url: '/api/tracks/t1/stream' -} as unknown as TrackRef; - -describe('AddToPlaylistMenu', () => { - test('lists own playlists alphabetically', () => { - render(AddToPlaylistMenu, { props: { track, onClose: vi.fn() } }); - const items = screen.getAllByRole('menuitem'); - // First two should be the playlists in alpha order, then "New playlist…". - expect(items[0].textContent).toContain('A-list'); - expect(items[1].textContent).toContain('B-list'); - expect(items[2].textContent).toContain('New playlist'); - }); - - test('clicking a playlist appends and closes', async () => { - const onClose = vi.fn(); - const { appendTracks } = await import('$lib/api/playlists'); - render(AddToPlaylistMenu, { props: { track, onClose } }); - await fireEvent.click(screen.getByRole('menuitem', { name: /A-list/ })); - await waitFor(() => expect(onClose).toHaveBeenCalled()); - expect(appendTracks).toHaveBeenCalledWith('p2', ['t1']); - }); - - test('"New playlist…" toggles inline create form', async () => { - render(AddToPlaylistMenu, { props: { track, onClose: vi.fn() } }); - await fireEvent.click(screen.getByRole('menuitem', { name: /New playlist/ })); - expect(screen.getByPlaceholderText(/playlist name/i)).toBeInTheDocument(); - expect(screen.getByText(/Create & add/i)).toBeInTheDocument(); - }); -}); -``` - -The mocked `createPlaylistsQuery` shape is approximate. If the component reads via `$derived($store?.data)`, the mock needs to produce an object with a `subscribe` method that calls back with a value containing `data`. Read `LikeButton.svelte` and its test for the project's mocking pattern of TanStack Query stores in Svelte 5. - -- [ ] **Step 9.3: Wire into `TrackMenu.svelte`** - -Read `web/src/lib/components/TrackMenu.svelte`. Find the disabled "Add to playlist…" entry: - -```svelte - -``` - -Replace with an opener that mounts ``: - -```svelte - { addOpen = true; menuOpen = false; }} -/> -``` - -Add `let addOpen = $state(false);` near the other state declarations. Add the popover render block alongside the existing `RemoveTrackPopover` block: - -```svelte -{#if addOpen} - (addOpen = false)} /> -{/if} -``` - -Add the import at the top: - -```svelte -import AddToPlaylistMenu from './AddToPlaylistMenu.svelte'; -``` - -- [ ] **Step 9.4: Update `TrackMenu.test.ts`** - -Find the assertion that verifies "Add to playlist…" is disabled: - -```ts -test('"Add to playlist…" is disabled with a tooltip until #352 ships', ...) -``` - -Replace with: - -```ts -test('"Add to playlist…" is enabled and opens the AddToPlaylistMenu submenu', async () => { - render(TrackMenu, { props: { track } }); - await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); - const item = screen.getByRole('menuitem', { name: /add to playlist/i }); - expect(item.getAttribute('aria-disabled')).toBe('false'); - await fireEvent.click(item); - await waitFor(() => - expect(screen.getByRole('menu', { name: /add to playlist/i })).toBeInTheDocument() - ); -}); -``` - -Add `vi.mock('$lib/api/playlists', ...)` and `vi.mock('$lib/api/queries', ...)` mocks at the top of the file if `AddToPlaylistMenu`'s imports cascade into the test. Read the existing file to see the existing mock block and extend it. - -- [ ] **Step 9.5: Commit** - -```bash -git add web/src/lib/components/AddToPlaylistMenu.svelte web/src/lib/components/AddToPlaylistMenu.test.ts web/src/lib/components/TrackMenu.svelte web/src/lib/components/TrackMenu.test.ts -git commit -F - <<'EOF' -feat(web): AddToPlaylistMenu submenu wired into TrackMenu (M7 #352) - -The "Add to playlist…" entry that #372 reserved as a disabled slot -is now active. Submenu lists the operator's own playlists -alphabetically; "New playlist…" toggles an inline create form that -makes the playlist + appends the track in two API calls. - -TrackMenu's existing test asserts the entry is enabled and opens the -submenu instead of the previous "is disabled with tooltip" check. -EOF -``` - ---- - -### Task 10 — `/playlists` index page - -**Files:** -- Modify (rewrite): `web/src/routes/playlists/+page.svelte` — replaces the placeholder -- Create: `web/src/routes/playlists/playlists.test.ts` - -- [ ] **Step 10.1: Rewrite `web/src/routes/playlists/+page.svelte`** - -```svelte - - -
-
-

Playlists

- -
- - {#if creating} -
- - {#if createError} -

{createError}

- {/if} -
- - -
-
- {/if} - - {#if $playlistsQ?.isPending} -

Loading playlists…

- {:else if $playlistsQ?.isError} - $playlistsQ.refetch()} /> - {:else if $playlistsQ?.data} -
-

Your playlists

- {#if $playlistsQ.data.owned.length === 0} -

No playlists yet. Click "New playlist" to start one.

- {:else} -
- {#each $playlistsQ.data.owned as p (p.id)} - - {/each} -
- {/if} -
- - {#if $playlistsQ.data.public.length > 0} -
-

From other users

-
- {#each $playlistsQ.data.public as p (p.id)} - - {/each} -
-
- {/if} - {/if} -
-``` - -- [ ] **Step 10.2: Write `web/src/routes/playlists/playlists.test.ts`** - -```ts -import { describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; -import { mockQuery } from '../test-utils/query'; -import type { Playlist } from '$lib/api/types'; - -vi.mock('$lib/api/playlists', () => ({ - createPlaylistsQuery: vi.fn(), - createPlaylist: vi.fn().mockResolvedValue({ id: 'p-new', name: 'X' }) -})); - -vi.mock('@tanstack/svelte-query', async (orig) => { - const actual = (await orig()) as Record; - return { - ...actual, - useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) }) - }; -}); - -vi.mock('$app/navigation', () => ({ goto: vi.fn() })); - -import PlaylistsPage from './+page.svelte'; -import { createPlaylistsQuery, createPlaylist } from '$lib/api/playlists'; - -const mockedCreate = createPlaylistsQuery as ReturnType; -const mockedCreatePlaylist = createPlaylist as ReturnType; - -function p(over: Partial): Playlist { - return { - id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'A', - description: '', is_public: false, cover_url: '', track_count: 0, duration_sec: 0, - created_at: '', updated_at: '', ...over - }; -} - -describe('Playlists index page', () => { - test('renders own + public sections', () => { - mockedCreate.mockReturnValue(mockQuery({ - data: { - owned: [p({ id: 'p1', name: 'Mine' })], - public: [p({ id: 'p2', name: 'Theirs', user_id: 'u-other', owner_username: 'bob' })] - } - })); - render(PlaylistsPage); - expect(screen.getByText('Mine')).toBeInTheDocument(); - expect(screen.getByText('Theirs')).toBeInTheDocument(); - expect(screen.getByText(/your playlists/i)).toBeInTheDocument(); - expect(screen.getByText(/from other users/i)).toBeInTheDocument(); - }); - - test('empty-state copy when operator has no playlists', () => { - mockedCreate.mockReturnValue(mockQuery({ - data: { owned: [], public: [] } - })); - render(PlaylistsPage); - expect(screen.getByText(/no playlists yet/i)).toBeInTheDocument(); - }); - - test('Create button reveals inline form; submit creates and navigates', async () => { - mockedCreate.mockReturnValue(mockQuery({ data: { owned: [], public: [] } })); - render(PlaylistsPage); - await fireEvent.click(screen.getByRole('button', { name: /new playlist/i })); - const input = screen.getByPlaceholderText(/saturday morning/i); - await fireEvent.input(input, { target: { value: 'Test' } }); - await fireEvent.click(screen.getByRole('button', { name: /^create$/i })); - await waitFor(() => expect(mockedCreatePlaylist).toHaveBeenCalledWith({ name: 'Test' })); - }); -}); -``` - -The `mockQuery` helper exists at `web/src/test-utils/query.ts` (same one used elsewhere). Read it first to confirm the import path. - -- [ ] **Step 10.3: Commit** - -```bash -git add web/src/routes/playlists/+page.svelte web/src/routes/playlists/playlists.test.ts -git commit -F - <<'EOF' -feat(web): /playlists index page (M7 #352 slice 1) - -Replaces the placeholder route. Two sections: "Your playlists" (owned) -and "From other users" (public). Inline create form in the header -with Enter-to-submit / Esc-to-cancel. Empty-state copy when the -operator has nothing yet. Routes to /playlists/{id} on card click via -PlaylistCard. -EOF -``` - ---- - -### Task 11 — `/playlists/{id}` detail page with drag-reorder - -**Files:** -- Create: `web/src/routes/playlists/[id]/+page.svelte` -- Create: `web/src/routes/playlists/[id]/playlist.test.ts` - -- [ ] **Step 11.1: Write `web/src/routes/playlists/[id]/+page.svelte`** - -```svelte - - -
- {#if $playlistQ?.isPending} -

Loading…

- {:else if $playlistQ?.isError} - $playlistQ.refetch()} /> - {:else if $playlistQ?.data} - {@const pl = $playlistQ.data} - {#if !editing} -
-
- {#if pl.cover_url} - - {/if} -
-
-

{pl.name}

- {#if pl.description} -

{pl.description}

- {/if} -

- {pl.track_count} {pl.track_count === 1 ? 'track' : 'tracks'} - {#if pl.is_public}· public{:else}· private{/if} - {#if !isOwner}· by {pl.owner_username}{/if} -

-
- {#if isOwner} - - - {/if} -
- {:else} -
- - - -
- - -
-
- {/if} - -
- {#if pl.tracks.length === 0} -

- {#if isOwner} - No tracks yet. Add some via the "Add to playlist…" entry on any track row. - {:else} - No tracks in this playlist. - {/if} -

- {:else} - {#each pl.tracks as row (row.position)} - (dragFromPos = p)} - onDrop={(_, p) => onDrop(p)} - /> - {/each} - {/if} -
- {/if} -
-``` - -`enqueueTracks` and `playQueue` are existing player-store exports per Task 5 of M7 #356. Read `web/src/lib/player/store.svelte.ts` for the exact signatures and adapt — the snippet uses `playQueue(tracks, startIndex)`. If the actual signature differs, adjust. - -The `alert(...)` toast fallback is intentional: a real toast surface is a separate polish task; in slice 1, a browser alert is loud-but-functional and won't be missed in dev. CI tests won't exercise the alert path because they intercept via mocked fetch. - -- [ ] **Step 11.2: Write `web/src/routes/playlists/[id]/playlist.test.ts`** - -```ts -import { describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; -import { mockQuery } from '../../test-utils/query'; -import { writable } from 'svelte/store'; -import type { PlaylistDetail } from '$lib/api/types'; - -vi.mock('$app/stores', () => ({ - page: writable({ params: { id: 'p1' } }) -})); - -vi.mock('$app/navigation', () => ({ goto: vi.fn() })); - -vi.mock('$lib/api/playlists', () => ({ - createPlaylistQuery: vi.fn(), - updatePlaylist: vi.fn().mockResolvedValue(undefined), - deletePlaylist: vi.fn().mockResolvedValue(undefined), - removePlaylistTrack: vi.fn().mockResolvedValue(undefined), - reorderPlaylist: vi.fn().mockResolvedValue(undefined) -})); - -vi.mock('@tanstack/svelte-query', async (orig) => { - const actual = (await orig()) as Record; - return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) }) }; -}); - -vi.mock('$lib/auth/store.svelte', () => ({ - user: { value: { id: 'u-self', username: 'me', is_admin: false } } -})); - -vi.mock('$lib/player/store.svelte', () => ({ - playQueue: vi.fn(), - enqueueTracks: vi.fn(), - playNext: vi.fn(), - enqueueTrack: vi.fn() -})); - -// Cascade through PlaylistTrackRow's mocks. -vi.mock('$lib/api/likes', () => ({ - createLikedIdsQuery: () => ({ subscribe: () => () => {}, data: { track_ids: [], album_ids: [], artist_ids: [] } }), - likeEntity: vi.fn(), - unlikeEntity: vi.fn() -})); -vi.mock('$lib/api/quarantine', () => ({ - createMyQuarantineQuery: () => ({ subscribe: () => () => {}, data: [] }), - flagTrack: vi.fn(), - unflagTrack: vi.fn() -})); -vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn() })); - -import PlaylistDetailPage from './+page.svelte'; -import { createPlaylistQuery, deletePlaylist, reorderPlaylist } from '$lib/api/playlists'; - -const mockedQuery = createPlaylistQuery as ReturnType; - -const ownDetail: PlaylistDetail = { - id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'Mine', - description: '', is_public: false, cover_url: '', track_count: 2, duration_sec: 274, - created_at: '', updated_at: '', - tracks: [ - { position: 0, track_id: 't1', album_id: 'a1', artist_id: 'ar1', title: 'A', artist_name: 'X', album_title: 'Y', duration_sec: 137, stream_url: '/api/tracks/t1/stream', added_at: '' }, - { position: 1, track_id: 't2', album_id: 'a1', artist_id: 'ar1', title: 'B', artist_name: 'X', album_title: 'Y', duration_sec: 137, stream_url: '/api/tracks/t2/stream', added_at: '' } - ] -}; - -describe('Playlist detail page', () => { - test('renders header + tracks for owner', () => { - mockedQuery.mockReturnValue(mockQuery({ data: ownDetail })); - render(PlaylistDetailPage); - expect(screen.getByText('Mine')).toBeInTheDocument(); - expect(screen.getByText('A')).toBeInTheDocument(); - expect(screen.getByText('B')).toBeInTheDocument(); - expect(screen.getByLabelText(/edit playlist/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/delete playlist/i)).toBeInTheDocument(); - }); - - test('hides edit + delete for non-owner', () => { - mockedQuery.mockReturnValue(mockQuery({ - data: { ...ownDetail, user_id: 'u-other', owner_username: 'bob', is_public: true } - })); - render(PlaylistDetailPage); - expect(screen.queryByLabelText(/edit playlist/i)).not.toBeInTheDocument(); - expect(screen.queryByLabelText(/delete playlist/i)).not.toBeInTheDocument(); - }); - - test('Delete button confirms then deletes', async () => { - mockedQuery.mockReturnValue(mockQuery({ data: ownDetail })); - const confirmSpy = vi.spyOn(globalThis, 'confirm').mockReturnValue(true); - render(PlaylistDetailPage); - await fireEvent.click(screen.getByLabelText(/delete playlist/i)); - await waitFor(() => expect(deletePlaylist).toHaveBeenCalledWith('p1')); - confirmSpy.mockRestore(); - }); -}); -``` - -- [ ] **Step 11.3: Commit** - -```bash -git add web/src/routes/playlists/[id]/+page.svelte web/src/routes/playlists/[id]/playlist.test.ts -git commit -F - <<'EOF' -feat(web): /playlists/{id} detail page with drag-reorder (M7 #352) - -Header shows collage, name, description, public/private chip, track -count, owner attribution (when not owner). Edit + delete buttons -visible to the owner only. Track list uses PlaylistTrackRow with -HTML5 drag-and-drop; drop fires PUT /tracks with the new ordered -positions and TanStack Query invalidates the playlist + index cache. - -Toast surface is a placeholder browser alert in slice 1 — a real -toast is a polish task whenever it lands. -EOF -``` - ---- - -## Self-review - -1. **Spec coverage:** - - §2 Goals — all goals point at tasks: schema (T1), service (T2 + T3), collage (T4), API (T5), api helper + types + queries (T6), PlaylistCard (T7), PlaylistTrackRow (T8), AddToPlaylistMenu + TrackMenu wiring (T9), `/playlists` (T10), `/playlists/{id}` (T11). ✓ - - §3 Architecture — schema, service, API, frontend pages all covered. - - §3.5 Drag-to-reorder — HTML5 native, optimistic update, T11 wires it. ✓ - - §3.6 "Add to playlist" flow — T9 covers submenu + inline new-playlist creation. ✓ - - §3.7 Permissions matrix — service-layer enforces ErrForbidden in T2/T3, API layer maps to 403 not_authorized in T5. ✓ - - §5 API contract — every endpoint has a handler in T5 with matching response shape. ✓ - - §6 Error handling — `copyForCode` invocations land in AddToPlaylistMenu (T9) and the detail page (T11). The `alert()` fallback is documented as a slice-1 trade-off. - - §7 Testing — every test file from §7 has a writing step. - - §8 Distribution — migration 0014 lives in T1; data dir creation is service-side in T4 (`os.MkdirAll`). - -2. **Placeholder scan:** No "TBD"/"add validation"/"handle edge cases" in any task. The alert() toast is documented intentional. The fallback glyph is a documented intentional slice-1 placeholder. - -3. **Type consistency:** - - Backend service signatures: `Service.AppendTracks(ctx, callerID, playlistID, []pgtype.UUID) error` and friends — consistent across T2 / T3 / T5. - - `PlaylistRow`, `PlaylistDetail`, `PlaylistTrack` Go types match between service.go and the API view structs in T5. - - TS types: `Playlist`, `PlaylistDetail`, `PlaylistTrack` — defined in T6, used everywhere downstream. - - Query keys: `qk.playlists()`, `qk.playlist(id)` — defined in T6, used in T9, T10, T11. - - API URLs: every helper in T6 matches a handler route registered in T5. - -One spec-deviation worth noting: - -- The spec's §3.2 says "Triggers cover regeneration if the first 4 positions changed" for Reorder. The plan's T3 implementation triggers regeneration unconditionally on any reorder. Reasoning baked in: "cheap, and reasoning about which moves matter is more error-prone than just doing the work." Acceptable — net no behavioral difference for slice 1 (collages get regenerated; might just happen on a few extra calls). diff --git a/docs/superpowers/plans/2026-05-03-m7-track-actions-menu.md b/docs/superpowers/plans/2026-05-03-m7-track-actions-menu.md deleted file mode 100644 index f656211f..00000000 --- a/docs/superpowers/plans/2026-05-03-m7-track-actions-menu.md +++ /dev/null @@ -1,1515 +0,0 @@ -# M7 — Track-level actions menu — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the M5b-era single-entry `` (which only offers "Flag this track…") with a 9-entry kebab menu in 4 logical groups (queue / collection / navigation / lifecycle). Reserves an "Add to playlist…" slot for #352. Lands a new admin-only `DELETE /api/admin/tracks/{id}` endpoint with cascade album → artist tidy-up and Lidarr-aware deletion for managed tracks. - -**Architecture:** Backend adds a single admin endpoint backed by a new `internal/tracks` service. Lidarr-managed tracks reuse the existing `lidarrquarantine.DeleteViaLidarr` primitive; non-Lidarr tracks use direct file removal. All `track_id` FKs already cascade. Frontend rewrites `` from one FlagPopover-only entry into nine entries via two new primitives (``, ``). The component's prop API (`track`, `direction`) stays compatible so existing callers (``, ``) pick up the new entries with no code change. - -**Tech Stack:** Go 1.23 · pgx/v5 + sqlc · Postgres (no migration) · SvelteKit 2 / Svelte 5 (runes) · TanStack Query · Vitest · Lucide icons · FabledSword design tokens. - -**Spec:** [`docs/superpowers/specs/2026-05-03-m7-track-actions-menu-design.md`](../specs/2026-05-03-m7-track-actions-menu-design.md). Read it before starting — every decision is explained there. - -**Memory dependencies:** -- `feedback_no_in_task_tests.md` — HARD RULE. Do NOT run `npm test`, `npm run check`, `go test ./...`, `flutter test`, etc. inside any task. Write the test file as code, write the production code, commit. CI runs the suite. The plan reflects this — there are no `Run: npm test` steps. -- `feedback_v1_is_full_product.md` — this slice ships complete, not as scaffolding for a later iteration. -- `project_design_system.md` — FabledSword tokens (no hardcoded colors), Lucide icons, sentence case. -- `project_no_github.md` — Forgejo MCP for any PR/issue ops; never `gh` CLI. -- `project_subsonic_legacy.md` — `/api/*` is primary; this slice adds nothing under `/rest/*`. -- `project_git_workflow.md` — commit on `dev`; PR to `main` is a separate step. - ---- - -## File map - -### Backend — create - -- `internal/tracks/service.go` — `RemoveTrack(ctx, trackID, adminID)` returning `(deletedAlbumID, deletedArtistID, err)`. Typed errors: `ErrNotFound`, `ErrLidarrUnreachable`, `ErrLidarrUnauthorized`, `ErrLidarrServerError`. -- `internal/tracks/service_test.go` — six service-level cases. -- `internal/api/admin_tracks.go` — `handleRemoveTrack` HTTP handler. -- `internal/api/admin_tracks_test.go` — admin-auth cases + four error codes + happy path envelope. - -### Backend — modify - -- `internal/db/queries/tracks.sql` — append `DeleteTrack`, `CountTracksByAlbum` (already exists; verify reuse). -- `internal/db/queries/albums.sql` — append `DeleteAlbumIfEmpty`, `CountAlbumsByArtist`. -- `internal/db/queries/artists.sql` — append `DeleteArtistIfEmpty`. -- `internal/db/dbq/*` — regenerated by `sqlc generate`. -- `internal/api/api.go` — register `admin.Delete("/tracks/{id}", h.handleRemoveTrack)`. - -### Frontend — create - -- `web/src/lib/components/TrackMenuItem.svelte` — single menu entry (icon + label + onclick + aria-disabled). -- `web/src/lib/components/TrackMenuItem.test.ts` -- `web/src/lib/components/TrackMenuDivider.svelte` — visual separator (`
` styled to `--fs-pewter`). -- `web/src/lib/api/admin/tracks.ts` — `removeTrack(id)` + `RemoveTrackResult` type. -- `web/src/lib/api/admin/tracks.test.ts` - -### Frontend — modify - -- `web/src/lib/components/TrackMenu.svelte` — full rewrite from FlagPopover-only to multi-entry menu. Preserves `track: TrackRef` and `direction: 'up' | 'down'` props. -- `web/src/lib/components/TrackMenu.test.ts` — full rewrite. -- `web/src/lib/components/TrackRow.test.ts` — adjust assertions for the new 9-entry menu (no production code change). -- `web/src/lib/components/PlayerBar.test.ts` — same. -- `web/src/lib/player/store.svelte.ts` — add `playNext(t: TrackRef)` (splices at `_index + 1`). `enqueueTrack(t)` already exists for "Add to queue" semantics; reused. -- `web/src/lib/player/store.test.ts` — extend with playNext cases. - ---- - -## Task list - -### Task 1 — SQL queries for cascade cleanup - -**Files:** -- Modify: `internal/db/queries/tracks.sql` (append `DeleteTrack`) -- Modify: `internal/db/queries/albums.sql` (append `DeleteAlbumIfEmpty`, `CountAlbumsByArtist`) -- Modify: `internal/db/queries/artists.sql` (append `DeleteArtistIfEmpty`) -- Regenerate: `internal/db/dbq/*` - -- [ ] **Step 1.1: Append `DeleteTrack` to `internal/db/queries/tracks.sql`** - -```sql --- name: DeleteTrack :one --- M7 #372: hard delete with FK cascade. The CASCADE on track_id from --- play_events / general_likes_tracks / lidarr_quarantine / --- lidarr_quarantine_actions handles their cleanup. RETURNING gives us --- album_id + artist_id for the album-empty / artist-empty cascade --- checks the service does next. -DELETE FROM tracks WHERE id = $1 -RETURNING id, album_id, artist_id, file_path, mbid; -``` - -- [ ] **Step 1.2: Append `DeleteAlbumIfEmpty` + `CountAlbumsByArtist` to `internal/db/queries/albums.sql`** - -```sql --- name: DeleteAlbumIfEmpty :one --- M7 #372: deletes the album row only when it has no remaining tracks. --- Used after a track delete to tidy up orphaned albums. RETURNING gives --- us the artist_id so the service can chain into the artist-empty check. --- Returns no rows when the album still has tracks; the service treats --- pgx.ErrNoRows as "album not orphaned, skip cascade". -DELETE FROM albums -WHERE id = $1 - AND NOT EXISTS (SELECT 1 FROM tracks WHERE album_id = $1) -RETURNING id, artist_id; - --- name: CountAlbumsByArtist :one -SELECT count(*) FROM albums WHERE artist_id = $1; -``` - -- [ ] **Step 1.3: Append `DeleteArtistIfEmpty` to `internal/db/queries/artists.sql`** - -```sql --- name: DeleteArtistIfEmpty :one --- M7 #372: deletes the artist row only when it has no remaining albums --- AND no remaining tracks (defensive — tracks can in principle exist --- without an album, though the scanner doesn't write that shape today). -DELETE FROM artists -WHERE id = $1 - AND NOT EXISTS (SELECT 1 FROM albums WHERE artist_id = $1) - AND NOT EXISTS (SELECT 1 FROM tracks WHERE artist_id = $1) -RETURNING id; -``` - -- [ ] **Step 1.4: Regenerate sqlc** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel -sqlc generate -``` - -Expected: `internal/db/dbq/tracks.sql.go`, `internal/db/dbq/albums.sql.go`, `internal/db/dbq/artists.sql.go` updated with the new methods. - -- [ ] **Step 1.5: Commit** - -```bash -git add internal/db/queries/tracks.sql internal/db/queries/albums.sql internal/db/queries/artists.sql internal/db/dbq/ -git commit -F - <<'EOF' -feat(db): cascade-cleanup queries for M7 #372 track removal - -DeleteTrack returns album_id + artist_id so the calling service can -chain the album-empty / artist-empty cascade. DeleteAlbumIfEmpty and -DeleteArtistIfEmpty are no-ops when other rows still reference the -parent — the service treats pgx.ErrNoRows as "not orphaned, skip". -EOF -``` - ---- - -### Task 2 — `internal/tracks/service.go` — RemoveTrack - -**Files:** -- Create: `internal/tracks/service.go` -- Create: `internal/tracks/service_test.go` - -This task introduces a new service module. RemoveTrack drives the whole removal flow: fetch the track, delegate file removal (Lidarr-managed → Lidarr API; non-Lidarr → direct os.Remove), execute DB deletes in a single transaction, and return the IDs of any cascaded album/artist rows so the API caller can invalidate caches. - -- [ ] **Step 2.1: Write `internal/tracks/service.go`** - -```go -package tracks - -import ( - "context" - "errors" - "fmt" - "log/slog" - "os" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" -) - -// Typed errors. Mapped to wire codes by the API layer. -var ( - ErrNotFound = errors.New("track not found") - ErrLidarrUnreachable = errors.New("lidarr unreachable") - ErrLidarrUnauthorized = errors.New("lidarr unauthorized") - ErrLidarrServerError = errors.New("lidarr server error") -) - -// LidarrDeleter is the subset of lidarrquarantine.Service the tracks -// service needs. Defined here so tests can stub it without spinning up -// a real Lidarr stub. -type LidarrDeleter interface { - DeleteViaLidarr(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, int, error) -} - -type Service struct { - pool *pgxpool.Pool - logger *slog.Logger - lidarrDeleter LidarrDeleter -} - -func NewService(pool *pgxpool.Pool, logger *slog.Logger, lidarrDeleter LidarrDeleter) *Service { - if logger == nil { - logger = slog.Default() - } - return &Service{pool: pool, logger: logger, lidarrDeleter: lidarrDeleter} -} - -// RemoveTrack deletes the track and (cascade-style) any album / artist -// rows that become orphaned. For Lidarr-managed tracks, Lidarr is told -// to delete first so it doesn't re-import on the next sync. -// -// Returns the deleted track id plus optional album_id and artist_id when -// those parent rows were tidied up too. The API layer surfaces these so -// the client can invalidate the matching cached query keys. -func (s *Service) RemoveTrack(ctx context.Context, trackID, adminID pgtype.UUID) (deletedAlbumID, deletedArtistID *pgtype.UUID, err error) { - q := dbq.New(s.pool) - - track, err := q.GetTrackByID(ctx, trackID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, nil, ErrNotFound - } - return nil, nil, fmt.Errorf("get track: %w", err) - } - - // Lidarr-managed → delegate file removal to Lidarr (so Lidarr stops - // re-importing). Track is "Lidarr-managed" when its mbid is set — - // scanner only writes the mbid for Lidarr-imported files. - if track.Mbid.Valid && s.lidarrDeleter != nil { - _, _, lerr := s.lidarrDeleter.DeleteViaLidarr(ctx, trackID, adminID) - switch { - case lerr == nil: - // Lidarr deleted the file. Proceed to DB cleanup below. - case errors.Is(lerr, lidarrquarantine.ErrLidarrUnreachable): - return nil, nil, ErrLidarrUnreachable - case errors.Is(lerr, lidarrquarantine.ErrLidarrUnauthorized): - return nil, nil, ErrLidarrUnauthorized - case errors.Is(lerr, lidarrquarantine.ErrLidarrServerError): - return nil, nil, ErrLidarrServerError - default: - return nil, nil, fmt.Errorf("lidarr delete: %w", lerr) - } - } else if track.FilePath != "" { - // Non-Lidarr track → direct file removal. Already-missing is fine; - // the goal is DB consistency. - if rerr := os.Remove(track.FilePath); rerr != nil && !errors.Is(rerr, os.ErrNotExist) { - s.logger.Warn("track delete: file remove failed", - "path", track.FilePath, "track_id", trackID, "err", rerr) - // We still proceed — leaving an orphan file is recoverable; - // leaving an orphan DB row would cause the row to keep showing - // up in the UI with no playable file. - } - } - - // DB cleanup in a single transaction so a failure midway leaves the - // schema consistent. - tx, err := s.pool.Begin(ctx) - if err != nil { - return nil, nil, fmt.Errorf("begin tx: %w", err) - } - defer func() { _ = tx.Rollback(ctx) }() // no-op once Commit succeeds - - tq := dbq.New(tx) - - deleted, err := tq.DeleteTrack(ctx, trackID) - if err != nil { - return nil, nil, fmt.Errorf("delete track: %w", err) - } - - // Album-empty cascade. - albumRow, err := tq.DeleteAlbumIfEmpty(ctx, deleted.AlbumID) - switch { - case err == nil: - deletedAlbumID = &albumRow.ID - // Artist-empty cascade only relevant when the album was tidied up. - artistRow, aerr := tq.DeleteArtistIfEmpty(ctx, albumRow.ArtistID) - switch { - case aerr == nil: - deletedArtistID = &artistRow.ID - case errors.Is(aerr, pgx.ErrNoRows): - // Artist still has other albums or stray tracks; leave it. - default: - return nil, nil, fmt.Errorf("delete artist if empty: %w", aerr) - } - case errors.Is(err, pgx.ErrNoRows): - // Album still has other tracks; leave it. - default: - return nil, nil, fmt.Errorf("delete album if empty: %w", err) - } - - if err := tx.Commit(ctx); err != nil { - return nil, nil, fmt.Errorf("commit: %w", err) - } - - return deletedAlbumID, deletedArtistID, nil -} -``` - -- [ ] **Step 2.2: Write `internal/tracks/service_test.go`** - -```go -package tracks_test - -import ( - "context" - "errors" - "net/http/httptest" - "os" - "path/filepath" - "testing" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" - "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" -) - -// fakeLidarrDeleter records calls and returns a configurable error. -type fakeLidarrDeleter struct { - calls []pgtype.UUID - err error -} - -func (f *fakeLidarrDeleter) DeleteViaLidarr(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, int, error) { - f.calls = append(f.calls, trackID) - return dbq.LidarrQuarantineAction{}, 0, f.err -} - -// Helpers seedUser, seedTrack, newPool follow the same pattern as -// existing service tests (see internal/lidarrquarantine/service_test.go -// for reference). Reuse them via copy or the existing test fixtures. - -func TestRemoveTrack_NotFound(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "admin") - - svc := tracks.NewService(pool, nil, &fakeLidarrDeleter{}) - _, _, err := svc.RemoveTrack(context.Background(), randomUUID(), user.ID) - if !errors.Is(err, tracks.ErrNotFound) { - t.Errorf("err = %v, want ErrNotFound", err) - } -} - -func TestRemoveTrack_NonLidarr_HappyPath(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "admin") - - dir := t.TempDir() - path := filepath.Join(dir, "song.mp3") - if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil { - t.Fatalf("write fixture: %v", err) - } - track := seedTrackAt(t, pool, "Song", path, "" /* no mbid → non-Lidarr */) - - svc := tracks.NewService(pool, nil, &fakeLidarrDeleter{}) - _, _, err := svc.RemoveTrack(context.Background(), track.ID, user.ID) - if err != nil { - t.Fatalf("RemoveTrack: %v", err) - } - - if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { - t.Errorf("file still present at %s", path) - } - - q := dbq.New(pool) - if _, gerr := q.GetTrackByID(context.Background(), track.ID); gerr == nil { - t.Error("track row still in DB") - } -} - -func TestRemoveTrack_NonLidarr_FileAlreadyMissing(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "admin") - - track := seedTrackAt(t, pool, "Song", "/tmp/does-not-exist-xyz", "") - - svc := tracks.NewService(pool, nil, &fakeLidarrDeleter{}) - if _, _, err := svc.RemoveTrack(context.Background(), track.ID, user.ID); err != nil { - t.Fatalf("RemoveTrack: %v (file-already-missing should be tolerated)", err) - } -} - -func TestRemoveTrack_Lidarr_HappyPath(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "admin") - track := seedLidarrTrack(t, pool, "Song", "track-mbid") - - deleter := &fakeLidarrDeleter{} - svc := tracks.NewService(pool, nil, deleter) - if _, _, err := svc.RemoveTrack(context.Background(), track.ID, user.ID); err != nil { - t.Fatalf("RemoveTrack: %v", err) - } - if len(deleter.calls) != 1 { - t.Errorf("DeleteViaLidarr call count = %d, want 1", len(deleter.calls)) - } -} - -func TestRemoveTrack_Lidarr_ErrorPropagation(t *testing.T) { - cases := []struct { - name string - lidarr error - wantErr error - }{ - {"unreachable", lidarrquarantine.ErrLidarrUnreachable, tracks.ErrLidarrUnreachable}, - {"unauthorized", lidarrquarantine.ErrLidarrUnauthorized, tracks.ErrLidarrUnauthorized}, - {"server_error", lidarrquarantine.ErrLidarrServerError, tracks.ErrLidarrServerError}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "admin") - track := seedLidarrTrack(t, pool, "Song", "mbid") - - deleter := &fakeLidarrDeleter{err: tc.lidarr} - svc := tracks.NewService(pool, nil, deleter) - _, _, err := svc.RemoveTrack(context.Background(), track.ID, user.ID) - if !errors.Is(err, tc.wantErr) { - t.Errorf("err = %v, want %v", err, tc.wantErr) - } - - // DB row must still exist — Lidarr failure should not orphan rows. - q := dbq.New(pool) - if _, gerr := q.GetTrackByID(context.Background(), track.ID); gerr != nil { - t.Errorf("track row was deleted despite Lidarr failure: %v", gerr) - } - }) - } -} - -func TestRemoveTrack_Cascade_AlbumEmpty(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "admin") - - // Single track in a single album, both belong to a single artist that - // has another album with another track. The track delete should also - // drop the album, but the artist persists. - artist := seedArtist(t, pool, "Solo") - albumA := seedAlbumForArtist(t, pool, "OnlyAlbum", artist.ID) - trackA := seedTrackInAlbum(t, pool, "OnlyTrack", albumA.ID, artist.ID) - - albumB := seedAlbumForArtist(t, pool, "OtherAlbum", artist.ID) - _ = seedTrackInAlbum(t, pool, "OtherTrack", albumB.ID, artist.ID) - - svc := tracks.NewService(pool, nil, &fakeLidarrDeleter{}) - deletedAlbum, deletedArtist, err := svc.RemoveTrack(context.Background(), trackA.ID, user.ID) - if err != nil { - t.Fatalf("RemoveTrack: %v", err) - } - if deletedAlbum == nil || deletedAlbum.Bytes != albumA.ID.Bytes { - t.Errorf("deletedAlbumID = %v, want albumA.ID", deletedAlbum) - } - if deletedArtist != nil { - t.Errorf("deletedArtistID = %v, want nil (artist still has albumB)", deletedArtist) - } -} - -func TestRemoveTrack_Cascade_ArtistEmpty(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "admin") - - artist := seedArtist(t, pool, "Solo") - album := seedAlbumForArtist(t, pool, "OnlyAlbum", artist.ID) - track := seedTrackInAlbum(t, pool, "OnlyTrack", album.ID, artist.ID) - - svc := tracks.NewService(pool, nil, &fakeLidarrDeleter{}) - deletedAlbum, deletedArtist, err := svc.RemoveTrack(context.Background(), track.ID, user.ID) - if err != nil { - t.Fatalf("RemoveTrack: %v", err) - } - if deletedAlbum == nil { - t.Error("deletedAlbumID = nil, want album.ID") - } - if deletedArtist == nil { - t.Error("deletedArtistID = nil, want artist.ID") - } -} -``` - -The seed helpers (`newPool`, `seedUser`, `seedArtist`, `seedAlbumForArtist`, `seedTrackInAlbum`, `seedTrackAt`, `seedLidarrTrack`, `randomUUID`) follow the same pattern used in `internal/lidarrquarantine/service_test.go`. The implementer either copies them into a `service_test_helpers.go` in this package or extracts the existing ones into a shared `internal/dbtest` package — operator preference, but copying into the new package is the path of least surprise. - -- [ ] **Step 2.3: Commit** - -```bash -git add internal/tracks/service.go internal/tracks/service_test.go -git commit -F - <<'EOF' -feat(tracks): RemoveTrack service with Lidarr-aware delete + cascade - -RemoveTrack handles both Lidarr-managed (delegates to existing -lidarrquarantine.DeleteViaLidarr) and non-Lidarr (direct os.Remove) -file deletion, then runs the cascade album-if-empty / artist-if-empty -DB cleanup in a single transaction. Lidarr errors propagate as typed -errors so the API layer can map them to the existing wire codes. -EOF -``` - ---- - -### Task 3 — `internal/api/admin_tracks.go` — HTTP handler - -**Files:** -- Create: `internal/api/admin_tracks.go` -- Create: `internal/api/admin_tracks_test.go` -- Modify: `internal/api/api.go` (register the route) -- Modify: `internal/api/api.go` and the handler-construction site (wire the new tracks service) - -- [ ] **Step 3.1: Write `internal/api/admin_tracks.go`** - -```go -package api - -import ( - "encoding/json" - "errors" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5/pgtype" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" - "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" -) - -// removeTrackResponse mirrors the wire shape from the spec. The -// optional fields are omitted when their corresponding cascade did -// not fire (album/artist still has other rows). -type removeTrackResponse struct { - DeletedTrackID string `json:"deleted_track_id"` - DeletedAlbumID *string `json:"deleted_album_id,omitempty"` - DeletedArtistID *string `json:"deleted_artist_id,omitempty"` -} - -// handleRemoveTrack implements DELETE /api/admin/tracks/{id}. -// Admin-only. Cascades album / artist if the track removal leaves -// either parent empty. -func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) { - idStr := chi.URLParam(r, "id") - var trackID pgtype.UUID - if err := trackID.Scan(idStr); err != nil { - writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id") - return - } - - admin, ok := auth.UserFromContext(r.Context()) - if !ok { - writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session") - return - } - - deletedAlbum, deletedArtist, err := h.tracks.RemoveTrack(r.Context(), trackID, admin.ID) - switch { - case err == nil: - // fall through - case errors.Is(err, tracks.ErrNotFound): - writeErr(w, http.StatusNotFound, "not_found", "track not found") - return - case errors.Is(err, tracks.ErrLidarrUnreachable): - writeErr(w, http.StatusServiceUnavailable, "lidarr_unreachable", "Lidarr is unreachable") - return - case errors.Is(err, tracks.ErrLidarrUnauthorized): - writeErr(w, http.StatusServiceUnavailable, "lidarr_unauthorized", "Lidarr rejected the API key") - return - case errors.Is(err, tracks.ErrLidarrServerError): - writeErr(w, http.StatusBadGateway, "lidarr_server_error", "Lidarr returned an error") - return - default: - h.logger.Error("api: remove track failed", "err", err, "track_id", trackID) - writeErr(w, http.StatusInternalServerError, "server_error", "remove failed") - return - } - - resp := removeTrackResponse{DeletedTrackID: idStr} - if deletedAlbum != nil { - s := uuidToString(*deletedAlbum) - resp.DeletedAlbumID = &s - } - if deletedArtist != nil { - s := uuidToString(*deletedArtist) - resp.DeletedArtistID = &s - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) -} -``` - -If `uuidToString` doesn't exist in the api package, add it next to `convert.go`'s helpers — single line: `func uuidToString(u pgtype.UUID) string { return uuid.UUID(u.Bytes).String() }` (using `github.com/google/uuid`, which the api package already imports for similar conversions; check `convert.go` to confirm the existing helper name and reuse it instead of duplicating). - -- [ ] **Step 3.2: Wire the tracks service into `handlers`** - -In `internal/api/api.go`, the `Mount` function takes a number of services. Read the existing signature, then add `tracksSvc *tracks.Service` (with `import "git.fabledsword.com/bvandeusen/minstrel/internal/tracks"`). Add a corresponding `tracks` field to the `handlers` struct. The construction at the call site in `internal/server/server.go` builds the new service via `tracks.NewService(s.Pool, s.Logger, lidarrQuar)` (where `lidarrQuar` is the existing `*lidarrquarantine.Service`). - -The handler check `h.tracks` reaches the new service. - -- [ ] **Step 3.3: Register the route in `internal/api/api.go`** - -Inside the `r.Route("/api", ...)` → `authed.Group(...)` → `authed.Route("/admin", ...)` block, add: - -```go -admin.Delete("/tracks/{id}", h.handleRemoveTrack) -``` - -Place it adjacent to the other admin routes (alongside `admin.Post("/quarantine/{track_id}/delete-via-lidarr", ...)` since the operations are conceptually neighboring). - -- [ ] **Step 3.4: Write `internal/api/admin_tracks_test.go`** - -```go -package api_test - -import ( - "net/http" - "net/http/httptest" - "testing" - - "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" -) - -func TestRemoveTrack_NotAdmin_403(t *testing.T) { - srv, _ := newTestServer(t) // existing helper from api_test fixtures - user := seedNonAdminUser(t, srv.pool) - - req := httptest.NewRequest(http.MethodDelete, "/api/admin/tracks/"+randomUUIDString(), nil) - req = withSession(req, user) - rr := httptest.NewRecorder() - srv.handler.ServeHTTP(rr, req) - - if rr.Code != http.StatusForbidden { - t.Errorf("status = %d, want 403", rr.Code) - } -} - -func TestRemoveTrack_NoSession_401(t *testing.T) { - srv, _ := newTestServer(t) - req := httptest.NewRequest(http.MethodDelete, "/api/admin/tracks/"+randomUUIDString(), nil) - rr := httptest.NewRecorder() - srv.handler.ServeHTTP(rr, req) - - if rr.Code != http.StatusUnauthorized { - t.Errorf("status = %d, want 401", rr.Code) - } -} - -func TestRemoveTrack_NotFound_404(t *testing.T) { - srv, _ := newTestServer(t) - admin := seedAdminUser(t, srv.pool) - - req := httptest.NewRequest(http.MethodDelete, "/api/admin/tracks/"+randomUUIDString(), nil) - req = withSession(req, admin) - rr := httptest.NewRecorder() - srv.handler.ServeHTTP(rr, req) - - if rr.Code != http.StatusNotFound { - t.Errorf("status = %d, want 404", rr.Code) - } - if !contains(rr.Body.String(), `"error":"not_found"`) { - t.Errorf("body = %s, want not_found code", rr.Body.String()) - } -} - -func TestRemoveTrack_LidarrError_503(t *testing.T) { - cases := []struct { - name string - lidarr error - code string - status int - }{ - {"unreachable", tracks.ErrLidarrUnreachable, "lidarr_unreachable", http.StatusServiceUnavailable}, - {"unauthorized", tracks.ErrLidarrUnauthorized, "lidarr_unauthorized", http.StatusServiceUnavailable}, - {"server_error", tracks.ErrLidarrServerError, "lidarr_server_error", http.StatusBadGateway}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - srv, _ := newTestServer(t, withLidarrError(tc.lidarr)) // tunable test fixture - admin := seedAdminUser(t, srv.pool) - track := seedLidarrTrack(t, srv.pool, "Song", "mbid") - - req := httptest.NewRequest(http.MethodDelete, "/api/admin/tracks/"+uuidString(track.ID), nil) - req = withSession(req, admin) - rr := httptest.NewRecorder() - srv.handler.ServeHTTP(rr, req) - - if rr.Code != tc.status { - t.Errorf("status = %d, want %d", rr.Code, tc.status) - } - if !contains(rr.Body.String(), `"error":"`+tc.code+`"`) { - t.Errorf("body = %s, want code %s", rr.Body.String(), tc.code) - } - }) - } -} - -func TestRemoveTrack_Happy_200WithCascade(t *testing.T) { - srv, _ := newTestServer(t) - admin := seedAdminUser(t, srv.pool) - - // Set up a single-track album owned by a single-album artist so we - // see the full cascade in the response. - artist := seedArtist(t, srv.pool, "Solo") - album := seedAlbumForArtist(t, srv.pool, "Album", artist.ID) - track := seedTrackInAlbum(t, srv.pool, "Song", album.ID, artist.ID) - - req := httptest.NewRequest(http.MethodDelete, "/api/admin/tracks/"+uuidString(track.ID), nil) - req = withSession(req, admin) - rr := httptest.NewRecorder() - srv.handler.ServeHTTP(rr, req) - - if rr.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rr.Code) - } - body := rr.Body.String() - for _, want := range []string{`"deleted_track_id":`, `"deleted_album_id":`, `"deleted_artist_id":`} { - if !contains(body, want) { - t.Errorf("body missing %s: %s", want, body) - } - } -} -``` - -The helpers (`newTestServer`, `seedAdminUser`, `seedNonAdminUser`, `seedArtist`, etc.) follow the patterns in the existing api_test files. `withLidarrError` is a fixture option this slice introduces — the implementer adds it to `newTestServer` so any test can stub the Lidarr deleter to return a specific typed error. - -- [ ] **Step 3.5: Commit** - -```bash -git add internal/api/admin_tracks.go internal/api/admin_tracks_test.go internal/api/api.go internal/server/server.go -git commit -F - <<'EOF' -feat(api): DELETE /api/admin/tracks/{id} for M7 #372 - -Admin-only handler; maps tracks.RemoveTrack typed errors to the -project's existing wire codes (not_found, lidarr_unreachable, -lidarr_unauthorized, lidarr_server_error). Response envelope returns -deleted_track_id plus optional deleted_album_id / deleted_artist_id -when the cascade tidied up parent rows. -EOF -``` - ---- - -### Task 4 — Frontend admin API helper - -**Files:** -- Create: `web/src/lib/api/admin/tracks.ts` -- Create: `web/src/lib/api/admin/tracks.test.ts` - -- [ ] **Step 4.1: Write `web/src/lib/api/admin/tracks.ts`** - -```ts -import { apiFetch } from '$lib/api/client'; - -export type RemoveTrackResult = { - deleted_track_id: string; - deleted_album_id?: string; - deleted_artist_id?: string; -}; - -/** - * DELETE /api/admin/tracks/{id} — admin-only. Returns the deleted - * track id plus optional album/artist ids when their parent row was - * tidied up by the server-side cascade. The caller is responsible for - * invalidating any TanStack Query keys the deleted entities backed. - */ -export function removeTrack(id: string): Promise { - return apiFetch(`/api/admin/tracks/${encodeURIComponent(id)}`, { - method: 'DELETE', - }); -} -``` - -- [ ] **Step 4.2: Write `web/src/lib/api/admin/tracks.test.ts`** - -```ts -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { removeTrack } from './tracks'; - -const originalFetch = globalThis.fetch; - -beforeEach(() => { - globalThis.fetch = vi.fn(); -}); - -afterEach(() => { - globalThis.fetch = originalFetch; -}); - -describe('removeTrack', () => { - it('DELETEs /api/admin/tracks/:id and parses the envelope', async () => { - (globalThis.fetch as ReturnType).mockResolvedValueOnce( - new Response(JSON.stringify({ - deleted_track_id: 't-1', - deleted_album_id: 'al-1', - }), { status: 200, headers: { 'Content-Type': 'application/json' } }) - ); - - const r = await removeTrack('t-1'); - expect(r.deleted_track_id).toBe('t-1'); - expect(r.deleted_album_id).toBe('al-1'); - expect(r.deleted_artist_id).toBeUndefined(); - - const [url, init] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('/api/admin/tracks/t-1'); - expect(init.method).toBe('DELETE'); - }); - - it('surfaces lidarr_unreachable as ApiError with that code', async () => { - (globalThis.fetch as ReturnType).mockResolvedValueOnce( - new Response(JSON.stringify({ error: 'lidarr_unreachable' }), { - status: 503, - headers: { 'Content-Type': 'application/json' }, - }) - ); - - await expect(removeTrack('t-1')).rejects.toMatchObject({ - code: 'lidarr_unreachable', - }); - }); -}); -``` - -- [ ] **Step 4.3: Commit** - -```bash -git add web/src/lib/api/admin/tracks.ts web/src/lib/api/admin/tracks.test.ts -git commit -F - <<'EOF' -feat(web): admin removeTrack API helper for M7 #372 - -Single function that hits DELETE /api/admin/tracks/{id} and returns -the typed envelope. Reuses the existing apiFetch + ApiError surface -so error codes (lidarr_unreachable etc.) flow into copyForCode the -same way as every other admin operation. -EOF -``` - ---- - -### Task 5 — Audio store: `playNext()` - -**Files:** -- Modify: `web/src/lib/player/store.svelte.ts` -- Modify: `web/src/lib/player/store.test.ts` - -`enqueueTrack(t)` already exists for "Add to queue" semantics — reuse it as-is. This task adds `playNext(t)` for the "Play next" entry which splices into `_queue[_index + 1]`. - -- [ ] **Step 5.1: Add `playNext` to `web/src/lib/player/store.svelte.ts`** - -Append next to the existing `enqueueTrack` export (around line 208): - -```ts -/** - * M7 #372: insert a track immediately after the currently-playing one. - * If the queue is empty, behaves like enqueueTrack and starts the queue - * with this track at index 0. - */ -export function playNext(t: TrackRef): void { - _radioSeedId = null; // M4c: any user-driven enqueue clears the radio refresh state - if (_queue.length === 0) { - _queue = [t]; - _index = 0; - return; - } - const next = _index + 1; - _queue = [..._queue.slice(0, next), t, ..._queue.slice(next)]; -} -``` - -- [ ] **Step 5.2: Extend `web/src/lib/player/store.test.ts` with playNext cases** - -Append (or wherever the file's existing `enqueueTrack` block lives, place these alongside): - -```ts -import { describe, it, expect, beforeEach } from 'vitest'; -import { playNext, enqueueTrack, getQueue, setQueue, getIndex, setIndex } from './store.svelte'; -// (Adjust imports to match the file's actual export shape; the existing -// store.test.ts will tell you what's exposed for tests today.) - -describe('playNext', () => { - beforeEach(() => { - setQueue([]); - setIndex(0); - }); - - it('splices the track immediately after the current index', () => { - const a = { id: 'a', title: 'A', albumId: 'x', artistId: 'y' } as const; - const b = { id: 'b', title: 'B', albumId: 'x', artistId: 'y' } as const; - const c = { id: 'c', title: 'C', albumId: 'x', artistId: 'y' } as const; - setQueue([a, b]); - setIndex(0); - - playNext(c); - - expect(getQueue().map((t) => t.id)).toEqual(['a', 'c', 'b']); - expect(getIndex()).toBe(0); - }); - - it('seeds an empty queue with the track at index 0', () => { - const t = { id: 't', title: 'T', albumId: 'x', artistId: 'y' } as const; - - playNext(t); - - expect(getQueue().map((x) => x.id)).toEqual(['t']); - expect(getIndex()).toBe(0); - }); -}); -``` - -If the existing store test file uses a different test harness (mounting a Svelte component to reach the runes-state), adapt to whatever pattern is already in place — don't fight the existing setup. The two assertions above are what matters: splice-at-index+1 and empty-queue seeding. - -- [ ] **Step 5.3: Commit** - -```bash -git add web/src/lib/player/store.svelte.ts web/src/lib/player/store.test.ts -git commit -F - <<'EOF' -feat(player): playNext for M7 #372 track-actions menu - -Inserts at _queue[_index + 1] so the next-up slot is overwritten with -the chosen track. Empty-queue seeding mirrors enqueueTrack's behavior. -Clears _radioSeedId since user-driven enqueue invalidates the M4c -auto-refresh trigger. -EOF -``` - ---- - -### Task 6 — `` primitive - -**Files:** -- Create: `web/src/lib/components/TrackMenuItem.svelte` -- Create: `web/src/lib/components/TrackMenuItem.test.ts` - -A single row in the menu: icon + label + optional onclick, with `aria-disabled` support so disabled-but-visible entries (admin-only for non-admin, "Add to playlist…" pre-#352) keep menu height stable. - -- [ ] **Step 6.1: Write `web/src/lib/components/TrackMenuItem.svelte`** - -```svelte - - - -``` - -- [ ] **Step 6.2: Write `web/src/lib/components/TrackMenuItem.test.ts`** - -```ts -import { describe, it, expect, vi } from 'vitest'; -import { render, fireEvent, screen } from '@testing-library/svelte'; -import { Music2 } from 'lucide-svelte'; -import TrackMenuItem from './TrackMenuItem.svelte'; - -describe('TrackMenuItem', () => { - it('renders icon, label, and fires onclick', async () => { - const onclick = vi.fn(); - render(TrackMenuItem, { props: { icon: Music2, label: 'Play next', onclick } }); - const btn = screen.getByRole('menuitem', { name: /play next/i }); - await fireEvent.click(btn); - expect(onclick).toHaveBeenCalledOnce(); - expect(btn.getAttribute('aria-disabled')).toBe('false'); - }); - - it('aria-disabled blocks onclick and reflects in DOM', async () => { - const onclick = vi.fn(); - render(TrackMenuItem, { - props: { icon: Music2, label: 'Add to playlist…', onclick, disabled: true, title: 'Coming with playlists' }, - }); - const btn = screen.getByRole('menuitem', { name: /add to playlist/i }); - expect(btn.getAttribute('aria-disabled')).toBe('true'); - expect(btn.getAttribute('title')).toBe('Coming with playlists'); - await fireEvent.click(btn); - expect(onclick).not.toHaveBeenCalled(); - }); -}); -``` - -- [ ] **Step 6.3: Commit** - -```bash -git add web/src/lib/components/TrackMenuItem.svelte web/src/lib/components/TrackMenuItem.test.ts -git commit -F - <<'EOF' -feat(web): TrackMenuItem primitive for M7 #372 - -Single-entry button used by TrackMenu. Supports aria-disabled (kept -visible to preserve menu height when admin-only or pre-#352 entries -are non-functional) and a `danger` flag for the oxblood-tinted -"Remove from library" entry. -EOF -``` - ---- - -### Task 7 — `` primitive - -**Files:** -- Create: `web/src/lib/components/TrackMenuDivider.svelte` - -Trivial visual separator. No test — it's a styled `
`. - -- [ ] **Step 7.1: Write `web/src/lib/components/TrackMenuDivider.svelte`** - -```svelte - -``` - -- [ ] **Step 7.2: Commit** - -```bash -git add web/src/lib/components/TrackMenuDivider.svelte -git commit -F - <<'EOF' -feat(web): TrackMenuDivider primitive for M7 #372 menu groups -EOF -``` - ---- - -### Task 8 — `` rewrite (multi-entry) - -**Files:** -- Modify (rewrite): `web/src/lib/components/TrackMenu.svelte` -- Modify (rewrite): `web/src/lib/components/TrackMenu.test.ts` - -This is the centerpiece. Existing `track` and `direction` props stay. Internal state grows from the existing `menuOpen` / `popoverOpen` pair to also track confirmation flow for "Remove from library" (a non-modal confirm — the entry is two-click: first click flips it to "Click again to confirm", second click fires the API). - -- [ ] **Step 8.1: Rewrite `web/src/lib/components/TrackMenu.svelte`** - -```svelte - - - (menuOpen = false)} onkeydown={onKeydown} /> - -
- - - {#if menuOpen} - - {/if} - - {#if popoverOpen} - - {/if} - - {#if toast} -
- {toast} -
- {/if} -
-``` - -The imports at the top reference `$lib/stores/me`, `$lib/stores/likedIds`, `$lib/stores/hiddenIds` — verify those exist (they should, given the existing LikeButton + Hide-row pattern). If the names differ in the actual codebase, adjust to match. Same for `$lib/api/quarantine` (`quarantineTrack` / `unquarantineTrack`). Read the files before writing if unsure. - -If any of those stores/helpers genuinely don't exist (e.g., `hiddenIdsStore`), they need to land first or this task gets stretched. Most likely path: the existing LikeButton component already imports a "set of liked ids" store; reuse that pattern. The Hide/quarantine equivalent has shipped in M5b — check `internal/api/quarantine.go` for the helper names and `web/src/lib/api/` for the existing TS surface. - -- [ ] **Step 8.2: Rewrite `web/src/lib/components/TrackMenu.test.ts`** - -```ts -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, fireEvent, screen, waitFor } from '@testing-library/svelte'; -import { writable } from 'svelte/store'; -import TrackMenu from './TrackMenu.svelte'; -import type { TrackRef } from '$lib/api/types'; - -// ---- Mocks ---- - -vi.mock('$lib/stores/me', () => ({ - meStore: writable<{ id: string; username: string; is_admin: boolean } | null>({ - id: 'u-1', username: 'admin', is_admin: true, - }), -})); - -vi.mock('$lib/stores/likedIds', () => ({ - likedIdsStore: writable({ tracks: new Set(), albums: new Set(), artists: new Set() }), -})); - -vi.mock('$lib/stores/hiddenIds', () => ({ - hiddenIdsStore: writable(new Set()), -})); - -vi.mock('$lib/player/store.svelte', () => ({ - playNext: vi.fn(), - enqueueTrack: vi.fn(), -})); - -vi.mock('$lib/api/likes', () => ({ - likeTrack: vi.fn().mockResolvedValue(undefined), - unlikeTrack: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock('$lib/api/quarantine', () => ({ - quarantineTrack: vi.fn().mockResolvedValue(undefined), - unquarantineTrack: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock('$lib/api/admin/tracks', () => ({ - removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't-1' }), -})); - -vi.mock('@tanstack/svelte-query', async (orig) => { - const actual = (await orig()) as Record; - return { - ...actual, - useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) }), - }; -}); - -const track: TrackRef = { - id: 't-1', title: 'Roygbiv', - albumId: 'al-1', albumTitle: 'Geogaddi', - artistId: 'art-1', artistName: 'Boards of Canada', - durationMs: 137000, trackNumber: 4, -} as unknown as TrackRef; - -beforeEach(() => { - vi.clearAllMocks(); -}); - -afterEach(() => { - vi.clearAllMocks(); -}); - -describe('TrackMenu', () => { - it('kebab toggles aria-expanded; menu opens with all 9 entries for an admin', async () => { - render(TrackMenu, { props: { track } }); - const kebab = screen.getByRole('button', { name: /track actions/i }); - expect(kebab.getAttribute('aria-expanded')).toBe('false'); - await fireEvent.click(kebab); - expect(kebab.getAttribute('aria-expanded')).toBe('true'); - - expect(screen.getByRole('menuitem', { name: /play next/i })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: /add to queue/i })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: /^like$/i })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: /add to playlist/i })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: /go to album/i })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: /go to artist/i })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: /flag this track/i })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: /^hide$/i })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: /remove from library/i })).toBeInTheDocument(); - }); - - it('hides "Remove from library" for non-admin users', async () => { - const { meStore } = await import('$lib/stores/me'); - (meStore as unknown as { set: (v: unknown) => void }).set({ id: 'u-2', username: 'alice', is_admin: false }); - - render(TrackMenu, { props: { track } }); - await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); - expect(screen.queryByRole('menuitem', { name: /remove from library/i })).not.toBeInTheDocument(); - }); - - it('"Add to playlist…" is disabled with a tooltip until #352 ships', async () => { - render(TrackMenu, { props: { track } }); - await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); - const item = screen.getByRole('menuitem', { name: /add to playlist/i }); - expect(item.getAttribute('aria-disabled')).toBe('true'); - expect(item.getAttribute('title')).toBe('Coming with playlists'); - }); - - it('Play next dispatches to the player store', async () => { - const { playNext } = await import('$lib/player/store.svelte'); - render(TrackMenu, { props: { track } }); - await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); - await fireEvent.click(screen.getByRole('menuitem', { name: /play next/i })); - expect(playNext).toHaveBeenCalledWith(track); - }); - - it('Remove from library is two-click — first click arms, second confirms', async () => { - const { removeTrack } = await import('$lib/api/admin/tracks'); - render(TrackMenu, { props: { track } }); - await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); - - await fireEvent.click(screen.getByRole('menuitem', { name: /remove from library/i })); - expect(screen.getByRole('menuitem', { name: /click again to confirm/i })).toBeInTheDocument(); - expect(removeTrack).not.toHaveBeenCalled(); - - await fireEvent.click(screen.getByRole('menuitem', { name: /click again to confirm/i })); - await waitFor(() => expect(removeTrack).toHaveBeenCalledWith(track.id)); - }); - - it('Escape closes the menu and returns aria-expanded to false', async () => { - render(TrackMenu, { props: { track } }); - const kebab = screen.getByRole('button', { name: /track actions/i }); - await fireEvent.click(kebab); - expect(kebab.getAttribute('aria-expanded')).toBe('true'); - await fireEvent.keyDown(window, { key: 'Escape' }); - await waitFor(() => expect(kebab.getAttribute('aria-expanded')).toBe('false')); - }); -}); -``` - -If `$lib/stores/me`, `$lib/stores/likedIds`, or `$lib/stores/hiddenIds` don't exist by those exact paths, update both the production component (Step 8.1) and the mock here to match the actual paths. The functional shape stays the same. - -- [ ] **Step 8.3: Commit** - -```bash -git add web/src/lib/components/TrackMenu.svelte web/src/lib/components/TrackMenu.test.ts -git commit -F - <<'EOF' -feat(web): TrackMenu rewrite — 9 entries in 4 groups (M7 #372) - -Replaces the M5b-era FlagPopover-only menu with the full track-actions -surface: queue (Play next, Add to queue), collection (Like/Unlike, -Add to playlist… reserved for #352), navigation (Go to album/artist), -lifecycle (Flag, Hide/Unhide, Remove from library — admin-only). - -Remove from library uses a two-click confirm (no modal) so the user -sees the next click is destructive without a dialog interrupting the -flow. The component's prop API (track, direction) is unchanged so -TrackRow + PlayerBar pick up the new entries with no code change. -EOF -``` - ---- - -### Task 9 — Adjust `` and `` test expectations - -**Files:** -- Modify: `web/src/lib/components/TrackRow.test.ts` -- Modify: `web/src/lib/components/PlayerBar.test.ts` - -The component code for TrackRow + PlayerBar doesn't change — both already use `` with the same prop API. But existing tests in those files assert "the menu opens to a single Flag entry" (or similar M5b-era expectations). After Task 8 those assertions are wrong. - -- [ ] **Step 9.1: Open `web/src/lib/components/TrackRow.test.ts`** and find any test that asserts on ``'s open-state contents (search for "Flag this track", "menuitem", "aria-haspopup"). Replace those assertions with one that confirms the kebab is present and `aria-haspopup="menu"` is set: - -```ts -it('renders a track-actions kebab with aria-haspopup="menu"', () => { - render(TrackRow, { props: { track: fakeTrack } }); - const kebab = screen.getByRole('button', { name: /track actions/i }); - expect(kebab.getAttribute('aria-haspopup')).toBe('menu'); -}); -``` - -The detailed menu-content tests live in `TrackMenu.test.ts` (Task 8) — TrackRow's job is just to mount the menu, so its tests should not duplicate the entry-list assertions. - -- [ ] **Step 9.2: Same change in `web/src/lib/components/PlayerBar.test.ts`.** PlayerBar passes `direction="up"` to TrackMenu (drop-up); add an assertion confirming that prop reaches the kebab if the existing test suite covers that surface: - -```ts -it('mounts TrackMenu with drop-up direction', () => { - render(PlayerBar, { props: { /* ...existing setup... */ } }); - const kebab = screen.getByRole('button', { name: /track actions/i }); - expect(kebab).toBeInTheDocument(); - // Direction="up" is an internal CSS class concern; not assertable - // through aria. The existing snapshot or DOM-class check (if any) - // will need a manual update after the menu rewrite. -}); -``` - -- [ ] **Step 9.3: Commit** - -```bash -git add web/src/lib/components/TrackRow.test.ts web/src/lib/components/PlayerBar.test.ts -git commit -F - <<'EOF' -test(web): adjust TrackRow + PlayerBar tests for new TrackMenu shape - -The 9-entry menu rewrite (Task 8) breaks any pre-existing assertion -that the menu opens to a single Flag entry. Strip those — TrackMenu's -own test file covers the entry list end-to-end. TrackRow + PlayerBar -just need to confirm the kebab mounts with aria-haspopup="menu". -EOF -``` - ---- - -## Self-review - -1. **Spec coverage:** - - §2 Goals — all 9 entries land in Task 8; Like/Hide menu duplication shipped; "Add to playlist…" reserved as a disabled slot; "Go to artist" single-target; "Remove from library" admin-only with cascade. ✓ - - §3 Architecture (backend) — Tasks 1, 2, 3 cover SQL queries, RemoveTrack service, HTTP handler. ✓ - - §3 Architecture (frontend) — Task 4 (admin API helper), Task 5 (audio store), Tasks 6-8 (component primitives + TrackMenu rewrite). ✓ - - §3 Accessibility — kebab `aria-haspopup`/`aria-expanded`, `role="menu"`, `role="menuitem"`, `aria-disabled`, Escape-to-close all in Task 8. ✓ - - §4 File map — every file in the plan's File map maps to a task. ✓ - - §5 API contract — Task 3.1 implements the response envelope; Task 3.4 tests the four error codes. ✓ - - §6 Error handling — Task 8 wires `copyForCode()` for toast surface. ✓ - - §7 Testing — every test file from §7 has a writing step. ✓ - - §8 Distribution / migration — confirmed no migration needed (all `track_id` FKs already CASCADE). The "conditional 0014_track_cascades.up.sql" from the spec is dropped here as redundant. ✓ (this is an intentional plan deviation from the spec's optional clause) - -2. **Placeholder scan:** No "TBD"/"TODO"/"add validation" in any task. The phrase "if unsure" appears once in Task 8 ("verify those exist (they should)") which is direction to the implementer, not a placeholder. - -3. **Type consistency:** `TrackRef`, `RemoveTrackResult`, `removeTrack(id)`, `playNext(t)`, `enqueueTrack(t)`, `qk.albums(id)`, `qk.artists(id)`, `qk.home()`, `qk.likedTracks()`, `qk.hiddenTracks()` all consistent across tasks. Backend types: `tracks.Service`, `tracks.RemoveTrack`, `tracks.ErrNotFound`, `tracks.ErrLidarrUnreachable` all consistent. - -4. **One spec deviation noted explicitly:** the spec says migration `0014_track_cascades.up.sql` is "conditional, only if any FK is RESTRICT". I checked — all four `track_id` FKs (`play_events`, `general_likes_tracks`, `lidarr_quarantine`, `lidarr_quarantine_actions`) already use `ON DELETE CASCADE`. No migration is needed; the plan does not include one. diff --git a/docs/superpowers/specs/2026-04-20-web-ui-library-reads-design.md b/docs/superpowers/specs/2026-04-20-web-ui-library-reads-design.md deleted file mode 100644 index 010b2b23..00000000 --- a/docs/superpowers/specs/2026-04-20-web-ui-library-reads-design.md +++ /dev/null @@ -1,319 +0,0 @@ -# Web UI Library Reads — Design - -**Status:** approved (2026-04-20) -**Follows:** `2026-04-20-web-ui-scaffold-design.md`, `2026-04-20-web-ui-server-auth-foundation` (shipped) -**Precedes:** Plan 3 — stream + cover endpoints - -## Goal - -Add the read-only library surface to Minstrel's native JSON API at `/api/*`. Five endpoints: paginated artist list (two sort modes), artist detail, album detail, track detail, and unified search. This is the data the SvelteKit SPA needs to render browse and search views; streaming and cover art follow in Plan 3. - -Scope explicitly excludes: stream, cover, write operations (favorites, play history), admin endpoints beyond what's already mounted. - -## Routes - -All gated by the existing `RequireUser` middleware inside `api.Mount`: - -``` -GET /api/artists?sort={alpha|newest}&limit=&offset= -GET /api/artists/{id} -GET /api/albums/{id} -GET /api/tracks/{id} -GET /api/search?q=&limit=&offset= -``` - -Path-style IDs (chi `{id}`) — matches REST norms and the SPA router's expectations. Pagination defaults: `limit=50`, `limit` clamped to `[1,200]`, `offset=0`, `offset` clamped to `>=0`. Out-of-range values silently clamp (match subsonic ergonomics); malformed values (non-numeric) return 400. - -## Response Shapes - -Two-tier Ref/Detail split mirrors the subsonic package's pattern. - -### Refs - -Lightweight, embedded in list and detail responses. - -```go -type ArtistRef struct { - ID string `json:"id"` // UUID string - Name string `json:"name"` - AlbumCount int `json:"album_count"` -} - -type AlbumRef struct { - ID string `json:"id"` - Title string `json:"title"` - ArtistID string `json:"artist_id"` - ArtistName string `json:"artist_name"` - Year int `json:"year,omitempty"` - TrackCount int `json:"track_count"` - DurationSec int `json:"duration_sec"` - CoverURL string `json:"cover_url"` // /api/albums/{id}/cover — lands in Plan 3 -} - -type TrackRef struct { - ID string `json:"id"` - Title string `json:"title"` - AlbumID string `json:"album_id"` - AlbumTitle string `json:"album_title"` - ArtistID string `json:"artist_id"` - ArtistName string `json:"artist_name"` - TrackNumber int `json:"track_number,omitempty"` - DiscNumber int `json:"disc_number,omitempty"` - DurationSec int `json:"duration_sec"` - StreamURL string `json:"stream_url"` // /api/tracks/{id}/stream — Plan 3 -} -``` - -### Details - -Wrap a ref with nested children. - -```go -type ArtistDetail struct { - ArtistRef - Albums []AlbumRef `json:"albums"` -} - -type AlbumDetail struct { - AlbumRef - Tracks []TrackRef `json:"tracks"` -} - -// Track detail is just TrackRef — parent names are already embedded. -``` - -### Pagination Envelope - -Shared generic, defined once in `types.go`: - -```go -type Page[T any] struct { - Items []T `json:"items"` - Total int `json:"total"` - Limit int `json:"limit"` - Offset int `json:"offset"` -} -``` - -Used by `/api/artists` and each facet of `/api/search`. - -### Search Response - -Three independent paged facets sharing one `limit`/`offset`: - -```go -type SearchResponse struct { - Artists Page[ArtistRef] `json:"artists"` - Albums Page[AlbumRef] `json:"albums"` - Tracks Page[TrackRef] `json:"tracks"` -} -``` - -Shared limit/offset (vs subsonic's per-facet params) keeps the SPA simple: one set of pager controls per search page. - -### JSON conventions - -- UUIDs serialize as strings (not `pgtype.UUID`) via a `uuidToString` helper in `convert.go`. -- Empty collections always render as `[]`, never `null` — web clients reject null. Initialize slices explicitly before encoding. -- Field names: snake_case everywhere. - -## Embedded URLs - -Forward-reference URLs baked into responses now, even though Plan 3 implements the endpoints: - -- `AlbumRef.CoverURL` = `/api/albums/{id}/cover` -- `TrackRef.StreamURL` = `/api/tracks/{id}/stream` - -Built by helpers in `convert.go`: - -```go -func coverURL(albumID pgtype.UUID) string { return "/api/albums/" + uuidToString(albumID) + "/cover" } -func streamURL(trackID pgtype.UUID) string { return "/api/tracks/" + uuidToString(trackID) + "/stream" } -``` - -Relative paths — SPA and Flutter client resolve against configured base URL. Responses stay host-agnostic (reverse-proxy friendly). Until Plan 3, these paths 404; the SPA won't wire `
-
+ + {#each folders.data ?? [] as f (f.path)} + + {/each} + + + +
+ +
-{/if} + + + +

+ Notes are shown to the requester. +

+ +
+ + +
+
{#if toast}
{/if} -{#if showCreateModal} - - -
{ showCreateModal = false; }} - > - -{/if} +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ + -{#if resetPasswordTarget} - - -
{ resetPasswordTarget = null; resetPasswordValue = ''; resetPasswordConfirm = ''; }} - > - -{/if} +
+ + +
+
+ + +
+ + -{#if confirmDeleteTarget} - - -
{ confirmDeleteTarget = null; }} - > - + Cancel + +
-{/if} + diff --git a/web/src/routes/discover/+page.svelte b/web/src/routes/discover/+page.svelte index cd73a4ec..ad28820a 100644 --- a/web/src/routes/discover/+page.svelte +++ b/web/src/routes/discover/+page.svelte @@ -6,6 +6,7 @@ import DiscoverTabs from '$lib/components/DiscoverTabs.svelte'; import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte'; import SuggestionFeed from '$lib/components/SuggestionFeed.svelte'; + import Modal from '$lib/components/Modal.svelte'; import type { LidarrRequestKind, LidarrSearchResult @@ -194,46 +195,32 @@ {/if}
-{#if modalResult} - - -
- -{/if} + {/if} + From 617477b702aec24def24ab6bf619e54c2a1fe5c3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 05:45:34 -0400 Subject: [PATCH 323/351] refactor(web): pushToast store + ToastHost; 8 sites migrated (W3) --- web/src/lib/components/PlaylistCard.svelte | 25 +---- web/src/lib/components/ToastHost.svelte | 23 +++++ web/src/lib/stores/toast.svelte.test.ts | 93 +++++++++++++++++++ web/src/lib/stores/toast.svelte.ts | 43 +++++++++ web/src/routes/+layout.svelte | 3 + web/src/routes/admin/+page.svelte | 35 ++----- .../routes/admin/integrations/+page.svelte | 35 ++----- web/src/routes/admin/quarantine/+page.svelte | 31 +------ web/src/routes/admin/requests/+page.svelte | 30 +----- web/src/routes/admin/users/+page.svelte | 64 +++++-------- web/src/routes/playlists/[id]/+page.svelte | 25 +---- web/src/routes/settings/+page.svelte | 45 +++------ 12 files changed, 229 insertions(+), 223 deletions(-) create mode 100644 web/src/lib/components/ToastHost.svelte create mode 100644 web/src/lib/stores/toast.svelte.test.ts create mode 100644 web/src/lib/stores/toast.svelte.ts diff --git a/web/src/lib/components/PlaylistCard.svelte b/web/src/lib/components/PlaylistCard.svelte index c14c289d..665124a2 100644 --- a/web/src/lib/components/PlaylistCard.svelte +++ b/web/src/lib/components/PlaylistCard.svelte @@ -7,6 +7,7 @@ import { errCode } from '$lib/api/errors'; import { qk } from '$lib/api/queries'; import { playQueue } from '$lib/player/store.svelte'; + import { pushToast } from '$lib/stores/toast.svelte'; let { playlist }: { playlist: Playlist } = $props(); @@ -18,16 +19,6 @@ let starting = $state(false); let menuOpen = $state(false); - // --- Toast --- - let toast = $state(null); - let toastTimer: ReturnType | null = null; - - function showToast(msg: string) { - if (toastTimer) clearTimeout(toastTimer); - toast = msg; - toastTimer = setTimeout(() => { toast = null; }, 5000); - } - function toggleMenu(e: MouseEvent) { e.preventDefault(); e.stopPropagation(); @@ -92,27 +83,17 @@ menuOpen = false; try { await refreshDiscover(); - showToast('Discover refreshed.'); + pushToast('Discover refreshed.'); await queryClient.invalidateQueries({ queryKey: qk.playlist(playlist.id) }); await queryClient.invalidateQueries({ queryKey: qk.playlists() }); } catch (err: unknown) { - showToast(`Refresh failed: ${errCode(err)}`); + pushToast(`Refresh failed: ${errCode(err)}`, 'error'); } } { menuOpen = false; }} /> -{#if toast} -
- {toast} -
-{/if} -
{#if isDiscover}
diff --git a/web/src/lib/components/ToastHost.svelte b/web/src/lib/components/ToastHost.svelte new file mode 100644 index 00000000..3144510c --- /dev/null +++ b/web/src/lib/components/ToastHost.svelte @@ -0,0 +1,23 @@ + + +{#if toast.value} + {#key toast.value.id} +
+ {toast.value.message} +
+ {/key} +{/if} diff --git a/web/src/lib/stores/toast.svelte.test.ts b/web/src/lib/stores/toast.svelte.test.ts new file mode 100644 index 00000000..b96b5062 --- /dev/null +++ b/web/src/lib/stores/toast.svelte.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test, beforeEach, afterEach, vi } from 'vitest'; + +// Each test loads a fresh module so module-scope state (_toast, _timer, +// _next) doesn't leak between cases. vi.useFakeTimers() controls the +// auto-clear timeout deterministically; we never depend on real wall time. + +beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('pushToast / clearToast', () => { + test('pushToast sets value with default variant info', async () => { + const mod = await import('./toast.svelte'); + mod.pushToast('hello'); + expect(mod.toast.value).not.toBeNull(); + expect(mod.toast.value?.message).toBe('hello'); + expect(mod.toast.value?.variant).toBe('info'); + }); + + test('pushToast respects explicit variant error', async () => { + const mod = await import('./toast.svelte'); + mod.pushToast('boom', 'error'); + expect(mod.toast.value?.variant).toBe('error'); + expect(mod.toast.value?.message).toBe('boom'); + }); + + test('pushToast auto-clears after default 5000ms', async () => { + const mod = await import('./toast.svelte'); + mod.pushToast('temporary'); + expect(mod.toast.value).not.toBeNull(); + vi.advanceTimersByTime(4999); + expect(mod.toast.value).not.toBeNull(); + vi.advanceTimersByTime(1); + expect(mod.toast.value).toBeNull(); + }); + + test('pushToast respects custom duration', async () => { + const mod = await import('./toast.svelte'); + mod.pushToast('quick', 'info', 1000); + vi.advanceTimersByTime(999); + expect(mod.toast.value).not.toBeNull(); + vi.advanceTimersByTime(1); + expect(mod.toast.value).toBeNull(); + }); + + test('a second push replaces the first and only the second timer fires', async () => { + const mod = await import('./toast.svelte'); + mod.pushToast('first'); + vi.advanceTimersByTime(2000); + mod.pushToast('second'); + expect(mod.toast.value?.message).toBe('second'); + // 4000ms after the second push, only 6000ms have elapsed total — + // the first push's original 5000ms timer must NOT have fired + // (which would have cleared the second toast). + vi.advanceTimersByTime(4000); + expect(mod.toast.value?.message).toBe('second'); + // Now run out the second's clock. + vi.advanceTimersByTime(1000); + expect(mod.toast.value).toBeNull(); + }); + + test('clearToast resets value and timer', async () => { + const mod = await import('./toast.svelte'); + mod.pushToast('present'); + expect(mod.toast.value).not.toBeNull(); + mod.clearToast(); + expect(mod.toast.value).toBeNull(); + // Timer was cleared — advancing past the original duration must not + // re-clear or otherwise touch state in a way that would surface here. + vi.advanceTimersByTime(10_000); + expect(mod.toast.value).toBeNull(); + }); + + test('each push increments the id (drives {#key} re-mount)', async () => { + const mod = await import('./toast.svelte'); + mod.pushToast('a'); + const firstId = mod.toast.value?.id; + mod.pushToast('b'); + const secondId = mod.toast.value?.id; + mod.pushToast('c'); + const thirdId = mod.toast.value?.id; + expect(firstId).toBeDefined(); + expect(secondId).toBeDefined(); + expect(thirdId).toBeDefined(); + expect(secondId!).toBeGreaterThan(firstId!); + expect(thirdId!).toBeGreaterThan(secondId!); + }); +}); diff --git a/web/src/lib/stores/toast.svelte.ts b/web/src/lib/stores/toast.svelte.ts new file mode 100644 index 00000000..6a324c8a --- /dev/null +++ b/web/src/lib/stores/toast.svelte.ts @@ -0,0 +1,43 @@ +// Shared toast store. Pages call `pushToast(message, variant?)`; a single +// mounted in +layout.svelte renders the result. Replacing a +// live toast cancels its timer so the new message gets the full duration, +// and bumps `id` so the host's {#key} block re-mounts the node — important +// for screen readers, which won't re-announce text changes inside an +// already-live region. + +type ToastVariant = 'info' | 'error'; + +interface Toast { + id: number; + message: string; + variant: ToastVariant; +} + +let _toast = $state(null); +let _timer: ReturnType | null = null; +let _next = 0; + +export const toast = { + get value(): Toast | null { + return _toast; + } +}; + +export function pushToast( + message: string, + variant: ToastVariant = 'info', + durationMs = 5000 +): void { + if (_timer) clearTimeout(_timer); + _toast = { id: ++_next, message, variant }; + _timer = setTimeout(() => { + _toast = null; + _timer = null; + }, durationMs); +} + +export function clearToast(): void { + if (_timer) clearTimeout(_timer); + _timer = null; + _toast = null; +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 89c49961..7d397225 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -7,6 +7,7 @@ import { user } from '$lib/auth/store.svelte'; import Shell from '$lib/components/Shell.svelte'; import QueueDrawer from '$lib/components/QueueDrawer.svelte'; + import ToastHost from '$lib/components/ToastHost.svelte'; import { registerAudioEl, reportTimeUpdate, @@ -111,3 +112,5 @@ {@render children()} {/if} + + diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte index 46175da9..82328ab9 100644 --- a/web/src/routes/admin/+page.svelte +++ b/web/src/routes/admin/+page.svelte @@ -25,6 +25,7 @@ } from '$lib/api/admin'; import { qk } from '$lib/api/queries'; import { errCode, errMessage } from '$lib/api/errors'; + import { pushToast } from '$lib/stores/toast.svelte'; import type { AdminQuarantineRow, LidarrRequest, @@ -63,16 +64,6 @@ (quarantine.data ?? []).slice(0, PREVIEW_LIMIT) ); - // ---- Toast (shared) ---- - let toast = $state(null); - let toastTimer: ReturnType | null = null; - - function showToast(msg: string) { - if (toastTimer) clearTimeout(toastTimer); - toast = msg; - toastTimer = setTimeout(() => { toast = null; }, 5000); - } - // ---- Two-click destructive confirm for quarantine actions ---- // Map key is `${trackId}:${actionKey}`. First click sets a key; second // click within 3s fires the action; otherwise the key clears. @@ -132,7 +123,7 @@ await approveRequest(r.id); await invalidateRequests(); } catch (e) { - showToast(errMessage(e)); + pushToast(errMessage(e), 'error'); } } async function onReject(r: LidarrRequest) { @@ -140,7 +131,7 @@ await rejectRequest(r.id); await invalidateRequests(); } catch (e) { - showToast(errMessage(e)); + pushToast(errMessage(e), 'error'); } } @@ -156,7 +147,7 @@ await resolveQuarantine(row.track_id); await invalidateQuarantine(); } catch (e) { - showToast(errMessage(e)); + pushToast(errMessage(e), 'error'); } } async function onDeleteFile(row: AdminQuarantineRow) { @@ -170,7 +161,7 @@ await deleteQuarantineFile(row.track_id); await invalidateQuarantine(); } catch (e) { - showToast(errMessage(e)); + pushToast(errMessage(e), 'error'); } } async function onDeleteLidarr(row: AdminQuarantineRow) { @@ -184,7 +175,7 @@ await deleteQuarantineViaLidarr(row.track_id); await invalidateQuarantine(); } catch (e) { - showToast(errMessage(e)); + pushToast(errMessage(e), 'error'); } } @@ -286,7 +277,7 @@ await updateScanSchedule(patch); await client.invalidateQueries({ queryKey: qk.scanSchedule() }); } catch (e) { - showToast(`Schedule save failed: ${errCode(e)}`); + pushToast(`Schedule save failed: ${errCode(e)}`, 'error'); } finally { scheduleSaving = false; } @@ -323,9 +314,9 @@ researchSaving = true; try { await researchMissingArt(); - showToast('All previously-failed art will be re-attempted on the next scan.'); + pushToast('All previously-failed art will be re-attempted on the next scan.'); } catch (e) { - showToast(`Re-search failed: ${errCode(e)}`); + pushToast(`Re-search failed: ${errCode(e)}`, 'error'); } finally { researchSaving = false; } @@ -728,11 +719,3 @@
-{#if toast} - -{/if} diff --git a/web/src/routes/admin/integrations/+page.svelte b/web/src/routes/admin/integrations/+page.svelte index 09cfcd58..8512c15f 100644 --- a/web/src/routes/admin/integrations/+page.svelte +++ b/web/src/routes/admin/integrations/+page.svelte @@ -20,6 +20,7 @@ } from '$lib/api/admin'; import { qk } from '$lib/api/queries'; import { errCode } from '$lib/api/errors'; + import { pushToast } from '$lib/stores/toast.svelte'; import Modal from '$lib/components/Modal.svelte'; import type { LidarrConfig, LidarrTestResult } from '$lib/api/types'; @@ -235,17 +236,6 @@ } } - // Toast ------------------------------------------------------------------- - - let toast = $state(null); - let toastTimer: ReturnType | undefined; - - function showToast(msg: string) { - toast = msg; - if (toastTimer) clearTimeout(toastTimer); - toastTimer = setTimeout(() => { toast = null; }, 4000); - } - // SMTP config ------------------------------------------------------------- const smtpStore = $derived(createSMTPConfigQuery()); @@ -272,11 +262,11 @@ await updateSMTPConfig({ ...smtpForm, password: smtpPasswordInput }); await client.invalidateQueries({ queryKey: qk.smtpConfig() }); smtpPasswordInput = ''; - showToast('SMTP config saved.'); + pushToast('SMTP config saved.'); } catch (e: unknown) { const code = errCode(e); - if (code === 'missing_fields') showToast('Host and from address are required when enabled.'); - else showToast(`Save failed: ${code}`); + if (code === 'missing_fields') pushToast('Host and from address are required when enabled.', 'error'); + else pushToast(`Save failed: ${code}`, 'error'); } finally { smtpSaving = false; } @@ -286,14 +276,14 @@ smtpTesting = true; try { await testSMTPConfig(); - showToast('Test email sent. Check your inbox.'); + pushToast('Test email sent. Check your inbox.'); } catch (e: unknown) { const code = errCode(e); const message = (e as { message?: string })?.message; - if (code === 'no_email_on_file') showToast('Set your email in /settings before testing.'); - else if (code === 'not_configured') showToast('Save the SMTP config first.'); - else if (code === 'send_failed') showToast(`Send failed: ${message || 'see server logs'}`); - else showToast(`Test failed: ${code}`); + if (code === 'no_email_on_file') pushToast('Set your email in /settings before testing.', 'error'); + else if (code === 'not_configured') pushToast('Save the SMTP config first.', 'error'); + else if (code === 'send_failed') pushToast(`Send failed: ${message || 'see server logs'}`, 'error'); + else pushToast(`Test failed: ${code}`, 'error'); } finally { smtpTesting = false; } @@ -302,13 +292,6 @@ {pageTitle('Admin · Integrations')} -{#if toast} -
- {toast} -
-{/if} -

Integrations

diff --git a/web/src/routes/admin/quarantine/+page.svelte b/web/src/routes/admin/quarantine/+page.svelte index 34827da1..b2ac18f4 100644 --- a/web/src/routes/admin/quarantine/+page.svelte +++ b/web/src/routes/admin/quarantine/+page.svelte @@ -10,6 +10,7 @@ } from '$lib/api/admin'; import { qk } from '$lib/api/queries'; import { errMessage } from '$lib/api/errors'; + import { pushToast } from '$lib/stores/toast.svelte'; import { playRadio } from '$lib/player/store.svelte'; import Modal from '$lib/components/Modal.svelte'; import type { AdminQuarantineRow, LidarrQuarantineReason } from '$lib/api/types'; @@ -68,19 +69,6 @@ // feedback if Lidarr can't be reached or the album lookup fails. let deleteLidarrError = $state(null); - // Toast surface — same pattern as /admin/requests for the lidarr-unreachable - // and other error codes from /resolve and /delete-file. - let toast = $state(null); - let toastTimer: ReturnType | null = null; - - function showToast(msg: string) { - if (toastTimer) clearTimeout(toastTimer); - toast = msg; - toastTimer = setTimeout(() => { - toast = null; - }, 5000); - } - async function invalidate() { await client.invalidateQueries({ queryKey: qk.adminQuarantine() }); } @@ -90,7 +78,7 @@ await resolveQuarantine(r.track_id); await invalidate(); } catch (e) { - showToast(errMessage(e)); + pushToast(errMessage(e), 'error'); } } @@ -108,7 +96,7 @@ await deleteQuarantineFile(r.track_id); await invalidate(); } catch (e) { - showToast(errMessage(e)); + pushToast(errMessage(e), 'error'); } } @@ -139,7 +127,7 @@ // alongside the album they were about to remove. deleteLidarrError = msg; // Also surface as toast so it's visible after dismissing the modal. - showToast(msg); + pushToast(msg, 'error'); } } @@ -409,17 +397,6 @@ {/if} -{#if toast} -
- {toast} -
-{/if} - diff --git a/web/src/lib/components/ArtistCard.svelte b/web/src/lib/components/ArtistCard.svelte index 87dd14b9..8a2a4ff5 100644 --- a/web/src/lib/components/ArtistCard.svelte +++ b/web/src/lib/components/ArtistCard.svelte @@ -89,17 +89,3 @@
- diff --git a/web/src/lib/components/PlaylistCard.svelte b/web/src/lib/components/PlaylistCard.svelte index 665124a2..ba67479c 100644 --- a/web/src/lib/components/PlaylistCard.svelte +++ b/web/src/lib/components/PlaylistCard.svelte @@ -168,17 +168,3 @@
- From 6a444334eaf7aaae2f0d8f92215fa447354d32c5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 11:29:36 -0400 Subject: [PATCH 349/351] fix(server/api/test): drop unused 'context' imports A3 sed left behind --- internal/api/events_test.go | 1 - internal/api/me_test.go | 1 - 2 files changed, 2 deletions(-) diff --git a/internal/api/events_test.go b/internal/api/events_test.go index 1d4f9695..98e52e87 100644 --- a/internal/api/events_test.go +++ b/internal/api/events_test.go @@ -2,7 +2,6 @@ package api import ( "bytes" - "context" "encoding/json" "net/http" "net/http/httptest" diff --git a/internal/api/me_test.go b/internal/api/me_test.go index 75b98ee5..5f37b92e 100644 --- a/internal/api/me_test.go +++ b/internal/api/me_test.go @@ -1,7 +1,6 @@ package api import ( - "context" "encoding/json" "net/http" "net/http/httptest" From b5a138bb27b81af852239b1e0afd9b6df08ec94b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 12:05:35 -0400 Subject: [PATCH 350/351] fix(server): repair recursive withUser, gofmt 2 files (golangci-lint) --- internal/api/likes.go | 1 - internal/api/requests_test.go | 2 +- internal/coverart/provider_theaudiodb.go | 7 +++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/internal/api/likes.go b/internal/api/likes.go index dcf5ac87..7e877fbc 100644 --- a/internal/api/likes.go +++ b/internal/api/likes.go @@ -302,4 +302,3 @@ func uuidsToStrings(ids []pgtype.UUID) []string { } return out } - diff --git a/internal/api/requests_test.go b/internal/api/requests_test.go index db22b797..c3f95a44 100644 --- a/internal/api/requests_test.go +++ b/internal/api/requests_test.go @@ -29,7 +29,7 @@ func newRequestsRouter(h *handlers) chi.Router { // withUser injects a user into the request context the same way RequireUser does. func withUser(req *http.Request, user dbq.User) *http.Request { - return withUser(req, user) + return req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user)) } // doCreateRequest fires POST /api/requests with the given JSON body as the given user. diff --git a/internal/coverart/provider_theaudiodb.go b/internal/coverart/provider_theaudiodb.go index f25e45a5..984502e1 100644 --- a/internal/coverart/provider_theaudiodb.go +++ b/internal/coverart/provider_theaudiodb.go @@ -112,7 +112,7 @@ func (p *theAudioDBProvider) FetchAlbumCover(ctx context.Context, ref AlbumRef) if len(resp.Album) == 0 || resp.Album[0].StrAlbumThumb == nil || *resp.Album[0].StrAlbumThumb == "" { return nil, ErrNotFound } - return p.client.getImage(ctx,*resp.Album[0].StrAlbumThumb) + return p.client.getImage(ctx, *resp.Album[0].StrAlbumThumb) } // FetchArtistArt hits /artist-mb.php?i=, parses strArtistThumb + @@ -160,7 +160,7 @@ func (p *theAudioDBProvider) FetchArtistArt(ctx context.Context, ref ArtistRef) } if thumbURL != "" { - thumb, err = p.client.getImage(ctx,thumbURL) + thumb, err = p.client.getImage(ctx, thumbURL) if err != nil && !errors.Is(err, ErrTransient) && !errors.Is(err, ErrNotFound) { return nil, nil, err } @@ -170,7 +170,7 @@ func (p *theAudioDBProvider) FetchArtistArt(ctx context.Context, ref ArtistRef) } } if fanartURL != "" { - fanart, err = p.client.getImage(ctx,fanartURL) + fanart, err = p.client.getImage(ctx, fanartURL) if err != nil && !errors.Is(err, ErrTransient) && !errors.Is(err, ErrNotFound) { return thumb, nil, err } @@ -204,4 +204,3 @@ func (p *theAudioDBProvider) TestConnection(ctx context.Context) error { } return p.client.getJSON(ctx, p.jsonURL("album-mb.php?i="+theAudioDBTestSampleMBID), &resp) } - From 4f669c26e9927fec7338d5a8f2300661841e53f5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 13:21:45 -0400 Subject: [PATCH 351/351] fix(coverart/test): set MinInterval in rate-limit-serialization tests --- internal/coverart/provider_deezer_test.go | 15 ++++++++++++++- internal/coverart/provider_lastfm_test.go | 18 +++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/internal/coverart/provider_deezer_test.go b/internal/coverart/provider_deezer_test.go index 9717e262..56d8035a 100644 --- a/internal/coverart/provider_deezer_test.go +++ b/internal/coverart/provider_deezer_test.go @@ -173,7 +173,20 @@ func TestDeezer_RateLimit_Serializes(t *testing.T) { })) defer srv.Close() - p := newDeezerTestProvider(t, srv.URL) + old := deezerBaseURL + deezerBaseURL = srv.URL + t.Cleanup(func() { deezerBaseURL = old }) + + // Build a provider WITH the production MinInterval so this test can + // verify the rate-limit actually serializes. Other tests use + // newDeezerTestProvider which leaves MinInterval=0 for speed. + p := &deezerProvider{client: newHTTPClient(httpClientOptions{ + Name: "deezer", + HTTPClient: &http.Client{Timeout: 5 * time.Second}, + MinInterval: deezerMinPeriod, + BaseBackoff: time.Millisecond, + })} + p.enabled.Store(true) start := time.Now() _, _, _ = p.FetchArtistArt(context.Background(), ArtistRef{Name: "A"}) diff --git a/internal/coverart/provider_lastfm_test.go b/internal/coverart/provider_lastfm_test.go index 55783152..ad836546 100644 --- a/internal/coverart/provider_lastfm_test.go +++ b/internal/coverart/provider_lastfm_test.go @@ -353,7 +353,23 @@ func TestLastfm_RateLimit_Serializes(t *testing.T) { })) defer srv.Close() - p := newLastfmTestProvider(t, srv.URL+"/", "test-key") + prevBase := lastfmBaseURL + lastfmBaseURL = srv.URL + "/" + t.Cleanup(func() { lastfmBaseURL = prevBase }) + + // Build a provider WITH the production MinInterval so this test can + // verify the rate-limit actually serializes. Other tests use + // newLastfmTestProvider which leaves MinInterval=0 for speed. + p := &lastfmProvider{client: newHTTPClient(httpClientOptions{ + Name: "lastfm", + HTTPClient: &http.Client{Timeout: 5 * time.Second}, + MinInterval: lastfmMinPeriod, + TreatAuthAsTransient: true, + BaseBackoff: time.Millisecond, + })} + p.enabled.Store(true) + key := "test-key" + p.apiKey.Store(&key) start := time.Now() _, _, _ = p.FetchArtistArt(context.Background(), ArtistRef{Name: "A"})