Five issues kettui flagged on PR #377:
- Worker race (reorganize_queue.py): _next_queued() picked an item and
released the lock, then re-acquired to flip status='running'. A
cancel() landing in that window marked the item cancelled but the
worker still ran it. Replaced with _claim_next_or_wait() that picks
AND flips under one lock acquisition.
- Wakeup race (reorganize_queue.py): _wakeup.clear() after the empty
check could lose an enqueue's _wakeup.set(), parking a freshly-queued
album for up to 60 seconds. Replaced Lock + Event with a single
threading.Condition; cond.wait() releases and re-acquires atomically
on notify.
- Bulk dedupe (reorganize_queue.py:enqueue_many): looped single-item
enqueue, so a duplicate album_id later in the same batch could slip
through if the worker finished the first copy before the loop
reached the second. Now holds the lock for the whole batch and tracks
a per-batch seen set, so intra-batch duplicates dedupe against each
other and not just pre-existing items.
- Preview button stuck disabled (library.js:loadReorganizePreview):
early returns and thrown errors skipped the re-enable line. Moved
state into a canApply flag committed in finally, so any exit path
lands the button correctly.
- DB helpers swallowing failures (music_database.py): get_album_display_meta
and get_artist_albums_for_reorganize used to catch every Exception
and return None / [], so a real DB outage masqueraded as "album not
found" / "no albums". Now lets exceptions bubble; the route layer
already wraps them as 500.
Tests:
- test_cancel_and_run_are_mutually_exclusive — hammers enqueue+cancel
pairs and asserts the invariant that no successfully-cancelled item
ever ran (catches regressions to the atomic pick).
- test_enqueue_many_dedupes_batch_internal_duplicates — pins the
intra-batch dedupe.
- test_get_album_display_meta_propagates_db_errors and
test_get_artist_albums_for_reorganize_propagates_db_errors — pin
the bubble-up behavior.
Changelog updated in helper.js and version modal.
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.
Four changes addressing kettui's PR #377 review comments:
1. **`_finalize_track` no longer over-counts on DB failure (🔴 bug).**
The function previously bailed on DB-update failure but
`_process_one_track` still incremented `summary['moved']`
unconditionally — overstating how many tracks the UI knows are
at their new locations. Fixed by:
- `_finalize_track` now returns ``bool`` (True only when DB row
was updated AND original was dealt with)
- Caller checks the return; on False, records as a failed track
with a clear message ("Track landed at new location but DB
update failed — file is at both old and new paths until library
scan re-indexes")
- Existing `test_db_update_failure_leaves_original_in_place` now
also asserts `moved == 0`, `failed == 1`, and that the error
message names the cause
2. **`executeReorganize` toast no longer says "undefined tracks" (🐛
bug).** `/reorganize` doesn't return `result.total` anymore (the
track count is determined server-side after planning), so the
"Reorganizing undefined tracks..." string was meaningless. Now uses
`result.message` from the backend instead.
3. **`_pollReorganizeStatus` distinguishes completed from skipped
(🟡 risk).** Backend now propagates the orchestrator's status
(`completed` / `no_source_id` / `no_album` / `no_tracks` /
`setup_failed` / `error`) into `_reorganize_state['result_status']`
so the frontend can warn appropriately. Two new helpers:
- `_classifyReorganizeOutcome(state)` — returns 'success' only
when `result_status === 'completed'` AND `failed === 0`;
'warning' otherwise
- `_formatReorganizeResultMessage(state)` — returns a message
specific to the outcome ("Reorganize skipped — album has no
metadata source ID. Run enrichment first." for `no_source_id`,
etc.)
Zero-failure non-completed runs now show as warnings instead of
green checkmarks.
4. **Bulk mode no longer counts skipped albums as succeeded (🟡
risk).** `_executeReorganizeAll`'s loop was treating any HTTP
200 response as success, ignoring the orchestrator's actual
outcome for that album. Fixed by:
- `_waitForReorganizeComplete()` now resolves with the final
state object (was: void)
- Loop checks `finalState.result_status === 'completed'` AND
`finalState.failed === 0` before counting `succeeded++`;
otherwise increments `skipped` (with a per-album warning
toast) or `failed` accordingly
- Final summary toast now reads
"Reorganized N of M albums, K skipped, J failed" and only
shows green when nothing was skipped or failed
All four addressed in a single commit because they form one
coherent UX-correctness fix — the bug bug (#1) and the count-
overstatement bug (#4) both made the user see "everything succeeded"
when reality was different. Together they make the UI honestly
reflect what actually happened.
Files:
- core/library_reorganize.py — `_finalize_track` returns bool,
`_process_one_track` reads it
- web_server.py — `_reorganize_state['result_status']` populated
from orchestrator's summary on success and on exception
- webui/static/library.js — `_classifyReorganizeOutcome` /
`_formatReorganizeResultMessage` helpers, single-album +
bulk-mode flows both consume them
- tests/test_library_reorganize_orchestrator.py — strengthened
the existing DB-failure test to assert moved/failed counts
Credit: kettui — four PR #377 review comments named all of these
precisely with line numbers and severity.
Reported by kettui on PR #374 review: the inline filter that backed
`set_album_api_track_count` only counted rows where `type_ == 'track'`,
but `discogs_client.get_album_tracks` itself accepts both `'track'`
AND empty `type_` as real songs (line 660: `type_ in ('track', '')`).
Releases where Discogs returns some real tracks with an empty `type_`
field would be undercounted, which would silently disagree with the
repair job's fallback `_get_expected_total` path (which calls into
`get_album_tracks_for_source` and therefore uses the client's count).
Extracted the filter into `count_discogs_real_tracks(tracklist)` —
single source of truth for the rule, testable in isolation, and the
worker call site is now a one-liner that names what it's doing. Also
defensive about the input shape: `type_ == None`, missing field, and
empty/None tracklist all handled cleanly.
10 tests pin the behavior:
- empty/missing/None type_ all count as a real track (the kettui case)
- 'heading', 'index', 'sub_track' excluded
- unknown future type strings excluded conservatively
- realistic multi-disc tracklist with mixed shapes counts correctly
- empty/None input returns 0 without raising
Credit: kettui — the PR #374 review comment that flagged this.
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.
Reported on Discord by Netti93: with Tidal configured for "HiRes only"
and "Allow Quality Fallback" disabled, tracks were still downloading
successfully — as m4a 320kbps files. Some "successful" downloads were
less than half the file size of the same track pulled via Tidarr/tiddl
from the same Tidal account.
Root cause: Tidal's API silently degrades to the best quality your
account + the track + your region permits. Setting
`session.audio_quality = Quality.hi_res_lossless` and calling
`track.get_stream()` on a track that's only available in AAC returns
an AAC stream with no error. The downloader wrote the m4a file to
disk, the ~7MB size sailed past the 100KB stub threshold, and the
download reported success.
The pre-existing "verify quality wasn't silently downgraded" block
only LOGGED a warning when this happened; it did not fail the tier.
Two knock-on effects:
- Users with "HiRes only, no fallback" got m4a files anyway, which
defeats the setting entirely.
- The worker-level fallback chain (hires → lossless → high → low)
couldn't advance past the first tier, because every tier
"succeeded" at whatever Tidal happened to serve.
Fix: after `track.get_stream()`, compare `stream.audio_quality`
against the tier we asked for using a rank-based ordering:
LOW < HIGH < LOSSLESS < HI_RES < HI_RES_LOSSLESS
- Same tier or higher → accept (so the occasional Tidal upgrade
doesn't get rejected just because it's not an exact match).
- Lower tier → reject THIS tier. The loop `continue`s and the next
fallback tier is tried, or the whole download fails honestly
when the user has fallback disabled. The existing final-error
log already has a hint directing users to enable fallback if
they want automatic Lossless substitution.
- Unrecognized `audioQuality` value (e.g. a new Tidal tier we
haven't mapped) → reject conservatively, so the next fallback
tier gets a chance and the diagnostic log names the unknown
value.
Why the rank-based approach instead of strict equality:
Tidal's API doesn't technically promise an exact-tier match on
serving; on tracks that are flagged in its catalog as a higher
tier, it can serve higher than the session setting. Rejecting
higher-than-asked quality would be user-hostile. And the `HI_RES`
(legacy MQA) value — not in tidalapi's modern `Quality` enum but
possibly still present on old catalog entries — needs to rank
below `HI_RES_LOSSLESS`: users asking for true lossless HiRes
should reject MQA since MQA is a lossy format.
tidalapi's `Quality` enum is a `str` subclass whose VALUES (not
member names) match what the Tidal API returns in the
`audioQuality` field (e.g. `Quality.hi_res_lossless.value ==
'HI_RES_LOSSLESS'`, `Quality.low_320k.value == 'HIGH'`). Both
sides of the comparison are coerced to `str` before use, so the
check is robust to whichever tidalapi version exposes the served
quality as an enum or a plain string.
The check is extracted as `_verify_stream_tier(stream, q_info,
q_key) -> (ok, reason)` at module scope — a pure function with no
I/O, unit-tested independently. Ten tests: match, three upgrade
cases (LOSSLESS → HI_RES_LOSSLESS, LOSSLESS → HI_RES, LOW → any
higher), three downgrade cases (the reported HiRes → AAC, HiRes
Lossless → MQA HiRes, Lossless → AAC), one unrecognized-tier case,
and two defensive paths for older tidalapi builds without
`audio_quality` on the stream object and for QUALITY_MAP entries
that lack `tidal_quality` (e.g. tidalapi wasn't importable at
module load). Test stub updated to use uppercase `Quality` values
matching real tidalapi so case-sensitivity regressions get caught.
Also removed the old codec-string-based warning block — the new
tier check is strictly stronger, and keeping the warning around
would just be dead code waiting to drift out of sync.
Deliberately NOT tackling in this PR (documented as follow-ups):
- Bit-depth verification of HiRes FLAC files via mutagen. The
`stream.audio_quality` tier check catches the main "HiRes
requested, got AAC" case; bit-depth would only matter if Tidal
labeled a stream HI_RES_LOSSLESS but served a 16-bit FLAC
(`Stream.bit_depth` isn't reliable for this — tidalapi defaults
missing `bitDepth` fields to 16, so a trust-the-stream check
would spuriously reject valid HiRes whenever Tidal omits the
field). A proper fix runs mutagen post-download to inspect the
actual file, then decides whether to delete + retry the next
tier — a whole new failure mode with design trade-offs that
deserve their own PR. The support logs don't show this
happening.
- The "manual remap still says Not Found" symptom. Might be
downstream of this same bug (silent-AAC "success" hitting a
later rejection), might be a separate task-state issue. Not
guessing without logs from the retry path.
- Quality-aware stub threshold. 100KB is a reasonable floor for
real stub/preview detection and there's no evidence the
universal threshold is misfiring in the wild.
Field-verified status: desk-verified via unit tests and empirical
checks against a live tidalapi import (confirming the `Quality`
enum's str-subclass behavior). Not yet smoke-tested end-to-end
against a real Tidal account with a HiRes-only-no-fallback
setting — Netti93 or anyone else with that config should notice
either the fix working (non-HiRes tracks fail honestly with a
clear log line) or any regression before wider release.
Files:
- core/tidal_download_client.py — new `_verify_stream_tier` helper
and `_QUALITY_RANK` table at module scope, called in the
download loop after the stream is fetched and before any
bandwidth is spent. Removed the old inline codec-based warning
since the new check supersedes it.
- tests/test_tidal_stream_tier_verification.py — ten tests covering
match / upgrade / downgrade / unknown / defensive paths.
- tests/test_tidal_search_shortening.py — fake `Quality` values
brought in line with tidalapi's real values so both files share
a consistent stub regardless of pytest collection order.
- webui/static/helper.js — WHATS_NEW entry under 2.40 describing
the rank-based tier comparison.
Reported on Discord by Netti93 — the "same account works via
Tidarr" comparison narrowed the cause to SoulSync's download path
rather than an account/region issue.
Reported by sassmastawillis: the Album Completeness maintenance job
scans 3127 albums in 0.1 seconds and reports 0 findings — for every
user, regardless of whether their library is actually complete.
Restoring an older DB surfaced 7 correct findings, so the code logic
works; the DB state is what's making everything look complete.
Root cause: `albums.track_count` is only ever written by server-sync
paths — Plex's `leafCount`/`childCount` and SoulSync standalone's
`len(tracks)`. It's the OBSERVED count of tracks SoulSync has indexed,
which is always exactly what `COUNT(tracks)` returns for that album.
The completeness job treated it as the EXPECTED total and compared it
against the observed count. They're equal by construction, so
`actual >= expected` is always true: skip, 0.1s scan, 0 findings.
Fix: new `api_track_count INTEGER` column on `albums`, written only by
metadata-source code paths. Populated in two places so the scan is
fast and the fallback is robust.
1. Enrichment workers — shared helper `set_album_api_track_count`
in `core/worker_utils.py`. Called by each worker's existing
`_update_album` method alongside its other album-column UPDATEs:
- spotify_worker: `album_obj.total_tracks` from the Spotify Album
dataclass (already in hand, zero new API calls)
- itunes_worker: same, from the iTunes Album dataclass
- deezer_worker: `nb_tracks` from full_data, falling back to
search_data when the full lookup didn't run
- discogs_worker: count of tracklist rows where `type_=='track'`
(Discogs tracklists interleave heading and index rows that
shouldn't count as songs)
Helper skips the write on zero/None/negative/non-numeric inputs
so a source lacking track info can't clobber a good value a
different source already wrote. Caller owns the transaction —
helper just queues an UPDATE on the caller's cursor without
committing, so it batches cleanly with each worker's existing
multi-UPDATE pattern.
Hydrabase worker deliberately not touched — it's a P2P mirror
that doesn't write album metadata to the local DB. Hydrabase-
primary users hit the fallback path below.
2. Album Completeness repair job — new `al.api_track_count` column
in the SELECT, read first in the scan loop. On miss (album never
enriched, or enrichment workers haven't run yet on a fresh
install), falls through to the existing `_get_expected_total()`
API lookup and persists the result via the same shared helper
(wrapped in connection/commit management since the repair job
runs outside a worker's batched transaction).
Also removed `al.track_count` from the scan's SELECT — now unused
since the observed count was the whole source of this bug, and
leaving a dead SELECT would invite a future engineer to re-introduce
the same comparison.
Help text on the job card was reworded so it honestly describes
current behavior ("counts cached during normal enrichment are used
when available; otherwise the job queries a metadata source
directly") rather than the old "active provider first, then others
as fallback" phrasing, which doesn't match how the cache actually
fills — any enrichment worker that runs can populate it, and the
last writer wins. Document-only follow-up if this edge case ever
bites in practice: add a `api_track_count_source` column so the
scan can prefer the configured primary source's count over others
(e.g. deluxe vs. standard edition mismatches). Not worth the
complexity today.
For existing users, the first completeness scan after upgrade is
fast to the extent their library is already enriched: the workers
already ran and populated `api_track_count` on their normal schedule.
For brand-new installs, the scan's fallback path handles the cold
start — slower, but correct, and subsequent scans are fast.
Does NOT affect:
- Download / post-processing / wishlist / sync code paths — none
of them read `track_count` for completeness semantics.
- Plex / Jellyfin / Navidrome / standalone sync — still write
`track_count` exactly as before; `api_track_count` is a separate
column they never touch.
- Other repair jobs.
- Any UI path — same finding schema, just correct counts now.
Files:
- database/music_database.py — idempotent migration adding
`api_track_count INTEGER DEFAULT NULL` to the existing album-column
check block.
- core/worker_utils.py — new `set_album_api_track_count` helper with
the documented skip-on-bad-input contract.
- core/spotify_worker.py, itunes_worker.py, deezer_worker.py,
discogs_worker.py — one-liner call from each `_update_album`.
- core/repair_jobs/album_completeness.py — scan uses the cache;
fallback path persists API-lookup results via the shared helper;
help text updated to match actual behavior.
- tests/test_worker_utils_album_track_count.py — 9 tests covering
the helper's write/skip contract + no-commit invariant.
- tests/test_album_completeness_job.py — 2 tests for the repair
job's fallback-path wrapper.
- webui/static/helper.js — WHATS_NEW entry.
Credit: sassmastawillis spotted the bug; the "restored older DB
finds 7 albums" signal pinpointed DB state over code logic and
made the diagnosis tractable.
Typing "michael jackson" returned 7 identical-looking cards because
MusicBrainz has many different PEOPLE sharing a canonical name — the
King of Pop plus a NZ poet, a photographer, a mashup artist, a
didgeridoo player, and more, all scoring 80+ on exact-name match.
All 7 passed the score filter. All 7 rendered with the same
fallback image because iTunes/Deezer only know the famous one.
Fix dedupes by normalized (lowercase, whitespace-trimmed) name before
building Artist dataclasses. Keeps the highest-scoring entry per name,
so the King of Pop (score 100) wins over the others (all score 80-81).
Artists with genuinely different names stay separate — a search for
"the beatles" still surfaces tribute bands if they're above threshold.
Implementation note: fetch `max(limit*3, 10)` from MB instead of
`limit` directly, so the dedup pool is large enough to still return
`limit` distinct artists after collapsing duplicates. Previously the
raw fetch was capped at the caller's limit, which would have left
fewer-than-requested results after dedup for common names.
3 new tests (49 total):
- Dedupe collapses 5 same-named entries to 1 (keeps highest score).
- Dedup key is case-insensitive and whitespace-normalized.
- Dedup preserves distinct names ("The Beatles" vs "The Beatles Revival"
stay separate).
Live-verified: "michael jackson" now returns 1 card, "kendrick lamar"
returns 1 card.
Credit: kettui spotted duplicate Michael Jackson cards in the search UI.
Three bugs from kettui's follow-up review pass on the MusicBrainz
search PR, all fixed in one commit because they share UI context.
1. Missing artist images on MB artist results
MusicBrainz doesn't store artist images directly. My earlier commit
returned `image_url=None` on every artist result and trusted the
frontend's lazy-loader — but the lazy-loader's `/api/artist/<id>/image?
source=musicbrainz` endpoint had no handler for MusicBrainz, so it
silently returned None and the emoji placeholder stayed.
Fix plumbs the artist name through:
- `renderCompactSection` stashes `data-artist-name` on artist cards.
- `search.js` and `downloads.js` lazy-loaders pass `name=<artist>` as a
query param.
- `/api/artist/<id>/image` accepts an optional `name` param.
- `metadata_service.get_artist_image_url` has a new `musicbrainz`
branch: since MB has no artist art, it searches fallback sources
(iTunes/Deezer by configured priority) for the artist name and
returns the first image found.
Verified live — Metallica/Kendrick Lamar/Daft Punk all resolve to
Deezer artist images via the name lookup.
2. total_tracks off-by-one on tracks with a release
`_recording_to_track` initialized `total_tracks = 1` and then summed
media track-counts on top. For an 11-track album, it reported 12. An
adapter-level regression introduced when the recording-projection
helper was extracted during the main MB refactor.
Fix: initialize at 0, sum normally. Standalone recordings with no
release (can happen for uncredited remixes etc.) still report 1 via
an explicit fallback — so the existing "single track" case isn't
broken.
3. "Artist Album Title" queries buried specific albums in the
discography list
Bare-name queries like "The Beatles Abbey Road" used to resolve "The
Beatles" as the artist and then browse their full discography — Abbey
Road was buried alphabetically among 200+ releases instead of being
the top result.
Fix adds a title-hint extractor. When the query starts with the
resolved artist name followed by more words, the trailing portion is
treated as a title hint. Browse results are filtered to those whose
release-group title contains the hint. If the filter matches nothing,
falls back to text-search with the hint as the title (the "keep the
old split-by-whitespace fallback" path kettui called for). If text-
search also misses, shows the full discography rather than nothing.
10 new tests in tests/test_musicbrainz_search.py (46 total):
- Title-hint extractor: basic match, case-insensitive, whitespace
tolerance, bare-artist-no-hint, artist-not-prefix-no-hint, word-
boundary required (no false splits on "Metallicasomething").
- Browse filtering by title hint.
- Text-search fallback when the title hint matches nothing in browse.
- Bare-artist queries return the full discography unfiltered.
- total_tracks for single-release, multi-disc, and no-release cases.
Clicking a MusicBrainz album returned 404 because the browse-based
search path now stores release-GROUP MBIDs in Album.id, but `get_album`
still hit `/ws/2/release/<mbid>` directly. Release-group MBIDs don't
resolve as release MBIDs — MB 404s. User log:
GET /api/spotify/album/b88655ba...?source=musicbrainz → 404
Error fetching release b88655ba...: 404 Client Error
The fix requires a two-step resolution for the new browse path:
1. Look up the release-group with `inc=releases+artist-credits` to get
the list of releases inside (original + reissues + regional + promo
editions). MB release-groups routinely hold 5-20 releases.
2. Pick a representative release: prefer Official status over Promo,
prefer releases with a real tracklist over stubs, then earliest date.
3. Fetch that release's full tracklist via `get_release`.
Two extra seconds at the 1-rps rate limit, but it's on click, not on
search results rendering.
Structure:
- New `MusicBrainzClient.get_release_group(mbid, includes)` method.
- New `_pick_representative_release(releases)` helper encapsulates the
ranking logic.
- Tracklist projection extracted into `_render_release_as_album` so
both paths share the same shape construction.
- `get_album` tries release-group first; falls back to direct release
lookup when the MBID turns out to be a release from the text-search
fallback path.
- Canonical Album.id stays the release-group MBID so a re-fetch with
the same URL hits the same code path idempotently.
3 new tests (now 33 total):
- End-to-end release-group → release resolution with mocked client
- Fallback to direct release lookup when rg lookup misses
- Representative-release picker ranks correctly
Verified against live API with the exact MBID that 404'd for the user
(b88655ba... for DAMN. by Kendrick Lamar): now returns in 1.2s with
the full 14-track listing (BLOOD., DNA., YAH., ELEMENT., FEEL., ...).
Three related fixes to make album/track results look like a real
artist discography instead of a firehose of fan-compiled bootlegs.
1. Drop 'compilation' from the release-group browse primary-type filter.
MB's OR filter (`type=album|ep|single|compilation`) silently breaks
when 'compilation' is included — Metallica drops from 1076 matches
to 82 because `compilation` is a SECONDARY type on MB, not a primary
type. The invalid value corrupts the filter for all types, not just
itself. Now we request `type=album|ep|single` which returns the full
1076; actual compilations (primary=Album + secondary=[Compilation])
are filtered out by the studio-preference logic below.
2. Filter release-groups with non-studio secondary-types
(Live/Compilation/Soundtrack/Remix/Demo/Mixtape/Interview/Audiobook/
Audio drama). For Metallica, the first 100 browse results are 12
studio albums + 83 live bootlegs + 5 compilations — without this
filter the Albums section was dominated by 2019-2021 broadcast
recordings. Falls back to the unfiltered list if filtering leaves
the result set empty (covers live-only niche artists).
3. Sort chronologically ASC by first-release-date. Wikipedia-style
discography ordering — debut album on top, then chronological.
Previous DESC sort put the most recent release on top which, for
prolific artists, meant 2020s material before their classics.
Track side of the same fix:
- Re-orders each recording's `releases` array to put studio releases
first before `_recording_to_track` picks up the first release for
album context. Without this, MB's arbitrary release order often
buried the canonical studio album under random live bootlegs.
- Filters out recordings that only exist on live/compilation release-
groups (keeps the ones with at least one studio release). Falls
back to the full set if the artist has no studio recordings at all.
- Sorts recordings by earliest studio-release year ASC so classic
tracks surface first.
Smoke test against live MB API confirmed:
- Artists: [Metallica score=100]
- Albums: Kill 'Em All (1983) → Ride the Lightning → Master of Puppets
→ ...And Justice for All → Metallica (Black Album) → Load → Reload
→ St. Anger → Death Magnetic → Lulu (2011)
- Tracks: real Metallica recordings (Killing Time, Nothing Else
Matters, Creeping Death, etc.) — a few remastered demos still leak
in where MB metadata quality is thin, but the bulk is correct.
- Total latency: 3.5 seconds.
4 new tests covering the studio filter, live-only fallback, preferred
release ordering, and live-only recording exclusion.
Credit: kettui flagged the poor MB results during PR #371 review.
The previous commit's `browse_artist_recordings` call passed
`inc=releases+artist-credits` — but MusicBrainz's recording browse
endpoint rejects `inc=releases` with HTTP 400. The adapter's error
handler returned an empty list, so the Tracks section stayed empty
even though the fix was supposed to populate it.
Browse without release info is useless for our search UI (tracks
would render with no album), so swap to the fielded Lucene search
`arid:<mbid>` on the `/recording` endpoint. That's the canonical MB
pattern for "find recordings by this artist WITH release context":
- arid: search accepts the artist MBID and returns recordings with
`releases` (release-group, date, media) embedded in each result.
- One API call per lookup, same as browse would have been.
Renamed the method to `search_recordings_by_artist_mbid` so the name
matches its behaviour — it's a search, not a browse. Adapter updated
to call the new name; tests updated to match.
Verified against the live API: Metallica's MBID returns 5 recordings
in ~1.8 seconds (vs the previous 400 error).
26 new unit tests in tests/test_musicbrainz_search.py covering:
- Cover Art URL construction (release + release-group scope, empty MBID,
unknown scope fallback)
- Structured query splitting (hyphen, en-dash, em-dash, bare name, no
false-positive splits on hyphens-inside-words)
- Artist search: score filtering, strict=False call contract, exception
handling, genre extraction from MB tags, mbid/name validation
- Top-artist resolver: memoization by normalized query, sub-threshold
returns None, negative-result caching, empty-query short-circuit
- Album search routing: bare query → browse path, structured query →
text path, no-artist-match falls back to text, text path score filter
- Track search routing: browse path, dedupe-by-title across
live/compilation variants, structured query → text path, text path
score filter
All mock the underlying MusicBrainzClient — no network calls.
Also adds a WHATS_NEW entry under 2.40 explaining the three user-visible
changes: Artists section now populates, album/track results match the
searched artist instead of random title collisions, and search completes
in ~3 seconds instead of 30+.
JohnBaumb's review: "If we're going to refactor the web_server.py soon,
might as well start moving stuff away from web_server.py in our PRs.
_build_source_only_artist_detail, make it a module, it's perfect."
This continues the pattern the prior commit started with the source-ID
lookup helpers: move the pure data-building logic to a side-effect-free
core module, leave a thin wrapper in web_server.py that bridges the
Flask response and the module-global clients.
**core/artist_source_detail.py** — pure function that takes the artist id,
name, and source plus dependency-injected per-source clients (spotify,
deezer, itunes, discogs) and a Last.fm API key. Returns
(payload_dict, http_status) so it isn't coupled to Flask.
**web_server.py wrapper** — builds the client bag from the module globals
(checks Spotify auth, constructs the Discogs client from the configured
token, reads the Last.fm API key) and wraps the core return in jsonify.
147 lines of logic go away from web_server.py; the 24-line wrapper is
purely glue.
**tests/test_artist_source_detail.py** — 21 focused tests covering the
response envelope, the source-specific ID-field stamping for all six
supported sources, the dedup_variants=False contract (the behaviour
that originally motivated the split of MetadataLookupOptions), per-source
genre/follower extraction with safe handling of missing or throwing
clients, and the Last.fm enrichment branch including the no-key and
error-path cases. Runtime 0.26s.
Cin's review note: typing artist_name as plain `str` forced callers
that didn't have a name to pass `""` as a placeholder, which leaks the
parameter's emptiness contract into every call site and reads badly in
tests. Switching to `Optional[str] = None` lets callers omit it.
The function body's `if artist_name and active_server:` check already
handles None and "" identically, so no body changes were needed. Tests
that previously passed `artist_name=""` drop the argument; one new test
covers the omitted-arg path explicitly.
The web_server.py wrapper takes the same default for symmetry.
Cin pointed out that the prior version of test_artist_source_lookup.py
AST-parsed web_server.py to verify a constant and to string-match a
function's response keys. That was a workaround for the fact that
web_server.py can't be imported at test time (it boots Spotify,
Soulseek, Plex, etc.) — the right answer is to move the logic into a
side-effect-free module so it can be imported and tested directly.
This commit:
- adds core/artist_source_lookup.py containing the SOURCE_ID_FIELD
map, the SOURCE_ONLY_ARTIST_SOURCES set, and find_library_artist_for_source
- replaces the inline definitions in web_server.py with imports +
a thin wrapper that injects the active media server
- rewrites the tests to import from the core module directly:
* mapping correctness is now a plain equality assertion
* lookup behaviour is exercised against a real MusicDatabase
* the AST parse and the string-matching contract test class are
gone
- drops the _build_source_only_artist_detail contract test entirely
(the weakest of the four — it was just string-matching the function
body); when that function moves to core/ it can get a real
behavioural test alongside.
Test runtime drops from ~161s to ~5.8s. All 18 tests pass.
Four targeted backend tests for behaviour added during the Search/Artists
unification work:
1. _SOURCE_ID_FIELD mapping is parsed out of web_server.py via AST and
compared against an explicit expectation, so silent renames break the
test instead of silently breaking library-upgrade detection.
2. Every column in _SOURCE_ID_FIELD must exist on the real artists table
after migrations run. This is the schema-vs-query contract that the
`deezer_artist_id` typo would have failed instantly.
3. The two queries from the watchlist-config enrichment path execute
verbatim against a fresh DB — separate ones for the artists table
(deezer_id / discogs_id) and the watchlist_artists join (deezer_artist_id).
Documents the column-name split that caused the original bug.
4. Static contract test for _build_source_only_artist_detail's response
shape: every JSON key the frontend reads (success/artist/discography/
image_url/server_source/genres/lastfm_*) must appear in the function
source, plus the dynamic source-id stamp and the dedup_variants=False
opt-out.
Plus a behavioural test for MetadataLookupOptions.dedup_variants=False
in test_metadata_service_discography.py — proves the flag actually keeps
variant releases that get_artist_detail_discography would otherwise
collapse to a single canonical entry.
The three discovery-pool tests hardcoded release_date strings
("2026-04-01", "2026-04-16") that were checked against a rolling
`datetime.now() - timedelta(days=7)` (or 21-60 day) cutoff in the
scanner. Once the wall clock advanced past the cutoff window the
releases were filtered out and the assertions failed — Python 3.11
Linux CI was already past 2026-04-23 UTC.
Replace the hardcoded values with a module-level
`_RECENT_RELEASE_DATE = now - 2 days` so the fixtures stay inside
every cutoff window regardless of when the suite runs.
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.
Part D + E of the deferred cleanup + the final version bump that
publishes the whole Search/Artists unification project.
Deletions:
- webui/static/artists.js (1903 lines) — removed entirely. The 2
remaining externally-referenced helpers (lazyLoadArtistImages +
showCompletionError) moved into shared-helpers.js first.
- webui/index.html — 140-line #artists-page HTML block and the
<script src="artists.js"> tag both removed.
init.js wiring:
- 'case artists:' removed from loadPageData switch (no page to init).
- navigateToPage top-level alias extended: 'artists' → 'search'
(same pattern as the existing 'downloads' → 'search' alias).
Legacy /artists bookmarks land on the unified Search page, the
natural place to find an artist now.
- _getPageFromPath now maps artist-detail → library as its parent
(was artists). Matches the existing library-nav-highlight at
init.js:2161.
Version bump:
- _SOULSYNC_BASE_VERSION 2.39 → 2.40.
- WHATS_NEW entries lose the 'unreleased' scaffolding and gain a
new top entry summarizing the unified artist-detail page + the
final artists.js retirement.
- version-info modal gets a 'Search & Artists Unification' section
at the top.
- The _getLatestWhatsNewVersion filter added during the unreleased-
tracking phase is rolled back — entries now display as soon as
they land in WHATS_NEW, matching the pre-unification behaviour.
Test suite:
- tests/test_script_split_integrity.py SPLIT_MODULES updated:
'artists.js' dropped, 'shared-helpers.js' added. escapeHtml's
cross-file dupe list entry updated to reference shared-helpers.
- 354/354 tests pass.
User-visible result after this commit:
- Sidebar: Search, Downloads, Discover, Library, Wishlist, etc. —
no more Artists entry.
- Click any artist anywhere: lands on the same /artist-detail page.
- Search page has a source dropdown; Soulseek is just another option.
- Legacy /downloads and /artists URLs alias to /search.
- Version button shows v2.3 (Docker major); "What's New" panel
opens to the unification summary.
Closes the project Cin requested in Discord. Future work: source-aware
/api/artist-detail could be extended to fall back through the whole
source priority chain when a specific source is given but returns no
discography. Not needed for the current flows.
Part C of the deferred unification cleanup. The Artists page is no
longer in the sidebar, but its JS file can't be deleted yet because
it houses ~20 general-purpose helpers that other modules depend on
(escapeHtml used in 229 places, service-status polling, image-colour
extraction, download-bubble infrastructure, discography completion
checking, enrichment card rendering).
Moved all non-page-specific code from artists.js into the new
webui/static/shared-helpers.js — pure copy/paste, zero logic change.
Two contiguous blocks extracted:
Block A (lines 1097..1398 of original artists.js): discography
completion suite — checkDiscographyCompletion, handleStreaming-
CompletionUpdate, cacheCompletionData, updateAlbumCompletion-
Overlay, getCompletionStatusText, setAlbumDownloadedStatus,
setAlbumDownloadingStatus.
Block B (lines 2206..EOF of original artists.js): download-bubble
infrastructure (artist + search + Beatport clusters with their
snapshot/hydrate/modal/monitor helpers), openDownloadMissingModal-
ForArtistAlbum, image-colour extractor and dynamic-glow helper,
escapeHtml, service-status polling, renderEnrichmentCards.
Function declarations in a plain <script> tag are auto-global, so all
existing callers continue to resolve without any import/export
changes. Load order in index.html: shared-helpers.js loads right
after core.js (which defines the artistDownloadBubbles / search-
DownloadBubbles / beatportDownloadBubbles globals these helpers use).
Stats:
artists.js: 4638 → 1903 lines (-2735)
shared-helpers.js: new, 2762 lines
No function duplicated between the two files
All 357 tests pass (3 new from split-integrity parametrization)
What's left in artists.js is purely the Artists page — search UI,
detail view, state switching, watchlist button, discography loading.
All of that is reachable only by typing /artists in the URL bar
since the sidebar entry was retired in Phase 4b. Parts D + E will
delete that remainder and the file itself.
Phase 3b of the Search/Artists unification. The Search page's
internal id was 'downloads', which clashed with the actual Downloads
page (id 'active-downloads') and confused anyone reading the code.
Renamed to 'search' across HTML, navigation, DOM selectors, and the
deep-link route list.
Backwards compat: navigateToPage('downloads') aliases to 'search'
at the top of the function; /downloads URL still serves index.html
and the client router resolves the page correctly; profile ACL
checks accept both 'search' and 'downloads' so existing profiles
with 'downloads' in allowed_pages keep working without migration.
Sidebar label unchanged. Zero visual change — pure internal tidy.
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.
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.
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.
The unknown_artist_fixer was updated to use deezer_id (matching the
actual tracks table column) but the test still passed deezer_track_id
in the track dict, causing the deezer lookup to miss and fall back
to Spotify.
- Move album-track resolution into metadata_service
- Use the configured provider order instead of Spotify-first branching
- Switch the frontend to the unified /api/album/<id>/tracks endpoint
- Add tests for source-priority lookup, DB resolution, and formatting
- pass provider-specific artist ids into the source-priority discography lookup
- stop relying on the local library artist id when querying external metadata
- add a regression test for source-specific artist id resolution
- Stop passing in spotify_id as the id in the UI, use the actual db id instead
- Fixes an issue where albums for another artist would end up being returned for the actual searched artist
- Remove the redundant artist_id filtering code
- Fixes an issue where not-currently-owned albums would be filtered out from the results, even if they were successfully fetched from the configured metadata provider
Coverage for fix 2.1:
TestResolveDbTrackIdsBatch:
- Batch returns the same (title, artist) -> id mapping as the
per-event lookup would have
- Case-insensitive matching preserved
- Empty event list returns an empty dict
- Events without a title are skipped
- A cursor-execute counter proxy confirms 50 events trigger exactly
one SQL query (not 50)
TestMapPlayCountsToDb:
- Returns updates only for server IDs that exist in tracks
- Empty input returns an empty list
- 30 server IDs trigger one batched query
TestEnrichStatsItems:
- Populates image_url / id / artist_id on matching artists, albums,
and tracks; skips rows with no match
- Empty or missing top_* lists are safe
- Three batched queries total (one per section) regardless of the
number of items in each list
Coverage for fix 4.2:
- _cleanup_old_requests evicts entries older than _MAX_REQUEST_AGE
and leaves fresh entries intact; returns the number removed
- Empty map is safe (no error)
- start_cleanup_thread is idempotent (returns False on second call)
- stop_cleanup_thread joins the thread and clears the handle
- The thread actually evicts stale entries on wakeup
- stop signals the thread to exit promptly via the stop event
instead of waiting for the next interval
Covers fix 4.1:
- Default limit (100) applied when no params given
- limit and offset slice correctly without overlap between pages
- status param accepts single or comma-separated values
- Unknown status returns empty list with total=0
- limit is clamped to a max of 500
- Negative or non-integer limit/offset fall back to safe defaults
- Tasks are returned newest-first by status_change_time
Coverage for fix 1.1:
TestBackfillMigration verifies the one-shot migration sets
match_status='matched' for rows that already have a populated
external ID (lastfm_url, musicbrainz_release_id,
musicbrainz_recording_id, tidal_id, qobuz_id) but NULL match_status,
and leaves rows without an ID untouched.
TestGetExistingIdColumnMapping verifies lastfm_worker reads
lastfm_url for all entity types and musicbrainz_worker reads the
correct per-type column (musicbrainz_id / musicbrainz_release_id /
musicbrainz_recording_id).
TestLastFMWorkerMarksMatched / TestTidalWorkerMarksMatched /
TestQobuzWorkerMarksMatched / TestMusicBrainzWorkerMarksMatched
verify each worker's _process_* short-circuit path sets
match_status='matched' (and does not re-call the external API) when
the entity already has an ID populated.
Covers original ordering preservation, partial/full hit thresholds,
empty result_ids, TTL expiration, cache miss behavior, and a
round-trip count assertion confirming 50 entities resolve in a
single SELECT (not 50).
8 test files had _DummyConfigManager missing get_active_media_server(),
causing failures when pytest ran them before the test file that had it.
Whichever file set sys.modules first won, and the incomplete dummy broke
later tests. Also fix script.js read_text() missing encoding='utf-8'
which failed on non-UTF-8 default locales.
- broaden the artist-detail dedup helper to catch trailing parenthetical edition and remaster variants
- keep the legacy hyphenated suffix fallback for older metadata
- add regression coverage for language-specific Edition and remaster cases
- move artist-detail discography resolution onto the shared source-priority metadata service
- keep the variant dedup helper in the UI-facing adapter
- pass the chosen source through completion checks
- add coverage for the new adapter and dedup behavior