Commit graph

1603 commits

Author SHA1 Message Date
BoulderBadgeDad
6dfe9f3d6a Worker orbs/hub: preserve logo aspect ratio + use SoulSync logo on Manage Workers button
- Nucleus logo now fits to the pulsing radius using the image's natural
  width/height, so it no longer stretches to a square.
- Manage Workers button swaps the helix emoji for the SoulSync logo
  (trans2.png) inside the existing accent badge.
2026-06-03 17:26:50 -07:00
BoulderBadgeDad
37f9046afd Worker orbs: render the SoulSync logo as the hub nucleus
The Manage Workers hub now draws /static/trans2.png (the SoulSync mark)
at its center instead of a plain colored core, scaled to the pulsing
radius and brightening slightly with energy. Energy-reactive glow, rings
and inbound pulses still surround it. Falls back to the drawn core while
the image loads.
2026-06-03 17:24:18 -07:00
BoulderBadgeDad
44fe7a7c33 Worker orbs: make the hub a living system — reactive, orbital, data-fed
Three upgrades to the Manage Workers nucleus:
- Energy-reactive: hub size, glow, heartbeat speed and ring count all
  scale with how many workers are actually running. Calm + dim when idle,
  big/bright/fast with 1-3 radiating rings when busy. The animation now
  reads as a live gauge.
