Commit graph

53 commits

Author SHA1 Message Date
BoulderBadgeDad
9c91ba29bf Discover: listening-driven recommendations + mix (#913), Fresh Tape fix
#913 was silently producing 0 recs: similar_artists.source_artist_id is a SOURCE id (Spotify/etc.), but the scan keyed id->name by internal artists.id (resolved nothing), and the consensus ranker was fed the name-collapsed get_top_similar_artists (consensus could never fire). Fixed + elevated:

- id->name keyed by source-id columns; raw per-seed edges (real consensus); similarity_rank threaded into the score; recency-weighted seeds (recent plays boost lifetime favs)
- new 'Based On Your Listening' artist row (/api/discover/listening-recommendations) with 'because you listen to X' explanations
- new 'Your Listening Mix' track row: each rec's top tracks via a guarded, name-resolved Spotify/Deezer fetch (falls back to the discovery pool), stored as full render dicts so the row can't shrink on pool rotation
- pure tested core: similarity_from_rank, build_recency_weighted_seeds, to_mix_track, names_match (+ rank-aware grouping)

Fresh Tape (5-10 tracks): future-dated albums sorted to the top of get_discovery_recent_albums and ate the 50-album budget before the is_future_release skip ran. Add exclude_future_years + fetch a generous budget; downstream caps unchanged. Regression tested.

Also drop the per-track block 'X' from the compact playlist rows (wrong spot). Plan/audit in DISCOVER_BEST_IN_CLASS_PLAN.md.
2026-06-25 10:15:20 -07:00
BoulderBadgeDad
0a5cda5189 Discover: promote recommendations to a first-class section + show the 'why'
- New 'Recommended For You' carousel section on the Discover page (between the
  hero and Your Artists), so recommendations aren't buried behind a hero modal
  button. Reuses the recommended-card markup/CSS, the watchlist add handler, and
  primes the modal cache so 'View All' opens instantly in sync.
- Re-frames the now-stale copy: recommendations are library-wide (the similar-
  artists worker feeds the whole library), not watchlist-only.
- Shows the real explanation from the backend's 'because' field —
  'Because you have X & Y' (with a full-list hover tooltip) instead of just a
  count — in both the section cards, the modal cards, and the hero subtitle.
- Cards lazy-enrich their images via the same endpoint the modal uses.
2026-06-03 18:28:19 -07:00
BoulderBadgeDad
c5e12b904f Artist Map v2: mobile-responsive toolbar/header
- Toolbar wraps on phones (<=760px): back + title + stats and the compact tools
  stay on row 1, the search drops to its own full-width row below so nothing gets
  crushed. Brand text hidden, stats truncate with ellipsis.
- Island nav + canvas height now MEASURE the toolbar height instead of assuming
  ~50px, so the taller wrapped header doesn't overlap the nav or clip the canvas.
64 JS integrity tests pass.
2026-06-03 14:14:07 -07:00
BoulderBadgeDad
e00589f40a Artist Map v2: mobile responsive (bottom-sheet panel, full-width map, resize reflow)
- Info panel becomes a bottom SHEET on phones (<=760px): slides up when you tap
  a bubble, doesn't steal map width (islands frame full-width via _artMapReservedW
  = 0 on mobile). Grip/handle to dismiss; a floating menu FAB opens it to the
  dashboard + top-artists. Desktop stays the right sidebar.
- Genre sidebar hidden on mobile (the top-left quick-jump nav handles genre
  switching; no room for a sidebar).
- Touch tap now selects a bubble (card in the sheet) instead of opening the modal,
  matching desktop click; ignores taps that were drags.
- Resize/orientation: debounced reflow that re-styles the panel for the new
  breakpoint, recomputes canvas size (minus sidebar/toolbar), and re-frames the
  focused island / fit. 64 JS integrity tests pass.
2026-06-03 14:00:07 -07:00
BoulderBadgeDad
409595974e Artist Map v2: bring the info panel to life — live watchlist state, debounced select, navbar fix
- Watchlist button now reflects real state: shows 'On watchlist' (filled) vs
  'Watchlist' (outline), confirmed per-artist via /api/watchlist/check, and
  flips instantly when you add/remove (cached in _watchSet so it stays correct
  as you browse). Uses the artist's source id, works on any map.
- Debounced hover-select: the card only swaps to a bubble you've settled on for
  ~0.8s, so sweeping toward the panel no longer keeps changing the card on
  bubbles you pass over. Clicking a bubble selects it instantly (bypasses the
  debounce, pins the card) instead of auto-opening the modal — Details button
  still opens it.
