Commit graph

1741 commits

Author SHA1 Message Date
BoulderBadgeDad
b105372d70 Fix #792: sync UI shadowed the new sync-mode setting (forced 'replace')
The reconcile setting never took effect: startPlaylistSync always sent
sync_mode (defaulting to 'replace' from the per-playlist <select>) AND clamped
any non-replace/append value back to 'replace' — so 'reconcile' could never be
sent and the global Settings value was always overridden. The per-server Plex
reconcile code was never even reached; replace ran and re-pushed the poster.

- Per-playlist select now defaults to 'Sync mode: default' (empty) which defers
  to Settings > Playlist sync mode, and gains a 'Reconcile' option for an
  explicit per-sync override.
- startPlaylistSync sends '' (not 'replace') when no explicit choice, so the
  backend uses the configured default; clamp now allows reconcile.
  (Other callers already sent no sync_mode, so they pick up the setting too.)
2026-06-04 15:27:00 -07:00
BoulderBadgeDad
939c660498 Fix #792: 'reconcile' playlist sync mode (edit in place, keep image/description)
Replace mode (default) deletes + recreates the server playlist every sync,
which wipes its custom image, description, and identity. Add an opt-in
'reconcile' sync mode that edits the existing playlist in place — adds the
tracks now in the source, removes the ones gone — without destroying the
object, so the user's custom art/description survive.

- Pure planner plan_playlist_reconcile(current, desired) -> {add, remove}.
- Per-client reconcile_playlist: Plex addItems/removeItems on the same object;
  Navidrome Subsonic updatePlaylist delta (songIdToAdd / descending
  songIndexToRemove); Jellyfin add + remove-by-PlaylistItemId on /Playlists/{id}/Items.
- sync_service: reconcile branch with a replace FALLBACK (if a server's in-place
  edit is unavailable/fails, sync still succeeds destructively — logged loudly).
- Default stays 'replace' (no behavior change). New Settings > Playlist sync mode
  picker (replace/reconcile/append) backed by playlist_sync.mode; per-request
  sync_mode still overrides.
- Reconcile skips the post-sync source-image push so a custom poster isn't
  re-clobbered (the bug).

Tests: planner (add/remove/dedupe/order/empty) + reconcile-or-replace dispatch
(success / false-fallback / exception-fallback / no-method). Per-server in-place
API calls need dev validation against real Plex/Jellyfin/Navidrome.

NOTE: opt-in only; default behavior unchanged.
2026-06-04 15:15:49 -07:00
BoulderBadgeDad
3fe635fcd6 no-sound fix: resume context on the 'play' event (covers all play paths)
Harden the previous fix: setPlayingState(true) misses resume/play calls that
bypass it (lines that just do 'if paused, play()'). Move the resume onto the
audio element's 'play' event, which fires on every playback start regardless
of code path. Keep the resume in npInitVisualizer for the first-play case
(context is created suspended after the 'play' event already fired). Drop the
now-redundant setPlayingState hook.
2026-06-04 14:36:06 -07:00
BoulderBadgeDad
5cb65ab00e Fix: streamed tracks play with no sound (suspended Web Audio context)
The visualizer calls createMediaElementSource(audioPlayer), which permanently
reroutes the shared <audio> element's output through npAudioContext. That
context is created from an async play().then() callback (outside the user
gesture), so browsers start it 'suspended' under the autoplay policy — and the
only resume() lived in the visualizer loop, which runs when the Now Playing
modal opens, NOT on play. Result: the element advances (looks like it's
playing) but its audio drains into a suspended context = no sound, everywhere.

