Commit graph

1148 commits

Author SHA1 Message Date
Broque Thomas
a9f827ef42 Reject Tidal streams that silently downgrade from the requested quality
Reported on Discord by Netti93: with Tidal configured for "HiRes only"
and "Allow Quality Fallback" disabled, tracks were still downloading
successfully — as m4a 320kbps files. Some "successful" downloads were
less than half the file size of the same track pulled via Tidarr/tiddl
from the same Tidal account.

Root cause: Tidal's API silently degrades to the best quality your
account + the track + your region permits. Setting
`session.audio_quality = Quality.hi_res_lossless` and calling
`track.get_stream()` on a track that's only available in AAC returns
an AAC stream with no error. The downloader wrote the m4a file to
disk, the ~7MB size sailed past the 100KB stub threshold, and the
download reported success.

The pre-existing "verify quality wasn't silently downgraded" block
only LOGGED a warning when this happened; it did not fail the tier.
Two knock-on effects:

- Users with "HiRes only, no fallback" got m4a files anyway, which
  defeats the setting entirely.
- The worker-level fallback chain (hires → lossless → high → low)
  couldn't advance past the first tier, because every tier
  "succeeded" at whatever Tidal happened to serve.

Fix: after `track.get_stream()`, compare `stream.audio_quality`
against the tier we asked for using a rank-based ordering:

    LOW < HIGH < LOSSLESS < HI_RES < HI_RES_LOSSLESS

- Same tier or higher → accept (so the occasional Tidal upgrade
  doesn't get rejected just because it's not an exact match).
- Lower tier → reject THIS tier. The loop `continue`s and the next
  fallback tier is tried, or the whole download fails honestly
  when the user has fallback disabled. The existing final-error
  log already has a hint directing users to enable fallback if
  they want automatic Lossless substitution.
- Unrecognized `audioQuality` value (e.g. a new Tidal tier we
  haven't mapped) → reject conservatively, so the next fallback
  tier gets a chance and the diagnostic log names the unknown
  value.

Why the rank-based approach instead of strict equality:

Tidal's API doesn't technically promise an exact-tier match on
serving; on tracks that are flagged in its catalog as a higher
tier, it can serve higher than the session setting. Rejecting
higher-than-asked quality would be user-hostile. And the `HI_RES`
(legacy MQA) value — not in tidalapi's modern `Quality` enum but
possibly still present on old catalog entries — needs to rank
below `HI_RES_LOSSLESS`: users asking for true lossless HiRes
should reject MQA since MQA is a lossy format.

tidalapi's `Quality` enum is a `str` subclass whose VALUES (not
member names) match what the Tidal API returns in the
`audioQuality` field (e.g. `Quality.hi_res_lossless.value ==
'HI_RES_LOSSLESS'`, `Quality.low_320k.value == 'HIGH'`). Both
sides of the comparison are coerced to `str` before use, so the
check is robust to whichever tidalapi version exposes the served
quality as an enum or a plain string.

The check is extracted as `_verify_stream_tier(stream, q_info,
q_key) -> (ok, reason)` at module scope — a pure function with no
I/O, unit-tested independently. Ten tests: match, three upgrade
cases (LOSSLESS → HI_RES_LOSSLESS, LOSSLESS → HI_RES, LOW → any
higher), three downgrade cases (the reported HiRes → AAC, HiRes
Lossless → MQA HiRes, Lossless → AAC), one unrecognized-tier case,
and two defensive paths for older tidalapi builds without
`audio_quality` on the stream object and for QUALITY_MAP entries
that lack `tidal_quality` (e.g. tidalapi wasn't importable at
module load). Test stub updated to use uppercase `Quality` values
matching real tidalapi so case-sensitivity regressions get caught.

Also removed the old codec-string-based warning block — the new
tier check is strictly stronger, and keeping the warning around
would just be dead code waiting to drift out of sync.

Deliberately NOT tackling in this PR (documented as follow-ups):

- Bit-depth verification of HiRes FLAC files via mutagen. The
  `stream.audio_quality` tier check catches the main "HiRes
  requested, got AAC" case; bit-depth would only matter if Tidal
  labeled a stream HI_RES_LOSSLESS but served a 16-bit FLAC
  (`Stream.bit_depth` isn't reliable for this — tidalapi defaults
  missing `bitDepth` fields to 16, so a trust-the-stream check
  would spuriously reject valid HiRes whenever Tidal omits the
  field). A proper fix runs mutagen post-download to inspect the
  actual file, then decides whether to delete + retry the next
  tier — a whole new failure mode with design trade-offs that
  deserve their own PR. The support logs don't show this
  happening.

- The "manual remap still says Not Found" symptom. Might be
  downstream of this same bug (silent-AAC "success" hitting a
  later rejection), might be a separate task-state issue. Not
  guessing without logs from the retry path.

- Quality-aware stub threshold. 100KB is a reasonable floor for
  real stub/preview detection and there's no evidence the
  universal threshold is misfiring in the wild.

