Skip to main content

libfreemkv/labels/
mod.rs

1//! Stream label extraction from BD-J disc files.
2//!
3//! Each parser module represents one BD-J authoring framework.
4//! To add a new format:
5//!   1. Create `src/labels/myformat.rs`
6//!   2. Implement `pub fn detect(udf: &UdfFs) -> bool`
7//!   3. Implement `pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>>`
8//!   4. Add `mod myformat;` below and one line to `PARSERS` array
9
10mod criterion;
11mod ctrm;
12mod paramount;
13mod pixelogic;
14pub mod vocab;
15
16use crate::disc::{DiscTitle, Stream};
17use crate::sector::SectorReader;
18use crate::udf::UdfFs;
19
20// Re-exported via crate::disc — the public API surfaces these next to
21// AudioStream/SubtitleStream so callers can map purpose/qualifier to display
22// text in their own locale.
23
24/// A stream label extracted from disc config files.
25#[derive(Debug, Clone)]
26#[allow(dead_code)]
27pub struct StreamLabel {
28    /// STN index (1-based)
29    pub stream_number: u16,
30    /// Audio or Subtitle
31    pub stream_type: StreamLabelType,
32    /// ISO 639-2 language code
33    pub language: String,
34    /// Display name (e.g. "Commentary", "Descriptive Audio")
35    pub name: String,
36    /// Stream purpose
37    pub purpose: LabelPurpose,
38    /// Additional qualifier
39    pub qualifier: LabelQualifier,
40    /// Codec hint from config (e.g. "TrueHD", "Dolby Digital", "Dolby Atmos")
41    pub codec_hint: String,
42    /// Regional variant (e.g. "US", "UK", "Castilian", "Canadian")
43    pub variant: String,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq)]
47pub enum StreamLabelType {
48    Audio,
49    Subtitle,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub enum LabelPurpose {
54    Normal,
55    Commentary,
56    Descriptive,
57    Score,
58    Ime,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq)]
62pub enum LabelQualifier {
63    None,
64    Sdh,
65    DescriptiveService,
66    Forced,
67}
68
69// ── Parser registry ────────────────────────────────────────────────────────
70//
71// Each entry: (name, detect_fn, parse_fn)
72// Order = priority. First match wins. Highest quality output first.
73
74type DetectFn = fn(&UdfFs) -> bool;
75type ParseFn = fn(&mut dyn SectorReader, &UdfFs) -> Option<Vec<StreamLabel>>;
76
77const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
78    ("paramount", paramount::detect, paramount::parse),
79    ("criterion", criterion::detect, criterion::parse),
80    ("pixelogic", pixelogic::detect, pixelogic::parse),
81    ("ctrm", ctrm::detect, ctrm::parse),
82    // ("deluxe",  deluxe::detect,     deluxe::parse),  // TODO: bytecode parser
83];
84
85/// Search disc for config files, extract labels, apply to streams.
86/// This is 100% optional — if anything fails, streams are untouched.
87pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle]) {
88    let labels = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| extract(reader, udf)))
89        .unwrap_or_default();
90    if labels.is_empty() {
91        return;
92    }
93
94    for title in titles.iter_mut() {
95        let mut audio_idx: u16 = 0;
96        let mut sub_idx: u16 = 0;
97
98        for stream in &mut title.streams {
99            match stream {
100                Stream::Audio(a) => {
101                    audio_idx += 1;
102                    if let Some(label) = labels.iter().find(|l| {
103                        l.stream_type == StreamLabelType::Audio && l.stream_number == audio_idx
104                    }) {
105                        // Structured fields — callers translate purpose to UI text.
106                        a.purpose = label.purpose;
107
108                        // a.label only carries codec/variant info. NEVER any
109                        // English purpose text — the CLI handles that via i18n.
110                        let mut parts = Vec::new();
111                        if !label.variant.is_empty() {
112                            parts.push(format!("({})", label.variant));
113                        }
114                        if !label.codec_hint.is_empty() {
115                            parts.push(label.codec_hint.clone());
116                        }
117                        if !parts.is_empty() {
118                            a.label = parts.join(" ");
119                        } else if !label.name.is_empty() && label.purpose == LabelPurpose::Normal {
120                            // Only fall back to the parser-supplied display
121                            // name when there's no purpose to flag — the CLI
122                            // handles purpose rendering itself.
123                            a.label = label.name.clone();
124                        }
125                    }
126                }
127                Stream::Subtitle(s) => {
128                    sub_idx += 1;
129                    if let Some(label) = labels.iter().find(|l| {
130                        l.stream_type == StreamLabelType::Subtitle && l.stream_number == sub_idx
131                    }) {
132                        s.qualifier = label.qualifier;
133                        if label.qualifier == LabelQualifier::Forced {
134                            s.forced = true;
135                        }
136                    }
137                }
138                _ => {}
139            }
140        }
141    }
142}
143
144/// Fill in default labels for any streams that don't have one.
145/// Runs after BD-J label extraction — fills gaps with codec + channel descriptions.
146/// This is the central place for all fallback label generation.
147pub fn fill_defaults(titles: &mut [crate::disc::DiscTitle]) {
148    use crate::disc::Stream;
149
150    for title in titles.iter_mut() {
151        for stream in &mut title.streams {
152            match stream {
153                Stream::Audio(a) if a.label.is_empty() => {
154                    a.label = generate_audio_label(&a.codec, &a.channels, a.secondary);
155                }
156                Stream::Video(v) if v.label.is_empty() => {
157                    v.label =
158                        generate_video_label(&v.codec, v.resolution.pixels(), &v.hdr, v.secondary);
159                }
160                Stream::Subtitle(s) if s.forced => {
161                    // Ensure forced subs are labeled even if BD-J didn't set a name
162                    // (subtitle labels are generally not set — this just marks forced)
163                }
164                _ => {}
165            }
166        }
167    }
168}
169
170fn generate_video_label(
171    codec: &crate::disc::Codec,
172    pixels: (u32, u32),
173    hdr: &crate::disc::HdrFormat,
174    secondary: bool,
175) -> String {
176    use crate::disc::HdrFormat;
177
178    if secondary {
179        // "Dolby Vision EL" is a brand identifier, not English prose, so the
180        // library may emit it. Other "secondary video" wording is a CLI
181        // concern — the library just leaves the label empty.
182        return match hdr {
183            HdrFormat::DolbyVision => "Dolby Vision EL".to_string(),
184            _ => String::new(),
185        };
186    }
187
188    let mut parts = Vec::new();
189
190    // Codec
191    parts.push(codec.name().to_string());
192
193    // Resolution
194    let (w, h) = pixels;
195    let res = if w >= 7680 {
196        "8K"
197    } else if w >= 3840 {
198        "4K"
199    } else if w >= 1920 {
200        "1080p"
201    } else if w >= 1280 {
202        "720p"
203    } else if h >= 576 {
204        "576p"
205    } else if h >= 480 {
206        "480p"
207    } else {
208        ""
209    };
210    if !res.is_empty() {
211        parts.push(res.into());
212    }
213
214    // HDR
215    match hdr {
216        HdrFormat::Sdr => {}
217        _ => parts.push(hdr.name().to_string()),
218    }
219
220    parts.join(" ")
221}
222
223fn generate_audio_label(
224    codec: &crate::disc::Codec,
225    channels: &crate::disc::AudioChannels,
226    _secondary: bool,
227) -> String {
228    use crate::disc::{AudioChannels, Codec};
229
230    // Full marketing names for disc audio codecs.
231    // These are codec brand identifiers, not user-facing English prose.
232    let codec_name = match codec {
233        Codec::TrueHd => "Dolby TrueHD",
234        Codec::Ac3 => "Dolby Digital",
235        Codec::Ac3Plus => "Dolby Digital Plus",
236        Codec::DtsHdMa => "DTS-HD Master Audio",
237        Codec::DtsHdHr => "DTS-HD High Resolution",
238        Codec::Dts => "DTS",
239        Codec::Lpcm => "LPCM",
240        Codec::Aac => "AAC",
241        Codec::Mp2 => "MPEG Audio",
242        Codec::Mp3 => "MP3",
243        Codec::Flac => "FLAC",
244        Codec::Opus => "Opus",
245        _ => return String::new(),
246    };
247
248    // Channel layout
249    let channel_str = match channels {
250        AudioChannels::Mono => "1.0",
251        AudioChannels::Stereo => "2.0",
252        AudioChannels::Stereo21 => "2.1",
253        AudioChannels::Quad => "4.0",
254        AudioChannels::Surround50 => "5.0",
255        AudioChannels::Surround51 => "5.1",
256        AudioChannels::Surround61 => "6.1",
257        AudioChannels::Surround71 => "7.1",
258        AudioChannels::Unknown => "",
259    };
260
261    // The "(Secondary)" suffix is a CLI/UI concern — callers display it from
262    // the AudioStream::secondary bool, not the library.
263    if channel_str.is_empty() {
264        codec_name.to_string()
265    } else {
266        format!("{} {}", codec_name, channel_str)
267    }
268}
269
270fn extract(reader: &mut dyn SectorReader, udf: &UdfFs) -> Vec<StreamLabel> {
271    for (_name, detect, parse) in PARSERS {
272        if detect(udf) {
273            if let Some(labels) = parse(reader, udf) {
274                return labels;
275            }
276        }
277    }
278    Vec::new()
279}
280
281// ── Shared helpers ─────────────────────────────────────────────────────────
282
283/// Check if a file exists in any BDMV/JAR subdirectory.
284pub(crate) fn jar_file_exists(udf: &UdfFs, filename: &str) -> bool {
285    find_jar_file(udf, filename).is_some()
286}
287
288/// Find a file in any BDMV/JAR subdirectory, return its path.
289pub(crate) fn find_jar_file(udf: &UdfFs, filename: &str) -> Option<String> {
290    let jar_dir = udf.find_dir("/BDMV/JAR")?;
291    for entry in &jar_dir.entries {
292        if entry.is_dir {
293            let path = format!("/BDMV/JAR/{}/{}", entry.name, filename);
294            // Check if file exists in this subdirectory
295            for child in &entry.entries {
296                if !child.is_dir && child.name.eq_ignore_ascii_case(filename) {
297                    return Some(path);
298                }
299            }
300        }
301    }
302    None
303}
304
305/// Read a file from any BDMV/JAR subdirectory by filename.
306pub(crate) fn read_jar_file(
307    reader: &mut dyn SectorReader,
308    udf: &UdfFs,
309    filename: &str,
310) -> Option<Vec<u8>> {
311    let path = find_jar_file(udf, filename)?;
312    udf.read_file(reader, &path).ok().filter(|d| !d.is_empty())
313}