labels+disc: codec from stream, audio-richness title tiebreak

Two validated audio-correctness fixes (proven on real discs Wicked/
Paddington/Dune/Fight Club via head-captures):

labels: apply_labels now derives the codec/channel descriptor from the
stream's OWN codec/channels unless the parser's codec_hint is BOTH
consistent with it AND richer (e.g. "Dolby Atmos" on a TrueHD stream).
A mis-bound hint ("AC-3 2.0" on a TrueHD track) is rejected and the
stream's own codec used — killing the cross-labeled shuffle (Wicked) and
the compat-core mislabel (Paddington), while keeping rich hints and
normalizing plain ones to uniform marketing names. (codec_hint_consistent
+ codec_hint_adds_detail, 5 tests.)

disc: canonical_title_order gains an audio-richness tiebreak
(lossless > channels > track-count) for titles that tie on
duration+clips — so a movie authored as a full-audio playlist plus a
stereo-only twin (Fight Club 00800 vs 00004) picks the full-audio one
instead of falling to array order.
This commit is contained in:
MattJackson
2026-06-04 19:10:11 -07:00
parent b96f6206fe
commit 36d1af1b7f
2 changed files with 222 additions and 4 deletions
+38
View File
@@ -1356,6 +1356,44 @@ impl Disc {
.cmp(&b_oversize) .cmp(&b_oversize)
.then_with(|| a.clips.len().cmp(&b.clips.len())) .then_with(|| a.clips.len().cmp(&b.clips.len()))
.then_with(|| b.duration_secs.total_cmp(&a.duration_secs)) .then_with(|| b.duration_secs.total_cmp(&a.duration_secs))
// Same length + clip count = the same feature authored as multiple
// playlists (a full-audio main vs an audio-reduced twin, e.g. Fight
// Club's 00800 [DTS-HD MA + 13 tracks] vs 00004 [stereo AC-3 only]).
// Prefer the richer audio so we never rip a stereo-only variant over
// the lossless-multichannel main feature.
.then_with(|| Self::audio_richness(b).cmp(&Self::audio_richness(a)))
}
/// Audio-richness rank for `canonical_title_order`'s same-length tiebreak.
/// Higher is better: `(any lossless track, best channel count, audio count)`.
fn audio_richness(t: &DiscTitle) -> (u8, u8, usize) {
let mut lossless = 0u8;
let mut max_ch = 0u8;
let mut count = 0usize;
for s in &t.streams {
if let Stream::Audio(a) = s {
count += 1;
if matches!(
a.codec,
Codec::TrueHd | Codec::DtsHdMa | Codec::DtsHdHr | Codec::Lpcm | Codec::Flac
) {
lossless = 1;
}
let ch = match a.channels {
AudioChannels::Surround71 => 8,
AudioChannels::Surround61 => 7,
AudioChannels::Surround51 => 6,
AudioChannels::Surround50 => 5,
AudioChannels::Quad => 4,
AudioChannels::Stereo21 => 3,
AudioChannels::Stereo => 2,
AudioChannels::Mono => 1,
AudioChannels::Unknown => 0,
};
max_ch = max_ch.max(ch);
}
}
(lossless, max_ch, count)
} }
fn detect_format(titles: &[DiscTitle]) -> DiscFormat { fn detect_format(titles: &[DiscTitle]) -> DiscFormat {
+184 -4
View File
@@ -207,14 +207,41 @@ pub(crate) fn apply_labels(labels: &[StreamLabel], titles: &mut [DiscTitle]) {
// Structured fields — callers translate purpose to UI text. // Structured fields — callers translate purpose to UI text.
a.purpose = label.purpose; a.purpose = label.purpose;
// Codec descriptor: trust the parser's `codec_hint` ONLY
// when it's consistent with the stream's actual codec — it
// may legitimately be richer (e.g. "Dolby Atmos" on a TrueHD
// stream, which the raw spec codec can't express). If the
// hint CONTRADICTS the stream (a mis-bound / shuffled label,
// e.g. "AC-3 2.0" on a TrueHD track, or "TrueHD" on a DD+
// track), discard it and derive the descriptor from the
// stream itself — that's correct per-stream and can never be
// shuffled. An empty hint is left for `fill_defaults`.
let codec_desc = if label.codec_hint.is_empty() {
// No codec hint — leave for fill_defaults.
String::new()
} else if !codec_hint_consistent(&label.codec_hint, &a.codec) {
// Hint contradicts the stream (mis-bound / shuffled):
// derive from the stream itself.
generate_audio_label(&a.codec, &a.channels, a.secondary)
} else if codec_hint_adds_detail(&label.codec_hint) {
// Consistent AND richer than the spec codec can express
// (e.g. "Dolby Atmos", "DTS:X") — keep the parser's hint.
label.codec_hint.clone()
} else {
// Consistent but a plain codec/channel restatement —
// normalize to the stream's own marketing descriptor so
// styling is uniform across tracks.
generate_audio_label(&a.codec, &a.channels, a.secondary)
};
// a.label only carries codec/variant info. NEVER any // a.label only carries codec/variant info. NEVER any
// English purpose text — the CLI handles that via i18n. // English purpose text — the CLI handles that via i18n.
let mut parts = Vec::new(); let mut parts = Vec::new();
if !label.variant.is_empty() { if !label.variant.is_empty() {
parts.push(format!("({})", label.variant)); parts.push(format!("({})", label.variant));
} }
if !label.codec_hint.is_empty() { if !codec_desc.is_empty() {
parts.push(label.codec_hint.clone()); parts.push(codec_desc);
} }
if !parts.is_empty() { if !parts.is_empty() {
a.label = parts.join(" "); a.label = parts.join(" ");
@@ -322,6 +349,69 @@ fn generate_video_label(
parts.join(" ") parts.join(" ")
} }
/// Does the parser's `codec_hint` name a codec consistent with the stream's
/// actual `codec`? [`apply_labels`] uses this to keep richer-but-consistent
/// hints (e.g. "Dolby Atmos" on a TrueHD stream — Atmos is a TrueHD extension
/// the raw spec codec can't express) while rejecting mis-bound ones (e.g.
/// "AC-3 2.0" on a TrueHD stream, the shuffled-label bug). Matching is by codec
/// FAMILY parsed out of the hint string. "Atmos" with no carrier named is
/// treated as compatible with its lossless carriers (TrueHD / E-AC-3). A hint
/// naming no recognizable codec family (pure editorial, e.g. "Commentary") is
/// consistent — it isn't asserting a codec.
fn codec_hint_consistent(hint: &str, codec: &crate::disc::Codec) -> bool {
use crate::disc::Codec;
let h = hint.to_ascii_lowercase();
let says_truehd = h.contains("truehd") || h.contains("true hd");
let says_ddp = h.contains("ac-3+")
|| h.contains("ac3+")
|| h.contains("e-ac-3")
|| h.contains("eac-3")
|| h.contains("eac3")
|| h.contains("digital plus")
|| h.contains("dd+");
let says_ac3 =
!says_ddp && (h.contains("ac-3") || h.contains("ac3") || h.contains("dolby digital"));
let says_dts_ma = h.contains("master audio") || h.contains("hd ma");
let says_dts_hr = h.contains("high resolution") || h.contains("hd hr");
let says_dts = !says_dts_ma && !says_dts_hr && h.contains("dts");
let says_lpcm = h.contains("lpcm") || h.contains("pcm");
let says_atmos = h.contains("atmos");
let names_family =
says_truehd || says_ddp || says_ac3 || says_dts_ma || says_dts_hr || says_dts || says_lpcm;
// Pure-editorial hint (no codec family named) isn't asserting a codec →
// consistent. "Atmos" alone implies a lossless carrier (TrueHD or DD+).
if !names_family {
return if says_atmos {
matches!(codec, Codec::TrueHd | Codec::Ac3Plus)
} else {
true
};
}
match codec {
Codec::TrueHd => says_truehd || says_atmos,
Codec::Ac3Plus => says_ddp || says_atmos,
Codec::Ac3 => says_ac3,
Codec::DtsHdMa => says_dts_ma,
Codec::DtsHdHr => says_dts_hr,
Codec::Dts => says_dts,
Codec::Lpcm => says_lpcm,
// Unknown / other stream codec — don't second-guess the parser's hint.
_ => true,
}
}
/// Does the hint carry object-audio detail the spec codec can't express
/// (Atmos / DTS:X)? Such hints are kept verbatim; plain codec/channel hints are
/// normalized to the stream's own descriptor for uniform styling across tracks.
fn codec_hint_adds_detail(hint: &str) -> bool {
let h = hint.to_ascii_lowercase();
h.contains("atmos") || h.contains("dts:x") || h.contains("dts-x") || h.contains("dtsx")
}
fn generate_audio_label( fn generate_audio_label(
codec: &crate::disc::Codec, codec: &crate::disc::Codec,
channels: &crate::disc::AudioChannels, channels: &crate::disc::AudioChannels,
@@ -1182,6 +1272,94 @@ mod apply_tests {
} }
} }
#[test]
fn apply_rejects_mismatched_codec_hint_and_uses_stream_codec() {
// Paddington case: a TrueHD+Atmos main track the parser mislabeled
// "AC-3 2.0" (a compat-core hint bound to the wrong stream). The hint
// contradicts the stream's real codec → discard it, use the stream's own.
let mut titles = vec![title_with(vec![audio(
0x1100,
Codec::TrueHd,
AudioChannels::Surround71,
"eng",
)])];
let labels = vec![audio_label(1, "eng", "AC-3 2.0", "")];
apply_labels(&labels, &mut titles);
if let Stream::Audio(a) = &titles[0].streams[0] {
assert_eq!(a.label, "Dolby TrueHD 7.1");
} else {
panic!("expected audio stream");
}
}
#[test]
fn apply_unshuffles_cross_labeled_streams() {
// Wicked case: hints fully cross-bound — a TrueHD stream wears "AC-3 5.1"
// and a DD+ stream wears "TrueHD 5.1". Each is corrected from its own
// stream codec, eliminating the shuffle.
let mut titles = vec![title_with(vec![
audio(0x1100, Codec::TrueHd, AudioChannels::Surround51, "eng"),
audio(0x1101, Codec::Ac3Plus, AudioChannels::Surround51, "spa"),
])];
let labels = vec![
audio_label(1, "eng", "AC-3 5.1", ""),
audio_label(2, "spa", "TrueHD 5.1", ""),
];
apply_labels(&labels, &mut titles);
let got: Vec<String> = titles[0]
.streams
.iter()
.filter_map(|s| {
if let Stream::Audio(a) = s {
Some(a.label.clone())
} else {
None
}
})
.collect();
assert_eq!(got, vec!["Dolby TrueHD 5.1", "Dolby Digital Plus 5.1"]);
}
#[test]
fn apply_keeps_consistent_richer_atmos_hint() {
// A DD+ Atmos stream legitimately labeled "Dolby Atmos" — the hint is
// richer than the spec codec yet consistent with it, so it's kept.
let mut titles = vec![title_with(vec![audio(
0x1100,
Codec::Ac3Plus,
AudioChannels::Surround51,
"eng",
)])];
let labels = vec![audio_label(1, "eng", "Dolby Atmos", "")];
apply_labels(&labels, &mut titles);
if let Stream::Audio(a) = &titles[0].streams[0] {
assert_eq!(a.label, "Dolby Atmos");
} else {
panic!("expected audio stream");
}
}
#[test]
fn apply_normalizes_plain_consistent_hint_to_marketing() {
// Wicked's French track: a DD+ stream whose hint "AC-3+ 5.1" is correct
// but short-form. A sibling DD+ track that fell back uses the marketing
// form — keeping the short form here would read inconsistently, so a
// plain (non-richer) consistent hint is normalized to the stream's own.
let mut titles = vec![title_with(vec![audio(
0x1100,
Codec::Ac3Plus,
AudioChannels::Surround51,
"fra",
)])];
let labels = vec![audio_label(1, "fra", "AC-3+ 5.1", "")];
apply_labels(&labels, &mut titles);
if let Stream::Audio(a) = &titles[0].streams[0] {
assert_eq!(a.label, "Dolby Digital Plus 5.1");
} else {
panic!("expected audio stream");
}
}
#[test] #[test]
fn apply_sets_purpose_on_audio_commentary() { fn apply_sets_purpose_on_audio_commentary() {
let mut titles = vec![title_with(vec![audio( let mut titles = vec![title_with(vec![audio(
@@ -1342,9 +1520,11 @@ mod apply_tests {
if let Stream::Audio(a) = &titles[0].streams[1] { if let Stream::Audio(a) = &titles[0].streams[1] {
assert_eq!(a.label, "Dolby Atmos"); assert_eq!(a.label, "Dolby Atmos");
} }
// Audio #2 (4th stream overall) // Audio #2 (4th stream overall). The plain "Dolby Digital" hint is
// consistent with the AC-3 stream but carries no channel info, so it's
// normalized to the stream's own uniform descriptor.
if let Stream::Audio(a) = &titles[0].streams[3] { if let Stream::Audio(a) = &titles[0].streams[3] {
assert_eq!(a.label, "Dolby Digital"); assert_eq!(a.label, "Dolby Digital 2.0");
} }
// Subtitle #1 // Subtitle #1
if let Stream::Subtitle(s) = &titles[0].streams[2] { if let Stream::Subtitle(s) = &titles[0].streams[2] {