- Inbound pulses: active workers fire colored particles along their spokes
  into the hub, so it visibly collects their output (eased to accelerate
  on arrival; cleared on collapse so they don't snap).
- Orbital rotation: worker orbs get a tangential nudge around the nucleus
  so the cluster slowly revolves like an atom instead of drifting randomly
  (active orbs orbit a touch faster).
2026-06-03 17:20:19 -07:00
BoulderBadgeDad
31d8ffaa55 Worker orbs: Manage Workers button becomes a pulsing hub/nucleus
The 'Manage Workers' orb now acts as a central nucleus instead of just
another drifting particle:
- Settles at canvas center with strong pull + heavy damping (no jitter)
- Drawn larger and brighter with a slow breathing pulse, white core
  highlight, and an expanding heartbeat ring
- Wired to every worker orb with full-length spokes (a traveling pulse
  runs along each), so it visually reads as the center managing the cluster
- Other orbs repel off it, leaving a clean halo around the nucleus
2026-06-03 17:12:00 -07:00
BoulderBadgeDad
623f7b16e0 Manage Workers button: join the worker-orbs collapse/reveal animation
The screenshot said it all — the orbs collapse into the floating particle cluster
after 7s idle, but the Manage Workers pill just sat there static. It wasn't in
worker-orbs.js's WORKER_DEFS. Added .em-manage-btn (purple) so it collapses into
a floating orb with the others and reveals on header hover — now it behaves like
the rest of the cluster instead of an out-of-place static button.
2026-06-03 16:50:57 -07:00
BoulderBadgeDad
08dcd00217 Enrichment manager modal: orb-style entrance (springy rise + cascading worker rail)
The modal opened with a plain pop — out of place next to the worker orbs. Now it
springs up from the bottom (toward the Manage Workers button) with the SAME easing
the orbs reveal with, then the worker rail assembles one-by-one: each chip springs
in staggered (scale 0.4→1) with a brief pulse of its own brand colour. Mirrors the
orb motion language AND walks your eye across every worker + its live state dot /
coverage bar as they land — cool + informative. Respects prefers-reduced-motion.
2026-06-03 16:47:22 -07:00
BoulderBadgeDad
152e8b6bf3 Similar Artists orb: wire it into the WebSocket status broadcast (the real fix)
THE root cause of 'orb frozen, click does nothing visibly': when a socket is
connected, the orbs don't poll — update*Status() bails on socketConnected and
relies on server pushes. similar_artists was missing from BOTH the server emit
loop (_emit_enrichment_status_loop's workers dict) and the client dispatch
(core.js socket.on('enrichment:<id>')), so the orb never received status → never
updated. Clicks DID pause the backend (modal showed paused), but the orb visual
was frozen. Added the worker to the emit loop + the socket.on handler.
2026-06-03 16:09:22 -07:00
BoulderBadgeDad
0a77542d84 Similar Artists orb: match the AudioDB/standard wiring exactly (no inline onclick)
Root cause of 'click does nothing': I flip-flopped between inline onclick and
addEventListener. A cached index.html with my inline onclick + fresh JS with
addEventListener = the click fires the toggle TWICE (pause then resume) = no net
change. Now identical to AudioDB/Deezer/etc.: NO inline onclick on the button,
single addEventListener('click', toggle) in the init. One handler, one fire.
2026-06-03 16:03:45 -07:00
BoulderBadgeDad
b79b26b7c5 Similar Artists orb: inline onclick (like Amazon) so the click reliably fires
The addEventListener wiring evidently wasn't firing the toggle (orb showed
running but clicking didn't pause). Switched the button back to an inline
onclick=toggleSimilarArtistsEnrichment() — identical to the Amazon orb, which
works — and exposed the fn on window so the inline handler always resolves.
Toggle logic unchanged (active ? pause : resume).
2026-06-03 15:59:23 -07:00
BoulderBadgeDad
940afbf4b2 Similar Artists orb: use the exact same toggle logic as the other orbs
Stop diverging — match toggleAmazonEnrichment/toggleSpotifyEnrichment verbatim:
contains('active') ? pause : resume. A paused orb isn't 'active', so a click
resumes it (same as every other worker). My earlier 'paused'-class variant was
what broke unpausing.
2026-06-03 15:53:47 -07:00
BoulderBadgeDad
c635ca5b82 Similar Artists orb: toggle from real backend state (fix 'won't unpause')
Class-based toggle had a hole: the orb may lack the 'paused' class even when the
backend is paused (before the first 2s status poll, or worker fallback), so a
click would PAUSE the already-paused worker (no-op) → 'clicking doesn't unpause'.
Now the toggle reads /status first and does the opposite of the real paused
state, so a paused worker always resumes on click.
2026-06-03 15:52:59 -07:00
BoulderBadgeDad
8e6cd0382c Similar Artists orb: register with the worker-orbs collapse/reveal system
The orb was excluded from worker-orbs.js's WORKER_DEFS list, so it never got the
shared 'collapse to floating orb after 7s idle / reveal on header hover'
animation (worker-orb-hidden / worker-orb-reveal) every other orb has. Added its
container (.similar-artists-enrich-button-container, purple) to the list.
2026-06-03 15:39:46 -07:00
BoulderBadgeDad
5949c104c3 Similar Artists orb: reliable pause + standard addEventListener wiring
- Orb wouldn't pause when the worker had finished its library: the toggle keyed
  off classList.contains('active'), but a done worker sits in the green
  'complete'/idle state, so clicking tried to resume (no-op). Now it pauses
  unless already paused → pausable in any state.
- Switched from inline onclick to addEventListener (matches spotify/itunes/etc.,
  the majority pattern) instead of the amazon/discogs inline style.
2026-06-03 15:33:39 -07:00
BoulderBadgeDad
8ba20f7c49 Similar Artists worker: DB-backed stats (orb matches modal) + default on
- get_stats now reports PERSISTENT counts from the DB (matched/not_found/pending
  + a progress.artists breakdown) instead of in-memory session counters, so the
  dashboard orb tooltip and the Manage modal agree (was showing 0 vs 14 after a
  restart) and it survives restarts — same approach as the other workers.
- Orb tooltip reads progress.artists ('Artists: 14 / 15 (93%)') like the rest.
- Worker now defaults to ON (running) instead of opt-in-paused; still honors a
  saved pause across restarts. It self-paces (~3s/artist) and backs off on
  MusicMap outages, so the orb spins/active like the others when there's work.
10 seam tests pass.
2026-06-03 15:26:29 -07:00
kekkokk
0b1fdba2a1 Fix standalone mirrored playlist sync and post-sync downloads.
SoulSync standalone matches library tracks without Plex fetchItem,
reports missing counts correctly, and skips server playlist writes.
Automation re-syncs when the mirror grows; after sync finishes, starts
organize download (organize-by-playlist) or wishlist processing.

UI: Spotify URL playlist-folder controls, organize toggle layout in the
discovery modal, reload organize preference when reopening Download Missing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 00:24:00 +02:00
BoulderBadgeDad
d70d410f6f Similar Artists worker: dashboard enrichment bubble
Adds the dashboard status bubble (the small icon row) for the Similar Artists
worker, alongside the modal entry. Mirrors the per-source bubbles: MusicMap logo,
purple accent, spinner + active/complete/paused states, hover tooltip, and a 2s
status poll against /api/enrichment/similar_artists/status. Click toggles
pause/resume. Tooltip shows matched/pending (the worker has no artist/album/track
phases). 74 JS integrity tests pass.
2026-06-03 15:19:46 -07:00
BoulderBadgeDad
89e3486e84 Similar Artists enrichment worker (MusicMap → match → store) for library artists
Closes the gap where similar artists only existed for WATCHLIST artists: a new
background worker populates them for the whole LIBRARY, slotting into the
existing enrichment-worker pattern (bubble + Manage Enrichment Workers modal,
status/pause/resume, matched/not_found/pending/errors).

Per source-matched library artist → get_musicmap_similar_artists(name, 25)
(the same matcher the artist-detail page uses: fetches MusicMap names, matches
each to the user's source chain — primary + active fallbacks — returns only
matched artists) → store via add_or_update_similar_artist keyed by the artist's
metadata source id, the SAME key the watchlist scanner + artist map use, so the
two cooperate (idempotent upsert + retry_days window).

  - core/similar_artists_worker.py: pure seams (pick_source_artist_id,
    map_payload_to_store_kwargs, process_artist) + the threaded worker; skips
    artists not yet source-matched; classifies not_found vs transient error
    (retry after 30d).
  - DB migration: similar_artists_match_status / _last_attempted on artists
    (mirrors every other source worker's tracking columns).
  - Registered in EnrichmentService + instantiated in web_server, DEFAULT-PAUSED
    (opt-in) like Amazon — MusicMap is scraped/outage-prone + this is library-wide.
  - SERVICE_ENTITY_SUPPORT['similar_artists']=('artist',) so the modal breakdown
    ('artists with / without similars') + Retry work; manual-match (inapplicable
    to a relationship) is gated out via relationship:true.
  - 10 seam tests; existing 80 enrichment tests still pass.

Note: keys under profile 1 (single-profile setups); multi-profile is future work.
2026-06-03 15:07:49 -07:00
BoulderBadgeDad
c8626b1e83 Artist Map v2: offset toolbar back button clear of the mobile hamburger menu
The fixed hamburger (top:16 left:16, 44px, z9999) sat on top of the map's back
button on mobile. Push .artmap-nav-left right by 52px on <=760px so the back
button clears it.
2026-06-03 14:32:44 -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
ed2a97d701 Fix: artist detail forced Enhanced view on source-only artists, hiding discog
The persisted Standard/Enhanced preference was re-applied on every artist load
BEFORE the data came back — so for an artist not in the library (source-only, no
Enhanced view) it still flipped to Enhanced, which showed an empty Enhanced pane
and never rendered the discography.

Now the preference is applied inside loadArtistDetailData, after we know the
artist's status (data.artist.server_source). Only library artists honour a saved
'enhanced' choice; source-only artists always stay on Standard (discography).
2026-06-03 13:40:00 -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
Francesco Durighetto
9ff2e7084a Fix organize-by-playlist downloads: library entries, wishlist, and stale Spotify cache
Persist organize_by_playlist on mirrored playlists and run playlist-folder
downloads from the auto-sync pipeline instead of the global wishlist phase.
Register SoulSync library rows after playlist-folder post-processing, route
failed organize batches to the wishlist correctly, and skip sync-time
unmatched wishlist only when organize download handles retries.

Invalidate stale playlist track caches on refresh (Spotify and Deezer ARL),
re-mirror on refetch, and improve standalone playlist modals (re-analysis,
Open in Mirrored). Add filesystem missing-track detection and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 10:26:32 +02: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
b0d5008673 Global process-first now re-queues failed too (matches per-worker pin)
Global priority previously set order only; per-worker pin also re-queued the
group's failed items. Made global consistent: setting a group globally now also
resets that group's not_found -> pending on every supporting worker, so each
worker sweeps ALL pending + failed of the group before moving on. Toast reports
total re-queued. Workers that don't enrich the group are skipped.
2026-06-02 20:48:39 -07:00
BoulderBadgeDad
3a560bd1bb Enrichment manager: rebuilt header + global process-first + cleaner rows
- Rebuilt the modal header: gradient top bar with a glowing 🧬 icon chip,
  gradient title + subtitle, and styled refresh/close — replaces the flat bar.
- Global 'process first everywhere' control in the header: Artists/Albums/
  Tracks/Auto applies to every worker at once (workers that don't enrich a
  group are skipped via the 400 the endpoint already returns). Sets order only.
- Match rows: replaced the loud accent-gradient artwork placeholder with a
  subtle neutral chip showing the entity glyph; real images layer over it and
  remove themselves on error, so missing/broken art never leaves ragged gaps.
- Removed overflow:hidden from .em-row.

Frontend only; JS syntax clean.
2026-06-02 20:41:05 -07:00
BoulderBadgeDad
f827ddc282 Enrichment manager: merge coverage+order into one card set, fix ordering, richer rows
Addresses three pieces of UI feedback:
- Fix entity order: enrichment coverage was rendering by object-key order
  (albums first). Now sorted canonically artist → album → track via
  _emOrderEntities, used everywhere.
- Combine 'Processing order' and 'Enrichment coverage' into a single set of
  entity cards: each card shows coverage (segmented matched/not_found/pending
  bar + %) AND is the click target to pin that group to enrich first, with
  live 'Now' / pinned 'First 📌' / 'Done' states and per-worker accent. Drops
  the two redundant sections (and the old chain/stats renderers).
- Richer match rows: status stripe down the left edge (red=not found,
  amber=pending), larger rounded artwork with a gradient placeholder, parent
  context (artist/album), and a subtle slide-on-hover.

Frontend only; JS syntax clean.
2026-06-02 20:27:57 -07:00
BoulderBadgeDad
f8afe56642 Enrichment priority: pinning sweeps the whole group; rail shows live phase
Aligns the 'process this group first' behaviour with intent:
- Pinning a group now also re-queues that group's previously-failed
  (not_found -> pending) items, so the worker processes ALL unmatched in the
  group (pending + missing), not just never-tried ones. Safe from loops: each
  is attempted once, still-unmatched return to not_found, and the pending-only
  worker hook won't re-pick them. Toast reports how many were re-queued.
- The left rail now shows each worker's current group while running
  ('Running · albums'), so you can see what every service is on at a glance.

Frontend only; reuses the tested /priority + /retry endpoints.
2026-06-02 19:58:14 -07:00
BoulderBadgeDad
62ee1f8520 Enrichment manager: 6 UX improvements
- #1 Unconfigured-source banner: when a source has enabled=false, show a
  notice that browsing works but matches/retries won't run until it's set up.
- #2 Rate-limit detail: when rate_limited, surface 'resumes in ~Xm' (from the
  status payload) instead of just a pill.
- #3 Richer rows: unmatched items now show parent context — an album's artist,
  a track's album — via a parent expression in the query (+ test).
- #4 Bulk select: per-row checkboxes + a bulk bar to retry several at once
  (capped concurrency), reusing the /retry item endpoint.
- #5 Remember last worker: selection persists in localStorage and is restored
  on open; openEnrichmentManager(workerId) supports future deep-linking
  (bubbles left on their pause-on-click behaviour).
- #6 Keyboard nav: ArrowUp/Down moves focus between rows; actions are native
  buttons (Enter/Space) and Escape closes — list isn't poll-refreshed so focus
  is stable.

53 enrichment tests green; JS syntax clean.
2026-06-02 19:50:57 -07:00
BoulderBadgeDad
e53a157793 Enrichment manager: 'process this group first' + refined hero header
Per-worker processing-order override + UI polish.

Feature — pin an entity group to enrich first:
- Each worker normally runs artist -> album -> track. A user can pin one
  group (artist/album/track) to run first from the modal; the worker keeps
  that group first until it's exhausted, then resumes the normal chain.
- core/worker_utils.py: read_enrichment_priority() (reads
  <service>_enrichment_priority each loop, live) + priority_pending_item()
  (shared, whitelisted query returning the worker's expected item shape;
  Spotify/iTunes get album_individual/track_individual via a type map).
- A guarded ~6-line hook at the top of all 11 workers' _get_next_item.
  CRITICAL: when nothing is pinned (default) the hook returns immediately,
  so default enrichment order is byte-identical to before. Discogs (no track)
  and Genius (no album) only honor their supported entities.
- core/enrichment/api.py: GET/POST /api/enrichment/<id>/priority (+ config_get
  hook); POST validates the entity against what the source enriches.
- 14 new tests (helper shapes, exhaustion, route get/set/clear/validate).

UI:
- Refined hero header: identity + inline status left, single Pause right,
  'now enriching' quiet sub-line; overall coverage % moved into the stats
  section ('82% matched · 1,203 of 1,460'). Hero gently pulses while running.
- New processing-order strip: artist→album→track steps showing the live phase
  (pulsing 'now'), pinned group ('first' + 📌), and done/remaining; click a
  step to pin it, click again for auto.

py_compile clean across all 11 workers; 52 enrichment tests green.
2026-06-02 19:45:04 -07:00
BoulderBadgeDad
fc9a9f1c90 Enrichment manager v2: working retry + bulk retry-all-failed
Fixes a correctness bug and adds bulk re-queuing.

- Bug: per-row 'Retry' used clear-match, which sets an item to not_found
  with last_attempted=NULL. The worker only retries not_found items where
  last_attempted < (now - 30d), and 'NULL < cutoff' is false in SQLite, so
  those items were never re-queued. Fixed by resetting match_status to NULL
  (pending), which every worker's queue picks up on the next pass.
- New POST /api/enrichment/<id>/retry with scope 'item' | 'failed'
  (failed = re-queue every not_found item of an entity type), backed by a
  pure whitelisted build_reset_query + MusicDatabase.reset_enrichment().
- UI: per-row Retry now hits /retry; a 'Retry all failed' bulk button appears
  when the current entity has not-found items (confirm + count toast); a hint
  line explains retry/match/auto-retry behaviour.
- 11 new tests (38 enrichment tests total, all green).
2026-06-02 19:19:39 -07:00
BoulderBadgeDad
0b3c3f656d Add Manage Enrichment Workers modal (v1 + polish)
Dashboard 'enrichment bubbles' could pause/hover but offered no way to
*manage* a worker. This adds a full management modal opened from a new
header button, covering all 11 enrichment sources.

Backend (testable core helper + seam tests; no live-DB dependency):
- core/enrichment/unmatched.py: pure, whitelisted SQL builders for the
  unmatched browser. service/entity validated against a support map (never
  interpolated raw); search + pagination bound as params; tracks join albums
  for artwork; limit capped at 200.
- database/music_database.py: get_enrichment_unmatched() +
  get_enrichment_breakdown() (the breakdown splits matched/not_found/pending,
  which the existing get_stats().progress lumps together).
- core/enrichment/api.py: GET /api/enrichment/<id>/{unmatched,breakdown} on
  the existing blueprint + a db_getter hook.
- web_server.py: wire db_getter=get_database.
- tests/enrichment/test_unmatched.py: 19 tests across builders, DB methods,
  and Flask routes.

Frontend (vanilla, matches app conventions):
- webui/static/enrichment-manager.js: worker rail with live status + coverage
  micro-bars, accent-themed detail panel (hero header, segmented matched/
  not_found/pending stat cards, current item, pause/resume), and a searchable
  paginated unmatched browser with inline manual match (reusing
  search-service + manual-match) and retry (clear-match re-queues).
- Polish: entrance/exit motion, scroll-lock, Escape, refresh control,
  flicker-free polling (in-place updates), skeleton loaders, relative
  timestamps, per-worker accent theming, real dashboard logos reused at
  runtime (with the same invert/circle treatment), responsive rail.
- index.html: header button + script include. style.css: full styling.

Reuses existing pause/resume, status, and manual search+assign endpoints.
Backend tests green (19 new + 11 existing enrichment tests).
2026-06-02 19:06:44 -07:00
BoulderBadgeDad
dfa5204e0a Repair settings: dropdown for fixed-choice settings (canonical source_selection)
The canonical source_selection setting was rendering as a free-text box — easy
to typo an invalid mode. Added a generic choice mechanism so it's a dropdown:

- RepairJob.setting_options: {key: [allowed values]} (default {} — opt-in).
- CanonicalVersionResolveJob declares source_selection's three modes.
- repair_worker.get_all_job_info() includes setting_options in the job payload.
- enrichment.js renders a <select> (options prettified, current value selected)
  for any key listed in setting_options; everything else renders by value type
  as before. The save path already reads <select>.value as a string, so no
  change needed there.

Generic — any future job can get dropdowns the same way. Jobs that don't
declare setting_options are untouched (empty dict -> existing input rendering).

Tests: source_selection exposes the 3 options and its default is one of them.
23 repair-job/worker + canonical tests pass (other jobs unaffected).
2026-06-02 15:33:13 -07:00
BoulderBadgeDad
28850672a6 Fix: duplicate detector kept lossy over lossless (rank format first)
The Duplicate Detector's 'Keep Best' auto-selection ranked copies by highest
bitrate -> duration -> track number, with no notion of format. A FLAC whose
bitrate the library scan never populated (a common gap) therefore lost to a
282 kbps MP3: 282 > 0, so the MP3 was kept and the FLAC deleted (reported on
Havok 'Prepare For Attack', and again on Kendrick GNX).

Fix: rank by format/lossless tier FIRST, then bitrate, duration, track number.
A lossless file now always beats a lossy one regardless of the recorded
bitrate; bitrate/duration/track# only break ties within the same format.

- core/library/duplicate_keep.py (new): pure, importable pick_duplicate_to_keep
  + duplicate_keep_sort_key + format_rank_for_path (extension rank mirroring
  auto_import_worker._quality_rank: flac=10 ... mp3=5 ... unknown=1).
- core/repair_worker.py: _fix_duplicates auto-pick now calls
  pick_duplicate_to_keep instead of the bitrate-first max().
- webui/static/enrichment.js: the KEEP/REMOVE recommendation mirrors the same
  format-first ranking so the badge matches what the backend will delete.

Parity: Python uses '.ext' keys (os.path.splitext), JS uses 'ext'
(split('.').pop()) -> identical results; both keep the first copy on a full
tie. Verified the only other dedup path (the standalone Duplicate Cleaner
automation, core/library/duplicate_cleaner.py) was already format-priority-first
and correct -- no change needed there.

Tests: tests/test_duplicate_keep.py (11 -- incl. the exact FLAC-with-missing-
bitrate vs 282 kbps MP3 case, format ranking, within-format tie-breakers, and
edge cases). 147 repair/duplicate tests still pass.

Note: why FLAC bitrate is NULL in the DB is a separate library-scan gap;
format-first ranking makes the keep decision correct regardless.
2026-06-01 12:49:34 -07:00
BoulderBadgeDad
6bc2836f47 Feature: preferred album-art source selection (opt-in, ordered, with fallback)
Lets users pick which providers' cover art to use and in what priority,
generalizing the single prefer_caa_art toggle into an ordered, mix-and-match
list (Sokhi's request). Fully opt-in: default album_art_order is [], so every
existing install is byte-for-byte unchanged until the user enables sources.

How it works:
- Per album, walk the user's ordered sources top-to-bottom; the first source
  that actually has THIS album's cover wins. A miss falls through to the next;
  if all miss, the download's own art is kept (today's default). The worst case
  is always exactly the cover you'd get today -- never wrong art, never an
  error into the download.
- Connection-gated: a source is only tried when the user is connected to it
  (free sources CAA/Deezer/iTunes/AudioDB always; Spotify only when
  authenticated). Tidal/Qobuz/HiFi deferred (cover-URL construction + no clean
  core accessor -- not shipping unverified extraction).
- Album-match validated: a source's art is used only when the album it returns
  matches the requested artist+album (significant-token subset, tolerant of
  Deluxe/Remastered/articles/feat./multi-artist). A loose top search hit for a
  different record is treated as a miss -> guarantees no wrong-album art.
- The list supersedes the legacy prefer_caa_art toggle: when album_art_order is
  non-empty it is the sole authority (add 'caa' to the list to use Cover Art
  Archive), and prefer_caa_art is neutralized for both the embedded-tag art and
  cover.jpg paths. With an empty list, prefer_caa_art behaves exactly as before.

Implementation:
- core/metadata/art_sources.py: pure resolver -- effective_art_order (config +
  legacy back-compat) and resolve_cover_art (ordered walk + fallback,
  exception-safe per source). No network/config/DB; fully unit-testable.
- core/metadata/art_lookup.py: availability gating, per-source lookups against
  existing clients (Deezer/iTunes/AudioDB/Spotify search + CAA via MBID),
  album-match validation, per-album caching, and select_preferred_art_url --
  the single gate the pipeline calls (no-op unless an explicit list is set).
- core/metadata/artwork.py: wired into embed_album_art_metadata and
  download_cover_art, gated so no configured list == current behavior.
- web_server.py: GET /api/metadata/art-sources (connected sources only).
- config/settings.py: default album_art_order: [].
- webui (index.html + settings.js): reorderable list in Core Features reusing
  the hybrid-source-list pattern + real service logos (with emoji fallback);
  load/save wired through the existing metadata_enhancement settings flow.
  loadArtSourceOrder populates the saved order synchronously (filtered to known
  sources, not availability) so a save before the availability fetch resolves,
  or a temporarily-disconnected source, can never wipe the saved order.

Tests: 40 unit/seam tests (resolver ordering/fallback/back-compat, availability,
per-source extraction, album-match validation incl. wrong-album/wrong-artist
rejection, caching, exception-safety, the off-by-default gate). Full metadata
suite still green (610 passed) -- the gated integration changes nothing when no
list is configured.

Note: the settings UI (DOM-heavy, not unit-testable in the JS harness) and the
live per-source art-fetch quality are validated by manual testing.
2026-06-01 11:45:07 -07:00
BoulderBadgeDad
6e7948b642 Mirrored playlist modal: revamp + fix header clipping on long playlists
The preview modal looked amateur and its header/footer clipped on long
playlists (wolf39's 316-track "Road trip" showed neither title nor buttons).

Root cause of the clip: .mm-list (the scroll area) was a flex child with
flex:1 but no min-height:0. Flex items default to min-height:auto, so the
list refused to shrink below its content, the modal blew past max-height,
and overflow:hidden + vertical centering pushed the header off the top and
the footer off the bottom. Now the list has min-height:0 and the hero +
action bar are flex-shrink:0, so they stay pinned and the list scrolls.

Visual revamp to match the rest of the app, using data already returned by
/api/mirrored-playlists/<id> (image_url on the playlist and each track):
- Hero uses real artwork (playlist cover -> first track art -> gradient
  fallback) with a blurred art backdrop + darkening overlay, replacing the
  emoji-in-a-box. Eyebrow + large title + meta line (source pill, owner,
  track count, total runtime, mirrored-ago).
- Track rows gain per-track album thumbnails, two-line title/artist, album,
  duration, and a sticky column header. Missing art falls back to a gradient
  tile via onerror (no broken-image icons).
- Cleaner action bar: primary Discover, secondary Auto-Sync, ghost Edit/
  Close, quiet danger-outline Delete.

Old .mirrored-modal-* / .mirrored-track-* / .mirrored-btn-* classes removed
from style.css and replaced with the new .mm-* set; the _escJs escaping in
the footer buttons (apostrophe fix) is preserved.
2026-06-01 09:31:20 -07:00
BoulderBadgeDad
ffb9249ded Fix: mirrored playlist action buttons dead when name has an apostrophe
A mirrored playlist named with an apostrophe (e.g. "Road trip-The
Rolfe's") rendered dead action buttons. _escAttr HTML-escapes ' to &#39;,
but it was used to inject the name into a single-quoted JS string inside an
inline onclick. The HTML parser decodes &#39; back to a bare ' BEFORE the JS
parser runs, producing an unterminated string literal -> SyntaxError -> the
whole handler fails to compile.

Two symptoms (both reproduced with the real name + the literal line-524
onclick template): clicking the X delete never ran event.stopPropagation(),
so the click bubbled to the card and opened the track preview instead; and
the preview's "Delete Mirror" silently did nothing (no DELETE request, no
log). Plain names ("Classic Rock") were unaffected, which is why it looked
intermittent.

Add a dedicated _escJs() that backslash-escapes the JS metacharacters (\, ')
first, then HTML-escapes the attribute-breaking chars - correct for a
single-quoted JS string inside a double-quoted HTML attribute. Convert all 16
inline-onclick string-argument sites to it: mirrored card (clear/Auto-Sync/
link/delete) and preview modal, plus the same latent bug in pool Fix Match /
Rematch, group bulk-toggle/rename/delete, and automation history/group/delete.
Genuine HTML-attribute usages (class/value/data-*/title/option) stay on
_escAttr where it is correct.

Tests: tests/static/test_stats_automations_esc.mjs extracts the real _escJs/
_escAttr from source and asserts apostrophe + quote/backslash/&/<> names
round-trip through HTML+JS decoding, documents that _escAttr throws a
SyntaxError for the apostrophe case while _escJs compiles clean, and pins
wolf39's exact name. pytest shim tests/test_stats_automations_esc_js.py runs
it under node --test (skips if node<22 / absent).
2026-06-01 09:02:25 -07:00
BoulderBadgeDad
227c9373fe Batches panel: redesign expanded track rows + fix scrollbar clipping
The per-track list inside an expanded batch was a cramped flat row with a faint
title and a -2px progress-bar hack, and the nested scrollbar sat on top of the
text. Reworked:
- Each row is now a grid: track number · title (+ artist sub-line) · right-aligned
  state, with hover, tabular-aligned numbers, per-row state coloring (✓ green /
  ✗ red / % accent / dim queued / strikethrough cancelled), and a clean full-width
  progress bar beneath downloading rows.
- Track list gets right padding + a thin, subtle scrollbar so it no longer
  clips titles; same thin-scrollbar treatment on the panel itself.
- Panel widened 340->366 with rebalanced side padding for more readable content.
Collapsed-panel behavior unchanged.
2026-05-31 22:44:58 -07:00
BoulderBadgeDad
f50e67ac9b Batches panel: Phase A visual upgrade (summary, segmented progress, ETA, live track)
Takes the Active Downloads batch panel from flat cards to a glanceable,
information-rich view:
- Sticky aggregate summary strip: 'N batches · X downloading · Y queued · speed · ~ETA'.
- Segmented progress bar per batch — proportional done (green) / failed (red) /
  active (accent, animated shimmer) / remaining, so the state reads at a glance
  instead of one dim fill.
- Colored stat chips (✓ done · ✗ failed · ↓ active · queued) + a per-batch ETA
  from a client-side completion-rate sampler (album bundles use the downloader's
  own speed/size). No backend changes — Phase A is frontend-only.
- 'Now downloading' line showing the live track on active batches.
- Expand chevron affordance (rotates when open); subtle phase tinting.
- Polished empty state with quick-start links (Search / Sync / Wishlist).

Card actions (filter / cancel / open-modal / expand) and the fade/history
behavior are unchanged. ETA/speed for non-bundle batches and a retry-failed
action are Phases B/C (backend).
2026-05-31 22:28:43 -07:00
BoulderBadgeDad
e072b49138 Track-detail modal: fix stray cover-art placeholder (hidden overridden by display:flex)
The 🎵 cover placeholder (and the empty provenance block) stayed visible even
when JS set hidden, because .td-thumb-ph / .td-provenance set display:flex,
which a class selector applies over the browser's [hidden] { display:none }.
Scope a winning rule (#track-detail-overlay [hidden] { display:none !important })
so toggled-off elements actually disappear — the cover shows alone when present.
2026-05-31 21:22:05 -07:00
BoulderBadgeDad
134d306511 Track-detail modal: click any download row for a rich, status-aware view
Clicking a track row in the download modal now opens a polished detail modal
(its own template, webui/track-detail-modal.html, included into index.html;
behavior in static/track-detail.js): cover, title/artist/album, status badge,
in-app play, source, quality, AcoustID verdict, file location, and the
expected-vs-downloaded provenance — backed by /api/downloads/task/<id>/detail.

It adapts by status:
- completed  -> play (library stream) + full provenance
- quarantined-> reason + Listen (quarantine stream) + Accept & Import + Search
- failed/not_found -> reason + Search

This absorbs the standalone quarantine chooser, which is removed (its
Listen/Accept/Search live here now, with the same Windows file-handle release
before Accept and the thin-sidecar -> Recover-to-Staging fallback). Plain
failed/not-found rows still go straight to the search modal; sync-import modal
unaffected. Status cells clear their clickable/detail state each render so a row
that flips to completed isn't left with a stale handler.
2026-05-31 20:24:37 -07:00
BoulderBadgeDad
a703c5fdc2 Quarantine: inline 'Approve' button also marks the row Completed
The actions-column Approve button (approveQuarantineFromDownloadRow) POSTed
/approve without a task_id, so it took the inner-pipeline path and never marked
the task completed — the row stayed 'Quarantined' even though the file imported.
The chooser's Accept was already fixed; this brings the inline button in line:
it now carries data-task-id and sends task_id, so the re-import runs through the
verification wrapper and the row flips to Completed on success.
2026-05-31 18:44:00 -07:00
BoulderBadgeDad
bad3eb1fab Quarantine: flip the modal row to Completed after Accept & Import
Accepting a quarantined item re-imported the file correctly, but the download
modal kept showing 'Quarantined'. The re-import ran through the inner pipeline,
which doesn't mark task completion (that's the verification wrapper's job), and
the sidecar context had no task_id anyway (popped before quarantine).

The chooser's Accept now sends the originating task_id, and the endpoint
re-runs the import through the verification wrapper with that task_id (+ batch_id
looked up from the task), so the task is marked completed only after the file is
verified moved — the row flips to Completed on the next poll. Manager-tab
approvals (no task_id, no JSON body — handled via get_json(silent=True)) keep
the original inner-pipeline path.

Also clear has-candidates + the quarantine dataset on every status render so a
row that goes quarantined -> completed doesn't keep a stale chooser attached.
2026-05-31 18:28:44 -07:00
BoulderBadgeDad
3060678f29 Quarantine: manage a quarantined file from the download modal (Listen / Accept / Search)
Clicking a quarantined track's status used to open the generic search modal,
identical to a plain failure — no way to review or recover the file. It now
opens a chooser:
- Listen: streams the file in-app via a new /api/quarantine/<id>/stream
  endpoint (range-supported; the real audio Content-Type is recovered from the
  sidecar since the on-disk file ends in .quarantined).
- Accept & Import: existing /approve (restore + re-import, gates bypassed).
- Search for a different result: the existing candidates modal (old behavior).

Non-quarantine failures (not_found / failed / cancelled) are unchanged — a
single click listener routes by dataset set at render time, so a task that
fails then later quarantines can't end up double-bound.

Also fixes the Accept failure on Windows: the Listen stream holds an open file
handle, so the subsequent restore move hit WinError 32 ('file in use') and the
endpoint mislabeled it 'thin sidecar'. Accept now releases the audio handle
before approving, and approve/recover moves retry briefly on transient OS locks
(_move_with_retry). Accept also auto-falls-back to Recover-to-Staging for
genuinely thin/orphaned sidecars.

Tests: stream-info resolution (sidecar + filename-fallback + missing), and
_move_with_retry success/give-up.
2026-05-31 15:41:04 -07:00
BoulderBadgeDad
fa750b6e89 Search: bump live-search debounce 300ms -> 600ms (#751)
Reporter (Vicky-2418) saw the artist search fire a separate external-API
search for nearly every letter typed. There WAS a 300ms debounce, but that's
short enough that a deliberately-typed name lands a keystroke per debounce
window, so each letter kicked off (and aborted) a fresh search — noisy in the
logs and wasteful.

Bumped both live-search surfaces that drive the shared SearchController
(external metadata APIs) to 600ms: the /search enhanced input (search.js) and
the global-search widget (downloads.js). 600ms coalesces a name being typed
into one search after the user pauses, while still feeling live. Enter still
triggers an immediate search on both (existing keypress/keydown handlers),
and the per-change abort already cancels stale in-flight fetches.

Frontend-only; both files syntax-clean.
2026-05-30 15:34:34 -07:00
BoulderBadgeDad
112ecbb24f Player: seek hover tooltip on the Now Playing progress bar
The mockup had a seek tooltip (timestamp tracks the cursor over the progress
bar) but it was never ported to the real player. Added it: mousemove computes
the hovered fraction -> formatTime(duration*frac), positions the tip, shows on
hover / hides on leave. Guarded when no duration. Frontend-only; JS + CSS clean.
2026-05-30 15:15:58 -07:00
BoulderBadgeDad
bf2a2ca928 Player: log SoulSync web-player plays (recently-played + smart-radio recency)
listening_history was populated ONLY from the media server; the web player
recorded nothing. Now a play heard ~10s logs to listening_history AND bumps
tracks.play_count/last_played — so the existing 'recently played' query reflects
actual SoulSync listening, and the Phase-2 smart-radio recency signal gets real
data.

- core/playback/play_log.build_play_event(): pure, DB-agnostic normalizer from
  player payload -> listening_history event shape. Caller supplies the
  timestamp (stays pure). Composite/streamed ids never become the int
  db_track_id; bool ids rejected; missing title -> skip. 9 unit tests.
- MusicDatabase.record_web_player_play(): inserts the history row + increments
  play_count/last_played for the library track in one call.
- /api/library/log-play: thin endpoint, server-side timestamp, best-effort
  (logging failure never 500s / never affects playback).
- Frontend: npMaybeLogPlay on timeupdate fires once per track at the 10s
  threshold (flag reset in setTrackInfo, set-before-fetch so it can't
  double-fire), fully fire-and-forget.

Pure builder is unit-tested; the DB write can't run in-sandbox (real DB throws)
so it's a thin straightforward insert+update. JS + web_server parse clean.
2026-05-30 15:11:46 -07:00
BoulderBadgeDad
f9bc96bd90 Player: 'Playing from' context header (Radio / <Artist> Radio)
Spotify-style context line above the track title. npSetPlayContext(text) shows/
hides it; set to 'Radio' when radio mode turns on, '<Artist> Radio' from
playArtistRadio (specific label wins over generic), cleared on stop/clearTrack
and when radio mode is turned off. Accent-colored name, uppercase label.

Frontend-only; JS + CSS clean.
2026-05-30 15:07:31 -07:00
BoulderBadgeDad
ab2b5c64f4 Player: shuffle + repeat on the mini-player (parity with modal)
The sidebar mini-player had prev/play/next/stop/expand but not the two
set-and-forget controls you reach for without opening the full view. Added
shuffle + repeat (3-mode, with a repeat-one badge) to the mini-controls.

State stays in sync both ways: handleNpShuffle/handleNpRepeat now call a shared
syncShuffleRepeatUI() that reflects state onto BOTH the modal and mini buttons,
so toggling in either place updates the other. Mini buttons reuse the same
handlers. Accent-active styling via --accent-light-rgb.

JS clean; CSS balance consistent with HEAD.
2026-05-30 14:57:04 -07:00
BoulderBadgeDad
1e514693f1 Player: 'Play next' action + accent-correct queue buttons
- Added playNext(track): inserts a track right after the current one (Spotify
  'Play next'), vs addToQueue which appends to the end. Falls back to
  addToQueue when nothing is playing.
- Artist-detail track rows now show BOTH a Play-next (⇥) and Add-to-queue (+)
  button; the delegated handler builds one shared library-track payload and
  routes to playNext / addToQueue. (Add-to-queue was already wired; play-next
  + the second button are new.)
- Fixed the queue button's hardcoded 29,185,84 to var(--accent-rgb) so it
  follows the settings accent (kettui UI-consistency), and styled the new
  play-next button to match.

Note: deliberately NOT adding queue buttons to SEARCH results — those are
stream/download (non-library) tracks the queue's auto-advance can't reliably
play. JS syntax clean on both files.
2026-05-30 14:54:14 -07:00
BoulderBadgeDad
65f49ccecd Player: N/P next-prev keys + global mute + persist volume across reloads
- Keyboard: added N (next) / P (previous) track shortcuts; 'm' mute now works
  whether or not the modal is open (was modal-only). Space/seek/volume/escape
  unchanged.
- Volume persistence: volume now saved to localStorage on every change (slider
  + arrow keys, via npPersistVolume) and restored on load instead of always
  resetting to 70%. npLoadSavedVolume validates the stored 0..100 value.
  initializeMediaPlayer applies it + syncs both slider UIs.

Frontend-only; init runs from init.js after full parse so the module consts
are defined. JS syntax clean.
2026-05-30 14:52:16 -07:00
BoulderBadgeDad
3c123958ca Fix: Artist Radio never populated the queue
playArtistRadio() flipped npRadioMode=true directly but never fetched similar
tracks, so the queue stayed empty until the current song ENDED (onAudioEnded is
what triggered the radio fetch). The modal's Radio button does it right via
npSetRadioMode(true, {fetchIfNeeded:true}).

Fix: await playLibraryTrack(...) (it's async and sets currentTrack only after
resolving the canonical DB row), THEN call npSetRadioMode(true, {fetchIfNeeded})
— which seeds the current track into the queue and immediately fetches the
radio queue. Replaces the old fixed-setTimeout guess that raced the async track
load (and could fire before currentTrack.id existed -> silent no-op).
2026-05-30 14:45:39 -07:00
BoulderBadgeDad
592b68c16c Player revamp: harden crossfade race conditions + global decl (audit fixes)
Self-audit of the revamp surface found real bugs, now fixed:

- DOUBLE-ADVANCE race: crossfade starts ~6s before track end, but when the
  track actually 'ended' fired, onAudioEnded ALSO advanced — two skips.
  onAudioEnded now bails when npXfadeActive (crossfade owns the advance).
- STRAY CROSSFADE on manual skip/stop: skipping or stopping mid-fade left the
  interval running, firing npFinishCrossfade on top of the manual change, and
  left the second <audio> playing. Added npCancelCrossfade() (clears the timer,
  tears down the 2nd audio, restores main volume) called at the top of
  playQueueItem and in handleStop. The fade interval also self-checks
  npXfadeActive each tick. npFinishCrossfade clears all flags cleanly so the
  legitimate handoff isn't treated as an abort.
- stream_start: moved 'global stream_background_task' to function top (it was
  declared inside an if-block — parsed, but brittle/bad form).

web_server parses; 76 streaming+radio tests pass; JS syntax clean; CSS balance
unchanged from HEAD.
2026-05-30 14:38:28 -07:00
BoulderBadgeDad
866f2e4a23 Now Playing: complete Media Session lock-screen controls
The Media Session API was partial — play/pause/stop/seek±10/prev/next handlers
+ metadata/artwork existed, but the OS lock-screen/Bluetooth/notification
control had a DEAD scrubber (no position, no drag-to-seek). Completed it:

- setPositionState (duration/position/rate) so the lock screen shows a live
  progress bar, pushed throttled (~1/s) from timeupdate, reset on
  loadedmetadata of a new track, and on manual seek.
- 'seekto' action handler so dragging the lock-screen/notification scrubber
  actually seeks (with fastSeek when available).

Now hardware/Bluetooth keys + the lock-screen scrubber fully drive playback
with art, metadata, and live position. Feature-detected throughout.
2026-05-30 14:23:29 -07:00
BoulderBadgeDad
843f4081cf Now Playing: click-to-seek synced lyrics
Click any synced lyric line to jump playback to that line's timestamp (and
resume if paused). Reuses the existing _npLyricsState.lines {time,text} data.
Hover affordance: accent-tinted line + pointer cursor. Synced lyrics only
(plain lyrics have no timestamps).
2026-05-30 14:17:51 -07:00
BoulderBadgeDad
a8985b317f Now Playing: fix squashed stop button + queue persistence + crafted entrance
- Stop button fix: my round .np-btn { width/height 46px; border-radius:50% }
  override was also hitting .np-btn-stop (it carries both classes), squashing
  the 'Stop' text pill into a tiny circle. Exempted .np-btn.np-btn-stop back to
  an auto-width pill.
- Queue persistence: npPersistQueue() (called from renderNpQueue, the single
  mutation hook) saves the queue to localStorage; npRestoreQueue() on init
  repopulates the panel on reload WITHOUT auto-playing (index reset to -1).
  Queue no longer vanishes on refresh.
- Crafted entrance: controls stagger-fade/rise in when the modal opens
  (npRiseIn keyframe, delays cascading util->progress->controls->volume->
  upnext). Art container excluded so its transform stays free for the
  play-scale.

Frontend-only; Boulder verifying live.
2026-05-30 14:17:07 -07:00
BoulderBadgeDad
ccfb3fb042 Now Playing: real crossfade for library tracks (experimental)
Crossfade was a no-op toggle. Real crossfade needs two tracks audible at once,
but /stream/audio only serves the ONE current track (single global
stream_state). So:

- web_server: extracted the range-serving body of /stream/audio into
  _serve_audio_file_with_range, and added /stream/library-audio?path= which
  serves an arbitrary LIBRARY file through it. Security: the path is resolved
  via _resolve_library_file_path (same validator /api/library/play uses) so it
  only serves files inside the configured transfer/download/media-library
  dirs — not arbitrary disk.
- frontend: a second hidden <audio> (#audio-player-xfade) preloads the NEXT
  library track when the current one is within 6s of ending (crossfade on,
  not repeat-one), ramps the two volumes in opposite directions, then hands
  off to playQueueItem so all normal now-playing state is set.

Honest limits (documented in code): library→library only (streamed tracks
hard-cut as before); there's a brief silent reload at hand-off because
playQueueItem re-points the single stream_state — the perceived crossfade has
already happened by then. EXPERIMENTAL — needs Boulder's live audio
verification; I can't test audio in-sandbox.

33 streaming tests still pass (stream_audio refactor is behavior-preserving).
2026-05-30 11:48:56 -07:00
BoulderBadgeDad
ffbe669c67 Now Playing: vibrant album-art color extraction + drag-to-reorder queue
Two next-level player features (frontend-only):

1. Album-art ambient color — replaced the flat pixel AVERAGE (which muddied
   every cover to grey-brown) with dominant-VIBRANT extraction: coarse
   histogram binning weighted by saturation² × population, then a punch-up
   pass (boost saturation ~1.3x, floor brightness) so the modal glow reads as
   the cover's real standout color, Apple-Music style. Feeds the existing
   --np-ambient-r/g/b hooks.

2. Drag-to-reorder queue — queue rows are now draggable; npReorderQueue moves
   the item AND recomputes npQueueIndex so the currently-playing track stays
   correctly tracked after a reorder. Accent drop-line indicator, grab cursor,
   dragging opacity.

Verified live in-browser by Boulder.
2026-05-30 11:46:31 -07:00
BoulderBadgeDad
3461d9235b Now Playing modal: full visual redesign + click-art visualizer, sleep timer, up-next
Player-revamp frontend (Phase 1). Brings the Now Playing modal to the approved
mockup look + features:

- Full restyle (override block in style.css): 28px modal radius, stronger
  art-driven ambient glow, 340px rounded art that scales while playing, bold
  28px title, accent artist name, accent FLAC pill, dominant 70px gradient
  play button, accent-gradient progress/volume/visualizer. All driven by the
  existing --accent-rgb / --accent-light-rgb so it follows the settings accent.
- Click album art -> Plexamp-style visualizer takeover, fed by the REAL
  music-synced Web Audio analyser (npStartVisualizerLoop), click again -> art.
- Rich queue rows: album thumbnail + title/artist + duration, equalizer
  animation on the now-playing row, hover-reveal remove.
- Up-next peek below the controls (shows the next queued track).
- Sleep timer (cycles 15/30/60m, real setTimeout -> handleStop).
- Crossfade toggle present (visual state + persisted pref; the dual-audio
  crossfade engine is the next step, not yet wired).

Frontend-only; verified live in-browser by Boulder. No backend/test surface.
2026-05-30 11:43:45 -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
BoulderBadgeDad
169c30fd5b UI: add .btn--sm/.btn--block/.btn--warning tier; migrate sync-history buttons
Formalize the compact 'toolbar' button tier as design-system modifiers
(.btn--sm), plus a full-width (.btn--block) and amber caution
(.btn--warning) modifier, so the many smaller per-page buttons can share
the .btn primitive without being forced to the large default size.

First adopter: the Sync page header buttons (.sync-history-btn) now use
.btn .btn--sm .btn--secondary. The class is kept as a JS/onboarding
selector hook; .auto-sync-manager-btn still tints Auto-Sync accent.
2026-05-29 11:30:13 -07:00
BoulderBadgeDad
ae0968e1b0 UI: migrate watchlist/wishlist action buttons to the shared .btn primitive
The watchlist + wishlist header/overview buttons used a bespoke
.watchlist-action-btn family (different padding/radius/font and white
primary text) instead of the shared .btn design-system primitive.
Migrate all 11 of them to .btn / .btn--primary / .btn--secondary /
.btn--danger so they match the rest of the app, and drop the now-dead
CSS.

The .watchlist-batch-remove-btn / .wishlist-batch-remove-btn hook
classes are kept on the remove buttons (their !important red overrides
compose correctly over .btn--secondary). Static HTML only; no JS-injected
usages, and mobile.css overrides target .playlist-modal-btn, not these.
2026-05-29 11:18:19 -07:00
BoulderBadgeDad
21426af7fe Tools: add Deep Scan option to the Database Updater
The Tools-page Database Updater dropdown only offered Incremental and
Full Refresh, even though the backend (/api/database/update with
deep_scan) and the dashboard Deep Scan button already supported a deep
scan. Wire the missing option into the Tools UI:

- Add a "Deep Scan" option to the #db-refresh-type dropdown.
- handleDbUpdateButtonClick now sends { deep_scan: true } for that
  option (deep scan takes precedence server-side) and confirms first,
  since deep scan removes stale entries — mirroring the dashboard flow.

Frontend-only; the progress/status handler already drives the bar from
the backend phase ("Deep scan: ...") and the help/docs copy already
described all three modes.
2026-05-29 10:47:02 -07:00
BoulderBadgeDad
d9a24d48c6 Fix: search results disappear when interacting with the media player (#732)
Search results live in an overlay dismissed by an outside-click handler
whose allow-list omitted the floating media player. Clicking the mini
player to open the now-playing modal (or clicking inside that modal)
registered as an outside click and tore the results down, forcing a
re-search.

Add the media player containers (#media-player mini bar and
#np-modal-overlay expanded modal) to the dismiss allow-lists in both the
Search page (search.js) and the global search widget (downloads.js),
which share the same outside-click pattern. Additive change: only adds
exceptions, so every existing dismiss case is unchanged.
2026-05-29 10:18:38 -07:00
BoulderBadgeDad
6129ea8508 UI consistency: normalize exception-page outer gutter to .page (40px)
Low-risk tidy-up for the full-bleed "exception" pages that aren't carded.
Every page already gets a 40px gutter from .page, but the exception pages were
piling on inconsistent extra padding (library +20px, active-downloads +28/32px,
discover/docs +0) — giving accidental 60 / 68-72 / 40 effective gutters.

Drop the redundant container padding on library and active-downloads so the
single .page 40px gutter is the shared, intentional outer spacing across the
full-width exception pages. discover (centered max-width) and docs (sidebar
layout) keep their functional layout; library's mobile padding override is
unaffected.
2026-05-29 08:53:03 -07:00
BoulderBadgeDad
079c169f8d UI consistency (tabs/cards): add .tab and .card primitives (no migration)
Add the canonical .tab (bordered rounded-pill filter tab) and .card ("glass"
content card) primitives as the documented design-system standard for new
markup and the React pages — modeled on the cleanest existing looks
(watchlist filter pill; dashboard service/stat card).

Deliberately NOT migrating the existing tab/card components onto them: the
current implementations are visually divergent and JS-coupled (active-state
toggled by class name, cards built in JS), so a blind consolidation risks
subtle regressions. These primitives let new/React code be consistent now;
the legacy components migrate when visually verifiable / in React.

Unused classes -> zero visual change to the current UI.
2026-05-29 08:28:18 -07:00
BoulderBadgeDad
dd5fe844d4 UI consistency (buttons 2/N): wishlist modal buttons -> .btn
Migrate the wishlist add-to-wishlist modal buttons onto the shared .btn
primitive: primary -> .btn--primary, secondary -> .btn--secondary, the green
download CTA -> new .btn--download modifier. Added a shared .btn.loading state
(amber pulse, reusing the existing pulse-loading keyframe) since
confirm-add-to-wishlist-btn toggles `loading` via JS (wishlist-tools.js).

Removed the dead .wishlist-modal-btn* rules and re-scoped the mobile
full-width override to `.wishlist-modal-actions .btn`.
2026-05-28 23:48:23 -07:00
BoulderBadgeDad
eebc58d3ff UI consistency (buttons 1/N): add shared .btn primitive; migrate config-modal
Start of the button-consolidation pass (kettui's #1). The app had ~236 button
classes / ~8-10 distinct looks with heavy near-duplication.

Introduce a canonical .btn design-system primitive (base + .btn--primary /
.btn--secondary / .btn--danger), modeled on the dominant existing look
(accent-gradient primary, translucent ghost, semantic danger) and built on the
accent CSS vars. New markup and the React pages should use this; existing
per-page button classes will migrate onto it family by family.

First family migrated: the config/settings modal buttons (.config-modal-btn*,
4 static uses, no JS refs) -> .btn .btn--primary / .btn--secondary. Removed the
now-dead .config-modal-btn* rules and re-scoped its mobile full-width override
to `.config-modal-actions .btn`.

Visible change is minor by design (padding 28->24px, gradient direction
normalized). Proof step for sign-off on the .btn look before rolling wider.
2026-05-28 23:40:10 -07:00
BoulderBadgeDad
45bbc99d94 UI consistency (page shell 3/N): playlist-explorer adopts .page-shell card
Convert the playlist-explorer page from a flat padded container to the
.page-shell floating card. Drop its bespoke `padding: 24px 32px`; keep the
full-height flex layout (display:flex / column / min-height:100%) since the
explorer fills the viewport.

Visible change by design. Watch: the full-height min-height:100% inside a
margin:20px card may run slightly tall — to be confirmed visually.
2026-05-28 22:57:09 -07:00
BoulderBadgeDad
def58a9907 UI consistency (page shell 2/N): automations adopts .page-shell card
First of the "flat -> card" conversions. The automations list view sat
directly on the page background (.automations-container = bare padding) while
its inner .dashboard-header is the same header dashboard uses. Adopt
.page-shell so the page becomes a floating gradient card structurally
identical to the dashboard (page-shell card > dashboard-header > content).

- Drop .automations-container's bespoke `padding: 20px 24px` (card padding now
  from .page-shell); keep the class as the mobile/JS hook.
- Add `page-shell` to the container in markup.

Visible change by design (this page was not previously a card). Mobile keeps
its existing .automations-container padding override.
2026-05-28 22:54:31 -07:00
BoulderBadgeDad
d2a730a6aa UI consistency (page shell 1/2): extract shared .page-shell primitive
First step of the page-layout-shell standardization (kettui's UI-consistency
point #1). The dashboard, tools, watchlist and wishlist pages each defined a
byte-identical "card" container (padding 28px 24px 30px, margin 20px, gradient
bg, radius 24px, border + border-top, layered shadow) under four different
class names.

Extract that into a single `.page-shell` primitive (modeled on the canonical
dashboard/stats look) and have the four pages adopt it. Each keeps its bespoke
class for page-specific extras and as a JS/mobile hook:
- dashboard-container: keeps display:flex / column / gap:25px
- watchlist/wishlist-page-container: keep position:relative
- tools-page-container: no extras (box now fully from .page-shell)

Zero visual change: computed styles are identical (declarations relocated, not
altered), and mobile.css overrides still target the retained bespoke classes.
Per-page themed headers (watchlist amber, etc.) are intentionally NOT touched.

The class name is intended for reuse by the React pages too, so the primitive
is shared across both stacks.

Next (wave 2): migrate settings / automations / playlist-explorer / library
onto .page-shell, which snaps their slightly-off spacing to canonical.
2026-05-28 22:38:55 -07:00
BoulderBadgeDad
ff974c0b5c Standardize artist-detail hero action buttons
The four artist-hero buttons (Artist Radio, Watchlist, Download
Discography, Enhance Quality) had drifted apart visually — different
sizes, weights, radii, hover treatments, and ad-hoc colors. Unify them
on the Download Discography look (the nicest of the set): accent-style
gradient fill, matching border, light-tinted text, compact 7x16 / 12px /
700 sizing, and a translateY(-1px) + colored glow on hover.

To keep them distinguishable, each carries its own hue within that
shared recipe:
- Artist Radio       -> violet (#8b5cf6)
- Watchlist          -> theme accent (keeps its amber "watching" state)
- Download Discography -> theme accent + shimmer (the primary action)
- Enhance Quality    -> cyan (#4fc3f7, its original signature color)

Also:
- Drop the shimmer from the three secondary buttons — four simultaneous
  shimmers were distracting; it now marks only the primary action.
- Remove the `margin: 12px 0 4px` on `.discog-download-wrap` (now
  `margin: 0; display: inline-flex`) that pushed the discography button
  ~4px below its siblings in the centered flex row.
- Include Artist Radio in the mobile button sizing override (was missing).
2026-05-28 12:35:02 -07:00
Broque Thomas
7145368d42 Basic search: visual overhaul + per-source picker in hybrid mode
Two things in this commit. Functional download / matched-download
behaviour is untouched — same JS handlers, same routes for the
download actions, same album-expand interaction.

VISUAL REDESIGN
- Glass search-bar card with accent radial wash + focus ring + pill
  primary search button
- Source chip row above the search bar (see below)
- Always-visible compact filter pill row (Type / Format / Sort) —
  pills carry both ``bs-filter-pill`` (new visual) and ``filter-btn``
  (legacy class for ``resetFilters`` + ``applyFiltersAndSort`` in
  wishlist-tools.js to keep working)
- Accent-tinted status pill matching the dashboard / auto-sync look
- Album result cards: glass card with accent left-edge stripe,
  52px brand-tinted cover icon, chevron expand indicator, pill
  action buttons (Download / Matched Album), accent glow on hover
- Track result cards: glass row with accent stripe, 44px icon,
  pill action buttons (Stream / Download / Matched Download)
- Multi-disc separators inside expanded album track lists styled
  with the accent treatment
- Responsive: action button columns stack vertically below 900px

New CSS lives in a self-contained ``webui/static/basic-search-v2.css``
sheet linked from index.html. Selectors are scoped to
``#basic-search-section`` for any class that already exists in
style.css (``.album-result-card``, ``.album-icon``, ``.track-*``,
etc.); the new ``bs-*`` prefixed classes for the search bar /
filters / source row / status are unscoped because they only exist
in the new markup. ``!important`` is used on the card-level rules
to defeat the original unscoped ``.album-result-card`` etc. rules
in style.css that would otherwise leak heavyweight padding /
box-shadow / 56px icon styles into the new design.

Also removed ``overflow: hidden`` from the original
``.album-result-card`` and ``.track-result-card`` rules in style.css
— those two classes only render in ``downloads.js`` basic search
results (verified via grep, two render sites only), so the
removal can't impact any other UI.

SOURCE PICKER (hybrid mode)
- New ``GET /api/search/sources`` endpoint returns the list of
  active sources from the orchestrator's chain (or the single
  active source in single-source mode).
- Frontend renders a chip row above the search bar. Click a chip
  to target that source for the next search; the chip's brand
  accent fills.
- In single-source mode the lone chip is rendered as a dashed-
  border label so the user always knows what they're searching
  but can't accidentally try to switch to sources that aren't
  configured.
- ``/api/search`` accepts an optional ``source`` body param. When
  set, ``core/search/basic.py:run_basic_search`` resolves the
  client directly via ``orchestrator.client(source)`` and calls
  its ``.search()`` instead of going through the hybrid chain.
- Backwards compatible: omitting ``source`` falls through to the
  original ``orchestrator.search()`` call exactly as before.
  Unknown source names also fall back to the default — typo
  protection.

TESTS (5 new + 6 pre-existing = 11 total in test_search_basic.py)
- source param routes to specific client, NOT orchestrator chain
- no source param preserves original orchestrator-default behaviour
- unknown source name falls back to orchestrator default
- ``run_basic_soulseek_search`` backwards-compat alias preserved
- source-targeted path serialises albums + tracks correctly

101 search-suite tests pass.
2026-05-28 10:22:07 -07:00
Broque Thomas
258905ff5c Fix: duplicate tracks in albums with Japanese / CJK titles (#722)
Reporter @Sokhii: downloading the Mushoku Tensei Original
Soundtrack II via Apple Music metadata + Tidal download
produced duplicate library entries — same audio file landed
under multiple track positions in the album view.

Root cause (verified by direct probe + isolated repro):
``MusicMatchingEngine.normalize_string`` correctly skipped
unidecode for CJK text (kanji→pinyin would have produced
gibberish — see the inline comment at line 74-76), but then
ran ``re.sub(r'[^a-z0-9\s$]', '', text)`` which stripped EVERY
CJK character. Every Japanese title normalised to ``''``.
``similarity_score`` has an early-out guard
    if not str1 or not str2: return 0.0
so EVERY CJK-vs-CJK title comparison returned 0.000.

Downstream effect: the matcher fell back to duration+artist
alone. For an OST album with 24 tracks all by the same artist
with similar durations, multiple iTunes track queries landed
on the SAME Tidal candidate. SoulSync wrote each download to
a different output filename (per the iTunes track position),
so on disk there were N copies of the same audio under
different track numbers. The user's library showed 34 entries
for an album with 24 actual tracks.

Probed iTunes album 1753240110 directly — 24 distinct tracks,
zero (disc, track_number) collisions, both US + JP storefronts.
So the duplicate origin was definitely downstream of metadata
fetch.

Fix: when CJK is detected upstream, the alphanumeric-strip step
also preserves CJK Unified Ideographs + radicals
(⺀-鿿), Hiragana + Katakana (぀-ヿ), Halfwidth
/ Fullwidth forms (＀-￯), and Hangul syllables
(가-힯). CJK titles now produce a comparable normalised
form instead of an empty string. ``similarity_score`` works as
intended:

  '命の灯火' vs '命の灯火' → 1.000  (was 0.000)
  '命の灯火' vs '無職転生' → 0.000  (was 0.000, but now from
                                       actual char comparison
                                       not from the empty-string
                                       guard)

Latin-only normalisation is completely unchanged. ``has_cjk``
is False for Latin input, so both the CJK-lowercase branch AND
the new CJK-preserve strip branch are skipped — Latin titles
go through the original unidecode + lowercase + strip path
verbatim. Tested via 4 regression tests that pin the Latin
baseline (simple, unidecode target, $-preservation, identical
+ different similarity scores).

16 new unit tests in ``tests/test_matching_engine_cjk.py``:
- Kanji / Hiragana / Katakana / Hangul / Chinese all survive
- CJK-only strip still removes Latin punctuation in the
  CJK branch
- Mixed Latin + CJK lowercases the Latin half
- Identical CJK titles → 1.0
- Disjoint CJK titles → near 0
- Partially overlapping CJK titles → midrange
- CJK doesn't falsely match unrelated Latin
- 4 Latin-baseline regression pins
- Real-world Mushoku Tensei OST scenario

371 text + imports + new CJK tests pass after the fix.
2026-05-28 08:53:43 -07:00
Broque Thomas
6d54203710 Bump version to 2.6.4
- _SOULSYNC_BASE_VERSION → 2.6.4
- helper.js: '2.6.4' unreleased → 'May 28, 2026 — 2.6.4 release'
- .github/workflows/docker-publish.yml default version_tag → 2.6.4
- pr_description.md: rewrite for 2.6.4 with #721 as the headline patch,
  2.6.3 fixes carried forward unchanged (2.6.3 was bumped on dev but
  never reached main / docker, so 2.6.4 is the first release to ship
  this batch)
2026-05-28 08:17:43 -07:00
Broque Thomas
df675c7c9f Fix: Usenet bundle stuck on "downloading release" when SAB History flips before storage lands (#721)
Follow-up to the 2.6.3 queue→history handoff fix (#706). User
@IamGroot60 reported in #721 that on 2.6.3 the bundle still gets
stuck mid-flight: SoulSync UI sits on "Usenet downloading release
61%" forever, SAB History shows the job as Completed 2+ minutes
ago, files are physically present in the slskd downloads folder
but never copied into ``storage/album_bundle_staging/<batch>/``.

Root cause: a second-stage gap in the SAB pipeline. SAB flips a
job's ``status`` to ``Completed`` in History as soon as par2 +
unrar finish, but its post-processing pipeline writes the final
``storage`` field a few seconds LATER (the move-to-final step).
``poll_album_download`` saw the first ``Completed`` read with
``save_path=None`` and bailed:

  if status.state in complete_states:
      return last_save_path  # ← None at this point

``download_album_to_staging`` got ``save_path=None``, set
``result['error']`` and returned. The bundle was marked failed but
the LAST progress emit before the failure was ``downloading
progress=0.61``, so the UI froze on "61%" — the terminal ``failed``
emit never registered on the user's screen because the renderer
holds the last-known progress.

Fix
- ``poll_album_download`` now tracks a separate transient counter
  for "complete state seen, save_path not yet set." Up to
  ``transient_miss_threshold`` (default 5) consecutive reads in
  that state are tolerated before the poll bails. SAB writes the
  ``storage`` field within 2-10 seconds of the History flip in
  practice — the default 5 × 2s = 10s window covers it.
- When save_path eventually lands, return it normally.
- When the threshold is exhausted with save_path still empty,
  emit terminal ``failed`` with an explicit message pointing at
  the missing save_path field — no more 6-hour silent spin.
- Earlier ``downloading`` reads with a non-empty ``save_path``
  (qBit / Transmission set this from the start of the download)
  remain "sticky" — if the eventual ``completed`` read has empty
  save_path, the cached one applies. So torrent flows aren't
  affected by the retry path.

SAB adapter (``_parse_history_slot``)
- Widened the save_path field fallback chain:
    storage → path → download_path → dirname → incomplete_path
  Covers SAB version differences (older builds populated ``path``)
  and forks that expose ``download_path`` or ``dirname``.
  ``incomplete_path`` is the last resort — SAB's in-progress dir
  before the final move — so the bundle plugin at least has a
  path to scan when nothing else lands.
- Whitespace-only values are skipped.
- Loud debug log when none of the known fields land — users on
  SAB versions / forks with novel field names need to see this in
  logs so we can grow ``_HISTORY_SAVE_PATH_KEYS``.

Tests
- ``test_album_bundle.py`` (3 new):
  - tolerates_completed_with_late_save_path_arrival — the #721
    scenario; first Completed read has no save_path, third has
    it; poll returns the path normally
  - gives_up_when_completed_with_no_save_path_persists — past
    the threshold the poll fails loudly instead of silent-spinning
  - uses_save_path_from_earlier_downloading_emit_if_completed_lacks_one
    — sticky save_path keeps torrent flows working
- ``test_usenet_client_adapters.py`` (6 new):
  - falls back to ``path`` when ``storage`` empty
  - falls back to ``download_path``
  - prefers ``storage`` when multiple fields present
  - returns ``None`` when all fields empty (the #721 gap window)
  - ignores whitespace-only values
  - uses ``incomplete_path`` as last resort

132 album-bundle + usenet tests pass.

Branch is on dev parented at 2.6.3 — user @IamGroot60 offered
to test on dev, so this is a candidate cherry-pick for either
a 2.6.4 hotfix or merge straight into dev for the next release.
2026-05-28 08:01:52 -07:00
Broque Thomas
a9608e1bcb Bump version to 2.6.3
- _SOULSYNC_BASE_VERSION → 2.6.3
- helper.js WHATS_NEW unreleased flag → 'May 27, 2026 — 2.6.3 release'

Note: .github/workflows/docker-publish.yml default version_tag was
also bumped to 2.6.3 locally, but .github is gitignored in this
repo — workflow updates need to land via the GitHub UI separately.
The workflow_dispatch input is overrideable at trigger time
regardless of the default, so this isn't blocking.
2026-05-27 22:26:16 -07:00