Field-verified status: desk-verified via unit tests and empirical
checks against a live tidalapi import (confirming the `Quality`
enum's str-subclass behavior). Not yet smoke-tested end-to-end
against a real Tidal account with a HiRes-only-no-fallback
setting — Netti93 or anyone else with that config should notice
either the fix working (non-HiRes tracks fail honestly with a
clear log line) or any regression before wider release.

Files:
- core/tidal_download_client.py — new `_verify_stream_tier` helper
  and `_QUALITY_RANK` table at module scope, called in the
  download loop after the stream is fetched and before any
  bandwidth is spent. Removed the old inline codec-based warning
  since the new check supersedes it.
- tests/test_tidal_stream_tier_verification.py — ten tests covering
  match / upgrade / downgrade / unknown / defensive paths.
- tests/test_tidal_search_shortening.py — fake `Quality` values
  brought in line with tidalapi's real values so both files share
  a consistent stub regardless of pytest collection order.
- webui/static/helper.js — WHATS_NEW entry under 2.40 describing
  the rank-based tier comparison.

Reported on Discord by Netti93 — the "same account works via
Tidarr" comparison narrowed the cause to SoulSync's download path
rather than an account/region issue.
2026-04-24 13:12:30 -07:00
Broque Thomas
a60546929e Fix Album Completeness job reporting zero findings for every album
Reported by sassmastawillis: the Album Completeness maintenance job
scans 3127 albums in 0.1 seconds and reports 0 findings — for every
user, regardless of whether their library is actually complete.
Restoring an older DB surfaced 7 correct findings, so the code logic
works; the DB state is what's making everything look complete.

Root cause: `albums.track_count` is only ever written by server-sync
paths — Plex's `leafCount`/`childCount` and SoulSync standalone's
`len(tracks)`. It's the OBSERVED count of tracks SoulSync has indexed,
which is always exactly what `COUNT(tracks)` returns for that album.
The completeness job treated it as the EXPECTED total and compared it
against the observed count. They're equal by construction, so
`actual >= expected` is always true: skip, 0.1s scan, 0 findings.

Fix: new `api_track_count INTEGER` column on `albums`, written only by
metadata-source code paths. Populated in two places so the scan is
fast and the fallback is robust.

1. Enrichment workers — shared helper `set_album_api_track_count`
   in `core/worker_utils.py`. Called by each worker's existing
   `_update_album` method alongside its other album-column UPDATEs:

   - spotify_worker: `album_obj.total_tracks` from the Spotify Album
     dataclass (already in hand, zero new API calls)
   - itunes_worker: same, from the iTunes Album dataclass
   - deezer_worker: `nb_tracks` from full_data, falling back to
     search_data when the full lookup didn't run
   - discogs_worker: count of tracklist rows where `type_=='track'`
     (Discogs tracklists interleave heading and index rows that
     shouldn't count as songs)

   Helper skips the write on zero/None/negative/non-numeric inputs
   so a source lacking track info can't clobber a good value a
   different source already wrote. Caller owns the transaction —
   helper just queues an UPDATE on the caller's cursor without
   committing, so it batches cleanly with each worker's existing
   multi-UPDATE pattern.

   Hydrabase worker deliberately not touched — it's a P2P mirror
   that doesn't write album metadata to the local DB. Hydrabase-
   primary users hit the fallback path below.

2. Album Completeness repair job — new `al.api_track_count` column
   in the SELECT, read first in the scan loop. On miss (album never
   enriched, or enrichment workers haven't run yet on a fresh
   install), falls through to the existing `_get_expected_total()`
   API lookup and persists the result via the same shared helper
   (wrapped in connection/commit management since the repair job
   runs outside a worker's batched transaction).

Also removed `al.track_count` from the scan's SELECT — now unused
since the observed count was the whole source of this bug, and
leaving a dead SELECT would invite a future engineer to re-introduce
the same comparison.

Help text on the job card was reworded so it honestly describes
current behavior ("counts cached during normal enrichment are used
when available; otherwise the job queries a metadata source
directly") rather than the old "active provider first, then others
as fallback" phrasing, which doesn't match how the cache actually
fills — any enrichment worker that runs can populate it, and the
last writer wins. Document-only follow-up if this edge case ever
bites in practice: add a `api_track_count_source` column so the
scan can prefer the configured primary source's count over others
(e.g. deluxe vs. standard edition mismatches). Not worth the
complexity today.

For existing users, the first completeness scan after upgrade is
fast to the extent their library is already enriched: the workers
already ran and populated `api_track_count` on their normal schedule.
For brand-new installs, the scan's fallback path handles the cold
start — slower, but correct, and subsequent scans are fast.

Does NOT affect:
- Download / post-processing / wishlist / sync code paths — none
  of them read `track_count` for completeness semantics.
- Plex / Jellyfin / Navidrome / standalone sync — still write
  `track_count` exactly as before; `api_track_count` is a separate
  column they never touch.
- Other repair jobs.
- Any UI path — same finding schema, just correct counts now.

Files:
- database/music_database.py — idempotent migration adding
  `api_track_count INTEGER DEFAULT NULL` to the existing album-column
  check block.
- core/worker_utils.py — new `set_album_api_track_count` helper with
  the documented skip-on-bad-input contract.
- core/spotify_worker.py, itunes_worker.py, deezer_worker.py,
  discogs_worker.py — one-liner call from each `_update_album`.
- core/repair_jobs/album_completeness.py — scan uses the cache;
  fallback path persists API-lookup results via the shared helper;
  help text updated to match actual behavior.
- tests/test_worker_utils_album_track_count.py — 9 tests covering
  the helper's write/skip contract + no-commit invariant.
- tests/test_album_completeness_job.py — 2 tests for the repair
  job's fallback-path wrapper.
- webui/static/helper.js — WHATS_NEW entry.

Credit: sassmastawillis spotted the bug; the "restored older DB
finds 7 albums" signal pinpointed DB state over code logic and
made the diagnosis tractable.
2026-04-24 12:39:41 -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
2b7d6c8c7c Fix global search popover not scrolling when results overflow
The source-picker refactor introduced a new stable DOM structure inside
`#gsearch-results`:

    <div id="gsearch-results">            <!-- max-height: 60vh, flex-col -->
      <div id="gsearch-source-row" />     <!-- icon row, controller-rendered -->
      <div id="gsearch-fallback-banner" />
      <div id="gsearch-body" />           <!-- surface renders results here -->
    </div>

But the companion CSS never landed. `#gsearch-body` had default block
layout, so when results exceeded the 60vh cap, they clipped silently
instead of scrolling. The old structure had `.gsearch-results-body`
with `overflow-y: auto; flex: 1` directly inside the panel; that rule
still exists but its selector now matches a nested div with no flex
parent, so `flex: 1` is a no-op and overflow doesn't trigger.

Fix: give the three stable children the right flex behaviour so the
body fills remaining space and scrolls.

- `#gsearch-source-row` and `#gsearch-fallback-banner` stay at natural
  height (flex-shrink: 0).
- `#gsearch-body` grows (flex: 1 1 auto), can shrink below content
  height (min-height: 0 — this is the critical bit, otherwise flex
  items won't shrink below their intrinsic size and overflow never
  triggers), and scrolls vertically.

Styled scrollbar matches the rest of the panel (4px, translucent thumb).
2026-04-24 08:34:03 -07:00
Broque Thomas
394ac73877 MusicBrainz: Tests for new search behavior + WHATS_NEW entry
26 new unit tests in tests/test_musicbrainz_search.py covering:

- Cover Art URL construction (release + release-group scope, empty MBID,
  unknown scope fallback)
- Structured query splitting (hyphen, en-dash, em-dash, bare name, no
  false-positive splits on hyphens-inside-words)
- Artist search: score filtering, strict=False call contract, exception
  handling, genre extraction from MB tags, mbid/name validation
- Top-artist resolver: memoization by normalized query, sub-threshold
  returns None, negative-result caching, empty-query short-circuit
- Album search routing: bare query → browse path, structured query →
  text path, no-artist-match falls back to text, text path score filter
- Track search routing: browse path, dedupe-by-title across
  live/compilation variants, structured query → text path, text path
  score filter

All mock the underlying MusicBrainzClient — no network calls.

Also adds a WHATS_NEW entry under 2.40 explaining the three user-visible
changes: Artists section now populates, album/track results match the
searched artist instead of random title collisions, and search completes
in ~3 seconds instead of 30+.
2026-04-24 08:18:47 -07:00
Broque Thomas
253e4d1e4a Fix Discover hero 'View Discography' 404ing on source-only artists
Clicking 'View Discography' on the Discover hero slideshow was calling
navigateToArtistDetail(id, name) without the third 'source' argument.
loadArtistDetailData then omits the `source` query param, so
/api/artist-detail falls through to a local DB lookup and returns 404
for artists that don't exist in the library — which is nearly every
hero artist, since they come from discover similar-artists.

Regression from the unification PR (93f1941) that rewrote the click
handler to route through the standalone /artist-detail page instead
of the old inline Artists view. The rewrite didn't thread the source.

Backend already includes `artist.source` on each hero entry. Fix:
- Stash artist.source as data-source on #discover-hero-discography
  when displayDiscoverHeroArtist populates the card.
- Read data-source in viewDiscoverHeroDiscography and pass it as the
  third arg to navigateToArtistDetail, so the eventual API call
  includes `?source=itunes/deezer/etc.` and returns the synthesized
  discography.

Reproduced by clicking View Discography on a non-Spotify hero artist
(log showed `GET /api/artist-detail/76258852?name=ДЕТИ+RAVE → 404,
Getting artist detail for ID: 76258852 (source=library)`).
2026-04-23 23:09:56 -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
ab7aeb302c Defer search-restore render so it survives nav-button click bubble
The navigate-back fix from the previous commit was being immediately
undone by the document outside-click handler. Race:

1. Click on sidebar nav-button → button handler runs synchronously,
   eventually calling _searchPageRestoreOnEnter → _renderFromState →
   showDropdown removes `hidden` class
2. Click event bubbles up to document
3. Document outside-click handler sees dropdown is now visible, sees
   the click target is a nav-button (not inside the search wrapper or
   the source row), calls hideDropdown → instantly hidden again

Fix: defer the _renderFromState call to setTimeout(0). The macrotask
runs AFTER the click event finishes propagating, so by the time the
dropdown becomes visible, the document outside-click handler has
already short-circuited (it saw the dropdown still hidden).

User reported having to delete + retype the last character of the
query to force a re-render — which worked because the input event
listener fires submitQuery, which routes through the controller
without going through the deferred path.
2026-04-23 22:17:33 -07:00
Broque Thomas
258644fd9f Drop Show/Hide Results button + auto-restore cached results on navigate-back
Cin flagged two related UX issues during PR review:

1. The "Show Results / Hide Results" toggle next to the search bar served
   no real purpose — there was nothing else on the Search page worth seeing
   instead of results, so toggling visibility was always pointless overhead.

2. Navigating away from /search via a sidebar link dismissed the dropdown
   (the click was caught by the outside-click handler). Coming back left
   the input populated but the results hidden, requiring a Show Results
   click or a fresh search. The cached state was intact in the controller
   the whole time — just not rendered.

Both fixed by the same direction: dropdown visibility becomes a pure
function of query state, never user-toggleable. The closure now exposes
`_searchPageRestoreOnEnter` so subsequent calls to `initializeSearchModeToggle`
re-render from the controller's cached state instead of early-returning.

Removes the button HTML, click handler, `updateToggleButtonState` function,
the desktop + responsive CSS for `.enhanced-search-btn`, and the orphaned
`.btn-icon` rule. Net -94 lines.
2026-04-23 22:11:49 -07:00
Broque Thomas
77d20e9aa8 Fix Clean Search History automation AttributeError on DownloadOrchestrator
The hourly `clean_search_history` automation was crashing with
`'DownloadOrchestrator' object has no attribute 'base_url'`. The guard
was written before the orchestrator refactor — `soulseek_client` is now
a DownloadOrchestrator that wraps individual download clients, with the
real Soulseek client sitting at `.soulseek`.

Two other call sites in web_server.py (lines 2634, 3092) already used
the correct `soulseek_client.soulseek.base_url` pattern with a getattr
guard. This call site was missed during the refactor.

Fix: reach through the orchestrator the same way the other sites do.
2026-04-23 18:01:34 -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
481d3d940f Mobile responsiveness for source picker, aura, and library empty CTA
The new components shipped this PR (source icon row, fallback banner,
glow aura, library-empty search CTA) had no responsive styling. On
phones the rows ran fine via horizontal scroll but the chips wasted a
lot of space per icon, the CTA could overflow on narrow screens, and
the aura kept its desktop-sized ellipse for no benefit.

At ≤768px (tablet/phone):
- Enhanced source row: tighter padding, 24x24 glyphs, 72px chip
  min-width.
- Global widget source row: even tighter, 20x20 glyphs, 62px chips.
- Fallback banners scale down to match.
- Aura shrinks to a 440x160 / 540x200 ellipse and a 180px-tall strip
  so it doesn't eat short mobile viewports.
- Library empty CTA allows text wrap + reduced padding so the
  "Search online for "long artist name"" string doesn't break the
  layout on narrow screens.

At ≤480px (phone):
- Enhanced chips drop to 44px min-width.
- Global widget chips drop to 40px.
- Both hide the source-name label, showing icon + tooltip only — the
  full 8-source row now fits or scrolls minimally at that scale.
- Aura narrows further to 140px tall.
- Library CTA nudges down to 12px font.
2026-04-23 17:28:16 -07:00
Broque Thomas
30ab21c0e5 Global search bar: ambient accent-glow aura under the pill
Adds a subtle radial glow at the bottom of the viewport that emanates
from the floating search bar, fades outward toward both window corners,
and shrinks vertically as it moves away from the bar. Makes the bar
easier to spot at a glance without a heavy full-width bar or a chrome
strip.

- New `.gsearch-aura` fixed element, 260px tall, full width, pointer
  events off. Radial-gradient with the accent color centered at the
  bottom middle; colour stops taper 620x230px by default, ramping to
  820x280px and brighter when the bar is focused/active.
- `_gsUpdateVisibility` hides the aura on /search alongside the bar
  via a simple `.hidden` class.
- Focus handler adds `.active` to the aura in step with the bar;
  `_gsDeactivate` removes it. z-index 99990 (below the bar at 99998,
  above most page content).
2026-04-23 17:28:16 -07:00
Broque Thomas
dd20298df4 Library page empty state: offer to search metadata sources for the query
When a user types an artist name into the library search and gets no
hits, the old empty state just said "No artists found — try adjusting
your search or filters." Dead end for the common case of "I searched
for someone I don't own yet."

The empty state now detects when libraryPageState.currentSearch is
non-empty and swaps in a CTA that hands the query off to /search:

  "kendrick" isn't in your library
  They might be available on a connected metadata source.
  [🔍 Search online for "kendrick" →]

Clicking the button navigates to /search, pre-fills the enhanced search
input, and dispatches an input event so the existing debounced search
fires automatically. Uses the same hand-off pattern _gsNavigateToSearchPage
already uses for Soulseek, so the Search page's source-picker flow
picks up naturally from there.

No change to the generic empty state (no query active) or to any other
library page behaviour.
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
ec0c425e71 Global widget: drop the double-panel look on the source row
The global search popover already draws its own frosted-glass panel
(via .gsearch-results), so putting another bordered/gradient container
around the source icons inside it read as "panel inside a panel" —
visually noisy and left a dark empty strip on the right when the row
didn't fill the popover width.

Strip the source row's own background/border, center-align the chips
(justify-content: center) so they stay grouped instead of drifting to
the left, and keep a subtle bottom divider so the icons still read as
a distinct control group above the results.
2026-04-23 17:28:15 -07:00
Broque Thomas
f6f8a3e960 Source picker visual boost: glassy row, brand-glow active, pulsing cache dot
Dresses up the bare chip row so the picker reads as a deliberate piece of
UI rather than a utility bar. Both the Search page (.enh-source-*) and
the global widget (.gsearch-source-*) get the same treatment.

- The row itself is now a frosted-glass panel (subtle white gradient,
  inner highlight, rounded 14px / 12px corners, outer shadow) so the
  picker feels unified instead of a loose strip of buttons.
- Chips bumped: min-width 90px (row) / 72px (widget), bigger padding,
  12px rounded corners, subtle linear gradient top-to-bottom, 30px icons
  (up from 22px) with a drop-shadow for depth.
- Hover lifts the chip by 1px with a darker drop-shadow and brighter
  border — cheap but effective microinteraction.
- Active state is brand-themed per source: the chip's background
  becomes a top-weighted gradient in the service's colour, the border
  matches, and an outer brand-coloured glow (6-22px blur) surrounds it.
  scale(1.03) pops it above neighbours. Label bumps to 700 weight when
  active. Same treatment for Spotify / Apple Music / Deezer / Discogs /
  Hydrabase / MusicBrainz / Music Videos / Soulseek.
- Cache dot gets a brand-coloured glow and a subtle 2.4s pulse so the
  "already fetched this query" hint is visible without being loud.
- Fallback-warning icons get an amber tint on both border and outer
  ring to match the existing fallback banner colour.
2026-04-23 17:28:15 -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
553ede8de9 Update interactive help + What's New for the source-picker redesign
The existing 2.40 WHATS_NEW entry described the short-lived "Search
from" dropdown that preceded this redesign. Updated to describe the
icon row + per-query cache + rate-limit fallback banner + global widget
parity that actually ships.

Click-for-help annotations and the "First Download" tour now point at
`#enh-source-row` (the new icon container) instead of the deleted
`.search-source-picker-container` dropdown and the deleted
`.enh-source-tabs` post-search tab bar. Adjusted the enhanced-search
tips so "multi-source tabs compare results" doesn't mislead — the
icons above the bar are how you compare now.

Version stays at 2.39 — the 2.40 WHATS_NEW section is accumulating
under the "Search & Artists unification" umbrella and will publish
when the whole 2.40 cycle ships.
2026-04-23 17:28:15 -07:00
Broque Thomas
9ddfcf254f Global search widget: same source-picker icon row + per-source cache
Matches the Search page redesign so both surfaces behave identically.
The sidebar popover previously always fan-out-fetched all sources on
every keystroke (via _gsFetchSourceStream streaming NDJSON for every
alternate) and exposed a post-search tab bar to switch views.

Now:

- The popover renders an always-visible source icon row at the top, one
  icon per source (Spotify, iTunes, Deezer, Discogs, Hydrabase,
  MusicBrainz, Music Videos, Soulseek).
- Typing fetches only the currently-selected source. No fan-out.
- Clicking a different icon: cache hit -> instant re-render; cache miss
  -> single-source fetch + render.
- Per-query cache cleared on query change; cache dots on icons show
  which sources already have results for the current query.
- Default active source read from /api/settings (metadata.fallback_source)
  on first focus; falls back to Spotify.
- Fallback banner shown when the backend served a different source than
  the one clicked (rate-limit auto-fallback).
- Soulseek icon click navigates to /search with the query pre-filled,
  since the raw file list doesn't fit the popover. The Search page
  takes over rendering from there.

Gone: _gsFetchSourceStream (fan-out), _gsRenderTabs, _gsSwitchSource,
_gsState.altAbortCtrl, per-section _loading sets.
Added: _gsInitDefaultSource, _gsFetchSource, _gsFetchYouTubeVideos,
_gsSourceRowHtml, _gsFallbackBannerHtml, _gsSetActiveSource,
_gsNavigateToSearchPage.
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
b547909604 Address PR review feedback from JohnBaumb and Cin
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.
2026-04-23 07:48:40 -07:00
Broque Thomas
1b3751598d Stop sidebar nav items bleeding through the sticky header
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.
2026-04-22 22:49:34 -07:00
Broque Thomas
1aedc2ddcf Don't highlight sidebar Library when on /artist-detail
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.
2026-04-22 22:25:06 -07:00
Broque Thomas
f684bd603d Keep Search page dropdown open across result clicks and modal close
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.
2026-04-22 21:55:13 -07:00
Broque Thomas
b0a07f993a Clean up stale Artists-page references in helper.js + docs.js
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.
2026-04-22 20:29:32 -07:00
Broque Thomas
135f6b9ea1 playStatsTrack: fall back to streaming sources when not in library
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.
2026-04-22 20:07:28 -07:00
Broque Thomas
20dfcf10a5 Bump artist-detail card size 25% (180→225px min, 150→190px on small screens) 2026-04-22 20:02:49 -07:00
Broque Thomas
648e46d460 Update streaming completion handler to target the new big-photo card overlay
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).
2026-04-22 19:59:35 -07:00
Broque Thomas
59298b5b17 Restore big-photo album cards on artist-detail page
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.
2026-04-22 19:53:56 -07:00
Broque Thomas
9b52c1d94f Smart back button on artist-detail — labels and routes by origin
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.
2026-04-22 19:35:17 -07:00
Broque Thomas
77567a5eda Sync currentArtistId on library upgrade + hide Similar Artists in Enhanced view
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.
2026-04-22 19:27:17 -07:00
Broque Thomas
9106617538 Show collection bars + Top Tracks for source artists, upgrade source clicks to library when possible
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.
2026-04-22 19:19:48 -07:00
Broque Thomas
f936b8cb12 Enrich source-only artist-detail response and skip discography dedup for source artists
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.
2026-04-22 17:14:39 -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
Broque Thomas
6a76405444 Add Similar Artists to standalone /artist-detail page, hide library-only UI for source artists
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.
2026-04-22 16:19:25 -07:00
Broque Thomas
18146098a7 Revert "Route source-artist clicks to standalone /artist-detail page"
This reverts commit 1c345e4eb5.
2026-04-22 15:52:54 -07:00
Broque Thomas
1a8071d6ec Revert "Retire artists.js and inline Artists page, ship unification at 2.40"
This reverts commit 71ff5cb5c3.
2026-04-22 15:52:53 -07:00
Broque Thomas
71ff5cb5c3 Retire artists.js and inline Artists page, ship unification at 2.40
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.
2026-04-22 15:38:32 -07:00
Broque Thomas
1c345e4eb5 Route source-artist clicks to standalone /artist-detail page
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.
2026-04-22 15:30:40 -07:00
Broque Thomas
89754480be Source-aware /api/artist-detail: fall back to metadata source when not in library
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.
2026-04-22 15:28:28 -07:00
Broque Thomas
a5d97261e4 Extract shared helpers from artists.js to shared-helpers.js
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.
2026-04-22 15:25:59 -07:00
Broque Thomas
3c7dc4de6e Artist detail back button falls back to Search, not Dashboard
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.
2026-04-22 14:29:30 -07:00
Broque Thomas
78c14d3084 Hold version at 2.39 and fold unification changelog into one 2.40 entry
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.
2026-04-22 14:25:20 -07:00
Broque Thomas
19e9174866 Fix 404 on source-artist click — revert Phase 4a source migrations, bump to 2.48
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.
2026-04-22 14:17:06 -07:00
Broque Thomas
d037643908 Clean up interactive help annotations for unified Search, bump to 2.47
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)
2026-04-22 13:59:42 -07:00
Broque Thomas
09f15ce7d2 Retire Artists sidebar entry, redirect entry points to Search, bump to 2.46
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.
2026-04-22 13:40:22 -07:00
Broque Thomas
9361c29965 Route all artist-detail callers to the standalone page, bump to 2.45
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.
2026-04-22 13:36:36 -07:00
Broque Thomas
f203b3e46d Remove embedded Download Manager from Search page, bump to 2.44
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.
2026-04-22 13:31:42 -07:00
Broque Thomas
6992e2e5b5 Rename Search page id from 'downloads' to 'search', bump to 2.43
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.
2026-04-22 13:22:49 -07:00
Broque Thomas
68d46c5aba Replace Enhanced/Basic toggle with source picker, bump to 2.42
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.
2026-04-22 13:06:13 -07:00
Broque Thomas
377343326f Dedupe enhanced-search fetch in widget and page, bump to 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.
2026-04-22 12:59:05 -07:00
Broque Thomas
952c35de39 Add source param to /api/enhanced-search, bump to 2.40
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.
2026-04-22 12:49:49 -07:00
Antti Kettunen
5420c0de24
Fix album track source propagation
- Pass source through artist, library, wishlist, and rehydration album-track fetches
- Preserve the resolved metadata source on cached discography and artist detail state
- Prevent 404s when opening artist-page album modals from non-Spotify sources
2026-04-22 21:21:32 +03:00
BoulderBadgeDad
00f116ebee
Merge pull request #352 from JohnBaumb/refactor/split-script-js
Split monolithic script.js into 17 domain modules
2026-04-22 10:49:17 -07:00
Broque Thomas
8f85b0c251 Fix silent wrong-artist track downloads (Maduk/Tom Walker bug)
User reported searching "Maduk - Leave A Light On" on Tidal silently
downloaded Tom Walker's completely different song of the same name, then
embedded Maduk's metadata into Tom Walker's audio. Three layers of
defense all failed permissively. Two of them are fixed here; the third
(score formula weights) was left alone since these two together cover it.

Layer 1 fix — candidate artist gate (web_server.py:27782)
  Old: `if _best_artist < 0.4 and confidence < 0.85: continue`
  New: `if _best_artist < 0.5 and confidence < 0.85: continue`

  SequenceMatcher returns exactly 0.400 for "maduk" vs "tom walker"
  (5-char vs 10-char strings with coincidental char matches), which
  slipped past the strict `< 0.4` check. The word-boundary containment
  check earlier in the function already short-circuits legitimate
  formatting variations to sim=1.0, so falling to SequenceMatcher means
  strings are genuinely different. 0.5 closes the fencepost AND gives
  a small safety buffer.

Layer 3 fix — AcoustID verification (acoustid_verification.py:316)
  When title matches but artist doesn't AND expected artist isn't found
  anywhere in AcoustID's returned recordings:
    Old: always SKIP (let file through, assume cover/collab)
    New: FAIL if artist_sim < 0.3 (clear mismatch)
         SKIP if artist_sim >= 0.3 (ambiguous — cover/collab/formatting)

  The 0.3 cutoff catches hard mismatches like Maduk/Tom Walker (sim ~0.2)
  while preserving benefit-of-the-doubt for borderline artist formatting
  differences. Legitimate covers and collabs where the expected artist
  appears anywhere in AcoustID's recordings still PASS via the existing
  secondary-match loop above.

Both fixes are defense-in-depth — either alone would have caught this
bug. Together they close the pre-download AND post-download gaps.

All 292 tests pass. Version bumped to 2.39 with changelog entries.
2026-04-22 10:32:55 -07:00
JohnBaumb
a66c4d06e1 Split monolithic script.js (78K lines) into 17 domain modules
Extracts the single 77,957-line script.js into focused modules:

  core.js            (874)   - Global state, confirm dialog, websocket, constants
  init.js            (2358)  - Initialization, personal settings, navigation
  media-player.js    (2398)  - Media player, audio, visualizer, radio
  settings.js        (3657)  - Settings page, quality profiles, API keys, auth
  search.js          (1542)  - Search functionality, page data loading
  sync-spotify.js    (2538)  - Spotify sync, YouTube backend, hero section
  downloads.js       (6398)  - Wing It, batched polling, cancel, notifications
  wishlist-tools.js  (7234)  - Wishlist, matched downloads, tools, retag
  sync-services.js   (9076)  - Tidal, Deezer, Beatport, YouTube, ListenBrainz sync
  artists.js         (4610)  - Artists page, artist downloads
  api-monitor.js     (3798)  - API rate monitor gauges
  library.js         (6652)  - Library, artist detail, enhanced management
  beatport-ui.js     (3902)  - Beatport sliders, genre browser
  discover.js        (8920)  - Discover page and all sub-sections
  enrichment.js      (3551)  - All enrichment workers, library repair
  stats-automations.js (7575) - Stats, automations, issues, import
  pages-extra.js     (2874)  - Playlist explorer, server playlists, active downloads

Load order: core.js first (globals), init.js last (DOMContentLoaded).
All other modules define functions and load in any order.
No functional changes - pure extraction along existing section boundaries.
2026-04-21 23:52:30 -07:00
BoulderBadgeDad
47ced912b9
Merge pull request #349 from kettui/fix/lazy-load-beatport-only-when-needed
Load Beatport content only when needed
2026-04-21 22:59:44 -07:00
Broque Thomas
78fa83c8ac Add $cdnum template variable for multi-disc filenames
New smart template variable that emits "CD01" / "CD02" etc. in filenames
on multi-disc albums, and expands to empty string on single-disc albums
so mixed libraries don't end up with "CD01" on every single.

Template behaviour:
- total_discs > 1 -> "CD{disc:02d}" (zero-padded, CD prefix)
- total_discs <= 1 -> empty string
- Both $cdnum and ${cdnum} bracket form supported
- Empty value collapses cleanly via existing double-dash regex plus new
  leading-dash cleanup pass

Wiring:
- _apply_path_template in web_server.py (download pipeline)
- _apply_path_template in core/repair_jobs/library_reorganize.py
  (Reorganize repair job)
- total_discs added to every album-mode template context:
  * download pipeline album branch (uses resolved total_discs even for
    single-track downloads from search)
  * per-album Reorganize preview + apply endpoints (pre-scan all track
    tags once, take max disc_number)
  * Library Reorganize repair job (already had album_total_discs map,
    just added to context dict)

Leading-dash cleanup added to _get_file_path_from_template (web_server)
and _build_path_from_template (library_reorganize) so templates like
"$cdnum - $track - $title" don't leave "- 05 - Title" on single-disc
albums.

UI:
- Template hint in Settings -> File Organization documents $cdnum
- Template validation variable list includes $cdnum
- Reorganize modal variable reference shows $cdnum with example "CD01"

Verified:
- Multi-disc disc 1 -> "CD01 - 05 - Track"
- Multi-disc disc 2 -> "CD02 - 05 - Track"
- Single-disc      -> "05 - Track" (no leading dash)
- Templates without $cdnum behave unchanged
- 276/276 tests pass
2026-04-21 22:55:37 -07:00
Broque Thomas
b9a7ed97be Add per-row cancel + Cancel All to downloads page, fix streaming cancel
Three closely-related changes bundled together. The UI work exposed the
backend bug when I tried to cancel a Deezer download and saw it marked
cancelled in the DB but continuing in the background.

Backend — cancel_task_v2 orchestrator dispatch fix:
  The slskd-specific cancel block was written back when soulseek_client
  was a raw SoulseekClient. It was later swapped to DownloadOrchestrator
  (which doesn't expose .base_url / ._make_request), so the first
  diagnostic log line crashed with AttributeError. The outer try/except
  swallowed it, leaving streaming downloads (YouTube / Tidal / Qobuz /
  HiFi / Deezer / Lidarr) running in the background after the user
  clicked cancel.

  Replaced the ~80-line block with a single
  soulseek_client.cancel_download(download_id, username, remove=True)
  call — the orchestrator's dispatch picks the right client by username,
  same path /api/downloads/cancel already uses successfully.

Per-row cancel button (fancy):
  Circular X button on .adl-row for rows in active or queued state.
  Hidden by default (opacity 0, translateX + scale), fades in + settles
  on .adl-row:hover with a cubic-bezier overshoot. Own :hover gives a
  1.12x scale pop and brighter red glow. Touch devices (@media
  (hover: none)) keep it visible.

  Backend: surfaced playlist_id in /api/downloads/all items so the
  frontend can hit cancel_task_v2 without a second lookup. Frontend:
  adlCancelRow(btnEl, playlistId, trackIndex) with double-click guard
  via data-cancelling + adl-row-cancel-pending class.

Cancel All header button:
  Red-themed button next to "Clear Completed". Only visible when any
  task is in downloading / searching / post_processing / queued state —
  auto-hides the moment the last one finishes. Confirm dialog shows
  "Cancel N tasks across M batches?". Iterates _adlBatches, calls
  /api/playlists/<batch_id>/cancel_batch sequentially (same endpoint
  each modal's "Cancel All" and the per-batch-card cancel use). Disables
  during the loop, mixed/success/error toast based on result.

All 276 tests pass.
2026-04-21 22:35:30 -07:00
Antti Kettunen
cc8875f30b
Pause Beatport work on tab exit
- Abort Beatport content loading when leaving the Sync/Beatport tab
- Stop Beatport slider autoplay plus discovery and sync subscriptions on exit
- Make Beatport bubble hydration cancellable so hidden tabs do not keep fetching
2026-04-22 08:12:35 +03:00
Antti Kettunen
8dbc819765
Lazy-load Beatport tab data
- Defer Beatport scraper fetches until Beatport tab opens
- Skip Beatport bubble hydration during app bootstrap
- Keep rebuild sliders and top lists behind first visit
2026-04-22 07:57:05 +03:00
Broque Thomas
03b7230ac2 Preserve cover art in discovery fix-modal cache matched_data
Companion fix to the provider-hardcode bug (6ceedc8). The cache
matched_data built by the 5 update_match / fix endpoints was dropping
image_url and album.images when album came back as a bare string —
common for Deezer and iTunes search results. Cache hits on re-discovery
then produced downloads with no artwork.

Each save site now carries image info through:
- album_obj gets image_url + images:[{url}] populated from spotify_track.image_url
- matched_data adds top-level image_url for pipeline consumers that check there
- Works for both dict-shaped album (Spotify) and string-shaped album (Deezer/iTunes)

Mirrors the handling already present in _build_fix_modal_spotify_data for
the in-memory result['spotify_data'] — explains why the UI showed art fine
during the fix modal but the cached entry lost it after restart.

save_discovery_cache_match uses INSERT OR REPLACE, so existing bad cache
entries refresh when the user re-fixes the track. No manual cache clearing
needed.

Added to 2.38 changelog (same round of discovery-fix work).
2026-04-21 21:26:36 -07:00
Broque Thomas
6ceedc8fd4 Fix manual discovery fixes lost after restart for non-Spotify users
Five update_match endpoints hardcoded the provider as 'spotify' when
saving manual fixes to the discovery cache, but the re-discovery worker
queries the cache with _get_active_discovery_source() — the user's
actual primary. If the primary was Deezer/iTunes/Discogs/Hydrabase, the
provider column never matched, so every manual fix looked like it
vanished on restart.

Replaced 'spotify' with _get_active_discovery_source() at all 5 sites:
- Tidal update_match (web_server.py:34569)
- Deezer update_match (web_server.py:36235)
- Spotify Public update_match (web_server.py:37084)
- YouTube update_match (web_server.py:38037)
- Discovery Pool fix (web_server.py:49787)

Now symmetric with how the auto-discovery workers already save. Spotify-
primary users see no change (the hardcoded value matched their source).

Version bumped to 2.38 with changelog + version-info entries.
2026-04-21 21:12:22 -07:00
Broque Thomas
d0ee9c73b3 Reveal download cancel button on hover instead of always showing
Cancel button on active download items was always visible, cluttering
the card. Now hidden by default and fades in when you hover the card
(or focus anything inside it, for keyboard a11y).

- opacity + pointer-events approach so layout doesn't jump on reveal
- 4px slide-in on reveal for a subtle entrance
- Touch devices (hover: none) keep the button always visible — no hover
  means no way to discover it otherwise
2026-04-21 21:12:06 -07:00
Broque Thomas
0af98cdded Merge main into dev (brings in PR #342 Plex PIN OAuth) 2026-04-21 20:22:08 -07:00
Broque Thomas
e5d4d61c0e Fix watchlist content filters: live false positives + auto-scan bypass
Two bugs reported in issue #320:

1. Auto-watchlist scan bypassed Global Override settings.
   scan_watchlist_profile applied _apply_global_watchlist_overrides, but
   the scheduled auto-scan called scan_watchlist_artists directly —
   bypassing the override. Users who unchecked "Albums" or "Live" under
   Watchlist → Global Override still saw full albums and live tracks
   added during nightly scans (per-artist defaults, which include
   everything, won).

   Moved override application into scan_watchlist_artists itself so
   every entry point respects it. scan_watchlist_profile now forwards
   the apply_global_overrides flag through to avoid double-application.

2. is_live_version (watchlist + discography backfill) and
   live_commentary_cleaner's content patterns used bare \blive\b, which
   matched verb uses like "What We Live For" by American Authors,
   "Live Forever" by Oasis, "Live and Let Die" by Wings.

   Tightened the live patterns to require clear recording context:
   (Live) / [Live Version] / - Live / Live at|from|in|on|version|
   session|recording|performance|album|show|tour|concert|edit|cut|take
   / In Concert / On Stage / Unplugged / Concert.

   Locked in 11 regression tests covering the reported false positives
   (What We Live For, Live Forever, Living on a Prayer, Live and Let Die)
   and the reported true positives (Dimension - Live at Big Day Out,
   MTV Unplugged, etc.).

Version bumped to 2.37 with changelog entries.
2026-04-21 19:16:25 -07:00
Broque Thomas
178719e443 Fix metadata cache health bar duplicating on findings dashboard
_loadCacheHealthStats ran async from loadRepairFindingsDashboard and
appended a new .repair-cache-health div each time. If the dashboard
refreshed while earlier fetches were still in flight, each resolving
fetch appended its own section — producing 2–6 stacked copies.

Now each fetch removes any existing .repair-cache-health inside the
dashboard before appending, so at most one bar is ever visible.
2026-04-21 18:47:15 -07:00
Broque Thomas
457763cbab Rebuild Discography Backfill: auto-wishlist, Fix All, section UI
Root-cause fix for "scanning 50 artists" then silence: when the master
repair worker was paused, force-run still kicked off _run_job but the
job's first wait_if_paused() blocked forever because is_paused was tied
to the master-enabled state. Force-run now bypasses master-pause —
scheduled runs still respect it.

Also fixes Fix All on discography findings doing nothing: the backend
bulk_fix_findings query had a fixable_types allowlist that excluded
missing_discography_track (and acoustid_mismatch). Added both.

Backfill job rebuild:
- auto_add_to_wishlist opt-in setting — creates findings AND pushes to
  wishlist during the scan
- 3-option fix dialog (Add to Wishlist / Just Clear / Cancel) on single
  Fix, Bulk Fix selection, and Fix All (page-level)
- Fix All "Just Clear" path uses the clear endpoint with job_id filter
  instead of the generic "may delete files" bulk-fix warning
- Batched in-memory matching using get_candidate_albums_for_artist +
  get_candidate_tracks_for_albums (same fast path the Library pages use)
- Rich album context per finding (id, name, album_type, release_date,
  images, artists, total_tracks) — flows through the wishlist pipeline
  so auto-processor classifies each track into the right cycle
  (albums vs singles) and post-processing gets correct folder/tags/art
- Per-artist progress logs [N/50] Scanning ArtistName
- Default interval 24h (was 168h); all release types default on; settings
  reordered with _section_* group headers (Core / Release Types /
  Content Filters)

Repair settings UI:
- Generic _section_<name> key convention renders as an uppercase group
  divider in the settings panel — any job can opt in
- .repair-setting-row gets a dashed bottom border so label↔toggle pairing
  is visually clear
- _prettifyRepairSettingKey fixes acronym capitalization (EPs, not Eps)

Version bumped to 2.36 with changelog entries.
2026-04-21 18:44:43 -07:00
Broque Thomas
75d0dc3d4f Fix manual discovery match producing download cards with no cover art
User reported: after clicking Fix on a Not-Found discovery track and
picking a replacement in the fix modal, the resulting download card had
no cover image, and the track seemed to still behave like a Wing-It
stub. Both suspicions were correct. Three compounding bugs:

1. /api/spotify/search_tracks returned only id/name/artists/album/
   duration_ms — no image_url — unlike the sibling /api/itunes/ and
   /api/deezer/ endpoints which include image_url. The fix modal had
   no image data to work with when users searched via Spotify.

2. Frontend selectDiscoveryFixTrack discarded any image info it did get
   and posted the same minimal shape to the backend.

3. All 7 backend discovery/update_match endpoints built
   result['spotify_data'] with 'album' as a bare string (track.album
   which is just the album name). The download pipeline expects
   spotify_album to be a dict with image_url or images[].url — a string
   yields blank cover art. Normal discovery workers already build album
   as a rich dict; the fix-modal path was the anomaly.

4. Bonus: result['wing_it_fallback'] was never cleared on manual match.
   Tracks fixed after the auto Wing-It fallback kept the flag set, so
   downstream code checking it treated them as wing-it even though the
   user picked real metadata.

Changes:

- New helper _build_fix_modal_spotify_data(spotify_track) in web_server.py
  near _build_discovery_wing_it_stub. Handles both string and dict album
  inputs, normalises to a dict with image_url and images populated when
  the payload carries one. Matches the shape produced by normal discovery
  so downstream code is happy on both paths.

- /api/spotify/search_tracks now returns image_url (parity with iTunes
  and Deezer endpoints).

- All 7 discovery/update_match endpoints (youtube, tidal, deezer,
  spotify-public, listenbrainz, beatport — 6 via the identical pattern
  plus the listenbrainz variant with its None branch) now:
    * use the helper to build spotify_data (album as dict + top-level
      image_url)
    * explicitly set result['wing_it_fallback'] = False

- selectDiscoveryFixTrack forwards track.image_url in the POST body and
  mirrors the helper's output in the local state update so the UI
  reflects the same shape immediately.

Audit: every downstream reader of spotify_data['album'] is already
dict-or-string tolerant (isinstance checks at lines 2073, 2158, 25844,
28938, 29011, etc.) so promoting album from string to dict is safe.
Normal discovery already sets it as a dict, so we're moving the fix-
modal path to match the existing majority case.

Full suite stays at 263 passed. Ruff clean.
2026-04-21 17:12:28 -07:00
Broque Thomas
fadbb286b7 Fix [object Object] in discovery live-update paths (bulletproof pass)
The previous commit only fixed the INITIAL-render transform for
Spotify/Tidal/Deezer discovery rows. User confirmed [object Object]
still appeared after discovery completed — because there are two
additional update paths that do their own row-transform:

- WebSocket live-update handler (populates rows as discovery progresses)
- Poll-based fallback (same shape, runs when socket is disconnected)

Both had the same naive `.artists.join(', ')` on potentially-object
arrays. The poll and socket handlers exist for each of Spotify Public,
Tidal, and Deezer — six occurrences total across three platforms, all
with the same bug class. Now all use the object-aware map-and-join
pattern consistent with the initial-render fix.

Also fixes two more spots in openDiscoveryFixModal that the earlier
sweep missed:
- Missing spotify_public branch in the apply-selected-match handler:
  after user picks a replacement track, state lookup failed, the local
  row wouldn't refresh even though the backend had succeeded.
- Same artist-join bug in the same handler (track.artists from the
  fix-modal search results could be array-of-objects).

Full suite stays at 263 passed. Ruff clean.
2026-04-21 16:14:36 -07:00
Broque Thomas
891b18540b Fix Spotify Playlist Discovery: Fix button, header readability, [object Object]
Three separate issues reported on the Spotify Playlist Discovery modal:

1. Fix button fails with "Track data not found"
   openDiscoveryFixModal() branched on platform name to locate the discovery
   state but had no case for 'spotify_public'. Row rendering passes that
   platform value when the source is a Spotify public playlist, so state
   lookup failed and the toast fired. Added the spotify_public branch —
   state lives in youtubePlaylistStates alongside the other reused platforms.

2. Table header too transparent to read
   .youtube-discovery-modal .discovery-table th used rgba(255,255,255,0.1)
   as background (10% white) with white text, which lost contrast when the
   orange progress bar or varied row content scrolled underneath. Switched
   to near-solid dark rgba(17,17,20,0.96) with a brighter border-bottom
   and z-index:5 so the sticky header stacks cleanly above table content.

3. "[object Object]" in the matched-artist column for Wing-It tracks
   The Spotify Public Playlist row-transform joined result.spotify_data.artists
   directly with .join(', '). Wing-It stub metadata (built server-side by
   _build_discovery_wing_it_stub) returns artists as [{name: "..."}] —
   array of objects. .join() stringified each object to "[object Object]".
   Same pattern existed in three places in script.js; all now map objects
   to .name and filter empties before joining. Graceful fallback to "-"
   if the result is empty.

No existing tests touched. Full suite stays at 263 passed. Ruff clean.
2026-04-21 15:38:50 -07:00
elmerohueso
f8dd846fea
Merge branch 'main' into plex-pin-auth 2026-04-21 16:05:40 -06:00
Broque Thomas
0b4647ddd4 Add per-service config status indicators to Settings Connections tab
Adds green/yellow header gradient on each service card showing whether the
user has filled in credentials, plus an expand-triggered verification layer
that surfaces working-or-not status inline.

Backend (web_server.py):
- SERVICE_CONFIG_REGISTRY mapping each of the 11 services in Connections to
  its config requirements. Supports required-keys, always-green, any-of,
  and custom-check semantics (Tidal uses token-file check, Qobuz accepts
  either email/password OR cached auth token).
- _is_service_configured(service) — cheap config presence check, no APIs hit.
- GET /api/settings/config-status — returns {service: {configured}} for all
  services in one call. Drives the page-load gradient.
- POST /api/settings/verify — takes {services: [...]}, runs
  run_service_test per service, caches results 5 min in-memory, parallelizes
  with ThreadPoolExecutor(max_workers=3) to avoid self-rate-limiting. Query
  param ?force=true busts cache.
- Added verify branches for iTunes, Deezer, Discogs, Qobuz, Hydrabase in
  run_service_test (previously missing — these services couldn't be tested).

HTML (webui/index.html):
- data-service="..." on all 11 .stg-service containers so JS can map card
  to backend service name.

CSS (webui/static/style.css):
- .status-configured gradient (subtle green, left-to-transparent fade)
- .status-missing gradient (yellow, same shape)
- Spinner badge in header for .status-checking state
- "Testing connection…" status line style inside panel body
- Red warning bar style for verify failures at top of expanded panel
- Brand dot now glows always (was only glowing when expanded); hover and
  expand states intensify the glow progressively.

JS (webui/static/script.js):
- applyServiceStatusGradients() fetches config-status and applies
  green/yellow class per card. Called on Connections tab activate + after
  any settings save.
- _stgVerifyServices(services, {force}) — batch verify POST, tracks
  in-flight state, renders spinners/status lines/warnings per service.
- toggleStgService() fires single-service verify when a card is expanded
  (not on collapse). Skipped if a verify is already in flight for that
  service.
- toggleAllServiceAccordions() fires one batched verify for all 11 services
  when "Expand All" is clicked; skipped on "Collapse All".
- _stgRefreshAfterSave() — after settings save, refreshes gradient (cheap)
  and re-verifies only the cards the user currently has expanded (so
  freshly-edited credentials show their new verify result immediately,
  without re-pinging every service).

Failure UI: top-of-panel red warning bar with the error message (e.g.
"Discogs token rejected (HTTP 401)", "Hydrabase not connected…"). Removed
automatically on next successful verify.

No existing tests changed. Full suite stays at 263 passed. Ruff clean.
2026-04-21 14:24:41 -07:00
Antti Kettunen
6f79214439
Route artist image lookup through metadata service
- Move /api/artist/<artist_id>/image resolution into core.metadata_service.
- Resolve artist artwork through source priority, with explicit source/plugin overrides preserved.
- Keep Spotify call tracking inside the client layer to avoid double counting.
- Update similar-artist lazy loading to pass source context and add service coverage.
2026-04-21 21:14:35 +03:00
Broque Thomas
cf143f70af Batch discography completion matching against pre-fetched candidates
Artist detail pages ran check_album_exists_with_editions and check_track_exists
per discography item, each firing 5+ title variations times 3 artist variations
of fuzzy LIKE searches plus fallback broad-artist queries. For a 30-album artist
that was ~450 SQL round-trips just to answer "which of these do I own."

Hoist the artist's library albums and tracks into memory once per request via
two new helpers — get_candidate_albums_for_artist and get_candidate_tracks_for_albums —
and thread them through as optional candidate_albums / candidate_tracks params on
check_album_exists_with_editions, check_album_exists_with_completeness,
check_track_exists, check_album_completion, and check_single_completion.

Batched path scores the same _calculate_album_confidence / _calculate_track_confidence
against the in-memory list, preserving Smart Edition Matching and accuracy.
Title-only cross-artist fallback still fires for collaborative-album edge cases.
None on either param preserves legacy per-item SQL behavior for unaffected callers.

Applied to both /api/library/completion-stream (library artist detail page) and
iter_artist_discography_completion_events (Artists search page). Timing logs
added to confirm the pre-fetch cost and loop elapsed time.

On a Kendrick page load, per-album resolution drops from ~8 seconds to under
the 50ms streaming sleep floor. Observed ~100x SQL reduction on the happy path.
2026-04-21 10:47:47 -07:00
Broque Thomas
739d2f67c1 Fix Reorganize All sending every album to Albums folder
The reorganize endpoints built a template context without albumtype,
so ${albumtype} silently fell through to the engine's hardcoded "Album"
default — EPs, singles, and compilations all landed in Albums/.

Wires albumtype through from the albums table's record_type column
(populated by Spotify/Deezer/iTunes enrichment workers) with track-count
fallback when record_type is missing. New helper mirrors the download
pipeline's classification logic so reorganize produces the same folders
as initial placement. Also handles Deezer's raw 'compile' value which
the Deezer worker writes directly without mapping.
2026-04-21 08:59:16 -07:00
Broque Thomas
14359a1f98 Add confirmation dialog to Reorganize All and fix button style
Shows standard SoulSync confirm dialog before bulk reorganize with
album count and template preview. Button now uses enhanced-sync-btn
class to match other artist header buttons.
2026-04-20 23:51:17 -07:00
Broque Thomas
95cf1eeeea Add Reorganize All Albums button, version bump to 2.35, changelogs
New "Reorganize All" button in enhanced library artist header processes
all albums sequentially using the configured path template.

Version bumped to 2.35. Updated What's New modal with major features
(Discography Backfill, Multi-Artist Tagging, Enriched Downloads,
Template Delimiters, Reorganize All). Updated helper.js changelog
with all April 20 fixes and features.
2026-04-20 23:46:37 -07:00
Broque Thomas
e39a3f2af7 Add multi-artist tagging options: separator, multi-value tags, feat-in-title
Three new settings in Paths & Organization:
- Artist Tag Separator: choose comma, semicolon, or slash between artists
- Write multi-value ARTISTS tag: each artist as separate tag value for
  Navidrome/Jellyfin multi-artist linking (FLAC ARTISTS key, ID3 TPE1
  multi-value, MP4 multi-entry)
- Move featured artists to title: keep only primary artist in ARTIST
  tag, append others as (feat. ...) in track title

All opt-in with defaults matching current behavior. Raw artist list
stored on metadata dict for tag writers to access without re-parsing.
2026-04-20 23:18:10 -07:00
Broque Thomas
9c15d00128 Enrich downloads page cards with artwork, artist, album, and source badges
The downloads page previously showed only title and source per download.
Now shows album artwork thumbnail, artist name, album name, source badge,
and quality badge (after post-processing). All metadata comes from the
existing matched_downloads_context — no extra API calls needed.

Falls back gracefully to title-only display when context metadata is
not available (e.g. orphaned Soulseek transfers with no task mapping).
2026-04-20 22:18:20 -07:00
Broque Thomas
ed97fecc31 Add Discography Backfill maintenance job
New repair job that scans each artist in the library, fetches their
full discography from metadata sources, and creates findings for any
tracks not already owned. Users review findings and click "Add to
Wishlist" to queue missing tracks for download.

Respects content filters (live/remix/acoustic/instrumental/compilation)
and release type filters (album/EP/single). Opt-in, disabled by default,
runs weekly, processes up to 50 artists per run with rate limiting.
2026-04-20 22:04:38 -07:00
Broque Thomas
dad915dc2c Add ${var} hint text to single, playlist, and video template hints 2026-04-20 21:07:16 -07:00
elmerohueso
e3dd5727d8 Cancel button when manually configuring plex 2026-04-20 22:00:18 -06:00
elmerohueso
3e90de7a47 clean up buttons for manually configuring plex 2026-04-20 21:57:21 -06:00
elmerohueso
d36f18f5d7 pin auth should disable fields and show library 2026-04-20 21:34:29 -06:00
elmerohueso
6a27e7930c plex oauth via pin/code 2026-04-20 21:22:44 -06:00
elmerohueso
6344f250fc buttons to configure plex or view the current configuration 2026-04-20 20:54:10 -06:00
Broque Thomas
93e036848b Add ${var} delimiter syntax for path templates
Users can now append literal text to template variables using curly
braces: ${albumtype}s produces "Albums", "Singles", "EPs". Without
braces, $albumtypes was rejected as an unknown variable by validation.

Both syntaxes work: $albumtype (plain) and ${albumtype} (delimited).
Bracket vars are resolved first to prevent partial matching conflicts.
Validation updated for album, single, and playlist templates.
2026-04-20 18:03:11 -07:00
Broque Thomas
e1819186e4 Add fix action prompt for AcoustID mismatch findings
AcoustID findings had no Fix button and bulk "Fix Selected" silently
defaulted to retag with no user choice. Now shows a 3-option prompt
(Retag / Re-download / Delete) for both individual and bulk fix,
matching the pattern used by orphan files and dead files.
2026-04-20 16:22:26 -07:00
Antti Kettunen
24abae6908
Refactor album track lookup to use source priority
- Move album-track resolution into metadata_service
- Use the configured provider order instead of Spotify-first branching
- Switch the frontend to the unified /api/album/<id>/tracks endpoint
- Add tests for source-priority lookup, DB resolution, and formatting
2026-04-20 21:29:46 +03:00
Broque Thomas
71bff55c6a Fix iTunes album tracks failing on region-restricted releases
iTunes API can return collection metadata without song tracks for
region-restricted albums. The _lookup fallback only checked if results
was empty, so a collection-only response was accepted and cached as
{'items': []}. All future lookups returned the cached empty result.

Three fixes:
- get_album_tracks now checks for actual song items and tries fallback
  storefronts when only collection metadata is returned
- Skip cached results with empty items array (prevents stale cache hits)
- Backend returns descriptive 404 error, frontend surfaces it in toast
2026-04-20 11:10:54 -07:00
Broque Thomas
f5441c7992 Fix sync buttons showing on undiscovered LB/Last.fm playlists
The standalone mode handler ran every 10s on WebSocket status push and
set display='' on all sync buttons matching [id$="-sync-btn"], which
removed the display:none that kept undiscovered playlist sync buttons
hidden. Now tracks which buttons it hid via a data attribute and only
restores those, preserving the hidden state on undiscovered playlists.
2026-04-20 10:42:39 -07:00
Broque Thomas
21ae328955 Fix track ownership false positives, wing-it wishlist leak, debug counts
Track ownership: check-tracks endpoint now filters by album context
when provided, preventing false "Found" when a track exists in a
different album by the same artist (e.g. Thriller on HIStory).

Wing-it wishlist: manual "Add to Wishlist" button now skips wing-it
fallback tracks (wing_it_ ID prefix), matching the behavior of
failed download and failed sync paths.

Debug info: watchlist/wishlist/automation counts were always 0
because get_db() doesn't exist — fixed to get_database().
2026-04-20 10:17:47 -07:00
Broque Thomas
2f2e5ddbd0 Fix album track lookup hardcoded to Spotify on Artists page
The /api/artist/{id}/album/{id}/tracks endpoint was hardcoded to use
spotify_client and returned 401 if Spotify wasn't authenticated,
even when the user's primary source was Deezer or iTunes. Now uses
the configured primary metadata source via _get_metadata_fallback_client
with Spotify as fallback. Also gives a clearer error message when
no metadata source is available at all.
2026-04-20 09:15:09 -07:00