Reorganize: hint at Unknown Artist Fixer for placeholder-metadata rows

Phase B of foxxify discord report. Pre-#524 manual-import bug left
some albums in the library with `artist=Unknown Artist` and `album.title
= <numeric album_id>`. Reorganize couldn't place them (no usable
metadata source ID) and emitted a generic "run enrichment first" hint
that doesn't apply — enrichment can't fix these rows. The right tool
is the existing `Fix Unknown Artists` repair job (reads file tags,
re-resolves metadata, re-tags + moves files).

Discoverability gap, not a logic gap. Reorganize now detects the bad-
metadata shape (Unknown Artist OR album.title that's a 6+ digit
numeric id) and emits a clear "run the Fix Unknown Artists repair
job" hint at both reason-emit sites (planner + executor). No
duplication of fixer logic.

WHATS_NEW entry covers both Phase A (orphan-format sibling handling,
already committed in d944a16) and Phase B since they ship in the same
PR for the same reporter.

20 new tests pin helpers + reason routing.
This commit is contained in:
Broque Thomas 2026-05-10 20:16:28 -07:00
parent d944a166f8
commit b5b6673216
3 changed files with 190 additions and 9 deletions

View file

@ -174,6 +174,45 @@ def authed_sources() -> List[dict]:
return out
_UNKNOWN_ARTIST_NAMES = {'unknown artist', 'unknown', ''}
def _is_unknown_artist(artist_name: Optional[str]) -> bool:
if not artist_name:
return True
return str(artist_name).strip().lower() in _UNKNOWN_ARTIST_NAMES
def _looks_like_album_id_title(album_title: Optional[str]) -> bool:
"""Pre-#524 manual-import bug left some albums with a numeric
album_id stored as `albums.title`. Detect that shape so reorganize
can point the user at Unknown Artist Fixer instead of the generic
'run enrichment' hint."""
if not album_title:
return False
stripped = str(album_title).strip()
return len(stripped) >= 6 and stripped.isdigit()
def _unresolvable_reason(album_data: dict, primary_source: str, strict_source: bool) -> str:
"""Reason text for albums reorganize can't place. Surfaces the
Unknown Artist Fixer hint when the row matches the bad-metadata
shape (Unknown Artist OR album-id-as-title) that fixer reads
file tags + re-resolves metadata, which reorganize itself doesn't
do."""
artist = album_data.get('artist_name')
title = album_data.get('title')
if _is_unknown_artist(artist) or _looks_like_album_id_title(title):
return (
"Album has placeholder metadata (Unknown Artist or numeric "
"title) — run the 'Fix Unknown Artists' repair job to "
"recover real artist/album from file tags before reorganize"
)
if strict_source:
return f"Source '{primary_source}' has no usable tracklist for this album"
return "No metadata source ID for this album"
def _resolve_source(album_data: dict, primary_source: str, strict_source: bool = False):
"""Walk the configured source priority looking for the first source
we have an ID for AND that returns a usable tracklist.
@ -552,11 +591,7 @@ def plan_album_reorganize(
album_data, primary_source, strict_source=strict_source
)
if not source:
reason = (
f"Source '{primary_source}' has no usable tracklist for this album"
if strict_source else
"No metadata source ID for this album"
)
reason = _unresolvable_reason(album_data, primary_source, strict_source)
return {
'status': 'no_source_id', 'source': None, 'api_album': None,
'total_discs': 1,
@ -1225,13 +1260,19 @@ def reorganize_album(
)
if plan['status'] == 'no_source_id':
summary['status'] = 'no_source_id'
summary['errors'].append({
'error': (
if _is_unknown_artist(album_data.get('artist_name')) or _looks_like_album_id_title(album_data.get('title')):
err_text = (
f"Album '{album_data.get('title', '?')}' has placeholder metadata "
"(Unknown Artist or numeric title) — run the 'Fix Unknown Artists' "
"repair job to recover real artist/album from file tags first."
)
else:
err_text = (
f"No reachable metadata source ID for '{album_data.get('title', '?')}'"
"run enrichment first to populate at least one of "
"spotify_album_id / itunes_album_id / deezer_id / discogs_id / soul_id."
),
})
)
summary['errors'].append({'error': err_text})
return summary
source = plan['source']

View file

@ -0,0 +1,139 @@
"""Pin the unresolvable-reason hint in library_reorganize.
Discord report (Foxxify) Phase B: stuck "Unknown Artist / <album_id>"
folders left over from the pre-#524 manual-import bug. Reorganize
couldn't move them (no usable metadata source ID) and emitted a
generic "run enrichment first" message but enrichment can't fix
these rows. The right tool is the existing Unknown Artist Fixer
repair job (reads file tags, re-resolves metadata, re-tags + moves
file). These tests pin the detection helpers + reason text so the
hint stays correct as the file evolves.
"""
from __future__ import annotations
from core.library_reorganize import (
_is_unknown_artist,
_looks_like_album_id_title,
_unresolvable_reason,
)
class TestIsUnknownArtist:
def test_unknown_artist_string(self):
assert _is_unknown_artist("Unknown Artist") is True
def test_unknown_artist_lowercase(self):
assert _is_unknown_artist("unknown artist") is True
def test_unknown_artist_with_whitespace(self):
assert _is_unknown_artist(" Unknown Artist ") is True
def test_unknown_alone(self):
"""Some import paths set just 'Unknown' (no 'Artist' suffix)."""
assert _is_unknown_artist("Unknown") is True
def test_empty_string(self):
assert _is_unknown_artist("") is True
def test_none(self):
assert _is_unknown_artist(None) is True
def test_real_artist(self):
assert _is_unknown_artist("Radiohead") is False
def test_artist_containing_unknown_substring(self):
"""Substring 'unknown' shouldn't trigger — only the exact
placeholder names. Real artists can contain that word."""
assert _is_unknown_artist("Unknown Mortal Orchestra") is False
class TestLooksLikeAlbumIdTitle:
def test_long_numeric_string_is_album_id(self):
"""Reporter's case: album.title set to the numeric album_id
by the pre-#524 manual-import bug."""
assert _looks_like_album_id_title("1234567890") is True
def test_six_digit_minimum(self):
"""Edge: 5 digits is too short to be a real album_id pattern
could just be an album titled '12345'. Cutoff is 6+."""
assert _looks_like_album_id_title("12345") is False
assert _looks_like_album_id_title("123456") is True
def test_alphanumeric_is_not_album_id(self):
"""Real album titles with numbers (Blink-182, Sum 41, etc.)
must not trigger."""
assert _looks_like_album_id_title("Sum 41") is False
assert _looks_like_album_id_title("1999") is False # short
def test_empty_string(self):
assert _looks_like_album_id_title("") is False
def test_none(self):
assert _looks_like_album_id_title(None) is False
def test_real_album_title(self):
assert _looks_like_album_id_title("In Rainbows") is False
def test_whitespace_stripped(self):
"""Defensive: leading/trailing whitespace shouldn't fool the
detector."""
assert _looks_like_album_id_title(" 1234567890 ") is True
class TestUnresolvableReason:
def test_unknown_artist_routes_to_fixer_hint(self):
"""Reporter's exact case — Unknown Artist row should point
at the Fix Unknown Artists repair job, not generic
enrichment advice."""
reason = _unresolvable_reason(
{'artist_name': 'Unknown Artist', 'title': 'Some Album'},
primary_source='deezer',
strict_source=False,
)
assert "Fix Unknown Artists" in reason
assert "placeholder metadata" in reason
def test_album_id_title_routes_to_fixer_hint(self):
"""Reverse case — album.title is a numeric album_id."""
reason = _unresolvable_reason(
{'artist_name': 'Real Artist', 'title': '9876543210'},
primary_source='deezer',
strict_source=False,
)
assert "Fix Unknown Artists" in reason
def test_real_album_with_no_source_id_keeps_enrichment_hint(self):
"""Sanity: real artist + real title but no source ID still
gets the generic enrichment hint. Don't mis-route normal
no-source-ID albums into the fixer flow."""
reason = _unresolvable_reason(
{'artist_name': 'Radiohead', 'title': 'In Rainbows'},
primary_source='deezer',
strict_source=False,
)
assert "Fix Unknown Artists" not in reason
assert "No metadata source ID" in reason
def test_strict_source_path_keeps_strict_text(self):
"""When strict_source=True and the row is fine (real artist
+ real title), the existing strict-source message is
preserved. Hint only fires for the bad-metadata shape."""
reason = _unresolvable_reason(
{'artist_name': 'Radiohead', 'title': 'In Rainbows'},
primary_source='spotify',
strict_source=True,
)
assert "Fix Unknown Artists" not in reason
assert "spotify" in reason.lower()
assert "tracklist" in reason
def test_strict_source_with_unknown_artist_prefers_fixer_hint(self):
"""Bad-metadata shape wins over strict-source — Unknown
Artist always needs the fixer regardless of source mode."""
reason = _unresolvable_reason(
{'artist_name': 'Unknown Artist', 'title': 'Whatever'},
primary_source='spotify',
strict_source=True,
)
assert "Fix Unknown Artists" in reason

