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.
The "← Back to Library" button on /artist-detail was hardcoded to
navigate back to the Library page regardless of where the user came
from. Now it captures the originating page and labels/routes
accordingly.
navigateToArtistDetail captures currentPage at call time (before the
swap to artist-detail) and stashes it on
artistDetailPageState.originPage. Falls back to library when the
origin can't be determined or when chaining detail-to-detail (e.g.
clicking a Similar Artist on the detail page).
Back button label now adapts:
- From Library card click → "← Back to Library"
- From Search result → "← Back to Search"
- From Discover hero / Your Artists → "← Back to Discover"
- From Watchlist artist detail → "← Back to Watchlist"
- From Wishlist / Stats / Explorer / Automations / Dashboard etc.
→ corresponding labels
- Unknown origin → "← Back to Library"
Click handler navigates to the captured origin instead of always
going to library. State is cleared on click so a fresh artist-detail
view starts clean next time.
Two bugs from the source-artist click flow:
1. After the backend's library upgrade kicks in (clicking a Deezer
result for an artist you already own routes through the library
path), the response's data.artist.id is the library PK while
artistDetailPageState.currentArtistId still held the source id.
Toggling Enhanced view then fired
/api/library/artist/<source_id>/enhanced which 404'd because the
source id isn't a library PK.
loadArtistDetailData now updates currentArtistId from
data.artist.id whenever they differ — Enhanced view, completion
checks, server sync etc. all use the right id.
2. The Similar Artists section is part of the standard view and was
staying visible when Enhanced view toggled on. toggleEnhancedView
now hides #ad-similar-artists-section in the same flow that
hides .discography-sections.
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.
LastFMClient() with no args has no api_key -> get_artist_info silently
returns None -> source artists never see bio/listeners/playcount even
though my previous commit was supposedly fetching them.
Now reads config_manager.get('lastfm.api_key') and only attempts the
enrichment when the key is configured. Users without Last.fm credentials
in Settings simply get image+name+badges+genres on source artists
(everything except bio + stats), which is fine.
Source artists landing on /artist-detail were rendering an almost-blank
hero — image + name + a tiny Download button — because the backend
response only had {id, name, image_url, server_source: null, genres: []}.
The library.js renderers do their best with what they have, and that
wasn't much.
Backend changes (_build_source_only_artist_detail):
- Set the source-specific ID field (deezer_id / spotify_artist_id /
itunes_artist_id / discogs_id / soul_id / musicbrainz_id) on
artist_info so the corresponding service badge renders on the hero.
- Try the source's own get_artist_info / get_artist for genres +
followers (Spotify always; Deezer/iTunes/Discogs when available).
Spotify also fills image_url if metadata_service.get_artist_image_url
came up empty.
- Last.fm enrichment by artist name — bio + listeners + playcount +
lastfm_url. Mirrors what library artists get from the cached
enrichment workers but on demand for source artists.
- All enrichment lookups are wrapped in try/except so a 500 from any
one source doesn't break the whole response.
Frontend (library.js populateArtistDetailPage):
- Watchlist button now initialises for source artists too. Falls back
to artist.id + artist.name when there's no canonical Spotify
identity (which is the common case for non-library artists).
Discography dedup opt-out:
- Added dedup_variants flag to MetadataLookupOptions (default True so
library artists are unchanged). Source-only path now passes
dedup_variants=False so every "Deluxe Edition" / "Remastered" /
"Anniversary" variant the source returns is shown — matches the
inline /artists page behaviour the user was comparing against.
Result: source artists' hero now shows badges + bio + listeners +
playcount + watchlist button + genres in addition to image and name.
Discography lists every release the source returns, not the deduped
canonical view.
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.
The bubble click handler hardcoded selectArtistForDetail (the inline
Artists page navigator). On the standalone /artist-detail page it
fired but couldn't actually navigate anywhere — the page just
re-rendered the similar-artists section for the same artist instead
of moving to the clicked artist.
Now: detect whether the standalone page is active. If yes,
navigateToArtistDetail(id, name, source). Otherwise fall back to
selectArtistForDetail for the inline page (unchanged behaviour
there). Both surfaces work correctly without the caller having to
know which page they're on.
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.
Part D + E of the deferred cleanup + the final version bump that
publishes the whole Search/Artists unification project.
Deletions:
- webui/static/artists.js (1903 lines) — removed entirely. The 2
remaining externally-referenced helpers (lazyLoadArtistImages +
showCompletionError) moved into shared-helpers.js first.
- webui/index.html — 140-line #artists-page HTML block and the
<script src="artists.js"> tag both removed.
init.js wiring:
- 'case artists:' removed from loadPageData switch (no page to init).
- navigateToPage top-level alias extended: 'artists' → 'search'
(same pattern as the existing 'downloads' → 'search' alias).
Legacy /artists bookmarks land on the unified Search page, the
natural place to find an artist now.
- _getPageFromPath now maps artist-detail → library as its parent
(was artists). Matches the existing library-nav-highlight at
init.js:2161.
Version bump:
- _SOULSYNC_BASE_VERSION 2.39 → 2.40.
- WHATS_NEW entries lose the 'unreleased' scaffolding and gain a
new top entry summarizing the unified artist-detail page + the
final artists.js retirement.
- version-info modal gets a 'Search & Artists Unification' section
at the top.
- The _getLatestWhatsNewVersion filter added during the unreleased-
tracking phase is rolled back — entries now display as soon as
they land in WHATS_NEW, matching the pre-unification behaviour.
Test suite:
- tests/test_script_split_integrity.py SPLIT_MODULES updated:
'artists.js' dropped, 'shared-helpers.js' added. escapeHtml's
cross-file dupe list entry updated to reference shared-helpers.
- 354/354 tests pass.
User-visible result after this commit:
- Sidebar: Search, Downloads, Discover, Library, Wishlist, etc. —
no more Artists entry.
- Click any artist anywhere: lands on the same /artist-detail page.
- Search page has a source dropdown; Soulseek is just another option.
- Legacy /downloads and /artists URLs alias to /search.
- Version button shows v2.3 (Docker major); "What's New" panel
opens to the unification summary.
Closes the project Cin requested in Discord. Future work: source-aware
/api/artist-detail could be extended to fall back through the whole
source priority chain when a specific source is given but returns no
discography. Not needed for the current flows.
Part B of the deferred unification cleanup. Now that Part A teaches
/api/artist-detail/<id> to fall back to a metadata-source lookup when
the library DB lookup misses, source-artist clicks can finally land
on the standalone page without 404ing — the goal Phase 4a aimed for
and had to roll back in commit 19e9174.
Re-migrating the seven callsites reverted earlier in this session:
- search.js enhanced-search source-artist onClick
- downloads.js _gsClickArtist (global widget non-library branch)
- downloads.js _navigateToArtistFromModal fallback
- discover.js viewRecommendedArtistDiscography
- discover.js viewDiscoverHeroDiscography
- discover.js 'Your Artists' card navAction inline onclick
- discover.js 'Your Artists' info-modal 'View All' button
- discover.js artist-map context menu
- discover.js genre-deep-dive artist click
- api-monitor.js watchlist discography view
Each replaces the navigateToPage('artists')+setTimeout+selectArtist-
ForDetail dance with a single navigateToArtistDetail(id, name,
source) call. The third arg seeds artistDetailPageState.currentArtist-
Source, which library.js now reads and forwards as ?source= to the
backend (added in Part A).
Effect: clicking an artist in any of these surfaces now lands on the
standalone /artist-detail page with a stable URL, source context
preserved, and owned-library data merged in when available. Library
artist clicks (unchanged) and media-player / stats links (unchanged)
all continue to use navigateToArtistDetail too, so they now
consistently share one destination.
The inline Artists page (#artists-page + selectArtistForDetail in
artists.js) still exists but has no external callers left — only the
page's own internal search-result click handler references the
function now. Parts D + E will delete the dead inline page and
finally remove artists.js.
Part A of the deferred unification cleanup. The standalone artist-
detail endpoint used to 404 whenever `artist_id` wasn't a local library
primary key, which is exactly what source artists (Deezer/Spotify/
iTunes/etc.) have. That forced the Phase 4a revert: source artists had
to use the inline Artists page because this endpoint couldn't handle
them.
New behaviour:
- Library PK path — unchanged. Existing callers see the same response.
- `/api/artist-detail/<id>?source=<src>&name=<name>` with source in
(spotify, itunes, deezer, discogs, hydrabase, musicbrainz) — when
the library DB lookup misses, synthesize a response by:
• fetching artist image via metadata_service.get_artist_image_url
with source_override (the helper already backing /api/artist/
<id>/image)
• fetching discography via metadata_service.get_artist_detail_
discography with MetadataLookupOptions(source_override=source,
artist_source_ids={source: artist_id})
• returning { success, artist: {id, name, image_url, server_source:
null, genres: []}, discography, enrichment_coverage: {} }
- Library PK missing AND no source — preserves the 404 (caller didn't
give enough info to fall back).
Frontend plumbing: library.js loadArtistDetailData now appends
?source=<src>&name=<name> to the fetch URL when
artistDetailPageState.currentArtistSource is set. The field is already
seeded by navigateToArtistDetail's third arg (added during the earlier
unification work), so no new state plumbing is needed.
populateArtistDetailPage gracefully handles the missing-library-data
case per earlier exploration — owned_releases empty is fine,
enrichment_coverage optional, spotify_artist_id optional.
Part B will re-route the source-artist callsites (Search / Discover /
Watchlist / etc.) back through navigateToArtistDetail so they actually
exercise this new fallback path.
Part C of the deferred unification cleanup. The Artists page is no
longer in the sidebar, but its JS file can't be deleted yet because
it houses ~20 general-purpose helpers that other modules depend on
(escapeHtml used in 229 places, service-status polling, image-colour
extraction, download-bubble infrastructure, discography completion
checking, enrichment card rendering).
Moved all non-page-specific code from artists.js into the new
webui/static/shared-helpers.js — pure copy/paste, zero logic change.
Two contiguous blocks extracted:
Block A (lines 1097..1398 of original artists.js): discography
completion suite — checkDiscographyCompletion, handleStreaming-
CompletionUpdate, cacheCompletionData, updateAlbumCompletion-
Overlay, getCompletionStatusText, setAlbumDownloadedStatus,
setAlbumDownloadingStatus.
Block B (lines 2206..EOF of original artists.js): download-bubble
infrastructure (artist + search + Beatport clusters with their
snapshot/hydrate/modal/monitor helpers), openDownloadMissingModal-
ForArtistAlbum, image-colour extractor and dynamic-glow helper,
escapeHtml, service-status polling, renderEnrichmentCards.
Function declarations in a plain <script> tag are auto-global, so all
existing callers continue to resolve without any import/export
changes. Load order in index.html: shared-helpers.js loads right
after core.js (which defines the artistDownloadBubbles / search-
DownloadBubbles / beatportDownloadBubbles globals these helpers use).
Stats:
artists.js: 4638 → 1903 lines (-2735)
shared-helpers.js: new, 2762 lines
No function duplicated between the two files
All 357 tests pass (3 new from split-integrity parametrization)
What's left in artists.js is purely the Artists page — search UI,
detail view, state switching, watchlist button, discography loading.
All of that is reachable only by typing /artists in the URL bar
since the sidebar entry was retired in Phase 4b. Parts D + E will
delete that remainder and the file itself.
When the user reached the inline Artists detail view from outside
the Artists page and no browser history is available (direct
bookmark), fall back to the Search page instead of Dashboard.
Search is the natural next step for finding a different artist.
Reverts the 2.40→2.49 version spam from this session — every phase
commit was bumping the display version when the whole Search/Artists
unification project should really be a single release.
Changes:
- _SOULSYNC_BASE_VERSION back to 2.39
- All session-level version-info sections consolidated — the endpoint
response is back to the pre-session 2.39 shape
- helper.js WHATS_NEW entries for 2.40–2.49 collapsed into a single
'2.40' block with one bullet per phase, marked unreleased
- _getLatestWhatsNewVersion / _showOlderNotes filter out entries
whose version is higher than the current build, so the 2.40 block
won't fire the 'new' badge or appear in the What's New panel until
we actually flip the build version
- Picks up the artist-detail back-button fix from the previous turn
(falls back to browser history when the user reached the inline
detail from outside the Artists page)
When the unification project is done, a single commit that bumps
_SOULSYNC_BASE_VERSION to 2.40 will publish the whole folded entry.
Phase 4a (9361c29) mistakenly routed every artist click to
navigateToArtistDetail, which fetches /api/artist-detail/<id>. That
endpoint only knows how to look up local DB primary keys. For source
artists (Spotify/Deezer/iTunes/etc.) the id is a metadata-source id,
not a library PK — so clicks 404'd out.
Library artists (db_artists section in search results, library page
clicks, stats links, media player) continue to go to the standalone
/artist-detail page as before. Source artists now route back to the
Artists page's inline view via selectArtistForDetail, which calls
/api/artist/<id>/discography with a source param — the endpoint that
actually handles non-library IDs.
Reverted 7 migration points:
- search.js: Enhanced Search source-artists onClick
- downloads.js: global widget _gsClickArtist non-library branch
- downloads.js: _navigateToArtistFromModal fallback
- discover.js: viewRecommendedArtistDiscography
- discover.js: viewDiscoverHeroDiscography
- discover.js: 'Your Artists' card name-click inline HTML
- discover.js: 'Your Artists' info-modal 'View All' button
- discover.js: artist-map context menu
- discover.js: genre-deep-dive artist click
- api-monitor.js: watchlist artist discography view
Phase 4a's goal of "one artist page for everything" is deferred —
it needs backend work on /api/artist-detail to accept a source param
and fall back to metadata-source lookup when the local DB lookup
fails. Keeping the signature extension on navigateToArtistDetail
(source parameter) in place for when that lands.
Phase 4c of the Search/Artists unification — docs-only cleanup.
The click-for-help system and the 'Your First Download' guided tour
referenced elements that no longer exist (the Basic/Enhanced toggle,
the embedded download-manager toggle, the active/finished queue
panels). Updated annotations + tour steps to match the current UI.
- New annotation for .search-source-picker-container (the dropdown)
- Removed 6 annotations for deleted elements
- 'first-download' tour now walks users through the source picker
and uses page: 'search' (PAGE_TOUR_MAP accepts both 'search' and
the legacy 'downloads' id so older bookmarks still match)
- Retired the 'artists-browse' standalone tour — no sidebar entry
- Dropped the dead #finished-queue detection in the setup milestone
check (the dashboard stat card is the single source of truth)
Phase 4b of the Search/Artists unification. Cin flagged that 'Artists'
in the sidebar read like a library section but was actually a
dedicated artist-search page, duplicating what unified Search already
does. Removed the sidebar entry so users funnel through Search.
- Sidebar Artists button gone
- 'Browse Artists' on empty Watchlist now opens Search
- 'View artist from Wishlist' opens Search pre-filled with the name
- Profile Home Page + Page Access drop the Artists option
artists.js stays on disk: it defines ~30 shared helpers used across
the app (escapeHtml, openDownloadMissingModalForArtistAlbum, service
status, download bubbles, image helpers) that library/discover/etc.
depend on. Wholesale deletion would orphan too much. The inline
Artists page and its selectArtistForDetail flow are still there —
just unreachable from the sidebar — so /artists deep links keep
working for bookmarks.
Phase 4a of the Search/Artists unification. The app had two artist-
detail implementations: the standalone page Library navigates to via
navigateToArtistDetail (its own route, deep-link support, highlights
Library in the sidebar), and an inline state inside the Artists page
reached via selectArtistForDetail. They rendered similar content but
were separate code paths and kept drifting apart (PR #356 just had
to fix source propagation in both).
Every external caller of selectArtistForDetail (9 sites across
api-monitor.js, discover.js, downloads.js, search.js) now calls
navigateToArtistDetail(id, name, source) directly. Removed ~63 lines
of the navigate-then-setTimeout-then-select dance. Source context
(Spotify/iTunes/Deezer/etc.) carries cleanly through via the new
third argument.
Artists sidebar entry, its inline search, and selectArtistForDetail
all still work — they just have no external callers. Phase 4b will
retire the sidebar entry and artists.js.
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 3b of the Search/Artists unification. The Search page's
internal id was 'downloads', which clashed with the actual Downloads
page (id 'active-downloads') and confused anyone reading the code.
Renamed to 'search' across HTML, navigation, DOM selectors, and the
deep-link route list.
Backwards compat: navigateToPage('downloads') aliases to 'search'
at the top of the function; /downloads URL still serves index.html
and the client router resolves the page correctly; profile ACL
checks accept both 'search' and 'downloads' so existing profiles
with 'downloads' in allowed_pages keep working without migration.
Sidebar label unchanged. Zero visual change — pure internal tidy.
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.
Phase 2 of the Search/Artists unification: the Search page dropdown
and the global spotlight widget both POST to /api/enhanced-search
with identical boilerplate. Extracted into enhancedSearchFetch() in
search.js (loaded before downloads.js). Both callers migrated. Zero
UX change — purely sets up Phase 3 to wire a source picker in one
place instead of two.
Phase 1 of the Search/Artists unification project: the endpoint now
accepts an optional `source` (spotify, itunes, deezer, discogs,
hydrabase, musicbrainz) so callers can target a single metadata source
instead of always fanning out. Omitted or `auto` preserves current
multi-source behavior — no existing callers break. Cache keys include
the source tag so per-source and fan-out results don't collide.
Adds a full Discover Sync tab to the Sync page with:
- Core UI scaffolding, playlist modal, empty-state handling
- ListenBrainz playlist integration with auto-update toggle persistence
- Sync progress tracking with matched/total counts on cards
- Navidrome playlist push on batch completion (V1 and V2 paths)
- Active download state display with polling resume on page reload
- Stuck-download detection for downloading and catch-all states
- Serialized sync queue to prevent concurrent backend contention
- Source badges, compact card layout, URL fixes