//! Warner CTRM — `menu_base.prop` and/or `language_streams.txt` //! //! Two sub-formats from the same framework. A disc may have one or both. //! When both exist, language_streams.txt provides structured types while //! menu_base.prop provides stream number → button name mapping. use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, vocab}; use crate::sector::SectorSource; use crate::udf::UdfFs; use std::collections::HashMap; /// Cheap signature check: a CTRM disc ships `menu_base.prop` and/or /// `language_streams.txt` inside a `/BDMV/JAR/*` archive. pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool { super::jar_file_exists(udf, "menu_base.prop") || super::jar_file_exists(udf, "language_streams.txt") } /// Full extraction: parses `language_streams.txt` (structured types) and /// `menu_base.prop` (stream numbers + button names), merging when both /// are present. Returns `None` when neither file is present/parseable or /// no labels result. pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option { // Try language_streams.txt first (richer structured data) let ls_labels = parse_language_streams(reader, udf); // Try menu_base.prop (stream numbers + key names) let mb_labels = parse_menu_base(reader, udf); // If we have both, merge: language_streams for structure, menu_base for names let labels = match (ls_labels, mb_labels) { (Some(ls), Some(mb)) => merge(ls, mb), (Some(ls), None) => ls, (None, Some(mb)) => mb, (None, None) => return None, }; if labels.is_empty() { return None; } // High confidence: both language_streams.txt and menu_base.prop // are structured key-value formats with documented types. Some(ParseResult::high(labels)) } fn merge(ls: Vec, mb: Vec) -> Vec { // language_streams has better type/purpose data, menu_base has button names // Match by stream number + type, take name from menu_base let mut result = ls; for label in &mut result { if let Some(mb_match) = mb .iter() .find(|m| m.stream_type == label.stream_type && m.stream_number == label.stream_number) && label.name.is_empty() && !mb_match.name.is_empty() { label.name = mb_match.name.clone(); } } // Append any menu_base-only stream (present in mb but not in ls by // (stream_type, stream_number)). Without this the both-files path // silently drops streams the menu_base-only path would have emitted: // language_streams is authoritative for type/purpose but is not // necessarily a superset of menu_base. for mb_label in mb { let already = result.iter().any(|l| { l.stream_type == mb_label.stream_type && l.stream_number == mb_label.stream_number }); if !already { result.push(mb_label); } } result } /// True if a property-key prefix denotes a commentary stream group. /// Tightened from a bare `prefix.contains("comm")` substring scan, which /// over-matched unrelated prefixes like `common_*` / `community_*`. We /// split on `_` and require a `commentary` (or `comm`) segment. fn prefix_is_commentary(prefix: &str) -> bool { prefix .split('_') .any(|seg| seg.eq_ignore_ascii_case("commentary") || seg.eq_ignore_ascii_case("comm")) } // ── language_streams.txt parser ──────────────────────────────────────────── fn parse_language_streams(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option> { let data = super::read_jar_file(reader, udf, "language_streams.txt")?; let text = std::str::from_utf8(&data).ok()?; let labels = parse_language_streams_text(text); if labels.is_empty() { return None; } Some(labels) } /// Parse the body of a `language_streams.txt` file into stream labels. /// /// This is the shipping parser: [`parse_language_streams`] does the UDF read /// and UTF-8 decode and then delegates here. It is split out — rather than /// duplicated under `#[cfg(test)]`, which is what it used to be — so the unit /// tests below exercise production code. A test that re-implements the /// function it guards cannot fail when the real function breaks. fn parse_language_streams_text(text: &str) -> Vec { let mut labels = Vec::new(); for line in text.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); if parts.len() < 4 { continue; } let type_str = parts[1]; // STN indices are 1-based; apply_labels pre-increments from 0 and // never matches a 0, so a 0 here would emit a dead label. Skip it // (matching the `n > 0` guard in parse_menu_base). let stream_num: u16 = match parts[2].parse() { Ok(n) if n > 0 => n, _ => continue, }; let language = parts[3].to_string(); let variant = if parts.len() > 4 { parts[4].to_string() } else { String::new() }; let (stream_type, purpose, qualifier) = match type_str { "audio_production" => ( StreamLabelType::Audio, LabelPurpose::Normal, LabelQualifier::None, ), "audio_commentary" => ( StreamLabelType::Audio, LabelPurpose::Commentary, LabelQualifier::None, ), "audio_ime" => ( StreamLabelType::Audio, LabelPurpose::Ime, LabelQualifier::None, ), "subtitle_production" => ( StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::None, ), "subtitle_commentary" => ( StreamLabelType::Subtitle, LabelPurpose::Commentary, LabelQualifier::None, ), "subtitle_narrative" => ( StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::Forced, ), "subtitle_dual" => ( StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::None, ), "subtitle_bonus" => ( StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::None, ), "subtitle_ime" => ( StreamLabelType::Subtitle, LabelPurpose::Ime, LabelQualifier::None, ), "subtitle_ime_narrative" => ( StreamLabelType::Subtitle, LabelPurpose::Ime, LabelQualifier::Forced, ), _ => continue, }; // Classify variant code let mut codec_hint = String::new(); let mut variant_code = String::new(); let mut final_purpose = purpose; if !variant.is_empty() { match variant.as_str() { // Purpose variants "eda" => final_purpose = LabelPurpose::Descriptive, // Dialect variants — pass through raw code from disc "csp" | "cs" | "lsp" | "ls" | "cf" | "pf" | "bp" | "pp" => { variant_code = variant.clone(); } // Everything else: defer to vocab::codec as the single // source of codec-name truth. If it recognizes the token // (returns something other than the input) it's a known // codec — store the canonical name. Otherwise it's an // unknown token, stored as-is. _ => codec_hint = vocab::codec(&variant).to_string(), } } labels.push(StreamLabel { stream_id: None, stream_number: stream_num, stream_type, language, name: String::new(), purpose: final_purpose, qualifier, codec_hint, variant: variant_code, }); } labels } // NOTE: `parse_menu_base` / `parse_menu_base_text` are defined just below this // module and structurally belong above it. They are left in place (with the // lint allowed) rather than relocated here — a ~120-line block move that is // safer to do as its own focused change than inline. #[allow(clippy::items_after_test_module)] #[cfg(test)] mod tests { use super::*; /// Run the real shipping parser ([`parse_menu_base_text`]) on a /// menu_base.prop body so tests exercise production code directly. fn parse_props(text: &str) -> Vec { parse_menu_base_text(text) } #[test] fn commentary_via_name() { let labels = parse_props( "audio_1.class=AudioButton\n\ audio_1.streamNumber=2\n\ audio_1.name=Director's Commentary\n\ audio_1.audioLanguage=eng\n", ); assert_eq!(labels.len(), 1); assert_eq!(labels[0].purpose, LabelPurpose::Commentary); assert_eq!(labels[0].language, "eng"); } #[test] fn commentary_via_prefix_when_name_silent() { let labels = parse_props( "audio_commentary_1.class=AudioButton\n\ audio_commentary_1.streamNumber=2\n\ audio_commentary_1.name=Track 2\n\ audio_commentary_1.audioLanguage=eng\n", ); assert_eq!(labels[0].purpose, LabelPurpose::Commentary); } #[test] fn commenter_does_not_false_match_commentary() { // Regression for the pre-refactor `name.contains("comment")` // bug: this would wrongly classify a "Commenter Pro" track as // Commentary. vocab::purpose enforces a word boundary. let labels = parse_props( "audio_1.class=AudioButton\n\ audio_1.streamNumber=2\n\ audio_1.name=Commenter Pro Track\n\ audio_1.audioLanguage=eng\n", ); assert_eq!(labels[0].purpose, LabelPurpose::Normal); } #[test] fn descriptive_via_name() { let labels = parse_props( "audio_1.class=AudioButton\n\ audio_1.streamNumber=3\n\ audio_1.name=English Descriptive Audio\n", ); assert_eq!(labels[0].purpose, LabelPurpose::Descriptive); } #[test] fn sdh_only_on_subtitles() { // SDH applied to a subtitle stream. let labels = parse_props( "subtitle_1.class=SubtitleButton\n\ subtitle_1.streamNumber=4\n\ subtitle_1.name=English SDH\n", ); assert_eq!(labels[0].qualifier, LabelQualifier::Sdh); } #[test] fn sdh_not_applied_to_audio_stream_even_if_name_contains_sdh() { // Audio streams should not pick up SDH (it's a subtitle // concept). Edge case: badly-authored name happens to include // "SDH" — we don't propagate it to audio metadata. let labels = parse_props( "audio_1.class=AudioButton\n\ audio_1.streamNumber=5\n\ audio_1.name=English SDH (track?)\n", ); assert_eq!(labels[0].qualifier, LabelQualifier::None); } #[test] fn dual_flag_entry_resolves_to_audio_with_no_subtitle_qualifier() { // An entry tripping BOTH flags (audio_ prefix sets is_audio, // class "SubtitleButton" sets is_subtitle). Audio wins the type, // and the subtitle qualifier (SDH) must NOT be carried onto the // resulting Audio label. Regression for the type/qualifier split. let labels = parse_props( "audio_1.class=SubtitleButton\n\ audio_1.streamNumber=6\n\ audio_1.name=English SDH\n", ); assert_eq!(labels.len(), 1); assert_eq!(labels[0].stream_type, StreamLabelType::Audio); assert_eq!(labels[0].qualifier, LabelQualifier::None); } /// Spec: `menu_base.prop` lines are skipped when `is_empty() || /// starts_with('#')` — either alone is sufficient. A commented-out /// key=value line must never be parsed into an entry. /// Mutation: `||` -> `&&` requires both, which a non-empty comment /// line can't satisfy, so it falls through to `line.find('=')` and /// gets parsed as a real property. #[test] fn menu_base_comment_line_with_equals_is_still_skipped() { let labels = parse_props( "#audio_1.class=AudioButton\n\ #audio_1.streamNumber=9\n\ #audio_1.name=Should Not Appear\n\ audio_2.class=AudioButton\n\ audio_2.streamNumber=1\n\ audio_2.name=Real Track\n", ); assert_eq!(labels.len(), 1, "commented-out entry must not be parsed"); assert_eq!(labels[0].name, "Real Track"); } /// Spec: `menu_base.prop` streamNumber (or audioStream/subtitleStream) /// must be strictly positive — `0` means "no STN entry" and must be /// skipped, matching the `n > 0` guard on the language_streams side. /// Mutation: `n > 0` -> `n >= 0` (or the guard deleted) would let a /// stream_num of 0 through, emitting a dead label apply_labels can /// never match (its counter starts at 1). #[test] fn menu_base_zero_stream_number_skipped() { let labels = parse_props( "audio_1.class=AudioButton\n\ audio_1.streamNumber=0\n\ audio_1.name=Disabled Slot\n", ); assert!( labels.is_empty(), "streamNumber=0 must be skipped, got {labels:?}" ); } /// Spec: `is_subtitle` is `class.contains("SubtitleButton") || /// prefix.starts_with("subtitle_")` — EITHER signal alone is /// sufficient to classify (and keep) a subtitle entry whose prefix /// doesn't follow the `subtitle_` naming convention. /// Mutation: `||` -> `&&` would require BOTH signals; an entry whose /// class says SubtitleButton but whose prefix is something else /// (e.g. a vendor-specific button id) would then satisfy neither /// `is_audio` nor `is_subtitle` and get dropped entirely. #[test] fn menu_base_subtitle_class_alone_is_sufficient() { let labels = parse_props( "menuBtn7.class=SubtitleButton\n\ menuBtn7.streamNumber=1\n\ menuBtn7.name=English SDH\n", ); assert_eq!( labels.len(), 1, "class=SubtitleButton alone must classify as subtitle, not be dropped" ); assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle); } #[test] fn prefix_commentary_segment_match_not_substring() { // Genuine commentary group segments match. assert!(prefix_is_commentary("audio_commentary")); assert!(prefix_is_commentary("audio_commentary_1")); assert!(prefix_is_commentary("comm")); // Substring-only prefixes must NOT match (the over-match bug). assert!(!prefix_is_commentary("common")); assert!(!prefix_is_commentary("audio_common_1")); assert!(!prefix_is_commentary("community")); assert!(!prefix_is_commentary("audio_1")); } fn lbl(t: StreamLabelType, n: u16, name: &str) -> StreamLabel { StreamLabel { stream_id: None, stream_number: n, stream_type: t, language: String::new(), name: name.to_string(), purpose: LabelPurpose::Normal, qualifier: LabelQualifier::None, codec_hint: String::new(), variant: String::new(), } } /// Spec: `merge`'s `mb.iter().find(...)` must match an mb entry by /// (stream_type AND stream_number) TOGETHER — either alone is not a /// unique key (there can be an audio #1 and a subtitle #1, or two /// different audio streams). /// Mutation: `&&` -> `||` inside the closure would match on type OR /// number alone, so `.find` (which returns the FIRST match) can pick /// an mb entry with the right type but the WRONG stream number. #[test] fn merge_matches_mb_entry_by_type_and_number_together() { // ls wants audio #2 (empty name, so it will borrow from mb). let ls = vec![lbl(StreamLabelType::Audio, 2, "")]; // mb's FIRST audio entry is #1 (wrong number); its #2 entry (the // real match) comes second. let mb = vec![ lbl(StreamLabelType::Audio, 1, "Wrong Number Match"), lbl(StreamLabelType::Audio, 2, "Correct Match"), ]; let merged = merge(ls, mb); assert_eq!( merged.len(), 2, "mb's own audio #1 must also survive as its own entry" ); let a2 = merged .iter() .find(|l| l.stream_type == StreamLabelType::Audio && l.stream_number == 2) .unwrap(); assert_eq!( a2.name, "Correct Match", "must match mb by (type AND number), not type or number alone" ); } #[test] fn merge_preserves_menu_base_only_streams() { // language_streams covers audio 1; menu_base has audio 1 (name) // AND a menu_base-only audio 2. The merge must keep audio 2 — // the both-files path previously dropped it. let ls = vec![lbl(StreamLabelType::Audio, 1, "")]; let mb = vec![ lbl(StreamLabelType::Audio, 1, "Main"), lbl(StreamLabelType::Audio, 2, "Commentary"), ]; let merged = merge(ls, mb); assert_eq!(merged.len(), 2, "menu_base-only stream must survive"); // ls audio 1 takes its name from mb. let a1 = merged .iter() .find(|l| l.stream_type == StreamLabelType::Audio && l.stream_number == 1) .unwrap(); assert_eq!(a1.name, "Main"); // mb-only audio 2 is appended. assert!( merged .iter() .any(|l| l.stream_number == 2 && l.name == "Commentary") ); } // ── Additional hardening tests: language_streams.txt parser ────────────── /// Spec: `audio_production` line → Audio / Normal / no qualifier. /// Mutation: misparse `audio_production` as subtitle → Audio fails assertion. #[test] fn ls_audio_production_parsed() { let labels = parse_language_streams_text("id1,audio_production,1,eng\n"); assert_eq!(labels.len(), 1); assert_eq!(labels[0].stream_type, StreamLabelType::Audio); assert_eq!(labels[0].purpose, LabelPurpose::Normal); assert_eq!(labels[0].qualifier, LabelQualifier::None); assert_eq!(labels[0].language, "eng"); assert_eq!(labels[0].stream_number, 1); } /// Spec: `audio_commentary` line → Audio / Commentary. /// Mutation: change purpose to Normal → commentary track not flagged. #[test] fn ls_audio_commentary_parsed() { let labels = parse_language_streams_text("id2,audio_commentary,3,eng\n"); assert_eq!(labels.len(), 1); assert_eq!(labels[0].stream_type, StreamLabelType::Audio); assert_eq!(labels[0].purpose, LabelPurpose::Commentary); } /// Spec: `audio_ime` → Audio / Ime (secondary music track). /// Mutation: remove Ime variant → purpose stays Normal. #[test] fn ls_audio_ime_parsed() { let labels = parse_language_streams_text("id3,audio_ime,2,jpn\n"); assert_eq!(labels[0].stream_type, StreamLabelType::Audio); assert_eq!(labels[0].purpose, LabelPurpose::Ime); } /// Spec: `subtitle_narrative` → Subtitle / Forced qualifier (forced narrative). /// Mutation: don't set Forced on narrative → forced flag not propagated. #[test] fn ls_subtitle_narrative_is_forced() { let labels = parse_language_streams_text("id4,subtitle_narrative,1,eng\n"); assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle); assert_eq!(labels[0].qualifier, LabelQualifier::Forced); } /// Immunity pin against the defect measured in the `paramount` parser, /// where a vendor `forced_sub` cell hung off a FULL dialogue track's own /// slot to say "this track also contains forced signs", and reading that /// cell as "this track is forced" flagged 30 MB dialogue tracks forced. /// /// This format cannot express that. The forced signal is not a flag beside /// a track's entry — it IS the entry's stream-kind token, drawn from a /// closed vocabulary in which `subtitle_production` (the full dialogue /// track) and `subtitle_narrative` (the forced-narrative track) are /// mutually exclusive alternatives in the same position. A row is one or /// the other; there is no cell a full track can carry to acquire the /// qualifier, so the paramount failure mode has no encoding here. /// /// Mutation: give `subtitle_production` a `Forced` qualifier, or add a /// forced side-flag that both kinds may carry. #[test] fn a_full_subtitle_track_kind_can_never_carry_the_forced_qualifier() { // Every subtitle kind in the vocabulary, one row each. let text = "id1,subtitle_production,1,eng\n\ id2,subtitle_commentary,2,eng\n\ id3,subtitle_dual,3,eng\n\ id4,subtitle_bonus,4,eng\n\ id5,subtitle_ime,5,kor\n\ id6,subtitle_narrative,6,eng\n\ id7,subtitle_ime_narrative,7,kor\n"; let labels = parse_language_streams_text(text); let forced: Vec<&str> = labels .iter() .filter(|l| l.qualifier == LabelQualifier::Forced) .map(|l| l.language.as_str()) .collect(); assert_eq!( forced.len(), 2, "only the two narrative kinds are forced, got {forced:?}" ); // The full dialogue kind specifically. let production = parse_language_streams_text("id,subtitle_production,1,eng\n"); assert_eq!(production[0].qualifier, LabelQualifier::None); } /// Spec: `subtitle_commentary` → Subtitle / Commentary. /// Mutation: treat as Normal → subtitle commentary not flagged. #[test] fn ls_subtitle_commentary_parsed() { let labels = parse_language_streams_text("id5,subtitle_commentary,4,eng\n"); assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle); assert_eq!(labels[0].purpose, LabelPurpose::Commentary); } /// Spec: `subtitle_ime_narrative` → Subtitle / Ime / Forced. /// Mutation: miss Forced → forced subtitles not identified. #[test] fn ls_subtitle_ime_narrative_is_ime_and_forced() { let labels = parse_language_streams_text("id6,subtitle_ime_narrative,2,kor\n"); assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle); assert_eq!(labels[0].purpose, LabelPurpose::Ime); assert_eq!(labels[0].qualifier, LabelQualifier::Forced); } /// Spec: stream_num=0 is SKIPPED (0 means "no STN entry"; apply_labels /// starts from 1). Mutation: allow 0 → dead label emitted, never matched. #[test] fn ls_zero_stream_num_skipped() { let labels = parse_language_streams_text("id,audio_production,0,eng\n"); assert!(labels.is_empty(), "stream_num=0 must be skipped"); } /// Spec: a non-numeric stream_num is skipped (malformed disc). /// Mutation: parse as 0 → dead label. #[test] fn ls_non_numeric_stream_num_skipped() { let labels = parse_language_streams_text("id,audio_production,N/A,eng\n"); assert!(labels.is_empty()); } /// Spec: an unrecognized type token is skipped. /// Mutation: emit Unknown stream label → wrong type label appears. #[test] fn ls_unknown_type_skipped() { let labels = parse_language_streams_text("id,audio_bonus_extended,1,eng\n"); assert!(labels.is_empty()); } /// Immunity pin. `language_streams.txt` states each stream's number in /// field 3, so a row the parser cannot use is simply dropped — it can /// never renumber the rows behind it. This is the property that keeps /// this parser out of the STN-slot-shifting failure mode that bites /// parsers which count positionally: there, a skipped entry silently /// pulls every later label one stream forward. /// /// Mutation: replace `parts[2]` with a running per-type counter → the /// three unusable rows here collapse the survivors onto 1/2 and 1. #[test] fn ls_stream_numbers_come_from_the_row_not_a_counter() { let labels = parse_language_streams_text( "id,audio_production,4,eng\n\ id,audio_bonus_extended,5,eng\n\ id,audio_production,0,fra\n\ id,audio_production,7,fra\n\ id,subtitle_production\n\ id,subtitle_narrative,9,deu\n", ); let nums: Vec<(StreamLabelType, u16)> = labels .iter() .map(|l| (l.stream_type, l.stream_number)) .collect(); assert_eq!( nums, vec![ (StreamLabelType::Audio, 4), (StreamLabelType::Audio, 7), (StreamLabelType::Subtitle, 9), ], "an unusable row drops out without shifting the numbering" ); assert_eq!(labels[2].qualifier, LabelQualifier::Forced); } /// Immunity pin, `menu_base.prop` side: the number comes from the /// entry's own `streamNumber` property, so a skipped entry (commented /// out, `streamNumber=0`, neither audio nor subtitle) leaves the /// surviving entries on their authored slots. /// /// Mutation: number by iteration order → the survivors collapse to 1/2. #[test] fn menu_base_stream_numbers_come_from_the_entry_not_a_counter() { let labels = parse_props( "#audio_0.class=AudioButton\n\ #audio_0.streamNumber=1\n\ audio_1.class=AudioButton\n\ audio_1.streamNumber=0\n\ audio_2.class=AudioButton\n\ audio_2.streamNumber=6\n\ other_1.class=SomeOtherButton\n\ other_1.streamNumber=2\n\ subtitle_1.class=SubtitleButton\n\ subtitle_1.streamNumber=11\n", ); let nums: Vec<(StreamLabelType, u16)> = labels .iter() .map(|l| (l.stream_type, l.stream_number)) .collect(); assert_eq!( nums, vec![(StreamLabelType::Audio, 6), (StreamLabelType::Subtitle, 11),], "skipped entries must not renumber the ones that survive" ); } /// Spec: `eda` variant → `Descriptive` purpose. /// Mutation: miss the `eda` branch → purpose stays Normal. #[test] fn ls_eda_variant_sets_descriptive() { let labels = parse_language_streams_text("id,audio_production,2,eng,eda\n"); assert_eq!(labels[0].purpose, LabelPurpose::Descriptive); } /// Spec: dialect variant codes (`bp`, `csp`, etc.) pass through as variant_code. /// Mutation: store as codec_hint → variant field empty on BP stream. #[test] fn ls_bp_variant_is_dialect_code() { let labels = parse_language_streams_text("id,audio_production,1,por,bp\n"); assert_eq!(labels[0].variant, "bp"); assert_eq!(labels[0].codec_hint, ""); } /// Spec: codec token from the 5th column → codec_hint via vocab::codec. /// Mutation: skip vocab lookup → raw token stored instead of canonical name. #[test] fn ls_codec_token_passed_to_vocab() { let labels = parse_language_streams_text("id,audio_production,1,eng,MLP\n"); // "MLP" maps to "TrueHD" via vocab::codec. assert_eq!(labels[0].codec_hint, "TrueHD"); } /// Spec: lines with fewer than 4 CSV fields are silently skipped. /// Mutation: parse short lines anyway → panic or garbage label emitted. #[test] fn ls_too_few_fields_skipped() { let labels = parse_language_streams_text("id,audio_production,1\n"); assert!(labels.is_empty()); } /// Spec: comment lines (starting with #) are skipped. /// Mutation: remove `starts_with('#')` guard → comment parsed as stream. #[test] fn ls_comment_lines_skipped() { let labels = parse_language_streams_text("# this is a comment\nid,audio_production,1,eng\n"); assert_eq!(labels.len(), 1); assert_eq!(labels[0].language, "eng"); } /// Spec: the skip test is `is_empty() || starts_with('#')` — EITHER /// condition alone must skip the line. A commented-out line that /// happens to look like valid CSV (a real authoring pattern for /// disabling a stream entry) must never produce a label. /// Mutation: `||` -> `&&` requires BOTH conditions, which a non-empty /// comment line can never satisfy, so it would fall through to the /// CSV parser and (since it has >= 4 comma fields) emit a spurious /// label instead of being skipped. #[test] fn ls_comment_line_with_csv_shape_is_still_skipped() { let labels = parse_language_streams_text("#id,audio_production,1,eng\nid2,audio_production,2,fra\n"); assert_eq!( labels.len(), 1, "the commented-out CSV-shaped line must not parse" ); assert_eq!(labels[0].language, "fra"); } /// Spec: `subtitle_dual` is a recognized subtitle type (Normal/no /// qualifier). Mutation: delete this match arm → falls to the /// catch-all `_ => continue`, silently dropping the stream. #[test] fn ls_subtitle_dual_parsed() { let labels = parse_language_streams_text("id,subtitle_dual,1,eng\n"); assert_eq!(labels.len(), 1, "subtitle_dual must produce a label"); assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle); assert_eq!(labels[0].purpose, LabelPurpose::Normal); assert_eq!(labels[0].qualifier, LabelQualifier::None); } /// Spec: `subtitle_bonus` is a recognized subtitle type (Normal/no /// qualifier). Mutation: delete this match arm → dropped as unknown. #[test] fn ls_subtitle_bonus_parsed() { let labels = parse_language_streams_text("id,subtitle_bonus,2,eng\n"); assert_eq!(labels.len(), 1, "subtitle_bonus must produce a label"); assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle); assert_eq!(labels[0].purpose, LabelPurpose::Normal); } /// Spec: `subtitle_ime` maps to Subtitle/Ime (no Forced qualifier, /// unlike `subtitle_ime_narrative`). /// Mutation: delete this match arm → dropped as unknown. #[test] fn ls_subtitle_ime_parsed() { let labels = parse_language_streams_text("id,subtitle_ime,3,jpn\n"); assert_eq!(labels.len(), 1, "subtitle_ime must produce a label"); assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle); assert_eq!(labels[0].purpose, LabelPurpose::Ime); assert_eq!(labels[0].qualifier, LabelQualifier::None); } /// Spec: multiple valid lines produce multiple labels. /// Mutation: stop after first label → only 1 label returned. #[test] fn ls_multiple_lines_produce_multiple_labels() { let text = "id1,audio_production,1,eng\nid2,audio_commentary,2,eng\nid3,subtitle_production,1,eng\n"; let labels = parse_language_streams_text(text); assert_eq!(labels.len(), 3); let audio: Vec<_> = labels .iter() .filter(|l| l.stream_type == StreamLabelType::Audio) .collect(); let subs: Vec<_> = labels .iter() .filter(|l| l.stream_type == StreamLabelType::Subtitle) .collect(); assert_eq!(audio.len(), 2); assert_eq!(subs.len(), 1); } /// Spec: prefix_is_commentary rejects "community_" as a false positive. /// This is the pre-fix bug: bare `contains("comm")` matched any word with /// "comm" as a substring. After the fix only whole-segment "comm" or /// "commentary" matches. /// Mutation: use `prefix.contains("comm")` → community_1 incorrectly matches. #[test] fn prefix_is_commentary_rejects_community_prefix() { assert!(!prefix_is_commentary("community_1")); assert!(!prefix_is_commentary("community")); assert!(!prefix_is_commentary("recommit_1")); } /// Spec: prefix_is_commentary matches "comm" as a standalone segment. /// Mutation: require "commentary" specifically → bare "comm" prefix fails. #[test] fn prefix_is_commentary_matches_bare_comm_segment() { assert!(prefix_is_commentary("comm")); assert!(prefix_is_commentary("audio_comm")); assert!(prefix_is_commentary("comm_track_1")); } } // ── menu_base.prop parser ────────────────────────────────────────────────── fn parse_menu_base(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option> { let data = super::read_jar_file(reader, udf, "menu_base.prop")?; let text = std::str::from_utf8(&data).ok()?; let labels = parse_menu_base_text(text); if labels.is_empty() { return None; } Some(labels) } /// Parse the body of a `menu_base.prop` file into stream labels. Split /// out from [`parse_menu_base`] (which only handles file I/O + UTF-8 /// decode) so unit tests exercise the real parsing logic instead of a /// hand-copied duplicate. Returns the labels sorted by (type, number). fn parse_menu_base_text(text: &str) -> Vec { // Parse key=value, group by prefix let mut entries: HashMap> = HashMap::new(); for line in text.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } let eq_pos = match line.find('=') { Some(p) => p, None => continue, }; let full_key = &line[..eq_pos]; let value = &line[eq_pos + 1..]; if let Some(dot_pos) = full_key.rfind('.') { let prefix = full_key[..dot_pos].to_string(); let key = full_key[dot_pos + 1..].to_string(); entries .entry(prefix) .or_default() .insert(key, value.to_string()); } } let mut labels = Vec::new(); for (prefix, props) in &entries { // Audio: has "streamNumber" or "audioStream" and audio-related class let is_audio = props .get("class") .is_some_and(|c| c.contains("AudioButton")) || prefix.starts_with("audio_"); let is_subtitle = props .get("class") .is_some_and(|c| c.contains("SubtitleButton")) || prefix.starts_with("subtitle_"); let stream_num_str = props .get("streamNumber") .or_else(|| props.get("audioStream")) .or_else(|| props.get("subtitleStream")); let stream_num: u16 = match stream_num_str.and_then(|s| s.parse().ok()) { Some(n) if n > 0 => n, _ => continue, }; if !is_audio && !is_subtitle { continue; } // Resolve the stream type FIRST: when an entry trips both flags // (e.g. an `audio_` prefix with a class containing // "SubtitleButton"), audio wins the type. let stream_type = if is_audio { StreamLabelType::Audio } else { StreamLabelType::Subtitle }; let name = props.get("name").cloned().unwrap_or_default(); // Purpose: ask vocab first (word-boundary matched — avoids the // "Commenter" false positive the prior `name.contains("comment")` // had). Then fall back to the structural prefix check // (`audio_commentary.foo`-style keys group commentary streams // regardless of display name). let purpose = match vocab::purpose(&name) { LabelPurpose::Normal if prefix_is_commentary(prefix) => LabelPurpose::Commentary, p => p, }; // Qualifier (SDH/Forced) is a subtitle-only concept. Gate on the // RESOLVED type, not the raw is_subtitle flag, so an entry that // resolved to Audio never carries a subtitle qualifier. let qualifier = if stream_type == StreamLabelType::Subtitle { vocab::qualifier(&name) } else { LabelQualifier::None }; // Try to extract language from audioLanguage/subtitleLanguage prop let language = props .get("audioLanguage") .or_else(|| props.get("subtitleLanguage")) .cloned() .unwrap_or_default(); labels.push(StreamLabel { stream_id: None, stream_number: stream_num, stream_type, language, name, purpose, qualifier, codec_hint: String::new(), variant: String::new(), }); } labels.sort_by_key(|l| (l.stream_type as u8, l.stream_number)); labels }