View file

@ -3416,6 +3416,7 @@ const WHATS_NEW = {
'2.4.3': [
// --- post-release patch work on the 2.4.3 line — entries hidden by _getLatestWhatsNewVersion until the build version bumps ---
{ date: 'Unreleased — 2.4.3 patch work' },
{ title: 'Library Reorganize: Stop Leaving Orphan Audio Files Behind + Hint For Unknown-Artist Rows', desc: 'discord report (foxxify): library reorganize wasn\'t organizing everything. two distinct gaps. (A) lossy-copy users have `track.flac` AND `track.opus` side-by-side at the source; the db only knows about ONE of those (whichever is the canonical library entry). reorganize moved the canonical, left the other format orphaned at the old location, and the empty-folder cleanup never fired because the source dir still had audio in it. fix: at the per-track finalize step the reorganize code now scans the source dir for sibling-stem audio files (same filename stem, audio extension, different format), moves them to the same destination dir as the canonical with the renamed stem + their original extension, then proceeds with the existing source removal + cleanup. preserves both formats post-move so users keep their flac archive AND their opus library copy. (B) old "Unknown Artist / album_id / 0 tracks" rows left over from the pre-#524 manual-import bug couldn\'t be relocated because the album row has no usable metadata source id — reorganize emitted a generic "run enrichment first" message that doesn\'t apply (enrichment can\'t fix these rows; they need their real metadata recovered from file tags). these are the existing `Fix Unknown Artists` repair job\'s domain — reads file tags, re-resolves artist/album/track via configured metadata source, re-tags + moves. reorganize now detects the bad-metadata shape (Unknown Artist OR album.title that\'s a 6+ digit numeric id) and emits a clear "run the Fix Unknown Artists repair job to recover real artist/album from file tags first" hint instead, pointing the user at the right tool. fixer was already implemented and handles the case end-to-end — discoverability gap, not a logic gap. 31 new tests pin: orphan-format detection (canonical-vs-sibling, multi-format, defensive on missing source dir, sidecar exclusion), sibling-move with renamed-stem propagation + dst-dir creation + idempotent re-runs + os-failure handling, and the unknown-artist-hint detection helpers (placeholder names, numeric-id title detection at 6+ digit cutoff, real-album-with-no-source-id keeps the generic enrichment hint, strict-source mode preserved when artist/title look fine).', page: 'library' },
{ title: 'AcoustID Scanner: Compilation Albums No Longer Flag Every Track', desc: 'discord report (skowl): downloaded a compilation album like "high tea music: vol 1" where every track has a different artist (eclypse, andromedik, t & sugah, gourski, himmes, sektor, lexurus, etc.) and the acoustid scanner flagged every single track as wrong song — the file tag had the correct per-track artist (e.g. "eclypse" for "city lights") but the scanner compared against the album-level artist ("andromedik", the curator). raw similarity 12% → wrong song flag. the multi-value-credit fix from the prior pr (foxxify) didn\'t help because both sides were single-value but DIFFERENT artists. cause: scanner sql joined `artists` table via `tracks.artist_id` which points at the ALBUM artist, not the per-track artist. but `tracks.track_artist` column was already populated with the correct per-track value by every server scan + auto-import path that handles compilations. scanner just wasn\'t reading it. fix: changed the scanner select to `COALESCE(NULLIF(t.track_artist, \'\'), ar.name)` — prefers per-track artist when populated, falls back to album artist for legacy rows / single-artist albums where track_artist is null. NULLIF handles the empty-string-instead-of-null case for legacy data. composes with foxxify\'s multi-value fix — for the rare compilation track where acoustid ALSO returns a multi-value credit, both paths work together. 2 new tests pin: compilation track uses per-track artist (reporter\'s exact case), null/empty track_artist falls back to album artist via coalesce.', page: 'library' },
{ title: 'AcoustID Scanner: Multi-Artist Songs No Longer Flagged As Wrong', desc: 'discord report (foxxify): the acoustid scanner repair job was flagging multi-artist tracks as "wrong song" because acoustid returns the full credit ("okayracer, aldrch & poptropicaslutz!") while the library db carries only the primary artist ("okayracer"). raw similarity scored ~43% — well below the 60% threshold — so the scanner created a wrong-song finding even though the audio was correct. user couldn\'t fix without lowering the global artist threshold to ~30% (which would let real mismatches through). cause: scanner used raw `SequenceMatcher` comparison that doesn\'t recognise the primary artist is just one of several contributors in the credit string. fix: extended the shared `core/matching/artist_aliases.py::artist_names_match` helper (lifted in #441) with credit-token splitting on common separators (comma, ampersand, semicolon, slash, plus, "feat.", "ft.", "featuring", "with", "vs.", "x"). when actual artist contains separators, helper splits into individual contributors and checks each against expected — primary-in-credit cases now resolve at 100% instead of 43%. composes with existing alias path so cross-script multi-artist credits ("hiroyuki sawano" expected, "澤野弘之, featured" actual) work too. wired into `core/repair_jobs/acoustid_scanner.py` — replaces the raw similarity call. acoustid post-download verifier already used the helper from #441 so it inherits the same fix automatically. 14 new tests pin: split-by-separator across 12 credit-string formats, primary at start/middle/end of credit, no-mask on genuine mismatches, single-token actual falls through to direct compare, multi-value composes with aliases, threshold still respected, end-to-end scanner integration with reporter\'s exact case (okayracer in okayracer-aldrch-poptropicaslutz credit → no finding), end-to-end scanner still flags genuine mismatches.', page: 'library' },
{ title: 'Deezer Cover Art: Embedded Covers No Longer Look Blurry', desc: 'discord report (tim): downloaded cover art via deezer metadata source came out visibly blurry in navidrome and on phones — particularly noticeable on large displays. cause: deezer\'s api returns `cover_xl` urls at 1000×1000 but the underlying cdn serves up to 1900×1900 by rewriting the size segment in the url path. soulsync wasn\'t doing the rewrite — same as iTunes mzstatic and spotify scdn already get upgraded. now `_upgrade_deezer_cover_url` (mirrors `_upgrade_spotify_image_url` pattern) rewrites the cdn url to request 1900×1900 before download. cdn serves source-native size when source < target so asking for 1900 on smaller-source albums returns the same bytes (no upscaling, no failure). applied at both download sites — auto post-process flow + the enhanced library view\'s "write tags to file" feature. existing `prefer_caa_art` toggle in settings → library → post-processing remains as the orthogonal workaround for users who want even higher quality (musicbrainz cover art archive, often 3000×3000+). 16 new tests pin: standard upgrade, alternate dzcdn host, artist picture urls, custom target sizes, idempotency on already-upgraded urls, defensive on non-deezer urls (spotify/itunes/caa/lastfm/random), empty/none handling.', page: 'settings' },