Add npEnsureAudioContextRunning() and call it on every play start
(setPlayingState(true)) plus right after the context is created in
npInitVisualizer. Resuming an already-running/absent context is a safe no-op.
2026-06-04 14:29:13 -07:00
BoulderBadgeDad
3b155411c2 Fix #787: Find & Add now records a durable manual match that survives a rescan
Find & Add on the playlist-sync page only wrote sync_match_cache, which is
DELETEd wholesale after every DB scan — so the source->library pairing (and
the user's manual matches) reverted to 'extra'/red-dot on the next shallow
scan. The three match stores (sync_match_cache, manual_library_track_matches,
discovery extra_data) were disconnected and all pointed at tracks.id, which a
rescan re-keys (esp. Jellyfin/Navidrome GUIDs).

Unify the match so it's one durable fact, recorded once, honored everywhere:
- Find & Add also writes a durable manual_library_track_matches row (one-way;
  the manual-match tool has no playlist to act on, so no reverse). Carries the
  library file path.
- New library_file_path column (idempotent migration) + find_track_id_by_file_path:
  re-resolve a stale library_track_id after a rescan re-keys the track, and
  self-heal the row.
- The sync compare display's override lookup now falls back to the durable
  manual match (resolve_durable_match_server_id) when sync_match_cache misses —
  so the pairing persists across a scan instead of reverting to a red dot.
  Purely additive: only adds matches when the cache returns nothing.

Tests: durable resolver (valid / stale-reresolve+self-heal / no-match / not-in-
playlist / missing-methods), file_path persistence + find_track_id_by_file_path.
2026-06-04 13:46:24 -07:00
dev
0fe0a4c45b perf(webui): make the UI usable with password-manager extensions
Password managers (Bitwarden/1Password/LastPass) treat this app's many API-key/
token/secret fields as login forms and re-scan the whole, constantly-mutating DOM
on every change — pegging the main thread for seconds and making hover/click/
scroll feel laggy. Two mitigations (measured to make the app usable with the
extension enabled):

- Tag all inputs with data-bwignore / data-1p-ignore / data-lpignore so the
  managers skip them (no autofill detection work).
- Rate-monitor equalizer: skip DOM writes while it's off-screen (offsetParent
  null). All pages stay mounted, so updating the hidden grid still triggered the
  managers' MutationObserver on every backend rate-monitor event for no benefit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:53:10 +02:00
dev
89fe7703fa perf(webui): flatten scroll-container background + scope explorer wheel listener
Two measured, universally-beneficial fixes (kept after determining the rest of
the earlier perf work was chasing a Bitwarden extension that pegged the main
thread, not real app bugs):

