- depth setting (light = core tags + matched source ids; full = same
multi-source enrichment cascade a fresh download gets, run additively
via embed_source_ids). Threaded through scan/finding/auto-apply and the
repair_worker fix handler.
- source now defaults to 'auto' (= your source priority / active source)
instead of blank.
- give native <option> popups a solid dark background (were white-on-white).
- tests for full-depth full_meta payload + enrich invocation + light no-op.
The job was the odd one out — auto_fix=False, no dry_run setting, so it never
showed the 'Dry Run' badge the other jobs do (the badge keys off
settings.dry_run === true). Aligned it to the standard pattern:
- auto_fix=True + dry_run setting defaulting True. Default behavior is unchanged
(findings only, nothing written) AND it now shows the Dry Run badge.
- Turning dry_run off makes the scan auto-apply in place (result.auto_fixed),
no finding — the opt-in 'just retag it' mode.
- Extracted a shared apply_track_plans() used by both the scan auto-apply and
the repair_worker fix handler (handler now resolves Docker paths then
delegates — one code path, no duplication).
Tests: dry_run=False auto-applies + writes + no finding; existing dry-run
finding/skip/apply tests still green. 410 passing.
New 'Library Re-tag' repair job (default-OFF, opt-in; weekly when enabled):
- Scans every source-matched album (spotify/itunes/deezer/musicbrainz album id),
pulls fresh metadata + tracklist from that source, reads each local track's
current tags, and uses the planner to compute per-field diffs.
- Dry-run by design: scan only CREATES findings — nothing touches a file. Each
finding is highly detailed: per-track old->new for every changed field, the
source used, the mode, a cover-art action, and any unmatched tracks, plus a
summary description. Settings: mode (overwrite | fill_missing), cover_art
(replace | fill_missing | skip), source override.
- Apply handler (_fix_library_retag in repair_worker): writes each track's
planned tags in place via tag_writer.write_tags_to_file (+ batch-embeds cover,
refreshes cover.jpg). Only adds/overwrites planned fields — no moves/renames/
re-match. Resolves Docker paths; read-only/unreachable files counted, never
crash. Media-server-only / unreachable tracks are skipped.
Registered in the job list + fix dispatch. The old per-download Retag Tool is
left untouched alongside this for now.
Previously the filler only flagged albums whose DB thumb_url was empty and, on
apply, only updated that DB thumb_url — so albums whose files had no embedded
art and no cover.jpg (but whose DB row had a URL) were never found, and even
'applying' art never touched the files. That's the reported 'doesn't scan all
albums' gap.
New core.metadata.art_apply (reuses the post-processing standard so the user's
album_art_order is honored):
- album_has_art_on_disk(): cheap-first check — folder cover.jpg/folder.jpg
sidecar, then embedded art in a representative track (FLAC/ID3/MP4/Vorbis).
- apply_art_to_album_files(): embeds via embed_album_art_metadata + writes
cover.jpg via download_cover_art; only ADDS art (never rewrites the user's
tags); read-only/unwritable files are skipped + counted, never crash.
Scan now examines every titled album and flags it when art is missing in the DB
OR on disk. Apply embeds into the album's audio files + writes cover.jpg in
addition to the DB thumbnail (media-server-only albums fall back to DB-only).
Tests cover sidecar/embedded detection, the cheap-first short-circuit, and the
apply orchestration (embeds each file + cover.jpg; read-only failures counted).
The canonical source_selection setting was rendering as a free-text box — easy
to typo an invalid mode. Added a generic choice mechanism so it's a dropdown:
- RepairJob.setting_options: {key: [allowed values]} (default {} — opt-in).
- CanonicalVersionResolveJob declares source_selection's three modes.
- repair_worker.get_all_job_info() includes setting_options in the job payload.
- enrichment.js renders a <select> (options prettified, current value selected)
for any key listed in setting_options; everything else renders by value type
as before. The save path already reads <select>.value as a string, so no
change needed there.
Generic — any future job can get dropdowns the same way. Jobs that don't
declare setting_options are untouched (empty dict -> existing input rendering).
Tests: source_selection exposes the 3 options and its default is one of them.
23 repair-job/worker + canonical tests pass (other jobs unaffected).
The Duplicate Detector's 'Keep Best' auto-selection ranked copies by highest
bitrate -> duration -> track number, with no notion of format. A FLAC whose
bitrate the library scan never populated (a common gap) therefore lost to a
282 kbps MP3: 282 > 0, so the MP3 was kept and the FLAC deleted (reported on
Havok 'Prepare For Attack', and again on Kendrick GNX).
Fix: rank by format/lossless tier FIRST, then bitrate, duration, track number.
A lossless file now always beats a lossy one regardless of the recorded
bitrate; bitrate/duration/track# only break ties within the same format.
- core/library/duplicate_keep.py (new): pure, importable pick_duplicate_to_keep
+ duplicate_keep_sort_key + format_rank_for_path (extension rank mirroring
auto_import_worker._quality_rank: flac=10 ... mp3=5 ... unknown=1).
- core/repair_worker.py: _fix_duplicates auto-pick now calls
pick_duplicate_to_keep instead of the bitrate-first max().
- webui/static/enrichment.js: the KEEP/REMOVE recommendation mirrors the same
format-first ranking so the badge matches what the backend will delete.
Parity: Python uses '.ext' keys (os.path.splitext), JS uses 'ext'
(split('.').pop()) -> identical results; both keep the first copy on a full
tie. Verified the only other dedup path (the standalone Duplicate Cleaner
automation, core/library/duplicate_cleaner.py) was already format-priority-first
and correct -- no change needed there.
Tests: tests/test_duplicate_keep.py (11 -- incl. the exact FLAC-with-missing-
bitrate vs 282 kbps MP3 case, format ranking, within-format tie-breakers, and
edge cases). 147 repair/duplicate tests still pass.
Note: why FLAC bitrate is NULL in the DB is a separate library-scan gap;
format-first ranking makes the keep decision correct regardless.
Reported bug: filling Jamiroquai's "Light Years" single pulled in
Gut's "Light Years" album tracks (different artist, completely
different genre — track titles like "Wound Fuck" and "Eat My Cum"
made the contamination obvious). The Album Completeness auto-fill
was the only file-copying path with a loose 0.50 SequenceMatcher
artist gate, which let unrelated candidates through whenever the
title matched well.
Two-stage defense now sits on the only album-fill code path
(_fix_incomplete_album in core/repair_worker.py):
- Stage 1 — _album_fill_target_artist_allows_track. Pre-search
gate: before doing any library lookup for a missing track,
refuse to operate if the missing track's source artist(s)
don't match the target album's artist. Compilation albums
(album_artist in {'various artists', 'various', 'soundtrack'})
bypass the gate so legitimate VA releases still work. Empty
source-artist metadata also bypasses for backward compat with
older missing-track records that don't carry per-track artist.
- Stage 2 — _album_fill_artist_names_match. Replaces the old
0.50 SequenceMatcher with an alias-aware 0.82 threshold that
uses core.matching.artist_aliases when available (handles
diacritic variants like Beyoncé/Beyonce and known stage names)
with a normalized-similarity fallback if the aliases module
isn't importable. Skipped candidates are logged at debug so a
later support ticket can show what was rejected and why.
Tests in tests/test_repair_worker_album_fill.py reproduce the
exact reported scenario: target album "Light Years" by Gut +
missing track from a Jamiroquai source → skipped with a logged
warning, no copy attempted, wishlist not poisoned. Second test
covers Stage 2 directly with a wrong-artist library candidate.
Existing test_perform_album_fill_copy_branch still passes.
Note: this fix prevents NEW cross-artist contamination via
Album Completeness. It does not clean up the data anomaly that
made Gut's library entry appear to have a "Light Years" album
in the first place — that's a separate data-quality issue worth
investigating if it recurs.
Closes#587. Three coordinated fixes per codex's diagnosis. AcoustID
verification gate left intact — these fixes target the upstream
scanner false-positive surface plus a separate retag-path gap.
Bug 1 — scanner used recordings[0] as authoritative
`core/repair_jobs/acoustid_scanner.py:_scan_file` only checked the
top fingerprint match's metadata. AcoustID often returns multiple
recordings per fingerprint (sample collisions, multi-MB-record
cases) and the wrong-credited recording can outrank the right-
credited one. Foxxify case 2 (Nana / Nana): top match credited the
wrong artist while a lower-ranked candidate matched the user's
expected metadata exactly.
Lifted the verifier's all-candidates check to a shared pure helper
`core/matching/acoustid_candidates.py:find_matching_recording`. Both
verifier and scanner can now ask "given these candidates, does ANY
of them match expected (title, artist)?" with the same contract.
Scanner suppresses the finding when any candidate matches.
Bug 2 — no duration check guards against fingerprint hash collisions
Foxxify case 3: 17-minute mashup edit fingerprinted to a 5-minute
late-70s Japanese hiphop track (different songs, fingerprint hash
collision on a sampled section). Scanner had no signal to detect
this and would have recommended retagging the 17-min file as the
5-min track.
`duration_mismatches_strongly` in the same helper module flags drifts
beyond max(60s, 35%). Scanner now skips findings when the candidate's
duration disagrees strongly with the file's expected duration. Loaded
duration via the existing tracks SQL (added `t.duration` to the
SELECT). Returns False when either side is unknown — no behavior
change for older rows without duration data.
Bug 3 — scanner retag bypassed multi-value ARTISTS tag setting
`core/repair_worker.py:_fix_wrong_song` called `write_tags_to_file`
with single-string artist updates. The writer only wrote TPE1
(single string) and never read the user's
`metadata_enhancement.tags.write_multi_artist` config. Multi-value
ARTISTS tags got stripped on every retag, contradicting the
post-download enrichment pipeline's behavior.
Per codex's pick (option B over routing through enhance_file_metadata),
extended `write_tags_to_file` with an optional `artists_list`
parameter. Each format-specific writer respects the config flag the
same way enrichment.py does:
- ID3: TPE1 stays as joined display string + TXXX:Artists multi-value
- Vorbis/Opus/FLAC: `artist` display string + `artists` multi-value key
- MP4: \xa9ART as list when on, single string when off
Scanner retag derives the per-artist list by splitting AcoustID's
credit through the existing `split_artist_credit` helper (same
separators the matching layer already uses).
Backward compatible: callers that don't pass `artists_list` get the
exact same single-string write as before. No regression for the
write_artist_image button or any other tag_writer caller.
15 tests on the candidate helper + duration guard.
13 tests on the tag_writer multi-value path (write/skip/single/
no-list cases for FLAC + the config-gate helper).
4 new scanner regression tests pinning lower-ranked candidate
suppression, no-suppression when no candidate matches, duration
mismatch skip, no-skip when duration matches.
Existing scanner tests updated for the new 11-column SQL select
(added duration column to fake schema + test row tuples).
Full suite: 3097 passed. Ruff clean.
Ruff S110 (try-except-pass) on the lookup inside
`_build_unresolvable_album_folder_error`. Swallowed exception is benign
(some test stubs don't expose `get_active_media_server` and we fall
back to 'unknown'), but ruff is right that bare pass is a smell.
Logger is the existing repair_worker logger, so this matches the same
"debug-log on optional-input failure" pattern used in
`core/library/path_resolver.py:_collect_base_dirs`.
GitHub issue #558: clicking Auto-Fill / Fix Selected on the Album
Completeness findings page returned a flat "Could not determine album
folder from existing tracks" error with no diagnostic. Reporter is on
Navidrome on Docker — the path resolver in
`core/library/path_resolver.py` couldn't find any of the album's tracks
on disk because Navidrome's Subsonic API doesn't expose filesystem
library paths the way Plex's API does (probed in #476). Default
settings → `library.music_paths` empty → no base directories to probe →
silent None. User had no signal about what to configure.
Not a regression of #476 — that fix targeted Plex auto-discovery and
worked correctly for it. Navidrome was never covered because the
protocol gives the resolver nothing to probe.
Fix scoped to the diagnostic surface, not auto-magic discovery:
- Added `resolve_library_file_path_with_diagnostic` returning
`(resolved, ResolveAttempt)`. ResolveAttempt records what the resolver
tried — `raw_path_existed`, `base_dirs_tried`, `had_config_manager`,
`had_plex_client`. Pure data, no rendering opinions.
- Legacy `resolve_library_file_path` becomes a thin wrapper that
drops the attempt; every existing call site is unchanged.
- `RepairWorker._fix_incomplete_album` now uses the diagnostic helper
and renders a multi-part error via `_build_unresolvable_album_folder_error`:
names the active media server, shows one sample DB-recorded path,
lists every base directory the resolver actually probed, and points
the user at Settings → Library → Music Paths as the actionable fix.
- Distinguishes empty-base-dirs vs tried-and-failed cases so the user
knows whether to add a mount or fix the existing one.
- No auto-probing of common Docker conventions (`/music`, `/media`, etc).
Speculative — could resolve to wrong dirs on the suffix-walk if a
conventional path happens to contain a partial collision. User stays
in control.
12 new tests:
- 7 in `tests/library/test_path_resolver.py`: tuple-shape contract,
raw-path-existed short-circuit, base-dirs listed even on walk
failure, had-flags reflect caller inputs, no-base-dirs returns
None with empty attempt, legacy `resolve_library_file_path`
delegates correctly across happy / suffix-walk / failure paths.
- 8 in `tests/test_repair_worker_unresolvable_folder_error.py`:
active server name in error, sample DB path verbatim, base dirs
listed, empty-base-dirs phrased differently, Settings hint always
present, defensive against None attempt / missing sample / missing
config_manager.
Full pytest sweep: 2774 passed.
- derive the destination server_source from the target album context
- write it on copied rows and retarget moved rows too
- cover the copy branch with a regression test
Mostly progress-callback try/except (caller-provided fns we don't
control) and best-effort file-move / DB-cleanup paths in the
auto-fixers. All converted to `logger.debug(...)`. No control-flow
changes.
Refs #369
Discord report: Duplicate Detector card said "372 findings" and Cover
Art Filler said "60 findings", but clicking the Findings tab's Pending
filter showed 0. User read it as "findings aren't being created" —
looked like a detector bug.
Actual cause: the badge sourced ``last_run.findings_created``
(historical "found in last scan") without considering current state.
After the user (or bulk-fix automation) resolved or dismissed those
findings, they no longer appeared on the Pending tab — but the badge
kept showing the last-scan number in red urgent styling.
Backend was correct end-to-end: detectors create pending rows,
bulk-fix moves them to resolved, Findings tab filters by status.
Only the badge display lied about current state.
Fix:
- ``RepairWorker._get_pending_count_by_job()`` — single SQL aggregation
returning ``{job_id: pending_count}`` for every job with pending
findings. O(1) lookup per job instead of N round trips.
- ``get_all_job_info()`` calls it once per request and adds
``pending_findings_count`` to each job's API response.
- ``enrichment.js`` job card now branches on the count:
- ``> 0`` → red ``"X pending"`` badge (urgent, action needed)
- ``= 0`` AND last scan found something → muted grey ``"X found in
last scan"`` (historical context, no action needed)
- New CSS class ``.repair-flow-badge.findings-historical`` for the
muted slate color so the two states are visually distinct.
User-visible result with the screenshotted state (372 dup / 60 cover-
art findings, all resolved):
- Before: red "372 findings" / "60 findings" — implied 432 things to
do, but Findings tab showed 0 pending
- After: grey "372 found in last scan" / "60 found in last scan" —
the badge text tells the user the count is historical, no surprise
when Pending is empty
Tests: 3 new tests in ``tests/test_create_finding_dedup_counter.py``
pin the per-job pending count helper:
- returns ``{job_id: count}`` based on status='pending' rows only;
resolved + dismissed rows excluded
- empty dict when no pending findings exist
- gracefully returns ``{}`` on DB error (badge falls back to
historical count via the existing JS ``or 0`` safety)
2188/2188 full suite green. Pure UI/state-display fix — no detector
logic, no backend behavior change.
`_create_finding` silently dedup-skipped re-discovered issues but
the caller incremented `findings_created` regardless. So a re-scan
that found the same issues as a prior scan reported 364 findings
in the badge while 0 NEW pending rows hit the db, leaving the
findings tab empty.
`_create_finding` now returns bool (True on insert, False on
dedup-skip / db error). All 16 repair jobs updated to only
increment `findings_created` on True. Added `findings_skipped_dedup`
counter surfaced in scan log: "Done: X scanned, 0 fixed, 0
findings (363 already existed), 0 errors".
Also fixed a missing `job_id` kwarg in album_tag_consistency that
was silently breaking finding creation for that scan.
Discord report (Samuel [KC]): tracks of the same album sometimes carry
different MUSICBRAINZ_ALBUMID tags, which causes Navidrome (and other
media servers grouping by album MBID) to split the album into multiple
entries. Two-part fix — one for existing libraries, one for the root
cause that lets new imports drift.
Part 1 — Detector + fix action (catches existing dissenters):
`core/repair_jobs/mbid_mismatch_detector.py`:
- New helpers: `_read_album_mbid_from_file` and
`_write_album_mbid_to_file` use the Picard-standard tag conventions
(`TXXX:MusicBrainz Album Id` for MP3, `MUSICBRAINZ_ALBUMID` for
FLAC/OGG, `----:com.apple.iTunes:MusicBrainz Album Id` for MP4).
- New scan phase `_scan_album_mbid_consistency` runs after the
existing track-MBID scan: groups tracks by DB `album_id`, reads
each track's embedded album MBID, finds the consensus
(most-common) MBID via `Counter`, flags dissenters. Tracks without
an album MBID at all are skipped (they don't break Navidrome —
only an explicit MBID disagreement does). Albums where MBIDs are
perfectly tied (no clear consensus) are skipped too — surface as
a manual decision instead of fixing toward a 1/N tie.
- New finding type `album_mbid_mismatch` carries `consensus_mbid`,
`wrong_mbid`, `consensus_count`, `total_tracks_with_mbid`, and a
human-readable reason string.
`core/repair_worker.py`:
- Added `'album_mbid_mismatch': self._fix_album_mbid_mismatch` to the
fix dispatch dict and to the `fixable_types` tuple so auto-fix +
bulk-fix paths pick it up.
- New `_fix_album_mbid_mismatch` method reads `consensus_mbid` from
finding details, resolves the dissenter's file path via the shared
library resolver, calls `_write_album_mbid_to_file` to rewrite the
tag in place. Doesn't touch the album's other tracks (they're
already in agreement).
Part 2 — Root cause fix (prevents new SoulSync imports from drifting):
The original in-memory `mb_release_cache` in `core/metadata/source.py`
maps `(normalized_album, artist) -> release_mbid` so per-track
enrichment of the same album hits the cache and writes the same
MUSICBRAINZ_ALBUMID to every track. That cache is bounded (4096
entries) and in-process — so cache eviction (when other albums are
processed in between) and server restart can BOTH cause
inconsistency. Per-track album-name variation (e.g. some tracks
tagged `"Album"`, others tagged `"Album (Deluxe)"`) and per-track
artist variation (features) make it worse.
`core/metadata/album_mbid_cache.py` (new module):
- DB-backed `lookup(normalized_album, artist) -> release_mbid` and
`record(...)` functions. Same key shape as the in-memory cache.
- Strict additive design: every public function is wrapped in
try/except and degrades to None / no-op on ANY database error.
The existing in-memory cache + MusicBrainz lookup remains the
authoritative fallback. If this module breaks, downloads continue
exactly as they would today.
`database/music_database.py`:
- New `mb_album_release_cache` table with composite primary key
`(normalized_album_key, artist_key)`. Reverse-lookup index on
`release_mbid` for future debug tooling. Created via the existing
`CREATE TABLE IF NOT EXISTS` migration pattern — idempotent, no
schema version bump needed.
`core/metadata/source.py`:
- Surgical change inside the existing `embed_source_ids`
in-memory-cache-miss branch: BEFORE calling MusicBrainz, consult
the persistent cache. If a previous SoulSync run already resolved
this album's release MBID, reuse it. After a successful MB lookup,
store in BOTH caches. Both calls wrapped in defensive try/except
so any failure falls through to existing logic.
Tests:
- `tests/metadata/test_album_mbid_cache.py` — 16 cache tests:
round-trip, idempotent re-record, overwrite semantics, clear_all,
album+artist independence (no Greatest Hits collisions),
defensive None-on-empty-input, graceful degradation when the DB
is unavailable / connection raises / commit fails, schema sanity
(table + index exist after init).
- `tests/test_album_mbid_consistency.py` — 13 detector tests:
tag read/write round-trip on real FLAC files, Picard-standard tag
descriptors, defensive paths (unreadable file, empty input),
detector behavior (agreement → no flags, lone dissenter → flag,
ties → no flag, single-track albums → skipped, no-MBID tracks →
skipped, unresolvable file paths → skipped).
- `tests/metadata/test_metadata_enrichment.py` — added autouse
fixture monkeypatching the persistent cache to no-op for tests in
this file. The existing tests pin per-call MB counts and
in-memory cache state; without the fixture, persistent rows from
earlier tests would bypass the MB call. Persistent layer has its
own dedicated tests.
Verified: 1782 tests pass (29 new), ruff clean, smoke test confirms
end-to-end cache round-trip works.
WHATS_NEW entry under '2.4.2' dev cycle.
GitHub issue #476 (gabistek, Docker on Arch host): "Auto-Fill" / "Fix
Selected" on the Album Completeness findings page returned
"Could not determine album folder from existing tracks" for every album.
Reproduces on any setup where the media-server library lives outside the
SoulSync transfer/download folders — Docker is the headline case but
native installs that point Plex at a NAS via SMB hit it too.
Root cause: `core/repair_worker.py:_resolve_file_path` only probed the
transfer + download folders. Docker users have their Plex/Jellyfin
library bind-mounted at /music (or similar) — neither configured in
SoulSync. Every existing track got silently treated as missing, so
`album_folder` stayed None and the fix workflow bailed.
The same incomplete logic was duplicated four more times in the
repair_jobs/ modules, all with the same bug. Album Completeness was
just the most user-visible — the same setups were also producing false
"missing file" findings from Dead File Cleaner, silent skips in
MBID Mismatch Detector, etc.
The web server already had the correct logic at
`web_server.py:_resolve_library_file_path` (probes transfer + download
+ Plex-reported library locations + user-configured library.music_paths).
The repair workers had never been updated to match.
Fix:
- New `core/library/path_resolver.py` extracts the union logic into a
single shared function `resolve_library_file_path()`. Probes (in
order, deduped): explicit transfer/download kwargs, config-derived
soulseek.transfer_path/download_path, Plex-reported library
locations (when a plex_client is passed), user-configured
library.music_paths. Each defensive: malformed config or a flaky
Plex client degrades to the dirs that did succeed.
- `core/repair_worker.py:_resolve_file_path` becomes a delegating
wrapper preserving the legacy signature, with a new `config_manager`
kwarg. All 15 in-tree call sites updated to thread
`self._config_manager` through.
- `core/repair_jobs/dead_file_cleaner.py`,
`mbid_mismatch_detector.py`, and `lossy_converter.py` get the same
treatment: duplicate function replaced with a thin wrapper, call
sites pass `context.config_manager`.
- `core/repair_jobs/acoustid_scanner.py` and
`unknown_artist_fixer.py` (which used to import from repair_worker)
now call the shared resolver directly with `context.config_manager`.
Side benefit: every other repair job (Dead File Cleaner, MBID
Mismatch Detector, Lossy Converter, AcoustID Scanner, Unknown Artist
Fixer) also stops missing files in the media-server library mount.
Single fix unblocks five user-visible features.
Tests: `tests/library/test_path_resolver.py` — 20 cases covering all
four base-dir sources, suffix-walk algorithm, dedup, defensive paths
(None plex client, malformed config entries, raising config_manager.get,
broken plex attribute access), Docker path translation. Full suite
1677 passed locally.
WHATS_NEW entry under '2.4.2' dev cycle.
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.
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.
New repair job that scans each artist in the library, fetches their
full discography from metadata sources, and creates findings for any
tracks not already owned. Users review findings and click "Add to
Wishlist" to queue missing tracks for download.
Respects content filters (live/remix/acoustic/instrumental/compilation)
and release type filters (album/EP/single). Opt-in, disabled by default,
runs weekly, processes up to 50 artists per run with rate limiting.
Jobs with interval_hours set to 0 caused ZeroDivisionError in
_pick_next_job staleness calculation. Now skips jobs with invalid
(zero or negative) intervals.
The retag fix for AcoustID mismatches was only updating the DB
record (title, artist_id) without writing corrected tags to the
actual audio file. Users would click Fix, the finding disappeared,
but the file on disk stayed unchanged. Now writes title and artist
tags to the file via Mutagen after the DB update.
Also fixed artist INSERT missing server_source when creating a new
artist during retag — now uses the active media server value.
The redownload branch had `import json, uuid` locally inside the function,
which caused Python to treat `uuid` as a local variable for the entire
function scope. When the retag branch ran instead, `uuid` was unbound.
Both modules are already imported at the top of the file.
Repair-worker album fills now generate explicit track IDs when copying rows, instead of relying on SQLite auto-assignment that no longer exists for TEXT primary keys. The unknown-artist fixer now does the same for new artists.
Also add a regression test for the album-fill copy branch and keep the AcoustID scanner resilient to legacy null-ID rows.
Rewrote the AcoustID scanner job to scan all library tracks (via DB file
paths resolved to disk) instead of only the Transfer folder. Checkpoints
by track ID for robust resume across restarts. Defaults changed to
enabled, 24h interval, batch size 200.
Added _fix_acoustid_mismatch handler with three actions:
- retag: update DB title/artist to match actual audio content
- redownload: add expected track to wishlist and delete wrong file
- delete: remove wrong file and DB record
This catches cases like a file tagged as "Dinosaur Bones" that is
actually "Helicopters" — the scanner fingerprints the audio, detects
the mismatch, and the user can fix it from Library Maintenance findings.
Centralize the ordered metadata source list and source-priority helper so album completeness and the repair worker follow the same Deezer/iTunes-first fallback order. This also removes the last duplicate priority logic from the touched repair paths.
Album completeness and any other repair job now uses the centralized source/client helpers instead of a worker-local Spotify client or override plumbing
- This keeps source selection aligned with the configured primary provider and removes the last Spotify-only special case from the job path.
This change ultimately is a step towards further centralizing the Spotify client access and the associated `is_spotify_authenticated` check.
- Currently these look-ups are done all over the place in different feature implementations directly, but moving forward, any feature that uses `get_primary_client` or `get_client_for_source` to access the Spotify client, won't have to duplicate any rate-limiting or auth checks as long as these getters are used
Album completeness and downstream repair flow now follow the configured
primary provider first, with Discogs and Hydrabase support added alongside
existing Spotify, iTunes, and Deezer paths.
Keep spotify_track_id for compatibility while preserving source-aware track
IDs for provider-neutral handling.
- Add interruptible stop events to background workers so shutdown
wakes out of long sleeps instead of waiting on fixed delays.
- Stop scan managers, repair worker, executors, and cleanup helpers
deterministically so process exit does not leave background threads
alive.
- Add startup warnings for stale SQLite WAL/SHM sidecars so unclean
shutdowns are easier to spot before init/migration errors cascade.
- Prevent forced kills from leaving SQLite sidecars behind, which
made rollbacks to older branches fail with malformed database
errors.
The finding dedup check only looked for 'pending' and 'resolved' status,
missing 'dismissed'. Dismissed findings were recreated as new entries on
every scan. Now includes 'dismissed' in the dedup check.
Orphan file detector improvements:
- Increased path suffix matching depth from 3 to 4 segments (covers
Genre/Artist/Album/track.flac paths)
- Added filename-based fallback when Mutagen can't read file tags —
parses title from "NN - Title [Quality].ext" pattern and matches
against parent/grandparent folder names as artist
All callers of _create_fallback_client() and _get_configured_fallback_source()
now use get_primary_client() and get_primary_source() directly. No more
legacy alias usage anywhere in the codebase.
New repair job that scans the library for tracks filed under "Unknown
Artist" and corrects them. Resolves correct metadata by:
1. Reading embedded file tags (if file has correct artist)
2. Looking up by source track ID (Spotify/Deezer/iTunes)
3. Searching by title as last resort
Dry run mode (default) creates findings for review. Live mode re-tags
the audio file, moves it to the correct folder structure, and updates
the database. Includes fix handler for applying individual findings.
Duplicate finding detail now shows each version as clickable — user
can choose which to keep instead of relying on auto-selection. Added
track_number as tiebreaker in auto-pick (higher track number wins
over 01, catching leftover duplicates from the playlist sync track
number bug). Track number displayed in the detail view for clarity.
Dead file fix now prompts with two options: Re-download (existing
behavior — adds to wishlist + deletes DB entry) or Remove from DB
(just deletes the dead track record without re-downloading). Works
for both single and bulk fix. Solves the issue where dismissing
dead files didn't remove the underlying track record, causing them
to reappear on every scan.
New repair job that scans track and album titles for live performances,
commentary, interviews, skits, and spoken word content. Creates findings
for user review — no auto-fix.
Configurable per content type (live, commentary, interviews, spoken word),
with optional album title scanning and tracks/albums scope toggle.
Fix action removes track from DB + deletes file, cleans up empty albums
and directories. Follows existing repair job pattern exactly.
fix_finding() was using a potentially stale transfer_folder that was
only refreshed during scheduled job runs, not on manual fix attempts.
Now re-reads the transfer path from config before each fix, matching
the same logic used by _run_next_job().
Also surfaces fix failure reasons to the user — bulk fix now logs each
failure with finding ID and error, and the frontend toast shows the
actual error message instead of just "X failed".
The fix was failing silently — returning error results without
writing to any log. Added info/warning logs at each failure point
(missing paths, source not found, path escapes transfer, destination
conflict) so Docker path resolution issues can be diagnosed.
When missing_tracks was empty (API unavailable at scan time) or the
album was completed between scan and fix, the handler returned a 400
error. Now it re-fetches missing tracks from Spotify/iTunes/Deezer at
fix time and auto-resolves findings where the album is already complete.
New maintenance job scans albums for tracks with mismatched album names, album
artist names, or MusicBrainz release IDs. These inconsistencies cause Navidrome
and other media servers to split one album into multiple entries. The fix
normalizes outlier tracks to the majority value by rewriting file tags.
- All 9 enrichment workers: stop auto-retrying 'error' status items (was infinite loop)
Only 'not_found' items retry after configured days; errors require manual full refresh
- Cover art dedup: check both 'pending' AND 'resolved' findings to prevent recreation
- Cover art scanner: top-level Spotify rate limit check skips Spotify entirely when
banned, falls back to iTunes/Deezer only, logs once instead of spamming 429s
Opus ffmpeg command now uses -map 0:a to extract only the audio stream,
matching the user's working command and preventing picture stream
interference with the encoder. After conversion, cover art is embedded
from the source FLAC using Mutagen: Opus gets METADATA_BLOCK_PICTURE
(base64-encoded per OGG spec), AAC gets MP4Cover. Applied to both
the post-download lossy copy and the repair job fix handler.
The lossy converter fix handler now reads codec and bitrate fresh
from the Settings page when converting, not from what was stored
in the finding details at scan time. Users can change their codec
preference after scanning and Fix All will use the new setting.
The lossy converter fix handler now reads delete_original from its
own job settings (repair.jobs.lossy_converter.settings.delete_original)
instead of the global lossy_copy.delete_original. Defaults to false.
Separate from the per-download Blasphemy Mode toggle in Settings.
New library maintenance job that scans for FLAC files missing a lossy
copy (MP3/Opus/AAC) and creates findings. Fix action converts via
ffmpeg using the configured codec/bitrate from Settings. Supports
Blasphemy Mode (delete original + update DB path). Finding details
store codec/bitrate from scan time for consistency. Disabled by
default, manual-run only, no auto-schedule.
Orphan detector: add normalized tag matching that strips parentheticals
and brackets (feat. X, [FLAC 16bit], etc.) and tries first-artist-only
for comma-separated artists. Prevents false orphan flags for tracks
like "The Mountain (feat. Dennis Hopper...)" that exist in DB as
"The Mountain". All lookups remain O(1) set operations.
Orphan fix: replace auto-delete with user choice prompt. Single Fix
and Fix All both show modal asking "Move to Staging" or "Delete".
Move to Staging relocates file to import staging folder for proper
re-import with metadata matching. Fix action flows through API
endpoint → repair_worker.fix_finding → _fix_orphan_file handler.
Staging path uses docker_resolve_path for container compatibility.
Wishlist entries from auto-fill were missing album images, album ID,
track number, disc number, and total tracks. Downloads would have no
cover art and wrong file organization. Now includes full album context
matching the standard wishlist data format.
- Only emit batch_complete automation event when successful_downloads > 0
- Remove int() cast on album_id in _fix_incomplete_album — Plex/Jellyfin
use hash/text IDs that SQLite stores as-is in INTEGER columns
- Use string comparison for album_id equality check
Finding was created with entity_id=None (file-based) but fix handler
required entity_id for DB update. Rewrote handler to work with file_path
as primary — writes corrected track number to file tags, renames file
if needed, updates DB path. Also stores total_tracks in finding details
for correct tag writing.