Active metadata source / media server / download source are app-wide
infrastructure, so the quick-switch modal is admin-only again:
openServiceSwitchModal() no-ops (with a toast) for non-admins, and the sidebar
Service Status loses its clickable affordance for them. Per-profile playlist
account selection will live on its own user-accessible surface, not here.
Regression from the #832 server-side launch gate (Beckid). On a PIN-required,
unverified session the gate 401'd /api/setup/status — which the frontend checks
BEFORE the PIN screen. The 401 left setup_complete undefined, so `!undefined`
relaunched the full setup wizard on every visit (cancel → PIN screen worked,
which was the tell).
Two-layer fix:
- Allowlist /api/setup/status in the launch gate (it's a harmless boolean, no
secrets) so the frontend gets the real answer even while locked.
- Make the frontend fail-safe: only launch the wizard on a definitive
setup_complete === false from an OK response — never on a 401/locked/ambiguous
one.
Test: locked session still 401s data endpoints but /api/setup/status returns
{setup_complete:true}; added a gate-allowlist assertion. 21 gate tests pass.
The Plex logo is a white wordmark, so it vanished on the modal's white logo
disc (it only shows on Settings because those toggles sit on dark). Added a
per-logo `dark` flag (Plex + SoulSync) that renders their disc dark (#1f2329)
across the hero, rail, and option cards, so the white logo is visible. Other
logos keep the white disc.
The modal's server logos were the odd ones out — Jellyfin used the wide
jellyfin.org wordmark and Navidrome a stretched navidrome.org image. Switched
the modal's _SS_SERVER_INFO (drives both the rail and the option grid) to clean
square icons: Plex's plex-logo.svg, Navidrome's tweakers.net icon, and the
homarr-labs Jellyfin PNG. Also swapped the Settings page Jellyfin toggle from
the jellyfin.org wordmark to the same homarr-labs icon so they match.
Follow-up to the modal fix: the sidebar Service Status + dashboard service card
also mislabeled "Spotify (no auth)" as plain "Spotify". They read the status
`source`, which came straight from metadata.fallback_source ('spotify') with no
awareness of the metadata.spotify_free flag.
- get_primary_source_status now reports a DISPLAY source of 'spotify_free' when
fallback_source='spotify' + metadata.spotify_free is set (the raw 'spotify' is
still used for the auth/connected checks), and treats the free path's
availability as "connected" so the dot isn't falsely red on a no-auth setup.
- getMetadataSourceLabel maps 'spotify_free' → "Spotify (no auth)"; the status
presentation treats spotify_free as part of the Spotify family (session /
rate-limit / cooldown display still work). Added a SOURCE_LABELS entry.
- testDashboardConnection normalizes spotify_free → spotify (the only logic
consumer of the source value — the dashboard Test button).
Routing is unchanged (the real source stays 'spotify' + free flag); this is
purely the display layer. Settings was always correct. 64 integrity tests pass;
the 2 failing soundcloud tests are pre-existing (confirmed identical on a clean
tree).
Correctness (the modal was lying): "Spotify (no auth)" is a COMPOSITE the
Settings page stores as fallback_source='spotify' + metadata.spotify_free=true,
not a literal 'spotify_free' value. The modal read the raw fallback_source and
showed plain "Spotify" as active even when Settings clearly said "(no auth)".
The endpoint now mirrors that mapping both ways — reports active='spotify_free'
when the flag is set, and switching to it writes fallback_source=spotify +
spotify_free=true (and clears the flag for any other source). Modal + Settings
now always agree.
Visual: the modal itself (not just the cards) is richer now —
- a hero header per tab: big brand-logo disc + "Active <kind> source" eyebrow +
the active name + a one-liner + an Active pill, all tinted by the brand color
with a soft radial glow (the Manage-Workers hero feel);
- the panel gained brand-tinted radial depth instead of flat black.
Test: spotify_free composite round-trips like Settings (stored split + reported
as spotify_free; flag clears on switch). 15 endpoint + 64 integrity tests pass.
Visual rework toward the Manage Workers feel:
- Cards are now circular brand-logo discs on white, with each service's brand
color (Spotify green, Deezer purple, Plex gold, …) driving the logo ring +
active glow/gradient + hover lift. Replaces the flat emoji tiles.
- The left rail is alive: each tab shows its category + the CURRENT active
choice's logo and label (e.g. "Metadata · Deezer"), with the active tab in a
brand-tinted gradient + accent bar — mirroring the worker rows.
Correctness fix (answers "modal says spotify, settings says spotify (no auth)"):
the modal read the RAW configured source, but the rest of the app shows the
EFFECTIVE one. get_primary_source() silently downgrades a configured 'spotify'
to the default (deezer) when Spotify isn't authenticated — so configured and
effective diverge. The endpoint now returns `effective` alongside `active`, and
the Metadata panel shows a note ("Configured source isn't connected — actually
using Deezer right now") whenever they differ. Settings was never broken; the
modal just wasn't showing the resolved source.
78 tests pass (integrity + endpoints); smoke confirms configured spotify →
effective deezer surfaces, spotify_free stays itself.
Replaces the basic credential-pill quick-switch with a Manage-Workers-styled
modal (topbar + left rail + panel, entrance animation, brand-logo cards).
- Sidebar Service Status: whole panel opens the modal; clicking the Metadata /
Media Server / Download rows deep-links straight to that tab. Removed the
"switch ▸" hover text.
- Three tabs: Metadata (source logo cards, unavailable ones dimmed), Server
(Plex/Jellyfin/Navidrome/SoulSync logos), Download (Single⇄Hybrid segmented
toggle; Hybrid shows a draggable priority list). Logos reuse SOURCE_LABELS +
HYBRID_SOURCES; active card gets an accent ring + check.
- Admin writes the GLOBAL active source/server/download (reuses the same setters
+ client reloads as the Settings save, so changes take effect immediately).
Non-admins see it read-only (editable=false) — the per-profile override is the
next layer.
Backend: GET /api/profiles/me/active-sources (any profile; reports editable),
POST /api/profiles/active-sources (@admin_only; validates against the allowed
metadata/server/download lists, applies + reloads). New service-switch.js
(registered + in the integrity registry); old modal removed from
credential-sets.js (admin Connected Accounts manager stays).
Tests: 14 endpoint tests — read shape, admin sets metadata/hybrid+order
(reflected), bad-value 400s, non-admin read-only + 403 on write. 64 integrity
tests pass; real-app smoke confirms render + deep-links + the full set/reflect
cycle.
Frontend for the credential-set feature, matching the blocklist/house modal
style. Functional end to end against the existing endpoints; visuals are a
clean first pass to refine.
Admin manager (Settings → Connected Accounts, admin-only — empty for non-admins):
per service, the saved accounts render as pills with a delete ✕, and "+ Add
account" reveals an inline form built from each service's required fields.
Create POSTs /api/credentials; secrets are entered but never read back (the API
only returns id/label). Loads via loadCredentialSets() at the end of
loadSettingsData().
Quick-switch modal (sidebar Service Status is now clickable for ALL profiles):
shows, per service the admin set up, a "Default" pill + one pill per account,
highlighting the profile's current choice; clicking persists via
/api/profiles/me/services/select and re-renders. Empty-state message when the
admin hasn't configured any alternates.
webui/static/credential-sets.js (new, registered in index.html), house-style
CSS appended, sidebar made clickable, settings hook added. Registered the new
module in the script-split integrity test (onclick coverage). 64 integrity
tests pass; real-app smoke confirms index renders, the asset serves, and
admin-create → per-profile-list round-trips.
Note: selections are stored but not yet consumed by the live clients (the
resolver remains dormant) — wiring playlist-pull/enrichment to use a profile's
selected account is the next step.
The hybrid download-source list set item.draggable=true and the help text said
"drag to reorder", but no drag handlers were wired — only the arrow buttons
worked (and _syncHybridOrderFromDOM was dead code). Wired real
dragstart/dragover/drop on each item, reordering _hybridVisualOrder (the same
model moveHybridSource uses) then rebuilding + autosaving. Added grab/grabbing
cursors + a dragging state. The arrow buttons still work unchanged.
Bumps _SOULSYNC_BASE_VERSION 2.6.8 → 2.6.9, the docker-publish workflow's
default version tag, and adds the 2.6.9 What's New entry (15 items, security
fixes first: #832 launch-PIN enforcement and the settings-secret leak, then
#833/#831/#830/#829/#828/#827/#825/#824/#823/#740, Spotify (no auth), multi-
artist tags, decimal-volume dedup).
Found during the #832 audit: GET /api/settings returned dict(config_data) — and
config_data is DECRYPTED in memory — so every API key, OAuth secret, Plex/
Jellyfin token, and service password went to the browser in cleartext. Fernet
"encrypted at rest" protects a leaked DB file; it does nothing once the API
hands the plaintext to the client (devtools, HAR captures, an XSS, a screen
share, or a non-PIN'd LAN viewer).
Fix (centralized in ConfigManager):
- redacted_config() deep-copies config and replaces every _SENSITIVE_PATHS value
that's actually set with REDACTED_SENTINEL; unset secrets stay empty so the UI
still shows "not configured". Dict-valued secrets (tidal/qobuz OAuth sessions)
collapse to the sentinel too. GET /api/settings now serves this copy.
- set() ignores a write of REDACTED_SENTINEL to a sensitive path, so the masked
placeholder round-tripped by an unchanged settings form can never overwrite
the real secret. A real value still saves; an empty value still clears.
Frontend: secret inputs are type=password, so the sentinel renders as dots
(looks like a saved secret). _wireRedactedSecrets() clears the mask on focus so
editing types fresh rather than onto the sentinel, and re-masks on blur if left
untouched — so an unchanged secret round-trips the sentinel (kept), an edited
one saves the new value, and a deliberately emptied one clears.
Tests: every sensitive path masks; unset stays empty; dict secrets mask; live
config not mutated; sentinel round-trip keeps the real secret; real value
overwrites; empty clears; sentinel on a non-secret path writes normally.
9 new tests; 518 config-touching tests pass (1 pre-existing soundcloud mock
failure, unrelated — fails identically on a clean tree).
Boulder: the cards are good but everything around them was basic — six
identical grey pill buttons, a plain header, and a dated Global Settings modal.
Action chips (artist-detail button language — tinted gradient + hover lift +
icon scale): Scan is the primary CTA with the accent gradient and a shimmer
sweep; the rest get per-hue identity (similar-artists blue, settings slate,
origins green, history amber, blocklist/cancel red). One .wl-chip base class
with a --chip-rgb variable per hue. Header count/timer become pill meta chips
(timer accent-tinted).
Chip-safe labels: the scan/update handlers set button.textContent, which would
wipe the new svg + shimmer children on first use — added _wlSetChipLabel()
(preserves icon/shimmer, swaps the text node) and converted all 11 writes.
Global Settings modal: emoji + inline-styled header replaced with the
origins/blocklist house-style head (title/sub/✕); option cards now show live
checked-state feedback (:has(:checked) accent ring + grayscale-dimmed icons
when off — also upgrades the per-artist config modal, same components); the
master-override toggle gets a CSS .enabled treatment instead of the hard-coded
green inline border the JS used to write.
All element ids/onclicks unchanged; JS syntax-checked; 131 watchlist tests pass.
Boulder's screenshots: the v1 deck shifted around depending on what data had
arrived (the album row vanished entirely without art, leaving floating
"Processing…/Processing…" text), the images were small, and the feed header
floated in empty space. Redesigned in the artist-detail-page language:
- Big 148px square portrait (rounded, shadowed) anchors the left side, with
the current album stamped as a 62px overlay badge in its corner — when art
is missing, both keep their slot and show a glyph placeholder instead of
collapsing, so the deck NEVER changes shape mid-scan.
- 24px artist name + uppercase accent phase line + a fixed-height
"now checking" block (accent left rule) for album + track, with stable
placeholders ("Looking for new releases…" / "—") instead of doubled
"Processing…" text.
- The additions feed is an inset fixed-height panel (artist-page sidebar
style): same size whether 0 or 10 tracks, empty state centered.
- JS: hide the artist photo when the CURRENT artist has none (previously the
prior artist's photo lingered), cleaner placeholder copy.
Boulder: the live display was a cramped ~600px box showing a fraction of the
data the scan already tracks, with no animation and no history.
Live scan deck (replaces the three-column box, full width):
- Header: pulsing live dot, "x / y artists" progress text, and two live
counter chips (found / added) that pop when they change.
- Animated progress bar (artist index / total) with a shimmer sweep.
- Stage: artist avatar with accent glow + name + readable phase line
("Checking album 2 of 5"), album art + album + current track.
- "Added to wishlist this run" feed: taller, bigger art, slide-in animation
that plays once per new track (feed re-renders only when it changes).
- All data was already in scan_state (current_artist_index, total_artists,
tracks_found/added_this_scan, current_phase) — just never displayed. The
legacy fullscreen-modal markup shares element ids and lacks the new ones,
so it keeps working untouched.
Scan History (persistent):
- New watchlist_scan_runs table — one row per run (status, timestamps,
artists/found/added counts) + the full track ledger JSON. Saved at scan
completion AND cancellation; idempotent on run_id; pruned to the last 100
runs. Wishlist rows erode as tracks download, so this is the durable record.
- GET /api/watchlist/scan/history (runs) + /history/<run_id>/tracks (ledger).
- New History button on the Watchlist page → modal in the origins/blocklist
house style: run cards (date, cancelled chip, artists/found/added stats)
expanding into the Added / Skipped track lists with art and badges.
Tests: save+fetch with ledger, idempotent re-save, prune keeps newest,
unknown-run empty, cancelled runs recorded. 398 watchlist/wishlist/history
tests pass; JS syntax-checked; all rendered strings escaped.
Tacobell444 (#707 follow-up): the scan summary said "New tracks: 19 • Added to
wishlist: 10" with no way to see which tracks those were — you had to scan your
wishlist and guess what was new.
Scan ledger: the scanner now records a per-run scan_track_events list (track,
artist, album, thumb, status added|skipped — skipped = found-new but declined
by add_to_wishlist: already queued or blocklisted; capped at 500). The status
endpoint already serializes scan_state, so the payload flows free. The
completed (and cancelled) scan summary on the Watchlist page gets a
"Show tracks" toggle expanding a styled list — Added section + Skipped section
with badges, reusing the live-feed row styling.
Download Origins grouping: the modal now groups entries by what triggered them
(watchlist artist / playlist name) with collapsible headers + counts instead of
a flat list with a per-row badge. Entries arrive newest-first so groups order
themselves by their newest download. Same row markup, checkboxes/delete intact.
Provenance: watchlist adds now stamp scan_run_id into wishlist source_info, so
per-run grouping is queryable later (future "what did run X add" views).
Tests: per-run ledger seam test (added + skipped statuses, album/artist fields,
FIFO unchanged). 316 watchlist/wishlist tests pass; JS syntax-checked.
Third round of the multi-artist report. The earlier fixes (Deezer contributors
upgrade, _artists_list, feat_in_title/artist_separator) were all in place and
correct — but gated on source == 'deezer', and on the real Search → Download
Now path NOTHING carried the source: core/search/sources.py serialized tracks
with no source field, search.js's enrichedTrack didn't add one, so
get_import_source() resolved '' and the whole Deezer-specific block silently
skipped. Files were tagged with only the primary artist until a Retag (which
rebuilds context with the source set — exactly why retagging always fixed it).
The earlier tests passed because they set context['source'] directly — the one
field the real flow never had (same mock-drift as the #823 append tests).
Reproduced with Netti93's exact track (deezer 3966840171) through the real
extract_source_metadata: before — source '', artists ['August Burns Red'];
after — source 'deezer', contributors fetched, artists ['August Burns Red',
'Polaris'], title 'Sonic Salvation (feat. Polaris)' per feat_in_title.
Fix, three layers:
- core/search/sources.py: serialized tracks/albums/artists carry "source"
(the canonical name the orchestrator already passes; '' when unnamed).
- core/imports/context.py get_import_source: also reads '_source' from the
nested dicts (track_info/original_search/album/artist) — additionally fixes
the discography/wishlist flows, which always passed '_source' that nothing
read.
- search.js: enrichedTrack + the album-download path carry source through to
the download task.
Tests: real-payload staging-shaped contexts (source in track_info, '_source'
shape, and the pre-fix sourceless shape staying safe — mocked Deezer client),
serializer source-field tests, resolver fallback tests; exact-shape serializer
tests updated for the new key. 1977 import/metadata/search tests pass (the
only 2 failures are the known soundcloud ones).
The per-album status only looked at added + the generic wishlist-skip count, so
anything else (other-artist credit, already owned, content-filtered) showed the
misleading "No new tracks" — which is what made Vicky-2418's artist-mismatch
skips look like "you already have it." The backend already streams the full
breakdown (tracks_skipped_artist/owned/filter); the UI just ignored it.
New shared _discogItemStatus(data) builds an accurate line from all the counts:
"4 by other artists", "13 already owned", "1 added, 2 already owned", etc.
Replaces the duplicated 4-line block at both render sites. Frontend only; JS
syntax + per-case output verified.
Per Boulder's calls on the new enrichment toggle:
- Naming: "Spotify Free" was misleading (it's a hybrid — pick it, connect an
account, and sync still uses your official playlists). Relabel the user-facing
strings to "Spotify (no auth)" — the real distinction is needs-credentials vs
not. Internal value/key (spotify_free, _free_*) unchanged, so no migration.
- Default ON: metadata.spotify_free_enrichment now defaults True (worker + UI
load both treat unset as on). So bulk enrichment runs on the no-auth path by
default and the official account is reserved for interactive search/sync; turn
the toggle off to enrich through the connected account. The toggle overrides
auth for the worker (authed users still enrich via no-auth) — matching the
intended model.
- Worker runs on the toggle alone: is_spotify_metadata_available() now honors
_prefer_free (+ package installed), so the worker enriches via no-auth even
with no account connected and no 'no-auth' source selected. Only fires on a
client carrying the flag (the worker's own), so interactive/watchlist
availability is unchanged.
- UI: moved the toggle from "Metadata Source" to the Spotify section next to the
auth fields, always visible, on by default. Help notes the genre trade-off.
Tests: prefer_free makes metadata available without auth/source (and is inert
without the package); interactive availability unaffected. 218 Spotify tests pass.
User-facing opt-in for metadata.spotify_free_enrichment (the engine landed in
38461295). A checkbox in the Metadata Source frame, independent of the primary-
source dropdown, so a user with an official Spotify account connected can choose
to run the bulk enrichment worker on the no-creds Spotify Free source — sparing
their official API quota / dodging rate-limit bans for interactive search + sync.
Help text notes the trade-off (no artist genres from Free). Default off.
Wiring mirrors the existing spotify_free setting: saved in the metadata payload,
loaded into the checkbox, persisted via the generic metadata.* config loop (no
backend change). Auto-save already covers checkboxes in #settings-page.
Tacobell444: the Logs tab has no savable settings, but its live-viewer controls
(source picker, filters, auto-scroll) were tripping the settings auto-save —
each one POSTs /api/settings and logs "Settings saved successfully via Web UI",
flooding app.log and drowning out the logs the user is trying to read.
Fix: debouncedAutoSaveSettings bails when the active settings tab is 'logs'
(checked via .stg-tab.active). Purely frontend — no save is scheduled while on
that tab, so the backend never logs the save. Doesn't touch the existing
_suppressSettingsAutoSave form-population guard, and every other tab auto-saves
exactly as before; manual Save still works everywhere.
The manual album "Add to Wishlist" modal had NO ownership check at any layer —
the album view opened the modal without ownership info, the modal added every
track, and the backend (add_album_track_to_wishlist) added each unconditionally.
So adding an album you (partially) own dumped the owned tracks straight into the
wishlist (carlosjfcasero #825) — and the auto-cleanup doesn't reliably remove
them. The bulk discography path already dedups (full missing-track analysis);
this path didn't.
Backend (the reliable seam): add_album_track_to_wishlist now skips a track that
already exists in the library, gated on the same wishlist.allow_duplicate_tracks
toggle the watchlist scan + cleanup use — OFF → skip owned (returns
{success, skipped:true}), ON → add anyway. Default is ON, so default users are
unaffected; the quality re-download flow uses a different endpoint, so it's
untouched.
Frontend: handleAddToWishlist + addModalTracksToWishlist count skipped tracks
separately so the toast is honest ("Added 3 (5 already owned)" / "All N already
in your library") instead of falsely claiming owned tracks were added.
Tests: skips owned when duplicates off, adds missing when off, adds owned when
on (and doesn't even run the check then). 205 wishlist tests pass.
Part 1 stopped existing full dates being destroyed; this adds first-class support
for full release dates so they can be set + persisted instead of truncated to a
year at the DB layer.
- Schema: new nullable `release_date TEXT` on the albums table (idempotent
ALTER-ADD-COLUMN repair on startup + the live CREATE). NULL = year-only, every
reader falls back to albums.year, so it ships safe/dormant.
- Tag writer: write_tags_to_file + build_tag_diff prefer db_data['release_date']
(the full date) over the year int; _date_to_write writes the full date. When
there's no release_date it's exactly Part-1 behavior (year, preserving an
equally-specific existing file date).
- Retag read path: SELECT al.release_date in the tag-preview/write queries and
thread it into _build_library_tag_db_data.
- Manual edit: release_date added to ALBUM_EDITABLE_FIELDS + a "Release Date"
field (YYYY-MM-DD, validated client-side) in the album editor; the artist-album
query returns it so existing values show. User-set dates are authoritative.
- Enrichment: Spotify + iTunes workers store the source's full release_date
(YYYY-MM / YYYY-MM-DD) when present, only when empty — never clobbering a
manual value.
Tests: writer uses release_date over year + overrides an existing file date;
falls back to year when absent; diff compares the full date. Migration verified
idempotent + enrichment no-clobber. 1435 tag/retag/db/library tests pass.
When a track shows "Not found", the manual search now accepts a pasted Tidal or
Qobuz track link, not just a typed query (CubeComming: the fuzzy search misses
versions; he can find the track on Tidal but can't get it to appear).
How it works (robust, reuses the proven path): parse the link → (source,
track_id) → fetch the track via the source client's get_track → build a clean
"artist title (version)" query → run THAT source's normal search → bubble the
result whose id matches the link to the top. So the candidate is a normal,
already-downloadable streaming result — no hand-built download encoding — and
it downloads through the existing verified flow.
Degrades gracefully: if the source isn't connected or the link can't be
resolved, it falls back to a normal text search of the raw input — the user is
never worse off than typing it themselves. Scoped to Tidal + Qobuz (the
streaming sources that download by track id, with public track URLs); Soulseek
can't take a link (P2P, no ids), YouTube/SoundCloud are URL-native via a
different path (future).
- core/downloads/track_link.py: pure parse_download_track_link (tidal/qobuz
/track/<id>, slug/region suffixes, scheme-less) + query_from_track_payload
(per-source title/artist, Tidal version-append).
- manual-search endpoint: link detection → resolve → restrict to that source →
id-match bubble.
- placeholder hint mentions pasting a link; maxlength 200→300 for long URLs.
Tests: 14 (parser shapes + payload extraction incl. remix version-append +
qobuz performer/album-artist fallback). JS valid.
#816 hover-flicker — .dash-card:hover and .qa-tile:hover both did
transform: translateY(-Npx). Hovering a card's bottom edge lifted it off the
cursor → un-hover → drop → re-hover, an infinite rapid loop. Since every
dashboard card is a .dash-card, all of them flickered ("all elements
affected"). Removed the translateY lift; the hover's stronger shadow + border
glow already reads as "raised" without moving the hit box. (qa-tile has
overflow:hidden so a pseudo-element gap-buffer can't help — removal is the
clean, consistent fix.)
#816 Automations "looks strange" — the .qa-tile__flow decoration sits in the
bottom row directly behind the green "Open →" CTA; at 0.45 opacity the accent
nodes/line competed with the CTA (green-on-green clutter). Toned to 0.22 so it
reads as faint background texture; still brightens on hover.
#817 badge overlap — .helper-first-launch-tip was right:84px, only ~4px clear
of the ? float button's 8px pulse ring (button left edge ~72px from right), so
the "New here?" tip touched the button. Moved to right:96px.
CSS values/comments only — no structural changes (brace delta unchanged).
#814 — the collapsed Batches rail (44px) hid .adl-batch-active and
.adl-batch-history-section but NOT the JS-rendered .adl-batch-summary line
("N batches · M downloading · …"), so it overflowed as clipped text. Added it
to the collapsed hide rule.
#815 — "Retry Failed" only toasted "Retrying N…" at the start and a generic
"Discovery complete!" at the end, with no sense of how many of the retried
tracks actually progressed. retryFailedMirroredDiscovery now stamps a baseline
(matches-before + retry count) on the state, and a shared completion toast
reports "Retry complete: X of N newly found[, Y still not found]" instead of
the generic message. Normal (non-retry) discovery still shows "Discovery
complete!".
JS syntax clean, 70 script-split/style tests pass.
A Library Maintenance job that cleans up downloads tracked by Download Origins
once they pass a per-origin retention window — findings by default, opt-in
auto-delete.
A download is only ever proposed for deletion when ALL hold: older than its
origin's retention, NOT still in an actively-mirrored playlist / watched
artist, and played fewer than the keep-threshold (default 2 → "played more
than once is kept"). Only touches downloads recorded from the Download Origins
feature forward — never pre-existing or manual library.
- core/library/expired_cleanup.py: pure decision core (retention_cutoff,
is_expired, select_expired) — no DB/clock, fully tested. play_count is the
reliable listen signal (last_played is often unpopulated, so recency isn't
used).
- ExpiredDownloadCleanerJob: gathers facts (play_count via a new
get_origin_cleanup_candidates join; active-mirror via get_mirrored_playlists;
watch via get_watchlist_artists) and either creates 'expired_download'
findings or, with auto_delete on, deletes in-scan. Default OFF, both
retentions default 'off'. Settings auto-render in the Library Maintenance
panel (same as Cover Art / Lyrics / Re-tag).
- delete_origin_download(): shared delete (resolve path → remove file → drop
track row → drop history row); a file that won't delete keeps its row +
reports. Used by auto mode AND the _fix_expired_download apply handler.
- Frontend: type/action ('Delete')/result labels + finding detail render.
Tests: 9 on the pure brain (windows, off, per-origin, protected, play-count
threshold, bad age) + 7 on the job (no-op when off, findings, mirror/watch
protection, auto-delete, delete helper missing/real file). 185 repair/origin
tests pass.
The lyrics sibling of the Cover Art Filler, plus retag integration — reusing
the existing LyricsClient (LRClib) the import pipeline already uses.
- lyrics_client: extracted the LRClib fetch (exact-match-with-duration →
search fallback) into a shared _fetch_remote_lyrics, used by both
create_lrc_file (unchanged behavior) and a new check-only has_remote_lyrics.
- MissingLyricsJob (core/repair_jobs/missing_lyrics.py): scans tracks with no
.lrc sidecar and — Option A — only flags ones LRClib actually has lyrics
for, so instrumentals/interludes are never surfaced or re-flagged. Registered
in the job list; default OFF; respects the lrclib_enabled toggle.
- _fix_missing_lyrics (repair_worker): applies a finding by fetching + writing
the .lrc and embedding lyrics via create_lrc_file.
- Re-tag tool: new 'lyrics' setting ('fetch'|'skip', default skip). When
'fetch', apply_track_plans now also fetches/refreshes the .lrc per track
(fetch-if-missing, re-embed-if-exists) — threaded through scan gates, finding
details, the auto-apply path, and the manual fix handler. Settings UI
auto-renders the dropdown from setting_options; no markup needed.
- Frontend: type/action/result labels for missing_lyrics + a finding detail
render case.
Tests: 12 — has_remote_lyrics truth table, sidecar detection, scan (only-
fixable / skip-existing / lrclib-disabled), the apply handler, and retag
lyrics_action on/off. 694 repair/lyrics/cover/retag tests pass.
The status pill is a z-index:-1 ::before drawn behind the text. On the
download-status column it looks right, but on the track-match-status variants
(match-found/missing/checking) the -1 pill rendered behind the adjacent
library-status cell and looked broken (Boulder). Removed the pill from the
match-status variants — they keep their coloured text (--row-state-fg), no
pill — and dropped the now-orphan z-index:0 stacking context from the base
rule (position:relative stays for the hover tooltip). Download-status pills
untouched.
Closes the last acquisition gap — user-initiated downloads. A blocklist isn't
a censor, so search + discography stay fully visible; instead the download
ACTION is gated, visibly and overridably:
- Download modal (start-missing-process): an up-front check — if the WHOLE
album or artist being downloaded is blocklisted, return 409 {blocked:true}
with the entity, before starting a batch. The modal shows "X is blocklisted
— download anyway?" and re-POSTs with ignore_blocklist:true on confirm
(threaded onto the batch so the Phase 2a per-track filter skips it).
Scattered single-track bans still fall through to the 2a filter quietly.
- Manual /api/download (search-result download): source-file-centric, so it
matches the blocked ARTIST by name; same 409 + confirm + override. search.js
now sends artist/title so the guard has something to match.
- Precedence confirmed: force-download overrides "already owned", NOT a ban
(the 2a filter runs on the force-expanded missing list).
Frontend: shared confirmBlockedDownload() helper; modal + search callers
handle the blocked response and retry with the override.
Tests: manual download blocked-by-name / unrelated-allowed / override-passes,
and the modal up-front 409 for a blocked album. 8 blocklist API tests pass.
Completes Phase 1 on top of the backend (43c798a7):
- Cross-source backfill: core/blocklist/backfill.py is a pure injected-resolver
core (resolve only missing sources, never raises); core/blocklist/runtime.py
wires the real metadata clients with a confident name-match (exact
significant-token equality; album/track also require the parent artist when
both expose one — no wrong IDs hung on an entry). Resolution runs
synchronously at add time, so a ban is cross-source from the first scan;
the artist name-fallback in matching covers any gap.
- API: GET/POST/DELETE /api/blocklist (profile-scoped) + /api/blocklist/search
(thin wrapper over the manual-match service search on the active source, so
the modal needn't know the source). Add resolves the other sources before
storing.
- Modal (webui/static/blocklist.js): tabbed Artists/Albums/Tracks in the
revamp design language (accent light-edge, pill tabs, debounced search with
spinner + out-of-order guard, per-result Block, "currently blocked" list
with a match-status star and per-row remove). Opened by a new "Blocklist"
button on the watchlist page, next to Download Origins.
Tests: 5 backfill (fill-missing-only, None/exception handling, arg shape) + 4
API (search proxy, add→backfill→list→delete round trip, validation). Modal
registered in the script-split onclick-coverage test; JS syntax-checked.
Self-review of df929dc0 found one gap: the crossfade preloader hits
/stream/library-audio with the file PATH, which 404s for a streamed (not
disk-mounted) Navidrome track — main playback worked, crossfade didn't.
/stream/library-audio now uses the same _build_library_stream_url fallback on
a disk-miss (resolving the song id from the new track_id param, or a DB
lookup by path), and the preloader passes next.id. Crossfade now works for
streamed libraries too.
Review also confirmed (no change needed): /api/stream/status returns only
status/progress/track_info/error_message — the Subsonic token in stream_url
never reaches the browser; it stays server-side and the browser only hits
/stream/audio. Proxy verified live: Range forwarded, 206 + Content-Range/
Accept-Ranges passthrough, body streamed in 64KB chunks, upstream closed.
mlody95pl: Navidrome sync works, playback fails ("Failed to resume playback").
Root cause: SoulSync plays library tracks by reading the file off its OWN
disk (/api/library/play → resolve path → serve bytes). "Report Real Path"
gives the correct path STRING, but that's Navidrome's container path — the
files still have to be mounted into the SoulSync container to open them, and
the user's compose has /music commented out. So disk resolution 404s.
Navidrome is a streaming server, so requiring a disk mirror to play from it is
the real limitation. Now, when a library file isn't on SoulSync's disk and the
active server is Navidrome, playback streams through the server's own Subsonic
/rest/stream API — no mount needed:
- NavidromeClient.build_stream_url(song_id, max_bitrate) — token-authed
/rest/stream URL (mirrors build_cover_art_url; password never exposed).
- /api/library/play: on disk-miss, _build_library_stream_url (Navidrome-only;
uses the song id sent by the player, or a DB lookup by file_path) sets a
session stream_url instead of failing.
- /stream/audio: proxies that stream_url with Range passthrough so HTML5
seeking works, streaming upstream bytes through in 64KB chunks (no full-file
buffering).
- session state gains stream_url; the two library-play callers now send the
track's server id.
Disk playback is unchanged (file_path path still wins when the file resolves),
so Plex/Jellyfin and mounted-Navidrome setups behave exactly as before.
Tests: 7 on the URL builder (auth shape, no-transcode default, maxBitRate,
guards) + 4 on the play-fallback routing (navidrome-only, passed-id vs
DB-lookup, none). 200 navidrome/stream/media-server tests pass.
Pache711: a cover-art finding showed the (correct) found album art next to a
(wrong) artist image with one "Apply Art" button — no way to take one and
skip the other. Turned out "Apply Art" only ever applied ALBUM art anyway;
the artist image was display-only context, so the bundling was an illusion
the UI created.
Now the finding is genuinely multi-target:
- scan (missing_cover_art.py): also searches for an artist image (always, so
a WRONG existing one can be replaced — Boulder's call), name-matched
exactly. Stored as found_artist_url only when it differs from the current
artist thumb, so nothing is offered when there's nothing to change.
- apply (_fix_missing_cover_art): honors a target via _fix_action —
'album' (default, unchanged "Apply Art" behavior: DB thumb + embed +
cover.jpg), 'artist' (the artist's DB image), or 'both'. New _fix_artist_art
sets artists.thumb_url for the album's artist.
- UI: each found image gets its own apply button — "Use for album" /
"Use for artist". Applying either resolves the finding, so taking the
correct one and ignoring the wrong one IS "fix one, dismiss the other".
Current artist art shows as "(current)" context with no button.
Default stays album-only, so the plain Apply Art button and every existing
caller behave exactly as before. Tests: 5 on the apply targets (artist-only /
album-only / default / both / missing-url) against a real SQLite DB, plus the
existing cover-art suite updated for the new artist search. 107 repair/
cover-art/UI-integrity tests pass.
Ashh: the manual-match modal fuzzy-searches a service and shows the top 8.
When the right release isn't in those 8 (common title — their example was
"Idols", which returns 8 unrelated releases and not Yungblud's), there was no
way through. But the user usually already knows the exact MBID.
Now the modal's search box doubles as a direct-ID box. Paste a MusicBrainz
MBID (bare UUID or a musicbrainz.org URL) and SoulSync looks that exact
entity up and shows it as the single result to confirm + Match — no fighting
the search ranking.
- core/library/direct_id.py: pure detector, returns the canonical ID only
when the text unambiguously IS one (whole-query UUID, or a UUID inside a
musicbrainz.org URL). "Idols", "Yungblud Idols", a UUID buried in free
text → None, so normal search is never hijacked.
- _search_service: direct-ID fast path before the fuzzy search —
get_release (→ get_release_group fallback for albums) / get_artist /
get_recording. A pasted-but-unresolvable ID falls THROUGH to fuzzy search,
so a typo can't dead-end the modal.
- UI: MusicBrainz placeholder now says "…or paste a MusicBrainz ID/URL".
Detector is service-keyed so Spotify/iTunes/etc. direct IDs can be added
later; today only MusicBrainz has a confirmable direct lookup, matching the
reporter's ask + screenshot. 9 tests: detector truth table (bare/URL/plain/
buried/other-service) + dispatch (confirmed release, release-group fallback,
unresolvable→fuzzy, plain query skips direct lookup).
Follow-up to 5187fe5f, which shipped stall handling as config-only keys.
Boulder wanted them user-accessible, so the two knobs now render in the
Torrent Client settings section:
- "Stalled torrent timeout (minutes)" — number input. Shown in MINUTES for
friendliness, stored in SECONDS (download_source.torrent_stall_timeout_
seconds). 0 disables. Blank/NaN falls back to the 10-min default on save.
- "When a torrent stalls" — Abandon (default) / Pause select, maps to
download_source.torrent_stall_action.
Both live under download_source (already in the settings POST allowlist), so
no backend change — load converts seconds→minutes, save converts back.
Inputs/selects only (no onclick), so the script-split onclick-coverage test
stays green. settings.js syntax-checked via Windows node.
User ask: "a modal that lists the tracks downloaded via watchlist" — extended,
as discussed, to playlists too. One modal, two tabs, opened from the Watchlist
page (watchlist tab preselected) and the Sync page (playlists tab) — same
shared-modal-different-entry-points UX as the rest of the app.
The data: library_history recorded which SERVICE a file came from but never
what TRIGGERED it. New origin/origin_context columns (migration + index) are
written once at the import chokepoint via core/downloads/origin.py, a pure
tested deriver that reads, in priority: an explicit _dl_origin stamp (set at
batch-task creation for direct playlist batches, where the playlist context
otherwise only survived in folder mode), the wishlist provenance already
riding in track_info.source_info (watchlist_artist_name / playlist_name —
watchlist_scanner has stamped these for ages), and the folder-mode playlist
thread. Manual downloads stay unclassified by design. History starts from
now — provenance can't be conjured retroactively.
API: GET /api/download-origins?origin=watchlist|playlist (paged) and POST
/api/download-origins/delete — deletes the file on disk (resolved through the
shared container/host path resolver), the matching library track row, and the
history entries; a file that refuses deletion keeps its row and reports the
error instead of lying.
UI: webui/static/origin-history.js — tabbed modal in the revamp design
language (accent light-edge, pill tabs, entry rows reusing the
library-history-entry components), per-row delete + select-all bulk delete
with honest result toasts, empty/loading states, per-tab totals.
Tests: 8 — deriver priority/shapes (incl. the exact watchlist_scanner
source_info shape and JSON-string survival), origin filtering + counts,
row fetch/delete isolation between origins, delete-track-by-path.
The lock-in pass caught it before it shipped anywhere: the pill styling set
display:inline-flex on the status cells — which are <td>s — knocking them out
of table-cell layout and corrupting the row grid. The pill is now a centered
pseudo-element painted BEHIND the text (z-index -1 inside the cell's own
stacking context), so the cell's box model is untouched. State colors stay as
CSS vars on the td and cascade into the pseudo.
Also covers the secondary live-progress writer discovered in the same pass:
it stamps legacy download-downloading / download-complete classes instead of
data-state — both vocabularies now get the same pills (accent + breathe while
downloading, green when complete).
The status cells were the last plain-text corner of the revamped modal, and
they're the most alive data in the app during a run. The renderer now stamps
data-state on the download-status cell and toggles .row-working on the row
(visual-only hooks; zero logic change). CSS turns both status columns into
state-colored pills — accent while searching/downloading, amber processing,
green completed, red failed, orange quarantined — and ONLY the actively-
working states breathe (opacity, compositor-only). The working row carries the
same accent edge treatment as hover, but earned by real work instead of the
mouse. prefers-reduced-motion respected.
- entrance: soft rise + settle, one-shot spring
- header light-sweep: the dashboard's signature strip (same keyframes, same
transform-only technique) drifting across both modal headers
- progress sheen: a light band scanning the FILL — it lives inside the fill's
clip, so zero progress shows nothing and motion is gated by real progress
- hero stats become glass chips with per-state color identity (found green,
missing amber, downloaded accent) and a top light-edge each
- download modal's close X matches the discovery one (circular ghost, rotates)
- press feel on every pill button (active scale)
- all of it honors prefers-reduced-motion; only transform/opacity animate
Both modals were functionally perfect but visually dated — flat dark panels,
heavy table grids, the discovery modal still wearing its legacy RED border.
Pure CSS override layer appended last in the cascade; markup and JS untouched.
Same design language as the dashboard pass, theme-aware via --accent-rgb:
- deep glass surface with an accent light-edge along the top
- progress bars -> rounded inset tracks with gradient accent fill + glow
- tables -> micro-label sticky headers, calm hairline rows, accent hover
glow with an inset edge bar, themed thin scrollbars, accent checkboxes
- download modal: the two stacked progress bars become side-by-side glass
cards; tracks toolbar with a pill selection counter; glass footer with
pill buttons (gradient primary, ghost secondary, soft-red danger)
- discovery modal: red border killed, kicker typography header, circular
rotating close button, carded progress + table, matching pill footer
The tile's liveness was wired to sync:progress / discovery:progress — both
ROOM-scoped (only clients watching a specific playlist receive them), so the
dashboard tile would basically never light. And the scheduled auto-sync runs
as an automation, reporting on automation:progress — the wrong tile.
The 1s sync emitter now also sends an UNSCOPED sync:active heartbeat while any
playlist work is running anywhere: manual per-playlist syncs (sync_states),
the UI-triggered mirrored pipeline (playlist_pipeline_progress_states), and
scheduled auto-sync pipelines (running automations whose action_type is
playlist_pipeline / sync_playlist / refresh_mirrored). Emitted only while
active; the tile's 6s freshness decay handles the off. The dashboard listens
for the heartbeat alongside the (kept) room-scoped signals.
The three bento tiles had signature background animations that were pure
decoration. Each now SURGES while its subsystem is actually working, driven by
the live socket events — idle keeps the exact calm look they always had:
- Auto-Sync: the EQ bars dance fast + brighter, the playhead sweeps quicker
and the pulse dot races while a sync/discovery pipeline is running
(sync:progress / discovery:progress)
- Tools: the gear spins up 4x and brightens while a tool, scan, db-update or
repair job is running (tool:* / scan:media / repair:progress, with a shape-
tolerant "actually running" check so the 1s idle pushes don't light it)
- Automations: the flow nodes + line signals pulse at 2.5x while an automation
is firing (automation:progress)
Tiles carry .is-live while the last matching event is <6s old; a 2s interval
handles decay (no rAF, no per-frame JS).
GPU pass on the same tiles, same visuals:
- hero playhead animated `left` (layout + paint every frame, 9s loop) -> a
full-width strip whose 1.5px line is a static background, transform-only
- flow-node pulse animated background + box-shadow x3 nodes -> bright state
painted once on a pseudo, opacity breathes; added to reduced-motion kills
Audit of every dashboard animation. Already good and untouched: orb canvas
(cached glow sprites, no shadowBlur, stops on tab-hide/page-switch/scroll),
shimmer scan, sidebar orbs, embers, rl-blink (all transform/opacity), and the
reduce-effects global kill-switch. The offenders were infinite animations of
paint-bound properties — each repaints its region every frame, forever:
- avatar halo: animated box-shadow on every active bar -> the bright state is
painted once on a wrap pseudo and only its OPACITY breathes (the wrap exists
because the avatar clips overflow)
- rate-limited warn: animated filter:brightness -> a white-wash pseudo whose
opacity breathes
- active-fill glow: animated box-shadow -> static glow at the old midpoint,
breathing moved to the tip's opacity
- header sweep: animated background-position across the full-width band (on
all four headers sharing the class) -> a real child strip translated inside
an overflow-clipped wrap; transform+opacity, zero paint
- orb canvas: renders at ~20fps while fully asleep (drift is at crawl speed —
invisible) instead of 60fps for the hours the dashboard sits idle
Visual parity throughout; peak-flash (event-driven, 0.65s one-shot) keeps its
box-shadow since its duty cycle is negligible.
1-3 tiny accent sparks per socket update drift up from each bar's fill tip,
scaled by the real (unclamped) rate — motion strictly means API calls are
happening right now. Self-removing DOM nodes with a per-bar live cap of 6,
suppressed during cooldown and under reduced-effects mode. The taste-risk one
of the set: revert this commit alone if it reads as noise.
The payload has carried daily_budget {used, limit, exhausted} forever and the
dashboard rendered none of it. The avatar disc now wears a conic progress rim
that fills as the day's real-API budget is spent — green to 70%, amber to 95%,
red after — and flips purple once the worker has bridged to Spotify Free for
the rest of the day (using_free now included in the emit payload). Tooltip
carries the exact used/limit numbers.
A banned service used to just tint red. The payload carries the seconds
remaining (rl_remaining), so the bar now locks into a cooldown state: the live
VU dims, a red column drains away as the ban ticks down (largest remaining
seen is latched as the denominator — only remaining is sent), and an m:ss
timer counts to recovery. The moment the ban expires the track flashes green
('recovered') and the VU takes the stage back. 'Back in 4:00', not
'something's red'.
A thin accent marker sticks at each bar's recent maximum, holds ~1.2s, then
falls a few percent per update until it rests on the live fill — exactly how a
hardware VU meter's peak LED behaves. A traffic burst stays readable for a few
seconds after it's over instead of vanishing with the next 1s sample. Hidden
while it sits on the fill so idle bars don't carry a stray line.
The inactive->active transition (caught at the existing 30-frame state
refresh) now jolts the orb: a random-direction velocity kick with a briefly
lifted speed cap so it actually darts, a fast shallow size wobble (~1s decay),
and three sparks. A worker waking up reads as 'it sprang into action' instead
of just getting brighter.
Zero active workers + no pulses in flight for 75s eases the whole header into
a drowse (~4s): nucleus dims to embers, logo and orbs fade ~35-45%, spokes and
links fade ~70%, drift slows to a quarter speed (velocities keep integrating
so motion stays continuous). The first sign of work wakes it in ~0.3s with
three staggered rings blooming out of the nucleus. Idle and busy finally look
DIFFERENT — the contrast is what makes activity read as alive.
Each inbound pulse draws 3 fading ghost positions behind its head. The path is
parametric (eased t), so the tail is the same easing evaluated slightly in the
past — no position history, and it naturally stretches as the pulse accelerates
into the nucleus. Makes the energy flow legible at a glance.
A telemetry pulse used to just vanish on arrival. Now it makes contact: a small
expanding ring at the rim where it hits (pulse's own color, ~0.5s fade) plus
two debris sparks splashing back along the approach direction — the nucleus
reads as absorbing the work instead of deleting it. Capped pool (24), reset
alongside sparks/inflows on page switches and collapses.
Each worker orb gets a slow z-oscillation (-1 back .. +1 front); orbs draw in
painter's order with the hub pinned at z=0, so the cluster visibly swings
behind and in front of the nucleus instead of drifting on a flat plane. Depth
scales size ±18% and dims the back arc; the physics stay 2D. Effect eases out
during the hover-expand morph so orbs land on their buttons at natural size.
On page load every orb sat at the canvas origin: orbs are created at (0,0) and
the random scatter skipped orbs that weren't visible yet — which on load is all
of them, since the header hasn't been laid out when init() runs. The whole
cluster then drifted in from the top-left corner.
scatterOrbs() -> centerOrbs(): spawn the cluster dead-center of the canvas,
positioning every orb regardless of visibility (a few px of jitter on purpose —
the separation force ignores pairs closer than 0.1px, so a perfect stack would
never split). enterOrbState() also re-centers right after resizeCanvas(), so
activations get the true center even when init ran against a hidden 0x0 header
— and returning to the dashboard replays the center-bloom intro.
Since the per-listener stream sessions refactor (Phase 3b), every browser gets
its own stream session — but the 1s 'tool:stream' socket broadcast still read
the legacy GLOBAL state (the DEFAULT session no real browser uses), so it told
every client "stopped" forever. The frontend skipped HTTP polling whenever the
WebSocket was up, so it only ever saw that wrong broadcast: the backend prep
downloaded the track, moved it into the session's stream folder and sat at
"ready" while the mini player showed nothing. Proxy users whose WebSockets
don't connect fell back to HTTP polling (session-correct) and streamed fine —
which is why this hid so well.
Fix: stream status is inherently per-listener, so stop pretending a global
broadcast can carry it —
- web_server.py: remove the 'tool:stream' emit from the tool-progress loop
(the broadcast thread has no request context; it can only ever see DEFAULT)
- media-player.js: the status poller always polls /api/stream/status (resolves
the caller's own session from the cookie); drop the dead broadcast handler
- core.js: unwire the 'tool:stream' socket listener
Observability fix that made this undebuggable: core/streaming/prepare.py used
getLogger(__name__) — outside the soulsync.* namespace where handlers attach —
so every prep log line (including failures) vanished from app.log. Moved to
get_logger("streaming.prepare") + a regression test locking the namespace.
34 streaming tests pass; ruff clean; web_server compiles; JS syntax-checked.
User request: the re-tag diff card shows old→new metadata per track but not
the file it applies to, so a wrong match is hard to spot before applying.
The finding already carries each track's file_path (details.tracks[].file_path
from the scan) — the renderer just wasn't showing it. Now each changed track
displays its filename beneath the title, so you can eyeball that the metadata
about to be written actually belongs to that file. Skipped when the label is
already the filename (track had no title). Pure frontend display change.
#798 follow-up. The worker's 500/day budget is a REAL-API ban shield, but
when it was hit the worker paused outright — even for a Spotify-Free user
with the uncapped free source available. So "I'm on Spotify Free" still
got capped overnight. The intuition is right: if it's ever using Spotify
Free, the budget shouldn't apply.
Fix: spent budget now becomes a third "use free" trigger (alongside
no-auth and rate-limited). When the real-API budget is exhausted and the
free source is available, the worker switches to free (uncapped) for the
rest of the day instead of pausing, then reverts to real-first on the
daily reset.
- should_use_free_fallback gains a budget_exhausted arg (free activates on
no-auth OR rate-limited OR spent-budget).
- the worker sets _budget_exhausted_use_free on ITS OWN client (a separate
instance from the search client — verified, so user searches still use
real auth), and clears it when the budget resets; _free_active() honors
the flag.
- get_stats() using_free reports the budget-bridge too, and the dashboard
bubble shows "Running (Spotify Free)" instead of "Daily Limit Reached"
(budgetStuck = exhausted AND not bridging).
A no-free user still pauses on the budget (nothing to bridge to). A pure
free-only worker never increments the budget at all. New gate test pins
the budget_exhausted trigger. Full suite clean.
Reinstate the Soulseek dependency (quality profile only affects Soulseek
downloads) that was dropped while fixing the empty-tile bug. Gate the whole
collapsible tile (#quality-profile-tile) as a unit instead of the inner group,
so it fully shows (Soulseek active + downloads tab) or fully hides — no empty
expandable shell. switchSettingsTab runs updateDownloadSourceUI after the
data-stg tab filter, so this gate is authoritative on tab switches.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Some tracks don't exist on the sources in the wanted cut — every copy is, say,
the instrumental. The retry engine correctly rejects each (version mismatch) and
gives up, leaving the track missing. New opt-in fallback: once a track's AcoustID
retries are fully exhausted, if every quarantined candidate for it failed the
SAME version mismatch (same matched version, e.g. all instrumental) and there are
>= N of them, accept the best (first-tried = oldest = highest-confidence) one.
Safety rules (core/imports/version_mismatch_fallback.py):
- Version mismatches only. Audio/artist mismatches (different recording) and
integrity/duration failures (truncated/wrong file) never participate.
- All qualifying entries must share the same matched version; a mix
(instrumental + live) is ambiguous → no acceptance.
- Re-import bypasses ONLY the AcoustID gate; integrity/duration/bit-depth still
run, so a truncated or genuinely wrong file is never let through here.
- Reuses the existing quarantine approve_quarantine_entry + re-verify dispatch.
Wired at the AcoustID give-up point in the verification wrapper. Two new
post_processing settings surfaced in the Retry Logic tile (default off):
accept_version_mismatch_fallback + version_mismatch_min_count.
Pure decision core + orchestration covered by tests (11). Acceptance logged at
WARNING with track + matched version.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Soulseek-only JS gate fought the settings tab filter for control of the
tile's display: gating the inner group left an empty expandable shell, gating
the wrapper hid the whole tile depending on activeSources/tab timing. Remove
the JS gate entirely and let the tab filter (data-stg="downloads" on
#quality-profile-tile) own visibility — identical to the working Retry Logic
tile. The tile now reliably shows on the Downloads tab.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Quality Profile tile expanded to an empty body: settings.js
updateSourceVisibility toggled only the inner #quality-profile-section
(Soulseek-only + downloads-tab gate), leaving the new collapsible tile's
header/body visible with hidden contents. Wrap the tile in
#quality-profile-tile and gate that wrapper as a unit instead, so the whole
tile shows (Soulseek active) or hides (otherwise) — no empty shell.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Settings reorg (Downloads page):
- Move the retry controls (retry next-best candidate, exhaustive retry,
retries-per-query) out of the Post-Processing tile into a new collapsible
"Retry Logic" tile on the Downloads tab (data-stg=downloads), collapsed by
default. Also decouples them from the post-processing master toggle, which
previously hid them when post-processing was disabled. Config keys are
unchanged (still post_processing.*); settings.js binds by element id so the
DOM move needs no JS change.
- Wrap the existing Quality Profile group in a matching collapsible tile,
collapsed by default.
Sidebar (reduce-effects):
- The perf PR (#793) gated .nav-button hover/active-hover behind
body:not(.reduce-effects), removing the highlight entirely in reduce-effects
mode. Restore the highlight there using only cheap properties (flat
background + border-color; the base already reserves a 1px transparent
border so there's no layout shift) while keeping the expensive gradient /
translateX transform / multi-layer box-shadow off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an opt-in exhaustive mode to the quarantine-retry path. Default
behaviour is unchanged: a single global cap (MAX_QUARANTINE_RETRIES=5).
When post_processing.retry_exhaustive is on, each source gets its OWN
retry budget sized as query_count x retries_per_query. Soulseek peers
collapse to one 'soulseek' bucket; streaming plugins keep their name.
The worker now records query_count on the task; the budget scales with
the track's real query count. Loop protection is threefold: per-source
cap, used_sources exhaustion (the natural terminator), and an absolute
ceiling (MAX_TOTAL_QUARANTINE_RETRIES=100).
New settings (config + WebUI): retry_next_candidate_on_mismatch (master),
retry_exhaustive, retries_per_query (default 5).
Tests: 6 new cases covering per-source budgeting, source separation,
Soulseek-peer bucketing, query_count default, and the absolute ceiling.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#798 follow-up. When the real Spotify API is banned but the worker keeps
matching via the no-creds Spotify Free source, every status surface still
read the literal rate_limited=True flag and showed "Rate Limited /
waiting Nm" — so the dashboard bubble looked paused/stuck even though the
worker (visible in Manage Workers) was actively matching.
- spotify_worker.get_stats() adds a `using_free` flag: rate_limited AND
is_spotify_metadata_available(). Computed ONLY when rate-limited, where
is_spotify_authenticated() returns False without an API probe, so the
2s status loop pays no quota cost.
- Dashboard bubble (enrichment.js): when using_free, the bubble is
'active', the tooltip says "Running (Spotify Free)" and "Now: X (via
Spotify Free)" instead of "Rate Limited / Waiting Nm". Clicking it
pauses (works) rather than hitting the resume-blocked toast.
- Manage Workers (enrichment-manager.js): status pill shows "Running
(Spotify Free)"; the warning banner is replaced with a calm "matching
via Spotify Free until the ban lifts" note.
The flag flows through both feeds (the /api/enrichment/spotify/status
poll and the WebSocket enrichment:* push) since both serialize
get_stats(). Genuinely-stuck (no-free) workers still show "Rate Limited".
Files SoulSync (or MusicBrainz Picard) already tagged carry Spotify /
iTunes / MusicBrainz / Deezer / Tidal / AudioDB / Genius / Last.fm IDs in
their metadata. Enrichment workers gate their queues on
{provider}_match_status IS NULL, so reading those IDs back and gap-filling
the {provider}_id + match_status='matched' columns lets the workers skip
the API lookup entirely — big API savings on an already-tagged library.
New manual job in Tools -> Database & Scanning ("Import IDs from File
Tags"): scans every library file, reads embedded IDs, fills any that are
missing in the DB. Background job + progress card, mirroring the
write-tags-batch pattern.
core/library/embedded_id_reconcile.py (pure + tested):
- plan_reconcile(): gap-fill plan for a track + its album + artist. Only
empty id columns are planned; a disagreeing embedded id is a conflict,
never applied.
- apply_reconcile_plan(): one guarded UPDATE per id column —
WHERE id=? AND (col IS NULL OR col=''). The guard makes the fill atomic:
if an enrichment worker matched the same entity between our read and
this write, the UPDATE affects 0 rows instead of clobbering it. Columns
are introspected so a schema missing a provider's columns is skipped.
- reconcile_track_row(): per-track orchestration (id extraction, plan ->
apply, keeping the in-memory parent maps fresh for sibling tracks).
Job hardening: paged track scan (bounded memory), per-page commits (don't
starve concurrent workers), per-file try/finally (one bad file can't abort
the run), counters from real rowcount.
Scope: 19 column-fills across 8 providers. MB *recording* (track) id is
left out (UFID frame the reader doesn't surface; Vorbis key ambiguous) —
MB album+artist are covered. Amazon/ASIN deliberately excluded (ASIN is a
different namespace than the worker's amazon_id). All target columns
verified against the live schema.
Purely additive: new module, two new endpoints, one new Tools card —
no existing behavior changed. 20 unit tests (incl. the concurrency guard).
Full suite clean (only pre-existing soundcloud /app env failures remain).
AcoustID returns a recording's title/artist in their ORIGINAL script
(e.g. "久石譲" for Joe Hisaishi) while SoulSync's expected metadata is
romanized/English. A correct download then fails verification on two
walls: the title can never clear the 0.70 similarity bar cross-script,
and the only skip path that ignores the title required a near-perfect
0.95 fingerprint plus a resolved alias. Result: every non-English
artist trips it. Two complementary fixes, per the reporter's two ideas.
Graceful fix (automatic):
- New pure core/matching/script_compat.py detects when two strings are
in genuinely different writing systems (CJK/Hangul/Cyrillic/Greek/
Arabic/Hebrew/Thai vs Latin). Accented Latin (Beyoncé, Sigur Rós)
stays Latin — no false trigger.
- acoustid_verification.py: when the EXPECTED artist and the matched
artist span scripts AND the artist is confirmed via the existing
MusicBrainz alias bridge, SKIP instead of quarantine, without the
0.95 floor (the 0.80 trust floor already gates the fingerprint).
- Deliberately narrow: keyed on the ARTIST spanning scripts + being
confirmed. A same-script artist with only a cross-script title keeps
the stricter 0.95 floor, so the #607 wrong-file protection (Kendrick
R.O.T.C, low-fingerprint Japanese-title) is untouched.
Per-request toggle (manual escape hatch):
- New "Skip AcoustID verification" checkbox in the download-missing
modal beside "Force Download All".
- skip_acoustid threads request -> batch -> per-track track_info ->
download context (same path as _playlist_folder_mode), landing on
the existing _skip_quarantine_check='acoustid' bypass. No new
mechanism; only the AcoustID gate is bypassed (integrity/bit-depth
still run).
Tests:
- tests/matching/test_script_compat.py — script-boundary cases.
- test_acoustid_skip_logic.py — Joe Hisaishi SKIPs at 0.85; unconfirmed
cross-script artist still FAILs; same-script low-fingerprint still
FAILs.
- test_downloads_candidates.py — toggle injects the bypass; absent
toggle keeps verification.
Full suite: 5169 passed; only pre-existing soundcloud /app env failures
remain. Zero regressions.
Consistency fix: Spotify Free is now its own entry in the metadata-source
dropdown (alongside Spotify / iTunes / Deezer / MusicBrainz) instead of a
side-toggle. Stored as fallback_source='spotify' + spotify_free=true so all
downstream 'spotify' routing and the spotify_* columns are unchanged.
Refined gate model (no toggle):
- Connected user (has credentials) -> official; bridges to free AUTOMATICALLY
during a rate-limit ban (no opt-in needed).
- No-auth user -> must pick 'Spotify Free' in the dropdown; then free serves.
- Never opted into Spotify (no creds, didn't pick it) -> free never runs, so no
surprise scraping. _free_wanted() = has_credentials OR picked-spotify-free is
the guard.
- AUTHED + healthy -> official always; free never opens.
UI: dropdown gains 'Spotify Free (no credentials)' (selectable when the package
is installed — surfaced via status.free_installed, since selecting it is the
opt-in and can't depend on having selected it); load/save map the dropdown value
to the (fallback_source, spotify_free) pair; old checkbox removed.
Gate model pinned by 6 scenario tests (connected/healthy, connected/ratelimited
bridge, no-auth picked, no-auth not-opted-in, package-missing). 117 tests green.
Surfaces the opt-in Spotify Free source so it's usable end-to-end:
- Settings: 'Enable Spotify Free (no credentials)' toggle that saves
metadata.spotify_free (load + save wired). Clear best-effort/limitations note.
- config-status: adds spotify.metadata_available (configured OR free-available),
keeping the configured flag = has-credentials so the Connections indicator
stays honest. Search source picker shows Spotify when metadata_available.
- status payload: adds spotify.metadata_available; the Settings primary-source
selector now allows picking Spotify when authed OR free-available.
Verified gate composition: OFF by default (no surprise scraping); ON + no auth +
installed -> available & serving; AUTHED -> official always wins (free never
runs); missing package -> gracefully unavailable. JS + integrity + 111 tests green.
A leftover `.sync-tab-server { flex: 1.4 !important }` from the old equal-width
pills tab strip leaked past the brand-chip restyle (its !important beat the
chip's flex:0 0 auto), so the active Server Playlists pill spanned the whole row
instead of fitting its label. Dropped just that declaration — the tab now behaves
like every other chip; its bespoke gradient + the rest of the rule are untouched.
Spotify/Apple/MusicBrainz/Deezer artist links now resolve via each source's
get-by-id (get_artist / Deezer get_artist_info), shaped to the artist card and
rendered as an artist result that opens the artist detail page through the
existing flow. Album/track link handling is unchanged; bare IDs still rejected.
Follow-up to the bare-ID footgun: a bare number like 525046 carries no
source and no entity type, so it resolved to whatever album happened to own
that id (a user pasting Kendrick's Deezer artist id got an unrelated album).
Now the resolver accepts provider URLs (and the explicit spotify: URI) only;
a bare/unrecognized string is rejected and the dropdown surfaces a hint to
paste a full link. URL parsing + album/track resolution are unchanged.
New 'Link / ID' input on the Search page: paste a Spotify / Apple Music /
MusicBrainz / Deezer URL (or a bare ID) and it's looked up directly on the
owning source — no fuzzy search, no scoring.
- core/search/by_id.py: source-agnostic parser (URL domain/path or bare-ID
format -> source,kind,id; numeric IDs fan out, first hit wins) + per-source
get-by-id dispatch + adapters projecting each provider's dict onto the
standard album/track card shape.
- /api/enhanced-search/by-id: thin additive route over resolve_identifier.
- Frontend: dedicated input that adopts the resolved source as active and
renders through the existing dropdown + download/import flow.
Purely additive — existing files are insertion-only; the resolver runs only
behind the new route. 29 seam tests cover parsing, shaping, fan-out, and
not-found.
toggleNotifPanel positions the panel inline from the bell's rect (panel.style.
right/bottom). The bell isn't flush to the right edge on mobile, so that inline
right offset + near-full-width pushed the panel off-screen left. The existing
mobile rule set right:12px without !important, so it lost to the inline style.
Now anchor both sides with !important (left+right+width:auto) so it always fits.
The visualizer is fixed at the desktop sidebar's edge; on mobile it floated over
the page content whenever music played, even with the off-canvas sidebar closed.
Hide it unless .sidebar.mobile-open (sibling selector, 3-class + !important to
beat the .active/.viz-* display rules). When the drawer opens it shows again.
The downloads page is a two-column desktop layout (main list + fixed 366px batch
panel) with NO mobile rules at all. Phone-only:
- .adl-layout stacks to a column; .adl-batch-panel goes full-width, swaps its
left border for a top border, and flows in the page (no independent scroll).
- .adl-header + .adl-controls stack so the filter pills get full width.
- .adl-filter-pills wrap instead of overflowing; cancel/clear buttons flex to fit.
- Hide the floating mini-player while the expanded Now Playing modal is open
(it has z-index 99998 vs the overlay's 10001, so it floated over the modal).
General fix (desktop too), via sibling selector on the overlay's open state.
- Artist hero image: drop the max-width:40vw cap on mobile (overrides the base
rule) so the image isn't artificially shrunk.
Existing mobile rules made it full-screen + stacked the body, but left the
desktop layout inside untouched. Phone-only (max-width:768px):
- album art scales (min(220px, 66vw)) instead of fixed 220px
- left/right columns full width; track info, action + util rows centered
- controls row gap tightened to fit a phone
- queue + lyrics panels: drop the 40px desktop side padding that crushed content,
give them a touch more vertical room
All phone-only (max-width:768px), all in mobile.css — desktop untouched.
- artist hero: drop the 100px image-container cap; artist name -> 1.6em centered
block; bio max-height:fit-content; center hero action buttons + match-status
chips (moved here from base rules so desktop stays as-is).
- #6 enhanced-view track table: a 6+ col table clipped to one visible column on
a phone. Drop table layout -> each row is a flex line (play . title . duration
. actions); secondary columns fold into the existing mobile actions sheet.
- #7 mini media player: was pinned at desktop coords (right:132px; width:340px)
and overflowed. Full-width bar sitting just above the bottom global search.
- #8 page heroes (tools-maintenance / watchlist / discover): trim desktop-sized
padding + margins that wasted space on mobile.
- #9 sync header: Auto-Sync / Library Match / Sync History didn't fit; stack the
header + wrap the buttons.
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.)
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.
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.
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.
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.
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>
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>
- 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.
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).
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.
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).
- 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.
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.
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.
- 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.
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.
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.
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).
- 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.
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).
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.
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.
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.
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.
- 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.
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.
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).
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
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.
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.
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.
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.
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).
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.
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.
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.
- 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.
- 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.
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>
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.
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.
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.
- 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.
- 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.
- 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.
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).
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.
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.
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.
- 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.
- 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.
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.
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.
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.
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.
- Ambient bob: bubbles gently float (sine offset, phase varies by position so
they move in a wave, not in unison). Driven by a persistent rAF loop that runs
only while bubbles are on screen + the tab is visible, and parks when zoomed
out (_liveCount==0) or the map closes — so an idle overview costs nothing.
- Glassy specular highlight (cached sprite, cheap drawImage per bubble) so
bubbles read as glossy orbs at every size.
- Tighter island spacing (water gap 7*nodeR → 3.5*nodeR) so the settled
overview is more substantial, not thin-spread — addresses the 'mini version'
feel after the reveal ripples fade.
- Ambient resumes on zoom and on tab re-focus; stops cleanly on close.
64 JS integrity tests pass.
Islands now bloom in like drops on water instead of a flat fade:
- Each island reveals in turn (staggered by island order); within an island,
bubbles fade + scale (0.55→1, ease-out) outward from the centre by radial
distance — a drop-in-water bloom. Genre titles fade in just after.
- A hue-tinted water ripple ring expands from each island centre as it blooms
(_artMapDrawRipples — reused by click ripples in Phase E).
- During the reveal the static buffer is bypassed so EVERY bubble can animate
(live layer, cap 2200); when the bloom ends it bakes into the buffer once and
steady-state returns to the cheap two-layer path.
- aAlpha folds into the global draw-alpha multiplier so fades compose cleanly.
64 JS integrity tests pass.
All three maps (watchlist / genre / explore) now lay out as genre 'islands' on
the water via one shared engine (_artMapLayoutIslands):
- Group artists by primary genre (long tail folds into 'Other'; max 14 islands).
- Each island is a FILLED disc of covers packed centre-out (no empty donut
hole), most-popular nearest the middle, focal artists sized up + centre-most.
- Islands spread by golden spiral + push-apart with generous water between.
- Clean floating genre TITLE above each island (hue-tinted, glow) instead of
the old giant translucent label bubble.
- Per-genre accent hue tints member-bubble borders so clusters read as a family.
- Discovery edges (watchlist→similar, center→ring1→ring2) remapped to the new
node ids so the hover constellation still works across islands.
Replaces the per-artist donut clusters from the screenshots. Shared helpers:
_artMapGroupByGenre, _artMapPackDisc, _artMapRemapEdges, _artMapFitToContent.
64 JS integrity tests pass.
Addresses the screenshot feedback (mix of detailed covers + blank dots, lag,
'weird' load):
- Pre-mask each album image into a circle ONCE at load (a canvas), so every
draw is a plain drawImage instead of a per-frame ctx.clip(). Clipping was
the live-layer stutter — hundreds of clips per frame. Now free.
- Draw album art at nearly every on-screen size (only sub-2.2px fall back to a
dot), instead of detailed-vs-blank-dot tiers. Consistent 'sea of covers'.
- Reveal is now a clean ease-out-cubic fade of the whole map (buffer blit +
live layer ramp together via _drawAlphaMul) — dropped the bouncy per-node
pop that read as 'weird'. The real island ripple bloom comes in Phase C.
64 JS integrity tests pass.
Foundation for the water/ripple redesign. Splits rendering into:
- Static far-field buffer: small/distant bubbles, baked once (cheap blit).
- Live overlay layer: every bubble big enough to read (radius*zoom >= LIVE_PX)
redrawn each frame in world space, so it can scale/bob/ripple. Viewport-
culled + capped at 600 draws.
The partition is frozen at buffer-build zoom (_liveBuildZoom) so the two sets
stay exact complements even mid-zoom — no flicker, no double-draw.
Adds an idle-capable rAF loop (_artMapStartLoop/_artMapStepAnimations) that runs
only while something animates and stops when still. First payload: a reveal —
the far field fades in globally while live bubbles pop outward from the camera
centre (ease-out-back, staggered by distance). Wired into all three loaders.
Bonus: live bubbles now draw full-res at the current zoom instead of through the
4096px-capped buffer, so zoomed-in artwork is crisp (addresses the earlier
low-res complaint structurally). Engine only — the island layout, ripple
choreography and click physics build on this in B–E. 64 JS integrity tests pass.
The streaming fix in Phase 4 still rebuilt the ENTIRE offscreen buffer (~1500
nodes) on each image wave, and any hover/pan during streaming hit that same
dirty flag — so interacting while images loaded redrew the whole world over and
over (the 'laggy until all images load' jank).
Now each arriving image composites ONLY its own node into the existing buffer
(_artMapCompositeNode) and does a cheap rAF-coalesced blit — no full rebuild.
The per-node draw is extracted into _artMapDrawNodeToBuffer so the full rebuild
and the incremental compositor share identical drawing (can't drift). Falls back
to a full rebuild only if the buffer isn't built yet. Pan/hover stay at
blit-speed the entire time images stream in.
All three maps (watchlist/genre/explore) now paint instantly with placeholder
circles and stay fully interactive (pan/zoom/hover/click) while images stream
in throttled ~280ms waves and sharpen the map in place. Replaces the old
blocking 'await all N images then paint' loaders — the headline 'feels slow'
fix. Focal/large nodes fetch first; a per-open load token cancels stale streams
when you jump to another artist, so rapid click-through never piles up fetches.
- Images: decode adaptively (focal/watchlist nodes ~256-384px, small nodes
~112-150px) instead of a flat 128px — crisp where it matters, memory still
bounded (~150-250MB, not 6GB). Fixes the low-res look.
- Hover constellation: drop the activation delay 800ms → 220ms (it felt 'gone'
because nothing happened for nearly a second), and draw the connection lines
as a wide-faint halo + crisp core (a real glow) with no per-frame gradients
or shadowBlur — stays cheap.
- Backdrop: subtle cached radial glow + vignette behind the map for depth
instead of a flat fill (one cheap fillRect/frame).
JS clean; 64 integrity tests pass.
Perf telemetry was the giveaway: after the buffer cap, rebuild + draw were both
~10ms, yet fps stayed 1-3 and the browser 'locked'. Cheap draw + locked system =
memory/GPU thrash, not drawing.
Cause: artist images load at up to 1000×1000, and a dense map holds ~1500 of
them — ~1500 × 1000² × 4B ≈ 6 GB of decoded ImageBitmap memory. The browser GCs/
evicts textures constantly → systemic lag the canvas timers don't see.
Fix: decode straight to a 128px avatar via createImageBitmap resize options
(nodes render tiny anyway). ~1500 × 128² × 4B ≈ 100 MB instead of 6 GB. Falls
back to full decode on engines that ignore the resize opts.
This is the one that should actually make it smooth. Perf overlay stays on 'd'.