Multi-disc albums: never write a disc-less track (floor disc to >=1)

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.
This commit is contained in:
BoulderBadgeDad 2026-06-22 09:30:04 -07:00
parent 1ad80d77a6
commit c11a742e58
4 changed files with 68 additions and 7 deletions

View file

@ -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

View file

@ -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)

View file

@ -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"))

View file

@ -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