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).
1476 lines
58 KiB
Rust
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);
|
|
}
|
|
}
|