- Ambient bob: bubbles gently float (sine offset, phase varies by position so
they move in a wave, not in unison). Driven by a persistent rAF loop that runs
only while bubbles are on screen + the tab is visible, and parks when zoomed
out (_liveCount==0) or the map closes — so an idle overview costs nothing.
- Glassy specular highlight (cached sprite, cheap drawImage per bubble) so
bubbles read as glossy orbs at every size.
- Tighter island spacing (water gap 7*nodeR → 3.5*nodeR) so the settled
overview is more substantial, not thin-spread — addresses the 'mini version'
feel after the reveal ripples fade.
- Ambient resumes on zoom and on tab re-focus; stops cleanly on close.
64 JS integrity tests pass.
Islands now bloom in like drops on water instead of a flat fade:
- Each island reveals in turn (staggered by island order); within an island,
bubbles fade + scale (0.55→1, ease-out) outward from the centre by radial
distance — a drop-in-water bloom. Genre titles fade in just after.
- A hue-tinted water ripple ring expands from each island centre as it blooms
(_artMapDrawRipples — reused by click ripples in Phase E).
- During the reveal the static buffer is bypassed so EVERY bubble can animate
(live layer, cap 2200); when the bloom ends it bakes into the buffer once and
steady-state returns to the cheap two-layer path.
- aAlpha folds into the global draw-alpha multiplier so fades compose cleanly.
64 JS integrity tests pass.
All three maps (watchlist / genre / explore) now lay out as genre 'islands' on
the water via one shared engine (_artMapLayoutIslands):
- Group artists by primary genre (long tail folds into 'Other'; max 14 islands).
- Each island is a FILLED disc of covers packed centre-out (no empty donut
hole), most-popular nearest the middle, focal artists sized up + centre-most.
- Islands spread by golden spiral + push-apart with generous water between.
- Clean floating genre TITLE above each island (hue-tinted, glow) instead of
the old giant translucent label bubble.
- Per-genre accent hue tints member-bubble borders so clusters read as a family.
- Discovery edges (watchlist→similar, center→ring1→ring2) remapped to the new
node ids so the hover constellation still works across islands.
Replaces the per-artist donut clusters from the screenshots. Shared helpers:
_artMapGroupByGenre, _artMapPackDisc, _artMapRemapEdges, _artMapFitToContent.
64 JS integrity tests pass.
Addresses the screenshot feedback (mix of detailed covers + blank dots, lag,
'weird' load):
- Pre-mask each album image into a circle ONCE at load (a canvas), so every
draw is a plain drawImage instead of a per-frame ctx.clip(). Clipping was
the live-layer stutter — hundreds of clips per frame. Now free.
- Draw album art at nearly every on-screen size (only sub-2.2px fall back to a
dot), instead of detailed-vs-blank-dot tiers. Consistent 'sea of covers'.
- Reveal is now a clean ease-out-cubic fade of the whole map (buffer blit +
live layer ramp together via _drawAlphaMul) — dropped the bouncy per-node
pop that read as 'weird'. The real island ripple bloom comes in Phase C.
64 JS integrity tests pass.
Foundation for the water/ripple redesign. Splits rendering into:
- Static far-field buffer: small/distant bubbles, baked once (cheap blit).
- Live overlay layer: every bubble big enough to read (radius*zoom >= LIVE_PX)
redrawn each frame in world space, so it can scale/bob/ripple. Viewport-
culled + capped at 600 draws.
The partition is frozen at buffer-build zoom (_liveBuildZoom) so the two sets
stay exact complements even mid-zoom — no flicker, no double-draw.
Adds an idle-capable rAF loop (_artMapStartLoop/_artMapStepAnimations) that runs
only while something animates and stops when still. First payload: a reveal —
the far field fades in globally while live bubbles pop outward from the camera
centre (ease-out-back, staggered by distance). Wired into all three loaders.
Bonus: live bubbles now draw full-res at the current zoom instead of through the
4096px-capped buffer, so zoomed-in artwork is crisp (addresses the earlier
low-res complaint structurally). Engine only — the island layout, ripple
choreography and click physics build on this in B–E. 64 JS integrity tests pass.
The streaming fix in Phase 4 still rebuilt the ENTIRE offscreen buffer (~1500
nodes) on each image wave, and any hover/pan during streaming hit that same
dirty flag — so interacting while images loaded redrew the whole world over and
over (the 'laggy until all images load' jank).
Now each arriving image composites ONLY its own node into the existing buffer
(_artMapCompositeNode) and does a cheap rAF-coalesced blit — no full rebuild.
The per-node draw is extracted into _artMapDrawNodeToBuffer so the full rebuild
and the incremental compositor share identical drawing (can't drift). Falls back
to a full rebuild only if the buffer isn't built yet. Pan/hover stay at
blit-speed the entire time images stream in.
All three maps (watchlist/genre/explore) now paint instantly with placeholder
circles and stay fully interactive (pan/zoom/hover/click) while images stream
in throttled ~280ms waves and sharpen the map in place. Replaces the old
blocking 'await all N images then paint' loaders — the headline 'feels slow'
fix. Focal/large nodes fetch first; a per-open load token cancels stale streams
when you jump to another artist, so rapid click-through never piles up fetches.
- Images: decode adaptively (focal/watchlist nodes ~256-384px, small nodes
~112-150px) instead of a flat 128px — crisp where it matters, memory still
bounded (~150-250MB, not 6GB). Fixes the low-res look.
- Hover constellation: drop the activation delay 800ms → 220ms (it felt 'gone'
because nothing happened for nearly a second), and draw the connection lines
as a wide-faint halo + crisp core (a real glow) with no per-frame gradients
or shadowBlur — stays cheap.
- Backdrop: subtle cached radial glow + vignette behind the map for depth
instead of a flat fill (one cheap fillRect/frame).
JS clean; 64 integrity tests pass.
Perf telemetry was the giveaway: after the buffer cap, rebuild + draw were both
~10ms, yet fps stayed 1-3 and the browser 'locked'. Cheap draw + locked system =
memory/GPU thrash, not drawing.
Cause: artist images load at up to 1000×1000, and a dense map holds ~1500 of
them — ~1500 × 1000² × 4B ≈ 6 GB of decoded ImageBitmap memory. The browser GCs/
evicts textures constantly → systemic lag the canvas timers don't see.
Fix: decode straight to a 128px avatar via createImageBitmap resize options
(nodes render tiny anyway). ~1500 × 128² × 4B ≈ 100 MB instead of 6 GB. Falls
back to full decode on engines that ignore the resize opts.
This is the one that should actually make it smooth. Perf overlay stays on 'd'.
Perf telemetry from the genre map (2004 nodes) proved it: the offscreen buffer
was 7465×10240 (76 megapixels) — rebuilt in ~979ms on every zoom and blitted at
~150ms/frame (3 fps), with the constellation overlay piling on top. The buffer
renders the WHOLE world, and the size cap was 10240px.
Cap the max buffer dimension to 4096 (MAX_BUFFER_PX). On the dense genre map
that's ~12MP instead of 76MP → ~6x faster rebuild and blit, and more nodes drop
under the LOD dot threshold so the rebuild also draws fewer image-clips. The cap
only binds on large worlds; small watchlist/explorer maps don't reach it and
stay full-resolution.
Tunable; perf overlay ('d' → app.log) stays so we can confirm the new numbers.
The on-canvas overlay text can't be copied (and can't be grabbed mid-freeze), so
when perf mode is on ('d'), the frontend now also POSTs the render timings to
/api/discover/artist-map/perf ~1.5x/sec, which logs them as [ARTMAP-PERF] in
app.log. Lets the bottleneck be diagnosed from the server side with no manual
copying.
- REVERT the spatial-grid hit-test I added in Phase 1. It inserted each node
into every grid cell its bounding box overlaps; the genre map's huge cluster
nodes span an enormous number of cells, so the first hover/click triggered a
multi-second synchronous build → 'can't hover or click' freeze. Back to a flat
O(N) single-pass hit-test (no per-move sort) — sub-ms even for thousands of
nodes, can't lock up.
- Keep the safe Phase 1 wins (render coalescing, tooltip de-churn, solid-stroke
connection lines).
- Add a perf overlay toggled with 'd' on the map: shows node/edge counts, the
offscreen buffer size + scale, zoom, and the last buffer-rebuild + draw times.
So we can measure the real drag/zoom bottleneck (buffer rebuild) instead of
optimising blind.
JS clean; 64 integrity tests pass.
The Explorer prompt accepted any loose text and explored whatever you typed.
Now it's a proper picker: type -> debounced search of the metadata source
(reuses /api/discover/build-playlist/search-artists — Hydrabase if active,
Spotify if configured, else the active metadata source) -> shows real artist
results with images -> click one to explore that resolved artist. Enter picks
the top match (never explores raw text); Escape/Cancel/backdrop close.
Pure frontend: rebuilds _showArtistMapSearchPrompt() (same Promise<name|null>
contract, so the caller is unchanged), reusing the playlist-builder's search
endpoint + picker styling. No backend change.
Investigating 'each mode is different / not good enough' showed the engine is
already shared across all three modes (watchlist/genre/explore) and already does
LOD rendering, eased camera, and debounced zoom-rebuilds — so the inconsistency
was perception driven mostly by the (now-fixed) lag, not separate engines.
This phase surfaces more real data the map already has: the hover tooltip now
shows each artist's live connection count (computed from the map edges), shown
consistently across all three modes. Cheap (only recomputed when the hovered
artist changes, after Phase 1's de-churn). Additive + safe.
JS syntax clean.
Kills the hover/move lag on dense maps. Root causes were in the live
mouse/render path, not the layout:
- Render coalescing: _artMapRender() now just requests a single rAF; the actual
draw (_artMapDraw) runs at most once per frame. A burst of mousemove/pan/
animation calls no longer triggers many full-buffer blits per second.
- Tooltip de-churn: only rebuild the tooltip innerHTML (and reload its image)
when the hovered artist changes; a plain mousemove just repositions. Was
rebuilding innerHTML + a new <img> every pixel of movement.
- Spatial-grid hit-test: bucket nodes into a coarse world grid and test only the
cell under the cursor, instead of sorting + scanning every node each move.
Grid rebuilds only when the node set changes.
- Constellation lines: draw all connection lines as ONE solid-stroke path
instead of creating a fresh linear-gradient object per line every frame —
that per-frame gradient churn was the main 'connected lines' lag.
No layout/data/click changes; behaviour identical, just frame-bound. Pure
frontend; JS syntax clean.
Continue the design-system unification (kettui UI-consistency item):
migrate the five remaining compact button families onto the shared
.btn .btn--sm primitive + color modifiers, and drop their bespoke base
CSS (net -125 lines of CSS).
- ya-header-btn (Your Albums/Artists, discover.js-injected) -> .btn .btn--sm
.btn--secondary; ya-refresh/ya-settings/ya-viewall co-modifiers kept.
- explorer-action-btn (Playlist Explorer) -> .btn--secondary / .btn--primary.
- repair-bulk-btn -> .btn--secondary / .btn--primary / .btn--warning (fix-all).
- enhanced-bulk-btn (Library bulk bar, library.js-injected) -> .btn--primary/
--secondary/--danger; class kept as a hook for the mobile.css size
override + the .tag-write / .rg-analyze special colors.
- profile-create-btn (init.js-injected) -> .btn .btn--block .btn--primary;
class kept for the scoped .profile-edit-buttons flex:1 rule.
mini-nav-btn deliberately left as a distinct icon-button archetype.
Update Import Music album and queue artwork fallbacks to use the shipped /static/placeholder-album.png asset instead of the nonexistent /static/placeholder.png path.
Replace the remaining static UI fallback to the missing placeholder path and add a regression test that fails if static JS references it again.
- replace click-driven artist-detail hops with semantic links
- keep SPA transitions via shell bridge interception for /artist-detail/:source/:id
- drop legacy page helper wrappers and dead bridge plumbing
- add a canonical TanStack route for artist-detail and keep the legacy page as the renderer target
- expose page-level artist-detail navigation on the shell bridge for legacy callers
- remove artist-detail-specific routing, origin stack, and back-label logic from the shared shell helpers
Add MusicBrainz watchlist artist ID storage, badges, linked-provider editing, and per-artist preferred source support.
Backfill watchlist MusicBrainz matches from already-enriched library artists so existing MusicBrainz worker matches appear in watchlist cards and settings.
Extend bulk watchlist add, liked artist matching, artist map source picking, and service status labels to recognize MusicBrainz, with regression tests for watchlist ID persistence and backfill.
Preserve source metadata for seasonal and cached discover album modals so artist links use real provider IDs instead of falling back to library/name routes.
Treat source-only artist detail discographies as clickable missing releases and skip library-only ownership/enhancement checks.
Two-part fix to the Your Albums "Download Missing" flow on Discover.
Part A — UX redesign
The prior `downloadMissingYourAlbums()` ran a per-album loop that
fired direct-download tasks via `openDownloadMissingModalForYouTube`.
Reported as silently failing — "Queuing 2/2" toast with no actual
transfer activity. Even when downloads worked, bypassing the
wishlist meant no retry / dedup / rate-limit / source-fallback
handling.
Replaced with a selectable-grid modal mirroring the Download
Discography pattern from the library page. Click the download
button → opens a checkbox grid showing every missing album (cover,
title, artist, year, track count, source) → user picks what they
actually want → click "Add to Wishlist" → each album's tracks get
resolved + queued through the existing wishlist auto-download
processor. NDJSON progress stream renders ✓/✗ per album.
New JS helpers:
- `_openYourAlbumsBatchModal(missingAlbums)` — builds the modal
- `_renderYourAlbumsBatchCard(row, index)` — per-album card
- `_yourAlbumsBatchSelectAll(select)` — bulk toggle
- `_updateYourAlbumsBatchFooterCount()` — live count + button text
- `_closeYourAlbumsBatchModal()` — overlay teardown
- `_startYourAlbumsBatchAddToWishlist()` — submit handler, NDJSON
progress consumer
- `_yourAlbumsPickSource(album)` — picks the single best source-id
per row (priority: spotify → deezer → tidal → discogs)
Reuses the `.discog-*` CSS classes from the library Download
Discography modal — no new CSS. Reuses the existing
`/api/artist/<id>/download-discography` endpoint. The endpoint's URL
artist_id param is functionally unused (per-album payload carries
everything — verified by reading the endpoint body), so the modal
posts with placeholder `your-albums` and gets multi-artist
resolution for free without backend changes.
Part B — Tidal album resolution
Reported as the original bug: clicking download on Tidal-only albums
did nothing because `/api/discover/album/<source>/<album_id>` had no
`tidal` branch and `tidal_client` had no `get_album_tracks` method.
`core/tidal_client.py`: new `get_album_tracks(album_id, limit=None)`
method. Two-phase: cursor-walk
`/v2/albums/<id>/relationships/items?include=items` for track refs +
position metadata (`meta.trackNumber` + `meta.volumeNumber`),
batch-hydrate via existing `_get_tracks_batch` for artist/album
names. Returns `Track` objects with `track_number` and `disc_number`
attached. Sort by (disc, track) so multi-disc compilations render in
album order.
`web_server.py`: new `'tidal'` source branch in
`/api/discover/album/<source>/<album_id>`. Resolves album metadata
via `get_album`, tracks via `get_album_tracks`, cover art via inline
`?include=coverArt` lookup. Same response shape as Spotify/Deezer
branches.
`webui/static/discover.js`:
- `tidal_album_id` added to `trySources` for the single-album click
flow (`openYourAlbumDownload`)
- Same source picker drives the new batch modal
- Virtual-id generation includes `tidal_album_id` so Tidal-only
albums get stable identifiers across discover-album-* / your-
albums-* contexts
10 new tests in `tests/test_tidal_album_tracks.py` pin:
- Single-page walk + hydration
- Multi-page cursor chain
- Multi-disc sort order (disc 1 → 2 in track order each)
- `limit` short-circuit at page boundary
- No-token short-circuit (no API call)
- HTTP error returns empty
- 429 raises (propagates to `rate_limited` decorator for retry)
- Forward-compat type filter (skips non-track entries)
- Partial-batch hydration failure containment
- Empty-album short-circuit (no batch call)
Full pytest: 2693 passed.
Owner decision: not worth shipping. The four library-driven personalized
sections were stubbed returning [] for ages because their schema
prereqs didn't exist; the prior commit re-enabled them by routing
through a new `_select_library_tracks` helper. Owner reviewed and chose
to delete the sections entirely instead.
Removed everywhere:
- `core/personalized_playlists.py` — `get_recently_added`,
`get_top_tracks`, `get_forgotten_favorites`, `get_familiar_favorites`
+ the `_select_library_tracks` helper (no other callers; verified
via grep).
- `web_server.py` — 4 route handlers
(`/api/discover/personalized/recently-added`, `top-tracks`,
`forgotten-favorites`, `familiar-favorites`).
- `webui/index.html` — 4 `<div class="discover-section">` blocks
(`#personalized-recently-added`, `#personalized-top-tracks`,
`#personalized-forgotten-favorites`,
`#personalized-familiar-favorites`).
- `webui/static/discover.js` — 4 load functions
(`loadPersonalizedRecentlyAdded`, `loadPersonalizedTopTracks`,
`loadPersonalizedForgottenFavorites`, `loadFamiliarFavorites`),
plus their entries in `loadDiscoverPage`'s Promise.all, plus
4 module-level state vars + 6 dead branches across
`openDownloadModalForDiscoverPlaylist` / `startDiscoverPlaylistSync`
and the sync-progress / rehydrate dispatchers.
- `webui/static/helper.js` — 4 tooltip / docs entries.
- `webui/static/sync-spotify.js` — 1 stale rehydrate dispatcher
branch (`discover_familiar_favorites`) caught during the global
grep pass.
- `tests/test_personalized_playlists_id_gate.py` — 3 library-method
tests + the test infrastructure that supported them
(`tracks` schema, `insert_library_track` helper). Documentation
header updated to reflect the deletion.
Net: -527 / +2 lines across 7 files.
What stays:
- Daily Mixes (also in personalized package, intentionally paused —
separate decision).
- Popular Picks + Hidden Gems + Discovery Shuffle (alive, not
affected by this deletion).
- All 14 tests in the personalized-playlists test file still pass.
- The PersonalizedPlaylistsService lift from the prior commit
(`_select_discovery_tracks` etc) — those are still in active use
by the surviving discovery_pool methods.
DISCOVER_TRACK_SELECTION_REVIEW.md at repo root contains historical
references to the four deleted endpoints. Treated as historical
context (same policy as WHATS_NEW), left alone.
2219/2219 full suite green (was 2222 - 3 deleted tests = 2219).
JS parses clean, ruff clean.
Both tabbed-browser sections — Time Machine ("Decade") and Browse by
Genre — re-implemented the same lifecycle by hand: fetch tabs list,
render the tab strip, attach click handlers, fetch content per tab,
render track list with sync + download action buttons + sync-status
block, handle empty/error/loading states. ~314 lines of identical
boilerplate split across two browsers.
Lifted into one shared `createTabbedBrowserSection(config)` helper.
Each browser is now a thin wrapper:
```js
const ctrl = createTabbedBrowserSection({
id: 'decade-browser',
tabsContainerEl: '#decade-tabs',
contentContainerEl: '#decade-content',
fetchTabs: async () => { ... },
renderTabButton: (tab, isActive) => `<button>...</button>`,
fetchTabContent: async (tab) => { ... },
renderTabContent: (tracks, tab) => `...`,
onTabContentRendered: (tab, contentEl) => { ... },
emptyMessage / errorMessage,
});
```
Migrated:
- `loadDecadeBrowserTabs` 85 → 3 lines
- `loadDecadeTracks` 67 → 3 lines
- `loadGenreBrowserTabs` 92 → 3 lines
- `loadGenreTracks` 70 → 3 lines
Helper: ~125 lines + ~100 lines of per-browser config blocks +
~25 lines of shared `_renderTabbedTrackList` (the two browsers had
byte-identical track-row markup so it lifted cleanly).
Public function names preserved — the four migrated functions stay
on the same signature so existing callers (`loadDiscoverPage`,
refresh buttons, inline handlers) don't change.
Side effects preserved — `decadeTracksCache[year]`, `activeDecade`,
`genreTracksCache[name]`, `activeGenre`, `availableGenres` still
mutated at the same lifecycle moments. The decade-specific
`startDecadeSync(decade)` and genre-specific `startGenreSync(name)`
sync-button handlers stay where they are; they're click handlers
attached to rendered content, not part of the tab lifecycle.
What didn't fit (intentionally left alone):
- `_renderCompactTrackRow` (the existing shared track-row helper) is
NOT used by the tabbed browsers — they had their own template
with a `track_data_json` fallback chain `_renderCompactTrackRow`
doesn't do. Unifying these two would change behavior for
non-tabbed sections, so the tabbed-browser variant lives as
`_renderTabbedTrackList`. Future cleanup could merge them by
giving `_renderCompactTrackRow` an opt-in fallback flag.
- `switchDecadeTab` / `switchGenreTab` still know about cache shape
so they can skip refetch on already-loaded tabs. Keeping that
in the per-browser switch is fine — it's a click handler, not
lifecycle.
Net: 8546 → 8578 LOC on `discover.js` (+32). Helper boilerplate
offsets the line count, but the win is single-source-of-truth, not
raw line reduction.
`node --check` clean. 2222/2222 full suite green.
Three changes tightening the controller before opening the PR.
DROP MAGIC `extractItems` DEFAULTS
Controller used to auto-pull `data.items` / `data.albums` /
`data.artists` / `data.tracks` / `data.results` when no extractor
was supplied. Removed the fallback chain — every section now MUST
provide an explicit `extractItems(data) => array`. Validated at
register-time so misuse fails immediately, not silently on first
load against an endpoint that happened to return two arrays.
Cin standard: explicit > implicit. Magic key-grabbing could pick
the wrong one in edge cases (e.g. an endpoint returning both
`data.albums` and `data.results` would have grabbed albums when
the section actually wanted results).
All 10 existing controller call sites already passed explicit
extractors, so no migration churn — this is purely tightening the
contract for future sections.
REPLACE `renderItems` NULL-RETURN CONVENTION WITH `manualDom: true`
Your Albums and similar sections that delegate to existing renderers
that target a CHILD element of `contentEl` used to signal "leave the
container alone" by returning null/undefined from `renderItems`. That
convention is easy to confuse with an accidental missing-return error.
Replaced with an explicit `manualDom: true` config flag. Renderer is
still called for its side-effects, controller just skips the innerHTML
swap. Clearer intent at the call site. Updated `loadYourAlbums` to
use the new flag.
PIN THE CONTROLLER CONTRACT WITH JS TESTS
Added `tests/static/test_discover_section_controller.mjs` — 32 tests
covering the controller's lifecycle contract:
- Config validation (every required field, mutual exclusivity of
fetchUrl/data, type checks on contentEl)
- Happy-path fetch → parse → render
- Empty state (default empty render, hideWhenEmpty + sectionEl,
success=false treated as empty, custom isSuccess override)
- Stale state (fires when isStale returns true, wins over empty,
custom renderStale override)
- Error state (HTTP non-ok, fetch throws, showErrorToast fires
window.showToast, default off doesn't fire)
- No-fetch `data:` mode (value + function form, doesn't call fetch)
- manualDom mode (skips innerHTML swap, still calls renderer)
- Callable `fetchUrl` (resolved at load time, refresh re-resolves)
- Load coalescing (concurrent loads share one fetch)
- Refresh bypasses coalescing (re-fires fetch every call)
- Hook error containment (throwing renderer/onSuccess hooks don't
crash the controller)
Runs via Node's stable built-in `--test` runner — no package.json,
no jest/vitest dependency, no compile step. Just `node --test`.
Pytest wrapper at `tests/test_discover_section_controller_js.py`
shells out to node and asserts clean exit, so the JS tests fail
the regular pytest sweep if the controller contract drifts.
Skipped gracefully when node isn't available or is < 22.
Closes the "controller is a contract, pin it at the test boundary"
gap that Cin would have flagged on review.
VERIFICATION
- 2205/2205 full pytest suite green (was 2204 + 1 new wrapper)
- 32/32 `node --test` pass on the controller test file directly
- ruff clean
- node --check clean on all touched JS files
Follow-up to the controller migration commits. Closes out the
extension list the per-section migrations surfaced as needed.
CONTROLLER EXTENSIONS
- Callable `fetchUrl: () => string` — resolves the seasonal-playlist
recreate-on-key-change hack from the prior commit.
- No-fetch `data:` mode — value or `() => value`. Lets render-only
sections like Seasonal Albums use the controller without inventing
a fake endpoint. Mutually exclusive with `fetchUrl`; validated up
front so misuse fails at register-time.
- `beforeLoad(ctx)` hook — runs before the spinner shows. Lets
dynamically-inserted sections like Because You Listen To ensure
their `contentEl` exists before the visibility check.
- `onSuccess(data, ctx)` hook — runs after the success gate but
before isEmpty / isStale. Cleaner home for sibling header /
subtitle / button updates than folding them into renderItems.
- `isStale(items, data)` + `onStale(ctx)` + `renderStale(items, data)`
+ `staleMessage` — third render state for "data is empty BUT
upstream is still discovering". Stale wins over empty when both
apply. Default stale UI is the same spinner block used elsewhere.
- `showErrorToast: true` config — opens a global `showToast(...)` in
addition to the in-section error block. Default off; sections that
have no recovery action shouldn't shout at the user.
- `renderItems` returning null/undefined now leaves contentEl
untouched. Lets a renderer do its own DOM manipulation (e.g.
delegating to an existing grid-render fn that targets a child
element) without fighting the controller's innerHTML swap.
MIGRATED THE 2 SKIPPED SECTIONS
- `loadYourAlbums` — uses `isStale`/`onStale`/`renderStale` for the
stale-fetch state, `onSuccess` for the subtitle/filters/download
side-effects, `hideWhenEmpty` + `sectionEl` for the truly-empty
case, `renderItems` returning null since it delegates to the
existing `_renderYourAlbumsGrid` + `_renderYourAlbumsPagination`.
- `loadSeasonalAlbums` — uses no-fetch `data:` mode because the
parent `loadSeasonalContent` already fetched the season payload.
`beforeLoad` updates the sibling title/subtitle text.
ERROR TOASTS ON ALL MIGRATED SECTIONS
Every migrated section now has `showErrorToast: true`. Section load
failures surface a global toast instead of silently spinning forever
or swallowing into console.debug. Same pattern JohnBaumb #369 asked
for at the Python layer, applied at the UI layer.
SHARED SYNC-STATUS BLOCK
Lifted the duplicated decade-tab + genre-tab sync-status HTML
(✓ completed | ⏳ pending | ✗ failed | percentage) into a single
`_renderSyncStatusBlock(idPrefix)` helper. Two call sites now share
one implementation. ListenBrainz playlists keep their own block
because the semantics differ — matching progress (total / matched /
failed) vs download progress.
DEAD-SECTION AUDIT — NONE DEAD
Audited the 13 supposedly-dead hidden sections from
DISCOVER_REVIEW.md. All 13 are alive: gated on user data (discovery
pool, library content, metadata cache) and self-surface when their
data exists via `style.display = 'block'` on the success path. The
review's grep missed the toggle. No deletions made.
DAILY MIXES ORPHAN CALL
Removed the orphaned `loadPersonalizedDailyMixes()` call from
`blockDiscoveryArtist` — Daily Mixes is intentionally paused (its
load call in `loadDiscoverPage` is commented out) so refreshing it
from the post-block hook was a no-op.
2204/2204 full suite green. JS parses clean (`node --check`).
Follow-up to the foundation commit. Drops the hand-rolled
try/catch + spinner injection + empty-state HTML + error-swallow
in seven sections by routing them through
`createDiscoverSectionController`. Each section keeps its existing
public function name + signature so callers, refresh buttons, and
dashboard wiring don't notice the swap.
Migrated:
- `loadDiscoverReleaseRadar` (Fresh Tape)
- `loadDiscoverWeekly` (The Archives)
- `loadDecadeBrowser` (Time Machine intro carousel)
- `loadGenreBrowser` (Browse by Genre intro carousel)
- `loadSeasonalPlaylist` (Seasonal Mix)
- `loadYourArtists`
- `loadBecauseYouListenTo`
Skipped (don't fit the controller's single-fetch / single-render-target
shape):
- `loadYourAlbums` — paginated grid + filters, updates four separate
UI elements (subtitle, filter chips, download button, grid).
- `loadSeasonalAlbums` — receives pre-fetched data from
`loadSeasonalContent`; no fetch URL to satisfy.
Hidden / dead sections (~13 of them — `loadPersonalized*`,
`loadDiscoveryShuffle`, `loadFamiliarFavorites`, `loadCache*`)
untouched in this pass. Separate audit commit will surface or kill
them.
Two side-effects worth noting:
- `loadDecadeBrowser` and `loadGenreBrowser` migrated for
completeness, but neither appears wired into `loadDiscoverPage` or
any inline handler. May be dead code — flagged for the audit pass.
- `loadSeasonalPlaylist` needs a per-load fetch URL (varies by
`currentSeasonKey`); worked around by recreating the controller
when the key changes. Cleaner option: extend the controller to
accept a `fetchUrl: () => string` callable form. Tracked in the
follow-up extension list below.
Controller extension candidates surfaced for follow-up:
- Callable `fetchUrl` (resolves the seasonal playlist
recreate-on-key-change hack)
- Explicit `isStale` / `onStale` hook (so Your Artists doesn't
fold stale handling into renderItems)
- `beforeLoad` / `ensureContentEl` hook (so Because You Listen To
can let the controller own the dynamic container creation)
- No-fetch `data:` mode (so render-only sections like Seasonal
Albums can use the controller too)
- `onSuccess(data)` hook (cleaner home for header / subtitle
side-effects vs folding them into renderItems)
Net: -76 lines in `discover.js` even after adding the per-section
render helpers. 2204/2204 full suite green. JS parses clean.
Every section on the discover page (Recent Releases, Your Artists,
Your Albums, Seasonal Albums, Seasonal Mix, Fresh Tape, The Archives,
Build Playlist, Time Machine, Browse by Genre, ListenBrainz Playlists,
Because You Listen To, plus ~13 hidden sections) currently
re-implements the same lifecycle by hand:
1. show a loading spinner in the carousel container
2. fetch the section's endpoint
3. parse the response, decide if the data is empty
4. either render the items, show an empty-state, or show an error
5. wire post-render handlers (download buttons, hover behavior, etc)
6. maybe expose refresh()
~30 sections worth of duplicated boilerplate, all subtly drifting.
Different empty-state messages. Different error handling (some
`console.debug`, some silently swallowed, some leave the spinner
spinning forever). Different sync-status icons (✓/⏳/✗ vs ♪/✓/✗).
No consistent error toast.
Lifted the lifecycle into a shared `createDiscoverSectionController`
in `webui/static/discover-section-controller.js`. Renderers stay
per-section because section data shapes legitimately differ — album
cards vs artist circles vs playlist tiles vs track rows. The
controller is the wrapper, not a forced visual abstraction.
Foundation contract:
createDiscoverSectionController({
id: 'recent-releases', // for diagnostic logging
contentEl: '#carousel', // selector or Element
fetchUrl: '/api/discover/...',
extractItems: (data) => [...], // pull list from response
renderItems: (items, data, ctx) => '<html>',
onRendered: (ctx) => { ... }, // optional post-render hook
loadingMessage / emptyMessage / errorMessage: copy
sectionEl + hideWhenEmpty: optional whole-section visibility
isSuccess / isEmpty: optional gate overrides
})
Returns `{ load, refresh, destroy, getState }`. Validates config up
front so misuse fails at register-time, not silently on load. Coalesces
concurrent loads (same in-flight promise returned) so a double-click
or repeated trigger doesn't double-fetch. `refresh()` bypasses the
coalesce so the refresh button always re-fires. Errors are logged
(console.debug by default, console.error when verboseErrors=true).
Renderer hook errors are caught + logged so a buggy render callback
can't tear down the controller — keeps the page resilient.
Migrated `Recent Releases` as the proof — simplest album-card shape,
no source-gating, no refresh button. Verified the contract covers it
end-to-end. The legacy `loadDiscoverRecentReleases` entry-point stays
public so existing callers don't change; internally it lazy-builds
the controller and triggers `load()`.
NOT in this commit:
- Other section migrations (one section per follow-up commit, keeps
reviews small + lets us sequence the work)
- Registry-driven section list (so the dead-section audit becomes
registry deletions instead of section-by-section removal)
- Global error toast wrapper
- Per-section "requires X primary source" gate
- Sync-status icon renderer unification
Once every section is on the controller, the discover-page cleanup
work (kill the 13 dead sections, standardize sync-status icons, add
error toasts) becomes single-line registry-level edits instead of
30 separate section-by-section rewrites.
2204/2204 full suite green. JS parses clean (`node --check`). Manual
smoke deferred until follow-up commits — Recent Releases unchanged
on the wire (same endpoint, same payload shape, same render output).
The Your Albums sources modal silently bailed on toggle clicks for
disconnected sources — toggle did nothing, no feedback, users had no
way to know why. Surfaced when users tried to enable Discogs without
having set a Discogs token first; same UX gap existed for the other
sources too but went unreported because most users have Spotify
connected by default.
Added per-source hint messages so the toast tells users exactly
where to set up credentials. Bonus: subtitle update after save now
includes 'discogs' in the source-name map (was undefined before,
fell through to lowercase 'discogs' in the rendered text).
Affects only the Your Albums sources modal — toggle behavior
unchanged for connected sources.
Discord request: pull user's Discogs collection into the Your Albums
section on Discover, similar to how Spotify Liked Albums works.
Implementation extends the existing 3-source pipeline (Spotify /
Tidal / Deezer) to a 4-source pipeline with click-context dispatch —
Discogs-only albums open with rich Discogs release detail (vinyl/CD
format, year, label, country, tracklist). Mirrors the per-source
dispatch pattern from enhanced/global search.
Discogs client (`core/discogs_client.py`):
- New `get_authenticated_username()` resolves the username for the
configured personal token via Discogs's `/oauth/identity` endpoint.
Cached on the instance so subsequent collection page-fetches don't
re-hit it.
- New `get_user_collection(username=None, folder_id=0, per_page=100,
max_pages=50)` walks all pages of `/users/{username}/collection/
folders/{folder_id}/releases`. Returns normalized dicts ready for
upsert_liked_album. folder_id=0 = Discogs's "All" folder.
Pagination cap of max_pages*per_page = 5000 releases — bounds
runtime on heavy collections.
- New `get_release(release_id)` thin wrapper for `/releases/{id}` —
returns the raw API response so the album-detail endpoint can
render rich context.
- Both methods defensive: missing token → empty list, malformed
responses → skipped, falsy ids → None. Disambiguation suffix
stripping (`Madonna (3)` → `Madonna`) so Discogs artist names
match what Spotify/Tidal/Deezer use.
Schema (`database/music_database.py`):
- New `discogs_release_id TEXT` column on `liked_albums_pool`.
Migration uses the established `try SELECT, except ALTER TABLE`
pattern. Idempotent; safe on existing installs.
- Added the column to the canonical CREATE TABLE for fresh installs.
- `upsert_liked_album` extended with `'discogs': 'discogs_release_id'`
in BOTH the INSERT and UPDATE id-column maps so Discogs source_id
routes to the new column. INSERT statement column count + value
count updated together.
Backend (`web_server.py`):
- `/api/discover/your-albums/sources` — adds Discogs to the
`connected` list when `discogs.token` config is set.
- `_fetch_liked_albums` — new branch for Discogs. Lazy-imports
DiscogsClient, respects the `enabled_sources` config, walks the
collection, upserts each release. Same try/except shape as the
existing source branches.
- `/api/discover/album/<source>/<album_id>` — new `discogs` branch
fetches the release via DiscogsClient.get_release, normalizes the
Discogs tracklist format, parses Discogs's `MM:SS`/`HH:MM:SS`
duration strings to milliseconds, returns the same response shape
as the Spotify/Deezer/iTunes branches.
Frontend (`webui/static/discover.js`):
- `openYourAlbumsSourcesModal` — adds Discogs to `sourceInfo` with
the vinyl emoji icon. Existing toggle/save plumbing handles it.
- `openYourAlbumDownload` — restructured the per-source dispatch:
builds an ordered list of (source, id) tuples, tries each in turn,
breaks on the first successful response. Pure-Discogs albums go
straight to the Discogs detail endpoint → modal opens with Discogs
context. Multi-source albums prefer Spotify/Deezer first since
their tracklists carry proper streaming IDs ready for download.
Tests: `tests/test_discogs_collection_source.py` — 12 cases:
- get_user_collection: empty without token, normalizes response
shape, strips disambiguation suffix, handles missing year, skips
malformed releases, paginates correctly, caps at max_pages,
uses explicit username when provided.
- get_release: passes id through to /releases/{id}, returns None
for invalid ids without API call.
- liked_albums_pool: discogs_release_id round-trips through upsert
+ get; multi-source dedup carries both Spotify and Discogs IDs
on the same row.
Verified: full suite 1825 pass (12 new), ruff clean, smoke test
populating + reading the discogs_release_id column round-trips
correctly via the real DB.
WHATS_NEW entry under '2.4.2' dev cycle.
Discover page used to show two near-identical sections:
- "Your Albums" — cross-source aggregator across Spotify / Deezer /
etc with a gear button to configure sources, search, status filter,
sort options, and a download-missing action.
- "Your Spotify Library" — Spotify-only with the same grid UI, same
refresh / download-missing buttons, same filter / sort controls.
The Spotify-only section was a strict subset of what Your Albums
already covers (Spotify is one of the configurable sources). User
flagged the redundancy when scoping the upcoming Discogs integration
and asked for the duplicate to be removed.
Removal scope:
- `webui/index.html` — drop the `#spotify-library-section` block (42
lines).
- `webui/static/discover.js` — drop the dead JS (~335 lines): state
vars `spotifyLibraryAlbums` / `spotifyLibraryPage` / etc, all the
loaders / renderers / pagination / click handlers, and the
`loadSpotifyLibrarySection()` call in `loadDiscoverPage`'s
Promise.all.
- `webui/static/helper.js` — drop the helper annotation entry at
`#spotify-library-section` and the matching guided-tour entry.
Backend untouched. The Spotify saved-albums cache
(`spotify_library_albums` table + watchlist_scanner upsert/cleanup
+ `/api/discover/spotify-library` endpoint + the DAO methods) is
shared infrastructure that Your Albums reads from when Spotify is
one of its configured sources. Removing the UI section just removes
the duplicate surface — Spotify saved albums still appear in Your
Albums via the existing source dispatch.
CSS class names (`.spotify-library-grid`, `.spotify-library-search`,
`.spotify-library-pagination`) intentionally remain on the surviving
Your Albums elements — they share the same visual styling and
renaming would be churn for no benefit.
Verified: full suite 1813 pass (no new tests — pure UI/dead-code
removal). Backend endpoint behavior unchanged. WHATS_NEW entry
under '2.4.2' dev cycle.
Clicking 'View Discography' on the Discover hero slideshow was calling
navigateToArtistDetail(id, name) without the third 'source' argument.
loadArtistDetailData then omits the `source` query param, so
/api/artist-detail falls through to a local DB lookup and returns 404
for artists that don't exist in the library — which is nearly every
hero artist, since they come from discover similar-artists.
Regression from the unification PR (93f1941) that rewrote the click
handler to route through the standalone /artist-detail page instead
of the old inline Artists view. The rewrite didn't thread the source.
Backend already includes `artist.source` on each hero entry. Fix:
- Stash artist.source as data-source on #discover-hero-discography
when displayDiscoverHeroArtist populates the card.
- Read data-source in viewDiscoverHeroDiscography and pass it as the
third arg to navigateToArtistDetail, so the eventual API call
includes `?source=itunes/deezer/etc.` and returns the synthesized
discography.
Reproduced by clicking View Discography on a non-Spotify hero artist
(log showed `GET /api/artist-detail/76258852?name=ДЕТИ+RAVE → 404,
Getting artist detail for ID: 76258852 (source=library)`).
Completes the artist-detail unification. Source artists now land on
the same /artist-detail page as library artists (with the source-aware
backend endpoint from earlier this session handling the data fetch).
The inline Artists page is gone — artists.js deleted, #artists-page
HTML block removed, /artists URL aliases to /search.
Source-artist callsites re-migrated from selectArtistForDetail to
navigateToArtistDetail (search results, global widget, download
modal, Discover hero / Your Artists cards / artmap context / genre
deep-dive, watchlist artist detail).
Visual upgrade to standalone hero: added .artist-detail-hero-bg +
.artist-detail-hero-overlay (blurred image bg, dark gradient — same
treatment as the inline page). library.js sets the bg image when
loading an artist.
Library-only UI hidden via CSS for source artists (existing rules
from the previous commit cover Enhanced toggle, Status filter,
completion bars, enrichment coverage, Top Tracks sidebar, Radio /
Enhance buttons).
Final 2 helpers (lazyLoadArtistImages used by wishlist-tools,
showCompletionError used by completion checker) moved from
artists.js into shared-helpers.js. The inline-page candidate set
was dropped from _resolveSimilarArtistsTargets.
init.js: 'artists' alias added at top of navigateToPage (same
pattern as the existing 'downloads' alias). 'case artists:' handler
removed from loadPageData. _getPageFromPath now maps artist-detail
to library as its parent (matches the existing nav highlight at
init.js:2161).
tests/test_script_split_integrity.py: artists.js removed from
SPLIT_MODULES; KNOWN_CROSS_FILE_DUPES updated to point escapeHtml
at shared-helpers.js instead of artists.js. 354/354 tests pass.
Net delta: -1700 lines.
Stays at 2.39. Once you've verified end-to-end (library artist ->
hero looks like inline visual; source artist from Search -> same
page, similar artists works, no 404s; /artists URL -> /search), a
follow-up commit bumps to 2.40 with the full WHATS_NEW entry that's
already prepped.
Part B of the deferred unification cleanup. Now that Part A teaches
/api/artist-detail/<id> to fall back to a metadata-source lookup when
the library DB lookup misses, source-artist clicks can finally land
on the standalone page without 404ing — the goal Phase 4a aimed for
and had to roll back in commit 19e9174.
Re-migrating the seven callsites reverted earlier in this session:
- search.js enhanced-search source-artist onClick
- downloads.js _gsClickArtist (global widget non-library branch)
- downloads.js _navigateToArtistFromModal fallback
- discover.js viewRecommendedArtistDiscography
- discover.js viewDiscoverHeroDiscography
- discover.js 'Your Artists' card navAction inline onclick
- discover.js 'Your Artists' info-modal 'View All' button
- discover.js artist-map context menu
- discover.js genre-deep-dive artist click
- api-monitor.js watchlist discography view
Each replaces the navigateToPage('artists')+setTimeout+selectArtist-
ForDetail dance with a single navigateToArtistDetail(id, name,
source) call. The third arg seeds artistDetailPageState.currentArtist-
Source, which library.js now reads and forwards as ?source= to the
backend (added in Part A).
Effect: clicking an artist in any of these surfaces now lands on the
standalone /artist-detail page with a stable URL, source context
preserved, and owned-library data merged in when available. Library
artist clicks (unchanged) and media-player / stats links (unchanged)
all continue to use navigateToArtistDetail too, so they now
consistently share one destination.
The inline Artists page (#artists-page + selectArtistForDetail in
artists.js) still exists but has no external callers left — only the
page's own internal search-result click handler references the
function now. Parts D + E will delete the dead inline page and
finally remove artists.js.
Phase 4a (9361c29) mistakenly routed every artist click to
navigateToArtistDetail, which fetches /api/artist-detail/<id>. That
endpoint only knows how to look up local DB primary keys. For source
artists (Spotify/Deezer/iTunes/etc.) the id is a metadata-source id,
not a library PK — so clicks 404'd out.
Library artists (db_artists section in search results, library page
clicks, stats links, media player) continue to go to the standalone
/artist-detail page as before. Source artists now route back to the
Artists page's inline view via selectArtistForDetail, which calls
/api/artist/<id>/discography with a source param — the endpoint that
actually handles non-library IDs.
Reverted 7 migration points:
- search.js: Enhanced Search source-artists onClick
- downloads.js: global widget _gsClickArtist non-library branch
- downloads.js: _navigateToArtistFromModal fallback
- discover.js: viewRecommendedArtistDiscography
- discover.js: viewDiscoverHeroDiscography
- discover.js: 'Your Artists' card name-click inline HTML
- discover.js: 'Your Artists' info-modal 'View All' button
- discover.js: artist-map context menu
- discover.js: genre-deep-dive artist click
- api-monitor.js: watchlist artist discography view
Phase 4a's goal of "one artist page for everything" is deferred —
it needs backend work on /api/artist-detail to accept a source param
and fall back to metadata-source lookup when the local DB lookup
fails. Keeping the signature extension on navigateToArtistDetail
(source parameter) in place for when that lands.
Phase 4a of the Search/Artists unification. The app had two artist-
detail implementations: the standalone page Library navigates to via
navigateToArtistDetail (its own route, deep-link support, highlights
Library in the sidebar), and an inline state inside the Artists page
reached via selectArtistForDetail. They rendered similar content but
were separate code paths and kept drifting apart (PR #356 just had
to fix source propagation in both).
Every external caller of selectArtistForDetail (9 sites across
api-monitor.js, discover.js, downloads.js, search.js) now calls
navigateToArtistDetail(id, name, source) directly. Removed ~63 lines
of the navigate-then-setTimeout-then-select dance. Source context
(Spotify/iTunes/Deezer/etc.) carries cleanly through via the new
third argument.
Artists sidebar entry, its inline search, and selectArtistForDetail
all still work — they just have no external callers. Phase 4b will
retire the sidebar entry and artists.js.