Commit graph

54 commits

Author SHA1 Message Date
BoulderBadgeDad
550fca0fe5 webui: sync organize-by-playlist toggles + stop dashboard poller 401-spam while locked
- The download-modal 'Organize by Playlist' toggle had no onchange, so flipping
  it never saved or synced the saved per-playlist preference. Add the handler
  (source auto-derived from the ref) so both controls read/write the one
  organize_by_playlist value — manual action persists, the other reflects it.
- loadDashboardSyncHistory polled /api/sync/history every 30s even while the
  launch-PIN/login gate was active, 401-spamming the log. Skip when locked, and
  on a 401 (stale session after a restart) surface the unlock screen so it
  self-heals instead of spamming.
2026-06-12 13:28:24 -07:00
BoulderBadgeDad
ce6ce4d8d6 Search: auto-select Spotify when "Spotify (no auth)" is the active source
On the Search page and the global search widget (both share createSearchController),
the source picker stayed empty when the active metadata source was Spotify-no-auth,
until you clicked Spotify manually.

Root cause: get_primary_source_status reports the no-auth composite as source
'spotify_free' (for display labelling). The controller's initActiveSource set
activeSource = 'spotify_free' (it's a valid SOURCE_LABELS entry), but the icon row
renders from SOURCE_ORDER, which only has 'spotify' — so no icon matched the active
source and nothing highlighted.

Fix: normalize 'spotify_free' -> 'spotify' when deriving the initial active source
(they're the same searchable source; the picker only has a Spotify icon). Now
no-auth auto-selects Spotify like plain Spotify does. One spot, fixes both surfaces.
2026-06-11 09:39:40 -07:00
BoulderBadgeDad
22947794e0 Profiles: label the no-auth Spotify composite everywhere it's shown
Follow-up to the modal fix: the sidebar Service Status + dashboard service card
also mislabeled "Spotify (no auth)" as plain "Spotify". They read the status
`source`, which came straight from metadata.fallback_source ('spotify') with no
awareness of the metadata.spotify_free flag.

- get_primary_source_status now reports a DISPLAY source of 'spotify_free' when
  fallback_source='spotify' + metadata.spotify_free is set (the raw 'spotify' is
  still used for the auth/connected checks), and treats the free path's
  availability as "connected" so the dot isn't falsely red on a no-auth setup.
- getMetadataSourceLabel maps 'spotify_free' → "Spotify (no auth)"; the status
  presentation treats spotify_free as part of the Spotify family (session /
  rate-limit / cooldown display still work). Added a SOURCE_LABELS entry.
- testDashboardConnection normalizes spotify_free → spotify (the only logic
  consumer of the source value — the dashboard Test button).

Routing is unchanged (the real source stays 'spotify' + free flag); this is
purely the display layer. Settings was always correct. 64 integrity tests pass;
the 2 failing soundcloud tests are pre-existing (confirmed identical on a clean
tree).
2026-06-10 10:41:09 -07:00
BoulderBadgeDad
2604704a27 #797: stop AcoustID quarantining correct non-English-artist downloads
AcoustID returns a recording's title/artist in their ORIGINAL script
(e.g. "久石譲" for Joe Hisaishi) while SoulSync's expected metadata is
romanized/English. A correct download then fails verification on two
walls: the title can never clear the 0.70 similarity bar cross-script,
and the only skip path that ignores the title required a near-perfect
0.95 fingerprint plus a resolved alias. Result: every non-English
artist trips it. Two complementary fixes, per the reporter's two ideas.

Graceful fix (automatic):
- New pure core/matching/script_compat.py detects when two strings are
  in genuinely different writing systems (CJK/Hangul/Cyrillic/Greek/
  Arabic/Hebrew/Thai vs Latin). Accented Latin (Beyoncé, Sigur Rós)
  stays Latin — no false trigger.
- acoustid_verification.py: when the EXPECTED artist and the matched
  artist span scripts AND the artist is confirmed via the existing
  MusicBrainz alias bridge, SKIP instead of quarantine, without the
  0.95 floor (the 0.80 trust floor already gates the fingerprint).
- Deliberately narrow: keyed on the ARTIST spanning scripts + being
  confirmed. A same-script artist with only a cross-script title keeps
  the stricter 0.95 floor, so the #607 wrong-file protection (Kendrick
  R.O.T.C, low-fingerprint Japanese-title) is untouched.

Per-request toggle (manual escape hatch):
- New "Skip AcoustID verification" checkbox in the download-missing
  modal beside "Force Download All".
- skip_acoustid threads request -> batch -> per-track track_info ->
  download context (same path as _playlist_folder_mode), landing on
  the existing _skip_quarantine_check='acoustid' bypass. No new
  mechanism; only the AcoustID gate is bypassed (integrity/bit-depth
  still run).

Tests:
- tests/matching/test_script_compat.py — script-boundary cases.
- test_acoustid_skip_logic.py — Joe Hisaishi SKIPs at 0.85; unconfirmed
  cross-script artist still FAILs; same-script low-fingerprint still
  FAILs.
- test_downloads_candidates.py — toggle injects the bypass; absent
  toggle keeps verification.

Full suite: 5169 passed; only pre-existing soundcloud /app env failures
remain. Zero regressions.
2026-06-05 16:02:01 -07:00
BoulderBadgeDad
2667eaec87 #798: Spotify Free UI — enable toggle + source availability
Surfaces the opt-in Spotify Free source so it's usable end-to-end:
- Settings: 'Enable Spotify Free (no credentials)' toggle that saves
  metadata.spotify_free (load + save wired). Clear best-effort/limitations note.
- config-status: adds spotify.metadata_available (configured OR free-available),
  keeping the configured flag = has-credentials so the Connections indicator
  stays honest. Search source picker shows Spotify when metadata_available.
- status payload: adds spotify.metadata_available; the Settings primary-source
  selector now allows picking Spotify when authed OR free-available.

Verified gate composition: OFF by default (no surprise scraping); ON + no auth +
installed -> available & serving; AUTHED -> official always wins (free never
runs); missing package -> gracefully unavailable. JS + integrity + 111 tests green.
2026-06-05 13:36:46 -07:00
BoulderBadgeDad
b105372d70 Fix #792: sync UI shadowed the new sync-mode setting (forced 'replace')
The reconcile setting never took effect: startPlaylistSync always sent
sync_mode (defaulting to 'replace' from the per-playlist <select>) AND clamped
any non-replace/append value back to 'replace' — so 'reconcile' could never be
sent and the global Settings value was always overridden. The per-server Plex
reconcile code was never even reached; replace ran and re-pushed the poster.

- Per-playlist select now defaults to 'Sync mode: default' (empty) which defers
  to Settings > Playlist sync mode, and gains a 'Reconcile' option for an
  explicit per-sync override.
- startPlaylistSync sends '' (not 'replace') when no explicit choice, so the
  backend uses the configured default; clamp now allows reconcile.
  (Other callers already sent no sync_mode, so they pick up the setting too.)
2026-06-04 15:27:00 -07:00
BoulderBadgeDad
c0c4528a28 PR #780 follow-ups: snapshot-based stale check + submit guard + dead code
- Stale-cache check (playlistTrackCacheIsStale) compared raw track_count to the
  filtered/cached track list, so any playlist with local or unavailable tracks
  always looked 'stale' and refetched + re-mirrored on every modal open. Now it
  compares the upstream snapshot_id (stored at cache time in the shared fetch
  choke point), and returns not-stale when no snapshot is available — explicit
  invalidation on refresh still handles real changes.
- organize_download: guard executor.submit so a refused job cleans up the batch
  instead of stranding it in 'analysis' (holding a limited analysis slot).
- Removed the dead, deprecated, unused mirrorSpotifyPlaylistTracks.
2026-06-03 20:45:17 -07:00
kekkokk
0b1fdba2a1 Fix standalone mirrored playlist sync and post-sync downloads.
SoulSync standalone matches library tracks without Plex fetchItem,
reports missing counts correctly, and skips server playlist writes.
Automation re-syncs when the mirror grows; after sync finishes, starts
organize download (organize-by-playlist) or wishlist processing.

UI: Spotify URL playlist-folder controls, organize toggle layout in the
discovery modal, reload organize preference when reopening Download Missing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 00:24:00 +02:00
Francesco Durighetto
9ff2e7084a Fix organize-by-playlist downloads: library entries, wishlist, and stale Spotify cache
Persist organize_by_playlist on mirrored playlists and run playlist-folder
downloads from the auto-sync pipeline instead of the global wishlist phase.
Register SoulSync library rows after playlist-folder post-processing, route
failed organize batches to the wishlist correctly, and skip sync-time
unmatched wishlist only when organize download handles retries.

Invalidate stale playlist track caches on refresh (Spotify and Deezer ARL),
re-mirror on refetch, and improve standalone playlist modals (re-analysis,
Open in Mirrored). Add filesystem missing-track detection and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 10:26:32 +02:00
Antti Kettunen
824b118759
refactor(webui): finish stats standalone handling in react
- render the standalone notice directly in the React stats header
- keep the legacy standalone sweep from hiding the stats control incorrectly
- update the stats route test and header layout to match the new behavior
2026-05-23 23:01:56 +03:00
Broque Thomas
de8e079a6d feat(media-player): playable tracks across modals + lyrics + cleanups
Three related improvements to the now-playing media player and the
"add to wishlist" / "download missing" modals.

1. Play buttons across track-list modals
   Every track row in the download-missing modals (Spotify, Tidal,
   YouTube, services, artist album, wishlist download-missing) and
   the add-to-wishlist modal now carries a play button. Click runs
   playTrackFromLibraryOrStream:
     - If the track has a local file_path → playLibraryTrack
     - Else POST /api/stats/resolve-track to find it in the library
       by title + artist → playLibraryTrack
     - Else fall back to _gsPlayTrack streaming
   Backend ownership response gains track_id / title / file_path so
   the wishlist modal's owned tracks can hand the right metadata
   to the player without an extra round trip.
   The add-to-wishlist modal previously showed the play button only
   on owned tracks; now the button is unconditional so the streaming
   fallback can take over for unowned ones (matches the standard
   pattern from the rest of the app).

2. Clean media-player display titles
   YouTube / Tidal / Qobuz / torrent / usenet plugins encode their
   source-side identifier into the filename field as
   <source_id>||<display> so download() can recover it later. The
   media player's track-title renderer never knew about this
   convention and showed strings like
   "wvgFsXoGFnQ||Sometimes I Cry When I'm Alone" verbatim in the
   now-playing UI. extractTrackTitle and setTrackInfo now strip the
   <id>|| prefix defensively so any path into the player gets a
   clean display.
   Local library playback also fetches canonical metadata from
   /api/stats/resolve-track when track.id is present so title /
   artist / album / album art come straight from the SoulSync DB
   instead of whatever the caller passed in. Falls back silently
   to caller values on any error so playback never blocks on the
   metadata fetch.

3. Lyrics panel + View Artist close
   New collapsed lyrics panel between the playback controls and
   queue panel. POST /api/lyrics/fetch (new backend endpoint)
   prefers the local .lrc / .txt sidecar files SoulSync writes
   during post-processing so downloaded tracks resolve lyrics with
   zero network hits; falls back to LRClib exact-match (when album
   + duration are available) then to LRClib search.
   Synced LRC results are parsed (handles multi-stamp lines for
   repeated choruses), and the active line highlights + smooth-
   scrolls into the middle of the viewport on every audio
   timeupdate. Plain-text results render without highlighting.
   Per-track cache prevents re-fetching when the user revisits the
   same track. Lyrics fetch is fire-and-forget — failure shows
   "No lyrics found" without ever blocking playback.
   View Artist on the expanded player now calls
   closeNowPlayingModal before navigating; the modal was previously
   sitting open over the artist page, hiding it. Handler is bound
   once and is a no-op when no artist_id is attached.

CSS additions are additive (new .modal-track-play-btn and
.np-lyrics-* rules); no existing styles touched. Backend endpoint
returns 200-with-success-false on any miss so callers can render
"no lyrics" without treating it as an error.

WHATS_NEW updated under 2.5.9 with two entries (lyrics + View
Artist close).
2026-05-22 21:19:50 -07:00
Broque Thomas
6c9b43225a Add torrent and usenet release staging support
Adds torrent/usenet as release-oriented download sources with album-bundle staging, live progress reporting, and post-processing that selects the requested audio file from completed releases instead of blindly importing the first file.

Keeps album-bundle behavior gated to single-source torrent/usenet album downloads, excludes release sources from hybrid album per-track searches, and allows hybrid non-album tracks to use release results safely.

Improves staged-release matching for featured/bonus track filenames while preserving version mismatches, records torrent/usenet provenance in library history, and updates service/status UI labels.

Covers the flow with focused lifecycle, status, staging, validation, task worker, post-processing, and import side-effect tests.
2026-05-21 14:22:21 -07:00
Broque Thomas
036faff8b1 feat(fix-popup): paste MusicBrainz URL/MBID to match directly
Power-user escape hatch on the Discovery Fix Track Match modal — when
fuzzy auto-search ranks the wrong recording among many same-title
versions (10 remasters, live cuts, alt sessions), paste the MusicBrainz
recording URL or bare UUID into the new field and resolve straight to
that record.

Layout:

- Shape adapter `get_recording_flat(mbid)` lives in
  `core/musicbrainz_search.py` next to existing `get_track_details`.
  Returns the flat Fix-popup track shape (artists as `string[]`,
  album as string, single `image_url`) — distinct from the
  Spotify-shaped nested dict `get_track_details` returns.

- New route `GET /api/musicbrainz/recording/<mbid>` is a thin wrapper:
  validates MBID format with an anchored UUID regex, calls the adapter,
  returns 400 / 404 / 200 with no inline shape massaging.

- Frontend `parseMusicBrainzMbid()` lives in `shared-helpers.js` —
  pure URL/UUID parser, reusable from other surfaces (failed-MB cache,
  manual match) without duplication.

- Fix modal HTML gets one new input row + button; existing search row
  and result render pipeline are untouched. New `lookupDiscoveryFixByMbid()`
  fetches the endpoint and feeds the single result through the existing
  `renderDiscoveryFixResults` -> confirm-dialog -> match pipeline, so MB-
  paste matches go through the exact same selection flow as auto-search
  results.

- Enter-key bound on the MBID input via a separate handler ref so its
  lifecycle matches the search-input handlers without conflating the
  two submit targets.

7 unit tests cover the adapter: happy path, empty/None MBID, MB returns
None, recording-without-release (empty album), multi-artist credits,
includes-list contract, and client-error swallow.

Out of scope: the Fix popup's fuzzy cascade is still hardcoded to
spotify/deezer/itunes regardless of which primary source the user has
configured. Adding MB to that cascade (when MB is the active primary)
is a separate concern.
2026-05-19 17:03:19 -07:00
Antti Kettunen
54efb85240
fix(webui): guard similar artist bubbles
- avoid calling buildArtistDetailPath when a similar artist has no usable id
- render a disabled bubble instead so empty MusicBrainz IDs do not crash the panel
2026-05-19 21:21:02 +03:00
Antti Kettunen
0d683d87c0
refactor(webui): link artist detail navigation
- replace click-driven artist-detail hops with semantic links
- keep SPA transitions via shell bridge interception for /artist-detail/:source/:id
- drop legacy page helper wrappers and dead bridge plumbing
2026-05-19 10:22:59 +03:00
Antti Kettunen
30c687ae7b
refactor(webui): cancel similar-artists in route
- expose a shell-bridge cancel primitive for similar-artists loading
- stop stale similar-artists streams from the artist-detail route lifecycle
- keep the legacy loader abort-only and make abort logs page-agnostic
- update bridge and route tests for the new cleanup path
2026-05-19 09:28:05 +03:00
Antti Kettunen
5e39f1ee09
refactor(webui): centralize artist-detail handoff
- add a canonical TanStack route for artist-detail and keep the legacy page as the renderer target
- expose page-level artist-detail navigation on the shell bridge for legacy callers
- remove artist-detail-specific routing, origin stack, and back-label logic from the shared shell helpers
2026-05-19 09:26:10 +03:00
Broque Thomas
f3ad65de34 Complete MusicBrainz watchlist source parity
Add MusicBrainz watchlist artist ID storage, badges, linked-provider editing, and per-artist preferred source support.

Backfill watchlist MusicBrainz matches from already-enriched library artists so existing MusicBrainz worker matches appear in watchlist cards and settings.

Extend bulk watchlist add, liked artist matching, artist map source picking, and service status labels to recognize MusicBrainz, with regression tests for watchlist ID persistence and backfill.
2026-05-18 19:19:25 -07:00
Broque Thomas
cd715f8697 Preserve source when opening artist detail 2026-05-17 23:40:39 -07:00
Broque Thomas
d39679951b Wire Amazon Music into enhanced search and global search source picker
- Add 'amazon' to VALID_SOURCES (and transitively VALID_STREAM_SOURCES)
  in core/search/orchestrator.py so the backend accepts it as a
  requested source without returning 400
- Add resolve_client('amazon') case — mirrors musicbrainz pattern,
  gets the cached AmazonClient from the metadata registry
- Add 'amazon' to _alternate_sources() so it appears as a tab when
  another source is primary (always available, no credentials)
- Add SERVICE_CONFIG_REGISTRY entry 'amazon': {'always': True} so
  /api/settings/config-status reports it as configured
- Add SOURCE_LABELS['amazon'] and SOURCE_ORDER entry in
  shared-helpers.js so both enhanced search and global search show
  the Amazon Music tab
- Add 'amazon' to _ALWAYS_CONFIGURED_SOURCES so the picker never
  dims the tab (no credentials required)
- Add .enh-tab-amazon.active CSS (Amazon orange #FF9900)
- 3530 tests pass
2026-05-16 14:18:18 -07:00
Broque Thomas
e407504e03 Fix search source picker defaulting to Spotify regardless of config
Enhanced search + global search popover always opened with the
Spotify icon active even when the user's primary metadata source
was Deezer / iTunes / Discogs / etc.

Trace: shared-helpers.js createSearchController reads
/status.metadata_source to pick the initial active icon, then
gates with SOURCE_LABELS[src]. Backend returns metadata_source
as a dict ({source, connected, response_time, ...}) — used
elsewhere for connection-state display — so SOURCE_LABELS[<dict>]
was always undefined, the guard never fired, and activeSource
silently stayed at the hardcoded 'spotify' default.

Fix reads .source off the dict (with fallback to plain-string for
forward compat). Other consumers already used ?.source — this was
the only stale call site.
2026-05-13 15:36:48 -07:00
Broque Thomas
caa1c198e5 Fix non-admin profiles defaulting to Spotify on search picker
Closes #515 (jaruca).

Search-picker controller in shared-helpers.js resolved the user's
configured primary metadata source by fetching `/api/settings`. That
endpoint is `@admin_only` (it returns full config including
credentials), so non-admin profiles got a 403 and the controller
silently fell back to the hardcoded `'spotify'` default — admin's
chosen source (deezer / itunes / discogs / etc) was ignored on every
non-admin profile, forcing manual reselection each session.

Switched to `/status`, which is public and already exposes the
resolved `metadata_source` for the dashboard. Same value the picker
needs — different endpoint that doesn't gate non-admins.

Admins see no behavior change. Non-admins now see admin's configured
primary source as the default active icon.

Refs #515
2026-05-07 11:53:02 -07:00
Broque Thomas
8de4a186b7 Fix three SoundCloud integration gaps surfaced by smoke testing
User report: switched download source to SoundCloud and noticed:
1. Download progress % stays at 0 until "suddenly done" — no live progress
2. Sidebar status indicator next to "SoundCloud" label is red
3. Dashboard service status card still shows "Soulseek" as the source name

Fix 1 — Live progress for HLS-segmented SoundCloud downloads
(`core/soundcloud_client.py`):
- yt-dlp's `total_bytes` / `total_bytes_estimate` for HLS describes the
  CURRENT FRAGMENT, not the whole download. So the byte-based
  percentage stayed near 0 the entire time — until 'finished' fired.
- Added `_update_download_progress_fragmented` which uses
  `fragment_index` / `fragment_count` (which yt-dlp DOES populate
  accurately for HLS) to compute a meaningful percentage. Total size
  is extrapolated from per-fragment average for the bytes/remaining
  display. Time-remaining estimate uses elapsed/index seconds-per-
  fragment.
- The progress hook prefers fragment progress when both fragment_index
  and fragment_count are present; falls back to byte-based for
  non-fragmented (progressive MP3) downloads. Five new unit tests pin
  the fragment-progress math, the 99.9% cap, and the defensive
  zero-index / unknown-id paths.

Fix 2 — Sidebar status indicator stays green for SoundCloud mode
(`web_server.py`):
- The `/api/status` route's `serverless_sources` tuple decides whether
  to even probe slskd. SoundCloud (and Lidarr) were missing — so when
  the active source was SoundCloud, the route fell through to "test
  slskd, mark not-relevant", which set `connected: False` and turned
  the sidebar dot red even though SoundCloud was working.
- Added `'soundcloud'` and `'lidarr'` to the tuple. Both are
  serverless from slskd's perspective, so the dot now stays green
  whenever they're the active source.

Fix 3 — Dashboard service card title shows the active source
(`webui/static/shared-helpers.js`):
- The dashboard's "Download Source" card has its own
  `sourceNames` map at line 3351 (separate from the sidebar map I
  already updated at 3396). Missed it during the integration PR.
- Added `'lidarr'` and `'soundcloud'` so the card title now reads
  "SoundCloud" / "Lidarr" instead of falling back to "Soulseek".

Bonus — Dashboard "Test Connection" button works for SoundCloud
(`core/connection_test.py`):
- The dashboard's Test Connection button on the download-source card
  sends `service` based on the active source — so for SoundCloud it
  was sending `service='soundcloud'`. `run_service_test` had no
  branch for it, so it fell through to "Unknown service." and the
  button always failed.
- Added a `soundcloud` branch that mirrors `/api/soundcloud/status`
  behavior: confirms yt-dlp is installed, runs a real cheap probe,
  returns a meaningful pass/fail. (HiFi has the same gap but no
  user reported it; out of scope for this fix.)

Verified:
- 41 unit tests pass (5 new fragment-progress tests added)
- Full suite 1732 passed
- Ruff clean
2026-05-03 13:09:02 -07:00
Broque Thomas
75fe04907f Wire SoundCloud as a first-class download source
Plug the previously-built SoundcloudClient (PR #478, the build-and-verify
phase) into every place a download source needs to appear. Follows the
same wiring contract as Tidal/Qobuz/HiFi/Deezer/Lidarr — orchestrator
routing, hybrid-mode picker, search dispatch, queue/cancel/clear,
provenance + library history, sidebar source label, settings UI all
work plug-and-play.

Backend wiring:
- `core/download_orchestrator.py` — import SoundcloudClient, _safe_init
  it at startup, add to _client() lookup, get_source_status(),
  check_connection's sources_to_check default, search source_names map,
  search_and_download_best _streaming_sources tuple, download
  source_map + source_names, and every iteration loop in
  reload_settings download-path-update / get_all_downloads /
  get_download_status / cancel_download (route + iterate) /
  clear_all_completed_downloads / cancel_all_downloads.
- `core/downloads/monitor.py` — added SoundCloud to the per-client
  loop that fetches active downloads outside the orchestrator (uses
  getattr fallback for older soulseek_client snapshots).
- `core/downloads/task_worker.py` — added SoundCloud (and Lidarr,
  which was missing too — bonus fix) to source_clients dict for hybrid
  fallback dispatch.
- `core/downloads/validation.py` — added 'soundcloud' to
  _streaming_sources so SoundCloud results go through the matching
  engine validation path instead of the Soulseek quality-filter path.
- `core/imports/side_effects.py` — three call sites: source_map for
  download_source label written to library_history, streaming-source
  guard for the `||`-encoded stream_id parsing, and source_service
  map for provenance recording. All three now include 'soundcloud'.
- `web_server.py` — five streaming-source detection tuples updated.
  New `/api/soundcloud/status` endpoint returns
  {available, configured, reachable} mirroring the Deezer/HiFi
  status-endpoint pattern; reachability runs a real cheap yt-dlp
  search so the settings Test Connection button gives a meaningful
  pass/fail signal.
- `config/settings.py` — added empty `soundcloud_download` defaults
  block so future tier-2 OAuth (SoundCloud Go+ session) doesn't have
  to migrate existing configs.

Frontend:
- `webui/index.html` — new `<option value="soundcloud">` in the
  download-source-mode dropdown, SoundCloud added to both hidden
  legacy hybrid-source selects, new settings container with info
  text + Test Connection button.
- `webui/static/settings.js` — HYBRID_SOURCES entry (with the
  SoundCloud cloud SVG icon), _hybridSourceEnabled default,
  updateDownloadSourceUI container display, allSources for legacy
  hybrid picker, testSoundcloudConnection function (hits the new
  status endpoint, color-codes the result), saveSettings
  soundcloud_download empty block.
- `webui/static/shared-helpers.js` — sidebar source-name map
  includes SoundCloud + Lidarr (Lidarr was also missing, bonus fix).
- `webui/static/helper.js` — WHATS_NEW entry under '2.4.2' dev cycle
  describing the user-visible change in the chill terse voice.

Tests:
- `tests/test_download_orchestrator_soundcloud.py` — 14 integration
  tests verifying the wiring: client constructed at startup, _client
  lookup resolves 'soundcloud', get_source_status includes it,
  download dispatcher routes username='soundcloud' to the SoundCloud
  client (and unknown usernames still fall back to Soulseek), hybrid
  search iterates SoundCloud when in order and skips it cleanly when
  unconfigured, get_all_downloads / get_download_status / cancel /
  clear walk SoundCloud, soundcloud-only mode dispatches only to
  SoundCloud, _streaming_sources tuple in validation includes
  'soundcloud'.
- `tests/downloads/test_download_orchestrator.py` — added
  `soundcloud` to the test fixture's _build_orchestrator helper so
  the new orchestrator attribute doesn't AttributeError in pre-
  existing tests that bypass __init__.

Verified:
- Full suite green (1728 passed, 2 deselected for soundcloud_live)
- Ruff clean
- Live SoundCloud-only mode search returns 25 SoundCloud tracks for
  "kendrick lamar luther" in <2s, returning properly-shaped
  TrackResult objects with username='soundcloud' and dispatch-key
  filename ready for the download path.

Out of scope (intentional deferrals):
- SoundCloud Go+ OAuth tier (256 kbps AAC) — anonymous-only for now.
  Adding auth later is a settings-page extension, no orchestrator
  changes needed.
- Album/playlist support — SoundCloud has playlists but they don't
  map to the album model the rest of SoulSync expects. Singles only.
2026-05-03 12:54:21 -07:00
Antti Kettunen
2693640c62
Hide dashboard status placeholders until ready
- Keep the sidebar and dashboard service cards neutral until the first /status payload arrives
- Prevent placeholder source names and card text from flashing on dashboard load
- Reveal the real service status only after the live snapshot populates the UI
2026-05-02 22:02:01 +03:00
Antti Kettunen
a2176af00e
Rename metadata source status selectors
- Switch the dashboard/sidebar service-status card from spotify-branded ids to metadata-source ids
- Update the shared status helpers to target the renamed metadata-source card
- Keep the actual Spotify auth and settings UI unchanged
2026-05-02 22:02:01 +03:00
Antti Kettunen
e2bd0e1871
Split metadata source and Spotify status
- Keep the primary metadata provider snapshot generic and move Spotify auth/rate-limit details into a separate status object.
- Update the websocket fixture and dashboard/settings consumers to read the two buckets independently.
2026-05-02 22:02:00 +03:00
Antti Kettunen
36267618a3
Rename status cache to metadata_source
Expose the primary metadata provider status under a generic cache key and update the websocket fixture plus frontend readers to match.
2026-05-02 22:02:00 +03:00
Antti Kettunen
1d9d399a2f
Fix dashboard metadata source testing
- Point the dashboard Test Connection button at the active metadata source instead of hardcoded Spotify.
- Populate the response line from the current status payload so the card no longer stays at Response: --.
- Keep the existing Spotify-specific auth handling when Spotify is the configured source.
2026-05-02 22:02:00 +03:00
Antti Kettunen
4e40bce3e9
Gate Discogs primary source by token
- Show Discogs with a lock icon until a personal access token is present.
- Prevent selecting locked Discogs and steer users to the Discogs settings section.
- Keep metadata-source availability and selection state synced as the token changes.
2026-05-01 12:59:38 +03:00
Antti Kettunen
5ff20fbfec
Polish Spotify source selection
- Show Spotify with a lock icon when it is not currently selectable.
- Keep the explanation in the hover title instead of cluttering the dropdown label.
- Redirect users to the Spotify settings section when they try to pick a locked source.
2026-05-01 12:51:51 +03:00
Antti Kettunen
287c9601fc
Mark Spotify settings as needing auth
- Drive the Spotify settings accordion from live auth state instead of treating it as configured/healthy when the session is missing.
- Reuse the existing yellow missing-state styling so unauthenticated Spotify is visually distinct from active Spotify.
- Keep the shared status refresh path updating the settings view immediately after auth changes.
2026-05-01 12:41:22 +03:00
Antti Kettunen
e615e407e6
Handle Spotify auth completion failures
- Return a distinct post-auth warning page when Spotify OAuth completes but the client still does not report an authenticated session.
- Send the completion signal back to the opener so the settings UI can refresh and show the warning state immediately.
- Keep the standalone callback server and the main Flask callback path aligned on the same result-page helper.
2026-05-01 12:29:06 +03:00
Antti Kettunen
f733744f91
Fix Spotify auth completion sync
- Make the Spotify auth completion popup notify the opener across callback origins.
- Refresh service status in the settings UI after auth completes so the button flips to Disconnect immediately.
- Keep the standalone callback instruction page and the main app flow working with the same completion signal.
2026-05-01 12:14:13 +03:00
Antti Kettunen
74e3cc460c
Simplify service status and labels
- Flatten the Spotify service-status rendering so it shows rate-limit and recovery states explicitly, while otherwise displaying the active metadata provider directly.
- Keep the Spotify auth controls and metadata-source picker aligned with the real session state after authenticate and disconnect flows.
- Return "Unmapped" for unknown metadata source labels instead of implying iTunes.
- Update the metadata registry tests to cover the new label fallback.
2026-05-01 12:06:58 +03:00
Antti Kettunen
55603be14c
Clarify Spotify auth flow and sync UI
- Send Spotify auth completion back to the opener so the settings page refreshes immediately
- Make the local auth flow go straight through to Spotify instead of showing the temporary instruction page
- Keep the remote/docker instruction page available for manual callback setups
- Sync Spotify status, connect/disconnect buttons, and metadata source selection after auth and disconnect
- Keep the disconnect behavior aligned with the active primary metadata source
2026-05-01 11:25:12 +03:00
Antti Kettunen
9646f6ca7f
Clarify Spotify auth actions
- Hide the auth button when a Spotify session is active
- Treat disconnect as a session change, not a provider swap
- Share metadata source labels in the registry
- Tighten rate-limit copy around Spotify-specific behavior
2026-05-01 10:36:50 +03:00
Broque Thomas
e309370862 Source picker: rename Soulseek icon to "Basic Search"
That source icon hits /api/search — raw slskd file results, the same
flow the UI historically labelled "Basic Search" before the source-icon
row replaced the dropdown. Reverting the label avoids implying it
returns Soulseek-flavoured metadata results in the same shape as the
other source icons.

Backend route + endpoint name unchanged; this is display-only.
2026-04-27 16:03:56 -07:00
Broque Thomas
b3722449fc MusicBrainz: Fix artist images, total_tracks off-by-one, and Artist+Title queries
Three bugs from kettui's follow-up review pass on the MusicBrainz
search PR, all fixed in one commit because they share UI context.

1. Missing artist images on MB artist results

MusicBrainz doesn't store artist images directly. My earlier commit
returned `image_url=None` on every artist result and trusted the
frontend's lazy-loader — but the lazy-loader's `/api/artist/<id>/image?
source=musicbrainz` endpoint had no handler for MusicBrainz, so it
silently returned None and the emoji placeholder stayed.

Fix plumbs the artist name through:
- `renderCompactSection` stashes `data-artist-name` on artist cards.
- `search.js` and `downloads.js` lazy-loaders pass `name=<artist>` as a
  query param.
- `/api/artist/<id>/image` accepts an optional `name` param.
- `metadata_service.get_artist_image_url` has a new `musicbrainz`
  branch: since MB has no artist art, it searches fallback sources
  (iTunes/Deezer by configured priority) for the artist name and
  returns the first image found.

Verified live — Metallica/Kendrick Lamar/Daft Punk all resolve to
Deezer artist images via the name lookup.

2. total_tracks off-by-one on tracks with a release

`_recording_to_track` initialized `total_tracks = 1` and then summed
media track-counts on top. For an 11-track album, it reported 12. An
adapter-level regression introduced when the recording-projection
helper was extracted during the main MB refactor.

Fix: initialize at 0, sum normally. Standalone recordings with no
release (can happen for uncredited remixes etc.) still report 1 via
an explicit fallback — so the existing "single track" case isn't
broken.

3. "Artist Album Title" queries buried specific albums in the
   discography list

Bare-name queries like "The Beatles Abbey Road" used to resolve "The
Beatles" as the artist and then browse their full discography — Abbey
Road was buried alphabetically among 200+ releases instead of being
the top result.

Fix adds a title-hint extractor. When the query starts with the
resolved artist name followed by more words, the trailing portion is
treated as a title hint. Browse results are filtered to those whose
release-group title contains the hint. If the filter matches nothing,
falls back to text-search with the hint as the title (the "keep the
old split-by-whitespace fallback" path kettui called for). If text-
search also misses, shows the full discography rather than nothing.

10 new tests in tests/test_musicbrainz_search.py (46 total):
- Title-hint extractor: basic match, case-insensitive, whitespace
  tolerance, bare-artist-no-hint, artist-not-prefix-no-hint, word-
  boundary required (no false splits on "Metallicasomething").
- Browse filtering by title hint.
- Text-search fallback when the title hint matches nothing in browse.
- Bare-artist queries return the full discography unfiltered.
- total_tracks for single-release, multi-disc, and no-release cases.
2026-04-24 10:17:59 -07:00
Broque Thomas
a6359a2690 Add <img onerror> fallbacks for search result images
Self-audit catch: my earlier cover-art commit claimed 'the frontend's
<img onerror> fallback handles 404s' — that was wrong. The enhanced
search result images in shared-helpers.js renderCompactSection and all
five gsearch-item/track templates in downloads.js render bare
`<img src="...">` with no fallback. With the MusicBrainz adapter now
emitting Cover Art Archive URLs deterministically (no HEAD probe),
albums that don't have cover art would show the browser's broken-image
icon instead of the emoji placeholder.

Two fallback shapes:

- shared-helpers.js renderCompactSection: the `<img>` sits inside a
  card with a sibling placeholder pattern. On error, replace the img's
  outerHTML with the placeholder div, matching the shape used when
  config.image is missing entirely.

- downloads.js gsearch items: the `<img>` sits inside a
  `.gsearch-item-art` div whose default text content is the emoji fallback
  (🎤 / 💿 / 🎶 / 🎵). On error, set parentElement.textContent to the
  emoji, which wipes the img and shows the glyph. Same shape as the
  "no image_url" branch.

Applies to every card type that renders a user-provided image URL so
the fix covers all sources that might return 404s — MB is the most
common offender but iTunes/Deezer/Discogs can all miss too.

Tested against the live MB API: Metallica albums without CAA cover art
now show the 💿 emoji instead of a broken-image icon.
2026-04-24 08:41:07 -07:00
Broque Thomas
527b51d69b Tighten Soulseek handoff + per-source request tokens after self-audit
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.
2026-04-23 22:37:07 -07:00
Broque Thomas
325292ce5a Treat Soulseek as configurable in source picker (require slskd_url)
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).
2026-04-23 22:28:47 -07:00
Broque Thomas
005c6ad73a Fix Soulseek handoff routing + stale-request flash on fast retype
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.
2026-04-23 22:24:46 -07:00
Broque Thomas
9f63280677 Extract source-picker into shared createSearchController factory
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.
2026-04-23 17:28:16 -07:00
Broque Thomas
c605904a5c Source picker: dim unconfigured sources, redirect to Settings on click
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.
2026-04-23 17:28:16 -07:00
Broque Thomas
86e6d8df49 Fix source-picker review items: real logos, cached click close, Soulseek clip
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.
2026-04-23 17:28:15 -07:00
Broque Thomas
a72810ce22 Search page: replace fan-out with source-picker icon row + per-source cache
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.
2026-04-23 17:28:15 -07:00
Broque Thomas
f85564a2de Move enhancedSearchFetch, SOURCE_LABELS, renderCompactSection to shared-helpers
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.
2026-04-23 17:28:15 -07:00
Broque Thomas
93f1941829 Unify artist detail: route source artists to standalone page, retire inline Artists page
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.
2026-04-22 17:00:52 -07:00
Broque Thomas
5219780c01 Page-aware click on similar-artist bubbles
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.
2026-04-22 16:43:36 -07:00