Three related fixes to make album/track results look like a real
artist discography instead of a firehose of fan-compiled bootlegs.
1. Drop 'compilation' from the release-group browse primary-type filter.
MB's OR filter (`type=album|ep|single|compilation`) silently breaks
when 'compilation' is included — Metallica drops from 1076 matches
to 82 because `compilation` is a SECONDARY type on MB, not a primary
type. The invalid value corrupts the filter for all types, not just
itself. Now we request `type=album|ep|single` which returns the full
1076; actual compilations (primary=Album + secondary=[Compilation])
are filtered out by the studio-preference logic below.
2. Filter release-groups with non-studio secondary-types
(Live/Compilation/Soundtrack/Remix/Demo/Mixtape/Interview/Audiobook/
Audio drama). For Metallica, the first 100 browse results are 12
studio albums + 83 live bootlegs + 5 compilations — without this
filter the Albums section was dominated by 2019-2021 broadcast
recordings. Falls back to the unfiltered list if filtering leaves
the result set empty (covers live-only niche artists).
3. Sort chronologically ASC by first-release-date. Wikipedia-style
discography ordering — debut album on top, then chronological.
Previous DESC sort put the most recent release on top which, for
prolific artists, meant 2020s material before their classics.
Track side of the same fix:
- Re-orders each recording's `releases` array to put studio releases
first before `_recording_to_track` picks up the first release for
album context. Without this, MB's arbitrary release order often
buried the canonical studio album under random live bootlegs.
- Filters out recordings that only exist on live/compilation release-
groups (keeps the ones with at least one studio release). Falls
back to the full set if the artist has no studio recordings at all.
- Sorts recordings by earliest studio-release year ASC so classic
tracks surface first.
Smoke test against live MB API confirmed:
- Artists: [Metallica score=100]
- Albums: Kill 'Em All (1983) → Ride the Lightning → Master of Puppets
→ ...And Justice for All → Metallica (Black Album) → Load → Reload
→ St. Anger → Death Magnetic → Lulu (2011)
- Tracks: real Metallica recordings (Killing Time, Nothing Else
Matters, Creeping Death, etc.) — a few remastered demos still leak
in where MB metadata quality is thin, but the bulk is correct.
- Total latency: 3.5 seconds.
4 new tests covering the studio filter, live-only fallback, preferred
release ordering, and live-only recording exclusion.
Credit: kettui flagged the poor MB results during PR #371 review.
The previous commit's `browse_artist_recordings` call passed
`inc=releases+artist-credits` — but MusicBrainz's recording browse
endpoint rejects `inc=releases` with HTTP 400. The adapter's error
handler returned an empty list, so the Tracks section stayed empty
even though the fix was supposed to populate it.
Browse without release info is useless for our search UI (tracks
would render with no album), so swap to the fielded Lucene search
`arid:<mbid>` on the `/recording` endpoint. That's the canonical MB
pattern for "find recordings by this artist WITH release context":
- arid: search accepts the artist MBID and returns recordings with
`releases` (release-group, date, media) embedded in each result.
- One API call per lookup, same as browse would have been.
Renamed the method to `search_recordings_by_artist_mbid` so the name
matches its behaviour — it's a search, not a browse. Adapter updated
to call the new name; tests updated to match.
Verified against the live API: Metallica's MBID returns 5 recordings
in ~1.8 seconds (vs the previous 400 error).
26 new unit tests in tests/test_musicbrainz_search.py covering:
- Cover Art URL construction (release + release-group scope, empty MBID,
unknown scope fallback)
- Structured query splitting (hyphen, en-dash, em-dash, bare name, no
false-positive splits on hyphens-inside-words)
- Artist search: score filtering, strict=False call contract, exception
handling, genre extraction from MB tags, mbid/name validation
- Top-artist resolver: memoization by normalized query, sub-threshold
returns None, negative-result caching, empty-query short-circuit
- Album search routing: bare query → browse path, structured query →
text path, no-artist-match falls back to text, text path score filter
- Track search routing: browse path, dedupe-by-title across
live/compilation variants, structured query → text path, text path
score filter
All mock the underlying MusicBrainzClient — no network calls.
Also adds a WHATS_NEW entry under 2.40 explaining the three user-visible
changes: Artists section now populates, album/track results match the
searched artist instead of random title collisions, and search completes
in ~3 seconds instead of 30+.
Cover Art Archive URLs are deterministic from the MBID: a GET either
307-redirects to the image or returns 404. The previous adapter fired
`requests.head(timeout=3)` per search result to probe for the image
first. 10 results × 3s worst-case = up to 30s of blocking HEAD calls
before a search returned.
The probe was defensive overhead — the frontend already handles 404 via
`<img onerror>` fallback. Building the URL deterministically and letting
the browser load it lazily collapses the tail latency to the real MB API
calls (artist-search + browse = ~3s at the 1-rps rate limit).
Also prefer release-group scope over per-release scope when both are
available — release-group covers every edition of an album, so the hit
rate is noticeably higher than pinning to a specific regional release.
Removes now-unused `self._art_cache` and the `requests` import.
Bare name queries (typing 'metallica') now resolve to an artist MBID via
the fuzzy search added in the previous commit, then BROWSE that artist's
release-groups and recordings instead of text-searching release/recording
titles. That's the only way to fix the core garbage-results issue: MB
indexes release/recording titles, not artist names, so 'recording:metallica'
matches random tracks literally titled 'Metallica' (all scoring 100).
Structure:
- `_split_structured_query` — detects 'Artist - Title' / 'Artist – Title' /
'Artist — Title' shapes. When present, text-search is correct (user
gave an explicit title to match).
- `_resolve_top_artist` — memoized per-instance lookup for the top-scoring
artist MBID. Backend fires artists/albums/tracks searches in parallel
against one shared client instance, and albums+tracks both need the
same artist lookup. Cache + lock means one HTTP call instead of three.
- `_release_group_to_album` / `_recording_to_track` — shared projection
helpers between the browse and text paths so both paths return the
same dataclass shape.
Search flow per kind:
- `search_albums('metallica')` → resolve top artist → browse release-groups
with `type=album|ep|single|compilation` → sort by type priority then
release date desc → Album dataclasses for top N.
- `search_tracks('metallica')` → resolve top artist → browse recordings
with `inc=releases+artist-credits` → dedupe by normalized title (MB
has many live/compilation variants of the same song) → sort by release
date desc → Track dataclasses for top N.
- `search_albums('foo - bar')` → structured query → text-search path
(unchanged behavior, now score-filtered to 80+).
- `search_tracks('foo - bar')` → same.
- Both text-search paths also dedupe through `_search_albums_text` /
`_search_tracks_text` helpers, which apply the 80-score filter that
the artist-first path gets free from the resolver's threshold.
Also dedupes text-path tracks through the new `_recording_to_track`
helper, replacing ~60 lines of inline projection code. Net change is
more lines overall (browse + helpers) but the text paths shrank and
the garbage-results issue is fixed.
Credit: kettui flagged the missing Artists section + unusable track
results during PR #371 review.
`MusicBrainzSearchClient.search_artists` has been a `return []` stub
since the feature landed, with a comment claiming the MB tab 'doesn't
show artists.' That's why kettui saw a missing Artists section on the
search page — not a missing render, a hardcoded empty list.
Re-enable it properly:
- New `strict=False` parameter on `MusicBrainzClient.search_artist`
sends a bare Lucene query instead of `artist:"..."`. MusicBrainz
matches bare queries against alias+artist+sortname indexes together,
which is the right behavior for user-facing fuzzy search (finds
typos, aliases, sortname variants). `strict=True` remains the
default for enrichment/AcoustID callers that want exact matches.
- Adapter filters results to `score >= 80`. MB assigns a 0-100 Lucene
score on every hit; the true artist + close variants score 100,
tribute bands and lookalikes typically land in the 40-65 range.
The cutoff keeps "Metallica" (100) and drops "Black Metallica
Tribute Band" (60) without hand-curated lists.
- Results returned as the same `Artist` dataclass used elsewhere in
the search-tab adapter layer. `popularity` carries the MB score
(0-100) so the frontend can sort/highlight top matches if desired.
Add `browse_artist_release_groups(mbid)` and `browse_artist_recordings(mbid)`
to MusicBrainzClient. These hit `/ws/2/release-group?artist=<mbid>` and
`/ws/2/recording?artist=<mbid>` respectively — the correct MusicBrainz
pattern for "give me everything linked to this artist."
Why this matters: our current search adapter calls text-search
(`release?query=...` / `recording?query=...`) for albums and tracks,
which matches entity titles literally. Typing "metallica" hits unrelated
releases titled "Metallica" and recordings named "Metallica" by obscure
bands — every garbage match scores 100 because they're all exact title
matches on the wrong field.
Browse walks the artist→release-group and artist→recording links
directly. Once we know the artist's MBID (from `search_artist`), browse
returns their actual discography instead of title collisions.
No behavior change yet — search adapter still uses the old path. Follow-
up commit wires the new endpoints in.
Reference: https://musicbrainz.org/doc/MusicBrainz_API — "Browse queries
retrieve entities linked to a known entity" vs search.
MusicBrainz mandates a meaningful User-Agent with contact info, warning
that bare strings can trigger IP blocking under load. Our client was
sending `SoulSync/2.3` with no contact — and the search adapter passed
an app version hard-coded at "2.3" that's now stale (UI is at 2.40).
Fix: default contact to the project URL (`https://github.com/Nezreka/SoulSync`)
when no email is supplied, so every request lands as
`SoulSync/<version> ( https://github.com/Nezreka/SoulSync )`. Drop the
search-adapter version suffix to a generic "2" since the exact UI minor
version would add noise to every MB request without helping operators
track issues.
Reference: https://musicbrainz.org/doc/MusicBrainz_API — "it is
important that your application sets a proper User-Agent string."
Clicking 'View Discography' on the Discover hero slideshow was calling
navigateToArtistDetail(id, name) without the third 'source' argument.
loadArtistDetailData then omits the `source` query param, so
/api/artist-detail falls through to a local DB lookup and returns 404
for artists that don't exist in the library — which is nearly every
hero artist, since they come from discover similar-artists.
Regression from the unification PR (93f1941) that rewrote the click
handler to route through the standalone /artist-detail page instead
of the old inline Artists view. The rewrite didn't thread the source.
Backend already includes `artist.source` on each hero entry. Fix:
- Stash artist.source as data-source on #discover-hero-discography
when displayDiscoverHeroArtist populates the card.
- Read data-source in viewDiscoverHeroDiscography and pass it as the
third arg to navigateToArtistDetail, so the eventual API call
includes `?source=itunes/deezer/etc.` and returns the synthesized
discography.
Reproduced by clicking View Discography on a non-Spotify hero artist
(log showed `GET /api/artist-detail/76258852?name=ДЕТИ+RAVE → 404,
Getting artist detail for ID: 76258852 (source=library)`).
Two bugs in the previous review-fix commits, found during a Cin-standard
re-audit:
A) Soulseek handoff stale state.query overrode the global widget's query
The previous fix pre-set basicInput.value before clicking the Search
page's Soulseek icon. But the click triggers onSoulseekSelected with
the controller's CURRENT state.query — which is whatever the user
last typed on /search, not the global widget's query. The Search
page callback then ran `if (query) basicInput.value = query;` and
overwrote the just-set value with the stale one before firing
performDownloadsSearch.
Fix: expose searchController as `_searchPageController` (mirrors
`_searchPageRestoreOnEnter` already at module scope). Global
widget's _gsNavigateToSearchPage syncs `_searchPageController.state.query`
to its own query before clicking the icon. Also added a fallback
for the case where the icon doesn't exist yet (controller still
mid-init): swap sections + run performDownloadsSearch directly.
B) Single _requestSeq token leaked loadingSources across sources
The earlier "stale request" fix used one global _requestSeq. But
when the user switched Spotify → Deezer mid-fetch, the Spotify
abort's catch block bailed (1 !== 2), leaving 'spotify' in
loadingSources forever — permanent spinner on the Spotify icon
even though no fetch was running for it.
Fix: per-source `_sourceRequestIds[src]` map. Same-source
supersession bails (correct), cross-source supersession still
clears the old source's loadingSources entry (correct).
Bonus defensive: submitQuery now invalidates every per-source token
and aborts the in-flight fetch when the query string changes. Catches
the residual edge case where user clears the input — the in-flight
fetch's settle would otherwise write stale data into the just-cleared
state.sources.
Cin flagged that Soulseek was always rendered as configured in the
source picker, even on dev instances with no slskd set up — letting
users click it and fire searches that could never succeed.
Three coordinated changes:
1. web_server.py SERVICE_CONFIG_REGISTRY: add Soulseek entry requiring
`slskd_url`. /api/settings/config-status now reports its real state
alongside every other service.
2. shared-helpers.js _ALWAYS_CONFIGURED_SOURCES: drop 'soulseek'. The
set is now just MusicBrainz + YouTube Music Videos (sources that
genuinely don't need user creds). Soulseek goes through the normal
config-status code path.
3. shared-helpers.js openSettingsForSource: special-case Soulseek to
route to Settings → Downloads tab (where slskd URL field lives,
gated behind the download-source-mode dropdown) and scroll to the
#soulseek-url input. Every other source still routes to Connections
and scrolls to its .stg-service card. Without this, Soulseek's
"click to configure" landed on a Connections card that doesn't
exist (Soulseek's URL/key fields are scoped to the download-source
selection on the Downloads tab).
Two AI-review findings from Cin (kettui) on the source-picker PR:
1. Soulseek handoff from global widget went through metadata flow
_gsNavigateToSearchPage(query, 'soulseek') wrote the query into
#enhanced-search-input and dispatched an input event. The Search
page controller's activeSource was whatever its default was
(spotify, deezer, etc.), so the debounced submitQuery ran the
enhanced /api/enhanced-search flow instead of the raw Soulseek
file search. The `src` parameter was effectively ignored.
Fix: when src === 'soulseek', pre-fill #downloads-search-input
directly and click the Search page's Soulseek icon. The icon click
triggers the controller's onSoulseekSelected callback, which owns
the section swap and re-runs performDownloadsSearch against the
value we just wrote to the basic input.
2. Stale in-flight requests cleared loadingSources after fast retype
createSearchController._fetchSource awaits the fetch result, then
unconditionally mutates state.loadingSources / state.sources in
the settle and catch blocks. When a user typed "abc" → fetch
started → typed "abcd" before the first fetch returned, the
second submitQuery aborted the first fetch and started its own.
The first fetch's catch (AbortError) then ran and cleared
loadingSources for that source — wiping the spinner the new
request had just set, and causing a brief flash of empty/error
state while the new fetch was still in flight.
Fix: monotonic _requestSeq token. Each _fetchSource call captures
the next value (++_requestSeq). Settle / catch blocks (and the
YouTube NDJSON streaming loop) bail before mutating shared state
if requestId !== _requestSeq. Existing abortCtrl behavior unchanged
— this is a layered defense for the catch-clobber pattern that
abort alone can't prevent.
The navigate-back fix from the previous commit was being immediately
undone by the document outside-click handler. Race:
1. Click on sidebar nav-button → button handler runs synchronously,
eventually calling _searchPageRestoreOnEnter → _renderFromState →
showDropdown removes `hidden` class
2. Click event bubbles up to document
3. Document outside-click handler sees dropdown is now visible, sees
the click target is a nav-button (not inside the search wrapper or
the source row), calls hideDropdown → instantly hidden again
Fix: defer the _renderFromState call to setTimeout(0). The macrotask
runs AFTER the click event finishes propagating, so by the time the
dropdown becomes visible, the document outside-click handler has
already short-circuited (it saw the dropdown still hidden).
User reported having to delete + retype the last character of the
query to force a re-render — which worked because the input event
listener fires submitQuery, which routes through the controller
without going through the deferred path.
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.
The hourly `clean_search_history` automation was crashing with
`'DownloadOrchestrator' object has no attribute 'base_url'`. The guard
was written before the orchestrator refactor — `soulseek_client` is now
a DownloadOrchestrator that wraps individual download clients, with the
real Soulseek client sitting at `.soulseek`.
Two other call sites in web_server.py (lines 2634, 3092) already used
the correct `soulseek_client.soulseek.base_url` pattern with a getattr
guard. This call site was missed during the refactor.
Fix: reach through the orchestrator the same way the other sites do.
Both the Search page and the global search widget ran the same source-
picker state machine (query, activeSource, per-query cache, fallbacks,
loading set, configured-source discovery, NDJSON streaming for YouTube,
default-source fall-forward). That was ~380 lines of near-duplicated
logic split across search.js and downloads.js, which meant every bug fix
or behavior tweak had to land twice and inevitably drifted.
createSearchController in shared-helpers.js now owns all of that. Each
surface passes per-surface wiring — a source-row DOM element, a CSS
class prefix, and callbacks for Soulseek handoff + unconfigured-source
redirect — and consumes the controller's state via an onStateChange
callback. The surface files shrink to their actual responsibilities:
results rendering, click handlers, and surface-specific visibility.
Zero UX change. Every keystroke, icon click, cache hit, rate-limit
fallback, and unconfigured-source redirect behaves identically to before
— verified via full pytest suite (395 passed) and node --check on all
three files.
WHATS_NEW entry added under the 2.40 unified-search bucket.
The new components shipped this PR (source icon row, fallback banner,
glow aura, library-empty search CTA) had no responsive styling. On
phones the rows ran fine via horizontal scroll but the chips wasted a
lot of space per icon, the CTA could overflow on narrow screens, and
the aura kept its desktop-sized ellipse for no benefit.
At ≤768px (tablet/phone):
- Enhanced source row: tighter padding, 24x24 glyphs, 72px chip
min-width.
- Global widget source row: even tighter, 20x20 glyphs, 62px chips.
- Fallback banners scale down to match.
- Aura shrinks to a 440x160 / 540x200 ellipse and a 180px-tall strip
so it doesn't eat short mobile viewports.
- Library empty CTA allows text wrap + reduced padding so the
"Search online for "long artist name"" string doesn't break the
layout on narrow screens.
At ≤480px (phone):
- Enhanced chips drop to 44px min-width.
- Global widget chips drop to 40px.
- Both hide the source-name label, showing icon + tooltip only — the
full 8-source row now fits or scrolls minimally at that scale.
- Aura narrows further to 140px tall.
- Library CTA nudges down to 12px font.
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.
The existing 2.40 WHATS_NEW entry described the short-lived "Search
from" dropdown that preceded this redesign. Updated to describe the
icon row + per-query cache + rate-limit fallback banner + global widget
parity that actually ships.
Click-for-help annotations and the "First Download" tour now point at
`#enh-source-row` (the new icon container) instead of the deleted
`.search-source-picker-container` dropdown and the deleted
`.enh-source-tabs` post-search tab bar. Adjusted the enhanced-search
tips so "multi-source tabs compare results" doesn't mislead — the
icons above the bar are how you compare now.
Version stays at 2.39 — the 2.40 WHATS_NEW section is accumulating
under the "Search & Artists unification" umbrella and will publish
when the whole 2.40 cycle ships.
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.
These three utilities lived inside search.js — the fetch helper at module
scope, and SOURCE_LABELS plus renderCompactSection as closures inside
initializeSearchModeToggle. The global search widget in downloads.js
already depends on enhancedSearchFetch via global scope and re-implements
the rendering inline.
Hoist all three to shared-helpers.js so both surfaces share the same
implementations. No behavior change — this is the refactor step that
precedes the source-picker redesign.
Also adds a 'soulseek' entry to SOURCE_LABELS for the upcoming icon row.
When a completed download's track_info has neither an `id` field nor a
`wishlist_id`, Methods 1-3 of _check_and_remove_from_wishlist() all skip
without defining `wishlist_tracks`. Method 4 (fuzzy match) then hits
`if not wishlist_tracks:` and raises UnboundLocalError, which the call
sites catch + log but silently skip the wishlist removal for that track.
Path became more common after the batch-queue-system refactor started
routing non-Spotify-id completions (e.g. discover sync tracks downloaded
under a non-Spotify primary source) through the same completion handler.
Fix: initialize `wishlist_tracks = []` at the top of the try block so
Method 3's reassignment still works and Method 4's `if not wishlist_tracks`
guard always has a defined value to test.
Credit to RENOxDECEPTION (JohnBaumb) for pinpointing the variable-scope
issue during PR #357 testing.
Add repository guard (github.repository == Nezreka/SoulSync) to
cleanup-dev-images, dev-nightly, and docker-publish workflows.
build-and-test stays available for fork contributors.
JohnBaumb's review: "If we're going to refactor the web_server.py soon,
might as well start moving stuff away from web_server.py in our PRs.
_build_source_only_artist_detail, make it a module, it's perfect."
This continues the pattern the prior commit started with the source-ID
lookup helpers: move the pure data-building logic to a side-effect-free
core module, leave a thin wrapper in web_server.py that bridges the
Flask response and the module-global clients.
**core/artist_source_detail.py** — pure function that takes the artist id,
name, and source plus dependency-injected per-source clients (spotify,
deezer, itunes, discogs) and a Last.fm API key. Returns
(payload_dict, http_status) so it isn't coupled to Flask.
**web_server.py wrapper** — builds the client bag from the module globals
(checks Spotify auth, constructs the Discogs client from the configured
token, reads the Last.fm API key) and wraps the core return in jsonify.
147 lines of logic go away from web_server.py; the 24-line wrapper is
purely glue.
**tests/test_artist_source_detail.py** — 21 focused tests covering the
response envelope, the source-specific ID-field stamping for all six
supported sources, the dedup_variants=False contract (the behaviour
that originally motivated the split of MetadataLookupOptions), per-source
genre/follower extraction with safe handling of missing or throwing
clients, and the Last.fm enrichment branch including the no-key and
error-path cases. Runtime 0.26s.
Four fixes from the review:
**library.js — back button stack (JohnBaumb):**
Replace the single-slot `artistDetailPageState.originPage` with an origin
stack. Chained navigation like Search → Artist A → similar Artist B →
similar Artist C now walks back one step at a time (C → B → A → Search)
instead of jumping straight to Search and skipping A and B.
`navigateToArtistDetail` takes an optional `{skipOriginPush}` flag so the
back button can re-enter a prior artist without re-pushing onto the stack.
Fresh entries from a non-artist page clear any stale stack from a prior
chain. Duplicate-click detection avoids pushing the same target twice.
Label derivation (`_updateArtistDetailBackButtonLabel`) reads the stack top
so the button says "Back to <ArtistName>" mid-chain and "Back to Search"
at the root.
**library.js — checkArtistEnhanceEligibility after library upgrade (Cin):**
The quality-analysis endpoint only works on library PKs. After the
library-upgrade branch rewrites `currentArtistId` from the source ID to
the library PK, the check was still using the original closure arg, so
upgraded source artists never hit `/api/library/artist/<id>/quality-analysis`.
Use `artistDetailPageState.currentArtistId` so the call gets the resolved id.
**init.js — isPageAllowed + home page recursion (Cin):**
- artist-detail is reachable from both Library and Search results now, so
permission check accepts either grant (plus legacy 'downloads'/'artists'
aliases). Search-only profiles can open source artists; legacy artists-only
profiles no longer recurse on the home redirect.
- `getProfileHomePage` rewrites 'artists' → 'search' (it already rewrote
'downloads') so legacy home_page values resolve correctly.
- Legacy-compat expanded in isPageAllowed to treat 'artists' as equivalent
to 'search' in both directions.
**init.js — profile edit forms dropping values on save (Cin):**
Both pageLabels maps (admin edit form + self-edit form) referenced the
legacy 'downloads'/'artists' keys. When editing a profile saved with
`home_page: 'search'` and `allowed_pages: ['search', 'library']`, the
home select didn't render a 'search' option, and the allowed_pages
checkboxes used 'downloads' as their value — so saving the form dropped
both values.
Update both maps to use 'search' as the canonical key. Add
`_normalizeLegacyAllowedPages` and `_normalizeLegacyHomePage` helpers that
migrate any legacy ids in allowed_pages/home_page on read, so a legacy
profile's first save upgrades its stored ids to the new canonical form.
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.
Cin: arriving at artist-detail from Search/Discover/Watchlist
highlighted the Library sidebar entry, which is misleading — the user
didn't navigate via Library. The hardcoded mapping was a holdover from
when artist-detail was reached only from the (now retired) Artists page.
Drop the special case so artist-detail behaves like playlist-explorer:
no [data-page] match in the sidebar, no highlight. The user's actual
origin page is already preserved on the back button.
The deep-link fallback in _getPageFromPath (artist-detail → library)
is left intact: if someone pastes /artist-detail in the URL bar with
no state to render, library is still the most sensible landing page,
and sidebar-highlighting Library in that scenario is correct because
they're literally on Library.
Cin observed that database/api_call_history.json was occasionally
landing on disk truncated mid-write — `_load()` would log
`History file is not valid JSON, starting fresh` and 24h of metrics
would be lost.
Root cause: `save()` opened the file in 'w' mode (which truncates to
0 bytes immediately) and then streamed JSON via `json.dump`. Any
SIGINT/SIGTERM/crash between truncate and final write left the file
half-formed — exactly Cin's symptom of the JSON cutting off mid-array.
Switch to the standard atomic pattern: write to a sibling .tmp file,
flush + fsync, then `os.replace` (atomic on every platform we run on).
Failed writes also clean up the leftover .tmp file. The canonical
file is now either the previous good copy or the new good copy —
never a partial one.
Cin's review note: typing artist_name as plain `str` forced callers
that didn't have a name to pass `""` as a placeholder, which leaks the
parameter's emptiness contract into every call site and reads badly in
tests. Switching to `Optional[str] = None` lets callers omit it.
The function body's `if artist_name and active_server:` check already
handles None and "" identically, so no body changes were needed. Tests
that previously passed `artist_name=""` drop the argument; one new test
covers the omitted-arg path explicitly.
The web_server.py wrapper takes the same default for symmetry.
Cin pointed out that the prior version of test_artist_source_lookup.py
AST-parsed web_server.py to verify a constant and to string-match a
function's response keys. That was a workaround for the fact that
web_server.py can't be imported at test time (it boots Spotify,
Soulseek, Plex, etc.) — the right answer is to move the logic into a
side-effect-free module so it can be imported and tested directly.
This commit:
- adds core/artist_source_lookup.py containing the SOURCE_ID_FIELD
map, the SOURCE_ONLY_ARTIST_SOURCES set, and find_library_artist_for_source
- replaces the inline definitions in web_server.py with imports +
a thin wrapper that injects the active media server
- rewrites the tests to import from the core module directly:
* mapping correctness is now a plain equality assertion
* lookup behaviour is exercised against a real MusicDatabase
* the AST parse and the string-matching contract test class are
gone
- drops the _build_source_only_artist_detail contract test entirely
(the weakest of the four — it was just string-matching the function
body); when that function moves to core/ it can get a real
behavioural test alongside.
Test runtime drops from ~161s to ~5.8s. All 18 tests pass.
On the unified Search page the results dropdown was dismissed three
ways that didn't match user intent:
- clicking an album row called hideDropdown() before opening the
download modal, so the dropdown was already gone by the time the
modal closed
- clicking a track row did the same
- clicking the play button on a track row did the same
- the outside-click handler treated a click inside the download
modal (its close button or backdrop) as a click outside the
dropdown, dismissing it on modal close
Reported by Cin: "clicking an album in the search results opens a
download modal as expected, but closing said modal also hides the
search results in the same go."
Drop the explicit hideDropdown() calls from those three handlers and
whitelist .download-missing-modal in the outside-click handler. The
dropdown now persists across the click + modal lifecycle so the user
can pick another result without re-running the search. Artist clicks
still dismiss because they navigate to /artist-detail.
The global search popover keeps its existing dismiss-on-click
behaviour — its high z-index conflicts with the modal stack and
auto-dismiss is the right pattern for a Spotlight-style popover.
Four targeted backend tests for behaviour added during the Search/Artists
unification work:
1. _SOURCE_ID_FIELD mapping is parsed out of web_server.py via AST and
compared against an explicit expectation, so silent renames break the
test instead of silently breaking library-upgrade detection.
2. Every column in _SOURCE_ID_FIELD must exist on the real artists table
after migrations run. This is the schema-vs-query contract that the
`deezer_artist_id` typo would have failed instantly.
3. The two queries from the watchlist-config enrichment path execute
verbatim against a fresh DB — separate ones for the artists table
(deezer_id / discogs_id) and the watchlist_artists join (deezer_artist_id).
Documents the column-name split that caused the original bug.
4. Static contract test for _build_source_only_artist_detail's response
shape: every JSON key the frontend reads (success/artist/discography/
image_url/server_source/genres/lastfm_*) must appear in the function
source, plus the dynamic source-id stamp and the dedup_variants=False
opt-out.
Plus a behavioural test for MetadataLookupOptions.dedup_variants=False
in test_metadata_service_discography.py — proves the flag actually keeps
variant releases that get_artist_detail_discography would otherwise
collapse to a single canonical entry.
The three discovery-pool tests hardcoded release_date strings
("2026-04-01", "2026-04-16") that were checked against a rolling
`datetime.now() - timedelta(days=7)` (or 21-60 day) cutoff in the
scanner. Once the wall clock advanced past the cutoff window the
releases were filtered out and the assertions failed — Python 3.11
Linux CI was already past 2026-04-23 UTC.
Replace the hardcoded values with a module-level
`_RECENT_RELEASE_DATE = now - 2 days` so the fixtures stay inside
every cutoff window regardless of when the suite runs.
The library-enrichment query inside /api/watchlist/artist/<id>/config
queries the `artists` table but used the column names from the
watchlist_artists table:
WHERE spotify_artist_id = ? OR itunes_artist_id = ?
OR deezer_artist_id = ? OR discogs_artist_id = ?
The `artists` table actually uses `deezer_id` and `discogs_id` for
those two columns (only `watchlist_artists` uses the `_artist_id`
suffix). The mismatch threw `no such column: deezer_artist_id` on
every config GET, which was caught by the surrounding try/except and
logged — releases came back empty and Spotify/genres etc. fell back
to defaults.
Visible side effects: the request that LOOKED slow ('1420.2ms') and
the recurring ERROR line in app.log every time a watchlist artist
overlay opened.
Watchlist-config GET now returns proper banner_url / summary / style
/ mood / label / genres for Deezer- and Discogs-source artists too.
The other watchlist queries in this endpoint (42302 / 42315 / 42379)
correctly target watchlist_artists and stay as-is.
helper.js click-for-help annotations had ~10 entries pointing at DOM
elements from the retired inline Artists page (#artists-search-input,
#artists-back-button, #artists-results-state, #artists-cards-container,
#artists-hero-section, .artists-hero-name/badges/genres/bio/stats,
#artist-detail-watchlist-btn/-settings-btn, .artist-detail-tabs,
#albums-tab, #singles-tab, #album-cards-container, #singles-cards-
container) plus #similar-artists-section pointing at the legacy id
(now #ad-similar-artists-section on the standalone page).
Replaced the dead Artists-page block with annotations that target
elements actually present on the standalone /artist-detail page:
.album-card, .completion-overlay, #ad-similar-artists-section,
.similar-artist-bubble, plus a new entry for .search-source-picker-
container on the unified Search page.
docs.js: 'How to: Set Up Auto-Downloads' step 1 used to read 'Search
for artists on the Artists page and click the Watch button on each
one'. Updated to 'Find artists via the Search page (or click an
artist anywhere in the app), then click the Watch button on the
artist detail page' — matches the post-unification flow.
Backend API endpoint references in docs.js (/library/artists, etc.)
are unrelated to the retired frontend page and stay as-is.
The Top Tracks sidebar play button on the artist-detail page (and the
same buttons on the Stats page) called /api/stats/resolve-track and
gave up with a 'Track not found in library' toast on a miss.
Now when the library lookup misses, falls through to /api/enhanced-
search/stream-track — the same Soulseek/YouTube/streaming-source
pipeline the search-results play button uses. So Last.fm popular
tracks, recent plays, and stats artist top tracks all play even if
you don't own the track yet.
Library hit still wins (faster, full quality). Only on miss does it
escalate to streaming. Final error toast updated to reflect both
paths having been tried.
The library completion stream calls updateLibraryReleaseCard once per
release as ownership resolves. The handler was still updating the OLD
card markup (.completion-text + .completion-fill / .completion-bar),
so cards rendered with the new .completion-overlay badge stayed stuck
in the pulsing 'Checking…' state forever.
Now updates the new structure in place:
- Toggles the .completion-overlay state class (checking → completed
/ nearly_complete / partial / missing) which the existing CSS uses
to colour and stop the checking-pulse animation.
- Rewrites the inner .completion-status text:
Owned → '✓ Owned'
Partial → 'X/Y' (75%+ → nearly_complete badge, else partial)
Missing → 'Missing'
- Sets a tooltip on the overlay with detailed track counts.
Per-card .release-card.checking class also gets removed when state
resolves (stops the whole-card opacity pulse).
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.