- Fix: panel started at top:0 and covered the navbar; now it starts below the
  .artist-map-toolbar (measured) so the toolbar stays clear. 64 tests pass.
2026-06-03 13:49:56 -07:00
BoulderBadgeDad
b0c0acb379 Artist Map v2: right-side info panel (dashboard + coverage + top artists + artist card)
A polished detail panel on the right of every map (never collides with the genre
sidebar; islands now frame in the space left of it):
  - Header dashboard: view title, Artists / Watchlist / Genres stat tiles, and a
    watchlist-coverage bar for the current genre/view.
  - Top-artists list: the current island's biggest artists, clickable (shows
    their card + ripples them on the map).
  - Rich artist card on hover/click: large art (from the decoded bitmap), genre
    chips, popularity bar, connection count, watchlist/discovered badge, and
    actions — Explore from here, Details, Watchlist toggle, Open artist page.
    Card stays pinned (no auto-revert) so you can reach its buttons; a back
    button returns to the list.
64 JS integrity tests pass.
2026-06-03 13:28:57 -07:00
BoulderBadgeDad
0d91bb78db Artist Map v2: fancier bloom — bubbles surface up into place
Bubbles now rise up into position (water-surfacing) with a soft ease-out-back
settle and alpha fading in a touch faster than scale. Stagger is continuous
radial + a deterministic per-bubble jitter so they fill in organically instead
of popping in visible rings/segments. 64 JS integrity tests pass.
2026-06-03 13:19:25 -07:00
BoulderBadgeDad
37595314f1 Artist Map v2 fix: streamed art now appears on its own (no manual zoom)
Root cause of the 'loads as placeholder orbs, only pops in after a zoom' bug:
streamed images were cached in _artMap.images but written into the buffer via
the per-node composite path, which didn't reliably refresh in one-island /
overflow mode — so covers stayed as placeholders until a zoom forced a full
rebuild that picked up the cached bitmaps.

Now that each map's buffer is small (one focused island, or a small explore
map), a throttled FULL rebuild on image arrival is cheap and always bakes every
cached image. Dropped the composite call from the stream; art fills in by itself
as it loads. 64 JS integrity tests pass.
2026-06-03 13:13:09 -07:00
BoulderBadgeDad
f95b52e3c4 Artist Map v2 — polish: island halo, hover-pop, genre quick-jump, declutter
- Soft genre-hued halo glows behind the focused island (cached per-hue sprite →
  one drawImage, no per-frame gradient) so it reads as a place on the water.
- Hover-pop: hovered bubbles scale up + get a bright hue ring + glow, even on
  static genre islands (drawn on top), so hover always feels tactile/responsive.
- Genre quick-jump: click the genre name in the nav for a dropdown of every
  genre island — jump straight to one instead of only prev/next.
- Decluttered: dropped the redundant in-world island titles in one-island mode
  (the nav bar already names the genre, and they could clip off the top).
64 JS integrity tests pass.
2026-06-03 13:01:58 -07:00
BoulderBadgeDad
e2eac64dce Artist Map v2: de-lag one-island genre view + move island nav to top-left
- Focused islands now render from the high-res buffer (one cheap crisp blit)
  instead of redrawing every bubble each frame for the bob. In one-island mode
  the buffer already covers just that island at high resolution, so this is
  crisp AND cheap — kills the genre lag. Bob/shove stay live only for small
  views (zoomed-in subsets, explore) where per-frame redraw is cheap.
  (Overflow threshold 650→140; the loop parks once the island bakes.)
- Fewer bubbles per island (maxPerIsland 500→300) — less cramped, lighter bloom.
- Island nav bar moved from bottom-center to top-left (clears the genre sidebar
  + toolbar). 64 JS integrity tests pass.
2026-06-03 11:09:02 -07:00
BoulderBadgeDad
80d775a5da Artist Map v2 — one-island-at-a-time view + API-backed toolbar search
Two things from feedback:

1) Toolbar search now queries the metadata source for ANY artist (like the
   discover page) and launches an exploration on click — instead of only
   filtering the current map's nodes (which showed nothing for off-map names).

