Adds a full Discover Sync tab to the Sync page with:
- Core UI scaffolding, playlist modal, empty-state handling
- ListenBrainz playlist integration with auto-update toggle persistence
- Sync progress tracking with matched/total counts on cards
- Navidrome playlist push on batch completion (V1 and V2 paths)
- Active download state display with polling resume on page reload
- Stuck-download detection for downloading and catch-all states
- Serialized sync queue to prevent concurrent backend contention
- Source badges, compact card layout, URL fixes
- 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
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.
Tidal's search engine chokes on long queries with multiple qualifier
words (remix credits, edit labels, bonus-disc markers). User reported
case: "maduk transformations remixed fire away fred v remix" returns 0,
but shortening to "maduk transformations remixed fire away" works.
Behaviour change:
- On a 0-result search, retry with progressively-shortened variants
(capped at 5 total attempts, 100ms pause between).
- Variants (in priority order):
1. strip trailing "(...)" / "[...]"
2. strip all parentheticals/brackets
3-5. drop last 1 / 2 / 3 tokens
6. keep first half of tokens (rounded up)
- Dedupes so identical variants don't re-query.
Safety — qualifier-aware filter:
- Variant keywords (Live / Remix / Acoustic / Extended / Unplugged /
Instrumental / Karaoke / etc.) are extracted from the original query
using word-boundary match so "edit" doesn't match "edition" and
"mix" doesn't match "remixed".
- If the original query carries any qualifiers, fallback results MUST
contain those qualifiers in their track names — otherwise a shortened
query could silently downgrade "Song (Live)" to the studio "Song".
- Tracks that fail the filter are dropped. If no variant produces
qualifier-matching tracks, returns ([], []) — the same outcome as the
original code, so no regression.
Contract preservation:
- Never raises to caller (outer try/except catches orchestration errors).
- Returns ([], []) on any failure path, same as original.
- Original-query successes take the same code path as before — no
behavioural change for queries that already work.
- Defensive guards for None/empty/non-string query (early return).
Logging:
- Preserves original warning/error/info messages for back-compat log
scraping.
- Adds fallback-success INFO log ("Tidal fallback query succeeded: ...")
so successful retries are visible in production logs.
- Adds qualifier-filter INFO/DEBUG logs with kept/total counts.
- Per-attempt exception logs at DEBUG (not ERROR) to avoid noise when
retries succeed.
- Traceback preserved on final failure.
Tests (16 regression tests in tests/test_tidal_search_shortening.py):
- Skowl's reported query reaches his working variant within the cap.
- Paren/bracket stripping priority.
- Short queries produce no variants.
- All variants unique (dedup guard).
- Progressive token drops present for long queries.
- Qualifier extraction is word-bounded (no "edit" in "edition").
- Qualifier extraction is case-insensitive.
- Track name filter requires ALL qualifiers.
- Empty-qualifier list passes every track (original-query behaviour).
All 292 tests pass.
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
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.
- 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
Download-status meta enrichment only checked spotify_album.images[0].url
for the card artwork. That's the Spotify-API shape, but the context
builder for wishlist and manually-fixed tracks populates spotify_album
with image_url (singular string) and no images array. Result: those
tracks downloaded and post-processed fine (different path) but the
downloads page showed a placeholder note icon.
Enrichment now falls through three spots before giving up:
1. spotify_album.images[0].url (Spotify-originated)
2. spotify_album.image_url (wishlist / fixed discovery)
3. track_info.image_url (some discovery flows)
Pure read-side fix — no changes to the context builder, so existing
behaviour for Spotify-primary users is unchanged.
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).
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.
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
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.
_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.
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.
Two bugs kept this job from finding anything useful on a typical library.
1. Wrong Deezer column name. The artists table has a deezer_id column
(per music_database.py:1986), but the job looked for deezer_artist_id
in both _scan_artist (line 132) and _get_library_artists (line 345).
For Deezer-primary users, this meant the Deezer ID never made it into
the source_ids map, so get_artist_discography fell back to artist-
name-only search — slower and less accurate than an ID lookup.
2. Spotify-reported EPs were silently excluded. Spotify lumps EPs and
true singles under album_type='single'. The previous
_should_include_release short-circuited on album_type='single' and
returned the include_singles setting (default False), so 4-6 track
EPs on Spotify-primary libraries never survived the filter — even
though include_eps defaulted to True. Only 7+ track full albums
made it through. This is the main reason users felt the job did
nothing.
Fixes:
- Use the correct deezer_id column name in both reference sites.
- Restructure _should_include_release so only 'album', 'ep', and
'compilation' are trusted outright. Anything else (including
'single' and missing type) falls through to a track-count
disambiguation matching the download pipeline's _get_album_type_display:
1-3 tracks = true single, 4-6 = EP, 7+ = album. A Spotify-returned
'single' with 5 tracks now correctly counts as an EP.
Full suite stays at 263 passed. Ruff clean.
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.
User reported pausing the Spotify/Last.fm/Genius enrichment worker via the
dashboard bubble would silently turn back on "by itself". Real cause was
a race in the download-yield auto-pause/resume loop (_emit_enrichment_worker_stats_loop):
1. Download starts. Loop sees worker running, auto-pauses it, adds its
name to _download_auto_paused.
2. User clicks the enrichment bubble to pause — already paused visually,
but they want it to STAY off. Pause endpoint sets config_manager
'_enrichment_paused' to True and calls worker.pause() — but does not
remove the name from _download_auto_paused.
3. Download finishes. Loop sees 'not downloading and name in
_download_auto_paused' and blindly flips w.paused = False,
overriding the user's explicit pause. Config still says paused,
but the worker is actually running.
Two defensive fixes:
- Auto-resume block now checks the user's persisted config intent before
flipping the worker on. If {name}_enrichment_paused is True in config,
the name is dropped from _download_auto_paused without touching
w.paused — user's pause stays honored.
- Pause endpoints for spotify-enrichment, lastfm-enrichment, and
genius-enrichment now also discard from _download_auto_paused so a
stale marker can't trigger this race again.
Both together mean the auto-pause loop can no longer override a manual
pause regardless of ordering.
Full suite stays at 263 passed. Ruff clean.
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.
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 previously-undocumented features to the Key Features section:
- Hydrabase P2P metadata network (dev-mode alternative to iTunes)
- Genre Whitelist for filtering junk tags across enrichment sources
- Multi-artist tagging options (separator, multi-value ARTISTS, feat-in-title)
- Live Log Viewer in Settings → Logs
- ReplayGain analysis during post-processing
Expands the Automation Engine section with more trigger examples,
multi-THEN actions (up to 3 per automation), Signal Chains cycle-
detection behavior, and Automation Groups.
Expands Mirrored Playlists to cover Auto Wing It metadata fallback
and the per-track Unmatch button with DB persistence.
Adds a Release Channels section to the main README explaining the three
Docker image tracks users can choose from: stable :latest (Docker Hub),
nightly :dev (GHCR, rebuilt from dev branch), and pinned version tags.
Includes a decision table for picking the right channel and switching
instructions for docker-compose users.
Notes on the Unraid section that the template points at :latest by
default, and how to switch an Unraid container to the :dev channel
by editing the Repository field.
Adds a Contributing section covering the dev → main PR workflow, how to
branch off dev, expectations around ruff + pytest passing locally, and
how to run the dev gunicorn config.
Mirrors a short release-channels blurb at the top of
Support/README-Docker.md pointing at the main README's full guide.
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.
PR #340 added ruff to the build-and-test.yml CI gate, which surfaced
286 pre-existing lint errors. Left unfixed, every feature branch push
fails CI. This commit resolves all of them so CI goes green and
contributors can actually land work.
Auto-fixes (248 of 286): removed unused f-string prefixes (F541),
renamed unused loop control variables with underscore prefix (B007),
removed duplicate imports (F811).
Manually fixed 10 latent bugs ruff caught (all wrapped in try/except
today, silently failing):
- music_database.py: _add_discovery_tables() called undefined
conn.commit() — would have crashed the iTunes-support migration
for existing databases. Now uses cursor.connection.commit().
- web_server.py settings GET: referenced undefined download_orchestrator
when it should be soulseek_client. Feature (_source_status on the
settings payload) was silently missing for UI auto-disable logic.
- web_server.py _process_wishlist_automatically: active_server
undefined in track-ownership check. Auto-wishlist was falling
through to the error handler and re-downloading owned tracks.
- web_server.py start_wishlist_missing_downloads: same active_server
bug in the manual wishlist path.
- web_server.py _process_failed_tracks_to_wishlist_exact: emitted
wishlist_item_added automation event with undefined artist_name
and track. Automation event silently never fired correctly.
- web_server.py discovery metadata enrichment: referenced cache
without calling get_metadata_cache() first. Track enrichment from
cached API responses was silently skipped.
- web_server.py Beatport discovery worker: wing-it fallback branch
used undefined successful_discoveries variable. Wing-it counter
never incremented correctly. Now uses state['spotify_matches']
consistently with the rest of the function.
- web_server.py _run_full_missing_tracks_process: stale import json
mid-function shadowed the module-level import, making an earlier
json.dumps() call reference an unbound local (F823).
- web_server.py discovery loop: platform loop variable shadowed
the module-level platform import (F402).
- core/watchlist_scanner.py: 7 lambda captures of loop variables
(B023 classic Python closure-in-loop bug) now bind at creation.
No existing tests had to change. Full suite stays at 263 passed.
The _DummyConfigManager stubs in test_metadata_service_musicmap.py and
test_metadata_service_artist_image.py were missing get_active_media_server(),
which the existing test_metadata_service_discography.py dummy provides.
Both files install their dummy via sys.modules["config.settings"] with an
"if not in sys.modules" guard, so whichever test file loads first wins.
When the new files load alphabetically before discography, the limited
dummy persists and later tests hit AttributeError on get_active_media_server.
Adds the same get_active_media_server method to both dummies so all three
test files are equivalent and test ordering no longer affects outcomes.
- 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.
- Relocate the streamed MusicMap similar-artist flow out of web_server.py and into core.metadata_service.
- Match similar artists through the configured source-priority chain instead of assuming Spotify first.
- Add iTunes artwork fallback so streamed artist payloads still carry image_url when search results are sparse.
- Cover the new service behavior with tests.
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.