Files
libfreemkv/src/disc/bluray.rs
T
Matthew Jackson 422f2b6bcf 3D MVC mux: audit round 2 (converged)
Second audit round converged (severity collapsed 6 HIGH -> 1; the one
HIGH was a bounded 32-element scan, not a defect; the sole spec MEDIUM
was the same false-positive re-raised — 0xBF matches ISO/IEC 14496-15
§7.6.2 verbatim). One genuine robustness fix plus coverage:

- extract_mvc_params: skip a zero-length NAL instead of abandoning the
  scan, so a stray length prefix before the subset SPS/PPS no longer
  silently drops 3D signalling. Test proves params after a zero-length
  NAL are still found.
- Tests: parser_for_mvc_dependent routes H.264 to a passthrough parser;
  passthrough with an IDR does not re-assert param sets (the keyframe &&
  !mvc branch).
- Document the per-playlist (not per-clip) is_3d latching as a known
  limitation (real main-feature playlists are uniformly 3D).
2026-07-13 11:39:07 -07:00

1476 lines
58 KiB
Rust

//! Blu-ray title scanning — MPLS playlist parsing, CLPI clip info, BD metadata.
use super::*;
use crate::clpi;
use crate::mpls;
use crate::sector::SectorSource;
use crate::udf;
/// Stream-file extensions probed for a BD-family playlist clip, in priority
/// order. A clip is normally `.m2ts`; AACS 2.1 (FMTS) discs name the main feature
/// `.fmts` (an M2TS transport stream plus forensic variant segments) and 3D discs
/// use `.ssif`. `.m2ts` is tried first, so a normal clip is unaffected — the
/// fallback only runs when `.m2ts` is absent (exactly when `file_extents` errors).
///
/// Scope: these are all variants that live in `BDMV/STREAM/` and are reached
/// through an MPLS playlist. HD-DVD's `.evo` does NOT belong here — HD-DVD is a
/// different tree (`HVDVD_TS/`) with `.XPL` playlists and needs its own
/// enumerator (a peer to `parse_playlist`), not another extension in this list.
const CLIP_STREAM_EXTS: [&str; 3] = ["m2ts", "fmts", "ssif"];
impl Disc {
/// Scan Blu-ray titles from MPLS playlists.
pub(super) fn scan_bluray_titles(
reader: &mut dyn SectorSource,
udf_fs: &udf::UdfFs,
) -> Vec<DiscTitle> {
let mut titles = Vec::new();
if let Some(playlist_dir) = udf_fs.find_dir("/BDMV/PLAYLIST") {
for entry in &playlist_dir.entries {
if !entry.is_dir && entry.name.to_lowercase().ends_with(".mpls") {
let path = format!("/BDMV/PLAYLIST/{}", entry.name);
if let Ok(mpls_data) = udf_fs.read_file(reader, &path) {
if let Some(title) =
Self::parse_playlist(reader, udf_fs, &entry.name, &mpls_data)
{
titles.push(title);
}
}
}
}
}
titles
}
/// Parse one MPLS playlist into a [`DiscTitle`].
///
/// Sums PlayItem durations; returns `None` if the playlist is under
/// 30 seconds (skips menu / clip-info stub playlists) or fails to
/// parse. Physical sector extents are pulled from the UDF allocation
/// descriptors of each referenced `.m2ts` (deduplicated by clip_id).
pub(super) fn parse_playlist(
reader: &mut dyn SectorSource,
udf_fs: &udf::UdfFs,
filename: &str,
data: &[u8],
) -> Option<DiscTitle> {
let parsed = mpls::parse(data).ok()?;
// Calculate duration from play items
let duration_ticks: u64 = parsed
.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;
// Skip very short playlists (< 30 seconds)
if duration_secs < 30.0 {
return None;
}
// Parse each clip for size, duration, and sector extents
let mut extents = Vec::new();
let mut total_size: u64 = 0;
// Set when any clip resolves to a STREAM/SSIF/<clip>.ssif — a Blu-ray 3D
// interleaved stream carrying both the base (left) and MVC dependent
// (right) views. Drives reading the SSIF for both eyes and adding the
// dependent-view stream below.
let mut is_3d = false;
let mut clips = Vec::with_capacity(parsed.play_items.len());
// BD playlists legally reference the same .m2ts clip_id from
// multiple PlayItems (multi-angle, seamless splits, looped
// segments). The physical extents and packet count must be
// counted ONCE per unique clip — mux reads extents in order, so
// a duplicate would mux the A/V twice and inflate size_bytes.
// Per-PlayItem Clip entries (differing in/out times) still get
// recorded.
let mut seen_clips: std::collections::HashSet<String> = std::collections::HashSet::new();
for play_item in &parsed.play_items {
let clip_dur = play_item.out_time.saturating_sub(play_item.in_time) as f64 / 45000.0;
let mut pkt_count: u32 = 0;
let clpi_path = format!("/BDMV/CLIPINF/{}.clpi", play_item.clip_id);
if let Ok(clpi_data) = udf_fs.read_file(reader, &clpi_path) {
if let Ok(clip_info) = clpi::parse(&clpi_data) {
pkt_count = clip_info.source_packet_count;
// Mark the clip seen ONLY after its .clpi parses — a transient
// read/parse failure on the first PlayItem referencing a clip
// must not permanently suppress its extents/size for a later
// PlayItem referencing the same clip that succeeds.
let first_ref = seen_clips.insert(play_item.clip_id.clone());
// Only fetch/push the physical extents and add to the
// total size the first time this clip_id is seen.
if first_ref {
total_size += pkt_count as u64 * 192;
// Get stream file extents from UDF allocation descriptors.
// Dual-layer discs split files across layers — UDF knows the real layout.
//
// The clip's stream file is normally `.m2ts`, but AACS 2.1
// (FMTS) discs name the main feature `.fmts` and 3D discs
// use `.ssif` (see [`CLIP_STREAM_EXTS`]). A normal `.m2ts`
// clip is unchanged — the fallback only runs when `.m2ts`
// is absent, which is exactly when `file_extents` errors.
// 3D discs interleave the left (base) and right (MVC
// dependent) views in STREAM/SSIF/<clip>.ssif — note the
// SSIF/ subdir. Prefer it when present: the SSIF is one
// transport stream carrying BOTH eyes on distinct PIDs,
// so muxing it captures the full 3D. 2D clips fall back to
// the base .m2ts / .fmts as before.
let ssif = format!("/BDMV/STREAM/SSIF/{}.ssif", play_item.clip_id);
let file_exts = match udf_fs.file_extents(reader, &ssif) {
Ok(exts) => {
is_3d = true;
Some(exts)
}
Err(_) => CLIP_STREAM_EXTS.iter().find_map(|ext| {
let path = format!("/BDMV/STREAM/{}.{}", play_item.clip_id, ext);
udf_fs.file_extents(reader, &path).ok()
}),
};
if let Some(file_exts) = file_exts {
for (lba, sectors) in file_exts {
if sectors > 0 && lba > 0 {
extents.push(Extent {
start_lba: lba,
sector_count: sectors,
});
}
}
}
}
}
}
clips.push(Clip {
clip_id: play_item.clip_id.clone(),
in_time: play_item.in_time,
out_time: play_item.out_time,
duration_secs: clip_dur,
source_packets: pkt_count,
});
}
// Build streams from STN table
let mut streams: Vec<Stream> = parsed
.streams
.iter()
.filter_map(|s| {
// Skip empty/padding entries (coding_type 0x00)
if s.coding_type == 0 {
return None;
}
let codec = Codec::from_coding_type(s.coding_type);
match s.stream_type {
1 | 6 | 7 => Some(Stream::Video(VideoStream {
pid: s.pid,
codec,
resolution: Resolution::from_video_format(s.video_format),
frame_rate: FrameRate::from_video_rate(s.video_rate),
hdr: match s.dynamic_range {
1 => HdrFormat::Hdr10,
2 => HdrFormat::DolbyVision,
_ => HdrFormat::Sdr,
},
color_space: match s.color_space {
1 => ColorSpace::Bt709,
2 => ColorSpace::Bt2020,
_ => ColorSpace::Unknown,
},
// Blu-ray HD/UHD video is square-pixel; display aspect
// equals the pixel grid (16:9). Anamorphic SD-on-BD is
// not special-cased here.
display_aspect: None,
secondary: s.secondary,
// No user-facing English in the library (numeric-code
// rule): the Dolby Vision enhancement layer is signalled
// structurally (secondary video + DolbyVision hdr) and
// the CLI/UI render the localized descriptor. `label`
// stays empty for disc video streams.
label: String::new(),
// TODO(spec): for 1080i HEVC/H.264/VC-1 titles, surface
// the measured field order (H.264/HEVC pic_struct, VC-1
// pulldown) from the codec parser instead of the TFF
// fallback; needs the parser→title channel (see dvd.rs).
// TODO(spec): prefer the HEVC/H.264 VUI colour_description
// (measured CICP) over this MPLS playlist-nibble guess
// once the parser surfaces it through the output title.
// `None` keeps the enum fallback. (HDR MaxCLL/Mastering
// metadata is a separate task and intentionally not here.)
measured_cicp: None,
})),
2 | 5 => {
// Guard: if coding_type is a subtitle codec (PGS 0x90/0x91),
// this is a misaligned stream -- treat as subtitle, not audio
if matches!(codec, Codec::Pgs) {
Some(Stream::Subtitle(SubtitleStream {
pid: s.pid,
codec,
language: s.language.clone(),
forced: false,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
}))
} else {
Some(Stream::Audio(AudioStream {
pid: s.pid,
codec,
channels: AudioChannels::from_audio_format(s.audio_format),
language: s.language.clone(),
sample_rate: SampleRate::from_audio_rate(s.audio_rate),
secondary: s.stream_type == 5,
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
}))
}
}
3 => Some(Stream::Subtitle(SubtitleStream {
pid: s.pid,
codec,
language: s.language.clone(),
forced: false,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
})),
// Stream type 4 = IG, unknown types -- skip.
other => {
tracing::warn!(
"dropping STN stream entry: unhandled stream_type {} (PID {:#06x}, coding_type {:#04x})",
other,
s.pid,
s.coding_type,
);
None
}
}
})
.collect();
// 3D: add the MVC dependent (right-eye) video stream. The base STN table
// lists only the left-eye video; the dependent view is a second video
// PID (stream_type 0x20) carried in the SSIF. The on-disc PAT/PMT are
// AACS-encrypted (unreadable pre-key) and the base STN omits the
// dependent view (it lives in the MPLS STN_table_SS), so we use the
// BD-3D PID convention: dependent = base-view video PID + 1
// (e.g. 0x1011 -> 0x1012). Reading the SSIF (above) provides its packets.
//
// Limitation: `is_3d` latches per PLAYLIST, not per clip. A playlist that
// mixed a 3D clip (has an SSIF) with a 2D clip (no SSIF) would tag the
// whole title 3D; the 2D clip's frames then mux as plain Blocks (no
// dependent PID → no BlockAdditional) under a track that still advertises
// the mvcC mapping. That output is valid (per-frame BlockAdditional is
// optional) but over-claims 3D for those frames. Real 3D main-feature
// playlists are single-clip or uniformly 3D, so this is not exercised;
// per-clip 3D would need per-clip stream sets (a larger change).
if is_3d {
if let Some(base) = streams.iter().find_map(|s| match s {
Stream::Video(v) => Some(v.clone()),
_ => None,
}) {
let dep_pid = base.pid.wrapping_add(1);
let have_dep = streams
.iter()
.any(|s| matches!(s, Stream::Video(v) if v.pid == dep_pid));
if !have_dep {
streams.push(Stream::Video(VideoStream {
pid: dep_pid,
secondary: true,
label: crate::disc::MVC_DEPENDENT_LABEL.to_string(),
..base
}));
}
}
}
// Convert marks to chapters. mark_type == 1 is an entry-mark
// (chapter); type 2 is a link point and type 0 is reserved, so
// neither is a chapter.
//
// Each mark's timestamp is in the timebase of the PlayItem it
// references (play_item_ref). The chapter's position on the
// muxed timeline is the summed duration of every preceding
// PlayItem plus the mark's offset within its own PlayItem. Using
// play_items[0].in_time for every mark would misplace chapters in
// multi-PlayItem playlists.
let chapters: Vec<Chapter> = parsed
.marks
.iter()
.filter(|m| m.mark_type == 1)
.filter_map(|m| {
let pi_idx = m.play_item_ref as usize;
let pi = parsed.play_items.get(pi_idx)?;
let preceding: f64 = parsed.play_items[..pi_idx]
.iter()
.map(|p| p.out_time.saturating_sub(p.in_time) as f64 / 45000.0)
.sum();
let within = (m.timestamp as f64 - pi.in_time as f64) / 45000.0;
let time_secs = preceding + within;
Some(Chapter {
time_secs: if time_secs < 0.0 { 0.0 } else { time_secs },
name: String::new(), // filled with the ordinal below
})
})
.enumerate()
.map(|(i, mut ch)| {
ch.name = super::chapter_name(i);
ch
})
.collect();
// Strip the .mpls suffix case-insensitively before parsing the
// numeric playlist id (the dir scan accepts any-case .mpls).
let playlist_num = filename
.get(..filename.len().saturating_sub(5))
.filter(|_| {
filename.len() >= 5 && filename[filename.len() - 5..].eq_ignore_ascii_case(".mpls")
})
.unwrap_or(filename);
let playlist_id = playlist_num.parse::<u16>().unwrap_or(0);
Some(DiscTitle {
playlist: filename.to_string(),
playlist_id,
duration_secs,
size_bytes: total_size,
clips,
streams,
chapters,
extents,
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
})
}
/// Read disc title from META/DL/bdmt_eng.xml (Blu-ray Disc Meta Table).
/// Prefers English, falls back to first available language.
/// Returns None if META directory is empty or XML has no usable title.
pub(super) fn read_meta_title(
reader: &mut dyn SectorSource,
udf_fs: &udf::UdfFs,
) -> Option<String> {
let meta_dir = udf_fs.find_dir("/BDMV/META")?;
for sub in &meta_dir.entries {
if !sub.is_dir {
continue;
}
let dl_path = format!("/BDMV/META/{}", sub.name);
if let Some(dl_dir) = udf_fs.find_dir(&dl_path) {
let xml_files: Vec<_> = dl_dir
.entries
.iter()
.filter(|e| !e.is_dir && e.name.to_lowercase().ends_with(".xml"))
.collect();
let eng = xml_files
.iter()
.find(|e| e.name.to_lowercase().contains("eng"));
let target = eng.or_else(|| xml_files.first());
if let Some(entry) = target {
let path = format!("{}/{}", dl_path, entry.name);
if let Ok(data) = udf_fs.read_file(reader, &path) {
let xml = String::from_utf8_lossy(&data);
if let Some(start) = xml.find("<di:name>") {
let s = start + "<di:name>".len();
if let Some(end) = xml[s..].find("</di:name>") {
let title = xml[s..s + end].trim().to_string();
if !title.is_empty() && title != "Blu-ray" {
return Some(title);
}
}
}
}
}
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::udf::fixture::*;
// ---------------------------------------------------------------
// MPLS builder (BD-ROM PlayList spec). Mirrors the layout the
// `mpls::parse` consumer reads (header@0, PlayList@playlist_start,
// PlayListMark@mark_start). Offsets cited against mpls.rs.
// ---------------------------------------------------------------
struct PiSpec {
clip_id: [u8; 5],
in_time: u32,
out_time: u32,
}
struct MarkSpec {
mark_type: u8,
play_item_ref: u16,
timestamp: u32,
}
/// One STN stream entry: stream_entry (len(1)=3, type(1)=0x01, pid(2))
/// plus stream_attributes (len(1) + coding_type(1) + payload). Matches
/// the mpls.rs test builders.
fn se_video(pid: u16, coding_type: u8) -> Vec<u8> {
let mut out = vec![3u8, 0x01];
out.extend_from_slice(&pid.to_be_bytes());
let attrs = vec![coding_type, 0x10]; // format/rate nibbles
out.push(attrs.len() as u8);
out.extend_from_slice(&attrs);
out
}
fn se_audio(pid: u16, coding_type: u8, lang: &[u8; 3]) -> Vec<u8> {
let mut out = vec![3u8, 0x01];
out.extend_from_slice(&pid.to_be_bytes());
// PGS in an audio slot uses PG layout (coding_type + lang(3)); the
// builder only needs the non-PGS audio layout here.
let attrs = vec![coding_type, 0x21, lang[0], lang[1], lang[2]];
out.push(attrs.len() as u8);
out.extend_from_slice(&attrs);
out
}
fn se_pg(pid: u16, coding_type: u8, lang: &[u8; 3]) -> Vec<u8> {
let mut out = vec![3u8, 0x01];
out.extend_from_slice(&pid.to_be_bytes());
let attrs = vec![coding_type, lang[0], lang[1], lang[2]];
out.push(attrs.len() as u8);
out.extend_from_slice(&attrs);
out
}
/// Build an MPLS playlist. `stn_counts` = (video, audio, pg, ig,
/// sec_audio, sec_video, pip_pg, dv); `stream_entries` are appended on
/// the FIRST play item in that order.
fn build_mpls(
items: &[PiSpec],
stn_counts: (u8, u8, u8, u8, u8, u8, u8, u8),
stream_entries: &[Vec<u8>],
marks: &[MarkSpec],
) -> Vec<u8> {
let playlist_start: u32 = 40;
let mut buf = Vec::new();
buf.extend_from_slice(b"MPLS0200"); // type+version
buf.extend_from_slice(&playlist_start.to_be_bytes()); // [8..12]
buf.extend_from_slice(&[0u8; 28]); // mark_start placeholder + pad to 40
// PlayList section: length(4) + reserved(2) + num_play_items(2)
// + num_sub_paths(2) header.
let pl_start = buf.len();
buf.extend_from_slice(&[0u8; 4]); // length placeholder
buf.extend_from_slice(&[0u8; 2]); // reserved
buf.extend_from_slice(&(items.len() as u16).to_be_bytes());
buf.extend_from_slice(&[0u8; 2]); // num_sub_paths
for (idx, pi) in items.iter().enumerate() {
let mut item = Vec::new();
item.extend_from_slice(&pi.clip_id); // [0..5]
item.extend_from_slice(b"M2TS"); // [5..9] codec_id
item.push(0); // [9] connection_condition
item.extend_from_slice(&[0u8; 2]); // [10..12] reserved
item.extend_from_slice(&pi.in_time.to_be_bytes()); // [12..16]
item.extend_from_slice(&pi.out_time.to_be_bytes()); // [16..20]
item.extend_from_slice(&[0u8; 8]); // [20..28] UO_mask
item.push(0); // [28] misc
item.push(0); // [29] still_mode
item.extend_from_slice(&[0u8; 2]); // [30..32] still_time
if idx == 0 {
// STN table: length(2)+reserved(2)+counts(8)+reserved(4).
let stn_start = item.len();
item.extend_from_slice(&[0u8; 2]); // length placeholder
item.extend_from_slice(&[0u8; 2]); // reserved
item.push(stn_counts.0);
item.push(stn_counts.1);
item.push(stn_counts.2);
item.push(stn_counts.3);
item.push(stn_counts.4);
item.push(stn_counts.5);
item.push(stn_counts.6);
item.push(stn_counts.7);
item.extend_from_slice(&[0u8; 4]); // reserved
for se in stream_entries {
item.extend_from_slice(se);
}
let stn_len = (item.len() - stn_start - 2) as u16;
item[stn_start..stn_start + 2].copy_from_slice(&stn_len.to_be_bytes());
}
buf.extend_from_slice(&(item.len() as u16).to_be_bytes());
buf.extend_from_slice(&item);
}
let pl_len = (buf.len() - pl_start - 4) as u32;
buf[pl_start..pl_start + 4].copy_from_slice(&pl_len.to_be_bytes());
// PlayListMark section.
let mark_start = buf.len() as u32;
buf[12..16].copy_from_slice(&mark_start.to_be_bytes());
let mark_section_len = 2 + marks.len() * 14;
buf.extend_from_slice(&(mark_section_len as u32).to_be_bytes());
buf.extend_from_slice(&(marks.len() as u16).to_be_bytes());
for m in marks {
buf.push(0); // [0] reserved
buf.push(m.mark_type); // [1] mark_type
buf.extend_from_slice(&m.play_item_ref.to_be_bytes()); // [2..4]
buf.extend_from_slice(&m.timestamp.to_be_bytes()); // [4..8]
buf.extend_from_slice(&[0u8; 6]); // [8..14] PID + duration
}
buf
}
// ---------------------------------------------------------------
// CLPI builder. `clpi::parse` reads "HDMV" magic, prog_info_start@12,
// cpi_start@16, source_packet_count@56. Zeroing prog_info/cpi starts
// disables those sections cleanly.
// ---------------------------------------------------------------
fn build_clpi(source_packet_count: u32) -> Vec<u8> {
let mut d = vec![0u8; 60];
d[0..4].copy_from_slice(b"HDMV");
d[4..8].copy_from_slice(b"0200");
// seq_info_start/prog_info_start/cpi_start all 0 → skipped.
d[56..60].copy_from_slice(&source_packet_count.to_be_bytes());
d
}
// ---------------------------------------------------------------
// Tests: parse_playlist
// ---------------------------------------------------------------
/// A playlist whose summed PlayItem duration is < 30 s is a menu /
/// clip-info stub and must be dropped (bluray.rs: `duration_secs <
/// 30.0 → None`). 45000 ticks/s timebase: 29 s = 1_305_000 ticks.
#[test]
fn parse_playlist_drops_under_30_seconds() {
let mut disc = MemDisc::new();
let mpls = build_mpls(
&[PiSpec {
clip_id: *b"00001",
in_time: 0,
out_time: 29 * 45000, // 29 s < 30 s threshold
}],
(0, 0, 0, 0, 0, 0, 0, 0),
&[],
&[],
);
let udf = make_min_fs(&mut disc);
assert!(
Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).is_none(),
"playlists shorter than 30s must be skipped"
);
}
/// At exactly 30 s the playlist is kept (`< 30.0` is strict).
#[test]
fn parse_playlist_keeps_exactly_30_seconds() {
let mut disc = MemDisc::new();
let mpls = build_mpls(
&[PiSpec {
clip_id: *b"00001",
in_time: 0,
out_time: 30 * 45000,
}],
(0, 0, 0, 0, 0, 0, 0, 0),
&[],
&[],
);
let udf = make_min_fs(&mut disc);
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
.expect("30s playlist must be kept");
assert!((t.duration_secs - 30.0).abs() < 1e-6);
}
/// Garbage that isn't an MPLS must yield None (parse error path), not
/// panic. mpls::parse rejects on missing "MPLS" magic.
#[test]
fn parse_playlist_rejects_non_mpls() {
let mut disc = MemDisc::new();
let udf = make_min_fs(&mut disc);
let junk = vec![0u8; 100];
assert!(Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &junk).is_none());
}
/// Build a full BDMV tree with one STREAM/.m2ts (Long-AD ICB) and one
/// CLPINF/.clpi, returning the navigable UdfFs plus a populated disc.
/// This is the canonical 0.31.0 extent-assembly fixture.
fn make_min_fs(disc: &mut MemDisc) -> udf::UdfFs {
// Empty BDMV/PLAYLIST so directory navigation in parse_playlist's
// clip lookups still works even when no clip files exist.
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "BDMV".to_string(),
icb_lba: 12,
dir_data_lba: 13,
files: Vec::new(),
subdirs: vec![],
}],
};
build_udf_skeleton(disc, 10);
lay_dir(disc, &root);
udf::read_filesystem(disc).expect("fs")
}
/// Full BDMV with STREAM + CLPINF for the listed clip ids. Each clip's
/// .m2ts gets a Long-AD ICB with `sectors` sectors at a distinct LBA;
/// each .clpi declares `packets` source packets. Returns the UdfFs.
fn make_bdmv_fs(
disc: &mut MemDisc,
clips: &[(
&str,
u32, /*sectors*/
u32, /*packets*/
u32, /*data_lba*/
)],
) -> udf::UdfFs {
make_bdmv_fs_ext(disc, clips, "m2ts")
}
/// As [`make_bdmv_fs`] but the STREAM file carries `stream_ext` instead of
/// `.m2ts` (e.g. "fmts" for an AACS 2.1 feature clip, "ssif" for 3D) — drives
/// the [`CLIP_STREAM_EXTS`] fallback in `parse_playlist`.
fn make_bdmv_fs_ext(
disc: &mut MemDisc,
clips: &[(
&str,
u32, /*sectors*/
u32, /*packets*/
u32, /*data_lba*/
)],
stream_ext: &str,
) -> udf::UdfFs {
// Layout LBAs: pick widely separated values to avoid collisions.
let mut stream_files = Vec::new();
let mut clipinf_files = Vec::new();
let mut icb = 100u32;
for (name, sectors, packets, data_lba) in clips {
let m2ts = format!("{name}.{stream_ext}");
// Size in bytes — file_extents derives sectors via div_ceil(2048).
let size = sectors * 2048;
stream_files.push(file(&m2ts, icb, *data_lba, size, true));
icb += 1;
let clpi = format!("{name}.clpi");
clipinf_files.push(file_with(
&clpi,
icb,
*data_lba + 1000,
build_clpi(*packets),
false,
));
icb += 1;
}
let bdmv = DirSpec {
name: "BDMV".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: Vec::new(),
subdirs: vec![
DirSpec {
name: "STREAM".to_string(),
icb_lba: 22,
dir_data_lba: 23,
files: stream_files,
subdirs: vec![],
},
DirSpec {
name: "CLIPINF".to_string(),
icb_lba: 24,
dir_data_lba: 25,
files: clipinf_files,
subdirs: vec![],
},
],
};
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![bdmv],
};
build_udf_skeleton(disc, 10);
lay_dir(disc, &root);
udf::read_filesystem(disc).expect("fs")
}
/// Single-clip playlist: size_bytes = source_packets * 192 and the
/// physical extent is pulled from the m2ts Long-AD ICB. Per bluray.rs:
/// `total_size += pkt_count * 192`; extents from file_extents.
#[test]
fn parse_playlist_single_clip_size_and_extent() {
let mut disc = MemDisc::new();
// 1000 sectors of m2ts at LBA 5000 (data_lba arg); 4000 packets.
let udf = make_bdmv_fs(&mut disc, &[("00001", 1000, 4000, 5000)]);
let mpls = build_mpls(
&[PiSpec {
clip_id: *b"00001",
in_time: 0,
out_time: 60 * 45000, // 60 s
}],
(0, 0, 0, 0, 0, 0, 0, 0),
&[],
&[],
);
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
// BD source packet = 192 bytes (188 TS + 4-byte timestamp header).
assert_eq!(t.size_bytes, 4000 * 192);
assert_eq!(t.extents.len(), 1, "one m2ts → one extent");
// file_extents absolute LBA = partition_start + data_lba.
assert_eq!(t.extents[0].start_lba, PART_START + 5000);
assert_eq!(t.extents[0].sector_count, 1000);
assert_eq!(t.clips.len(), 1);
assert_eq!(t.clips[0].source_packets, 4000);
}
/// AACS 2.1: the feature clip is `00001.fmts`, NOT `.m2ts`. The
/// [`CLIP_STREAM_EXTS`] fallback in `parse_playlist` must still resolve the
/// physical extent — before the fix the hard-coded `.m2ts` path errored,
/// yielding empty extents (a silent empty rip and 0 encrypted samples for key
/// resolution). Size still comes from the `.clpi`, which parses regardless.
#[test]
fn parse_playlist_fmts_clip_resolves_extent() {
let mut disc = MemDisc::new();
// Only a .fmts stream exists for clip 00001 (no .m2ts on disc).
let udf = make_bdmv_fs_ext(&mut disc, &[("00001", 1000, 4000, 5000)], "fmts");
let mpls = build_mpls(
&[PiSpec {
clip_id: *b"00001",
in_time: 0,
out_time: 60 * 45000,
}],
(0, 0, 0, 0, 0, 0, 0, 0),
&[],
&[],
);
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
assert_eq!(t.size_bytes, 4000 * 192, "size from .clpi source packets");
assert_eq!(
t.extents.len(),
1,
"the .fmts extent must be resolved via fallback"
);
assert_eq!(t.extents[0].start_lba, PART_START + 5000);
assert_eq!(t.extents[0].sector_count, 1000);
}
/// THE 0.31.0 DEDUP PATH. A playlist that references the SAME clip_id
/// from multiple PlayItems (seamless split / looped segment) must count
/// the physical extents and packet bytes EXACTLY ONCE — mux reads
/// extents in order, so a duplicate would mux the A/V twice and inflate
/// size_bytes (bluray.rs: `first_ref = seen_clips.insert(...)` gates
/// both `total_size +=` and the `extents.push`). Per-PlayItem Clip
/// entries are still recorded for both.
#[test]
fn parse_playlist_dedups_repeated_clip_extents_and_size() {
let mut disc = MemDisc::new();
let udf = make_bdmv_fs(&mut disc, &[("00001", 1000, 4000, 5000)]);
let mpls = build_mpls(
&[
PiSpec {
clip_id: *b"00001",
in_time: 0,
out_time: 60 * 45000,
},
PiSpec {
clip_id: *b"00001", // SAME clip — second reference
in_time: 60 * 45000,
out_time: 120 * 45000,
},
],
(0, 0, 0, 0, 0, 0, 0, 0),
&[],
&[],
);
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
// Extent and size counted ONCE despite two PlayItems.
assert_eq!(
t.extents.len(),
1,
"repeated clip must not duplicate extent"
);
assert_eq!(
t.size_bytes,
4000 * 192,
"size counted once per unique clip"
);
// But BOTH PlayItems are recorded as Clip entries (differing times).
assert_eq!(t.clips.len(), 2, "each PlayItem still gets a Clip entry");
assert_eq!(t.clips[0].clip_id, "00001");
assert_eq!(t.clips[1].clip_id, "00001");
}
/// Distinct clips each contribute their own extent and bytes, in
/// PlayItem order (mux relies on extent order).
#[test]
fn parse_playlist_distinct_clips_accumulate_in_order() {
let mut disc = MemDisc::new();
let udf = make_bdmv_fs(
&mut disc,
&[("00001", 1000, 4000, 5000), ("00002", 500, 2000, 9000)],
);
let mpls = build_mpls(
&[
PiSpec {
clip_id: *b"00001",
in_time: 0,
out_time: 60 * 45000,
},
PiSpec {
clip_id: *b"00002",
in_time: 0,
out_time: 30 * 45000,
},
],
(0, 0, 0, 0, 0, 0, 0, 0),
&[],
&[],
);
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
assert_eq!(t.extents.len(), 2);
assert_eq!(t.extents[0].start_lba, PART_START + 5000);
assert_eq!(t.extents[1].start_lba, PART_START + 9000);
assert_eq!(t.size_bytes, (4000 + 2000) * 192);
}
/// A clip whose .clpi is missing contributes NO size and NO extent
/// (bluray.rs only fetches extents inside the `if let Ok(clpi_data)`
/// and `if let Ok(clip_info)` blocks), but the Clip entry is still
/// recorded with packet count 0. Never panics on the missing read.
#[test]
fn parse_playlist_missing_clpi_yields_no_extent_no_size() {
let mut disc = MemDisc::new();
// STREAM has the m2ts but CLIPINF is empty for this clip.
let udf = make_bdmv_fs(&mut disc, &[]); // no clips wired
// Re-lay a STREAM-only tree: put an m2ts but no clpi.
let udf = {
let _ = udf;
let bdmv = DirSpec {
name: "BDMV".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: Vec::new(),
subdirs: vec![
DirSpec {
name: "STREAM".to_string(),
icb_lba: 22,
dir_data_lba: 23,
files: vec![file("00009.m2ts", 100, 5000, 1000 * 2048, true)],
subdirs: vec![],
},
DirSpec {
name: "CLIPINF".to_string(),
icb_lba: 24,
dir_data_lba: 25,
files: Vec::new(), // no .clpi
subdirs: vec![],
},
],
};
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![bdmv],
};
let mut d2 = MemDisc::new();
build_udf_skeleton(&mut d2, 10);
lay_dir(&mut d2, &root);
disc = d2;
udf::read_filesystem(&mut disc).expect("fs")
};
let mpls = build_mpls(
&[PiSpec {
clip_id: *b"00009",
in_time: 0,
out_time: 60 * 45000,
}],
(0, 0, 0, 0, 0, 0, 0, 0),
&[],
&[],
);
let t = Disc::parse_playlist(&mut disc, &udf, "00009.mpls", &mpls).expect("title");
assert_eq!(t.size_bytes, 0, "no clpi → no size contribution");
assert!(t.extents.is_empty(), "no clpi → no extent fetched");
assert_eq!(t.clips.len(), 1);
assert_eq!(t.clips[0].source_packets, 0);
}
/// `file_extents` filters extents with `lba == 0` or `sectors == 0`
/// (bluray.rs: `if sectors > 0 && lba > 0`). A clip whose data lands at
/// partition-relative LBA 0 would produce abs LBA == PART_START (> 0),
/// so to exercise the lba==0 guard we'd need partition_start 0; instead
/// verify a zero-length declared file produces no extent. A 0-byte
/// m2ts → sectors == 0 → dropped.
#[test]
fn parse_playlist_zero_length_extent_is_filtered() {
let mut disc = MemDisc::new();
// m2ts declared 0 bytes → file_extents sectors = div_ceil(0,2048)=0.
let udf = {
let bdmv = DirSpec {
name: "BDMV".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: Vec::new(),
subdirs: vec![
DirSpec {
name: "STREAM".to_string(),
icb_lba: 22,
dir_data_lba: 23,
files: vec![file("00001.m2ts", 100, 5000, 0, true)],
subdirs: vec![],
},
DirSpec {
name: "CLIPINF".to_string(),
icb_lba: 24,
dir_data_lba: 25,
files: vec![file_with("00001.clpi", 102, 8000, build_clpi(4000), false)],
subdirs: vec![],
},
],
};
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![bdmv],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
udf::read_filesystem(&mut disc).expect("fs")
};
let mpls = build_mpls(
&[PiSpec {
clip_id: *b"00001",
in_time: 0,
out_time: 60 * 45000,
}],
(0, 0, 0, 0, 0, 0, 0, 0),
&[],
&[],
);
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
// size still counted (from clpi packets) but the empty extent dropped.
assert_eq!(t.size_bytes, 4000 * 192);
assert!(t.extents.is_empty(), "zero-sector extent must be filtered");
}
// ---------------------------------------------------------------
// Tests: STN stream mapping
// ---------------------------------------------------------------
/// stream_type 1 video (HEVC 0x24) → Stream::Video with the parsed PID
/// and codec. coding_type 0x24 maps to HEVC (Codec::from_coding_type).
#[test]
fn parse_playlist_maps_video_stream() {
let mut disc = MemDisc::new();
let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]);
let mpls = build_mpls(
&[PiSpec {
clip_id: *b"00001",
in_time: 0,
out_time: 60 * 45000,
}],
(1, 0, 0, 0, 0, 0, 0, 0),
&[se_video(0x1011, 0x24)],
&[],
);
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
let videos: Vec<_> = t
.streams
.iter()
.filter_map(|s| match s {
Stream::Video(v) => Some(v),
_ => None,
})
.collect();
assert_eq!(videos.len(), 1);
assert_eq!(videos[0].pid, 0x1011);
assert_eq!(videos[0].codec, Codec::Hevc);
}
/// A PGS coding_type (0x90) sitting in the AUDIO STN slot is a
/// misaligned-stream guard case: bluray.rs routes it to Subtitle, not
/// Audio (`if matches!(codec, Codec::Pgs)`). Wrong-title regression
/// guard: ensures audio slot data never silently becomes a fake audio
/// track when it is really PGS.
#[test]
fn parse_playlist_pgs_in_audio_slot_becomes_subtitle() {
let mut disc = MemDisc::new();
let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]);
let mpls = build_mpls(
&[PiSpec {
clip_id: *b"00001",
in_time: 0,
out_time: 60 * 45000,
}],
// 1 audio entry, but its coding_type is PGS (0x90).
(0, 1, 0, 0, 0, 0, 0, 0),
&[se_pg(0x1100, 0x90, b"eng")],
&[],
);
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
assert!(
t.streams.iter().all(|s| !matches!(s, Stream::Audio(_))),
"PGS in audio slot must NOT become an audio stream"
);
assert!(
t.streams
.iter()
.any(|s| matches!(s, Stream::Subtitle(sub) if sub.codec == Codec::Pgs)),
"PGS in audio slot must become a PGS subtitle"
);
}
/// A real audio entry (AC-3 0x81) in the audio slot → Stream::Audio.
#[test]
fn parse_playlist_maps_audio_stream() {
let mut disc = MemDisc::new();
let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]);
let mpls = build_mpls(
&[PiSpec {
clip_id: *b"00001",
in_time: 0,
out_time: 60 * 45000,
}],
(0, 1, 0, 0, 0, 0, 0, 0),
&[se_audio(0x1100, 0x81, b"eng")],
&[],
);
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
let audios: Vec<_> = t
.streams
.iter()
.filter_map(|s| match s {
Stream::Audio(a) => Some(a),
_ => None,
})
.collect();
assert_eq!(audios.len(), 1);
assert_eq!(audios[0].codec, Codec::Ac3);
assert_eq!(audios[0].language, "eng");
}
/// stream_type 3 PG (PGS 0x90) → Stream::Subtitle with language.
#[test]
fn parse_playlist_maps_pg_subtitle() {
let mut disc = MemDisc::new();
let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]);
let mpls = build_mpls(
&[PiSpec {
clip_id: *b"00001",
in_time: 0,
out_time: 60 * 45000,
}],
(0, 0, 1, 0, 0, 0, 0, 0),
&[se_pg(0x1200, 0x90, b"fra")],
&[],
);
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
let subs: Vec<_> = t
.streams
.iter()
.filter_map(|s| match s {
Stream::Subtitle(sub) => Some(sub),
_ => None,
})
.collect();
assert_eq!(subs.len(), 1);
assert_eq!(subs[0].codec, Codec::Pgs);
assert_eq!(subs[0].language, "fra");
}
// ---------------------------------------------------------------
// Tests: chapters
// ---------------------------------------------------------------
/// Only mark_type 1 (entry-mark) becomes a chapter; type 2 (link
/// point) and type 0 (reserved) are dropped (bluray.rs filter
/// `m.mark_type == 1`).
#[test]
fn parse_playlist_only_entry_marks_become_chapters() {
let mut disc = MemDisc::new();
let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]);
let mpls = build_mpls(
&[PiSpec {
clip_id: *b"00001",
in_time: 0,
out_time: 120 * 45000,
}],
(0, 0, 0, 0, 0, 0, 0, 0),
&[],
&[
MarkSpec {
mark_type: 1,
play_item_ref: 0,
timestamp: 0,
},
MarkSpec {
mark_type: 2,
play_item_ref: 0,
timestamp: 30 * 45000,
}, // link → drop
MarkSpec {
mark_type: 1,
play_item_ref: 0,
timestamp: 60 * 45000,
},
MarkSpec {
mark_type: 0,
play_item_ref: 0,
timestamp: 90 * 45000,
}, // reserved → drop
],
);
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
assert_eq!(
t.chapters.len(),
2,
"only the two type-1 marks are chapters"
);
}
/// In a multi-PlayItem playlist, a mark referencing PlayItem 1 is
/// placed at (sum of preceding PlayItem durations) + (mark offset
/// within its own PlayItem). Using play_items[0].in_time for every
/// mark would misplace it (bluray.rs `preceding + within`). PI0 = 60s,
/// mark in PI1 at its in_time → chapter at exactly 60 s.
#[test]
fn parse_playlist_chapter_time_accounts_for_preceding_play_items() {
let mut disc = MemDisc::new();
let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]);
let pi1_in = 10 * 45000u32;
let mpls = build_mpls(
&[
PiSpec {
clip_id: *b"00001",
in_time: 0,
out_time: 60 * 45000, // PI0 lasts 60 s
},
PiSpec {
clip_id: *b"00001",
in_time: pi1_in,
out_time: pi1_in + 60 * 45000,
},
],
(0, 0, 0, 0, 0, 0, 0, 0),
&[],
&[MarkSpec {
mark_type: 1,
play_item_ref: 1,
timestamp: pi1_in, // at the very start of PI1
}],
);
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
assert_eq!(t.chapters.len(), 1);
// preceding (PI0 = 60s) + within (timestamp - pi1.in_time = 0) = 60s.
assert!(
(t.chapters[0].time_secs - 60.0).abs() < 1e-6,
"chapter must sit at 60s, got {}",
t.chapters[0].time_secs
);
}
/// A mark whose timestamp precedes its PlayItem's in_time would yield a
/// negative within-offset; bluray.rs clamps the chapter to 0.0 (`if
/// time_secs < 0.0 { 0.0 }`). Never emits a negative chapter time.
#[test]
fn parse_playlist_negative_chapter_time_clamped_to_zero() {
let mut disc = MemDisc::new();
let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]);
let mpls = build_mpls(
&[PiSpec {
clip_id: *b"00001",
in_time: 50 * 45000,
out_time: 110 * 45000,
}],
(0, 0, 0, 0, 0, 0, 0, 0),
&[],
&[MarkSpec {
mark_type: 1,
play_item_ref: 0,
timestamp: 0, // before in_time → would be negative
}],
);
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
assert_eq!(t.chapters.len(), 1);
assert_eq!(t.chapters[0].time_secs, 0.0);
}
/// A mark referencing a non-existent PlayItem index is dropped via the
/// `?` on `play_items.get(pi_idx)` — must not panic or index OOB.
#[test]
fn parse_playlist_mark_with_bad_play_item_ref_dropped() {
let mut disc = MemDisc::new();
let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]);
let mpls = build_mpls(
&[PiSpec {
clip_id: *b"00001",
in_time: 0,
out_time: 60 * 45000,
}],
(0, 0, 0, 0, 0, 0, 0, 0),
&[],
&[MarkSpec {
mark_type: 1,
play_item_ref: 99, // no such PlayItem
timestamp: 0,
}],
);
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
assert!(
t.chapters.is_empty(),
"out-of-range mark ref must be dropped"
);
}
// ---------------------------------------------------------------
// Tests: playlist id parsing
// ---------------------------------------------------------------
/// playlist_id is the numeric stem of the filename with the .mpls
/// suffix stripped case-insensitively (bluray.rs `playlist_num`).
#[test]
fn parse_playlist_id_strips_suffix_case_insensitive() {
let mut disc = MemDisc::new();
let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]);
let mpls = build_mpls(
&[PiSpec {
clip_id: *b"00001",
in_time: 0,
out_time: 60 * 45000,
}],
(0, 0, 0, 0, 0, 0, 0, 0),
&[],
&[],
);
// Uppercase suffix must still parse the numeric stem.
let t = Disc::parse_playlist(&mut disc, &udf, "00800.MPLS", &mpls).expect("title");
assert_eq!(t.playlist_id, 800);
assert_eq!(
t.playlist, "00800.MPLS",
"playlist field keeps original name"
);
}
/// A non-numeric stem falls back to playlist_id 0 (`parse::<u16>()
/// .unwrap_or(0)`), never panics.
#[test]
fn parse_playlist_id_non_numeric_defaults_zero() {
let mut disc = MemDisc::new();
let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]);
let mpls = build_mpls(
&[PiSpec {
clip_id: *b"00001",
in_time: 0,
out_time: 60 * 45000,
}],
(0, 0, 0, 0, 0, 0, 0, 0),
&[],
&[],
);
let t = Disc::parse_playlist(&mut disc, &udf, "MENU.mpls", &mpls).expect("title");
assert_eq!(t.playlist_id, 0);
}
// ---------------------------------------------------------------
// Tests: scan_bluray_titles
// ---------------------------------------------------------------
/// scan_bluray_titles enumerates BDMV/PLAYLIST/*.mpls and keeps only
/// playlists that parse to a >= 30s title. A short one is dropped.
#[test]
fn scan_bluray_titles_keeps_long_drops_short() {
let mut disc = MemDisc::new();
// Build full tree with PLAYLIST holding two .mpls + STREAM/CLIPINF.
let long_mpls = build_mpls(
&[PiSpec {
clip_id: *b"00001",
in_time: 0,
out_time: 7200 * 45000, // 2 h
}],
(0, 0, 0, 0, 0, 0, 0, 0),
&[],
&[],
);
let short_mpls = build_mpls(
&[PiSpec {
clip_id: *b"00001",
in_time: 0,
out_time: 5 * 45000, // 5 s menu
}],
(0, 0, 0, 0, 0, 0, 0, 0),
&[],
&[],
);
// m2ts (Long-AD) + clpi for clip 00001.
let stream = DirSpec {
name: "STREAM".to_string(),
icb_lba: 22,
dir_data_lba: 23,
files: vec![file("00001.m2ts", 100, 5000, 1000 * 2048, true)],
subdirs: vec![],
};
let clipinf = DirSpec {
name: "CLIPINF".to_string(),
icb_lba: 24,
dir_data_lba: 25,
files: vec![file_with("00001.clpi", 102, 8000, build_clpi(4000), false)],
subdirs: vec![],
};
let playlist = DirSpec {
name: "PLAYLIST".to_string(),
icb_lba: 26,
dir_data_lba: 27,
files: vec![
file_with("00800.mpls", 104, 30000, long_mpls, false),
file_with("00801.mpls", 110, 40000, short_mpls, false),
],
subdirs: vec![],
};
let bdmv = DirSpec {
name: "BDMV".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: Vec::new(),
subdirs: vec![stream, clipinf, playlist],
};
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![bdmv],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = udf::read_filesystem(&mut disc).expect("fs");
let titles = Disc::scan_bluray_titles(&mut disc, &udf);
assert_eq!(titles.len(), 1, "only the 2h playlist should survive");
assert_eq!(titles[0].playlist_id, 800);
}
/// With no PLAYLIST directory, scan_bluray_titles returns an empty
/// vec (the `find_dir` is None) — never panics.
#[test]
fn scan_bluray_titles_no_playlist_dir_is_empty() {
let mut disc = MemDisc::new();
let udf = make_min_fs(&mut disc); // BDMV exists, no PLAYLIST
assert!(Disc::scan_bluray_titles(&mut disc, &udf).is_empty());
}
// ---------------------------------------------------------------
// Tests: read_meta_title
// ---------------------------------------------------------------
/// read_meta_title extracts <di:name> from BDMV/META/DL/*eng*.xml and
/// prefers the English file (bluray.rs `eng.or_else(first)`).
#[test]
fn read_meta_title_extracts_english_di_name() {
let mut disc = MemDisc::new();
let xml = b"<x><di:name>My Movie</di:name></x>".to_vec();
let dl = DirSpec {
name: "DL".to_string(),
icb_lba: 30,
dir_data_lba: 31,
files: vec![file_with("bdmt_eng.xml", 104, 50000, xml, false)],
subdirs: vec![],
};
let meta = DirSpec {
name: "META".to_string(),
icb_lba: 28,
dir_data_lba: 29,
files: Vec::new(),
subdirs: vec![dl],
};
let bdmv = DirSpec {
name: "BDMV".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: Vec::new(),
subdirs: vec![meta],
};
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![bdmv],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = udf::read_filesystem(&mut disc).expect("fs");
assert_eq!(
Disc::read_meta_title(&mut disc, &udf),
Some("My Movie".to_string())
);
}
/// The placeholder title "Blu-ray" and empty titles are rejected
/// (bluray.rs `!title.is_empty() && title != "Blu-ray"`).
#[test]
fn read_meta_title_rejects_placeholder_and_empty() {
for body in ["<di:name>Blu-ray</di:name>", "<di:name> </di:name>"] {
let mut disc = MemDisc::new();
let dl = DirSpec {
name: "DL".to_string(),
icb_lba: 30,
dir_data_lba: 31,
files: vec![file_with(
"bdmt_eng.xml",
104,
50000,
body.as_bytes().to_vec(),
false,
)],
subdirs: vec![],
};
let meta = DirSpec {
name: "META".to_string(),
icb_lba: 28,
dir_data_lba: 29,
files: Vec::new(),
subdirs: vec![dl],
};
let bdmv = DirSpec {
name: "BDMV".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: Vec::new(),
subdirs: vec![meta],
};
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![bdmv],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = udf::read_filesystem(&mut disc).expect("fs");
assert_eq!(
Disc::read_meta_title(&mut disc, &udf),
None,
"placeholder/empty title must be rejected for body {body:?}"
);
}
}
/// No META directory → None.
#[test]
fn read_meta_title_no_meta_dir_is_none() {
let mut disc = MemDisc::new();
let udf = make_min_fs(&mut disc);
assert_eq!(Disc::read_meta_title(&mut disc, &udf), None);
}
}