- .main-content had a linear-gradient background. A gradient on the scroll
  container is re-rastered across the whole scrolled area every scroll frame
  (the compositor can't translate a cached tile): ~25% dropped frames -> <1%
  once flattened to a solid color (visually identical, was rgb 10->15->11).

- The explorer wheel-zoom listener was a non-passive listener on `document`,
  which disables compositor (async) scrolling app-wide so every wheel/trackpad
  scroll runs through the main thread. Scoped it to the explorer viewport.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 20:35:21 +02:00
BoulderBadgeDad
adbdda7b0e Library Re-tag: add light/full depth setting, default source to active, fix dropdown CSS
- depth setting (light = core tags + matched source ids; full = same
  multi-source enrichment cascade a fresh download gets, run additively
  via embed_source_ids). Threaded through scan/finding/auto-apply and the
  repair_worker fix handler.
- source now defaults to 'auto' (= your source priority / active source)
  instead of blank.
- give native <option> popups a solid dark background (were white-on-white).
- tests for full-depth full_meta payload + enrich invocation + light no-op.
2026-06-04 10:21:30 -07:00
BoulderBadgeDad
2328e159a1 Tools page: fix section nesting + move Metadata Updater into Metadata & Cache
Two fixes:
- The retag-tool-card removal accidentally ate the </div></div> that closed the
  Metadata & Cache grid + section, so the Management section nested inside it.
  Restored the close — Management is a sibling section again. (div balance back
  to 1998/1998.)
- Moved the Metadata Updater card from 'Database & Scanning' into 'Metadata &
  Cache' where it belongs.
2026-06-04 09:51:00 -07:00
BoulderBadgeDad
d91e6a384d Remove the old Retag Tool (superseded by Library Re-tag job + Write Tags)
The old per-download Retag Tool was limited (only native-pipeline downloads,
100-group cap, manual per-group) and did the wrong thing — it moved/reorganized
files instead of just tagging. It's superseded by the new Library Re-tag job
(whole-library, in-place) + the enhanced-library 'Write Tags' button.

Removed: the post-download record_retag_download ingestion hook (stops writing
retag_groups on every download), core/library/retag.py, the web_server state +
deps + /api/retag/* endpoints + the tool:retag WebSocket emit, the dashboard
card + both modals (index.html), the core.js socket handler, and the tools-page
wiring + help entry (wishlist-tools.js). Updated the import-pipeline test.

Verified: web_server parses, app + core imports OK, 392 tests pass, no live
references to removed symbols.

Left as inert (harmless) for a careful follow-up sweep: the retag_groups/
retag_tracks tables + their DB CRUD methods (no longer written/read), and the
now-orphaned retag JS helper functions (no entry point/wiring/socket calls them;
interspersed with wishlist functions, so not blind-deleted).
2026-06-04 09:33:03 -07:00
BoulderBadgeDad
50b6876d6b Library re-tag: render the per-track old->new diff in the finding card
Wire library_retag into the repair findings UI: a 'Re-tag' type badge, an
'Apply Tags' fix button, and an expandable detail that shows, per track, every
tag that would change as old -> new (plus source/mode/cover-action summary and
any unmatched tracks). So the dry-run finding is actually reviewable before you
apply it — the rich details_json the job stores now surfaces in the card.
2026-06-04 09:01:29 -07:00
BoulderBadgeDad
83e3f6b660 2.6.6: add curated highlights to the version modal (VERSION_MODAL_SECTIONS)
The version-button modal renders from VERSION_MODAL_SECTIONS (the curated
highlight reel), separate from the WHATS_NEW detailed log. Its top entries were
stale (2.5/2.6.0 era), so promote the 2.6.6 highlights to the top per the file's
release process: Artist Map v2, self-explaining recommendations, the cover-art
filler file-embedding, and a Recent Fixes & Performance roundup (qBittorrent
5.2.0, organize-by-playlist #780, nav/scroll perf #783, dashboard mobile).
2026-06-03 22:33:07 -07:00
BoulderBadgeDad
febefc8324 Release 2.6.6: version bump + What's New
- Bump _SOULSYNC_BASE_VERSION 2.6.5 -> 2.6.6 (the single source of truth that
  propagates to the UI, backups, and the update check).
- Add the 2.6.6 What's New block (qBittorrent 5.2.0 login fix, Cover Art Filler
  on-disk detection + file embedding + stricter matching, recommendations
  explainability + Discover section, organize-by-playlist #780, nav/scroll perf
  #783, dashboard mobile polish).
- Finalize the 2.6.5 block: it shipped in tag 2.6.5 but was left flagged
  unreleased (so its notes never displayed) — stripped the flag + dated it per
  the file's own release convention.
2026-06-03 22:28:06 -07:00
BoulderBadgeDad
b4ddb66d0b Dashboard: harden rate-monitor (enrichment) equalizer responsiveness
The #rate-monitor-section equalizer had breakpoints but two narrow-bar gaps:
- The status pill ("Not configured", "Yielding") is wider than a thin
  equalizer column and spilled over neighbours — now capped to the bar width
  with the label truncating via ellipsis.
- Wrapped rows were left-aligned (orphan bar stranded) with no vertical gap —
  now centered with a row-gap so multi-row layouts read intentionally.
Plus smaller value/name fonts at <=480px so tiny bars stay legible.
2026-06-03 21:20:23 -07:00
BoulderBadgeDad
8b585b9b02 PR #783 follow-up: revert Socket.IO to polling-first transport
PR #783 reordered transports to websocket-first for faster connects. Reverting
to the polling-first default: it's the most compatible behind reverse proxies
that don't forward WebSocket upgrade headers (common self-hosted setups), where
websocket-first silently breaks real-time updates. The connect-time gain isn't
worth the connectivity risk. Everything else from #783 (scroll-pause, content-
visibility, dashboard parallelization, settings fixes, reduce-effects) kept.
2026-06-03 21:07:40 -07:00
BoulderBadgeDad
4c65f403e1
Merge pull request #783 from nick2000713/perf/webui-navigation-scroll
perf(webui): faster navigation, smoother scroll, fix spurious settings save on load
2026-06-03 21:06:33 -07:00
BoulderBadgeDad
c0c4528a28 PR #780 follow-ups: snapshot-based stale check + submit guard + dead code
- Stale-cache check (playlistTrackCacheIsStale) compared raw track_count to the
  filtered/cached track list, so any playlist with local or unavailable tracks
  always looked 'stale' and refetched + re-mirrored on every modal open. Now it
  compares the upstream snapshot_id (stored at cache time in the shared fetch
  choke point), and returns not-stale when no snapshot is available — explicit
  invalidation on refresh still handles real changes.
- organize_download: guard executor.submit so a refused job cleans up the batch
  instead of stranding it in 'analysis' (holding a limited analysis slot).
- Removed the dead, deprecated, unused mirrorSpotifyPlaylistTracks.
2026-06-03 20:45:17 -07:00
BoulderBadgeDad
0353d365d6
Merge pull request #780 from kekkokk/feature/organize-by-playlist-library
Fix organize-by-playlist: library registration, wishlist after failed downloads, and stale playlist cache
2026-06-03 20:33:18 -07:00
BoulderBadgeDad
0c40dc2d3d Worker orbs: make the nucleus health state gradual, kill the flicker
The error/health state was jarring: a red ring flickering at sin(time*12) plus
stress speeding up the heartbeat, which read as the whole glow flickering. Now
it's all gradual: stress no longer changes the heartbeat speed, the red tint is
softened (never full alarm-red) and eases in/out via a small accumulating
errorHeat bump + smooth decay, and the warning ring is a single soft ring that
breathes slowly (sin*1.4) at low alpha instead of strobing.
2026-06-03 19:14:22 -07:00
BoulderBadgeDad
7706e1b16b Dashboard: make the enrichment services cluster mobile-responsive
On mobile, worker-orbs is disabled so the enrichment buttons render as real
buttons. They were a ragged centered flex-wrap with the wide 'Manage Workers'
pill jammed inline. Now (<=768px, scoped to #dashboard-page so Settings etc.
are untouched): the 44px icon buttons spread evenly across the full width in an
auto-fit grid, and the Manage Workers pill gets its own full-width row.
2026-06-03 19:10:31 -07:00
BoulderBadgeDad
8723f9c433 Worker orbs: tone down the nucleus pulse — subtle, not throbbing
The expanding heartbeat ring read as a heavy circular pulse. Now: the nucleus
barely breathes (size oscillation cut ~70%), the glow holds steady instead of
pulsing, the logo no longer visibly throbs, and the heartbeat ring is a single
very-faint halo that only appears when workers are actually busy. The red
error-warning ring is unchanged (still punchy, since it only fires on real
failures).
2026-06-03 18:57:26 -07:00
BoulderBadgeDad
0a5cda5189 Discover: promote recommendations to a first-class section + show the 'why'
- New 'Recommended For You' carousel section on the Discover page (between the
  hero and Your Artists), so recommendations aren't buried behind a hero modal
  button. Reuses the recommended-card markup/CSS, the watchlist add handler, and
  primes the modal cache so 'View All' opens instantly in sync.
- Re-frames the now-stale copy: recommendations are library-wide (the similar-
  artists worker feeds the whole library), not watchlist-only.
- Shows the real explanation from the backend's 'because' field —
  'Because you have X & Y' (with a full-list hover tooltip) instead of just a
  count — in both the section cards, the modal cards, and the hero subtitle.
- Cards lazy-enrich their images via the same endpoint the modal uses.
2026-06-03 18:28:19 -07:00
BoulderBadgeDad
48b8247a0c Worker orbs: nucleus reflects worker health, not just activity
The hub now reads as a health gauge on top of the activity gauge. A new
decaying errorHeat (0..1) is bumped by onStatus whenever a worker reports a
real error increment, and cools over ~6s. While stressed the nucleus blends
toward red, its heartbeat quickens (agitation), and a fast-flickering red
warning ring appears — so a glance distinguishes 'busy and healthy' from
'something's actually failing'. Since 404s are classified as not_found now,
this only lights up on genuine failures (timeouts, 5xx).
2026-06-03 18:20:46 -07:00
BoulderBadgeDad
5149aca358 Worker orbs: drip real pulses steadily instead of bursting on each status tick
Status pushes land every ~2s, so the previous fixed 'drain 2/frame' fired a
whole window's worth of pulses in a fraction of a second then went quiet.
Now each orb sets a release rate when a status arrives (pending / ~2s, with
a floor so a lone event still shows within ~0.75s) and the loop drips pulses
out via a fractional accumulator — so a busy worker streams a steady line up
its spoke and a slow one sends the occasional single pulse.
2026-06-03 17:49:43 -07:00
BoulderBadgeDad
9a5c85a424 Worker orbs: drive hub pulses from real enrichment telemetry
The inbound pulses are now event-driven instead of a random trickle:
- core.js forwards every enrichment:<id> WebSocket status to a new
  window.workerOrbs.onStatus hook (extra listener, UI handlers untouched).
- onStatus diffs the cumulative stats counters (matched/not_found/repaired/
  synced/scanned, and errors) between pushes and queues one pulse per real
  item processed (worker's brand colour) or error (red). First sample only
  sets a baseline so we never dump the whole backlog at once.
- tick() drains a couple of queued pulses per frame so bursts stagger up
  the spoke; cap of 8 queued per update prevents flooding on big jumps.
- Falls back to the old ambient trickle for any orb that hasn't received a
  status yet, so nothing goes dead if the socket is quiet.

Bonus perf: an idle/slow worker now emits almost nothing instead of a
constant random stream of particles.
2026-06-03 17:40:52 -07:00
dev
05c15833fa perf(webui): faster navigation, smoother scroll, no spurious settings save
Navigation & sidebar feedback:
- Show legacy pages optimistically on pointerdown + CSS :active so the
  sidebar reacts instantly instead of waiting for the click/router cycle.
- Defer heavy per-page init via requestIdleCallback so a page becomes
  scrollable before its init work runs.

Scroll smoothness:
- Cache particle canvas dimensions (no forced reflow per navigation).
- Pause particle + worker-orb canvas redraws during active scroll so the
  scroll gets the full frame budget.
- content-visibility:auto on discover shelves and search/wishlist/library
  list items to skip off-screen layout.

Dashboard:
- Run the independent initial loads in parallel (Promise.all) instead of
  six sequential awaits, collapsing the reflow cascade.

Settings:
- Wire input listeners once instead of rescanning the ~960-node subtree
  on every visit.
- Suppress auto-save while the form is programmatically populated on load,
  fixing a spurious full save (4 POSTs + backend service re-init) that
  fired on every Settings visit.

Reduce Visual Effects = full performance mode:
- Also halts particles, worker orbs and all filters; hides the static
  sidebar aura circles that looked broken without their blur/animation.

Global search bar hidden on settings/help/issues/import pages.
2026-06-04 02:40:27 +02:00
BoulderBadgeDad
634b9ca0a0 Worker orbs: cache glow sprites for perf + SoulSync logo in enrichment modal topbar
Performance:
- Bake one soft glow sprite per colour into an offscreen canvas and blit
  it with drawImage instead of allocating a radial gradient every frame.
  This was the hot path: sparks + inbound pulses + every orb glow each
  built a gradient per frame (100+/frame at 60fps). Colours quantised to
  8-step buckets to bound the cache (imperceptible tint shift, keeps the
  rainbow path from minting a sprite every frame).
- Cache each orb's button element at init so the 30-frame active-state
  check no longer re-runs querySelector.
- Net: the pulses/glows look identical, far fewer allocations per frame.

UI:
- Enrichment manager modal topbar icon now uses the SoulSync logo
  (trans2.png) instead of the helix emoji, matching the dashboard button.
2026-06-03 17:30:54 -07:00
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
7956aaac9e Fix #772: manual import progress bar stuck at 0 / 'Failed' on slow imports
Per-track import does heavy synchronous server-side enrichment (metadata,
art, lyrics) that can take 60-90s/track, far longer when external sources
are degraded. The React apiClient (ky) had no timeout, so ky's default 10s
aborted the import-process request client-side even though the server
completed the import (200) and moved the files. The import loop then counted
the aborted call as an error, so the bar stayed at 0 and flipped to 'Failed'
while files imported fine.

Give the two import-process calls (album/process, singles/process) an
explicit 5-min timeout. Scoped to import only -- every other endpoint keeps
the 10s default; bounded, not disabled. Server behavior unchanged.

Adds a test asserting both calls pass the long timeout.
2026-06-02 16:02:26 -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