use super::xml;
use crate::sector::SectorReader;
use crate::udf::UdfFs;
use std::collections::BTreeMap;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
pub struct DiscMetadata {
pub titles: BTreeMap<String, String>,
pub descriptions: BTreeMap<String, String>,
pub disc_number: Option<(u32, u32)>,
}
pub fn detect(udf: &UdfFs) -> bool {
let Some(dir) = udf.find_dir("/BDMV/META/DL") else {
return false;
};
dir.entries
.iter()
.any(|e| !e.is_dir && is_bdmt_filename(&e.name))
}
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<DiscMetadata> {
let dir = udf.find_dir("/BDMV/META/DL")?;
let mut out = DiscMetadata::default();
for entry in &dir.entries {
if entry.is_dir {
continue;
}
let Some(lang) = lang_code_from_filename(&entry.name) else {
continue;
};
let path = format!("/BDMV/META/DL/{}", entry.name);
let Ok(bytes) = udf.read_file(reader, &path) else {
continue;
};
let Ok(text) = std::str::from_utf8(&bytes) else {
continue;
};
let Some((title, description, disc_set)) = parse_bdmt_xml(&lang, text) else {
continue;
};
out.titles.insert(lang.clone(), title);
if let Some(desc) = description {
out.descriptions.insert(lang.clone(), desc);
}
if out.disc_number.is_none() {
if let Some(ds) = disc_set {
out.disc_number = Some(ds);
}
}
}
if out.titles.is_empty() {
None
} else {
Some(out)
}
}
fn is_bdmt_filename(name: &str) -> bool {
lang_code_from_filename(name).is_some()
}
fn lang_code_from_filename(name: &str) -> Option<String> {
let lower = name.to_ascii_lowercase();
let stem = lower.strip_suffix(".xml")?;
let lang = stem.strip_prefix("bdmt_")?;
if lang.len() != 3 || !lang.chars().all(|c| c.is_ascii_alphabetic()) {
return None;
}
Some(lang.to_string())
}
pub(crate) type BdmtFields = (String, Option<String>, Option<(u32, u32)>);
pub(crate) fn parse_bdmt_xml(_lang_code: &str, xml_text: &str) -> Option<BdmtFields> {
let title = extract_title(xml_text)?;
let description = xml::text(xml_text, "description")
.filter(|s| !s.is_empty())
.filter(|s| !looks_like_xml(s))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let disc_set = extract_disc_set(xml_text);
Some((title, description, disc_set))
}
fn looks_like_xml(s: &str) -> bool {
let t = s.trim_start();
t.starts_with('<')
}
fn extract_title(xml_text: &str) -> Option<String> {
for tag in ["name", "title"] {
if let Some(s) = xml::text(xml_text, tag) {
let trimmed = s.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
if let Some((s, e)) = xml::find_element(xml_text, "tableOfContents", 0) {
let block = &xml_text[s..e];
if let Some(t) = xml::text(block, "titleName") {
let trimmed = t.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
fn extract_disc_set(xml_text: &str) -> Option<(u32, u32)> {
let n = xml::text(xml_text, "discNumber")?
.trim()
.parse::<u32>()
.ok()?;
let total = xml::text(xml_text, "numSets")
.or_else(|| xml::text(xml_text, "numberOfSets"))?
.trim()
.parse::<u32>()
.ok()?;
Some((n, total))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_simple_title() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>Dune Part Two</di:name>
</discInfo>"#;
let (title, desc, set) = parse_bdmt_xml("eng", xml).expect("title should parse");
assert_eq!(title, "Dune Part Two");
assert_eq!(desc, None);
assert_eq!(set, None);
}
#[test]
fn extract_title_element_variant() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:title>The Matrix</di:title>
<di:description>A film about computers.</di:description>
</discInfo>"#;
let (title, desc, _) = parse_bdmt_xml("eng", xml).unwrap();
assert_eq!(title, "The Matrix");
assert_eq!(desc.as_deref(), Some("A film about computers."));
}
#[test]
fn extract_title_from_table_of_contents_fallback() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:tableOfContents>
<di:titleName>Inside Out 2</di:titleName>
</di:tableOfContents>
</discInfo>"#;
let (title, _, _) = parse_bdmt_xml("eng", xml).unwrap();
assert_eq!(title, "Inside Out 2");
}
#[test]
fn extract_box_set_position() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>LOTR Disc 2</di:name>
<di:discNumber>2</di:discNumber>
<di:numSets>5</di:numSets>
</discInfo>"#;
let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap();
assert_eq!(set, Some((2, 5)));
}
#[test]
fn extract_box_set_position_alternate_total_tag() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>X</di:name>
<di:discNumber>3</di:discNumber>
<di:numberOfSets>6</di:numberOfSets>
</discInfo>"#;
let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap();
assert_eq!(set, Some((3, 6)));
}
#[test]
fn extract_box_set_requires_both_fields() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>X</di:name>
<di:discNumber>1</di:discNumber>
</discInfo>"#;
let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap();
assert_eq!(set, None);
}
#[test]
fn multiple_languages_keyed_correctly() {
let eng_xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>Dune Part Two</di:name>
</discInfo>"#;
let fra_xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>Dune Deuxième Partie</di:name>
<di:description>Suite du film de 2021.</di:description>
</discInfo>"#;
let mut meta = DiscMetadata::default();
for (lang, blob) in [("eng", eng_xml), ("fra", fra_xml)] {
let (title, desc, ds) = parse_bdmt_xml(lang, blob).unwrap();
meta.titles.insert(lang.to_string(), title);
if let Some(d) = desc {
meta.descriptions.insert(lang.to_string(), d);
}
if meta.disc_number.is_none() {
if let Some(d) = ds {
meta.disc_number = Some(d);
}
}
}
assert_eq!(
meta.titles.get("eng").map(String::as_str),
Some("Dune Part Two")
);
assert_eq!(
meta.titles.get("fra").map(String::as_str),
Some("Dune Deuxième Partie")
);
assert!(meta.descriptions.get("eng").is_none());
assert_eq!(
meta.descriptions.get("fra").map(String::as_str),
Some("Suite du film de 2021.")
);
assert_eq!(meta.disc_number, None);
}
#[test]
fn malformed_xml_returns_none() {
let bad = "this is not xml &&& <<< nope";
assert!(parse_bdmt_xml("eng", bad).is_none());
let truncated = "<discInfo><di:name>";
assert!(parse_bdmt_xml("eng", truncated).is_none());
}
#[test]
fn description_with_only_child_xml_is_dropped() {
let xml = r#"<discInfo>
<di:name>Top Gun: Maverick</di:name>
<di:description>
<di:thumbnail href="tgm_meta_sm.jpg" />
<di:thumbnail href="tgm_meta_lg.jpg" />
</di:description>
</discInfo>"#;
let (title, description, _) =
parse_bdmt_xml("eng", xml).expect("title is present so parse must succeed");
assert_eq!(title, "Top Gun: Maverick");
assert!(
description.is_none(),
"description containing only XML children must be dropped, got {description:?}"
);
}
#[test]
fn description_with_plain_text_passes_through() {
let xml = r#"<discInfo>
<di:name>Some Movie</di:name>
<di:description>An epic tale of one man's quest for tea.</di:description>
</discInfo>"#;
let (_, description, _) = parse_bdmt_xml("eng", xml).expect("must parse");
assert_eq!(
description.as_deref(),
Some("An epic tale of one man's quest for tea.")
);
}
#[test]
fn whitespace_in_title_is_trimmed() {
let xml = r#"<discInfo><di:name>
Dune Part Two
</di:name></discInfo>"#;
let (title, _, _) = parse_bdmt_xml("eng", xml).unwrap();
assert_eq!(title, "Dune Part Two");
}
#[test]
fn lang_code_extraction() {
assert_eq!(lang_code_from_filename("bdmt_eng.xml"), Some("eng".into()));
assert_eq!(lang_code_from_filename("BDMT_FRA.XML"), Some("fra".into()));
assert_eq!(lang_code_from_filename("bdmt_jpn.xml"), Some("jpn".into()));
assert_eq!(lang_code_from_filename("bdmt_.xml"), None);
assert_eq!(lang_code_from_filename("bdmt_engl.xml"), None);
assert_eq!(lang_code_from_filename("bdmt_e1g.xml"), None);
assert_eq!(lang_code_from_filename("bdmt_eng.txt"), None);
assert_eq!(lang_code_from_filename("foo.xml"), None);
}
}