2) Genre + watchlist maps now frame ONE genre island at a time, with prev/next
   nav (and ← / → keys) through the genres. This sidesteps the persistent
   'renders small/sparse' bug entirely: only the focused island is visible, so
   the buffer covers a small region at HIGH res (crisp covers, no more shrunk
   images) and the live layer handles just ~hundreds of bubbles (bob works, no
   overflow). Each island blooms in (drop-in-water) on focus. Explore stays
   multi-island (it's small). A bottom nav bar shows genre name + i/N.
   Streaming caches off-island images silently (no redraw) so navigating is
   instant. 64 JS integrity tests pass.
2026-06-03 11:00:10 -07:00
BoulderBadgeDad
21b6d17d3c Artist Map v2 — Phase F (fix): robust live/buffer partition + 30fps loop
Fixes the genre-map 'renders small/sparse after the reveal, zoom fixes it' bug.
Root cause: tighter islands (Phase D) raised the fit-zoom so nearly every bubble
crossed the live-size threshold → the buffer excluded them all (thought they
were live) but the live layer is capped, so only ~600 of 1800 drew until a zoom
rebuilt the partition.

Fix: _artMapRebuildBuffer now counts would-be-live bubbles; if more than the
live layer can draw (>450), it sets _liveOverflow and bakes EVERYTHING into the
buffer (full, correct render). The live layer + bob only take over once zoomed
in enough that few bubbles qualify. So the overview is always complete,
regardless of zoom. Trade-off: very large maps (genre 1800) render from the
buffer (no per-bubble bob, slightly softer when deeply zoomed until the
zoom-rebuild sharpens) — correctness over flourish on the crowd.

Also: whole animation loop capped at ~30fps (reveal/ripple/bob all read fine at
30) to cut the churn on dense maps; a pending rebuild (dirty) always draws so the
throttle can't skip the post-reveal bake. 64 JS integrity tests pass.
2026-06-03 10:46:41 -07:00
BoulderBadgeDad
682bdc7fb1 Artist Map v2 — Phase F (perf): de-lag hover, dense zoom, fix tooltip photo
Addresses the perf + tooltip feedback:
  - Hover constellation no longer clips per node every frame (images are already
    pre-masked circles) — that per-node ctx.clip() was the hover-lag culprit once
    the ambient loop forced continuous redraws. Now a plain drawImage + arc tint.
  - Ambient buoyancy loop runs at ~30fps when idle (full 60 only during
    reveal/ripple), halving redraw cost on dense zoomed-in maps while keeping the
    bob smooth.
  - Gloss highlight gated to bubbles >=12px on screen (skips the dense swarm) —
    halves per-frame drawImage cost when zoomed in.
  - Tooltip photo now paints from the already-decoded bitmap into a canvas
    instead of a fresh <img src> reload — fixes the blank photo when sweeping
    across dense zoomed-in bubbles (the <img> was churn-reloading faster than it
    could decode). 64 JS integrity tests pass.
2026-06-03 10:31:54 -07:00
BoulderBadgeDad
cdc1f0bc10 Artist Map v2 — Phase E: click ripple shoves nearby bubbles
Clicking (or tapping) the map now drops a water ripple: a hue-tinted ring
expands from the point AND nearby bubbles get shoved radially outward at the
wavefront, then settle back as it passes and decays (_artMapNodeDisplacement —
a gaussian bump at the expanding front, world-space radial push). Ripples emit
from the clicked bubble's centre in its genre hue (or the bare click point),
and still open the artist after a beat. Replaces the old single purple ring.

Note: the physical shove acts on live-layer (zoomed-in) bubbles; at the far-out
overview the ring shows but the tiny baked bubbles don't move. 64 tests pass.
2026-06-03 10:16:27 -07:00
BoulderBadgeDad
33c4eea201 Artist Map v2 — Phase D: ambient buoyancy + glassy bubbles + tighter islands
- 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.
2026-06-03 10:13:05 -07:00
BoulderBadgeDad
9cce7810ea Artist Map v2 — Phase C: ripple-bloom reveal
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.
2026-06-03 09:52:38 -07:00
BoulderBadgeDad
f311ff0440 Artist Map v2 — Phase B: genre islands everywhere
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.
2026-06-03 09:48:18 -07:00
BoulderBadgeDad
0f63024d4b Artist Map v2 — Phase A.1: pre-masked circular images, consistent art, clean reveal
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.
2026-06-03 09:37:17 -07:00
BoulderBadgeDad
4b04f765a0 Artist Map v2 — Phase A: two-layer live animation engine
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.
2026-06-03 09:22:40 -07:00
BoulderBadgeDad
8d4de4dc49 Artist Map v2 — Phase 5: incremental buffer compositing (no lag while images stream)
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.
2026-06-03 08:54:32 -07:00
BoulderBadgeDad
ff2bddf1db Artist Map v2 — Phase 4: progressive image streaming (paint-first, click-click-click)
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.
2026-06-03 08:46:11 -07:00
BoulderBadgeDad
c97108ff6b Artist Map v2 — Phase 3 polish: crisp adaptive images, snappier glowing hover, premium backdrop
- 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.
2026-06-03 08:25:34 -07:00
BoulderBadgeDad
20b77a774a Artist Map v2 — the real lock: downscale artist images on decode (6GB → ~100MB)
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'.
2026-06-03 08:18:41 -07:00
BoulderBadgeDad
6fa733dc6e Artist Map v2 — fix the real bottleneck: cap the offscreen buffer (76MP → ~12MP)
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.
2026-06-03 08:03:30 -07:00
BoulderBadgeDad
884c3b5c07 Artist Map perf overlay: also POST timings to app.log (readable server-side)
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.
2026-06-03 07:54:32 -07:00
BoulderBadgeDad
77d6d49069 Artist Map v2 — fix regression + add perf overlay
- 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.
2026-06-03 07:34:41 -07:00
BoulderBadgeDad
b85392977c Artist Explorer: search-and-select instead of free text
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.
2026-06-03 07:12:04 -07:00
BoulderBadgeDad
1b21983ce7 Artist Map v2 — Phase 2: richer real data on hover (connection counts)
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.
2026-06-02 23:41:56 -07:00
BoulderBadgeDad
14388d4f42 Artist Map v2 — Phase 1: performance engine
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.
2026-06-02 23:37:27 -07:00
BoulderBadgeDad
9b34d06b6d UI: migrate remaining compact button families to the .btn--sm tier
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.
2026-05-29 11:40:31 -07:00
Broque Thomas
4179926899 Fix missing album placeholder asset path
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.
2026-05-22 08:34:42 -07:00
Antti Kettunen
0d683d87c0
refactor(webui): link artist detail navigation
- 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
2026-05-19 10:22:59 +03:00
Antti Kettunen
5e39f1ee09
refactor(webui): centralize artist-detail handoff
- 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
2026-05-19 09:26:10 +03:00
Broque Thomas
f3ad65de34 Complete MusicBrainz watchlist source parity
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.
2026-05-18 19:19:25 -07:00
Broque Thomas
a6282b3009 Fix source artist detail navigation from discover modals
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.
2026-05-18 13:50:10 -07:00
Broque Thomas
cd715f8697 Preserve source when opening artist detail 2026-05-17 23:40:39 -07:00
Broque Thomas
4fb9f38798 Your Albums: selectable wishlist modal + Tidal album resolution
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.
2026-05-11 12:36:16 -07:00
Broque Thomas
959562f6b0 Delete Recently Added / Top Tracks / Forgotten Favorites / Familiar Favorites
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.
2026-05-08 07:31:51 -07:00
Broque Thomas
44dd7f980f Discover: unify Decade + Genre tabbed browsers
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.
2026-05-08 07:15:37 -07:00
Broque Thomas
c557d9196e Discover controller — Cin pre-review polish
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
2026-05-07 20:35:10 -07:00
Broque Thomas
dc2323cde6 Discover cleanup: controller extensions, toast errors, migrate skipped sections
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`).
2026-05-07 20:05:39 -07:00
Broque Thomas
4ee78bb973 Migrate 7 more discover sections to the shared controller
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.
2026-05-07 19:21:19 -07:00
Broque Thomas
07a71f0432 Discover section controller foundation + migrate Recent Releases
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).
2026-05-07 18:14:56 -07:00
Broque Thomas
09cea9f013 Show toast hint when toggling a disconnected source on Your Albums
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.
2026-05-03 21:44:56 -07:00
Broque Thomas
4b23bee4a9 Add Discogs collection as a Your Albums source
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.
2026-05-03 21:27:46 -07:00
Broque Thomas
e84d187e76 Drop redundant standalone "Your Spotify Library" section on Discover
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.
2026-05-03 20:52:44 -07:00
Broque Thomas
253e4d1e4a Fix Discover hero 'View Discography' 404ing on source-only artists
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)`).
2026-04-23 23:09:56 -07:00
Broque Thomas
93f1941829 Unify artist detail: route source artists to standalone page, retire inline Artists page
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.
2026-04-22 17:00:52 -07:00
Broque Thomas
18146098a7 Revert "Route source-artist clicks to standalone /artist-detail page"
This reverts commit 1c345e4eb5.
2026-04-22 15:52:54 -07:00
Broque Thomas
1c345e4eb5 Route source-artist clicks to standalone /artist-detail page
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.
2026-04-22 15:30:40 -07:00