Files
minstrel/docs/superpowers/specs/2026-04-30-m6a-home-page-design.md
T
bvandeusen fd053e76ca 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) <noreply@anthropic.com>
2026-05-01 08:07:37 -04:00

26 KiB
Raw Blame History

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 (<AlbumCard>, <ArtistRow>) is rebuilt at the design-system bar; <ArtistRow>'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 <ArtistCard> (~140px wide) with alphabetical letter dividers between sort-name groups.
  • /library/albums is a parallel wrapping grid of <AlbumCard> with alphabetical dividers by album title.
  • <AlbumCard> polish-pass: replace pre-FabledSword styling, add play-button overlay alongside the existing add-to-queue button.
  • <ArtistRow> retired (replaced by <ArtistCard> 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.

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-<a> wrapper, no relative-positioning click-trap).
  • Shell.sveltenavItems updated to Home · Artists · Albums · Liked · Hidden · Search · Discover · Requests · Playlists · Settings.

Retired components:

  • ArtistRow.svelte — replaced by <ArtistCard> everywhere. The absolute inset-0 <a> 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 <RecentlyAddedSection>, <RediscoverSection>, <MostPlayedSection>, <LastPlayedSection> (or inlines the four sections if subdivision adds noise without value).
  • web/src/routes/library/artists/+page.svelte — wrapping grid of <ArtistCard> via <AlphabeticalGrid>. 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.tslistHome() + 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:

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:

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):

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):

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."

<HorizontalScrollRow> 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).

<AlbumCard> (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 <a> 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.

<ArtistCard> (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}.

<CompactTrackCard> (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.

<AlphabeticalGrid> (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 <ArtistCard>) and /library/albums (with <AlbumCard>).

/library/artists

  • H2 "Artists" + Vellum subtitle showing total count ("327 artists").
  • <AlphabeticalGrid> wrapping <ArtistCard> 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 <AlbumCard> (squares) and dividers driven by albums.sort_title[0].

Shell.svelte nav update

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)

  • <HorizontalScrollRow>: arrow clicks fire scrollBy; left disabled at start; right disabled at end; renders items snippet.
  • <AlbumCard>: play overlay click fetches album detail and calls playQueue with index 0; card click navigates to /albums/{id}.
  • <ArtistCard>: play overlay click fetches /api/artists/{id}/tracks, shuffles, calls playQueue at 0; falls back to Disc3 when cover_art_path is null.
  • <CompactTrackCard>: card click calls playQueue with section tracks at the right index.
  • <AlphabeticalGrid>: 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 <HorizontalScrollRow> 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 <ArtistRow> retired; <ArtistCard> (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 <AlbumCard> 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., <RecentlyAddedSection>) 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.