From e78b4effe992c4509194c8a2fdc2661e89ea58d0 Mon Sep 17 00:00:00 2001 From: Matthew Jackson Date: Sun, 10 May 2026 21:32:54 -0700 Subject: [PATCH] labels: surface MPLS chapter summary in LabelAnalysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LabelAnalysis gains `chapter_summary: Vec` — one row per .mpls file in /BDMV/PLAYLIST/, with chapter count (PlaylistMark entries with mark_type ≤ 1) and approximate playlist duration in seconds. Sorted by playlist filename. Sourced from the existing crate::mpls parser (no new format work). Useful for identifying the main feature playlist at a glance — it's the one with the longest duration. Verified on disc-11 (Dune Pt 2): 00800.mpls correctly identified as 2h 45m 49s with 18 chapters amid 30+ shorter playlists. Doesn't touch the per-title `disc::DiscTitle::chapters` field which disc::bluray.rs already populates from the same marks during disc init — this is purely the diagnostic surface for labels-analyze. --- src/labels/mod.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/labels/mod.rs b/src/labels/mod.rs index 77991a5..6f9f8cf 100644 --- a/src/labels/mod.rs +++ b/src/labels/mod.rs @@ -541,6 +541,8 @@ pub fn analyze(reader: &mut dyn SectorReader, udf: &UdfFs) -> LabelAnalysis { None }; + let chapter_summary = collect_chapter_summary(reader, udf); + LabelAnalysis { parser, parsers_detected, @@ -549,9 +551,59 @@ pub fn analyze(reader: &mut dyn SectorReader, udf: &UdfFs) -> LabelAnalysis { labels, disc_metadata, gap_fill_added, + chapter_summary, } } +/// Scan `/BDMV/PLAYLIST/*.mpls`, parse each, return a row per playlist +/// with chapter count (mark_type ≤ 1) and total duration. Sorted by +/// playlist filename. Skipped entries (read error, parse error, no +/// marks) silently dropped — this is a diagnostic field, not a +/// correctness-critical one. +fn collect_chapter_summary(reader: &mut dyn SectorReader, udf: &UdfFs) -> Vec { + let Some(playlist_dir) = udf.find_dir("/BDMV/PLAYLIST") else { + return Vec::new(); + }; + let mut names: Vec = playlist_dir + .entries + .iter() + .filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(".mpls")) + .map(|e| e.name.clone()) + .collect(); + names.sort(); + + let mut out: Vec = Vec::new(); + for name in names { + let path = format!("/BDMV/PLAYLIST/{}", name); + let Ok(data) = udf.read_file(reader, &path) else { + continue; + }; + let Ok(playlist) = crate::mpls::parse(&data) else { + continue; + }; + let chapter_count = playlist.marks.iter().filter(|m| m.mark_type <= 1).count(); + if chapter_count == 0 { + continue; + } + // Duration: sum of (out_time - in_time) across play items, + // each in 45kHz PTS ticks → seconds. Approximates the disc + // module's per-title duration; we don't claim sample accuracy + // here, just enough to identify "the long one" (main movie). + let duration_ticks: u64 = playlist + .play_items + .iter() + .map(|pi| pi.out_time.saturating_sub(pi.in_time) as u64) + .sum(); + let duration_secs = duration_ticks as f64 / 45000.0; + out.push(ChapterSummary { + playlist: name, + chapter_count, + duration_secs, + }); + } + out +} + /// Result of [`analyze`]. #[doc(hidden)] #[derive(Debug, Clone)] @@ -588,6 +640,20 @@ pub struct LabelAnalysis { /// every MPLS-known stream slot, or MPLS itself was the chosen /// parser. Diagnostic for the labels-analyze tool. pub gap_fill_added: usize, + /// Per-playlist chapter summary: `(playlist_filename, chapter_count, duration_secs)`. + /// Sourced from MPLS PlaylistMark entries with `mark_type ≤ 1` + /// (chapter entries). Ordered by playlist filename. Empty if no + /// MPLS files have parseable marks, or the disc isn't Blu-ray. + pub chapter_summary: Vec, +} + +/// One row of the per-playlist chapter summary in `LabelAnalysis`. +#[doc(hidden)] +#[derive(Debug, Clone)] +pub struct ChapterSummary { + pub playlist: String, + pub chapter_count: usize, + pub duration_secs: f64, } /// List filenames found under any `/BDMV/JAR//` subdirectory of