Migrate the wishlist add-to-wishlist modal buttons onto the shared .btn
primitive: primary -> .btn--primary, secondary -> .btn--secondary, the green
download CTA -> new .btn--download modifier. Added a shared .btn.loading state
(amber pulse, reusing the existing pulse-loading keyframe) since
confirm-add-to-wishlist-btn toggles `loading` via JS (wishlist-tools.js).
Removed the dead .wishlist-modal-btn* rules and re-scoped the mobile
full-width override to `.wishlist-modal-actions .btn`.
Start of the button-consolidation pass (kettui's #1). The app had ~236 button
classes / ~8-10 distinct looks with heavy near-duplication.
Introduce a canonical .btn design-system primitive (base + .btn--primary /
.btn--secondary / .btn--danger), modeled on the dominant existing look
(accent-gradient primary, translucent ghost, semantic danger) and built on the
accent CSS vars. New markup and the React pages should use this; existing
per-page button classes will migrate onto it family by family.
First family migrated: the config/settings modal buttons (.config-modal-btn*,
4 static uses, no JS refs) -> .btn .btn--primary / .btn--secondary. Removed the
now-dead .config-modal-btn* rules and re-scoped its mobile full-width override
to `.config-modal-actions .btn`.
Visible change is minor by design (padding 28->24px, gradient direction
normalized). Proof step for sign-off on the .btn look before rolling wider.
Convert the playlist-explorer page from a flat padded container to the
.page-shell floating card. Drop its bespoke `padding: 24px 32px`; keep the
full-height flex layout (display:flex / column / min-height:100%) since the
explorer fills the viewport.
Visible change by design. Watch: the full-height min-height:100% inside a
margin:20px card may run slightly tall — to be confirmed visually.
First of the "flat -> card" conversions. The automations list view sat
directly on the page background (.automations-container = bare padding) while
its inner .dashboard-header is the same header dashboard uses. Adopt
.page-shell so the page becomes a floating gradient card structurally
identical to the dashboard (page-shell card > dashboard-header > content).
- Drop .automations-container's bespoke `padding: 20px 24px` (card padding now
from .page-shell); keep the class as the mobile/JS hook.
- Add `page-shell` to the container in markup.
Visible change by design (this page was not previously a card). Mobile keeps
its existing .automations-container padding override.
First step of the page-layout-shell standardization (kettui's UI-consistency
point #1). The dashboard, tools, watchlist and wishlist pages each defined a
byte-identical "card" container (padding 28px 24px 30px, margin 20px, gradient
bg, radius 24px, border + border-top, layered shadow) under four different
class names.
Extract that into a single `.page-shell` primitive (modeled on the canonical
dashboard/stats look) and have the four pages adopt it. Each keeps its bespoke
class for page-specific extras and as a JS/mobile hook:
- dashboard-container: keeps display:flex / column / gap:25px
- watchlist/wishlist-page-container: keep position:relative
- tools-page-container: no extras (box now fully from .page-shell)
Zero visual change: computed styles are identical (declarations relocated, not
altered), and mobile.css overrides still target the retained bespoke classes.
Per-page themed headers (watchlist amber, etc.) are intentionally NOT touched.
The class name is intended for reuse by the React pages too, so the primitive
is shared across both stacks.
Next (wave 2): migrate settings / automations / playlist-explorer / library
onto .page-shell, which snaps their slightly-off spacing to canonical.
The four artist-hero buttons (Artist Radio, Watchlist, Download
Discography, Enhance Quality) had drifted apart visually — different
sizes, weights, radii, hover treatments, and ad-hoc colors. Unify them
on the Download Discography look (the nicest of the set): accent-style
gradient fill, matching border, light-tinted text, compact 7x16 / 12px /
700 sizing, and a translateY(-1px) + colored glow on hover.
To keep them distinguishable, each carries its own hue within that
shared recipe:
- Artist Radio -> violet (#8b5cf6)
- Watchlist -> theme accent (keeps its amber "watching" state)
- Download Discography -> theme accent + shimmer (the primary action)
- Enhance Quality -> cyan (#4fc3f7, its original signature color)
Also:
- Drop the shimmer from the three secondary buttons — four simultaneous
shimmers were distracting; it now marks only the primary action.
- Remove the `margin: 12px 0 4px` on `.discog-download-wrap` (now
`margin: 0; display: inline-flex`) that pushed the discography button
~4px below its siblings in the centered flex row.
- Include Artist Radio in the mobile button sizing override (was missing).
Two things in this commit. Functional download / matched-download
behaviour is untouched — same JS handlers, same routes for the
download actions, same album-expand interaction.
VISUAL REDESIGN
- Glass search-bar card with accent radial wash + focus ring + pill
primary search button
- Source chip row above the search bar (see below)
- Always-visible compact filter pill row (Type / Format / Sort) —
pills carry both ``bs-filter-pill`` (new visual) and ``filter-btn``
(legacy class for ``resetFilters`` + ``applyFiltersAndSort`` in
wishlist-tools.js to keep working)
- Accent-tinted status pill matching the dashboard / auto-sync look
- Album result cards: glass card with accent left-edge stripe,
52px brand-tinted cover icon, chevron expand indicator, pill
action buttons (Download / Matched Album), accent glow on hover
- Track result cards: glass row with accent stripe, 44px icon,
pill action buttons (Stream / Download / Matched Download)
- Multi-disc separators inside expanded album track lists styled
with the accent treatment
- Responsive: action button columns stack vertically below 900px
New CSS lives in a self-contained ``webui/static/basic-search-v2.css``
sheet linked from index.html. Selectors are scoped to
``#basic-search-section`` for any class that already exists in
style.css (``.album-result-card``, ``.album-icon``, ``.track-*``,
etc.); the new ``bs-*`` prefixed classes for the search bar /
filters / source row / status are unscoped because they only exist
in the new markup. ``!important`` is used on the card-level rules
to defeat the original unscoped ``.album-result-card`` etc. rules
in style.css that would otherwise leak heavyweight padding /
box-shadow / 56px icon styles into the new design.
Also removed ``overflow: hidden`` from the original
``.album-result-card`` and ``.track-result-card`` rules in style.css
— those two classes only render in ``downloads.js`` basic search
results (verified via grep, two render sites only), so the
removal can't impact any other UI.
SOURCE PICKER (hybrid mode)
- New ``GET /api/search/sources`` endpoint returns the list of
active sources from the orchestrator's chain (or the single
active source in single-source mode).
- Frontend renders a chip row above the search bar. Click a chip
to target that source for the next search; the chip's brand
accent fills.
- In single-source mode the lone chip is rendered as a dashed-
border label so the user always knows what they're searching
but can't accidentally try to switch to sources that aren't
configured.
- ``/api/search`` accepts an optional ``source`` body param. When
set, ``core/search/basic.py:run_basic_search`` resolves the
client directly via ``orchestrator.client(source)`` and calls
its ``.search()`` instead of going through the hybrid chain.
- Backwards compatible: omitting ``source`` falls through to the
original ``orchestrator.search()`` call exactly as before.
Unknown source names also fall back to the default — typo
protection.
TESTS (5 new + 6 pre-existing = 11 total in test_search_basic.py)
- source param routes to specific client, NOT orchestrator chain
- no source param preserves original orchestrator-default behaviour
- unknown source name falls back to orchestrator default
- ``run_basic_soulseek_search`` backwards-compat alias preserved
- source-targeted path serialises albums + tracks correctly
101 search-suite tests pass.
The sync-tabs row had 14 sources jostling for horizontal space —
labels wrapped to 2 lines, the active pill ate disproportionate
room, the whole strip felt cramped and would only get worse as
more sources get added.
Restyled the strip as circular brand-logo chips. Inactive tabs
are 40px discs that show only the source's icon; the currently-
active tab swells into a pill that reveals its label inline.
Hover surfaces the source name as a native tooltip via the
title attr. Each chip carries its source's brand color as a
hover ring + active fill (Spotify green, Tidal orange, Qobuz
blue, Deezer purple, iTunes coral, YouTube red, Beatport green,
LB orange, Last.fm red, SSD teal).
Three sources share a logo with another source (Spotify Link
/ Spotify, Deezer Link / Deezer, iTunes Link / no native iTunes
but same logo family). Each "Link" variant carries a small
chain-link badge bottom-right so the chip disambiguates without
forcing the label to always be visible.
CSS-only swap — same JS handlers, same .active class, same
data-tab routing. HTML edit wraps each tab's label in a
``<span class="sync-tab-label">`` and adds ``data-link="true"``
to the Link variants so the CSS can target them.
Responsive: chips collapse to 36px on laptop / tablet and 32px
on mobile; the divider hides on mobile and gap tightens.
The sidebar source-group headers (Spotify / Tidal / Qobuz / Deezer /
YouTube / Last.fm Radio / ListenBrainz / iTunes Link / SoulSync
Discovery / Spotify Link) only showed the source name in caps —
the dashboard equalizer + connections panels both render the
actual brand logo, so the sidebar reading as text-only felt
disconnected.
Added a small (18px) circular brand-logo chip to the left of
each source-group title, sourced from the same URLs the
dashboard equalizer avatars use. Dark glass backdrop + accent
ring + drop-shadow on the logo so the chip stays legible
against either light or dark marks; brightness(0) invert(1)
applied to Tidal / Qobuz / iTunes-Link so their dark-foreground
marks render as white silhouettes against the disc (same
recipe the equalizer overrides use). Last.fm's square avatar
PNG clips to a circle via object-fit: cover.
Sources without a publicly available logo (Beatport, file
imports) fall through to no-chip — the <img onerror> swap
hides the broken image so the header still renders cleanly.
The Auto-Sync manager modal had been carrying its original visual
treatment forward unchanged while the rest of the app moved
toward the glassy / accent-radial / gradient-border aesthetic
the dashboard now sets. Restyled every surface inside the modal
to match.
Strategy: selector-based override layer appended at the end of
``webui/static/style.css`` — every selector in the new block
already exists in the original CSS above; the new block wins on
cascade order. Zero HTML / JS changes; functionality untouched.
Delete the v2 block to revert.
Surfaces restyled
- Modal shell: glass + thin accent border + corner radial wash
+ inner top-edge highlight, matching the dashboard ``.dash-card``
architecture
- Header: gradient-clipped title, accent-tinted eyebrow, hairline
accent separator below, spinning-X close button
- KPI summary tiles: dashboard-style gradient tiles, accent
top-edge glow on hover, gradient stat numbers
- Live monitor strip: accent-tinted glass card, status-colored
borders (running = green, error = red)
- Refresh / intro buttons: pill primary with accent fill + glow
on hover (replaces the bare ghost button)
- Tabs: underline-style with accent fill + soft radial glow on
the active tab (replaces the pill-tab look)
- Sidebar: glass panel, source groups as collapsible-feel cards,
accent border on scheduled playlist tiles, accent ring on the
filter input focus
- Board: subtle accent radial spotlight backdrop; columns are
glass cards with gradient headers + accent drag-over glow
- Drop zones: animated dashed pill with accent radial wash;
accent-tinted text on drag-over
- Scheduled cards: accent left-edge stripe, gradient pill timing
badges, pill "Run now" primary + rotating ghost X — health
variants (failing / warning) re-tint the left-edge stripe
- Run history rows: dashboard recent-activity aesthetic, accent
hover lift, pill status badges with colored borders
- Bulk schedule popover: glass card with accent border, pill
buttons, red ghost for unschedule
- Weekly editor: glass modal matching version-modal vibe,
day-toggle pills (accent fill when active), pill save button
- Empty / monitor-empty states: dashed glass card with subtle
vibe
- Soft accent-tinted scrollbars throughout the modal
Last.fm ships a square Twitter avatar; clip it to a circle so the
disc reads as a uniform chip. Tidal / Qobuz / Discogs / Amazon
ship dark-foreground marks that disappear against the dark glass
avatar backdrop — invert to a white silhouette so the logo
actually reads. The per-service accent drop-shadow still applies
so the brand color cue is preserved as a glow around the white
silhouette.
Initial-letter glyphs (SP / AM / DZ / ...) read as placeholder
once the brand-logo equalizer disc was visualised — each chip
should carry the service's actual mark. Wired the same logo
URLs the header-action worker orbs already load (Spotify press
asset, iTunes Wikimedia SVG, Deezer brandfetch symbol, Last.fm
avatar, Genius logo, MusicBrainz Wikimedia SVG, AudioDB local
PNG, Tidal / Qobuz / Discogs SVGRepo marks, Amazon local SVG)
into a new _RATE_GAUGE_LOGOS map and rendered as an ``<img>``
inside the avatar disc.
Visual details
- Disc backdrop switched from a solid accent-gradient fill to a
dark glass radial + accent-tinted ring + accent drop-shadow
on the logo. The service color still anchors the chip without
competing with the logo for contrast.
- Logo sized at 75% of the disc for breathing room. Drop-shadow
pops dark / multi-tone marks against the dark backdrop.
- Avatar bumped to 34px / 28px / 26px across desktop / tablet /
mobile so logos read clearly at every breakpoint.
Resilience
- ``<img onerror>`` swaps in an initial-letter glyph span on
load failure (CDN drop, network blip). The ``.rate-eq-avatar
--fallback`` variant restores the original accent-gradient
disc look so the fallback chip still reads as branded.
Asset
- AudioDB ships no public logo URL — saved the existing header-
action base64 PNG (~30 KB) to ``webui/static/audiodb.png`` so
the equalizer can reference it as ``/static/audiodb.png`` like
Amazon already does.
Four upgrades that take the equalizer row from clean to vibey.
All tied together by the same --eq-accent / --eq-glow CSS
variables so future tweaks stay coherent across the four
animation layers.
1. Brand-color avatar disc above each bar. Circular chip with a
2-3 letter glyph (SP / AM / DZ / LF / GN / MB / ADB / TD /
QB / DC / AZ) and a radial gradient using the service's
accent. Inner highlight + drop-shadow for depth; slow halo
pulse when the worker is running. Anchors each capsule to
its identity so the row reads as "these are your services"
not "these are 11 anonymous bars."
2. Peak-flash detector. When ``cpm`` actually steps upward
between socket updates (above a small jitter floor so
near-zero noise doesn't trigger), the peak tip briefly
flares white-hot, the fill flashes brighter, and the
reflection puddle ripples — all on a 650ms one-shot the JS
removes after fire. Mimics a hardware VU meter's peak-
detect LED. Sells the "alive" feeling by tying bar
movement to real call activity, not just continuous
animation.
3. Rolling-counter number animation. The live count under
the bar digit-animates from old→new with easeOutCubic
over 520ms instead of snapping. Per-element animation
handles tracked in a WeakMap so a fast second update
cancels the prior RAF loop instead of fighting it.
Premium-counter feel.
4. Glass-surface reflection puddle. Soft accent-colored
blurred ellipse under each bar; opacity scales with the
real (unclamped) rate via the --eq-glow variable so idle
bars don't pollute the row with permanent ground-light.
Rate-limited bars get a red puddle. Peak-flash briefly
intensifies the puddle so the surface "ripples" with the
call burst. Mounted on the host button (not the track) so
it escapes the track's overflow clipping.
Responsive: avatar disc shrinks to 26px at laptop/tablet,
24px at mobile.
The rate monitor on the dashboard used a 10-column grid of circular
SVG speedometers. With 11 services configured (Amazon was the
straw), the grid produced 10-in-row-1 + 1-orphan-in-row-2, breaking
the dashboard's tile symmetry. Speedometers also wasted ~80% of
their pixels on empty arc — most services sit at 0 cpm most of
the time, so the row visually read as a wall of empty gauges.
Replaced with a VU-meter / equalizer row: one vertical capsule
per service, brand-color gradient filling from the bottom, bar
height tracks ``calls/min ÷ limit``. Music-app native aesthetic,
fits the existing accent-heavy glassy vibe, and symmetric by
design at any service count — services slot into the flex row.
Visual details
- 4% sliver floor on idle bars so the row reads as "everything
alive" instead of "8 dead gauges" — vibe over literal zero
- Continuous shimmer scan when worker is running (vertical wash)
- Slow breathing pulse on idle bars
- Red gradient + faster pulse when rate-limited
- White-hot peak tip glows in the service's accent color
- Status pill below each bar (Running pulses green, Paused amber)
- Big count number floats top-center of the track
Behavior
- Click any bar opens the same detail modal the speedometer used —
no data-flow changes, no API changes, drop-in visual swap.
- Renderer auto-detects the dashboard context (data-card="enrichment")
and routes through the equalizer path; legacy speedometer code
still ships for any non-dashboard mount.
- Responsive: tightens at laptop/tablet breakpoints, wraps to
5-per-row on phones.
PR 3 of the schedule-types feature — see
``memory/project_auto_sync_schedule_types.md``. Backend
``next_run_at`` + ``weekly_time`` trigger handler landed in PRs 1-2.
This PR exposes them in the Auto-Sync manager so users can finally
schedule playlists by day-of-week + time instead of only hourly
intervals.
**UI layout:**
The Auto-Sync modal grows a ``Weekly Board`` tab between
``Hourly Board`` (renamed from ``Schedule Board``) and
``Automation Pipelines``. Same sidebar (mirrored playlists grouped
by source, with filter). Main panel is 7 day columns Mon-Sun
instead of 10 hour buckets. Drag a playlist onto a day column →
creates a single-day weekly schedule at the default time
(09:00 in the browser's IANA tz from
``Intl.DateTimeFormat().resolvedOptions().timeZone``). Click any
scheduled card → opens an editor popover for time, multi-day
toggles, tz override, and unschedule.
Multi-day schedules render under every matching column (Mon-Wed-Fri
schedule appears as three cards, one per column) — matches how
users think about "this playlist runs on Mon AND Wed AND Fri".
**Mutual exclusion:** one schedule per playlist. The save path on
either tab deletes any existing schedule of the OTHER kind before
installing the new one. Backend can technically run both as two
separate automation rows, but two cards under the same playlist
would surprise users and the engine has no merge semantic for
"daily-and-hourly".
**Pure-function helpers** (testable via node:test, matching the
existing ``tests/static/test_auto_sync.mjs`` pattern):
- ``detectBrowserTimezone()`` — Intl tz with UTC fallback for
browsers where Intl is absent.
- ``autoSyncWeeklyTrigger({time, days, tz})`` — defensive payload
builder: garbage time → 09:00, unrecognised days dropped,
missing tz → browser tz.
- ``autoSyncWeeklyFromTrigger(config)`` — inverse parser with
the same defensive shape. Empty days expands to every weekday
(matches ``next_run_at`` engine semantic). Returns null for
non-object configs so ``buildAutoSyncScheduleState`` can route
broken rows to automationPipelines instead of silently
bucketing them as every-day weekly.
- ``autoSyncWeeklyLabel(parsed)`` — sorted "Mon, Wed, Fri @
09:00" / collapses to "Daily @ HH:MM" for full-week / "Unscheduled"
for null. Canonical Mon-Sun ordering regardless of input order.
**Tests:** 26 new node:test cases across ``detectBrowserTimezone``
x1, ``autoSyncWeeklyTrigger`` x6, ``autoSyncWeeklyFromTrigger`` x6,
``autoSyncWeeklyLabel`` x5, and ``buildAutoSyncScheduleState``
weekly bucketing x5 (covering owned weekly_time → weeklySchedules,
hourly stays in playlistSchedules, non-owned falls through to
automationPipelines, legacy-named auto-sync rows still recognised,
garbage trigger_config falls through). All 62 node:test cases pass;
261 across the automation pytest suite still green (zero regression
on PRs 1-2's plumbing). Python wrapper at
``tests/test_auto_sync_js.py`` shells out cleanly.
**CSS** (themed to the existing Auto-Sync gradient + accent
variables):
- 7-column grid for the weekly board, narrower than the 10
hour-bucket layout.
- Editor popover with backdrop-blur, accent-tinted save / delete
buttons, hover states that pick up the user's accent color.
- ``scheduled-elsewhere`` state for playlists with an hourly
schedule visible on the weekly board (dashed border + opacity)
so the user knows a drop will replace, not stack.
**WHATS_NEW entry** under 2.6.3 unreleased — first user-visible
slice of the schedule-types feature.
PR 4 (Monthly UI tab) deferred until weekly proves wanted.
The mirrored-playlist cards in the Auto-Sync schedule modal's
sidebar were truncating long names with ellipsis on a single line
+ rendering meta info at 10px, which made entries like
"Top Missed Recordings of 2024 for Nezreka" or "ListenBrainz
Weekly Exploration" unreadable.
- Name wraps to multiple lines instead of ellipsis-truncating
(sidebar is narrow; truncation hid critical disambiguating
text like the year / week / username).
- Bumped name 12px → 13px, meta 10px → 11px with brighter color
(0.4 → 0.55 alpha).
- Bumped card padding 10px/12px → 12px/14px + spacing 6px → 8px
so multi-line entries have breathing room.
- Pinned the leading status dot to the first text line via
``margin-top`` so multi-line names flow underneath rather than
push the dot off-center.
Last of the three unified-tab phases. Surfaces the user's
persisted personalized playlists (decade mixes, hidden gems,
popular picks, daily mixes, discovery shuffle, etc.) on the
Sync page so they participate in the mirrored-playlist +
Auto-Sync pipeline like every other source.
Different shape from the LB / Last.fm tabs:
- Tracks already carry Spotify / iTunes / Deezer IDs (matched
at generation time from the discovery pool), so there is NO
MB-style "needs discovery" hop. The mirror is created with
fully-populated ``matched_data`` JSON inline, downstream
consumers (sync, wishlist) see canonical extra_data
immediately.
- Click on a card runs the kind's generator
(``POST /api/personalized/playlist/<kind>/<variant>/refresh``)
+ grabs the fresh track snapshot + mirrors under a synthetic
id of the form ``ssd_<kind>_<variant>`` (e.g. ``ssd_decade_1980s``,
``ssd_hidden_gems``). Re-clicks UPSERT the same row, so the
Auto-Sync schedule survives every refresh.
- Sub-tabs / archive concept don't apply here — each personalized
playlist is already a singleton per (profile, kind, variant);
the manager handles its own rotation.
New file: ``webui/static/sync-soulsync-discovery.js`` (~210 lines).
``initializeSyncPage`` learns a new tab branch. CSS adds
``soulsync-discovery-icon`` (star SVG, teal ``#14b8a6``) +
``.soulsync-discovery-playlist-card`` joins the unified card
selector group with a matching teal accent.
WHATS_NEW entry added under 2.6.3.
236 tests still green; no Python paths touched.
Sibling to the ListenBrainz Sync tab from Phase 1c.1. Last.fm Radio
playlists already live in the same ``listenbrainz_playlists`` table
as LB ones (``playlist_type='lastfm_radio'``) and run through the
same MB-track discovery worker, so this tab is intentionally thin
— list + render + delegate. Card click hands straight off to the
LB Sync-tab click handler since the downstream modal + state
machine are identical.
- ``webui/index.html``: new ``<button data-tab="lastfm-sync">``
+ tab content container between the LB tab and the existing
Import / Mirrored tabs. Plus a ``<script>`` tag for the new
module.
- ``webui/static/sync-lastfm.js`` (new): ``loadLastfmSyncPlaylists``
hits the existing ``/api/discover/listenbrainz/lastfm-radio``
endpoint, ``renderLastfmSyncPlaylists`` mirrors the LB card
shape with a ``📻`` icon + a ``.lastfm-playlist-card`` brand
class, click handler forwards to
``handleListenBrainzSyncCardClick``.
- ``webui/static/sync-listenbrainz.js``: the shared 500ms refresh
loop now iterates LB + Last.fm cards in one pass and treats
either tab as "active" for liveness. No second loop needed.
- ``webui/static/sync-services.js``: new tab-activation branch in
``initializeSyncPage`` mirrors the LB pattern.
- ``webui/static/style.css``: ``.lastfm-icon`` SVG (Last.fm "as"
logo, red), and ``.lastfm-playlist-card`` joins the unified
card selector group with the Last.fm-red accent
(``rgba(213, 16, 7, ...)``).
- ``web_server.py``: the lastfm-radio endpoint now includes
``track_count`` in its JSPF payload (same fix as the LB
endpoints last commit).
- WHATS_NEW entry added under 2.6.3.
Mirrors created from Last.fm radios participate in the same auto-
trim Phase 1c.1's cascade-delete hook does — when the LB manager
rotates a stale ``lastfm_radio`` row out of its 5-most-recent
window, the matching ``source='lastfm'`` mirror row is removed
along with it. Library files stay on disk.
225 tests across adapter + automation suites still green; this
commit adds no Python paths to test.
Two interacting bugs that left LB Sync-tab cards rendering with a
solid orange gradient background instead of the dark glass style
every other Sync-page card uses:
1. **Duplicate element id** ``listenbrainz-tab-content``: the new
Sync-tab content div reused the same id the Discover page's
pre-existing LB section already owned. Two elements with the
same id is invalid HTML, and ``getElementById`` in the refresh
loop was hitting the Sync version first while ``initialize
SyncPage``'s ``${tabId}-tab-content`` lookup could race against
it. Renamed the Sync-page tab id + ``data-tab`` attribute to
``listenbrainz-sync`` (matches the existing ``${tabId}-tab-
content`` convention so the lookup becomes
``listenbrainz-sync-tab-content``). Discover-page LB tab
keeps its original id untouched.
2. **Dead ``.listenbrainz-playlist-card`` rule** at style.css
L36155 painting a solid ``linear-gradient(#eb743b → #d26230)``
over the card. That class was orphaned — no JS or HTML
instantiated it before Phase 1c.1 — but it sat at higher
source order than my unified ``.youtube-playlist-card,
.tidal-playlist-card, ...`` rule, so the bare-class selector
won the cascade and overwrote the dark glass background.
Also removed the matching dead ``.listenbrainz-icon { font-
size: 48px }`` rule and its local ``@keyframes pulse`` copy
(the keyframes are defined in four other live blocks).
3. **Missing LB selectors in unified inner-element rules**:
``.listenbrainz-playlist-card`` was only added to the OUTER
card selector group in the first pass — the inner
``.playlist-card-icon`` / ``.playlist-card-content`` /
``.playlist-card-name`` / ``.playlist-card-info`` /
``.playlist-card-action-btn`` (+ ::before, :hover, :disabled)
selector groups were left out, so the inner elements lost all
their styling. Bulk-added LB to every group so the card
inherits the full glass shell the other sources get, with a
brand-orange ``rgba(235, 116, 59, ...)`` accent matching the
Tidal / Deezer / Spotify-public pattern.
Two bugs from the initial LB tab commit (a7053a60):
1. **All cards showed identical "ListenBrainz Playlist / 0 tracks"
defaults.** The /api/discover/listenbrainz/* endpoints wrap each
entry in JSPF shape — ``{playlist: {identifier, title, creator,
annotation, track}}`` — but renderListenBrainzSyncPlaylists was
reading ``p.title`` / ``p.creator`` / ``p.track_count`` directly,
so every field hit its fallback. Now unwraps the inner playlist
object, extracts the MBID from the identifier URL via
``.split('/').pop()`` (matches buildListenBrainzPlaylistsHtml on
the Discover page), and reads track_count from
``annotation.track_count`` with a fallback to ``track.length``.
2. **The tab looked too orange.** The initial commit gave the
sub-tabs a saturated orange surface that clashed with the rest
of the app, and the new ``.listenbrainz-playlist-card`` class
wasn't in the unified ``.youtube-playlist-card,
.tidal-playlist-card, ...`` selector group — so the card lost
its dark glass base and inherited only my override CSS. Two
fixes:
- Added ``.listenbrainz-playlist-card`` to the unified card
selector group (base + ::before + hover + hover::before + icon)
so it picks up the dark glass background. The brand accent
stripe + hover glow use ``rgba(235, 116, 59, ...)`` matching
the other source cards' subtle accent pattern.
- Sub-tabs reverted to a neutral dark surface (``rgba(255,
255, 255, 0.04)``) with the orange used only as a thin
accent on the active state's border + inset shadow.
- Dropped the ``.refresh-button.listenbrainz`` override so the
refresh button falls back to the user's chosen accent like
the Spotify / Qobuz refresh buttons do.
First user-facing slice of the Discover-to-Sync unification. Adds a
ListenBrainz tab on the Sync page alongside Tidal / Qobuz /
Spotify Public / Beatport / etc. so users can mirror + auto-sync
ListenBrainz playlists from the same surface as every other source,
without detouring through the Discover page.
The Discover-page LB flow already owns all the heavy lifting
(state machine, discovery polling, sync → mirror creation). This
commit adds the Sync-page entry point only — list cached LB
playlists, render cards, pre-fetch tracks on click, hand off to
``openDownloadModalForListenBrainzPlaylist``. Zero backend changes.
- ``webui/index.html``: new ``<button data-tab="listenbrainz">`` +
tab content container with "For You / My Playlists /
Collaborative" sub-tabs and a refresh button.
- ``webui/static/sync-listenbrainz.js`` (new): ``loadListenBrainz
SyncPlaylists`` fetches all three LB cache categories in parallel,
``renderListenBrainzSyncPlaylists`` renders cards in the standard
``.youtube-playlist-card`` shell with the existing phase-state
helpers (so card colors / button text stay consistent with Tidal
/ Qobuz / etc.). Click handler populates the
``listenbrainzTracksCache`` from
``/api/discover/listenbrainz/playlist/<mbid>`` if not already
primed, then defers to the shared modal opener.
- ``webui/static/sync-services.js``: one new branch in
``initializeSyncPage`` to lazy-load the tab on first activation.
- ``webui/static/style.css``: ``.listenbrainz-icon`` SVG (orange
play-button in circle for inactive, white for active),
``.listenbrainz-sub-tab-btn`` styling for the sub-tabs,
``.refresh-button.listenbrainz`` accent.
- ``webui/static/helper.js``: WHATS_NEW entry under 2.6.3.
Auth-not-connected case is surfaced as a friendly placeholder
pointing the user at Settings → Connections instead of an empty
list.
The iTunes Link tab was reusing the generic `import-file-icon` (a
blue document glyph), which read as "import a file" rather than
"iTunes / Apple Music link". Added a dedicated `.itunes-icon`
inline-SVG matching the iTunes 11+ / Apple Music aesthetic —
pink-red circle with a white double-stem note glyph — and switched
the tab button to use it. Stays consistent with the rest of the
tab icons in the file (all inline data URIs, no external fetches).
Also moved the Qobuz tab from between Deezer and Deezer Link to
between Tidal and Deezer, so the Deezer / Deezer Link pair sits
adjacent and the lossless-streaming services (Tidal / Qobuz) group
naturally. Updated the Qobuz Playlist Sync modal-section feature
line to drop the now-stale "between Deezer and Deezer Link"
position claim.
New iTunes Link tab between Deezer Link and YouTube. Accepts album,
track, and playlist URLs from music.apple.com / iTunes. Pulls the
tracklist, runs it through the same discovery -> sync -> download
pipeline as the other link tabs.
Apple Music playlists go through amp-api with a Bearer JWT scraped
from the SPA. The legacy meta-tag and inline `"token":"..."` paths
are gone in the current music.apple.com SPA, so the extractor now
walks the page's `<script src>` list (prioritising index/chunk/main
bundles), fetches up to 8 JS bundles, regex-matches JWT-shaped
strings, and base64-decodes each payload to confirm it carries
Apple media-api claims (`root_https_origin`, or `iss + iat + exp`)
before trusting it. Filters out analytics / error-reporter JWTs that
also ship in the bundle.
Tokens are cached at module scope for 6h behind a threading.Lock so
the three-worker discovery executor doesn't thunder-herd Apple on
cold start, and amp-api calls go through a single helper that on
401 invalidates the cache, refetches the page, force-refreshes the
token, and retries the request once. The playlist fetcher memoises
the page HTML for the cache-miss path so we don't refetch it for
every paginated `/tracks` page.
spotify_public discovery worker accepts the new platform shape so
iTunes Link reuses the same matching code path as Deezer Link and
Spotify-public. UI bits live in the sync-services.js iTunes Link
tab, with platform plumbing through wishlist-tools.js for the
multi-source state map.
Auto-Sync: equalizer cycle slowed 1.6s -> 3.2s, amplitude swing
tightened (0.4-1.0x of base height -> 0.55-0.85x) so the bars
breathe instead of slamming. Playhead duration slowed 5.5s -> 9s
and the line was thinned + given a softer accent color (rgba 0.7
instead of full light) and a smaller drop-shadow. Playhead now
fades in over the first 10% and fades out over the last 15% so it
glides on and off rather than appearing at the edge.
Automations: the flow line was using a background-position sweep
that snapped from end to start each loop — visible as a reset jump
every cycle. Rewrote the sweep as a pseudo-element with its own
translateX + opacity animation: fades in at 15%, runs across, fades
out before snapping back. Node pulse + line sweep both run on the
same 3.2s cycle now so the three nodes and two lines stay in
phase. Node animation delays adjusted to evenly stagger across the
new cycle length.
Two tweaks based on usage feedback.
Automations flow was anchored at \`right: -8%\` which pushed the
trigger->action->notify chain off the right edge of the minor tile.
Repositioned to fill the bottom of the tile with left/right inset
matching the tile padding, and bumped the base opacity from 0.25 to
0.45 so the chips are actually visible without hovering. Connecting
lines now have a 60%-wide bright accent sweep that travels
left-to-right along each segment in sync with the node pulses, so
the flow reads as a signal propagating through the chain rather
than three nodes blinking in place.
Auto-Sync hero gets a vertical accent playhead that scrolls
left-to-right across the equalizer bars on a 5.5s loop — a
now-playing scrubber overlay that adds horizontal motion to the
existing vertical bar pulse. Drop-shadow filter gives it a soft
glow as it passes over each bar. prefers-reduced-motion disables
both the playhead and the new line sweep.
Auto-Sync hero on the left (spans both rows), Tools + Automations
stacked on the right. Each tile gets a CSS-only ambient animation
that visually represents what that section does — no more three
identical rectangles.
Auto-Sync (hero, 2 rows tall): 20-bar live equalizer animates along
the bottom edge with per-bar offsets so it reads as a real audio
waveform. Foreground has a live status pulse dot + accent kicker,
big 56px icon, large title, description, and a CTA bar separated
by a hairline rule.
Tools (top-right): an oversized gear icon rotates slowly off the
right edge as a watermark. Hover speeds it up (28s -> 12s) and
brightens the tint.
Automations (bottom-right): three nodes connected by gradient lines
pulse in sequence, mimicking trigger -> action -> notify flow. Each
node glows + halos on its phase.
Card recipe (gradient body, top accent stripe, accent border on
hover, multi-layer shadow) is the same library-status-card vocab
the rest of the dashboard already uses. Container query
(container-type: inline-size) drives every dimension via
clamp(min, Ncqw + base, max) so padding, text, icon, and animation
sizes scale with the actual card width — no overflow on narrow
dashboards. Single-column stack at <=560px.
prefers-reduced-motion disables all three signature animations.
Two upgrades to the schedule board:
Bulk schedule. Each source group in the sidebar gets a small "Bulk"
button next to the title. Clicking it opens a popover with the same
ten standard buckets plus "Custom interval…" (prompts for hours) and
"Unschedule all". Picking a bucket POSTs/PUTs the schedule for every
schedulable playlist in that source. Result toast aggregates ok/fail
counts. Big quality-of-life for "I want every Spotify playlist
weekly" without 30 individual drags.
Custom interval columns. The board's column set is no longer the
hardcoded `AUTO_SYNC_BUCKETS` list — it's the union of those plus any
hour values currently in use by playlist_schedules. A 6h or 36h
schedule (created via the bulk custom prompt, or hand-edited in the
Automations page) now renders as its own dashed-border column instead
of silently disappearing from the board because it didn't match a
standard bucket. Standard columns still render solid; custom ones get
a "custom" eyebrow + dashed border so they're visually distinct.
Six small UX additions on the Playlist Auto-Sync manager:
- Sidebar gets a "Filter playlists…" search input. Re-renders only
the schedule panel on input so focus is preserved while typing.
- Scheduled cards show a red `!` badge + red border tint when the
last three pipeline runs failed (yellow `⚠` if at least one of the
last few failed). Surfaces chronically broken schedules visually
instead of leaving them indistinguishable from healthy ones.
- Run History tab title shows a red error count badge when there are
failed runs in the loaded window.
- Run History tab body gains All / Errors / Completed filter pills
with per-bucket counts.
- Load-more button at the bottom of the history tab pulls another
50 entries (capped at 500).
- "Run pipeline again" button in the expanded detail of each history
card re-triggers that playlist's pipeline directly.
Also dropped the "Discovered: completed" result pill — `tracks_discovered`
in the result payload is a status string, not a count, and the same
data is already in the before/after stats grid above.
Three problems wrapped into one pass on the Playlist Auto-Sync surface:
1. Visual: the manager modal had its own vibe (radial gradient, pill
tabs, sky-blue chrome) that didn't line up with the rest of the
app. Reworked the modal shell, KPI summary, live pipeline monitor,
tab bar, schedule board sidebar, and column cards to use the
standard SoulSync patterns — gradient `#1a1a1a → #121212`,
accent-tinted 1px border, 20px radius, underline tabs, dense dark
card pattern that Automations + Library pages already use. Modal
now uses near-full screen so there's room for the schedule board
without horizontal scroll pain. Run history cards followed the
same path: slim horizontal row mirroring `.automation-card` plus
an expanded detail that mirrors the Automations run-history modal
(stats-grid + facts row + result pills + log section).
2. Hang: the previous SQL fix for the run-history "in library" count
added `COLLATE NOCASE` on the join columns of `tracks` and
`artists`. SQLite can't use `idx_artists_name` or `idx_tracks_title`
when the comparison collation doesn't match the column collation,
so the join did a full table scan per mirrored playlist track.
~18s per playlist × 30 playlists = `/api/mirrored-playlists` hung
indefinitely and the modal stayed at "Loading schedule…" forever.
Switched the join back to case-sensitive equality (~6ms per
playlist, 3000× faster). Spotify names canonicalize to the same
form as library imports so the recall loss is in the rounding
error of pure case-only mismatches.
3. Slowness: even after the hang fix, each modal open spent ~1.5s
gathering per-playlist status counts. The endpoint looped
`get_mirrored_playlist_status_counts(playlist_id)` per row, which
opened a fresh SQLite connection + PRAGMA setup each time. Added
`get_all_mirrored_playlist_status_counts(profile_id)` which
returns counts for every mirrored playlist owned by the active
profile in 4 batched `GROUP BY` queries over a single connection.
Modal load dropped to ~280ms.
Also fixed: `tracks.artist` reference in `get_mirrored_playlist_status_counts`
that never worked since the schema went relational — the query threw
"no such column", got swallowed by the try/except, and the in-library
count silently defaulted to 0 on every playlist. Rewired to join
through `artists`.
`get_mirrored_playlist_status_counts` (single-playlist) kept for
callers that still want it, but the modal endpoint uses the batched
version.
Flatten playlist pipeline history cards into a stable header, flow, preview, and detail structure so the run history does not collapse into broken line rows. Keep explicit expand bindings and preserve the richer detail payload rendering.
Bind run history card expand interactions after the modal renders instead of relying on inline handlers, and reshape the run cards into a clearer two-row layout with controlled metadata, preview chips, and roomier expanded detail padding.
Make playlist pipeline run history cards clickable and keyboard-accessible, with expanded detail sections for summary stats, timeline, before/after snapshots, result payload fields, and future run logs. Refresh the card styling so the expanded state remains responsive inside the Auto-Sync modal.
Bring Auto-Sync automation and run-history cards closer to the Automations page pattern with status dots, flow chips, compact metadata, and denser run preview details.
Normalize sparse playlist pipeline history rows before rendering and add a visible fallback for empty entries so the Run History tab cannot collapse into unreadable divider lines.
Render playlist pipeline history as visible run cards with fallback summaries, preview chips, metadata, Details controls, and an explicit empty result message for sparse payloads.
Give placed playlist cards a dedicated content wrapper, full-width action row, wider board columns, and defensive wrapping so titles, timing badges, and Run now controls stay inside card bounds.
Compact the inactive pipeline monitor, widen schedule columns, increase board gutters, and rework scheduled playlist cards so Run now and remove actions no longer crowd playlist text.
Persist per-playlist pipeline run snapshots from the shared playlist pipeline, expose a history API, and upgrade the Auto-Sync modal with live pipeline monitoring, Run now controls, and a runs-style history tab.
Update the dashboard Quick Actions tile to use the shared accent color variables for lane glow, icon chips, borders, hover states, and keyboard focus while keeping the three-destination launcher responsive.
Replace the old Tools CTA with a unified three-lane dashboard launcher for Tools, Auto-Sync, and Automations, using restrained glass/accent styling and responsive stacked behavior.
The Playlist Auto-Sync schedule board was showing "next in 8h" on every
card regardless of the configured interval. Root cause: backend stores
next_run as a naive UTC string ("2026-05-25 05:00:00") and the new
auto-sync renderer was parsing it with plain `new Date(...)`, which
treats unmarked timestamps as local time. On Pacific time that offsets
the displayed countdown by ~8 hours. Auto-Sync now routes through the
existing `_autoParseUTC` helper that the rest of the Automations page
already uses, so countdowns line up with the wall clock.
A separate correctness fix in the automation update API: when a PUT
changes `trigger_type` or `trigger_config`, the stored `next_run` is
now blanked before the engine reschedules. Previously the scheduler's
restart-survival path would preserve a stale future timestamp from the
prior interval, so dragging a playlist from the 8h column to the 1h
column kept firing at the old 8h mark. Boot-time restart behavior is
unchanged — only user-driven schedule changes reset the clock.
Modal restyle: the Auto-Sync manager's hardcoded sky-blue palette is
replaced with `var(--accent-rgb)` everywhere so the modal honors the
user's chosen accent color. Tinted glow on the modal border, tabbed
header active state, scheduled-playlist chips, scrollbars, and a new
drag-over highlight on columns all follow the accent theme. The
column drag-over state is wired through new ondragleave handling so
the highlight clears reliably when leaving a column.
Constrain Auto-Sync columns inside the modal with per-column vertical scrolling, add responsive layouts for narrower and shorter viewports, and separate schedule interval labels from next-run timing.
Also prevents unsupported mirrored sources from being scheduled into the playlist pipeline while still showing them as unavailable in the sidebar.
Upgrade the Auto-Sync modal into a tabbed manager with a richer schedule board and a separate read-only Automation Pipelines tab for existing playlist_pipeline automations.
Add a Sync-page Auto-Sync manager with source-grouped mirrored playlists, interval columns, and drag/drop scheduling backed by playlist_pipeline automations.
Schedules created by the board are editable there, while existing custom pipeline automations are shown as locked automation-managed entries.
Rename the manual pipeline button to Auto-Sync and make non-JSON endpoint failures show an actionable restart message instead of a raw JSON parse error.
Expose playlist-native run and status endpoints that reuse the shared mirrored playlist pipeline engine while routing progress into playlist UI state.
Add a Run Pipeline action to mirrored playlist cards and modals with live status polling, and make the shared pipeline lock atomic for manual and scheduled callers.
Centralize mirrored playlist source reference normalization so edited links and IDs are stored consistently. Preserve URL-backed refresh refs, surface missing-source refresh failures, count background sync failures in pipeline summaries, and retry guarded automation skips after a short delay instead of losing a scheduled run. Add focused coverage for source refs, mirrored playlist source updates, refresh failures, and guarded retry behavior.
Refactor and enhance the player radio feature: add npSetRadioMode, npQueueHasNext, and npEnsureCurrentTrackInQueue helpers to centralize radio-state changes and conditional radio fetch logic; replace direct npRadioMode toggles with npSetRadioMode in the expanded player and artist-radio flow (now awaits playLibraryTrack and triggers fetchIfNeeded). Add accessibility (aria-pressed) and label/pulse elements to the radio button, and update CSS for improved visuals and active-state animation. Also adjust toasts/messages and ensure the current library track is seeded into the queue when needed.
Move the artist watchlist and discography actions into the main artist hero action row so they sit with Artist Radio and Enhance Quality. Apply a shared compact pill treatment for the hero actions while preserving the existing button IDs and click behavior.
Expand matched MusicBrainz release groups into concrete releases for specific album searches so import users can choose the correct edition by track count, format, country, and disambiguation. Preserve distinct MusicBrainz release IDs instead of deduping same-title variants, carry release metadata through import matching, and surface those details on album result cards. Add coverage for variant preservation and release-group expansion.
Qobuz joins Tidal and Deezer as a first-class playlist sync source.
New Qobuz tab on the Sync page lists user playlists + a virtual
Favorite Tracks entry, and clicks route through the same discovery →
sync → download pipeline the other services already use.
Backend:
* core/qobuz_client.py — new get_user_playlists, get_playlist,
get_user_favorite_tracks, get_user_favorite_tracks_count. Returns
normalized dicts (matches Deezer client shape, not Tidal's
dataclasses) so the discovery worker can iterate directly without
duck-typing. Virtual `qobuz-favorites` ID dispatches to favorites
fetcher inside get_playlist — same trick Tidal uses with
COLLECTION_PLAYLIST_ID. Both list endpoints paginate against
Qobuz's 500-cap limit.
* core/discovery/qobuz.py — new worker module. Mirrors
core/discovery/deezer.py: pause enrichment, iterate tracks,
hit discovery cache, fall back to _search_spotify_for_tidal_track,
build wing-it stub on miss, sync results to mirrored playlist.
* web_server.py — adds /api/qobuz/playlists, /playlist/<id>,
/discovery/start/<id>, /discovery/status/<id>, /discovery/update_match,
/playlists/states, /state/<id>, /reset/<id>, /delete/<id>,
/update_phase/<id>, /sync/start/<id>, /sync/status/<id>,
/sync/cancel/<id>. One-for-one with the Tidal + Deezer endpoint
sets. Qobuz discovery executor registered for clean shutdown.
Frontend:
* webui/static/sync-services.js — full handler set (loadQobuzPlaylists,
createQobuzCard, openQobuzDiscoveryModal, startQobuzDiscoveryPolling,
startQobuzPlaylistSync, startQobuzSyncPolling, cancelQobuzSync,
startQobuzDownloadMissing, rehydrateQobuzDownloadModal, etc.).
Reuses the shared YouTube discovery modal via fake `qobuz_<id>`
urlHash and is_qobuz_playlist flag. Shared switch statements in
getModalActionButtons / generateTableRowsFromState / Wing It helpers
in downloads.js gain new isQobuz branches alongside the existing
per-service ones.
* webui/index.html — new Qobuz tab button + content div, slotted
between Deezer and Deezer Link.
* webui/static/style.css — new .qobuz-icon for the tab icon.
* webui/static/core.js — qobuzPlaylists / qobuzPlaylistStates /
qobuzPlaylistsLoaded globals.
Followed the existing per-service pattern verbatim rather than
refactoring the duplicated transformers across Tidal / Deezer /
Spotify-public / YouTube / Mirrored — that refactor is its own follow-up
PR per the "don't break Tidal/Deezer" scope discipline. Adding the 6th
copy of a proven pattern is lower risk than collapsing 5 working
services behind a new abstraction.
Tests:
* tests/test_qobuz_playlists.py — 12 tests covering pagination,
normalization, favorites virtual-ID routing, artist-name fallback
chain (performer → album.artist → 'Unknown Artist'), and
unauthenticated short-circuits.
Import album search silently fell through to the next source in
METADATA_SOURCE_PRIORITY when the configured primary returned zero
matches — intentional behavior shared with the auto-import worker
(see core/auto_import_worker.py:1316). With MusicBrainz selected and
a query MB couldn't resolve, users saw Deezer cards with no indication
their primary was bypassed.
Backend now echoes `primary_source` on /api/import/search/albums,
/api/import/search/tracks, and /api/import/staging/suggestions.
Frontend renders a per-card 'via {source}' badge when the served
source differs from the primary, plus a banner above the grid when
every card came from a fallback source. Fallback semantics unchanged.
Also collapses an inline duplicate of _renderSuggestionCard inside
importPageSearchAlbum into a single shared renderer.
Regression test pins the contract: response carries primary_source +
per-album source when the chain falls back.
Add dynamic level badges to the hybrid source order settings. The first enabled source shows Album-level only when it supports album-bundle downloads; every other source shows Track-level to make fallback behavior visible.
Update the helper copy and badge styling so users can understand why putting Soulseek, Torrent, or Usenet first changes album-download behavior.
- delete the old stats page HTML, JS, and CSS now that the React route owns the experience
- preserve helper/tour selectors by exposing the legacy stats ids from the React page
- move shared track playback fallback into library code
- convert the sidebar nav to real links with URL-driven state
- intercept left-clicks so internal navigation stays in-app while preserving native browser link behavior
- keep artist-detail transitions param-aware and update route tests
Three related improvements to the now-playing media player and the
"add to wishlist" / "download missing" modals.
1. Play buttons across track-list modals
Every track row in the download-missing modals (Spotify, Tidal,
YouTube, services, artist album, wishlist download-missing) and
the add-to-wishlist modal now carries a play button. Click runs
playTrackFromLibraryOrStream:
- If the track has a local file_path → playLibraryTrack
- Else POST /api/stats/resolve-track to find it in the library
by title + artist → playLibraryTrack
- Else fall back to _gsPlayTrack streaming
Backend ownership response gains track_id / title / file_path so
the wishlist modal's owned tracks can hand the right metadata
to the player without an extra round trip.
The add-to-wishlist modal previously showed the play button only
on owned tracks; now the button is unconditional so the streaming
fallback can take over for unowned ones (matches the standard
pattern from the rest of the app).
2. Clean media-player display titles
YouTube / Tidal / Qobuz / torrent / usenet plugins encode their
source-side identifier into the filename field as
<source_id>||<display> so download() can recover it later. The
media player's track-title renderer never knew about this
convention and showed strings like
"wvgFsXoGFnQ||Sometimes I Cry When I'm Alone" verbatim in the
now-playing UI. extractTrackTitle and setTrackInfo now strip the
<id>|| prefix defensively so any path into the player gets a
clean display.
Local library playback also fetches canonical metadata from
/api/stats/resolve-track when track.id is present so title /
artist / album / album art come straight from the SoulSync DB
instead of whatever the caller passed in. Falls back silently
to caller values on any error so playback never blocks on the
metadata fetch.
3. Lyrics panel + View Artist close
New collapsed lyrics panel between the playback controls and
queue panel. POST /api/lyrics/fetch (new backend endpoint)
prefers the local .lrc / .txt sidecar files SoulSync writes
during post-processing so downloaded tracks resolve lyrics with
zero network hits; falls back to LRClib exact-match (when album
+ duration are available) then to LRClib search.
Synced LRC results are parsed (handles multi-stamp lines for
repeated choruses), and the active line highlights + smooth-
scrolls into the middle of the viewport on every audio
timeupdate. Plain-text results render without highlighting.
Per-track cache prevents re-fetching when the user revisits the
same track. Lyrics fetch is fire-and-forget — failure shows
"No lyrics found" without ever blocking playback.
View Artist on the expanded player now calls
closeNowPlayingModal before navigating; the modal was previously
sitting open over the artist page, hiding it. Handler is bound
once and is a no-op when no artist_id is attached.
CSS additions are additive (new .modal-track-play-btn and
.np-lyrics-* rules); no existing styles touched. Backend endpoint
returns 200-with-success-false on any miss so callers can render
"no lyrics" without treating it as an error.
WHATS_NEW updated under 2.5.9 with two entries (lyrics + View
Artist close).
Clarifies album-bundle progress text in the download modal and active downloads panel so release-first downloads read as downloading a release, then matching tracks after staging.
Adds waiting-state copy and tooltips for rows blocked on release staging, plus source-specific library history badge styling for Torrent, Usenet, Staging, and Auto-Import.
Route torrent and Usenet album bundles through private per-batch staging so Auto-Import cannot race public staging or duplicate imports.
Expose album-bundle progress in batch status and render it on the Downloads page while the external client is still downloading.
Tighten release handoff safety by rejecting archive path traversal, ignoring torrent candidates without a usable URL, and skipping Soulseek source reuse for torrent/Usenet batches.
Tests: .venv/bin/python -m pytest tests/downloads/test_downloads_status.py tests/test_album_bundle_dispatch.py tests/downloads/test_downloads_staging.py tests/test_torrent_usenet_plugins.py
Torrent and usenet clients each download to their own folders
(not Soulseek's). SoulSync needs read access to those paths to
import the resulting files. Bare-metal setups work without
configuration; Docker setups need volume mounts; remote
downloader hosts need a network mount.
- webui/index.html: orange warning card on the Indexers &
Downloaders hero, listing the three deployment shapes
(bare-metal / Docker / remote) and what each needs.
- webui/static/style.css: ind-hero-warning rule set —
warning-tone palette (amber on dark glass) so the card
reads as advisory, not destructive. Inline ul + code
styling for the bullet list inside.
- docker-compose.yml: commented placeholder mounts under the
existing IMPORTANT block for /downloads/torrents and
/downloads/usenet. Same uncomment-and-edit pattern as the
existing slskd helper block. Documents the in-container path
must match what the torrent / usenet client reports as its
save_path.
Restructure the Indexers & Downloaders tab to mirror the
Paths & Organization / Post-Processing / Library Preferences
pattern on the Library page — each subsystem (Indexers / Torrent
Client / Usenet Client) gets its own collapsible section header
with a status dot, hint, and animated arrow.
Visual cues borrowed from Lidarr but rendered in SoulSync's
existing dark-glass theme:
- Intro hero card at the top of the tab with a 1-2-3 flow:
Indexers find releases → Downloader fetches → SoulSync imports.
Accent-color stepper pills + sub-copy summarising what's
optional vs required.
- Status dot in each section header — grey 'unknown' before
testing, green after Test Connection succeeds, red on failure.
Driven by _setIndStatusDot() helper called from each test
handler. Soft glow on the active states.
- Per-service service-title color accents matching existing
spotify-title / tidal-title pattern: prowlarr-title (orange,
Prowlarr brand), torrent-title (sky blue, qBit family),
usenet-title (violet).
- Indexer list cards replace the inline-emoji list — proper
protocol badges (Torrent vs Usenet pill), monospace id chip,
privacy tag, dimmed appearance when the indexer is disabled
in Prowlarr.
- Indexers section starts open; Torrent + Usenet start collapsed
since most users only configure one protocol.
No behavior changes — same fields, same endpoints, same save
flow. Pure visual restructure of the panels added in the previous
three commits.
- replace click-driven artist-detail hops with semantic links
- keep SPA transitions via shell bridge interception for /artist-detail/:source/:id
- drop legacy page helper wrappers and dead bridge plumbing
- watchlist_scanner: fall back to album.image_url when album object has no
images list (affects MusicBrainz CAA URLs, iTunes, Deezer — all use
image_url on the Album dataclass, not the Spotify-style images array)
- Pulse Downloads nav icon while active downloads are in progress, same
pattern as watchlist scan animation
Add MusicBrainz watchlist artist ID storage, badges, linked-provider editing, and per-artist preferred source support.
Backfill watchlist MusicBrainz matches from already-enriched library artists so existing MusicBrainz worker matches appear in watchlist cards and settings.
Extend bulk watchlist add, liked artist matching, artist map source picking, and service status labels to recognize MusicBrainz, with regression tests for watchlist ID persistence and backfill.
Show actionable missing album tracks in the enhanced library from canonical metadata, with a practical Manage flow for Add to Library or I Have This.
Implement I Have This as a non-destructive copy/import path: copy the chosen existing file, run normal post-processing with the missing track context, insert the real library row, and inherit album identity tags from target siblings so Navidrome does not split albums.
Improve the modal with selectable search results, visible import progress, disabled controls during import, and missing-track row styling.
- Artist cards, hero section, and enhanced view now show Amazon Music badges
when amazon_id is populated (AMAZON_LOGO_URL constant, orange #FF9900 brand)
- Enhanced view artist and album match status rows include amazon_match_status
chip with click-to-rematch via openManualMatchModal
- getServiceUrl: added amazon (album/track ASIN → music.amazon.com) and fixed
missing discogs entries; serviceLabels adds tidal/qobuz/amazon
- Enhanced view enhanced-artist-id-badges includes amazon_id entry
- DB SELECTs for library artists list and artist detail now return amazon_id;
both response dicts include the field
- watchlist_artists migration adds amazon_artist_id column
- Watchlist config GET: amazon_artist_id in SELECT/WHERE/response (index 18)
- Watchlist artists list response includes amazon_artist_id
- link-provider endpoint: amazon added to valid_providers and col_map
- _populateLinkedProviderSection: amazonId param + Amazon Music source row
- Watchlist card source badges render Amazon pill (watchlist-source-amazon CSS)
- _openSourceSearch labels map includes amazon
- service_search: amazon_worker injected via init(); _search_service amazon branch
uses search_artists/albums/tracks, same {id,name,image,extra} return shape
- _SERVICE_ID_COLUMNS: amazon → amazon_id for artist/album/track
- _init_service_search call passes amazon_worker_obj
- amazon_client._fetch_album_metas: 5-minute TTL cache per ASIN — cached hits
skip _rate_limit() and HTTP call entirely; fixes ~10s artist detail load
- registry.py: removed amazon from METADATA_SOURCE_PRIORITY and
METADATA_SOURCE_LABELS — T2Tunes has no discography API, cannot serve as a
primary metadata source; Amazon remains a download source + ASIN enricher
- Settings metadata source dropdown and help text updated accordingly
Adds full parity with Deezer/Qobuz/Tidal/Discogs in every dashboard
UI layer — orb button, live tooltip, WebSocket push, rate speedometer.
- webui/index.html: Amazon enrichment orb button after Discogs
- webui/static/amazon.svg: local icon (a + smile, same pattern as
hydrabase.png — avoids external URL dependency)
- webui/static/style.css: Amazon button/spinner/tooltip CSS with
FF9900 brand color; added to mobile tooltip suppress list
- webui/static/worker-orbs.js: Amazon orb in WORKER_DEFS [255,153,0]
- webui/static/api-monitor.js: Amazon in rate gauge services list,
label, and color map
- webui/static/enrichment.js: updateAmazonEnrichmentStatusFromData,
toggleAmazonEnrichment, DOMContentLoaded init + 2s poll
- webui/static/core.js: socket.on enrichment:amazon-enrichment listener
- web_server.py: amazon-enrichment added to _emit_enrichment_status_loop
workers dict so WebSocket pushes fire every 2s
- Add 'amazon' to VALID_SOURCES (and transitively VALID_STREAM_SOURCES)
in core/search/orchestrator.py so the backend accepts it as a
requested source without returning 400
- Add resolve_client('amazon') case — mirrors musicbrainz pattern,
gets the cached AmazonClient from the metadata registry
- Add 'amazon' to _alternate_sources() so it appears as a tab when
another source is primary (always available, no credentials)
- Add SERVICE_CONFIG_REGISTRY entry 'amazon': {'always': True} so
/api/settings/config-status reports it as configured
- Add SOURCE_LABELS['amazon'] and SOURCE_ORDER entry in
shared-helpers.js so both enhanced search and global search show
the Amazon Music tab
- Add 'amazon' to _ALWAYS_CONFIGURED_SOURCES so the picker never
dims the tab (no credentials required)
- Add .enh-tab-amazon.active CSS (Amazon orange #FF9900)
- 3530 tests pass
Two-layer accent glow that follows the cursor across the bento grid:
- Soft halo (1280px, blur 48) lerps toward target with a delay; bright
inner core (540px, blur 18, screen-blended) lerps faster.
- Both layers gently pulse on different rhythms so the blob feels alive
even when stationary.
- Target = cursor position when hovering any .dash-card; otherwise the
grid center (idle resting position). On leaving cards/gap, blob waits
1.5s before drifting back to center -- a small dwell that lets it
feel intentional rather than skittish.
- Card backgrounds darkened to near-black with stronger borders for
contrast against the accent glow.
Performance:
- requestAnimationFrame loop runs only while the blob is moving and
idles when settled at the target.
- Two-pass per frame: read all getBoundingClientRect() first, then
write CSS vars in a second pass -- one layout flush per frame
instead of one per card.
- IntersectionObserver snaps to grid center the first time the
dashboard becomes visible (handles the case where home page is
hidden at attach time).
Honors the existing reduce-effects setting:
- CSS hides both blob layers via body.reduce-effects.
- JS MutationObserver on body class kills the rAF loop when toggled
on; re-snaps to center and restarts when toggled off.
- prefers-reduced-motion media query disables the pulse animations.
Replaces the old stacked dashboard with a bento grid: services, stats,
library, syncs, tools, activity, enrichment each live in their own card.
- 3-col on desktop (>=1500px), 2-col on laptop, 2-col tighter on tablet,
1-col stack on mobile (<700px). Sub-grids inside each card adapt at
every breakpoint (service tiles 3-2-1, stat cards 3-2, gauge tiles
10-5-4-3-2).
- Cards use the user's accent color for glow + hover border + CTA icons
(was hardcoded per-card hues).
- Mount fade-up with per-card stagger; subtle bloom drift; reduced-motion
honored.
- Enrichment row collapses the per-service gauge tile (hides the 3-stat
row, scales the gauge SVG to fill the tile width) so all 10 services
fit on one row at desktop.
- Recent syncs stacks vertically inside its bento card instead of
overflowing horizontally.
- Every existing id, button, and JS hook preserved -- no behavior change,
pure visual + responsive overhaul.
Audit-trail PR added two buttons to the Downloads page — one always
visible next to the 'Batches' panel title, one inside the collapsible
'Recent History' header. User wants only the Recent History one.
Removes the panel-header button + the unused
.adl-batch-panel-header-actions style. Recent History button +
the original Dashboard button remain.
- Delete the static issues page renderer and detail modal helpers
- Keep the React issues route as the only implementation
- Drop the dead mobile CSS and troubleshooter hook that only targeted the removed shell
- File-based routing with tanstack router
- Persist top-level navigation state in url, even for most legacy pages
- Striving for an intuitive and simple folder structure where
route-related code is colocated, but the amount of files is still
kept to a minimum
- Replace native fetch with `ky`
- Familiar api, but more polished
Closes#572 (rhwc).
Navidrome has no API for setting an artist image — it reads
`artist.jpg` (or `folder.jpg`) from the artist folder during
library scans. SoulSync's `update_artist_poster` for Navidrome
was a no-op, so users only ever saw album-art-derived thumbnails
as the artist photo.
- new "Write Artist Image" button on artist detail page
- POST /api/artist/<id>/write-image-to-disk derives the artist
folder from any track's resolved file_path (reuses
_resolve_library_file_path so docker mount translation +
library.music_paths probes from #558 apply), fetches the photo
from the configured metadata source priority chain, downloads
with content-type validation, writes atomically via
`<filename>.tmp + os.replace`
- when active server is Navidrome, triggers a library scan
immediately so the file is picked up
- respects existing artist.jpg (frontend prompts before
overwriting) so user-supplied photos aren't clobbered
- works for plex / jellyfin too as a fallback layer — both
servers also read artist.jpg from disk
26 tests pin the pure helpers in core/library/artist_image.py:
folder derivation (trailing sep / empty / non-string), URL
picking (missing attr / whitespace / non-string), download
(non-image content-type / 404 / timeout / empty body), atomic
write (replace / temp-cleanup-on-failure / overwrite guard /
missing folder).
- new "Audit" button on each download row in the library history
modal opens a second modal visualizing the download lifecycle as
an interactive horizontal stepper (request → source → match →
verify → process → place) with click-to-expand detail cards
- hero header with album art + track title + meta line + status
pills (source / quality / acoustid result)
- three tabs: Lifecycle / Tags / Lyrics
- Tags tab reads the audio file live via mutagen at audit-open
time via new GET /api/library/history/<id>/file-tags endpoint;
file is the single source of truth so background enrichment
writes (audiodb / lastfm / genius / replaygain / lyrics fetch)
show up too. flat key/value rows stacked vertically (label-above-
value) so long MBIDs / URLs / joined genre lists wrap cleanly.
source IDs grouped per-service into 2-col sub-card grid.
- Lyrics tab renders the full transcript with dimmed timecodes.
- post-processing step infers observable changes from source-vs-
final state (format conversion, file rename via tag template,
folder template).
- "Download History" button also added to the Downloads page batch
panel header so it's reachable outside the dashboard.
- mobile responsive: tabs + stepper scroll horizontally, modal
goes full-screen, hero stacks below 480px.
19 helper tests pin the mutagen reader: id3 (TIT2/TPE1/TALB + TXXX
+ USLT + APIC), vorbis (FLAC dict + _id/_url passthrough), file
metadata (format / bitrate / duration), defensive paths (empty /
missing file / mutagen returns None / mutagen raises), stringify
edge cases (list / tuple / int / frame-with-text / whitespace).
Adds an explicit field to the Album dataclass in core/metadata/types.py
and the client-level Album dataclasses in deezer_client.py,
itunes_client.py, and hydrabase_client.py (the legacy discography path
reads from client objects, not typed dicts).
Deezer extracts explicit_lyrics (int→bool), iTunes extracts
collectionExplicitness ('explicit' string), Hydrabase forwards the
explicit field from the server response. Spotify, Discogs, MusicBrainz,
Qobuz, and Tidal have no explicit signal and stay None.
The flag threads through both builder functions in discography.py and
renders as a small "E" badge next to explicit titles in the discography
download modal and artist-detail page cards.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Card titles in the discography modal now display their full text
across multiple lines rather than being cut off with an ellipsis.
Artwork and the selection checkbox are pinned to the top of the card
so they align with the first line of text when titles wrap.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Discord report (CJFC, 2026-04-26): syncing a Spotify playlist to the
server overwrote anything manually added to the server-side playlist.
The fix adds a per-sync mode picker next to the Sync button on the
playlist details modal — Replace (default, current delete-recreate
behavior) or Append only (preserves existing tracks, only adds new
ones). Useful when the source platform caps playlist size and the
user is manually building beyond it on the server.
Implementation:
* New `append_to_playlist(name, tracks)` method on Plex / Jellyfin /
Navidrome clients. Each uses the server's NATIVE append API:
- Plex: `existing_playlist.addItems(new_tracks)`
- Jellyfin: `POST /Playlists/<id>/Items?Ids=...&UserId=...`
- Navidrome: Subsonic `updatePlaylist?songIdToAdd=...`
Falls back to `create_playlist` when the playlist doesn't exist
yet (first sync). No delete-recreate, no backup playlist created
(preserves playlist creation date + metadata + non-soulsync-managed
tracks).
* Dedup-by-server-native-id (ratingKey for Plex, GUID for Jellyfin,
song-id for Navidrome) — never re-adds a track already on the
playlist. Server-native identity, not fuzzy title+artist match,
so it can't false-collide.
* `sync_service.sync_playlist` accepts `sync_mode='replace'|'append'`
kwarg. Single if/else branch dispatches to `append_to_playlist` or
`update_playlist`. Threaded through `core/discovery/sync.run_sync_task`
and the `/api/sync/start` HTTP handler. Validation on the API rejects
unknown mode strings (defaults to 'replace').
* Frontend: per-playlist `<select id="sync-mode-${id}">` rendered next
to the Sync button in both modal renderers (sync-spotify.js for
Spotify playlists, sync-services.js for Deezer ARL playlists).
`startPlaylistSync` reads the select at click time; missing select
(other callers like discover.js) defaults to 'replace' so backward
compat preserved without per-call-site updates.
* SoulSync standalone has no playlist methods at all and the modal
hides the Sync button entirely on it via `_isSoulsyncStandalone` —
dispatch never reaches that path, no defensive fallback needed.
15 new tests pin per-server append behavior:
- missing playlist → create_playlist delegation
- dedup filtering (existing IDs skipped, only new tracks added)
- empty new-track set short-circuits without API call
- failure paths return False without raising
- contract listing (KNOWN_PER_SERVER_METHODS includes
'append_to_playlist'; Plex / Jellyfin / Navidrome all implement)
Plus tests/discovery/test_discovery_sync.py fake `sync_playlist`
fixture got `sync_mode='replace'` default to match the new signature
(was breaking after the kwarg add; now passing).
WHATS_NEW entry under new '2.6.0' block (hidden by
`_getLatestWhatsNewVersion` until next release bump).
Closes CJFC discord request.
Three follow-on fixes to the manual-search candidates modal once people
started actually using it:
1. NDJSON streaming. Manual search waited for every source to return
before showing anything. Now streams one event per source as each
completes — header line, source_results per source, done terminator.
Frontend appends rows incrementally via response.body.getReader().
2. Manual picks no longer auto-retry on failure. New _user_manual_pick
flag set on the task in /download-candidate. Both monitor retry
paths (not-in-live-transfers stuck + Errored state) bail on the
flag. Surfaces the failure to the user instead of silently picking
a different candidate via fresh search.
3. Non-Soulseek manual picks (youtube/tidal/qobuz/hifi/deezer/
soundcloud/lidarr) no longer stuck at "downloading 0%" forever. The
live_transfers IF branch now marks manual-pick tasks failed
directly when the engine reports Errored, instead of deferring to
the monitor (which bails on manual picks). Engine fallback in else
branch covers the rare race where the orchestrator's pre-populated
transfer lookup is missing the entry.
Plus a deadlock fix discovered along the way: the new failure path
synchronously called on_download_completed while holding tasks_lock,
which itself re-acquires the same Lock — non-reentrant
threading.Lock self-deadlocked the polling thread. While wedged, every
other endpoint that needed the lock (including /candidates → other
failed rows couldn't open modals) hung waiting. Moved completion
callbacks onto a daemon thread so the lock releases first.
Plus failed/not_found/cancelled rows are now ALWAYS clickable (not
just when the auto-search cached candidates) — the modal carries the
manual search bar, which is the user's recourse for empty results.
Plus manual download worker now runs on a dedicated thread instead of
competing with the batch's 3-worker missing_download_executor pool —
saturated batches no longer queue manual picks indefinitely.
All scoped to manual picks via the _user_manual_pick flag — auto
attempt flow byte-identical to before. Engine fallback gated on the
flag too so auto attempts in the else branch keep the original
do-nothing behavior (safety valve handles the stuck-forever case).
Also dropped _handle_failed_download from web_server.py — defined
but had no callers (dead code).
17 new unit tests pin the gate behavior:
- engine fallback: Errored/Cancelled/Succeeded/InProgress transitions,
manual-pick gate, terminal-state skip, soulseek skip, missing
download_id skip, engine returning None, orchestrator exception
- monitor: manual-pick skips not-in-live-transfers retry + Errored
retry
- IF-branch end-to-end: Errored marks failed, "Completed, Errored"
hits failure branch, auto attempts defer to monitor
Manual-search endpoint tests rewritten for NDJSON: 11 cases (validation,
single-source dispatch, parallel "all" dispatch, one-event-per-source
streaming shape, unconfigured-source skip + reject, header metadata,
per-source exception isolation).
Full suite 2259 passed, 1 skipped.
When an auto-download fails or returns "not found" with leftover
candidates, the user can already click the status cell to open a
modal showing those candidates and pick a different one. This adds
a manual search bar to that modal — type any query, hit search,
get a fresh round of results without having to bail out and start
over from the main search page.
Solves the case where the auto-query was bad (featured artist not
in title, parentheticals like "(Remastered 2019)" tripping the
matcher, slight artist-name variants, transliteration) but the
file genuinely exists on the source.
Frontend (downloads.js)
- Added a manual-search section above the existing auto-candidates
table inside the candidates modal.
- Source picker is smart per download mode:
- Single-source mode (soulseek-only / youtube-only / etc) shows
a "Searching X" label, no dropdown.
- Hybrid mode shows a dropdown with "All sources" default + every
configured source. Picking "All" runs parallel searches across
them and tags each result row with its source badge.
- Only configured sources show up; unconfigured are hidden.
- Validation: button disabled until query length >= 2, "Type at
least 2 characters" hint until threshold crosses.
- Loading state on search button while the request is in flight.
- Manual results render in a separate table above the existing
auto-candidates table, using the same row template (file /
quality / size / duration / user / ⬇ button) so the renderer
helper is shared.
- Click ⬇ reuses the existing `downloadCandidate(taskId, candidate,
trackName)` flow — same retry path, same AcoustID verification
when the file lands, no shortcut around the safety net.
- Re-running the search with a different query replaces the
previous manual results.
Backend (web_server.py)
- Extended `GET /api/downloads/task/<id>/candidates` response with:
- `download_mode` (e.g. 'hybrid', 'soulseek')
- `available_sources` (list of configured source IDs + labels)
- `source` field on each candidate (purely additive — frontend
auto-renderer ignores it on legacy code paths, manual-search
renderer uses it for the badge)
- Added `POST /api/downloads/task/<id>/manual-search`:
- Body: `{ query, source: 'all' | <source_id> }`
- Validates query length (>=2 trimmed) → 400
- Validates source against the configured-sources gate → 400
(rejects unconfigured sources even when explicitly named)
- For 'all': parallel `ThreadPoolExecutor` dispatch across every
configured download source, merged results
- For specific source: just that source
- Returns same shape as `/candidates` so the frontend renderer
is reused
- New module-level helpers: `_STREAMING_SOURCE_NAMES`,
`_infer_candidate_source`, `_serialize_candidate`,
`_list_available_download_sources`. The existing `/candidates`
endpoint also goes through `_serialize_candidate` so the source
badge is consistent across both flows.
Behavior preserved
- Existing modal layout / candidates table / ⬇ button are
byte-identical when the user doesn't use manual search.
- `downloadCandidate()` JS function untouched.
- `/candidates` and `/download-candidate` endpoints
backwards-compatible — only NEW fields added, nothing changed
or removed.
Tests
`tests/test_manual_search_endpoint.py` — 10 tests:
- `test_manual_search_validates_query_length`
- `test_manual_search_validates_source` (whitelist gate)
- `test_manual_search_handles_task_not_found` (404)
- `test_manual_search_dispatches_to_configured_source_only`
- `test_manual_search_all_dispatches_parallel`
- `test_manual_search_skips_unconfigured_sources`
- `test_manual_search_rejects_unconfigured_source_explicitly`
- `test_manual_search_returns_same_shape_as_candidates`
- `test_manual_search_single_source_mode_lists_source` (verifies
`available_sources` reflects the active mode)
- `test_manual_search_isolates_per_source_exceptions` (one source
throwing doesn't kill the merged result)
2242/2242 full suite green (was 2232 + 10 new). Ruff clean.
JS parses clean.
Closes#513 (s66jones).
The artist detail page already showed a "Popular on Last.fm" sidebar —
list of an artist's top tracks by playcount, with a play button per row
but no download action. Issue #513 wanted a way to grab those tracks
the same way zotify let users grab "top X songs" without pulling the
full discography.
Pulls from the configured primary metadata source (Spotify
`artist_top_tracks`, Deezer `/artist/{id}/top`) when available, falls
back to the existing Last.fm display-only mode for sources that don't
expose popularity ranking (iTunes / Discogs / MusicBrainz). Source
label in the section title shifts to match.
Each row gets a hover-revealed download button that wishlists the
single track via the existing /api/add-album-to-wishlist endpoint
(preserves the track's real album metadata, so the wishlist worker
later places the file in its proper album folder).
A "Download All" footer button opens the standard download modal in
PLAYLIST context, not album context — the virtual playlist_id is
`top_tracks_<source>_<artistId>` which doesn't match any of the
album-prefix checks in `startMissingTracksProcess` (downloads.js).
That keeps `is_album_download=false`, so the master worker doesn't
inject a wrapper context as `_explicit_album_context`. Each track
downloads using its own real album metadata, files land in proper
per-album folders on disk (not a fake "Top Tracks" folder).
Backend additions:
- `SpotifyClient.get_artist_top_tracks(artist_id, country, limit)` —
wraps `spotipy.artist_top_tracks`, returns up to 10 tracks for the
market (Spotify's API cap). UI-side limit trim only.
- `DeezerClient.get_artist_top_tracks(artist_id, limit)` — wraps
`/artist/{id}/top?limit=N`, converts Deezer's raw shape to the same
Spotify-compatible dict layout (id, name, artists, album with
album_type / total_tracks / images, duration_ms, track_number,
disc_number) so downstream code doesn't branch on source.
- `GET /api/artist/<id>/top-tracks` — dispatches to whichever client
matches the primary source. Resolves per-source artist IDs from the
DB row first (matching what /discography already does) so a Spotify
ID in the URL still works when Deezer is primary, and vice versa.
Returns `{success, source, tracks, resolved_artist_id}` on hit;
`{success: False, reason: 'unsupported_source' | 'spotify_not_authenticated'
| 'deezer_unavailable' | 'no_tracks_found'}` on miss so the frontend
can decide whether to fall through to Last.fm.
Frontend:
- `_loadArtistTopTracks` tries the metadata source first, falls
through to the legacy `/api/artist/0/lastfm-top-tracks` call if the
source can't deliver. Section title and per-row UI shift based on
which source answered.
- New per-row `.hero-top-track-download` button (hover-revealed).
- New `.hero-top-tracks-download-all` footer button — only visible
when metadata-source mode rendered the list (Last.fm fallback hides
it since rows have no track IDs to download).
Tests: 10 new tests pin the client methods —
- Spotify: returns track list, honors UI limit cap, returns empty when
unauthed / artist_id missing / API throws.
- Deezer: shape conversion to Spotify-compatible dict, empty when no
data / artist_id missing, limit clamping at upper bound, default
fallback when limit=0, malformed entries skipped.
The Flask endpoint dispatcher itself isn't covered by the new test
file because importing web_server at test-collection time spins up
worker threads that race with caplog-using tests elsewhere in the
suite (specifically test_library_reorganize_orchestrator). Endpoint
verified manually; the underlying client methods (the load-bearing
logic) are covered.
2204/2204 full suite green (was 2194 + 10 new).
Discord report: Duplicate Detector card said "372 findings" and Cover
Art Filler said "60 findings", but clicking the Findings tab's Pending
filter showed 0. User read it as "findings aren't being created" —
looked like a detector bug.
Actual cause: the badge sourced ``last_run.findings_created``
(historical "found in last scan") without considering current state.
After the user (or bulk-fix automation) resolved or dismissed those
findings, they no longer appeared on the Pending tab — but the badge
kept showing the last-scan number in red urgent styling.
Backend was correct end-to-end: detectors create pending rows,
bulk-fix moves them to resolved, Findings tab filters by status.
Only the badge display lied about current state.
Fix:
- ``RepairWorker._get_pending_count_by_job()`` — single SQL aggregation
returning ``{job_id: pending_count}`` for every job with pending
findings. O(1) lookup per job instead of N round trips.
- ``get_all_job_info()`` calls it once per request and adds
``pending_findings_count`` to each job's API response.
- ``enrichment.js`` job card now branches on the count:
- ``> 0`` → red ``"X pending"`` badge (urgent, action needed)
- ``= 0`` AND last scan found something → muted grey ``"X found in
last scan"`` (historical context, no action needed)
- New CSS class ``.repair-flow-badge.findings-historical`` for the
muted slate color so the two states are visually distinct.
User-visible result with the screenshotted state (372 dup / 60 cover-
art findings, all resolved):
- Before: red "372 findings" / "60 findings" — implied 432 things to
do, but Findings tab showed 0 pending
- After: grey "372 found in last scan" / "60 found in last scan" —
the badge text tells the user the count is historical, no surprise
when Pending is empty
Tests: 3 new tests in ``tests/test_create_finding_dedup_counter.py``
pin the per-job pending count helper:
- returns ``{job_id: count}`` based on status='pending' rows only;
resolved + dismissed rows excluded
- empty dict when no pending findings exist
- gracefully returns ``{}`` on DB error (badge falls back to
historical count via the existing JS ``or 0`` safety)
2188/2188 full suite green. Pure UI/state-display fix — no detector
logic, no backend behavior change.
Discord request (Samuel [KC]): show how much disk space the library
takes on the Stats page. Implementation piggybacks on the existing
deep scan — Plex/Jellyfin/Navidrome all return file size in their
track API responses, so we read it during the deep scan and store
it on the tracks row. Aggregation is then a single SQL query — no
filesystem walk, no extra I/O during the scan, no separate stat
job. SoulSync standalone gets size from os.path.getsize at insert
time (different code path; the file is local when we write the row).
Schema (`database/music_database.py`):
- New `file_size INTEGER` column on `tracks`. Migration uses the
established `try SELECT, except ALTER TABLE ADD COLUMN` pattern.
Idempotent; safe on existing installs. NULL on legacy rows so
they don't contribute to totals until next deep scan refreshes.
- Added the column to the canonical CREATE TABLE so fresh installs
get it without going through the migration path.
Track-object plumbing:
- `core/jellyfin_client.py` — JellyfinTrack reads MediaSources[0].Size
alongside existing Bitrate read. None when 0 / missing.
- `core/navidrome_client.py` — NavidromeTrack reads `size` from
the Subsonic song object (int coercion + None on parse fail).
- `core/soulsync_client.py` — SoulSyncTrack does os.path.getsize
(only "server" where size has to come from disk).
- Plex needs no client-side change: track.media[0].parts[0].size
is read directly inside insert_or_update_media_track.
Persistence — TWO separate insert paths:
(a) `database/music_database.py:insert_or_update_media_track` —
Plex/Jellyfin/Navidrome flows. Reads file_size from Plex's
MediaPart OR `track_obj.file_size` wrapper attribute (defensive
Plex-attr-not-present check + > 0 type guard).
INSERT writes the new column.
UPDATE uses COALESCE(?, file_size) so a None from the server
on a re-sync (rare Jellyfin Size omission) doesn't blank an
existing value. Pinned via test.
(b) `core/imports/side_effects.py:record_soulsync_library_entry` —
SoulSync standalone flow. Completely separate code path: the
standalone deep scan moves files to staging for auto-import
rather than calling insert_or_update_media_track. After the
auto-import processes them, side_effects writes the tracks row
directly. Reads file_size via os.path.getsize(final_path) at
insert time (file is local) and includes it in the INSERT
column list. SoulSync only does INSERT-if-not-exists (no
UPDATE path), so no COALESCE concern.
Aggregator (`database/music_database.py:get_library_disk_usage`):
- SELECT COALESCE(SUM(file_size), 0), COUNT(file_size),
COUNT(*) - COUNT(file_size) for the totals.
- Per-format breakdown done in Python via os.path.splitext over
(file_path, file_size) rows — sidesteps SQLite's first-vs-last-dot
ambiguity for paths like /music/Kendrick/M.A.A.D City/01.flac.
- Defensive: skips empty paths, paths without extension, and
implausibly long extensions (>6 chars). Returns the full
empty-shape dict (NOT a partial / undefined) when the column
doesn't exist or queries fail, so the UI's `if (!data.has_data)`
branch handles fresh installs cleanly.
API + UI:
- `core/stats/queries.py` — thin pass-through get_library_disk_usage
matching the existing query-helper convention.
- `web_server.py` — new /api/stats/library-disk-usage endpoint
mirroring the /api/stats/db-storage pattern.
- `webui/index.html` — new card in System Statistics above the
Database Storage card.
- `webui/static/stats-automations.js` — _loadLibraryDiskUsage +
_renderLibraryDiskUsage. Empty state: "Run a Deep Scan to
populate (X tracks pending)". Partial: "X measured (+Y pending)".
Full: total + format bars proportional to the largest format.
- `webui/static/style.css` — .stats-disk-* styled to match the
Database Storage card.
Backward compatibility:
- Migration is additive; existing rows get NULL file_size; the
empty-shape return from the aggregator means the UI renders
cleanly without errors before any deep scan runs.
- Old installs upgrading will see "Run a Deep Scan to populate
(N tracks pending)". Running their next deep scan fills sizes —
the existing scan flow doesn't need any changes, just consumes
the new track-wrapper attribute.
Tests:
- `tests/test_library_disk_usage.py` — 13 cases covering schema
migration, NULL defaults on legacy inserts, fresh-install empty
shape, summing with mixed NULL/known sizes, per-format breakdown,
mixed-case extensions, paths with album-name dots, missing
extensions, empty file_path, implausibly long extensions,
JellyfinTrack.file_size persistence via insert_or_update_media_track,
COALESCE preservation on null re-sync.
- `tests/imports/test_import_side_effects.py` — extended the
existing record_soulsync_library_entry test to assert
track_row['file_size'] == os.path.getsize(final_path), pinning
the SoulSync-standalone path. Test fixture's tracks schema also
updated to include the file_size column.
Verified: full suite 1813 pass (13 new, 1 existing-test extension),
ruff clean, smoke test populating + reading the column round-trips
correctly.
WHATS_NEW entry under '2.4.2' dev cycle.
The 'Live Per-Track Progress' work shipped a backend in-progress row + top-of-tab
progress text but the history cards themselves stayed visually stale during
processing — lowercase "processing" badge, neutral styling, no per-track hint.
Smoke-testing also surfaced two latent identification bugs that prevented
multi-disc rips with features (Kendrick GKMC Deluxe) from importing at all.
Card-level live progress (`webui/static/stats-automations.js`):
- Cache `/api/auto-import/status` response in `_autoImportLastStatus`; poller
awaits status before re-rendering results so the card has the live data.
- Add 'processing' entries to statusLabels / statusIcons / statusClass.
- When card folder_name matches `current_folder`, swap the meta line to
`track N/M: <track name>` and tag the matching row in the expanded list
as `auto-import-track-row-active`; prior rows tag as `-row-done`.
Card styling (`webui/static/style.css`):
- `.auto-import-processing` blue left border, `.auto-import-badge-processing`
pulse animation, active/done track-row classes.
Multi-disc enumeration (`core/auto_import_worker.py:_scan_directory`):
- Old code skipped disc folders during recursion AND only attached them to a
parent that had its own loose audio. A folder containing only `Disc 1/`,
`Disc 2/` was invisible. Now: when a directory has only disc subdirs and no
loose audio, treat that directory itself as the album candidate. Disc folders
still skipped when standing alone.
- Add `FolderCandidate.is_staging_root` flag (set when the staging dir itself
becomes the candidate via this path) so identification can refuse to use the
meaningless folder name.
Tag identification (`core/auto_import_worker.py:_identify_from_tags`):
- Per-track `artist` tag fragmented consensus on albums with features
("Kendrick Lamar" / "Kendrick Lamar, Drake" / "Kendrick Lamar, Dr. Dre"
produced 3 separate `(album, artist)` keys for one album). Now group by
album first, then pick the most-common artist within that album group.
- `_read_file_tags` now prefers `albumartist` over `artist` for album-level
identity; falls back to `artist` for files without albumartist.
- Add INFO-level log when tag identification rejects, showing top albums and
their counts so the user can diagnose multi-disc / tagging issues.
Folder-name false-match guard (`core/auto_import_worker.py:_identify_folder`):
- When `is_staging_root` is set, skip the folder-name strategy entirely. Logs
the skip and falls through to AcoustID. Without this, dropping disc folders
directly into staging caused the scanner to search the metadata source for
the literal name "Staging", which false-matched against random albums (e.g.
"Stamina, Dinos" — a French rap album — at 13% confidence).
What's New entries added under 2.4.2 dev cycle.
- Keep the sidebar and dashboard service cards neutral until the first /status payload arrives
- Prevent placeholder source names and card text from flashing on dashboard load
- Reveal the real service status only after the live snapshot populates the UI
Artist-detail is a "pseudo-page" reachable from Library, the unified
Search page, and the global search popover. It has no [data-page]
match in the sidebar, so navigateToPage's bulk-active-removal left
every nav button unhighlighted while the user was viewing an artist —
the sidebar offered no visual anchor for where they were.
Now:
- navigateToPage('artist-detail') falls back to highlighting the
Library button when no [data-page] match exists, anchoring the
sidebar to the canonical home for artist detail views.
- A new _updateSidebarLibraryBreadcrumb() helper rewrites the Library
button label between plain "Library" and a "Library / Artist Name"
breadcrumb based on currentPage + artistDetailPageState. Long names
(>14 chars) truncate with an ellipsis; the full name shows on hover
via the title attribute.
- Called from navigateToPage (entering / leaving the page) and from
loadArtistDetailData (covers same-page artist switches in the
similar-artist chain where currentPage stays 'artist-detail').
CSS adds .nav-text-root / .nav-text-sep / .nav-text-context selectors
so the "Library" anchor word stays visually dominant while the
separator and artist name dim to a secondary tier — readable but not
competing for attention.
Pure visual change. No backend touched. No new tests (DOM-only).
Replaces the single-slot "one reorganize at a time, return 409 on collision"
model with a per-user FIFO queue. Buttons stay clickable, "Reorganize All"
is one backend call instead of an N-call JS loop, and a status panel mounted
at the top of the artist actions bar shows live progress (active item,
queued count, recent completions) with per-item cancel buttons.
Backend
- core/reorganize_queue.py: singleton queue + worker thread, dedupe-on-
enqueue, cancel rules (queued cancellable, running not), enqueue_many
for bulk operations, progress fan-out via update_active_progress
- core/reorganize_runner.py: factory builds the worker's runner closure
with injected dependencies. Reads config per-call so changing the
download path in Settings takes effect on the next reorganize without
a server restart
- database/music_database.py: get_album_display_meta and
get_artist_albums_for_reorganize — moves the SQL out of route handlers
- web_server.py: thin enqueue/snapshot/cancel/clear endpoints, runner
registration at module load. Old _reorganize_state globals + status
endpoint deleted. Static-asset cache buster (?v=<server-start>)
added so JS/CSS updates ship live without users clearing cache
Frontend
- webui/static/library.js: status panel mount, polling (1.5s when
active, 8s when idle), expand/collapse, per-item cancel, debounced
enhanced-view reload (one reload per artist batch instead of N).
Per-album reorganize button paints with queued/running indicator
and short-circuits to a toast when the album is already in queue
- webui/static/style.css: panel + button styling matching the existing
glass-UI accents
- webui/static/helper.js + version modal: WHATS_NEW entry
Tests (22 new)
- tests/test_reorganize_queue.py (19 tests): FIFO order, dedupe,
per-item source, cancel rules, continue-on-failure, snapshot
shape, progress propagation, bulk enqueue
- tests/test_reorganize_runner.py (4 tests): per-call config reads,
setup-failure summary, dependency injection, progress fan-out
- tests/test_reorganize_db_methods.py (7 tests): SQL JOIN behavior,
ordering, fallback for blank strings, artist isolation
Full suite 549 passed in 27s.
Reported on Discord by winecountrygames. The library "Reorganize" tool
had several layered bugs that all traced to the same root cause: the
endpoint reinvented every wheel post-processing already turns — its own
template engine, its own disc-number resolution from file tags, its own
sidecar sweep, its own collision detection — and each had drifted from
the canonical path used by fresh downloads. Reported symptoms:
- 3-disc Aerosmith deluxe collapsed to a flat single-disc layout
- Half the tracks on other albums silently skipped, no error / no count
- Re-runs left empty leftover album folders cluttering the artist dir
Architecture: stop reinventing wheels. Route reorganize through exactly
the same pipeline downloads use. Per-album:
1. Fetch the canonical tracklist from a metadata source (Spotify /
iTunes / Deezer / Discogs / Hydrabase) using the album's stored
source IDs. New `core/library_reorganize.py::plan_album_reorganize`
does this — primary-source-first, fall through priority chain
unless the user picked a specific source in the modal (strict mode).
2. For each local track, find the matching API entry via a scored
candidate matcher. Score components: exact-title (100),
substring-with-length-ratio (40-90), track-number agreement (20).
Hard reject when the two titles have different version
differentiators (Remix vs no-remix means different recordings,
not annotation drift). Below threshold = unmatched, surfaced as
"not in source's tracklist, left in place" rather than silently
mis-routing.
3. Copy the file to a per-album staging directory, build the same
context dict the import flow builds (`spotify_album` /
`track_info` / etc. with `is_album_download=True` so the path
builder enters ALBUM mode, not SINGLE mode), call
`_post_process_matched_download(...)` — same function fresh
downloads use. Post-process handles tagging, multi-disc subfolder
decisions, sidecar regeneration, AcoustID verification.
4. Read `context['_final_processed_path']` to learn where it landed.
Update `tracks.file_path` in the DB BEFORE removing the original
(DB-update failure leaves the file at both locations, recoverable
via library scan; the reverse would orphan the row). Delete
per-track sidecars (post-process recreates them at the new
destination).
3 concurrent workers per album via ThreadPoolExecutor, matching the
download path's per-batch worker count. State mutations all guarded by
a single lock; staging filenames carry a UUID prefix so concurrent
copies of identically-named source files don't overwrite each other.
Source picker in the modal lets the user choose which source to read
the tracklist from. Two endpoints feed it:
- `/api/library/album/<id>/reorganize/sources` — sources for THIS
album that are both authed AND have a stored ID. For the per-
album modal.
- `/api/library/reorganize/sources` — all authed sources globally.
For the bulk "Reorganize All" modal where per-album ID coverage
varies.
When the user picks a specific source, the orchestrator runs in
`strict_source=True` mode (no fallback chain) — picking Spotify means
"use Spotify or fail", not "use Spotify and silently fall back."
Preview endpoint shares the same planning logic as apply via
`preview_album_reorganize` — the destination path comes from the same
`_build_final_path_for_track` post-process uses, so what you see in
the preview is exactly what you get on apply.
Empty destination folders (from earlier failed runs OR from the
current run when post-process creates a dir then fails AcoustID)
get cleaned up after each successful run: walk up to the artist
folder from any successful destination, prune empty album-sibling
folders one level deep. Bounded scope = won't touch unrelated user
dirs.
Web_server.py shrinks by ~450 net lines. The endpoint handler is now
a thin wrapper that builds injected callables (path resolver, post-
process function, DB updater, empty-dir cleaner), spawns a thread
that calls `reorganize_album()`, and returns. All actual logic lives
in `core/library_reorganize.py` where it's unit-testable without
spinning up Flask.
Frontend cleanup: the per-call template input in both reorganize
modals (per-album and bulk) was redundant — the backend always uses
the configured global download template. Removed the input and the
variables-grid reference UI it was for.
39 new unit tests pin every contract:
- source resolution (no_source_id when album has none, fallthrough
chain when primary returns nothing, strict mode bypasses fallback)
- matcher scoring (exact / substring / multi-disc disambiguation /
smart-quote tolerance / dash-vs-parens / bonus-track substring /
Remix-vs-original differentiator rejection / "Real" doesn't false-
match "Real Real Real" / track-number-only no longer fires)
- file safety (DB-update failure leaves original in place, post-
process failure leaves original in place, post-process exception
caught and original preserved, success removes original AND
updates DB in the right order)
- sidecar handling (per-track .lrc/.nfo deleted on success, kept on
failure; album-level cover.jpg/folder.jpg cleaned only when
directory has no remaining audio)
- staging cleanup (recreated between tracks because post-process
nukes it, dir cleaned up on success AND on failure)
- destination-dir prune (empty siblings removed, real album with
files preserved, no recursive sweep)
- source picker (only authed-with-stored-ID sources for per-album,
all authed sources for bulk; strict mode doesn't fall back)
- concurrency (3 workers in flight, state stays consistent under
races, stop_check cuts off pending tasks)
- preview parity (preview produces same destination as apply for
multi-disc; ALBUM mode not SINGLE mode; unmatched/no-path tracks
surfaced with reasons)
Limitations (deliberate punts, NOT in this PR):
- Renamed local titles on multi-disc albums where track_number
also disagrees: matcher returns nothing (track is "not in
source"). Fixable by using duration_ms as a tertiary signal.
- Per-track in-modal source switching with per-album track-count
hints (would need a second API call before opening the modal).
- UI status panel on the artist page during a run — currently
just toasts. Documented as a follow-up PR.
Files:
- core/library_reorganize.py — new module: plan_album_reorganize,
preview_album_reorganize, reorganize_album, available_sources_for_album,
authed_sources, _score_candidate, helpers for staging/post-
processing/finalizing, sidecar + dest-dir cleanup
- core/metadata_service.py — no changes; reused get_album_for_source,
get_album_tracks_for_source, get_source_priority,
get_client_for_source
- web_server.py — three endpoints (preview / apply / sources GETs)
are thin wrappers; -450 net lines
- tests/test_library_reorganize_orchestrator.py — 39 tests covering
every contract above
- webui/static/library.js — source picker UI in both modals; dead
template input + variables-grid removed
- webui/static/style.css — dropdown option styling fix (white-on-
white was unreadable)
Reported on Discord by winecountrygames — his bug report named the
trigger button (Enhanced view → Reorganize All) and both symptoms
(multi-disc collapse, half-album skip), which let the diagnosis go
straight to the architectural problem.
The source-picker refactor introduced a new stable DOM structure inside
`#gsearch-results`:
<div id="gsearch-results"> <!-- max-height: 60vh, flex-col -->
<div id="gsearch-source-row" /> <!-- icon row, controller-rendered -->
<div id="gsearch-fallback-banner" />
<div id="gsearch-body" /> <!-- surface renders results here -->
</div>
But the companion CSS never landed. `#gsearch-body` had default block
layout, so when results exceeded the 60vh cap, they clipped silently
instead of scrolling. The old structure had `.gsearch-results-body`
with `overflow-y: auto; flex: 1` directly inside the panel; that rule
still exists but its selector now matches a nested div with no flex
parent, so `flex: 1` is a no-op and overflow doesn't trigger.
Fix: give the three stable children the right flex behaviour so the
body fills remaining space and scrolls.
- `#gsearch-source-row` and `#gsearch-fallback-banner` stay at natural
height (flex-shrink: 0).
- `#gsearch-body` grows (flex: 1 1 auto), can shrink below content
height (min-height: 0 — this is the critical bit, otherwise flex
items won't shrink below their intrinsic size and overflow never
triggers), and scrolls vertically.
Styled scrollbar matches the rest of the panel (4px, translucent thumb).
Cin flagged two related UX issues during PR review:
1. The "Show Results / Hide Results" toggle next to the search bar served
no real purpose — there was nothing else on the Search page worth seeing
instead of results, so toggling visibility was always pointless overhead.
2. Navigating away from /search via a sidebar link dismissed the dropdown
(the click was caught by the outside-click handler). Coming back left
the input populated but the results hidden, requiring a Show Results
click or a fresh search. The cached state was intact in the controller
the whole time — just not rendered.
Both fixed by the same direction: dropdown visibility becomes a pure
function of query state, never user-toggleable. The closure now exposes
`_searchPageRestoreOnEnter` so subsequent calls to `initializeSearchModeToggle`
re-render from the controller's cached state instead of early-returning.
Removes the button HTML, click handler, `updateToggleButtonState` function,
the desktop + responsive CSS for `.enhanced-search-btn`, and the orphaned
`.btn-icon` rule. Net -94 lines.
Adds a subtle radial glow at the bottom of the viewport that emanates
from the floating search bar, fades outward toward both window corners,
and shrinks vertically as it moves away from the bar. Makes the bar
easier to spot at a glance without a heavy full-width bar or a chrome
strip.
- New `.gsearch-aura` fixed element, 260px tall, full width, pointer
events off. Radial-gradient with the accent color centered at the
bottom middle; colour stops taper 620x230px by default, ramping to
820x280px and brighter when the bar is focused/active.
- `_gsUpdateVisibility` hides the aura on /search alongside the bar
via a simple `.hidden` class.
- Focus handler adds `.active` to the aura in step with the bar;
`_gsDeactivate` removes it. z-index 99990 (below the bar at 99998,
above most page content).
When a user types an artist name into the library search and gets no
hits, the old empty state just said "No artists found — try adjusting
your search or filters." Dead end for the common case of "I searched
for someone I don't own yet."
The empty state now detects when libraryPageState.currentSearch is
non-empty and swaps in a CTA that hands the query off to /search:
"kendrick" isn't in your library
They might be available on a connected metadata source.
[🔍 Search online for "kendrick" →]
Clicking the button navigates to /search, pre-fills the enhanced search
input, and dispatches an input event so the existing debounced search
fires automatically. Uses the same hand-off pattern _gsNavigateToSearchPage
already uses for Soulseek, so the Search page's source-picker flow
picks up naturally from there.
No change to the generic empty state (no query active) or to any other
library page behaviour.
The picker used to render every source whether or not the user had
credentials for it. Clicking Discogs with no token, Hydrabase with no
URL, or Spotify with nothing saved would fire a doomed fetch — at best
a silent empty state, at worst a confusing fallback to another source.
Now the picker reads /api/settings/config-status (the same endpoint the
Settings → Connections page already uses for the green/yellow status
dot) on init and dims icons whose service isn't set up. Clicking a
dimmed icon navigates to Settings → Connections and scrolls to the
relevant service card with a brief accent-coloured pulse to orient
the user.
Sources the backend's SERVICE_CONFIG_REGISTRY doesn't cover
(musicbrainz, youtube_videos, soulseek) are permanently treated as
configured — they need no user credentials, so dimming them would
mislead.
Extra guard: if the user's configured primary metadata source is
itself unconfigured (Spotify saved as primary but no client_id yet),
`_initDefaultSource` falls forward to the first configured source so
the default active icon is never a "set up" chip.
Shared helpers:
- fetchSourceConfiguredMap() centralizes the config-status lookup for
both surfaces. Falls back permissively if the endpoint fails so the
picker never stops working over a network hiccup.
- openSettingsForSource(src) navigates to Settings → Connections and
scrolls to `[data-service=src]`, pulsing a 2.2s accent flash
(.stg-service-flash) so the user doesn't lose their place.
CSS:
- .unconfigured: 42% opacity, 0.7 grayscale filter, subdued hover
state with no transform/glow (feels "look but don't touch"),
defensive override to kill brand glow if somehow active.
- @keyframes stg-service-flash-anim for the scroll-to highlight.
The global search popover already draws its own frosted-glass panel
(via .gsearch-results), so putting another bordered/gradient container
around the source icons inside it read as "panel inside a panel" —
visually noisy and left a dark empty strip on the right when the row
didn't fill the popover width.
Strip the source row's own background/border, center-align the chips
(justify-content: center) so they stay grouped instead of drifting to
the left, and keep a subtle bottom divider so the icons still read as
a distinct control group above the results.
Dresses up the bare chip row so the picker reads as a deliberate piece of
UI rather than a utility bar. Both the Search page (.enh-source-*) and
the global widget (.gsearch-source-*) get the same treatment.
- The row itself is now a frosted-glass panel (subtle white gradient,
inner highlight, rounded 14px / 12px corners, outer shadow) so the
picker feels unified instead of a loose strip of buttons.
- Chips bumped: min-width 90px (row) / 72px (widget), bigger padding,
12px rounded corners, subtle linear gradient top-to-bottom, 30px icons
(up from 22px) with a drop-shadow for depth.
- Hover lifts the chip by 1px with a darker drop-shadow and brighter
border — cheap but effective microinteraction.
- Active state is brand-themed per source: the chip's background
becomes a top-weighted gradient in the service's colour, the border
matches, and an outer brand-coloured glow (6-22px blur) surrounds it.
scale(1.03) pops it above neighbours. Label bumps to 700 weight when
active. Same treatment for Spotify / Apple Music / Deezer / Discogs /
Hydrabase / MusicBrainz / Music Videos / Soulseek.
- Cache dot gets a brand-coloured glow and a subtle 2.4s pulse so the
"already fetched this query" hint is visible without being loud.
- Fallback-warning icons get an amber tint on both border and outer
ring to match the existing fallback banner colour.
Three follow-up fixes after browser testing:
1. Clicking a source whose results are already cached was closing the
results dropdown. The outside-click handler treated the icon click
as "outside" because the icon row lives above the input wrapper, not
inside it. The icon click handler now calls stopPropagation so the
document handler never runs. Also added an `#enh-source-row`
whitelist to the search-page outside-click handler as a second
layer of defense.
2. The icon chips used generic emojis (🎵, 🍎, 🎶, etc.) which don't
convey brand identity. SOURCE_LABELS now carries a `logo` URL per
source (mirroring the existing constants in core.js): the real
Spotify / Apple Music / Deezer / Discogs / MusicBrainz / Hydrabase /
Soulseek brand logos render as <img> inside the chip. Music Videos
stays on emoji since the codebase has no YouTube-specific logo
constant. renderSourceRow (Search page) and _gsSourceRowHtml (global
widget) both honor the new field; loading state still overrides
with an hourglass.
3. When Soulseek was selected, the icon row appeared clipped at the
top of the page. Caused by the flex parent (.downloads-main-panel)
compressing the row when .search-section.active competes for space
with flex-grow:1. Added `flex-shrink: 0` + explicit `overflow-y: visible`
on both .enh-source-row and .gsearch-source-row so the row keeps
its natural height even under layout pressure. Logo <img> elements
got explicit 22x22 / 18x18 containers so they render at chip scale
without the inline font-size hack.
Matches the Search page redesign so both surfaces behave identically.
The sidebar popover previously always fan-out-fetched all sources on
every keystroke (via _gsFetchSourceStream streaming NDJSON for every
alternate) and exposed a post-search tab bar to switch views.
Now:
- The popover renders an always-visible source icon row at the top, one
icon per source (Spotify, iTunes, Deezer, Discogs, Hydrabase,
MusicBrainz, Music Videos, Soulseek).
- Typing fetches only the currently-selected source. No fan-out.
- Clicking a different icon: cache hit -> instant re-render; cache miss
-> single-source fetch + render.
- Per-query cache cleared on query change; cache dots on icons show
which sources already have results for the current query.
- Default active source read from /api/settings (metadata.fallback_source)
on first focus; falls back to Spotify.
- Fallback banner shown when the backend served a different source than
the one clicked (rate-limit auto-fallback).
- Soulseek icon click navigates to /search with the query pre-filled,
since the raw file list doesn't fit the popover. The Search page
takes over rendering from there.
Gone: _gsFetchSourceStream (fan-out), _gsRenderTabs, _gsSwitchSource,
_gsState.altAbortCtrl, per-section _loading sets.
Added: _gsInitDefaultSource, _gsFetchSource, _gsFetchYouTubeVideos,
_gsSourceRowHtml, _gsFallbackBannerHtml, _gsSetActiveSource,
_gsNavigateToSearchPage.
The Search page previously fired a primary /api/enhanced-search request
plus a fan-out loop (_queueAlternateSourceFetches / _fetchAlternateSource)
that streamed NDJSON from /api/enhanced-search/source/<src> for every
other configured source. One search = 7 API calls across Spotify, iTunes,
Deezer, Discogs, Hydrabase, MusicBrainz, and YouTube Music Videos. The
post-search tab bar then let users switch views between the results that
had already been fetched.
This changes the default to explicit per-source selection:
- The old <select id="search-source-select"> dropdown and the
<div id="enh-source-tabs"> post-search tab bar are replaced by a
single always-visible icon row (#enh-source-row) above the search
bar. One button per source, horizontal-scroll on narrow screens.
- Typing fetches only the currently-selected source. No fan-out.
- Clicking a different icon switches to that source and fetches it
on demand, unless results for this query are already cached.
- Per-query cache (Map keyed by source) is cleared whenever the query
changes; cached icons show a small dot, loading icons show a spinner.
- Soulseek is a first-class icon in the row — selecting it routes to
the existing raw-file basic search, no change to that renderer.
- YouTube Music Videos is its own icon, still uses the NDJSON stream
endpoint for incremental rendering.
- Default active icon reads metadata.fallback_source from /api/settings
on init; falls back to Spotify.
- Rate-limit fallback (backend serves Deezer when Spotify is banned)
surfaces as an amber banner above results plus an amber border on the
clicked icon, so users understand why the returned results don't
match the source they picked.
SOURCE_LABELS in shared-helpers.js gains an 'icon' field per source and
a new SOURCE_ORDER constant for the canonical picker order. The fan-out
functions (_queueAlternateSourceFetches, _fetchAlternateSource,
renderSourceTabs, window._switchEnhSourceTab) are gone.
Backend untouched — POST /api/enhanced-search already supported a
`source` param for single-source mode; we were just never using it by
default. Global widget redesign to match is the next commit.
The sticky .sidebar-header had two layered issues that let nav items
show through it while the user scrolled the sidebar:
- its background was a single linear-gradient of rgba() stops,
starting at ~14% accent on transparent — the upper portion of the
header was effectively translucent
- .sidebar > * sets z-index 1 on every sidebar child, so the header
and the nav buttons share a stacking level. Sticky alone doesn't
lift the header; with equal z-index the nav wins on DOM order
Layer the existing accent gradient over a solid rgb(18, 18, 18) base
(visual unchanged, fully opaque), and bump the header to z-index 2 so
it paints above the nav buttons as they scroll under it.
The standalone /artist-detail page rendered releases via createReleaseCard
in a stacked layout: square image on top, then title, then year, then a
completion bar — all inside a 300px-tall card with internal padding. The
inline Artists page (now retired) used a richer treatment: full-bleed
artwork with a dark gradient overlay and the title + year pinned at the
bottom. This commit brings that look to the standalone page.
Card markup (still .release-card so all the existing JS filter +
state hooks work, plus .album-card for the visual):
<div class="release-card album-card" ...>
<div class="album-card-image" data-bg-src="..."></div>
<div class="completion-overlay [state]">
<span class="completion-status">...</span>
</div>
<div class="album-card-content">
<div class="album-card-name">title</div>
<div class="album-card-year">year</div>
</div>
[optional .mb-card-icon]
</div>
Image loads lazily via the existing observeLazyBackgrounds /
data-bg-src plumbing in core.js — call moved into populateRelease-
Section so each batch of new cards gets observed.
Completion overlay (top-right floating badge):
- Library artists: 'Checking…' / '✓ Owned' / 'N/M' / 'X%' / 'Missing'
based on release.owned + track_completion shape (existing logic
preserved, just rendered as a badge instead of a bar).
- Source artists (no library data): omitted entirely. The card just
shows artwork + title + year, which is what the user asked for.
CSS: scoped overrides under #artist-detail-page .release-card.album-card
neutralize the old release-card background gradient, internal padding,
fixed 300px height, and flex column layout. Cards become aspect-ratio:1
square with overflow:hidden so the image fills and the gradient + text
sit on top.
Filter state (data-is-live / data-is-compilation / data-is-featured)
still tagged on each card so the Include filter group keeps working.
Smoke: library Kendrick Lamar should now look like the inline Artists
page used to — square cards, big artwork, name + year on the bottom.
Source-clicked artist (Schoolboy Q from Deezer) shows the same
visual without the completion overlay.
Three issues from screenshots:
1. .collection-overview was hidden for source artists (CSS rule too
aggressive). It actually renders fine — just shows 0/N "missing"
for each release type, which is useful info. Removed from the hide
rules.
2. #artist-hero-sidebar (Top Tracks "Popular on Last.fm") was also
hidden. The renderer (_loadArtistTopTracks in library.js) already
fetches by artist name via /api/artist/0/lastfm-top-tracks, so it
works for source artists too. Removed from the hide rules.
3. Clicking a source-artist result for someone you ALREADY have in
the library was loading the bare source view instead of the
library view (bug). Backend now does a "library upgrade" lookup
in get_artist_detail: when the direct ID lookup misses but a
source param is provided, search the artists table by the source-
specific ID column (deezer_id / spotify_artist_id / etc.). If a
match exists, use that library PK and the rest of the library
path runs normally — owned releases, enrichment, completion
bars, all the goodies you'd see if you'd clicked from Library.
Falls back to a name match within the active server, then to the
source-only response if nothing matches.
The remaining library-only items (artist-enrichment-coverage, Radio
button, Enhance Quality button) stay hidden for source artists since
they all require owned tracks.
Completes the artist-detail unification. Source artists now land on
the same /artist-detail page as library artists (with the source-aware
backend endpoint from earlier this session handling the data fetch).
The inline Artists page is gone — artists.js deleted, #artists-page
HTML block removed, /artists URL aliases to /search.
Source-artist callsites re-migrated from selectArtistForDetail to
navigateToArtistDetail (search results, global widget, download
modal, Discover hero / Your Artists cards / artmap context / genre
deep-dive, watchlist artist detail).
Visual upgrade to standalone hero: added .artist-detail-hero-bg +
.artist-detail-hero-overlay (blurred image bg, dark gradient — same
treatment as the inline page). library.js sets the bg image when
loading an artist.
Library-only UI hidden via CSS for source artists (existing rules
from the previous commit cover Enhanced toggle, Status filter,
completion bars, enrichment coverage, Top Tracks sidebar, Radio /
Enhance buttons).
Final 2 helpers (lazyLoadArtistImages used by wishlist-tools,
showCompletionError used by completion checker) moved from
artists.js into shared-helpers.js. The inline-page candidate set
was dropped from _resolveSimilarArtistsTargets.
init.js: 'artists' alias added at top of navigateToPage (same
pattern as the existing 'downloads' alias). 'case artists:' handler
removed from loadPageData. _getPageFromPath now maps artist-detail
to library as its parent (matches the existing nav highlight at
init.js:2161).
tests/test_script_split_integrity.py: artists.js removed from
SPLIT_MODULES; KNOWN_CROSS_FILE_DUPES updated to point escapeHtml
at shared-helpers.js instead of artists.js. 354/354 tests pass.
Net delta: -1700 lines.
Stays at 2.39. Once you've verified end-to-end (library artist ->
hero looks like inline visual; source artist from Search -> same
page, similar artists works, no 404s; /artists URL -> /search), a
follow-up commit bumps to 2.40 with the full WHATS_NEW entry that's
already prepped.
First increment of the artist-detail unification redesign. Delivers
the two most-visible missing pieces for source artists without touching
the hero layout — that's a later commit.
Changes:
- HTML: new #ad-similar-artists-section inside #artist-detail-main
(scoped IDs with 'ad-' prefix so they don't collide with the inline
Artists page, which has the same section using base IDs).
- shared-helpers.js: similar-artists helpers (loadSimilarArtists +
display/progressive/createBubble + lazy image loader) moved out of
artists.js. New _resolveSimilarArtistsTargets() resolver picks
whichever candidate set has a `.page.active` ancestor, so the same
function works on both the inline Artists page and the standalone
artist-detail page without caller changes.
- library.js populateArtistDetailPage: sets
document.body.dataset.artistSource = 'library' | 'source' before
rendering, and fires loadSimilarArtists(artist.name) after
populating the rest of the page.
- style.css: body[data-artist-source='source'] rules hide
library-only UI on the artist-detail page — Enhanced view toggle,
Status (owned/missing) filter, completion bars, enrichment
coverage, Top Tracks sidebar, Radio / Enhance Quality buttons,
"X owned / Y missing" section-stats counts. CSS-only, additive,
library artists completely unaffected.
Impact today:
- Library artists: Similar Artists section now appears at the
bottom of their detail page (previously only the inline Artists
page had it). All other UI unchanged.
- Source artists: still route to the inline Artists page (Part B
reverted earlier this session). The standalone page is now
source-ready infrastructure-wise, but source artists don't reach
it yet. A later commit will re-migrate source callers to the
standalone page once the hero rendering is also source-ready.
artists.js shrinks from 1903 -> 1584 lines (similar-artists block
extracted). shared-helpers.js grows correspondingly. 357/357 tests
still pass. No version bump — this is still 2.39 pending.
Phase 3c of the Search/Artists unification. The Search page carried
a second copy of the Download Manager (active + finished queues,
clear/cancel-all buttons) that was hidden by default and duplicated
the dedicated Downloads page. That duplicate is now gone.
Removed:
- Side-panel HTML block and the toggle button that showed/hid it
- ~290 lines of polling + render infra in downloads.js: loadDownloads-
Data, startDownloadPolling/stopDownloadPolling, updateDownload-
Queues, renderQueue, updateTabCounts/updateDownloadStats,
initializeDownloadTabs/switchDownloadTab, cancelDownloadItem,
clearFinishedDownloads, cancelAllDownloads, and the
activeDownloads/finishedDownloads globals
- initializeDownloadManagerToggle and its call from init.js
- Stopped hitting /api/downloads/status every second on the Search
page (the dedicated Downloads page already polls its own view)
CSS grid for the Search page collapsed from '1fr 370px' to '1fr' now
that the right panel is gone. Unused .controls-panel__* / .download-
manager__* / .downloads-side-panel CSS rules kept in place — harmless,
can be pruned later.
Phase 3 of the Search/Artists unification. The Search page's two-mode
toggle is replaced by a single 'Search from' dropdown: All sources
(Auto), Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz,
or Soulseek (raw files). Auto keeps today's fan-out behavior for
backwards compatibility; picking a specific source hits only that
provider. 'Soulseek' routes to the raw-file basic section, so one
picker covers both old modes. Loading text and the enhanced fetch
now respect the selected source. Zero API changes — uses the source
param added in 2.40 and the shared fetch helper from 2.41.
Three closely-related changes bundled together. The UI work exposed the
backend bug when I tried to cancel a Deezer download and saw it marked
cancelled in the DB but continuing in the background.
Backend — cancel_task_v2 orchestrator dispatch fix:
The slskd-specific cancel block was written back when soulseek_client
was a raw SoulseekClient. It was later swapped to DownloadOrchestrator
(which doesn't expose .base_url / ._make_request), so the first
diagnostic log line crashed with AttributeError. The outer try/except
swallowed it, leaving streaming downloads (YouTube / Tidal / Qobuz /
HiFi / Deezer / Lidarr) running in the background after the user
clicked cancel.
Replaced the ~80-line block with a single
soulseek_client.cancel_download(download_id, username, remove=True)
call — the orchestrator's dispatch picks the right client by username,
same path /api/downloads/cancel already uses successfully.
Per-row cancel button (fancy):
Circular X button on .adl-row for rows in active or queued state.
Hidden by default (opacity 0, translateX + scale), fades in + settles
on .adl-row:hover with a cubic-bezier overshoot. Own :hover gives a
1.12x scale pop and brighter red glow. Touch devices (@media
(hover: none)) keep it visible.
Backend: surfaced playlist_id in /api/downloads/all items so the
frontend can hit cancel_task_v2 without a second lookup. Frontend:
adlCancelRow(btnEl, playlistId, trackIndex) with double-click guard
via data-cancelling + adl-row-cancel-pending class.
Cancel All header button:
Red-themed button next to "Clear Completed". Only visible when any
task is in downloading / searching / post_processing / queued state —
auto-hides the moment the last one finishes. Confirm dialog shows
"Cancel N tasks across M batches?". Iterates _adlBatches, calls
/api/playlists/<batch_id>/cancel_batch sequentially (same endpoint
each modal's "Cancel All" and the per-batch-card cancel use). Disables
during the loop, mixed/success/error toast based on result.
All 276 tests pass.
Cancel button on active download items was always visible, cluttering
the card. Now hidden by default and fades in when you hover the card
(or focus anything inside it, for keyboard a11y).
- opacity + pointer-events approach so layout doesn't jump on reveal
- 4px slide-in on reveal for a subtle entrance
- Touch devices (hover: none) keep the button always visible — no hover
means no way to discover it otherwise
Root-cause fix for "scanning 50 artists" then silence: when the master
repair worker was paused, force-run still kicked off _run_job but the
job's first wait_if_paused() blocked forever because is_paused was tied
to the master-enabled state. Force-run now bypasses master-pause —
scheduled runs still respect it.
Also fixes Fix All on discography findings doing nothing: the backend
bulk_fix_findings query had a fixable_types allowlist that excluded
missing_discography_track (and acoustid_mismatch). Added both.
Backfill job rebuild:
- auto_add_to_wishlist opt-in setting — creates findings AND pushes to
wishlist during the scan
- 3-option fix dialog (Add to Wishlist / Just Clear / Cancel) on single
Fix, Bulk Fix selection, and Fix All (page-level)
- Fix All "Just Clear" path uses the clear endpoint with job_id filter
instead of the generic "may delete files" bulk-fix warning
- Batched in-memory matching using get_candidate_albums_for_artist +
get_candidate_tracks_for_albums (same fast path the Library pages use)
- Rich album context per finding (id, name, album_type, release_date,
images, artists, total_tracks) — flows through the wishlist pipeline
so auto-processor classifies each track into the right cycle
(albums vs singles) and post-processing gets correct folder/tags/art
- Per-artist progress logs [N/50] Scanning ArtistName
- Default interval 24h (was 168h); all release types default on; settings
reordered with _section_* group headers (Core / Release Types /
Content Filters)
Repair settings UI:
- Generic _section_<name> key convention renders as an uppercase group
divider in the settings panel — any job can opt in
- .repair-setting-row gets a dashed bottom border so label↔toggle pairing
is visually clear
- _prettifyRepairSettingKey fixes acronym capitalization (EPs, not Eps)
Version bumped to 2.36 with changelog entries.
Three separate issues reported on the Spotify Playlist Discovery modal:
1. Fix button fails with "Track data not found"
openDiscoveryFixModal() branched on platform name to locate the discovery
state but had no case for 'spotify_public'. Row rendering passes that
platform value when the source is a Spotify public playlist, so state
lookup failed and the toast fired. Added the spotify_public branch —
state lives in youtubePlaylistStates alongside the other reused platforms.
2. Table header too transparent to read
.youtube-discovery-modal .discovery-table th used rgba(255,255,255,0.1)
as background (10% white) with white text, which lost contrast when the
orange progress bar or varied row content scrolled underneath. Switched
to near-solid dark rgba(17,17,20,0.96) with a brighter border-bottom
and z-index:5 so the sticky header stacks cleanly above table content.
3. "[object Object]" in the matched-artist column for Wing-It tracks
The Spotify Public Playlist row-transform joined result.spotify_data.artists
directly with .join(', '). Wing-It stub metadata (built server-side by
_build_discovery_wing_it_stub) returns artists as [{name: "..."}] —
array of objects. .join() stringified each object to "[object Object]".
Same pattern existed in three places in script.js; all now map objects
to .name and filter empties before joining. Graceful fallback to "-"
if the result is empty.
No existing tests touched. Full suite stays at 263 passed. Ruff clean.
Adds green/yellow header gradient on each service card showing whether the
user has filled in credentials, plus an expand-triggered verification layer
that surfaces working-or-not status inline.
Backend (web_server.py):
- SERVICE_CONFIG_REGISTRY mapping each of the 11 services in Connections to
its config requirements. Supports required-keys, always-green, any-of,
and custom-check semantics (Tidal uses token-file check, Qobuz accepts
either email/password OR cached auth token).
- _is_service_configured(service) — cheap config presence check, no APIs hit.
- GET /api/settings/config-status — returns {service: {configured}} for all
services in one call. Drives the page-load gradient.
- POST /api/settings/verify — takes {services: [...]}, runs
run_service_test per service, caches results 5 min in-memory, parallelizes
with ThreadPoolExecutor(max_workers=3) to avoid self-rate-limiting. Query
param ?force=true busts cache.
- Added verify branches for iTunes, Deezer, Discogs, Qobuz, Hydrabase in
run_service_test (previously missing — these services couldn't be tested).
HTML (webui/index.html):
- data-service="..." on all 11 .stg-service containers so JS can map card
to backend service name.
CSS (webui/static/style.css):
- .status-configured gradient (subtle green, left-to-transparent fade)
- .status-missing gradient (yellow, same shape)
- Spinner badge in header for .status-checking state
- "Testing connection…" status line style inside panel body
- Red warning bar style for verify failures at top of expanded panel
- Brand dot now glows always (was only glowing when expanded); hover and
expand states intensify the glow progressively.
JS (webui/static/script.js):
- applyServiceStatusGradients() fetches config-status and applies
green/yellow class per card. Called on Connections tab activate + after
any settings save.
- _stgVerifyServices(services, {force}) — batch verify POST, tracks
in-flight state, renders spinners/status lines/warnings per service.
- toggleStgService() fires single-service verify when a card is expanded
(not on collapse). Skipped if a verify is already in flight for that
service.
- toggleAllServiceAccordions() fires one batched verify for all 11 services
when "Expand All" is clicked; skipped on "Collapse All".
- _stgRefreshAfterSave() — after settings save, refreshes gradient (cheap)
and re-verifies only the cards the user currently has expanded (so
freshly-edited credentials show their new verify result immediately,
without re-pinging every service).
Failure UI: top-of-panel red warning bar with the error message (e.g.
"Discogs token rejected (HTTP 401)", "Hydrabase not connected…"). Removed
automatically on next successful verify.
No existing tests changed. Full suite stays at 263 passed. Ruff clean.
The downloads page previously showed only title and source per download.
Now shows album artwork thumbnail, artist name, album name, source badge,
and quality badge (after post-processing). All metadata comes from the
existing matched_downloads_context — no extra API calls needed.
Falls back gracefully to title-only display when context metadata is
not available (e.g. orphaned Soulseek transfers with no task mapping).
When playlist discovery fails to match a track on any metadata API,
instead of marking it "Not Found" and excluding it from downloads,
automatically build stub metadata from the raw source title/artist
and include it in the download queue. Soulseek searches with the
raw data, post-processing enhances whatever it can find.
All 7 discovery workers updated: YouTube, ListenBrainz, Tidal,
Deezer, Spotify Public, Beatport, and automated mirrored playlists.
Amber "Wing It" badge distinguishes stubs from real API matches.
Fix button still available so users can manually find a proper match.
Wing It stubs persist in DB for mirrored playlists and are
re-attempted on future discovery runs. Failed wing-it downloads
skip wishlist per-track (checked by wing_it_ ID prefix) so real
matched failures in the same batch still go to wishlist normally.
- Fix level filter showing nothing: now uses heuristic classification
for print() output (error/traceback/failed→ERROR, warn→WARNING, etc.)
in addition to exact logger format matching
- Speed up WebSocket updates from 2s to 0.5s polling
- Add search box with 300ms debounce — filters both initial load and live
- Use DocumentFragment for batch DOM appends (performance)
- Increase line cap from 1000 to 2000
- Backend search parameter support in /api/logs/tail
Terminal-style real-time log viewer with:
- Log file selector (app, post-processing, acoustid, source reuse)
- Color-coded log levels (DEBUG gray, INFO blue, WARNING yellow, ERROR red)
- Level filter buttons (All/Debug/Info/Warn/Error)
- Auto-scroll with toggle, copy and clear buttons
- Live updates via WebSocket (2s polling, pushes new lines)
- Initial load fetches last 200 lines via REST API
- 1000-line display cap with oldest lines trimmed
Also fixes Advanced tab settings (Discovery Pool, Security, etc.) being
hidden inside collapsed Library Preferences section body — misplaced
closing div caused them to be invisible.
New core/genre_filter.py with ~180 curated default genres. When strict
mode is enabled in Settings → Library Preferences → Genre Whitelist,
only whitelisted genres pass through during enrichment. Junk tags from
Last.fm (artist names, radio shows, playlist names) are silently dropped.
Applied at all 10 genre write points: Spotify, Last.fm, AudioDB, Deezer,
Discogs, iTunes, Qobuz enrichment workers + post-processing genre merge
+ initial download artist/album creation.
Strict mode is OFF by default — zero behavior change for existing users.
First enable auto-populates the whitelist with defaults. Users can add,
remove, search, and reset genres via the Settings UI.
Users can now override which metadata provider (Spotify, Deezer, Apple Music,
Discogs) is used when scanning a specific watchlist artist for new releases.
The selector appears in the artist config modal and only shows sources the
artist has enrichment IDs for. Default behavior is unchanged — all artists
use the global metadata source unless explicitly overridden.
Three collapsible categories, collapsed by default:
- Paths & Organization (file templates + music library paths)
- Post-Processing (metadata, tags, conversion, lyrics)
- Library Preferences (import, content filter, stats, playlists, M3U)
Section headers have data-stg=library so they only appear on the
Library tab. Bolder headers with accent-colored arrows and subtle
border. Collapse state preserved when switching settings tabs.
New MusicBrainz tab in Enhanced and Global search — finds tracks and
albums on MusicBrainz's community database with Cover Art Archive
images. Covers obscure tracks that Spotify/Deezer/iTunes miss.
- core/musicbrainz_search.py: search adapter with Track/Artist/Album
dataclasses, Cover Art Archive integration, smart query parsing
- Albums deduplicated (keeps best version with date and art)
- No artist results shown (MusicBrainz has no artist images)
- Album detail with full tracklist for download modal
- Smart word-boundary splitting for queries without separators
- Global search results container widened from 620px to 920px
- UI version bumped to 2.32
Fourth server option on the Connections tab with SoulSync logo and
'Standalone' label. Config panel shows Transfer folder path and
Verify Folder button. Test connection counts audio files in the
Transfer folder. Settings save/load properly detects soulsync toggle.
Race condition: scanner re-scanned folders while post-processing was
still moving files, causing partial matches and ghost failures. Now
tracks in-progress paths and skips them on subsequent scans.
Coverage penalty fix: individual tracks that match at 80%+ confidence
now auto-import even when overall album coverage is low (e.g. 2 of 18
tracks present). Previously low coverage killed the entire import.
Import page: stats bar, filter pills, Scan Now, Approve All, Clear
History (clears imported + failed), live scan progress.
Toggle appeared off when running because CSS :checked rules were
scoped to .repair-master-toggle. Added auto-import-toggle-label
selectors. Refresh now re-renders whichever tab is active.
Split Downloads page into main list (left) and batch panel (right).
Each active batch gets a color-coded card with artwork thumbnail,
progress bar, per-track status with download percentages, and
expandable track list. Download rows get matching color indicators.
- Click batch name to open its download/wishlist modal
- Filter icon narrows main list to one batch with clear banner
- Collapsible panel toggle for full-width list view
- Completed batches fade out after 15 seconds
- 7-day batch history with source type color dots
- Artwork fallback shows colored initial when no art available
- Per-track progress: download %, spinner for searching, proc label
- source_page column on sync_history for UI origin tracking
- /api/downloads/all includes batch summaries and per-track progress
- /api/downloads/batch-history endpoint for history queries
- Responsive layout, overflow-x hidden to prevent scroll flicker
Full auto-import pipeline: background worker watches the staging folder,
identifies music using embedded tags → folder name parsing → AcoustID
fingerprinting, matches files to metadata source tracklists, and
processes high-confidence matches through the existing post-processing
pipeline automatically.
Worker: AutoImportWorker with start/stop/pause/resume, configurable
scan interval (default 60s), confidence threshold (default 90%), and
auto-process toggle. Processes one folder per cycle, alphabetical
order. Disc folder detection, stability checking, content hash dedup.
Confidence gate: 90%+ auto-processes silently, 70-90% queued as
pending review with approve/dismiss actions, <70% flagged for manual
identification. Track matching uses weighted algorithm (title 45%,
artist 15%, track number 30%, album tag 10%).
Database: auto_import_history table tracks every scan result with
folder hash, match data JSON, confidence, status, timestamps.
API: 7 endpoints — status, toggle, settings (GET/POST), results
(filtered/paginated), approve, reject.
UI: Auto tab on Import page with enable toggle, confidence slider,
scan interval selector. Live result cards with album art, confidence
bar (green/yellow/red), status badges, match stats. 5-second polling.
Server Playlists tab now displays ALL playlists from the media server,
split into two sections: Synced Playlists (with mirrored/history match,
full opacity, "Synced" badge, "Open Editor" action) and Other Server
Playlists (everything else, dimmed at 70%, "View Tracks" action). Both
sections have header with icon, title, and count. Unsynced cards fade
to full opacity on hover.
Artist Sync button on enhanced library page now does true bidirectional
sync: Phase 1 pulls new albums/tracks from the media server using the
DatabaseUpdateWorker in deep scan mode (preserves enrichment), Phase 2
removes stale DB entries for files no longer on disk. Works for Plex,
Jellyfin, and Navidrome. Toast shows +albums, +tracks, -stale counts.
Repair jobs tab redesigned: 2-column grid layout with glass gradient
cards, accent top line on hover, hover lift effect, job description
text below name, running state with pulsing accent bar. Responsive
to single column under 900px.
Fixed deezer_artist_id → deezer_id column name on artists table lookup.
Expanded albums: click album tile to reveal track list with per-track
remove buttons. Art shrinks to banner, title/count move to static
position, tracks scroll at 200px max-height. Handles many albums with
flex-wrap and align-items: flex-start.
Expanded singles: visible labels below 44px art circles, remove button
on hover. No longer tooltip-only.
Live processing: polls wishlist stats every 5s, detects auto-processing
and manual download batches. Orbs pulse with accent glow during active
processing. Nebula auto-refreshes when tracks complete (count decreases).
Polling stops on page navigation.
Download flow: single "Download Wishlist" button opens category choice
dialog (Albums/Singles with counts). If processing is already active,
shows toast or reopens existing download modal. Toned down processing
pulse animation (3s cycle, scale 1.03, brightness 1.1).
Eight visual upgrades to the wishlist nebula:
1. Watchlist artist photos used for orb images (cross-referenced via API)
2. Hover tooltip with artist name and track count
3. Pulse animation on orbs when their category is next in auto-processing
4. Album art ring — tiny covers orbit each orb in a slow spinning ring
5. Staggered entry animation — orbs fade in and float up on page load
6. Click artist name to navigate to Artists page with search pre-filled
7. Improved label styling with accent color hover + underline
8. Responsive art ring sizing per orb size class
Bespoke wishlist page design: each artist is a glowing orb sized by
track count (sm/md/lg), with a spinning conic gradient ring colored by
artist name hash. Click to expand — albums appear as satellite rows
with cover art, singles as compact pills. Remove buttons on hover at
every level (album, single track).
Search bar filters orbs in real-time. Download Albums/Singles buttons
trigger the existing category download flow. Orbs sorted by track count
(biggest artists first). Responsive layout with mobile breakpoints.
Added z-index: 10 to .dashboard-header to elevate its stacking context
above the dashboard content sections. Bumped all 13 worker orb tooltip
z-index values from 1000 to 5000. The tooltips now render above section
headers when they drop downward from the header into the content area.
Full automation page upgrade with group management and drag-and-drop:
Backend: batch_update_group() and bulk_set_enabled() DB methods, new
PUT /api/automations/group and POST /api/automations/bulk-toggle endpoints.
Group headers: rename (inline edit), delete (choice dialog — keep
automations or delete all), bulk toggle (enable/disable all in group).
Actions appear on hover, styled as small icon buttons.
Drag and drop: non-system cards are draggable between group sections.
Drop zones show dashed accent border feedback. Collapsed sections
auto-expand on 500ms drag-hover. System/Hub sections dimmed during drag.
dragenter counter pattern handles child element bubbling.
Delete group dialog: glass card modal with three options — keep
automations (move to My Automations), delete everything, or cancel.
Worker orbs now centered across the full header width instead of
right-aligned. Watchlist/Wishlist buttons moved to absolute top-right
corner in their own container, preventing tooltip overflow from pushing
them to a second line. Import button removed from header (accessible
via sidebar).
Responsive: at 900px quick-nav drops to static full-width row, worker
orb tooltips hidden on mobile (status visible via orb color/spinner,
details on Tools page). At 768px everything stacks vertically.
Adaptive card on the Dashboard showing library state with four modes:
- No server: gold accent, directs to Settings
- Disconnected: gold warning with troubleshooting guidance
- Empty library: blue accent with prominent Scan Now button
- Healthy: green accent with stats grid (artists/albums/tracks/DB size),
Refresh button (incremental) and Deep Scan button (full re-check)
Stats displayed as mini cards with individual icons. Animated glow orb,
gradient accent top line, shimmer progress bar during scans. Deep scan
added to /api/database/update endpoint (deep_scan flag) — re-checks
every track, adds new ones, removes stale, preserves enrichment data.
Confirmation dialog explains what deep scan does before starting.
Dashboard Tools & Operations section replaced with a compact link card.
All 10 tool cards moved to a dedicated Tools page in the sidebar, grouped
into three sections: Database & Scanning, Metadata & Cache, Management.
Library Maintenance promoted to hero position at the top of the page with
accent top bar, logo, enable toggle, and tabbed content (Jobs, Findings,
History) — no longer buried in a modal. openRepairModal() now navigates
to the Tools page. Repair modal HTML removed.
Tool initialization extracted from loadDashboardData() into a dedicated
initializeToolsPage() with idempotent event listener wiring. Container
sizing updated to use margin: 20px (matching Dashboard/Stats) instead of
max-width: 1400px for consistent full-width appearance across all pages.
Watchlist and Wishlist are now proper sidebar pages with full design
treatment matching the app's established visual language — glass
containers, gradient headers, accent lines, card hover effects.
Watchlist page: artist grid with sort (name/scan date/date added),
search filter, last scan summary strip, live scan activity, batch
selection, all existing sub-modals (artist config, global settings,
artist detail slideout) preserved and working.
Wishlist page: stats strip (album count, singles count, next cycle),
category cards with mosaic backgrounds, track list with inline search
filter, batch operations, download integration. Auto-processing
detection on header button shows download progress modal when active.
Header buttons rewired to navigate to pages. All refresh points updated
to reinitialize pages instead of reopening modals. Timer/polling cleanup
on page navigation. Artist detail overlay converted to fixed positioning.
Adds a new Last.fm Radio section to the Discover page that lets users
search a track on Last.fm, generate a similar-tracks playlist, and run
it through the existing discovery/download/sync pipeline. Also generates
playlists automatically from top listening history during watchlist scans
(max once per week).
- core/lastfm_client.py: Add get_similar_tracks() using track.getsimilar
- core/listenbrainz_manager.py: Add save_lastfm_radio_playlist() with
deterministic MBID (MD5 seed), cleanup limit of 5 for lastfm_radio type
- web_server.py: Add /api/lastfm/configured, /api/lastfm/search/tracks,
/api/lastfm/radio/generate, /api/discover/listenbrainz/lastfm-radio;
fix playlist['name'] KeyError in discovery worker that was resetting
phase back to 'fresh' after completion
- core/watchlist_scanner.py: Add _generate_lastfm_radio_playlists() with
weekly throttle, called at end of scan_all_watchlist_artists()
- webui/index.html: Add #lastfm-radio-section above ListenBrainz section,
hidden unless Last.fm API key is configured
- webui/static/script.js: Search/generation/card-load functions; fix
discovery modal labels (Last.fm Radio vs ListenBrainz), description
update on completion, belt-and-suspenders completion handling inside
updateYouTubeDiscoveryModal; fix album/duration display for tracks
without metadata; music note SVG placeholder for missing art
- webui/static/style.css: Styles for search bar, dropdown, result rows
The sidebar player was a poor use of vertical real estate and created the
collapsed-state layout issues. The mini player is now a fixed 360px widget
at bottom-right (above the bell/help buttons), matching the convention of
most streaming apps.
Changes:
- Removed media player from sidebar; sidebar spacer now pushes support/version
section to bottom as before
- New .mini-player-body horizontal layout: album art | track info | controls
- Added prev/next skip buttons (mini-nav-btn) with same skip logic as the
Now Playing modal; updateNpPrevNextButtons() now syncs both sets
- .media-player.idle now display:none (widget hides entirely when no track)
- Progress bar is a flush full-width line at the top of the widget
- Volume slider kept as hidden DOM element for JS compatibility; volume is
set via the Now Playing modal
- Toast container moved up to bottom:174px to stay above the mini player
- expand-hint button updated to four-corner expand icon, opens NP modal
- All volumeSlider and click-exclusion references updated for null-safety
New core/replaygain.py module uses FFmpeg's ebur128 filter (already a
project dependency) to analyze integrated loudness and true peak, then
writes ReplayGain 2.0 tags (-18 LUFS reference) to MP3 (TXXX frames),
FLAC/OGG/Opus (Vorbis comments), and M4A/MP4 (freeform atoms).
Three analysis modes in the enhanced library view:
- Per-track RG button: synchronous single-track analysis (~1-3 s)
- Album "ReplayGain" button: background job writing both track gain
and album gain (mean LUFS across all album tracks) to every file
- Bulk bar "ReplayGain" button: batch track-gain for selected tracks
read_file_tags() in tag_writer.py extended with four new optional keys
(replaygain_track_gain/_peak, replaygain_album_gain/_peak) so existing
RG values surface in the tag-preview diff view. Purely additive — no
existing endpoints or DB schema changed.
Gear button next to View All opens a sources modal letting users pick
which connected services (Spotify, Tidal, Last.fm, Deezer) contribute
artists to the Your Artists carousel. Setting saved via standard
/api/settings endpoint under discover.your_artists_sources.
- GET /api/discover/your-artists/sources returns enabled config + which
services are currently connected
- _fetch_and_match_liked_artists skips sources not in the enabled list
- Disconnected services shown dimmed and non-interactive in modal
- Saving with nothing selected blocked with error toast
- Remove z-index from .sidebar-header (fixes artist map overlap)
- Add padding-bottom to #automations-list-view (search bar overlap fix)
Add a 10vh bottom padding rule for #automations-list-view in webui/static/style.css to provide extra spacing at the bottom of the automations list and prevent content from being obscured by fixed UI elements (e.g., footer).
Add padding-bottom: 10vh to #settings-page .settings-content so the
bottom section is not obscured by the floating search bar overlay.
Closes#292 (item 3)
Replace div badges with data-url/onclick handlers by semantic <a> elements (with href, target="_blank" and rel="noopener noreferrer") for clickable artist badges, keeping non-clickable badges as divs. Update CSS to target .artist-hero-badge and unify hover/image rules instead of relying on data-url attribute, preserving visual behavior and removing pointer cursor for non-clickable divs. Also remove rendering of the server_source badge from the artist meta panel. These changes improve accessibility, security, and maintainability of badge markup and styling.
Version bump to 2.3 with rewritten What's New modal covering all
changes since v2.2. Docker publish workflow default updated.
Sidebar improvements:
- Header stays pinned at top while nav and player scroll beneath it
- Media player collapses to compact single-line when no track is
playing, expands to full size when playback starts
Fixes:
- Server playlists endpoint Plex Tag object crash (getattr fix)
- Server playlists tab auto-refreshes after download completion
- Fixed dead code syntax error in archived version notes
sync-tab-content had overflow:hidden which clipped long content like
the file import preview table and server playlist editor. Changed to
overflow-y:auto so all sync tabs scroll when content exceeds the
container height.
New sidebar page showing every download task across the app in a unified
live-updating list. Tracks from Sync, Discover, Artists, Search, and
Wishlist all appear in one place.
Features:
- Filter pills: All / Active / Queued / Completed / Failed
- Section headers grouping by status category
- Track position (3 of 19) for album/playlist batches
- Album art, artist/album metadata, batch context, error messages
- Status dots with accent glow for active, green for complete, red fail
- Clear Completed button removes terminal items from tracker
- Nav badge shows active download count from any page via WebSocket
Fixes artist [object Object] display — handles all format variations
(list of dicts, list of strings, dict, string) for artist and album
fields in the API response.
- Moved _downloadMusicVideo to top-level scope so global search can use
it (was inside enhanced search conditional that only runs on downloads page)
- Global search video cards use base64 data attributes to avoid JSON
escaping issues in onclick handlers
- Darkened thumbnail overlay during download for better progress visibility
- Larger progress ring (52px) with accent-colored glow shadow
Click any video card in Music Videos tab to download. Flow:
1. Search primary metadata source for clean artist/title
2. Fall back to YouTube title parsing if no match
3. Download video via yt-dlp (best quality MP4)
4. Save to configured Music Videos folder as Artist/Title-video.mp4
UI shows circular progress ring on the thumbnail during download,
green checkmark on completion, red X on error (clickable to retry).
Cards are non-interactive while downloading.
Backend: /api/music-video/download and /api/music-video/status endpoints
YouTube client: download_music_video() method keeps video format
New "Music Videos" pill tab alongside Spotify/Deezer/iTunes/Discogs
in both enhanced search and global search. Searches YouTube via yt-dlp
and displays results in a video card grid with 16:9 thumbnails, play
overlay, duration badge, channel name, and view count.
- Backend: /api/enhanced-search/source/youtube_videos endpoint with
search_videos() method on YouTubeClient returning YouTubeSearchResult
- Frontend: Video grid layout with responsive cards, YouTube red tab
color, proper section hiding when switching between metadata and
video tabs
- Global search: Full parity with enhanced search video rendering
- No download functionality yet — display only
New toggle in Settings > Appearance disables backdrop blur (220
instances), animations (238), transitions (961), and box shadows
(804) across the entire UI via a single body class. Significantly
reduces GPU/CPU usage on low-end devices. Default off — no change
for existing users. Applied from localStorage on load to prevent
flash.
Library page: new dropdown filter to show artists matched or unmatched
to any metadata source (Spotify, MusicBrainz, Deezer, Discogs, etc).
Select "No Discogs" to find artists needing manual Discogs matching.
Filter applied as WHERE clause on the source ID columns.
Discogs enrichment: added to valid_services whitelist, _enrichment_locks,
and _run_single_enrichment handler. The Enrich button was returning an
error when Discogs was selected from the dropdown.
Entries are now compact cards that expand on click to reveal source
details. Shows expected vs downloaded title/artist with red mismatch
highlighting. Source artist column added to DB. Streaming track IDs
extracted from the id||name filename pattern. File and ID always on
their own line to avoid edge-case misplacement.
Track original source filename, track ID, and AcoustID verification
result for every download. Helps debug wrong-file downloads from
streaming sources like Tidal. Each column migrated independently
for crash safety. Frontend shows source detail line and color-coded
AcoustID badge per entry. Button renamed to "Download History".
Visual overhaul of the API Configuration section: each service frame
is now a collapsible accordion with brand-colored dots, chevron
indicators, and smooth expand/collapse animations. Includes an
Expand All / Collapse All toggle. No functional changes — all element
IDs, save/load logic, and tab switching preserved.
New download_source column on library_history table records which source
(Soulseek, Tidal, Qobuz, HiFi, YouTube, Deezer) each track was downloaded
from. Extracted from context username during post-processing.
Frontend shows source badge alongside quality badge on each download entry.
Source breakdown bar below tabs shows per-source totals with color-coded
chips (e.g., "Soulseek: 847 | Tidal: 203"). Includes DB migration for
existing installs. Existing entries show quality only (source is NULL).
Replaced single "Change" button with per-source rows showing match
status for each provider (Spotify, Apple Music, Deezer, Discogs).
Each row has Fix/Match button that searches that specific source API,
plus clear button to remove individual matches.
- Per-source search uses _search_service (same as enrichment modal)
- Backend: added Discogs to valid providers, empty ID clears match
- Fixed provider validation to accept 'discogs' alongside others
- Clear sets DB column to NULL instead of rejecting empty string
Initial load fetches 200 tracks instead of all. "Load More" button at
bottom shows (200 of 5,000) and loads next batch on click. Backend now
returns total count alongside limited results for both album and singles
categories. Rendering logic unchanged — just operates on smaller sets.
Playlist Pipeline — single automation that runs the full playlist lifecycle:
refresh → discover → sync → download missing. Replaces 4-automation signal
chains. Phase-aware progress display (Phase 1/4, 2/4, etc.), guard function
prevents concurrent runs, fire-and-forget wishlist at end. Re-sync loop
catches newly downloaded tracks on next scheduled run.
- New action type 'playlist_pipeline' with handler, blocks endpoint config,
builder UI (playlist select, process all, skip wishlist checkboxes),
help modal, result display map, and Hub template
- Removed 3 redundant Hub templates (Release Radar, Discovery Weekly,
Playlist Auto-Sync) — all replaced by the pipeline
- Fixed sync completion polling (status is 'finished' not 'complete')
- Fixed refresh handler progress hijack (null out _automation_id)
- Fixed matched_tracks field access from sync_states result
Also in this commit:
- Wishlist badges on enhanced search and global search tracks (amber)
- Discogs added to manual enrichment modal search + artist/album dropdowns
- Profile PIN forgot recovery on profile selection dialog
cover.jpg was always written from Spotify/iTunes URL (640x640) when the
first track in an album reached _download_cover_art before MusicBrainz
lookup completed. Later tracks with MBID skipped because file existed.
Fix: when cover.jpg exists but is small (<200KB) and we now have a CAA
MBID, attempt to upgrade it with the high-res CAA version. If CAA fetch
fails, keep existing cover — no pointless overwrites.
Also adds Forgot PIN recovery to the profile selection PIN dialog,
reusing the same credential verification flow as the launch lock screen.
Backend reset endpoint now accepts profile_id parameter.
- Image proxy endpoint (/api/image-proxy) for canvas CORS — allowlisted CDNs,
browser-like UA for Deezer, 24h cache headers. Direct CORS first, proxy fallback.
- Server-side 5-min cache on all artist map endpoints with auto-invalidation
on watchlist add/remove, scan complete, and new MusicMap discoveries.
- Explorer fetches similar artists from MusicMap on-the-fly when none stored,
saves to DB for instant future visits. Validates artist names against
Spotify/iTunes API before loading map — rejects gibberish with 404.
- Genre map per-genre cap removed (was 300 backend, 400 frontend).
- Center node in Explorer uses type 'center' not 'watchlist' — no longer
misidentified as a watchlist artist.
- Error overlay auto-dismisses after 2.5s and returns to Discover page.
- Helper What's New restructured with dated sections (April 4/3/2/1, March),
trimmed from ~80 to ~38 entries, date headers styled as purple dividers.
- Version modal updated with Artist Map section and recent fixes.
Full-width section with three mode cards: Watchlist (existing), Genres
(placeholder), and Explorer (placeholder). Dark gradient background
with purple/blue ambient glow, animated dot grid overlay, and polished
card hover effects. Replaces the old small hero button.
Responsive: 3-column desktop, 1-column mobile.
Toolbar redesigned: three-section layout (back+brand / centered search
with icon / tool buttons). Brand icon in accent purple. Zoom buttons in
compact pill group. Tool labels visible on desktop, icon-only on mobile.
Added keyboard shortcuts modal (keyboard icon button): lists all 10
shortcuts with styled kbd elements.
Fixes:
- Mouse wheel zoom min matched to button zoom min (0.02)
- Right-click no longer triggers left-click info modal (button filter)
- Removed external "Open on Spotify" link from context menu
- Search results dropdown centered under search bar
- Keyboard: Escape close, +/- zoom, F fit, S search, H toggle similar
- Right-click context menu: Artist Info, View Discography, Watchlist,
Open on Spotify — glass-style with auto-close
- Filter toggle button + H key: hide/show similar artists for clean
watchlist-only overview
- Removed node dragging (caused visual desync with offscreen buffer)
- Better loading progress text
- Cleanup: keyboard handler removed on close, context menu hidden
- Metadata cache backfill: batch-lookup all node names across all sources
to fill missing IDs, images, and genres
- Source-aware navigation: View Discography passes correct source to
artist page so non-active-source artists load correctly
- Constellation hover effect: 800ms delay, fade in/out animation, dim
overlay with glowing connection lines to related artists
- Click ripple animation on node selection
- Related artists list in info modal with clickable navigation
- Rich tooltip with artist photo, name, genres on hover
- Removed node dragging (caused visual desync with offscreen buffer)
- Performance: cached constellation lookups, lighter cache query
(no raw_json), canvas.width for proper DPR overlay coverage
Visual canvas map on Discover page showing watchlist artists as large
anchor bubbles surrounded by their similar artists, sized by relevance.
Layout: golden angle spiral for watchlist nodes with push-apart guarantee,
spiral packing with spatial grid collision detection for similar artists.
Offscreen buffer rendering for smooth pan/zoom (single drawImage blit).
Features:
- 320px watchlist bubbles, similar sized 25-55% by rank/occurrence
- Search bar with instant filter + animated zoom-to-node
- Tooltip with artist photo, name, genre tags on hover
- Touch support: single finger pan, pinch zoom, tap to click
- Zoom +/- buttons and fit-to-screen with smooth 250ms animation
- Click opens artist info modal (same as Your Artists)
- Loading overlay with image count progress
- Async image loading via createImageBitmap (non-blocking)
YOUR ARTISTS (major feature):
- Aggregates liked/followed artists from Spotify, Tidal, Last.fm, Deezer
- Matches to ALL metadata sources (Spotify, iTunes, Deezer, Discogs)
- DB-first matching: library → watchlist → cache → API search (capped)
- Image backfill from Spotify API for artists missing artwork
- Carousel on Discover page with 20 random matched artists
- View All modal with search, source filters, sort, pagination
- Artist info modal: hero image, matched source badges, genres, bio,
listeners/plays from Last.fm, watchlist toggle, view discography
- Auto-refresh with loading state on first load, polls until ready
- Deduplication by normalized name across all services
DEEZER OAUTH:
- Full OAuth flow: /auth/deezer + /deezer/callback
- Settings UI on Connections tab (App ID, Secret, Redirect URI)
- Token stored encrypted, auto-included in API calls
- get_user_favorite_artists() for liked artists pool
SERVICE CLIENTS:
- Spotify: added user-follow-read scope + get_followed_artists()
- Tidal: get_favorite_artists() with V2/V1 fallback
- Last.fm: get_authenticated_username() + get_user_top_artists()
FAILED MB LOOKUPS MANAGER:
- Manage button on Cache Health modal
- Browse/filter/search all failed MusicBrainz lookups
- Search MusicBrainz directly and manually match entries
- Optimized cache health queries (11 → 4 consolidated)
- Dashboard cache stats now poll every 15s
EXPLORER IMPROVEMENTS:
- Discover button on undiscovered playlist cards
- Status badges: explored/wishlisted/downloaded/ready
- Auto-refresh during discovery via polling
- Redesigned controls: prominent Explore button, icons
BUG FIXES:
- Fix album artist splitting on collab albums (collab mode fed
album-level artists instead of per-track)
- Fix cover.jpg not moving during library reorganize (post-pass sweep)
- Fix cover.jpg missing when album detection takes fallback path
- Fix wishlist auto-processing toast spam (was firing every 2s)
- Fix media player collapsing on short viewports
- Fix watchlist rate limiting (~90% fewer API calls)
- Configurable spotify.min_api_interval setting
- Better Retry-After header extraction
- Encrypt Last.fm and Discogs credentials at rest
- Add $discnum template variable (unpadded disc number)
Added min-height and flex-shrink: 0 to prevent the sidebar flex layout
from compressing the player to zero height. Desktop 120px, tablet 100px,
phone 90px. The sidebar-spacer absorbs compression instead.
Explore button moved to its own row below playlist cards with gradient,
search icon, and "Explore Selected Playlist" label. Impossible to miss.
Mode toggle redesigned as pill segmented control with grid/list icons.
Action bar buttons get inline SVG icons (checkmark, square, heart).
Primary buttons use gradient + glow shadow treatment.
Build hint shows "Select a playlist above, then explore" → updates to
"Ready: [name]" when playlist selected. Discovery poller refreshes
cards every 5s while active. Button re-enables as "Open" after modal
launch so user can reopen closed discovery modal.
1. Discover button on undiscovered playlist cards — triggers discovery
directly from Explorer instead of redirecting to Sync page. Button
changes to "Open" to reopen modal after closing.
2. Status badges on playlist cards: checkmark (in library), heart
(wishlisted), star (fully discovered), percentage (needs discovery).
Meta line shows "N in library · M wishlisted" counts.
3. Auto-refresh: polls every 5s during active discovery to update cards.
WebSocket listener for discovery:progress events. Cards refresh when
discovery completes.
4. Explored tracking: playlists get green checkmark badge after tree is
built (session-only, resets on reload).
Backend: new get_mirrored_playlist_status_counts with fail-safe design —
core discovery counts use simple reliable queries, library/wishlist
counts are best-effort extras that won't break discovery detection.
Card layout redesigned: badges inline with playlist name, discover
button below meta text, no more absolute positioning overlaps.
New feature: Failed MusicBrainz Lookups management modal accessible
from Cache Health. Browse all failed lookups with type filter tabs,
search bar, pagination. Click any entry to search MusicBrainz and
manually match — saves MBID at 100% confidence. Clear individual
entries or bulk clear all.
Backend: 4 new endpoints — failed-mb-lookups list, mb-entry delete,
musicbrainz/search (artist/release/recording), mb-match save.
Performance: Cache health stats consolidated from 11 queries to 4
using CASE expressions. Added partial index on musicbrainz_cache for
failed lookups. Dashboard cache stats now poll every 15s instead of
single fire-and-forget fetch. Failed MB type counts cached on frontend,
only re-fetched after mutations.
Also includes: library reorganize now moves cover.jpg via post-pass
sidecar sweep, and changelog updates.
- Watchlist artist list: discogs_artist_id in API response
- Watchlist source badges: Discogs badge on artist cards
- Watchlist config modal: discogs_artist_id in SQL query, WHERE clause,
response, and linked provider section with badge
- CSS for watchlist-source-discogs and watchlist-provider-badge.discogs
- Add _extract_discogs_fields to metadata cache — handles Discogs field
names (title vs name, images array, Artist - Title format)
- Worker uses _fetch_and_cache_artist/_fetch_and_cache_album helpers
that cache raw data while returning it for enrichment
- All search/lookup methods cache results for repeat queries
- Cache browser: Discogs stat pill, source filter, clear button, badge
- Fixes albums showing as 'Unknown' and artists missing images in cache
- SpotifyClient: add _discogs lazy-load property, route _fallback to
DiscogsClient when configured (requires token, falls back to iTunes)
- web_server: _get_metadata_fallback_client returns DiscogsClient when
selected and token present
- Enhanced search: Discogs added as source tab with NDJSON streaming,
only available when token configured
- Alternate sources list includes Discogs when token is set
- Frontend: source labels, tab styling, fetch list all include Discogs
- Consistent with iTunes/Deezer pattern — same interfaces, same routing
- Add Discogs to WORKER_DEFS in worker-orbs.js so it participates
in the floating orb animation like all other enrichment workers
- Use SVG logo image instead of text
- Fix spinner and state CSS to match exact pattern of other workers
- Circular button with "dc" logo text, matching exact pattern of
AudioDB/Deezer/Spotify/iTunes/Last.fm/Genius/Tidal/Qobuz buttons
- Spinner animation when active, dimmed when paused, green when complete
- Hover tooltip showing status, current item, and progress stats
- Click to toggle pause/resume with config persistence
- WebSocket status handler updates button state in real-time
- Merge enrichment worker status into rate monitor WebSocket payload
- Hide old enrichment pills — rate monitor cards now show: service name,
worker status badge, arc gauge, calls/min, 1h/24h counts, budget bar
- Debounce idle detection with 5s grace period — prevents status
flickering between Running and Idle on every worker cycle
- Responsive grid layout with richer card design
- New core/api_call_tracker.py — centralized tracker with rolling 60s
timestamps (speedometer) and 24h minute-bucketed history (charts)
- Instrument all 9 service client rate_limited decorators to record
actual API calls with per-endpoint tracking for Spotify
- 1-second WebSocket push loop for real-time gauge updates
- Modern radial arc gauges with service brand colors, glowing active
arc, endpoint dot, 0/max scale labels, smooth CSS transitions
- Click any gauge to open detail modal with 24h call history chart
(Canvas 2D, HiDPI, gradient fill, grid lines, danger zone band)
- Spotify modal shows per-endpoint history lines with color legend
and live per-endpoint breakdown bars
- Rate limited state indicator — blinking red badge with countdown
timer appears on gauge card when Spotify ban is active
- REST endpoint GET /api/rate-monitor/history/<service> for chart data
- Responsive grid layout (5 cols desktop, 3 tablet, 2 phone)
- Per-section loading spinners (artists/albums/tracks) shown until each
NDJSON chunk arrives, auto-replaced with real content on receipt
- Active tab content auto-re-renders as streaming data arrives for both
enhanced search and global search
- Global search lazy-loads artist images for iTunes/Deezer via
/api/artist/{id}/image fallback (album art), matching enhanced search
- Change <int:track_id> to <track_id> on 5 library track endpoints —
Jellyfin uses GUID strings, int converter rejected them with 404 (#237)
- Add PUT /api/library/clear-match endpoint — sets service ID to NULL
and match status to not_found, allowing users to undo wrong matches (#236)
- Add "Clear Match" button in the manual match modal for all services
- Add bottom padding to .page to prevent floating buttons (bell, help)
from overlapping track action buttons at page bottom (#237)
- New discovery_artist_blacklist table with NOCASE name matching
- Filter blacklisted artists from all 6 discovery pool queries, hero
endpoint, and recent releases via SQL subquery and Python set check
- Name-based filtering means one block covers all sources (Spotify/iTunes/Deezer)
- Hover any discovery track row → ✕ button to quick-block that artist
- 🚫 button on Discover hero opens management modal with search-to-add
(powered by enhanced search) and list of blocked artists with unblock
- CRUD API: GET/POST/DELETE /api/discover/artist-blacklist
- Updated changelogs
- Add MusicBrainz to Cache Browser: stats pill, source filter, dedicated
browse endpoint, cards with matched/failed status indicators
- Add Clear MusicBrainz and Clear Failed MB Only to cache clear dropdown
- Move MusicBrainz into Cache Health "By Source" bar chart alongside
Spotify/iTunes/Deezer instead of isolated metric row
- Rename ambiguous "Failed Lookups" to "Failed MB Lookups" in summary cards
- Add browse-musicbrainz and clear-musicbrainz API endpoints
- Add musicbrainz_total/musicbrainz_failed to cache stats response
- Add Global Search Bar and MusicBrainz cache to changelogs
Persistent Spotlight-style search bar at bottom-center, accessible
from any page via click, /, or Ctrl+K. Hidden on Downloads page
where enhanced search already exists.
Features matching enhanced search:
- Clear button when input has text
- Source tabs with live switching
- Source badges, library check, play buttons
- Album click opens download modal directly
- Artist click navigates to detail page
- Tab switching stays open (timestamp guard)
- Mobile responsive
1. LB Discover page cards now use the dropdown (Download/Sync) instead
of the old single button with full choice dialog
2. Dropdown position auto-flips downward when button is near viewport top
3. Dropdown centered on button instead of right-aligned
4. Sync completion toast now includes playlist name and ⚡ indicator
5. Download modal already shows ⚡ prefix on playlist name as indicator
All changes purely additive — existing flows unaffected.
Live status: updateYouTubeModalSyncProgress was hardcoded to youtube-*
element IDs but each source uses its own prefix (listenbrainz-*, tidal-*,
deezer-*, etc). Now tries all prefixes to find the correct elements.
Fixes Wing It sync progress AND a pre-existing bug where normal LB/Tidal
sync from the modal wouldn't show live progress.
Wing It button added to sync_complete phase so it persists after sync.
Fixed tracks lookup with state.playlist?.tracks fallback. Increased
button size in modal to match other action buttons.