From 36d1af1b7fdda8bab70027285868424ca5628aa4 Mon Sep 17 00:00:00 2001 From: MattJackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:10:11 -0700 Subject: [PATCH] labels+disc: codec from stream, audio-richness title tiebreak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/disc/mod.rs | 38 ++++++++++ src/labels/mod.rs | 188 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 222 insertions(+), 4 deletions(-) diff --git a/src/disc/mod.rs b/src/disc/mod.rs index b935521..9d3d26a 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -1356,6 +1356,44 @@ impl Disc { .cmp(&b_oversize) .then_with(|| a.clips.len().cmp(&b.clips.len())) .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 { diff --git a/src/labels/mod.rs b/src/labels/mod.rs index fb4a056..72d3ff7 100644 --- a/src/labels/mod.rs +++ b/src/labels/mod.rs @@ -207,14 +207,41 @@ pub(crate) fn apply_labels(labels: &[StreamLabel], titles: &mut [DiscTitle]) { // Structured fields — callers translate purpose to UI text. 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 // English purpose text — the CLI handles that via i18n. let mut parts = Vec::new(); if !label.variant.is_empty() { parts.push(format!("({})", label.variant)); } - if !label.codec_hint.is_empty() { - parts.push(label.codec_hint.clone()); + if !codec_desc.is_empty() { + parts.push(codec_desc); } if !parts.is_empty() { a.label = parts.join(" "); @@ -322,6 +349,69 @@ fn generate_video_label( 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( codec: &crate::disc::Codec, 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 = 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] fn apply_sets_purpose_on_audio_commentary() { let mut titles = vec![title_with(vec![audio( @@ -1342,9 +1520,11 @@ mod apply_tests { if let Stream::Audio(a) = &titles[0].streams[1] { 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] { - assert_eq!(a.label, "Dolby Digital"); + assert_eq!(a.label, "Dolby Digital 2.0"); } // Subtitle #1 if let Stream::Subtitle(s) = &titles[0].streams[2] {