Closes#513 (s66jones).
The artist detail page already showed a "Popular on Last.fm" sidebar —
list of an artist's top tracks by playcount, with a play button per row
but no download action. Issue #513 wanted a way to grab those tracks
the same way zotify let users grab "top X songs" without pulling the
full discography.
Pulls from the configured primary metadata source (Spotify
`artist_top_tracks`, Deezer `/artist/{id}/top`) when available, falls
back to the existing Last.fm display-only mode for sources that don't
expose popularity ranking (iTunes / Discogs / MusicBrainz). Source
label in the section title shifts to match.
Each row gets a hover-revealed download button that wishlists the
single track via the existing /api/add-album-to-wishlist endpoint
(preserves the track's real album metadata, so the wishlist worker
later places the file in its proper album folder).
A "Download All" footer button opens the standard download modal in
PLAYLIST context, not album context — the virtual playlist_id is
`top_tracks_<source>_<artistId>` which doesn't match any of the
album-prefix checks in `startMissingTracksProcess` (downloads.js).
That keeps `is_album_download=false`, so the master worker doesn't
inject a wrapper context as `_explicit_album_context`. Each track
downloads using its own real album metadata, files land in proper
per-album folders on disk (not a fake "Top Tracks" folder).
Backend additions:
- `SpotifyClient.get_artist_top_tracks(artist_id, country, limit)` —
wraps `spotipy.artist_top_tracks`, returns up to 10 tracks for the
market (Spotify's API cap). UI-side limit trim only.
- `DeezerClient.get_artist_top_tracks(artist_id, limit)` — wraps
`/artist/{id}/top?limit=N`, converts Deezer's raw shape to the same
Spotify-compatible dict layout (id, name, artists, album with
album_type / total_tracks / images, duration_ms, track_number,
disc_number) so downstream code doesn't branch on source.
- `GET /api/artist/<id>/top-tracks` — dispatches to whichever client
matches the primary source. Resolves per-source artist IDs from the
DB row first (matching what /discography already does) so a Spotify
ID in the URL still works when Deezer is primary, and vice versa.
Returns `{success, source, tracks, resolved_artist_id}` on hit;
`{success: False, reason: 'unsupported_source' | 'spotify_not_authenticated'
| 'deezer_unavailable' | 'no_tracks_found'}` on miss so the frontend
can decide whether to fall through to Last.fm.
Frontend:
- `_loadArtistTopTracks` tries the metadata source first, falls
through to the legacy `/api/artist/0/lastfm-top-tracks` call if the
source can't deliver. Section title and per-row UI shift based on
which source answered.
- New per-row `.hero-top-track-download` button (hover-revealed).
- New `.hero-top-tracks-download-all` footer button — only visible
when metadata-source mode rendered the list (Last.fm fallback hides
it since rows have no track IDs to download).
Tests: 10 new tests pin the client methods —
- Spotify: returns track list, honors UI limit cap, returns empty when
unauthed / artist_id missing / API throws.
- Deezer: shape conversion to Spotify-compatible dict, empty when no
data / artist_id missing, limit clamping at upper bound, default
fallback when limit=0, malformed entries skipped.
The Flask endpoint dispatcher itself isn't covered by the new test
file because importing web_server at test-collection time spins up
worker threads that race with caplog-using tests elsewhere in the
suite (specifically test_library_reorganize_orchestrator). Endpoint
verified manually; the underlying client methods (the load-bearing
logic) are covered.
2204/2204 full suite green (was 2194 + 10 new).
Discord report: Duplicate Detector card said "372 findings" and Cover
Art Filler said "60 findings", but clicking the Findings tab's Pending
filter showed 0. User read it as "findings aren't being created" —
looked like a detector bug.
Actual cause: the badge sourced ``last_run.findings_created``
(historical "found in last scan") without considering current state.
After the user (or bulk-fix automation) resolved or dismissed those
findings, they no longer appeared on the Pending tab — but the badge
kept showing the last-scan number in red urgent styling.
Backend was correct end-to-end: detectors create pending rows,
bulk-fix moves them to resolved, Findings tab filters by status.
Only the badge display lied about current state.
Fix:
- ``RepairWorker._get_pending_count_by_job()`` — single SQL aggregation
returning ``{job_id: pending_count}`` for every job with pending
findings. O(1) lookup per job instead of N round trips.
- ``get_all_job_info()`` calls it once per request and adds
``pending_findings_count`` to each job's API response.
- ``enrichment.js`` job card now branches on the count:
- ``> 0`` → red ``"X pending"`` badge (urgent, action needed)
- ``= 0`` AND last scan found something → muted grey ``"X found in
last scan"`` (historical context, no action needed)
- New CSS class ``.repair-flow-badge.findings-historical`` for the
muted slate color so the two states are visually distinct.
User-visible result with the screenshotted state (372 dup / 60 cover-
art findings, all resolved):
- Before: red "372 findings" / "60 findings" — implied 432 things to
do, but Findings tab showed 0 pending
- After: grey "372 found in last scan" / "60 found in last scan" —
the badge text tells the user the count is historical, no surprise
when Pending is empty
Tests: 3 new tests in ``tests/test_create_finding_dedup_counter.py``
pin the per-job pending count helper:
- returns ``{job_id: count}`` based on status='pending' rows only;
resolved + dismissed rows excluded
- empty dict when no pending findings exist
- gracefully returns ``{}`` on DB error (badge falls back to
historical count via the existing JS ``or 0`` safety)
2188/2188 full suite green. Pure UI/state-display fix — no detector
logic, no backend behavior change.
Discord request (Samuel [KC]): show how much disk space the library
takes on the Stats page. Implementation piggybacks on the existing
deep scan — Plex/Jellyfin/Navidrome all return file size in their
track API responses, so we read it during the deep scan and store
it on the tracks row. Aggregation is then a single SQL query — no
filesystem walk, no extra I/O during the scan, no separate stat
job. SoulSync standalone gets size from os.path.getsize at insert
time (different code path; the file is local when we write the row).
Schema (`database/music_database.py`):
- New `file_size INTEGER` column on `tracks`. Migration uses the
established `try SELECT, except ALTER TABLE ADD COLUMN` pattern.
Idempotent; safe on existing installs. NULL on legacy rows so
they don't contribute to totals until next deep scan refreshes.
- Added the column to the canonical CREATE TABLE so fresh installs
get it without going through the migration path.
Track-object plumbing:
- `core/jellyfin_client.py` — JellyfinTrack reads MediaSources[0].Size
alongside existing Bitrate read. None when 0 / missing.
- `core/navidrome_client.py` — NavidromeTrack reads `size` from
the Subsonic song object (int coercion + None on parse fail).
- `core/soulsync_client.py` — SoulSyncTrack does os.path.getsize
(only "server" where size has to come from disk).
- Plex needs no client-side change: track.media[0].parts[0].size
is read directly inside insert_or_update_media_track.
Persistence — TWO separate insert paths:
(a) `database/music_database.py:insert_or_update_media_track` —
Plex/Jellyfin/Navidrome flows. Reads file_size from Plex's
MediaPart OR `track_obj.file_size` wrapper attribute (defensive
Plex-attr-not-present check + > 0 type guard).
INSERT writes the new column.
UPDATE uses COALESCE(?, file_size) so a None from the server
on a re-sync (rare Jellyfin Size omission) doesn't blank an
existing value. Pinned via test.
(b) `core/imports/side_effects.py:record_soulsync_library_entry` —
SoulSync standalone flow. Completely separate code path: the
standalone deep scan moves files to staging for auto-import
rather than calling insert_or_update_media_track. After the
auto-import processes them, side_effects writes the tracks row
directly. Reads file_size via os.path.getsize(final_path) at
insert time (file is local) and includes it in the INSERT
column list. SoulSync only does INSERT-if-not-exists (no
UPDATE path), so no COALESCE concern.
Aggregator (`database/music_database.py:get_library_disk_usage`):
- SELECT COALESCE(SUM(file_size), 0), COUNT(file_size),
COUNT(*) - COUNT(file_size) for the totals.
- Per-format breakdown done in Python via os.path.splitext over
(file_path, file_size) rows — sidesteps SQLite's first-vs-last-dot
ambiguity for paths like /music/Kendrick/M.A.A.D City/01.flac.
- Defensive: skips empty paths, paths without extension, and
implausibly long extensions (>6 chars). Returns the full
empty-shape dict (NOT a partial / undefined) when the column
doesn't exist or queries fail, so the UI's `if (!data.has_data)`
branch handles fresh installs cleanly.
API + UI:
- `core/stats/queries.py` — thin pass-through get_library_disk_usage
matching the existing query-helper convention.
- `web_server.py` — new /api/stats/library-disk-usage endpoint
mirroring the /api/stats/db-storage pattern.
- `webui/index.html` — new card in System Statistics above the
Database Storage card.
- `webui/static/stats-automations.js` — _loadLibraryDiskUsage +
_renderLibraryDiskUsage. Empty state: "Run a Deep Scan to
populate (X tracks pending)". Partial: "X measured (+Y pending)".
Full: total + format bars proportional to the largest format.
- `webui/static/style.css` — .stats-disk-* styled to match the
Database Storage card.
Backward compatibility:
- Migration is additive; existing rows get NULL file_size; the
empty-shape return from the aggregator means the UI renders
cleanly without errors before any deep scan runs.
- Old installs upgrading will see "Run a Deep Scan to populate
(N tracks pending)". Running their next deep scan fills sizes —
the existing scan flow doesn't need any changes, just consumes
the new track-wrapper attribute.
Tests:
- `tests/test_library_disk_usage.py` — 13 cases covering schema
migration, NULL defaults on legacy inserts, fresh-install empty
shape, summing with mixed NULL/known sizes, per-format breakdown,
mixed-case extensions, paths with album-name dots, missing
extensions, empty file_path, implausibly long extensions,
JellyfinTrack.file_size persistence via insert_or_update_media_track,
COALESCE preservation on null re-sync.
- `tests/imports/test_import_side_effects.py` — extended the
existing record_soulsync_library_entry test to assert
track_row['file_size'] == os.path.getsize(final_path), pinning
the SoulSync-standalone path. Test fixture's tracks schema also
updated to include the file_size column.
Verified: full suite 1813 pass (13 new, 1 existing-test extension),
ruff clean, smoke test populating + reading the column round-trips
correctly.
WHATS_NEW entry under '2.4.2' dev cycle.
The 'Live Per-Track Progress' work shipped a backend in-progress row + top-of-tab
progress text but the history cards themselves stayed visually stale during
processing — lowercase "processing" badge, neutral styling, no per-track hint.
Smoke-testing also surfaced two latent identification bugs that prevented
multi-disc rips with features (Kendrick GKMC Deluxe) from importing at all.
Card-level live progress (`webui/static/stats-automations.js`):
- Cache `/api/auto-import/status` response in `_autoImportLastStatus`; poller
awaits status before re-rendering results so the card has the live data.
- Add 'processing' entries to statusLabels / statusIcons / statusClass.
- When card folder_name matches `current_folder`, swap the meta line to
`track N/M: <track name>` and tag the matching row in the expanded list
as `auto-import-track-row-active`; prior rows tag as `-row-done`.
Card styling (`webui/static/style.css`):
- `.auto-import-processing` blue left border, `.auto-import-badge-processing`
pulse animation, active/done track-row classes.
Multi-disc enumeration (`core/auto_import_worker.py:_scan_directory`):
- Old code skipped disc folders during recursion AND only attached them to a
parent that had its own loose audio. A folder containing only `Disc 1/`,
`Disc 2/` was invisible. Now: when a directory has only disc subdirs and no
loose audio, treat that directory itself as the album candidate. Disc folders
still skipped when standing alone.
- Add `FolderCandidate.is_staging_root` flag (set when the staging dir itself
becomes the candidate via this path) so identification can refuse to use the
meaningless folder name.
Tag identification (`core/auto_import_worker.py:_identify_from_tags`):
- Per-track `artist` tag fragmented consensus on albums with features
("Kendrick Lamar" / "Kendrick Lamar, Drake" / "Kendrick Lamar, Dr. Dre"
produced 3 separate `(album, artist)` keys for one album). Now group by
album first, then pick the most-common artist within that album group.
- `_read_file_tags` now prefers `albumartist` over `artist` for album-level
identity; falls back to `artist` for files without albumartist.
- Add INFO-level log when tag identification rejects, showing top albums and
their counts so the user can diagnose multi-disc / tagging issues.
Folder-name false-match guard (`core/auto_import_worker.py:_identify_folder`):
- When `is_staging_root` is set, skip the folder-name strategy entirely. Logs
the skip and falls through to AcoustID. Without this, dropping disc folders
directly into staging caused the scanner to search the metadata source for
the literal name "Staging", which false-matched against random albums (e.g.
"Stamina, Dinos" — a French rap album — at 13% confidence).
What's New entries added under 2.4.2 dev cycle.
- Keep the sidebar and dashboard service cards neutral until the first /status payload arrives
- Prevent placeholder source names and card text from flashing on dashboard load
- Reveal the real service status only after the live snapshot populates the UI
Artist-detail is a "pseudo-page" reachable from Library, the unified
Search page, and the global search popover. It has no [data-page]
match in the sidebar, so navigateToPage's bulk-active-removal left
every nav button unhighlighted while the user was viewing an artist —
the sidebar offered no visual anchor for where they were.
Now:
- navigateToPage('artist-detail') falls back to highlighting the
Library button when no [data-page] match exists, anchoring the
sidebar to the canonical home for artist detail views.
- A new _updateSidebarLibraryBreadcrumb() helper rewrites the Library
button label between plain "Library" and a "Library / Artist Name"
breadcrumb based on currentPage + artistDetailPageState. Long names
(>14 chars) truncate with an ellipsis; the full name shows on hover
via the title attribute.
- Called from navigateToPage (entering / leaving the page) and from
loadArtistDetailData (covers same-page artist switches in the
similar-artist chain where currentPage stays 'artist-detail').
CSS adds .nav-text-root / .nav-text-sep / .nav-text-context selectors
so the "Library" anchor word stays visually dominant while the
separator and artist name dim to a secondary tier — readable but not
competing for attention.
Pure visual change. No backend touched. No new tests (DOM-only).
Replaces the single-slot "one reorganize at a time, return 409 on collision"
model with a per-user FIFO queue. Buttons stay clickable, "Reorganize All"
is one backend call instead of an N-call JS loop, and a status panel mounted
at the top of the artist actions bar shows live progress (active item,
queued count, recent completions) with per-item cancel buttons.
Backend
- core/reorganize_queue.py: singleton queue + worker thread, dedupe-on-
enqueue, cancel rules (queued cancellable, running not), enqueue_many
for bulk operations, progress fan-out via update_active_progress
- core/reorganize_runner.py: factory builds the worker's runner closure
with injected dependencies. Reads config per-call so changing the
download path in Settings takes effect on the next reorganize without
a server restart
- database/music_database.py: get_album_display_meta and
get_artist_albums_for_reorganize — moves the SQL out of route handlers
- web_server.py: thin enqueue/snapshot/cancel/clear endpoints, runner
registration at module load. Old _reorganize_state globals + status
endpoint deleted. Static-asset cache buster (?v=<server-start>)
added so JS/CSS updates ship live without users clearing cache
Frontend
- webui/static/library.js: status panel mount, polling (1.5s when
active, 8s when idle), expand/collapse, per-item cancel, debounced
enhanced-view reload (one reload per artist batch instead of N).
Per-album reorganize button paints with queued/running indicator
and short-circuits to a toast when the album is already in queue
- webui/static/style.css: panel + button styling matching the existing
glass-UI accents
- webui/static/helper.js + version modal: WHATS_NEW entry
Tests (22 new)
- tests/test_reorganize_queue.py (19 tests): FIFO order, dedupe,
per-item source, cancel rules, continue-on-failure, snapshot
shape, progress propagation, bulk enqueue
- tests/test_reorganize_runner.py (4 tests): per-call config reads,
setup-failure summary, dependency injection, progress fan-out
- tests/test_reorganize_db_methods.py (7 tests): SQL JOIN behavior,
ordering, fallback for blank strings, artist isolation
Full suite 549 passed in 27s.
Reported on Discord by winecountrygames. The library "Reorganize" tool
had several layered bugs that all traced to the same root cause: the
endpoint reinvented every wheel post-processing already turns — its own
template engine, its own disc-number resolution from file tags, its own
sidecar sweep, its own collision detection — and each had drifted from
the canonical path used by fresh downloads. Reported symptoms:
- 3-disc Aerosmith deluxe collapsed to a flat single-disc layout
- Half the tracks on other albums silently skipped, no error / no count
- Re-runs left empty leftover album folders cluttering the artist dir
Architecture: stop reinventing wheels. Route reorganize through exactly
the same pipeline downloads use. Per-album:
1. Fetch the canonical tracklist from a metadata source (Spotify /
iTunes / Deezer / Discogs / Hydrabase) using the album's stored
source IDs. New `core/library_reorganize.py::plan_album_reorganize`
does this — primary-source-first, fall through priority chain
unless the user picked a specific source in the modal (strict mode).
2. For each local track, find the matching API entry via a scored
candidate matcher. Score components: exact-title (100),
substring-with-length-ratio (40-90), track-number agreement (20).
Hard reject when the two titles have different version
differentiators (Remix vs no-remix means different recordings,
not annotation drift). Below threshold = unmatched, surfaced as
"not in source's tracklist, left in place" rather than silently
mis-routing.
3. Copy the file to a per-album staging directory, build the same
context dict the import flow builds (`spotify_album` /
`track_info` / etc. with `is_album_download=True` so the path
builder enters ALBUM mode, not SINGLE mode), call
`_post_process_matched_download(...)` — same function fresh
downloads use. Post-process handles tagging, multi-disc subfolder
decisions, sidecar regeneration, AcoustID verification.
4. Read `context['_final_processed_path']` to learn where it landed.
Update `tracks.file_path` in the DB BEFORE removing the original
(DB-update failure leaves the file at both locations, recoverable
via library scan; the reverse would orphan the row). Delete
per-track sidecars (post-process recreates them at the new
destination).
3 concurrent workers per album via ThreadPoolExecutor, matching the
download path's per-batch worker count. State mutations all guarded by
a single lock; staging filenames carry a UUID prefix so concurrent
copies of identically-named source files don't overwrite each other.
Source picker in the modal lets the user choose which source to read
the tracklist from. Two endpoints feed it:
- `/api/library/album/<id>/reorganize/sources` — sources for THIS
album that are both authed AND have a stored ID. For the per-
album modal.
- `/api/library/reorganize/sources` — all authed sources globally.
For the bulk "Reorganize All" modal where per-album ID coverage
varies.
When the user picks a specific source, the orchestrator runs in
`strict_source=True` mode (no fallback chain) — picking Spotify means
"use Spotify or fail", not "use Spotify and silently fall back."
Preview endpoint shares the same planning logic as apply via
`preview_album_reorganize` — the destination path comes from the same
`_build_final_path_for_track` post-process uses, so what you see in
the preview is exactly what you get on apply.
Empty destination folders (from earlier failed runs OR from the
current run when post-process creates a dir then fails AcoustID)
get cleaned up after each successful run: walk up to the artist
folder from any successful destination, prune empty album-sibling
folders one level deep. Bounded scope = won't touch unrelated user
dirs.
Web_server.py shrinks by ~450 net lines. The endpoint handler is now
a thin wrapper that builds injected callables (path resolver, post-
process function, DB updater, empty-dir cleaner), spawns a thread
that calls `reorganize_album()`, and returns. All actual logic lives
in `core/library_reorganize.py` where it's unit-testable without
spinning up Flask.
Frontend cleanup: the per-call template input in both reorganize
modals (per-album and bulk) was redundant — the backend always uses
the configured global download template. Removed the input and the
variables-grid reference UI it was for.
39 new unit tests pin every contract:
- source resolution (no_source_id when album has none, fallthrough
chain when primary returns nothing, strict mode bypasses fallback)
- matcher scoring (exact / substring / multi-disc disambiguation /
smart-quote tolerance / dash-vs-parens / bonus-track substring /
Remix-vs-original differentiator rejection / "Real" doesn't false-
match "Real Real Real" / track-number-only no longer fires)
- file safety (DB-update failure leaves original in place, post-
process failure leaves original in place, post-process exception
caught and original preserved, success removes original AND
updates DB in the right order)
- sidecar handling (per-track .lrc/.nfo deleted on success, kept on
failure; album-level cover.jpg/folder.jpg cleaned only when
directory has no remaining audio)
- staging cleanup (recreated between tracks because post-process
nukes it, dir cleaned up on success AND on failure)
- destination-dir prune (empty siblings removed, real album with
files preserved, no recursive sweep)
- source picker (only authed-with-stored-ID sources for per-album,
all authed sources for bulk; strict mode doesn't fall back)
- concurrency (3 workers in flight, state stays consistent under
races, stop_check cuts off pending tasks)
- preview parity (preview produces same destination as apply for
multi-disc; ALBUM mode not SINGLE mode; unmatched/no-path tracks
surfaced with reasons)
Limitations (deliberate punts, NOT in this PR):
- Renamed local titles on multi-disc albums where track_number
also disagrees: matcher returns nothing (track is "not in
source"). Fixable by using duration_ms as a tertiary signal.
- Per-track in-modal source switching with per-album track-count
hints (would need a second API call before opening the modal).
- UI status panel on the artist page during a run — currently
just toasts. Documented as a follow-up PR.
Files:
- core/library_reorganize.py — new module: plan_album_reorganize,
preview_album_reorganize, reorganize_album, available_sources_for_album,
authed_sources, _score_candidate, helpers for staging/post-
processing/finalizing, sidecar + dest-dir cleanup
- core/metadata_service.py — no changes; reused get_album_for_source,
get_album_tracks_for_source, get_source_priority,
get_client_for_source
- web_server.py — three endpoints (preview / apply / sources GETs)
are thin wrappers; -450 net lines
- tests/test_library_reorganize_orchestrator.py — 39 tests covering
every contract above
- webui/static/library.js — source picker UI in both modals; dead
template input + variables-grid removed
- webui/static/style.css — dropdown option styling fix (white-on-
white was unreadable)
Reported on Discord by winecountrygames — his bug report named the
trigger button (Enhanced view → Reorganize All) and both symptoms
(multi-disc collapse, half-album skip), which let the diagnosis go
straight to the architectural problem.
The source-picker refactor introduced a new stable DOM structure inside
`#gsearch-results`:
<div id="gsearch-results"> <!-- max-height: 60vh, flex-col -->
<div id="gsearch-source-row" /> <!-- icon row, controller-rendered -->
<div id="gsearch-fallback-banner" />
<div id="gsearch-body" /> <!-- surface renders results here -->
</div>
But the companion CSS never landed. `#gsearch-body` had default block
layout, so when results exceeded the 60vh cap, they clipped silently
instead of scrolling. The old structure had `.gsearch-results-body`
with `overflow-y: auto; flex: 1` directly inside the panel; that rule
still exists but its selector now matches a nested div with no flex
parent, so `flex: 1` is a no-op and overflow doesn't trigger.
Fix: give the three stable children the right flex behaviour so the
body fills remaining space and scrolls.
- `#gsearch-source-row` and `#gsearch-fallback-banner` stay at natural
height (flex-shrink: 0).
- `#gsearch-body` grows (flex: 1 1 auto), can shrink below content
height (min-height: 0 — this is the critical bit, otherwise flex
items won't shrink below their intrinsic size and overflow never
triggers), and scrolls vertically.
Styled scrollbar matches the rest of the panel (4px, translucent thumb).
Cin flagged two related UX issues during PR review:
1. The "Show Results / Hide Results" toggle next to the search bar served
no real purpose — there was nothing else on the Search page worth seeing
instead of results, so toggling visibility was always pointless overhead.
2. Navigating away from /search via a sidebar link dismissed the dropdown
(the click was caught by the outside-click handler). Coming back left
the input populated but the results hidden, requiring a Show Results
click or a fresh search. The cached state was intact in the controller
the whole time — just not rendered.
Both fixed by the same direction: dropdown visibility becomes a pure
function of query state, never user-toggleable. The closure now exposes
`_searchPageRestoreOnEnter` so subsequent calls to `initializeSearchModeToggle`
re-render from the controller's cached state instead of early-returning.
Removes the button HTML, click handler, `updateToggleButtonState` function,
the desktop + responsive CSS for `.enhanced-search-btn`, and the orphaned
`.btn-icon` rule. Net -94 lines.
Adds a subtle radial glow at the bottom of the viewport that emanates
from the floating search bar, fades outward toward both window corners,
and shrinks vertically as it moves away from the bar. Makes the bar
easier to spot at a glance without a heavy full-width bar or a chrome
strip.
- New `.gsearch-aura` fixed element, 260px tall, full width, pointer
events off. Radial-gradient with the accent color centered at the
bottom middle; colour stops taper 620x230px by default, ramping to
820x280px and brighter when the bar is focused/active.
- `_gsUpdateVisibility` hides the aura on /search alongside the bar
via a simple `.hidden` class.
- Focus handler adds `.active` to the aura in step with the bar;
`_gsDeactivate` removes it. z-index 99990 (below the bar at 99998,
above most page content).
When a user types an artist name into the library search and gets no
hits, the old empty state just said "No artists found — try adjusting
your search or filters." Dead end for the common case of "I searched
for someone I don't own yet."
The empty state now detects when libraryPageState.currentSearch is
non-empty and swaps in a CTA that hands the query off to /search:
"kendrick" isn't in your library
They might be available on a connected metadata source.
[🔍 Search online for "kendrick" →]
Clicking the button navigates to /search, pre-fills the enhanced search
input, and dispatches an input event so the existing debounced search
fires automatically. Uses the same hand-off pattern _gsNavigateToSearchPage
already uses for Soulseek, so the Search page's source-picker flow
picks up naturally from there.
No change to the generic empty state (no query active) or to any other
library page behaviour.
The picker used to render every source whether or not the user had
credentials for it. Clicking Discogs with no token, Hydrabase with no
URL, or Spotify with nothing saved would fire a doomed fetch — at best
a silent empty state, at worst a confusing fallback to another source.
Now the picker reads /api/settings/config-status (the same endpoint the
Settings → Connections page already uses for the green/yellow status
dot) on init and dims icons whose service isn't set up. Clicking a
dimmed icon navigates to Settings → Connections and scrolls to the
relevant service card with a brief accent-coloured pulse to orient
the user.
Sources the backend's SERVICE_CONFIG_REGISTRY doesn't cover
(musicbrainz, youtube_videos, soulseek) are permanently treated as
configured — they need no user credentials, so dimming them would
mislead.
Extra guard: if the user's configured primary metadata source is
itself unconfigured (Spotify saved as primary but no client_id yet),
`_initDefaultSource` falls forward to the first configured source so
the default active icon is never a "set up" chip.
Shared helpers:
- fetchSourceConfiguredMap() centralizes the config-status lookup for
both surfaces. Falls back permissively if the endpoint fails so the
picker never stops working over a network hiccup.
- openSettingsForSource(src) navigates to Settings → Connections and
scrolls to `[data-service=src]`, pulsing a 2.2s accent flash
(.stg-service-flash) so the user doesn't lose their place.
CSS:
- .unconfigured: 42% opacity, 0.7 grayscale filter, subdued hover
state with no transform/glow (feels "look but don't touch"),
defensive override to kill brand glow if somehow active.
- @keyframes stg-service-flash-anim for the scroll-to highlight.
The global search popover already draws its own frosted-glass panel
(via .gsearch-results), so putting another bordered/gradient container
around the source icons inside it read as "panel inside a panel" —
visually noisy and left a dark empty strip on the right when the row
didn't fill the popover width.
Strip the source row's own background/border, center-align the chips
(justify-content: center) so they stay grouped instead of drifting to
the left, and keep a subtle bottom divider so the icons still read as
a distinct control group above the results.
Dresses up the bare chip row so the picker reads as a deliberate piece of
UI rather than a utility bar. Both the Search page (.enh-source-*) and
the global widget (.gsearch-source-*) get the same treatment.
- The row itself is now a frosted-glass panel (subtle white gradient,
inner highlight, rounded 14px / 12px corners, outer shadow) so the
picker feels unified instead of a loose strip of buttons.
- Chips bumped: min-width 90px (row) / 72px (widget), bigger padding,
12px rounded corners, subtle linear gradient top-to-bottom, 30px icons
(up from 22px) with a drop-shadow for depth.
- Hover lifts the chip by 1px with a darker drop-shadow and brighter
border — cheap but effective microinteraction.
- Active state is brand-themed per source: the chip's background
becomes a top-weighted gradient in the service's colour, the border
matches, and an outer brand-coloured glow (6-22px blur) surrounds it.
scale(1.03) pops it above neighbours. Label bumps to 700 weight when
active. Same treatment for Spotify / Apple Music / Deezer / Discogs /
Hydrabase / MusicBrainz / Music Videos / Soulseek.
- Cache dot gets a brand-coloured glow and a subtle 2.4s pulse so the
"already fetched this query" hint is visible without being loud.
- Fallback-warning icons get an amber tint on both border and outer
ring to match the existing fallback banner colour.
Three follow-up fixes after browser testing:
1. Clicking a source whose results are already cached was closing the
results dropdown. The outside-click handler treated the icon click
as "outside" because the icon row lives above the input wrapper, not
inside it. The icon click handler now calls stopPropagation so the
document handler never runs. Also added an `#enh-source-row`
whitelist to the search-page outside-click handler as a second
layer of defense.
2. The icon chips used generic emojis (🎵, 🍎, 🎶, etc.) which don't
convey brand identity. SOURCE_LABELS now carries a `logo` URL per
source (mirroring the existing constants in core.js): the real
Spotify / Apple Music / Deezer / Discogs / MusicBrainz / Hydrabase /
Soulseek brand logos render as <img> inside the chip. Music Videos
stays on emoji since the codebase has no YouTube-specific logo
constant. renderSourceRow (Search page) and _gsSourceRowHtml (global
widget) both honor the new field; loading state still overrides
with an hourglass.
3. When Soulseek was selected, the icon row appeared clipped at the
top of the page. Caused by the flex parent (.downloads-main-panel)
compressing the row when .search-section.active competes for space
with flex-grow:1. Added `flex-shrink: 0` + explicit `overflow-y: visible`
on both .enh-source-row and .gsearch-source-row so the row keeps
its natural height even under layout pressure. Logo <img> elements
got explicit 22x22 / 18x18 containers so they render at chip scale
without the inline font-size hack.
Matches the Search page redesign so both surfaces behave identically.
The sidebar popover previously always fan-out-fetched all sources on
every keystroke (via _gsFetchSourceStream streaming NDJSON for every
alternate) and exposed a post-search tab bar to switch views.
Now:
- The popover renders an always-visible source icon row at the top, one
icon per source (Spotify, iTunes, Deezer, Discogs, Hydrabase,
MusicBrainz, Music Videos, Soulseek).
- Typing fetches only the currently-selected source. No fan-out.
- Clicking a different icon: cache hit -> instant re-render; cache miss
-> single-source fetch + render.
- Per-query cache cleared on query change; cache dots on icons show
which sources already have results for the current query.
- Default active source read from /api/settings (metadata.fallback_source)
on first focus; falls back to Spotify.
- Fallback banner shown when the backend served a different source than
the one clicked (rate-limit auto-fallback).
- Soulseek icon click navigates to /search with the query pre-filled,
since the raw file list doesn't fit the popover. The Search page
takes over rendering from there.
Gone: _gsFetchSourceStream (fan-out), _gsRenderTabs, _gsSwitchSource,
_gsState.altAbortCtrl, per-section _loading sets.
Added: _gsInitDefaultSource, _gsFetchSource, _gsFetchYouTubeVideos,
_gsSourceRowHtml, _gsFallbackBannerHtml, _gsSetActiveSource,
_gsNavigateToSearchPage.
The Search page previously fired a primary /api/enhanced-search request
plus a fan-out loop (_queueAlternateSourceFetches / _fetchAlternateSource)
that streamed NDJSON from /api/enhanced-search/source/<src> for every
other configured source. One search = 7 API calls across Spotify, iTunes,
Deezer, Discogs, Hydrabase, MusicBrainz, and YouTube Music Videos. The
post-search tab bar then let users switch views between the results that
had already been fetched.
This changes the default to explicit per-source selection:
- The old <select id="search-source-select"> dropdown and the
<div id="enh-source-tabs"> post-search tab bar are replaced by a
single always-visible icon row (#enh-source-row) above the search
bar. One button per source, horizontal-scroll on narrow screens.
- Typing fetches only the currently-selected source. No fan-out.
- Clicking a different icon switches to that source and fetches it
on demand, unless results for this query are already cached.
- Per-query cache (Map keyed by source) is cleared whenever the query
changes; cached icons show a small dot, loading icons show a spinner.
- Soulseek is a first-class icon in the row — selecting it routes to
the existing raw-file basic search, no change to that renderer.
- YouTube Music Videos is its own icon, still uses the NDJSON stream
endpoint for incremental rendering.
- Default active icon reads metadata.fallback_source from /api/settings
on init; falls back to Spotify.
- Rate-limit fallback (backend serves Deezer when Spotify is banned)
surfaces as an amber banner above results plus an amber border on the
clicked icon, so users understand why the returned results don't
match the source they picked.
SOURCE_LABELS in shared-helpers.js gains an 'icon' field per source and
a new SOURCE_ORDER constant for the canonical picker order. The fan-out
functions (_queueAlternateSourceFetches, _fetchAlternateSource,
renderSourceTabs, window._switchEnhSourceTab) are gone.
Backend untouched — POST /api/enhanced-search already supported a
`source` param for single-source mode; we were just never using it by
default. Global widget redesign to match is the next commit.
The sticky .sidebar-header had two layered issues that let nav items
show through it while the user scrolled the sidebar:
- its background was a single linear-gradient of rgba() stops,
starting at ~14% accent on transparent — the upper portion of the
header was effectively translucent
- .sidebar > * sets z-index 1 on every sidebar child, so the header
and the nav buttons share a stacking level. Sticky alone doesn't
lift the header; with equal z-index the nav wins on DOM order
Layer the existing accent gradient over a solid rgb(18, 18, 18) base
(visual unchanged, fully opaque), and bump the header to z-index 2 so
it paints above the nav buttons as they scroll under it.
The standalone /artist-detail page rendered releases via createReleaseCard
in a stacked layout: square image on top, then title, then year, then a
completion bar — all inside a 300px-tall card with internal padding. The
inline Artists page (now retired) used a richer treatment: full-bleed
artwork with a dark gradient overlay and the title + year pinned at the
bottom. This commit brings that look to the standalone page.
Card markup (still .release-card so all the existing JS filter +
state hooks work, plus .album-card for the visual):
<div class="release-card album-card" ...>
<div class="album-card-image" data-bg-src="..."></div>
<div class="completion-overlay [state]">
<span class="completion-status">...</span>
</div>
<div class="album-card-content">
<div class="album-card-name">title</div>
<div class="album-card-year">year</div>
</div>
[optional .mb-card-icon]
</div>
Image loads lazily via the existing observeLazyBackgrounds /
data-bg-src plumbing in core.js — call moved into populateRelease-
Section so each batch of new cards gets observed.
Completion overlay (top-right floating badge):
- Library artists: 'Checking…' / '✓ Owned' / 'N/M' / 'X%' / 'Missing'
based on release.owned + track_completion shape (existing logic
preserved, just rendered as a badge instead of a bar).
- Source artists (no library data): omitted entirely. The card just
shows artwork + title + year, which is what the user asked for.
CSS: scoped overrides under #artist-detail-page .release-card.album-card
neutralize the old release-card background gradient, internal padding,
fixed 300px height, and flex column layout. Cards become aspect-ratio:1
square with overflow:hidden so the image fills and the gradient + text
sit on top.
Filter state (data-is-live / data-is-compilation / data-is-featured)
still tagged on each card so the Include filter group keeps working.
Smoke: library Kendrick Lamar should now look like the inline Artists
page used to — square cards, big artwork, name + year on the bottom.
Source-clicked artist (Schoolboy Q from Deezer) shows the same
visual without the completion overlay.
Three issues from screenshots:
1. .collection-overview was hidden for source artists (CSS rule too
aggressive). It actually renders fine — just shows 0/N "missing"
for each release type, which is useful info. Removed from the hide
rules.
2. #artist-hero-sidebar (Top Tracks "Popular on Last.fm") was also
hidden. The renderer (_loadArtistTopTracks in library.js) already
fetches by artist name via /api/artist/0/lastfm-top-tracks, so it
works for source artists too. Removed from the hide rules.
3. Clicking a source-artist result for someone you ALREADY have in
the library was loading the bare source view instead of the
library view (bug). Backend now does a "library upgrade" lookup
in get_artist_detail: when the direct ID lookup misses but a
source param is provided, search the artists table by the source-
specific ID column (deezer_id / spotify_artist_id / etc.). If a
match exists, use that library PK and the rest of the library
path runs normally — owned releases, enrichment, completion
bars, all the goodies you'd see if you'd clicked from Library.
Falls back to a name match within the active server, then to the
source-only response if nothing matches.
The remaining library-only items (artist-enrichment-coverage, Radio
button, Enhance Quality button) stay hidden for source artists since
they all require owned tracks.
Completes the artist-detail unification. Source artists now land on
the same /artist-detail page as library artists (with the source-aware
backend endpoint from earlier this session handling the data fetch).
The inline Artists page is gone — artists.js deleted, #artists-page
HTML block removed, /artists URL aliases to /search.
Source-artist callsites re-migrated from selectArtistForDetail to
navigateToArtistDetail (search results, global widget, download
modal, Discover hero / Your Artists cards / artmap context / genre
deep-dive, watchlist artist detail).
Visual upgrade to standalone hero: added .artist-detail-hero-bg +
.artist-detail-hero-overlay (blurred image bg, dark gradient — same
treatment as the inline page). library.js sets the bg image when
loading an artist.
Library-only UI hidden via CSS for source artists (existing rules
from the previous commit cover Enhanced toggle, Status filter,
completion bars, enrichment coverage, Top Tracks sidebar, Radio /
Enhance buttons).
Final 2 helpers (lazyLoadArtistImages used by wishlist-tools,
showCompletionError used by completion checker) moved from
artists.js into shared-helpers.js. The inline-page candidate set
was dropped from _resolveSimilarArtistsTargets.
init.js: 'artists' alias added at top of navigateToPage (same
pattern as the existing 'downloads' alias). 'case artists:' handler
removed from loadPageData. _getPageFromPath now maps artist-detail
to library as its parent (matches the existing nav highlight at
init.js:2161).
tests/test_script_split_integrity.py: artists.js removed from
SPLIT_MODULES; KNOWN_CROSS_FILE_DUPES updated to point escapeHtml
at shared-helpers.js instead of artists.js. 354/354 tests pass.
Net delta: -1700 lines.
Stays at 2.39. Once you've verified end-to-end (library artist ->
hero looks like inline visual; source artist from Search -> same
page, similar artists works, no 404s; /artists URL -> /search), a
follow-up commit bumps to 2.40 with the full WHATS_NEW entry that's
already prepped.
First increment of the artist-detail unification redesign. Delivers
the two most-visible missing pieces for source artists without touching
the hero layout — that's a later commit.
Changes:
- HTML: new #ad-similar-artists-section inside #artist-detail-main
(scoped IDs with 'ad-' prefix so they don't collide with the inline
Artists page, which has the same section using base IDs).
- shared-helpers.js: similar-artists helpers (loadSimilarArtists +
display/progressive/createBubble + lazy image loader) moved out of
artists.js. New _resolveSimilarArtistsTargets() resolver picks
whichever candidate set has a `.page.active` ancestor, so the same
function works on both the inline Artists page and the standalone
artist-detail page without caller changes.
- library.js populateArtistDetailPage: sets
document.body.dataset.artistSource = 'library' | 'source' before
rendering, and fires loadSimilarArtists(artist.name) after
populating the rest of the page.
- style.css: body[data-artist-source='source'] rules hide
library-only UI on the artist-detail page — Enhanced view toggle,
Status (owned/missing) filter, completion bars, enrichment
coverage, Top Tracks sidebar, Radio / Enhance Quality buttons,
"X owned / Y missing" section-stats counts. CSS-only, additive,
library artists completely unaffected.
Impact today:
- Library artists: Similar Artists section now appears at the
bottom of their detail page (previously only the inline Artists
page had it). All other UI unchanged.
- Source artists: still route to the inline Artists page (Part B
reverted earlier this session). The standalone page is now
source-ready infrastructure-wise, but source artists don't reach
it yet. A later commit will re-migrate source callers to the
standalone page once the hero rendering is also source-ready.
artists.js shrinks from 1903 -> 1584 lines (similar-artists block
extracted). shared-helpers.js grows correspondingly. 357/357 tests
still pass. No version bump — this is still 2.39 pending.
Phase 3c of the Search/Artists unification. The Search page carried
a second copy of the Download Manager (active + finished queues,
clear/cancel-all buttons) that was hidden by default and duplicated
the dedicated Downloads page. That duplicate is now gone.
Removed:
- Side-panel HTML block and the toggle button that showed/hid it
- ~290 lines of polling + render infra in downloads.js: loadDownloads-
Data, startDownloadPolling/stopDownloadPolling, updateDownload-
Queues, renderQueue, updateTabCounts/updateDownloadStats,
initializeDownloadTabs/switchDownloadTab, cancelDownloadItem,
clearFinishedDownloads, cancelAllDownloads, and the
activeDownloads/finishedDownloads globals
- initializeDownloadManagerToggle and its call from init.js
- Stopped hitting /api/downloads/status every second on the Search
page (the dedicated Downloads page already polls its own view)
CSS grid for the Search page collapsed from '1fr 370px' to '1fr' now
that the right panel is gone. Unused .controls-panel__* / .download-
manager__* / .downloads-side-panel CSS rules kept in place — harmless,
can be pruned later.
Phase 3 of the Search/Artists unification. The Search page's two-mode
toggle is replaced by a single 'Search from' dropdown: All sources
(Auto), Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz,
or Soulseek (raw files). Auto keeps today's fan-out behavior for
backwards compatibility; picking a specific source hits only that
provider. 'Soulseek' routes to the raw-file basic section, so one
picker covers both old modes. Loading text and the enhanced fetch
now respect the selected source. Zero API changes — uses the source
param added in 2.40 and the shared fetch helper from 2.41.
Three closely-related changes bundled together. The UI work exposed the
backend bug when I tried to cancel a Deezer download and saw it marked
cancelled in the DB but continuing in the background.
Backend — cancel_task_v2 orchestrator dispatch fix:
The slskd-specific cancel block was written back when soulseek_client
was a raw SoulseekClient. It was later swapped to DownloadOrchestrator
(which doesn't expose .base_url / ._make_request), so the first
diagnostic log line crashed with AttributeError. The outer try/except
swallowed it, leaving streaming downloads (YouTube / Tidal / Qobuz /
HiFi / Deezer / Lidarr) running in the background after the user
clicked cancel.
Replaced the ~80-line block with a single
soulseek_client.cancel_download(download_id, username, remove=True)
call — the orchestrator's dispatch picks the right client by username,
same path /api/downloads/cancel already uses successfully.
Per-row cancel button (fancy):
Circular X button on .adl-row for rows in active or queued state.
Hidden by default (opacity 0, translateX + scale), fades in + settles
on .adl-row:hover with a cubic-bezier overshoot. Own :hover gives a
1.12x scale pop and brighter red glow. Touch devices (@media
(hover: none)) keep it visible.
Backend: surfaced playlist_id in /api/downloads/all items so the
frontend can hit cancel_task_v2 without a second lookup. Frontend:
adlCancelRow(btnEl, playlistId, trackIndex) with double-click guard
via data-cancelling + adl-row-cancel-pending class.
Cancel All header button:
Red-themed button next to "Clear Completed". Only visible when any
task is in downloading / searching / post_processing / queued state —
auto-hides the moment the last one finishes. Confirm dialog shows
"Cancel N tasks across M batches?". Iterates _adlBatches, calls
/api/playlists/<batch_id>/cancel_batch sequentially (same endpoint
each modal's "Cancel All" and the per-batch-card cancel use). Disables
during the loop, mixed/success/error toast based on result.
All 276 tests pass.
Cancel button on active download items was always visible, cluttering
the card. Now hidden by default and fades in when you hover the card
(or focus anything inside it, for keyboard a11y).
- opacity + pointer-events approach so layout doesn't jump on reveal
- 4px slide-in on reveal for a subtle entrance
- Touch devices (hover: none) keep the button always visible — no hover
means no way to discover it otherwise
Root-cause fix for "scanning 50 artists" then silence: when the master
repair worker was paused, force-run still kicked off _run_job but the
job's first wait_if_paused() blocked forever because is_paused was tied
to the master-enabled state. Force-run now bypasses master-pause —
scheduled runs still respect it.
Also fixes Fix All on discography findings doing nothing: the backend
bulk_fix_findings query had a fixable_types allowlist that excluded
missing_discography_track (and acoustid_mismatch). Added both.
Backfill job rebuild:
- auto_add_to_wishlist opt-in setting — creates findings AND pushes to
wishlist during the scan
- 3-option fix dialog (Add to Wishlist / Just Clear / Cancel) on single
Fix, Bulk Fix selection, and Fix All (page-level)
- Fix All "Just Clear" path uses the clear endpoint with job_id filter
instead of the generic "may delete files" bulk-fix warning
- Batched in-memory matching using get_candidate_albums_for_artist +
get_candidate_tracks_for_albums (same fast path the Library pages use)
- Rich album context per finding (id, name, album_type, release_date,
images, artists, total_tracks) — flows through the wishlist pipeline
so auto-processor classifies each track into the right cycle
(albums vs singles) and post-processing gets correct folder/tags/art
- Per-artist progress logs [N/50] Scanning ArtistName
- Default interval 24h (was 168h); all release types default on; settings
reordered with _section_* group headers (Core / Release Types /
Content Filters)
Repair settings UI:
- Generic _section_<name> key convention renders as an uppercase group
divider in the settings panel — any job can opt in
- .repair-setting-row gets a dashed bottom border so label↔toggle pairing
is visually clear
- _prettifyRepairSettingKey fixes acronym capitalization (EPs, not Eps)
Version bumped to 2.36 with changelog entries.
Three separate issues reported on the Spotify Playlist Discovery modal:
1. Fix button fails with "Track data not found"
openDiscoveryFixModal() branched on platform name to locate the discovery
state but had no case for 'spotify_public'. Row rendering passes that
platform value when the source is a Spotify public playlist, so state
lookup failed and the toast fired. Added the spotify_public branch —
state lives in youtubePlaylistStates alongside the other reused platforms.
2. Table header too transparent to read
.youtube-discovery-modal .discovery-table th used rgba(255,255,255,0.1)
as background (10% white) with white text, which lost contrast when the
orange progress bar or varied row content scrolled underneath. Switched
to near-solid dark rgba(17,17,20,0.96) with a brighter border-bottom
and z-index:5 so the sticky header stacks cleanly above table content.
3. "[object Object]" in the matched-artist column for Wing-It tracks
The Spotify Public Playlist row-transform joined result.spotify_data.artists
directly with .join(', '). Wing-It stub metadata (built server-side by
_build_discovery_wing_it_stub) returns artists as [{name: "..."}] —
array of objects. .join() stringified each object to "[object Object]".
Same pattern existed in three places in script.js; all now map objects
to .name and filter empties before joining. Graceful fallback to "-"
if the result is empty.
No existing tests touched. Full suite stays at 263 passed. Ruff clean.
Adds green/yellow header gradient on each service card showing whether the
user has filled in credentials, plus an expand-triggered verification layer
that surfaces working-or-not status inline.
Backend (web_server.py):
- SERVICE_CONFIG_REGISTRY mapping each of the 11 services in Connections to
its config requirements. Supports required-keys, always-green, any-of,
and custom-check semantics (Tidal uses token-file check, Qobuz accepts
either email/password OR cached auth token).
- _is_service_configured(service) — cheap config presence check, no APIs hit.
- GET /api/settings/config-status — returns {service: {configured}} for all
services in one call. Drives the page-load gradient.
- POST /api/settings/verify — takes {services: [...]}, runs
run_service_test per service, caches results 5 min in-memory, parallelizes
with ThreadPoolExecutor(max_workers=3) to avoid self-rate-limiting. Query
param ?force=true busts cache.
- Added verify branches for iTunes, Deezer, Discogs, Qobuz, Hydrabase in
run_service_test (previously missing — these services couldn't be tested).
HTML (webui/index.html):
- data-service="..." on all 11 .stg-service containers so JS can map card
to backend service name.
CSS (webui/static/style.css):
- .status-configured gradient (subtle green, left-to-transparent fade)
- .status-missing gradient (yellow, same shape)
- Spinner badge in header for .status-checking state
- "Testing connection…" status line style inside panel body
- Red warning bar style for verify failures at top of expanded panel
- Brand dot now glows always (was only glowing when expanded); hover and
expand states intensify the glow progressively.
JS (webui/static/script.js):
- applyServiceStatusGradients() fetches config-status and applies
green/yellow class per card. Called on Connections tab activate + after
any settings save.
- _stgVerifyServices(services, {force}) — batch verify POST, tracks
in-flight state, renders spinners/status lines/warnings per service.
- toggleStgService() fires single-service verify when a card is expanded
(not on collapse). Skipped if a verify is already in flight for that
service.
- toggleAllServiceAccordions() fires one batched verify for all 11 services
when "Expand All" is clicked; skipped on "Collapse All".
- _stgRefreshAfterSave() — after settings save, refreshes gradient (cheap)
and re-verifies only the cards the user currently has expanded (so
freshly-edited credentials show their new verify result immediately,
without re-pinging every service).
Failure UI: top-of-panel red warning bar with the error message (e.g.
"Discogs token rejected (HTTP 401)", "Hydrabase not connected…"). Removed
automatically on next successful verify.
No existing tests changed. Full suite stays at 263 passed. Ruff clean.
The downloads page previously showed only title and source per download.
Now shows album artwork thumbnail, artist name, album name, source badge,
and quality badge (after post-processing). All metadata comes from the
existing matched_downloads_context — no extra API calls needed.
Falls back gracefully to title-only display when context metadata is
not available (e.g. orphaned Soulseek transfers with no task mapping).
When playlist discovery fails to match a track on any metadata API,
instead of marking it "Not Found" and excluding it from downloads,
automatically build stub metadata from the raw source title/artist
and include it in the download queue. Soulseek searches with the
raw data, post-processing enhances whatever it can find.
All 7 discovery workers updated: YouTube, ListenBrainz, Tidal,
Deezer, Spotify Public, Beatport, and automated mirrored playlists.
Amber "Wing It" badge distinguishes stubs from real API matches.
Fix button still available so users can manually find a proper match.
Wing It stubs persist in DB for mirrored playlists and are
re-attempted on future discovery runs. Failed wing-it downloads
skip wishlist per-track (checked by wing_it_ ID prefix) so real
matched failures in the same batch still go to wishlist normally.
- Fix level filter showing nothing: now uses heuristic classification
for print() output (error/traceback/failed→ERROR, warn→WARNING, etc.)
in addition to exact logger format matching
- Speed up WebSocket updates from 2s to 0.5s polling
- Add search box with 300ms debounce — filters both initial load and live
- Use DocumentFragment for batch DOM appends (performance)
- Increase line cap from 1000 to 2000
- Backend search parameter support in /api/logs/tail
Terminal-style real-time log viewer with:
- Log file selector (app, post-processing, acoustid, source reuse)
- Color-coded log levels (DEBUG gray, INFO blue, WARNING yellow, ERROR red)
- Level filter buttons (All/Debug/Info/Warn/Error)
- Auto-scroll with toggle, copy and clear buttons
- Live updates via WebSocket (2s polling, pushes new lines)
- Initial load fetches last 200 lines via REST API
- 1000-line display cap with oldest lines trimmed
Also fixes Advanced tab settings (Discovery Pool, Security, etc.) being
hidden inside collapsed Library Preferences section body — misplaced
closing div caused them to be invisible.
New core/genre_filter.py with ~180 curated default genres. When strict
mode is enabled in Settings → Library Preferences → Genre Whitelist,
only whitelisted genres pass through during enrichment. Junk tags from
Last.fm (artist names, radio shows, playlist names) are silently dropped.
Applied at all 10 genre write points: Spotify, Last.fm, AudioDB, Deezer,
Discogs, iTunes, Qobuz enrichment workers + post-processing genre merge
+ initial download artist/album creation.
Strict mode is OFF by default — zero behavior change for existing users.
First enable auto-populates the whitelist with defaults. Users can add,
remove, search, and reset genres via the Settings UI.
Users can now override which metadata provider (Spotify, Deezer, Apple Music,
Discogs) is used when scanning a specific watchlist artist for new releases.
The selector appears in the artist config modal and only shows sources the
artist has enrichment IDs for. Default behavior is unchanged — all artists
use the global metadata source unless explicitly overridden.
Three collapsible categories, collapsed by default:
- Paths & Organization (file templates + music library paths)
- Post-Processing (metadata, tags, conversion, lyrics)
- Library Preferences (import, content filter, stats, playlists, M3U)
Section headers have data-stg=library so they only appear on the
Library tab. Bolder headers with accent-colored arrows and subtle
border. Collapse state preserved when switching settings tabs.
New MusicBrainz tab in Enhanced and Global search — finds tracks and
albums on MusicBrainz's community database with Cover Art Archive
images. Covers obscure tracks that Spotify/Deezer/iTunes miss.
- core/musicbrainz_search.py: search adapter with Track/Artist/Album
dataclasses, Cover Art Archive integration, smart query parsing
- Albums deduplicated (keeps best version with date and art)
- No artist results shown (MusicBrainz has no artist images)
- Album detail with full tracklist for download modal
- Smart word-boundary splitting for queries without separators
- Global search results container widened from 620px to 920px
- UI version bumped to 2.32
Fourth server option on the Connections tab with SoulSync logo and
'Standalone' label. Config panel shows Transfer folder path and
Verify Folder button. Test connection counts audio files in the
Transfer folder. Settings save/load properly detects soulsync toggle.
Race condition: scanner re-scanned folders while post-processing was
still moving files, causing partial matches and ghost failures. Now
tracks in-progress paths and skips them on subsequent scans.
Coverage penalty fix: individual tracks that match at 80%+ confidence
now auto-import even when overall album coverage is low (e.g. 2 of 18
tracks present). Previously low coverage killed the entire import.
Import page: stats bar, filter pills, Scan Now, Approve All, Clear
History (clears imported + failed), live scan progress.
Toggle appeared off when running because CSS :checked rules were
scoped to .repair-master-toggle. Added auto-import-toggle-label
selectors. Refresh now re-renders whichever tab is active.
Split Downloads page into main list (left) and batch panel (right).
Each active batch gets a color-coded card with artwork thumbnail,
progress bar, per-track status with download percentages, and
expandable track list. Download rows get matching color indicators.
- Click batch name to open its download/wishlist modal
- Filter icon narrows main list to one batch with clear banner
- Collapsible panel toggle for full-width list view
- Completed batches fade out after 15 seconds
- 7-day batch history with source type color dots
- Artwork fallback shows colored initial when no art available
- Per-track progress: download %, spinner for searching, proc label
- source_page column on sync_history for UI origin tracking
- /api/downloads/all includes batch summaries and per-track progress
- /api/downloads/batch-history endpoint for history queries
- Responsive layout, overflow-x hidden to prevent scroll flicker
Full auto-import pipeline: background worker watches the staging folder,
identifies music using embedded tags → folder name parsing → AcoustID
fingerprinting, matches files to metadata source tracklists, and
processes high-confidence matches through the existing post-processing
pipeline automatically.
Worker: AutoImportWorker with start/stop/pause/resume, configurable
scan interval (default 60s), confidence threshold (default 90%), and
auto-process toggle. Processes one folder per cycle, alphabetical
order. Disc folder detection, stability checking, content hash dedup.
Confidence gate: 90%+ auto-processes silently, 70-90% queued as
pending review with approve/dismiss actions, <70% flagged for manual
identification. Track matching uses weighted algorithm (title 45%,
artist 15%, track number 30%, album tag 10%).
Database: auto_import_history table tracks every scan result with
folder hash, match data JSON, confidence, status, timestamps.
API: 7 endpoints — status, toggle, settings (GET/POST), results
(filtered/paginated), approve, reject.
UI: Auto tab on Import page with enable toggle, confidence slider,
scan interval selector. Live result cards with album art, confidence
bar (green/yellow/red), status badges, match stats. 5-second polling.
Server Playlists tab now displays ALL playlists from the media server,
split into two sections: Synced Playlists (with mirrored/history match,
full opacity, "Synced" badge, "Open Editor" action) and Other Server
Playlists (everything else, dimmed at 70%, "View Tracks" action). Both
sections have header with icon, title, and count. Unsynced cards fade
to full opacity on hover.