From c11a742e584fda9c9e4d00fe53b61b625d96cb4b Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Mon, 22 Jun 2026 09:30:04 -0700 Subject: [PATCH] Multi-disc albums: never write a disc-less track (floor disc to >=1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sokhi: some tracks in a multi-disc album showed up with a null disc in Jellyfin and floated ungrouped above the disc sections (tracks 3/9/15). Mechanism: the tag writer only wrote the disc tag when disc_number was truthy, and enrichment CLEARS all tags before rewriting — so a track whose disc came back 0 / None / '' lost its disc entirely. Those falsy values slipped through because source.py defaulted with 'is not None' (a literal 0 passed) and context.py's or-chain can yield None; this happens especially when a track resolves to a different edition than its siblings. Fix: normalize_disc_number() floors any value to >=1, and enrichment now writes the disc tag UNCONDITIONALLY (like the track number) so a track is never disc-less. source.py uses the same floor so the metadata dict (and the 'Disc N' folder org) stays consistent. Valid multi-disc values are preserved untouched. Tests: normalize floors 0/None/''/negatives/non-numeric -> 1, preserves 1..4 and tolerates '2.0'. 1406 enrich/metadata/track-number tests green, ruff clean. NOTE: this fixes the SYMPTOM (never ungrouped). The deeper cause — a track matching a DIFFERENT edition/release than its album siblings (the Persona-box-set mismatch in the sample file; the canonical-version problem) — is separate and still open. --- core/imports/track_number.py | 21 ++++++++++++++ core/metadata/enrichment.py | 17 +++++++---- core/metadata/source.py | 6 +++- tests/imports/test_normalize_disc_number.py | 31 +++++++++++++++++++++ 4 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 tests/imports/test_normalize_disc_number.py diff --git a/core/imports/track_number.py b/core/imports/track_number.py index 63b53b03..cbe0e251 100644 --- a/core/imports/track_number.py +++ b/core/imports/track_number.py @@ -159,3 +159,24 @@ def resolve_track_number( # value the pre-fix resolver would have used. A correctly-named file # with a stale/wrong embedded tag is therefore never regressed. return _coerce_positive(embedded_track_number) + + +def normalize_disc_number(value) -> int: + """Coerce a disc value to a positive int, defaulting to 1. + + Every track in a multi-disc album MUST carry a disc number, or Jellyfin/Plex + leave the disc-less ones floating ungrouped above the disc sections (Sokhi's + "tracks 3/9/15 at the top"). Upstream sources can hand back 0, None, '', or a + non-numeric string for some tracks — especially when a track resolved to a + different edition than its siblings — and the tag-writer only wrote the disc + tag when it was truthy, so those tracks lost it entirely on the clear-then- + rewrite. Flooring to >=1 here means a track is never written disc-less. + """ + try: + n = int(value) # int, float, or clean int-string + except (TypeError, ValueError): + try: + n = int(float(str(value).strip())) # tolerate "2.0" + except (TypeError, ValueError): + return 1 + return n if n >= 1 else 1 diff --git a/core/metadata/enrichment.py b/core/metadata/enrichment.py index e3642419..69716a6c 100644 --- a/core/metadata/enrichment.py +++ b/core/metadata/enrichment.py @@ -131,6 +131,14 @@ def enhance_file_metadata(file_path: str, context: dict, artist: dict, album_inf track_num_str = format_track_number_tag( metadata.get('track_number'), metadata.get('total_tracks') ) + # Disc number is written UNCONDITIONALLY (floored to >=1), like the + # track number above. The old code only wrote it when truthy, so a + # track whose disc came back 0/None/'' (e.g. matched to a different + # edition) lost its disc tag on the clear-then-rewrite and floated + # ungrouped above the disc sections in Jellyfin/Plex (Sokhi). + from core.imports.track_number import normalize_disc_number + _disc_num = normalize_disc_number(metadata.get('disc_number')) + disc_num_str = str(_disc_num) write_multi = cfg.get("metadata_enhancement.tags.write_multi_artist", False) artists_list = metadata.get("_artists_list", []) @@ -162,8 +170,7 @@ def enhance_file_metadata(file_path: str, context: dict, artist: dict, album_inf if metadata.get("genre"): audio_file.tags.add(symbols.TCON(encoding=3, text=[metadata["genre"]])) audio_file.tags.add(symbols.TRCK(encoding=3, text=[track_num_str])) - if metadata.get("disc_number"): - audio_file.tags.add(symbols.TPOS(encoding=3, text=[str(metadata["disc_number"])])) + audio_file.tags.add(symbols.TPOS(encoding=3, text=[disc_num_str])) elif is_vorbis_like(audio_file, symbols): if metadata.get("title"): audio_file["title"] = [metadata["title"]] @@ -180,8 +187,7 @@ def enhance_file_metadata(file_path: str, context: dict, artist: dict, album_inf if metadata.get("genre"): audio_file["genre"] = [metadata["genre"]] audio_file["tracknumber"] = [track_num_str] - if metadata.get("disc_number"): - audio_file["discnumber"] = [str(metadata["disc_number"])] + audio_file["discnumber"] = [disc_num_str] elif isinstance(audio_file, symbols.MP4): if metadata.get("title"): audio_file["\xa9nam"] = [metadata["title"]] @@ -198,8 +204,7 @@ def enhance_file_metadata(file_path: str, context: dict, artist: dict, album_inf audio_file["trkn"] = [format_track_number_tuple( metadata.get("track_number"), metadata.get("total_tracks") )] - if metadata.get("disc_number"): - audio_file["disk"] = [(metadata["disc_number"], 0)] + audio_file["disk"] = [(_disc_num, 0)] embed_source_ids(audio_file, metadata, context, runtime=runtime) diff --git a/core/metadata/source.py b/core/metadata/source.py index ab44b241..9f9a9190 100644 --- a/core/metadata/source.py +++ b/core/metadata/source.py @@ -1092,7 +1092,11 @@ def extract_source_metadata(context: dict, artist: dict, album_info: dict) -> di disc_num = original_search.get("disc_number") if disc_num is None and album_info: disc_num = album_info.get("disc_number") - metadata["disc_number"] = disc_num if disc_num is not None else 1 + # Floor to >=1: a 0 / '' / non-numeric disc must not slip through (the old + # `is not None` let a literal 0 past, which then read as disc-less downstream + # and ungrouped the track in Jellyfin/Plex). Also feeds the "Disc N" folder org. + from core.imports.track_number import normalize_disc_number + metadata["disc_number"] = normalize_disc_number(disc_num) if album_ctx and album_ctx.get("release_date"): release_date = _normalize_release_date_tag(album_ctx.get("release_date")) diff --git a/tests/imports/test_normalize_disc_number.py b/tests/imports/test_normalize_disc_number.py new file mode 100644 index 00000000..a2c4e6dd --- /dev/null +++ b/tests/imports/test_normalize_disc_number.py @@ -0,0 +1,31 @@ +"""Sokhi: some tracks in a multi-disc album got a null disc in Jellyfin and floated +ungrouped above the disc sections. Root cause: the tag-writer only wrote the disc +tag when disc_number was truthy, and upstream a 0 / None / '' (esp. when a track +matched a different edition than its siblings) slipped through — so on the +clear-then-rewrite those tracks lost their disc entirely. normalize_disc_number +floors any value to >=1 so a track is never written disc-less.""" + +from __future__ import annotations + +import pytest + +from core.imports.track_number import normalize_disc_number + + +@pytest.mark.parametrize("value,expected", [ + (1, 1), (2, 2), (4, 4), + ("1", 1), ("3", 3), (" 2 ", 2), + (0, 1), ("0", 1), # the bug: 0 must floor to 1, not vanish + (None, 1), ("", 1), (" ", 1), + (-1, 1), ("-2", 1), # negatives floor to 1 + ("abc", 1), ("1/4", 1), # non-numeric -> 1 (never raises) + (2.0, 2), # float-ish via str() +]) +def test_normalize_disc_number(value, expected): + assert normalize_disc_number(value) == expected + + +def test_valid_multidisc_values_preserved(): + # a real disc on a 4xLP must survive untouched + for d in (1, 2, 3, 4): + assert normalize_disc_number(d) == d