use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::io::{self, Read};
use std::sync::atomic::{AtomicBool, Ordering};
use super::chapters::{ChapterClip, ChapterSummary, walk_chapters};
use super::clpi::TsStreamClipFile;
use super::interleaved::TsInterleavedFile;
use super::m2ts::{TsStreamFile, bytes_to_f64, round_long};
use super::measured::{self, MeasuredSnapshot};
use super::mpls::TsPlaylistFile;
use super::order::{self, PlaylistFilter};
use super::shortfall::{self, ShortStreamFile};
use crate::discovery::{BdFileKind, BdmvDir};
use crate::error::{BdError, ScanError, ScanStage};
use crate::index;
use crate::primitives::Pid;
use crate::stream::{TsAudioMode, TsFrameRate, TsStream, TsStreamType, TsVideoStream};
use crate::vfs::{self, BdDir, BdFile, SearchOption};
const DISCINFO_NS: &str = "urn:BDA:bdmv;discinfo";
#[derive(Debug, Clone, PartialEq)]
pub struct PlaylistSummary {
pub name: String,
pub total_length: f64,
pub file_size: u64,
pub interleaved_file_size: u64,
pub chapter_count: usize,
pub stream_count: usize,
pub angle_count: usize,
pub has_loops: bool,
pub streams: Vec<StreamSummary>,
pub clips: Vec<ClipSummary>,
pub chapters: Vec<ChapterSummary>,
}
const MAX_EXTRA_ANGLES: usize = 254;
impl PlaylistSummary {
#[must_use]
pub fn total_packet_size(&self) -> u64 {
self.clips
.iter()
.filter(|clip| clip.angle_index == 0)
.fold(0_u64, |sum, clip| sum.saturating_add(clip.packet_size()))
}
#[must_use]
pub fn total_angle_packet_size(&self) -> u64 {
self.clips.iter().fold(0_u64, |sum, clip| sum.saturating_add(clip.packet_size()))
}
#[must_use]
pub fn total_angle_length(&self) -> f64 {
let mut length = 0.0;
for clip in &self.clips {
length += clip.length;
}
length
}
#[must_use]
pub fn total_bit_rate(&self) -> u64 {
rate_over(self.total_packet_size(), self.total_length)
}
#[must_use]
pub fn total_angle_bit_rate(&self) -> u64 {
rate_over(self.total_angle_packet_size(), self.total_angle_length())
}
#[must_use]
pub fn has_hidden_streams(&self) -> bool {
self.streams.iter().any(|stream| stream.is_hidden)
}
#[must_use]
pub const fn estimated_bytes(&self) -> Option<u64> {
if self.interleaved_file_size > 0 {
Some(self.interleaved_file_size)
} else if self.file_size > 0 {
Some(self.file_size)
} else {
None
}
}
#[must_use]
pub fn angle_totals(&self) -> Vec<AngleTotals> {
let mut totals = Vec::new();
for angle in 1..=self.angle_count.min(MAX_EXTRA_ANGLES) {
let angle_index = i32::try_from(angle).unwrap_or(i32::MAX);
let mut timeline: Vec<(u64, &ClipSummary)> = Vec::new();
for clip in &self.clips {
if clip.angle_index == 0 || clip.angle_index == angle_index {
let key = clip.relative_time_in.to_bits();
match timeline.iter_mut().find(|(existing, _)| *existing == key) {
Some(slot) => slot.1 = clip,
None => timeline.push((key, clip)),
}
}
}
let mut length = 0.0;
let mut packet_size: u64 = 0;
let mut timeline_packet_size: u64 = 0;
for (_, clip) in &timeline {
timeline_packet_size = timeline_packet_size.saturating_add(clip.packet_size());
if clip.angle_index == angle_index {
packet_size = packet_size.saturating_add(clip.packet_size());
length += clip.length;
}
}
totals.push(AngleTotals { length, packet_size, timeline_packet_size });
}
totals
}
}
pub const HIDDEN_STREAMS_NOTE: &str =
"(*) Some playlists on this disc have hidden tracks. These tracks are marked with an asterisk.";
fn rate_over(size: u64, seconds: f64) -> u64 {
if seconds > 0.0 {
u64::try_from(round_long(bytes_to_f64(size) * 8.0 / seconds)).unwrap_or(0)
} else {
0
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ClipSummary {
pub name: String,
pub display_name: String,
pub file_size: u64,
pub interleaved_file_size: u64,
pub angle_index: i32,
pub relative_time_in: f64,
pub length: f64,
pub measured: bool,
pub payload_bytes: u64,
pub packet_count: u64,
pub packet_seconds: f64,
pub file_seconds: f64,
pub streams: Vec<ClipStreamTally>,
}
impl ClipSummary {
#[must_use]
pub const fn packet_size(&self) -> u64 {
measured::packet_size(self.packet_count)
}
#[must_use]
pub fn packet_bit_rate(&self) -> u64 {
rate_over(self.packet_size(), self.packet_seconds)
}
#[must_use]
pub const fn estimated_bytes(&self) -> Option<u64> {
if self.interleaved_file_size > 0 {
Some(self.interleaved_file_size)
} else if self.file_size > 0 {
Some(self.file_size)
} else {
None
}
}
}
#[cfg(any(test, feature = "test-fixtures"))]
pub mod fixtures {
use super::{ClipSummary, PlaylistSummary};
#[must_use]
pub fn clip(name: &str, length: f64) -> ClipSummary {
ClipSummary {
name: name.to_owned(),
display_name: name.to_owned(),
file_size: 0,
interleaved_file_size: 0,
angle_index: 0,
relative_time_in: 0.0,
length,
measured: false,
payload_bytes: 0,
packet_count: 0,
packet_seconds: 0.0,
file_seconds: 0.0,
streams: Vec::new(),
}
}
#[must_use]
pub fn clips(names: &[&str], length: f64) -> Vec<ClipSummary> {
names.iter().map(|name| clip(name, length)).collect()
}
#[must_use]
pub fn playlist(name: &str, total_length: f64, clips: Vec<ClipSummary>) -> PlaylistSummary {
PlaylistSummary {
name: name.to_owned(),
total_length,
file_size: 0,
interleaved_file_size: 0,
chapter_count: 0,
stream_count: 0,
angle_count: 0,
has_loops: false,
streams: Vec::new(),
clips,
chapters: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClipStreamTally {
pub pid: Pid,
pub stream_type: TsStreamType,
pub codec_short_name: String,
pub payload_bytes: u64,
pub packet_count: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AngleTotals {
pub length: f64,
pub packet_size: u64,
pub timeline_packet_size: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StreamSummary {
pub pid: Pid,
pub stream_type: TsStreamType,
pub codec_short_name: String,
pub codec_name: String,
pub codec_alt_name: &'static str,
pub bitrate: i64,
pub active_bitrate: i64,
pub language_name: String,
pub language_code: String,
pub description: String,
pub full_description: String,
pub channel_description: String,
pub sample_rate: i32,
pub bit_depth: i32,
pub channel_count: i32,
pub height: i32,
pub angle_index: usize,
pub is_hidden: bool,
pub ssif_only: bool,
}
#[derive(Debug, Clone, PartialEq)]
#[expect(
clippy::struct_excessive_bools,
reason = "the eight flags are independent disc properties (3D/50Hz/UHD/AACS/BD+/BD-Java/D-BOX/PSP), not a state machine"
)]
pub struct BdRom {
pub volume_label: String,
pub disc_title: Option<String>,
pub size: u64,
pub interleaved_size: u64,
pub is_3d: bool,
pub is_50hz: bool,
pub is_uhd: bool,
pub is_aacs_encrypted: bool,
pub is_bd_plus: bool,
pub is_bd_java: bool,
pub is_dbox: bool,
pub is_psp: bool,
pub playlists: Vec<PlaylistSummary>,
}
#[derive(Debug)]
pub struct ScanReport {
pub bdrom: BdRom,
pub errors: Vec<ScanError>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScanProgress<'a> {
pub file: &'a str,
pub done: u64,
pub total: u64,
}
pub struct ScanObservers<'a> {
progress: Option<&'a mut dyn FnMut(ScanProgress<'_>)>,
measured: Option<&'a mut dyn FnMut(MeasuredSnapshot)>,
cancel: Option<&'a AtomicBool>,
}
impl<'a> ScanObservers<'a> {
#[must_use]
pub const fn none() -> Self {
Self { progress: None, measured: None, cancel: None }
}
#[must_use]
pub fn new(progress: &'a mut dyn FnMut(ScanProgress<'_>), cancel: &'a AtomicBool) -> Self {
Self { progress: Some(progress), measured: None, cancel: Some(cancel) }
}
#[must_use]
pub fn with_measured(mut self, measured: &'a mut dyn FnMut(MeasuredSnapshot)) -> Self {
self.measured = Some(measured);
self
}
}
impl fmt::Debug for ScanObservers<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ScanObservers")
.field("progress", &self.progress.is_some())
.field("measured", &self.measured.is_some())
.field("cancelled", &self.cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)))
.finish_non_exhaustive()
}
}
struct Progress<'a> {
callback: &'a mut dyn FnMut(ScanProgress<'_>),
measured: Option<&'a mut dyn FnMut(MeasuredSnapshot)>,
done: u64,
total: u64,
cancel: &'a AtomicBool,
}
impl Progress<'_> {
fn advance(&mut self, file: &str, bytes: u64) {
self.done = self.done.saturating_add(bytes).min(self.total);
self.heartbeat(file);
}
fn heartbeat(&mut self, file: &str) {
(self.callback)(ScanProgress { file, done: self.done, total: self.total });
}
fn finish_file(&mut self, file: &str, target: u64) {
self.done = self.done.max(target).min(self.total);
self.heartbeat(file);
}
fn emit_measured(&mut self, file: &str, playlists: &[TsPlaylistFile]) {
if let Some(observer) = self.measured.as_deref_mut() {
observer(measured::snapshot(file, playlists));
}
}
}
struct CountingReader<'a, 'b> {
inner: Box<dyn vfs::ReadSeek>,
name: String,
progress: &'a mut Progress<'b>,
}
impl Read for CountingReader<'_, '_> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.progress.heartbeat(&self.name);
let bytes = self.inner.read(buf)?;
self.progress.advance(&self.name, u64::try_from(bytes).unwrap_or(u64::MAX));
Ok(bytes)
}
}
fn scan_total(
stream_files: &BTreeMap<String, u64>,
interleaved_files: &BTreeMap<String, u64>,
scan_files: Option<&BTreeSet<String>>,
) -> u64 {
let mut total: u64 = 0;
for (name, size) in stream_files {
if scan_files.is_some_and(|selected| !selected.contains(name)) {
continue;
}
let stem = clip_stem(name);
let source = interleaved_files.get(&format!("{stem}.SSIF")).copied().unwrap_or(*size);
total = total.saturating_add(source);
}
total
}
struct Sink<'a> {
errors: Option<&'a mut Vec<ScanError>>,
}
impl Sink<'_> {
fn absorb<T>(
&mut self,
stage: ScanStage,
file: &str,
fallback: T,
result: Result<T, BdError>,
) -> Result<T, BdError> {
match result {
Ok(value) => Ok(value),
Err(reason) => match self.errors.as_deref_mut() {
Some(errors) => {
errors.push(ScanError { file: file.to_owned(), stage, reason });
Ok(fallback)
}
None => Err(reason),
},
}
}
fn record(&mut self, stage: ScanStage, file: &str, reason: BdError) {
if let Some(errors) = self.errors.as_deref_mut() {
errors.push(ScanError { file: file.to_owned(), stage, reason });
}
}
fn absorb_with_backup<T>(
&mut self,
stage: ScanStage,
file: &str,
fallback: T,
primary: Result<T, BdError>,
backup: impl FnOnce() -> Option<Result<T, BdError>>,
) -> Result<T, BdError> {
let reason = match primary {
Ok(value) => return Ok(value),
Err(reason) => reason,
};
if self.errors.is_some()
&& let Some(Ok(value)) = backup()
{
self.record(stage, file, reason);
return Ok(value);
}
self.absorb(stage, file, fallback, Err(reason))
}
}
struct ClipMeta<'a> {
clip_streams: &'a BTreeMap<u16, TsStream>,
scanned: Option<&'a TsStreamFile>,
has_50hz_video: bool,
relative_length: f64,
length: f64,
stream_file_present: bool,
interleaved_file_present: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScanMode {
Metadata,
Codecs,
Full,
}
impl ScanMode {
const fn scans_packets(self) -> bool {
matches!(self, Self::Codecs | Self::Full)
}
const fn measures_bitrate(self) -> bool {
matches!(self, Self::Full)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScanOptions {
pub keep_partial: bool,
pub skip_quick_pass: bool,
pub aacs_encrypted: Option<bool>,
}
impl Default for ScanOptions {
fn default() -> Self {
Self { keep_partial: true, skip_quick_pass: false, aacs_encrypted: None }
}
}
impl BdRom {
pub fn open(
root: &dyn BdDir,
mode: ScanMode,
options: ScanOptions,
scan_files: Option<&BTreeSet<String>>,
observers: ScanObservers<'_>,
) -> Result<Self, BdError> {
Self::open_impl(root, mode, options, scan_files, observers, &mut Sink { errors: None })
}
pub fn open_resilient(
root: &dyn BdDir,
mode: ScanMode,
options: ScanOptions,
scan_files: Option<&BTreeSet<String>>,
observers: ScanObservers<'_>,
) -> Result<ScanReport, BdError> {
let mut errors = Vec::new();
let bdrom = Self::open_impl(
root,
mode,
options,
scan_files,
observers,
&mut Sink { errors: Some(&mut errors) },
)?;
Ok(ScanReport { bdrom, errors })
}
fn open_impl(
root: &dyn BdDir,
mode: ScanMode,
options: ScanOptions,
scan_files: Option<&BTreeSet<String>>,
observers: ScanObservers<'_>,
sink: &mut Sink<'_>,
) -> Result<Self, BdError> {
let walked = walked_disc_root(root);
let root = walked.as_deref().unwrap_or(root);
let bdmv = vfs::find_directory(root, BdmvDir::Bdmv)?.ok_or(BdError::StructureNotFound)?;
let clipinf = vfs::find_directory(&*bdmv, BdmvDir::ClipInf)?;
let playlist = vfs::find_directory(&*bdmv, BdmvDir::Playlist)?;
let stream = vfs::find_directory(&*bdmv, BdmvDir::Stream)?;
let ssif = match &stream {
Some(s) => vfs::find_directory(&**s, BdmvDir::Ssif)?,
None => None,
};
let meta = vfs::find_directory(&*bdmv, BdmvDir::Meta)?;
let bdjo = vfs::find_directory(&*bdmv, BdmvDir::BdJo)?;
let snp = vfs::find_directory(root, BdmvDir::Snp)?;
let (Some(clipinf), Some(playlist)) = (clipinf, playlist) else {
return Err(BdError::StructureNotFound);
};
let (backup_index, backup_playlist, backup_clipinf) = discover_backups(&*bdmv, sink);
let root_name = root.name().to_owned();
let volume_label = root_name.clone();
let (size, interleaved_size) =
sink.absorb(ScanStage::Discovery, &root_name, (0, 0), directory_size(root))?;
let is_uhd = read_uhd_flag(&*bdmv, &backup_index, sink)?;
let is_aacs_encrypted = options
.aacs_encrypted
.unwrap_or_else(|| detect_aacs_encryption(root, stream.as_deref()));
let is_bd_plus =
sink.absorb(ScanStage::Discovery, &root_name, false, has_bd_plus_dir(root))?;
let is_bd_java = match &bdjo {
Some(d) => sink.absorb(ScanStage::Discovery, d.name(), false, has_any_file(&**d))?,
None => false,
};
let is_psp = match &snp {
Some(s) => sink.absorb(ScanStage::Discovery, s.name(), false, has_mnv_file(&**s))?,
None => false,
};
let is_dbox = sink.absorb(ScanStage::Discovery, &root_name, false, has_film_index(root))?;
let is_3d = match &ssif {
Some(s) => sink.absorb(ScanStage::Discovery, s.name(), false, has_any_file(&**s))?,
None => false,
};
let disc_title = match &meta {
Some(m) => {
sink.absorb(ScanStage::Discovery, m.name(), None, read_disc_title_from(&**m))?
}
None => None,
};
let (stream_files, interleaved_files, clip_files) =
build_file_maps(stream.as_deref(), ssif.as_deref(), &*clipinf, &backup_clipinf, sink)?;
let mut parsed = parse_playlists(&*playlist, &backup_playlist, sink)?;
let (scanned, measured) = if mode.scans_packets() {
run_measurement_scan(
stream.as_deref(),
ssif.as_deref(),
&mut parsed,
&clip_files,
&stream_files,
&interleaved_files,
scan_files,
mode.measures_bitrate(),
options,
observers,
sink,
)?
} else {
(BTreeMap::new(), BTreeMap::new())
};
let detail = scanned_detail(mode, options, &scanned, &measured);
let mut playlists = Vec::new();
let mut is_50hz = false;
for playlist in &parsed {
let summary = build_summary(
playlist,
&clip_files,
&stream_files,
&interleaved_files,
detail,
&measured,
);
if let Some((summary, hz)) =
sink.absorb(ScanStage::Playlist, &playlist.name, None, summary.map(Some))?
{
is_50hz = is_50hz || hz;
playlists.push(summary);
}
}
Ok(Self {
volume_label,
disc_title,
size,
interleaved_size,
is_3d,
is_50hz,
is_uhd,
is_aacs_encrypted,
is_bd_plus,
is_bd_java,
is_dbox,
is_psp,
playlists,
})
}
}
impl BdRom {
#[must_use]
pub const fn full_size(&self) -> u64 {
self.size.saturating_add(self.interleaved_size)
}
#[must_use]
pub fn presentation_order(&self, filter: &PlaylistFilter) -> Vec<usize> {
order::presentation_order(&self.playlists, filter)
}
#[must_use]
pub fn extra_features(&self) -> Vec<&'static str> {
let flags = [
(self.is_uhd, "Ultra HD"),
(self.is_bd_java, "BD-Java"),
(self.is_50hz, "50Hz Content"),
(self.is_3d, "Blu-ray 3D"),
(self.is_dbox, "D-BOX Motion Code"),
(self.is_psp, "PSP Digital Copy"),
];
flags.into_iter().filter(|&(set, _)| set).map(|(_, label)| label).collect()
}
#[must_use]
pub fn short_stream_files(&self) -> Vec<ShortStreamFile> {
shortfall::short_stream_files(&self.playlists)
}
}
const MAX_BDMV_ANCESTORS: usize = 64;
fn is_scannable_bdmv(bdmv: &dyn BdDir) -> bool {
let has = |which| matches!(vfs::find_directory(bdmv, which), Ok(Some(_)));
has(BdmvDir::ClipInf) && has(BdmvDir::Playlist)
}
fn walked_disc_root(input: &dyn BdDir) -> Option<Box<dyn BdDir>> {
if BdmvDir::from_name(input.name()) == Some(BdmvDir::Bdmv) && is_scannable_bdmv(input) {
return input.parent();
}
let mut dir = input.parent()?;
for _ in 0..MAX_BDMV_ANCESTORS {
if BdmvDir::from_name(dir.name()) == Some(BdmvDir::Bdmv) && is_scannable_bdmv(&*dir) {
return dir.parent();
}
dir = dir.parent()?;
}
None
}
fn read_index_bytes(bdmv: &dyn BdDir) -> Result<Option<Vec<u8>>, BdError> {
for file in bdmv.get_files()? {
if file.name().eq_ignore_ascii_case("index.bdmv") {
return Ok(Some(read_file(&*file)?));
}
}
Ok(None)
}
fn read_uhd_flag(
bdmv: &dyn BdDir,
backup_index: &BackupFiles,
sink: &mut Sink<'_>,
) -> Result<bool, BdError> {
let bytes = sink.absorb_with_backup(
ScanStage::Discovery,
"index.bdmv",
None,
read_index_bytes(bdmv),
|| recover_backup(backup_index, "index.bdmv", |_, bytes| Ok(Some(bytes.to_vec()))),
)?;
let Some(bytes) = bytes else {
return Ok(false);
};
if index::has_index_tag(&bytes) {
return Ok(index::is_uhd(&bytes));
}
sink.record(
ScanStage::Discovery,
"index.bdmv",
BdError::UnknownFileType(index::read_index_version(&bytes).unwrap_or_default()),
);
match recover_backup(backup_index, "index.bdmv", |_, bytes| Ok(bytes.to_vec())) {
Some(Ok(backup)) => Ok(index::is_uhd(&backup)),
_ => Ok(false),
}
}
fn has_bd_plus_dir(root: &dyn BdDir) -> Result<bool, BdError> {
for child in root.get_directories()? {
if matches!(child.name().to_ascii_uppercase().as_str(), "BDSVM" | "SLYVM" | "ANYVM") {
return Ok(true);
}
}
Ok(false)
}
const SOURCE_PACKET_BYTES: usize = 192;
const ALIGNED_UNIT_BYTES: usize = 6144;
const SAMPLED_STREAM_FILES: usize = 2;
const SAMPLED_UNITS_PER_FILE: usize = 2;
const MAX_STRAY_SYNCS: usize = 3;
fn detect_aacs_encryption(root: &dyn BdDir, stream: Option<&dyn BdDir>) -> bool {
has_aacs_key_file(root) && stream.is_some_and(stream_content_encrypted)
}
fn has_aacs_key_file(root: &dyn BdDir) -> bool {
let Ok(dirs) = root.get_directories() else {
return false;
};
dirs.iter().filter(|dir| dir.name().eq_ignore_ascii_case("AACS")).any(|dir| {
dir.get_files().is_ok_and(|files| {
files.iter().any(|file| file.name().eq_ignore_ascii_case("Unit_Key_RO.inf"))
})
})
}
fn stream_content_encrypted(stream: &dyn BdDir) -> bool {
let Ok(mut files) = vfs::find_files(stream, BdFileKind::Stream) else {
return false;
};
if files.is_empty() {
return false;
}
files.sort_by(|a, b| b.length().cmp(&a.length()).then_with(|| a.name().cmp(b.name())));
files.truncate(SAMPLED_STREAM_FILES);
files.iter().all(|file| file_head_encrypted(&**file))
}
fn file_head_encrypted(file: &dyn BdFile) -> bool {
let Ok(mut reader) = file.open_read() else {
return false;
};
let mut unit = [0_u8; ALIGNED_UNIT_BYTES];
for _ in 0..SAMPLED_UNITS_PER_FILE {
if reader.read_exact(&mut unit).is_err() || !unit_shows_encryption(&unit) {
return false;
}
}
true
}
fn unit_shows_encryption(unit: &[u8; ALIGNED_UNIT_BYTES]) -> bool {
let mut syncs =
unit.chunks_exact(SOURCE_PACKET_BYTES).map(|packet| packet.get(4) == Some(&0x47));
let first = syncs.next() == Some(true);
let strays = syncs.filter(|&hit| hit).count();
first && strays <= MAX_STRAY_SYNCS
}
fn has_any_file(dir: &dyn BdDir) -> Result<bool, BdError> {
Ok(!dir.get_files()?.is_empty())
}
fn has_film_index(root: &dyn BdDir) -> Result<bool, BdError> {
for file in root.get_files()? {
if file.name().eq_ignore_ascii_case("FilmIndex.xml") {
return Ok(true);
}
}
Ok(false)
}
fn has_mnv_file(dir: &dyn BdDir) -> Result<bool, BdError> {
for file in dir.get_files()? {
if file.name().rsplit_once('.').is_some_and(|(_, ext)| ext.eq_ignore_ascii_case("mnv")) {
return Ok(true);
}
}
Ok(false)
}
fn directory_size(dir: &dyn BdDir) -> Result<(u64, u64), BdError> {
let mut size: u64 = 0;
let mut interleaved: u64 = 0;
for file in dir.get_files()? {
if file.extension().eq_ignore_ascii_case(".ssif") {
interleaved = interleaved.saturating_add(file.length());
} else {
size = size.saturating_add(file.length());
}
}
for child in dir.get_directories()? {
let (child_size, child_interleaved) = directory_size(&*child)?;
size = size.saturating_add(child_size);
interleaved = interleaved.saturating_add(child_interleaved);
}
Ok((size, interleaved))
}
fn read_disc_title_from(meta: &dyn BdDir) -> Result<Option<String>, BdError> {
let files = meta.get_files_pattern_option("bdmt_eng.xml", SearchOption::AllDirectories)?;
let Some(file) = files.first() else {
return Ok(None);
};
let bytes = read_file(&**file)?;
let text = String::from_utf8_lossy(&bytes);
Ok(read_disc_title(&text))
}
fn read_disc_title(xml: &str) -> Option<String> {
let xml = xml.strip_prefix('\u{feff}').unwrap_or(xml);
let doc = roxmltree::Document::parse(xml).ok()?;
let name = doc
.root_element()
.children()
.find(|n| n.has_tag_name((DISCINFO_NS, "discinfo")))?
.children()
.find(|n| n.has_tag_name((DISCINFO_NS, "title")))?
.children()
.find(|n| n.has_tag_name((DISCINFO_NS, "name")))?;
let title: String =
name.descendants().filter(roxmltree::Node::is_text).filter_map(|n| n.text()).collect();
if title.eq_ignore_ascii_case("blu-ray") {
return None;
}
Some(title)
}
fn file_sizes(dir: Option<&dyn BdDir>, kind: BdFileKind) -> Result<BTreeMap<String, u64>, BdError> {
let mut map = BTreeMap::new();
if let Some(dir) = dir {
for file in vfs::find_files(dir, kind)? {
map.insert(file.name().to_ascii_uppercase(), file.length());
}
}
Ok(map)
}
fn scan_clip_files(
clipinf: &dyn BdDir,
backup: &BackupFiles,
sink: &mut Sink<'_>,
) -> Result<BTreeMap<String, TsStreamClipFile>, BdError> {
let mut map = BTreeMap::new();
let files = sink.absorb(
ScanStage::Discovery,
clipinf.name(),
Vec::new(),
vfs::find_files(clipinf, BdFileKind::ClipInfo).map_err(BdError::from),
)?;
for file in files {
let name = file.name();
let scan = read_file(&*file).and_then(|bytes| TsStreamClipFile::scan(name, &bytes));
let recovered =
sink.absorb_with_backup(ScanStage::ClipInfo, name, None, scan.map(Some), {
|| recover_backup(backup, name, |n, b| TsStreamClipFile::scan(n, b).map(Some))
})?;
if let Some(parsed) = recovered {
map.insert(name.to_ascii_uppercase(), parsed);
}
}
Ok(map)
}
fn parse_playlists(
playlist_dir: &dyn BdDir,
backup: &BackupFiles,
sink: &mut Sink<'_>,
) -> Result<Vec<TsPlaylistFile>, BdError> {
let mut playlist_handles = sink.absorb(
ScanStage::Discovery,
playlist_dir.name(),
Vec::new(),
vfs::find_files(playlist_dir, BdFileKind::Playlist).map_err(BdError::from),
)?;
playlist_handles.sort_by_key(|f| f.name().to_ascii_uppercase());
let mut parsed = Vec::new();
for handle in &playlist_handles {
let name = handle.name();
let scan = read_file(&**handle).and_then(|bytes| TsPlaylistFile::scan(name, &bytes));
let recovered =
sink.absorb_with_backup(ScanStage::Playlist, name, None, scan.map(Some), {
|| recover_backup(backup, name, |n, b| TsPlaylistFile::scan(n, b).map(Some))
})?;
if let Some(playlist) = recovered {
parsed.push(playlist);
}
}
Ok(parsed)
}
type ScannedFiles = BTreeMap<String, TsStreamFile>;
const fn scanned_detail<'a>(
mode: ScanMode,
options: ScanOptions,
scanned: &'a ScannedFiles,
measured: &'a ScannedFiles,
) -> &'a ScannedFiles {
if options.skip_quick_pass && mode.measures_bitrate() { measured } else { scanned }
}
type FileMaps = (BTreeMap<String, u64>, BTreeMap<String, u64>, BTreeMap<String, TsStreamClipFile>);
fn build_file_maps(
stream: Option<&dyn BdDir>,
ssif: Option<&dyn BdDir>,
clipinf: &dyn BdDir,
backup_clipinf: &BackupFiles,
sink: &mut Sink<'_>,
) -> Result<FileMaps, BdError> {
let stream_files = sink.absorb(
ScanStage::Discovery,
stream.map_or("STREAM", BdDir::name),
BTreeMap::new(),
file_sizes(stream, BdFileKind::Stream),
)?;
let interleaved_files = sink.absorb(
ScanStage::Discovery,
ssif.map_or("SSIF", BdDir::name),
BTreeMap::new(),
file_sizes(ssif, BdFileKind::Interleaved),
)?;
let clip_files = scan_clip_files(clipinf, backup_clipinf, sink)?;
Ok((stream_files, interleaved_files, clip_files))
}
#[expect(
clippy::too_many_arguments,
reason = "the scan orchestration threads the per-open context (dirs, maps, selection, observers, sink) one level down"
)]
fn run_measurement_scan(
stream: Option<&dyn BdDir>,
ssif: Option<&dyn BdDir>,
parsed: &mut [TsPlaylistFile],
clip_files: &BTreeMap<String, TsStreamClipFile>,
stream_files: &BTreeMap<String, u64>,
interleaved_files: &BTreeMap<String, u64>,
scan_files: Option<&BTreeSet<String>>,
measure: bool,
options: ScanOptions,
observers: ScanObservers<'_>,
sink: &mut Sink<'_>,
) -> Result<(ScannedFiles, ScannedFiles), BdError> {
static NEVER_CANCELLED: AtomicBool = AtomicBool::new(false);
let mut discarded = |_: ScanProgress<'_>| {};
let ScanObservers { progress, measured, cancel } = observers;
let callback: &mut dyn FnMut(ScanProgress<'_>) = match progress {
Some(callback) => callback,
None => &mut discarded,
};
#[expect(
clippy::option_if_let_else,
reason = "the `map_or`/`map` rewrite infers the closure's return at the bundle's lifetime, so the reborrow does not shorten and `discarded`, a local, cannot meet it"
)]
let measured: Option<&mut dyn FnMut(MeasuredSnapshot)> = match measured {
Some(measured) => Some(&mut *measured),
None => None,
};
let cancel = cancel.unwrap_or(&NEVER_CANCELLED);
let total = scan_total(stream_files, interleaved_files, scan_files);
let skip_quick = options.skip_quick_pass && measure;
let quick = if skip_quick {
BTreeMap::new()
} else {
let mut silent = |_: ScanProgress<'_>| {};
let quick_progress = &mut if measure {
Progress { callback: &mut silent, measured: None, done: 0, total: 0, cancel }
} else {
Progress { callback: &mut *callback, measured: None, done: 0, total, cancel }
};
scan_stream_files(
stream,
ssif,
parsed,
scan_files,
quick_progress,
sink,
options.keep_partial,
false,
false,
)?
};
for playlist in parsed.iter_mut() {
if let Ok(metas) =
collect_clip_metas(playlist, clip_files, stream_files, interleaved_files, &quick)
{
resolve_playlist_streams(playlist, &metas, skip_quick);
}
}
clear_measurements(parsed);
if !measure {
return Ok((quick, BTreeMap::new()));
}
let progress = &mut Progress { callback, measured, done: 0, total, cancel };
let full = scan_stream_files(
stream,
ssif,
parsed,
scan_files,
progress,
sink,
options.keep_partial,
true,
skip_quick,
)?;
for playlist in parsed.iter_mut() {
if let Ok(metas) =
collect_clip_metas(playlist, clip_files, stream_files, interleaved_files, &full)
&& let Some(reference) = select_reference(&metas)
{
if skip_quick {
remove_mvc_placeholder(playlist, reference);
}
if let Some(file) = reference.scanned {
remerge_graphics_detail(playlist, &file.streams);
}
}
}
Ok((quick, full))
}
fn remove_mvc_placeholder(playlist: &mut TsPlaylistFile, reference: &ClipMeta<'_>) {
if let Some(file) = reference.scanned
&& !file.streams.contains_key(&MVC_PID)
&& !reference.clip_streams.contains_key(&MVC_PID)
{
playlist.streams.remove(&MVC_PID);
for angle in &mut playlist.angle_streams {
angle.remove(&MVC_PID);
}
}
}
fn collect_clip_metas<'a>(
playlist: &TsPlaylistFile,
clip_files: &'a BTreeMap<String, TsStreamClipFile>,
stream_files: &BTreeMap<String, u64>,
interleaved_files: &BTreeMap<String, u64>,
scanned: &'a BTreeMap<String, TsStreamFile>,
) -> Result<Vec<ClipMeta<'a>>, BdError> {
let mut clips: Vec<ClipMeta<'a>> = Vec::new();
for clip in &playlist.stream_clips {
let stem = clip_stem(&clip.name);
let Some(clip_file) = clip_files.get(&format!("{stem}.CLPI")) else {
if clip.angle_index == 0 {
return Err(BdError::MissingClipFile(format!("{stem}.CLPI")));
}
continue;
};
clips.push(ClipMeta {
clip_streams: &clip_file.streams,
scanned: scanned.get(&clip.name),
has_50hz_video: clip_has_50hz_video(&clip_file.streams),
relative_length: clip.relative_length,
length: clip.length,
stream_file_present: stream_files.contains_key(&clip.name),
interleaved_file_present: interleaved_files.contains_key(&format!("{stem}.SSIF")),
});
}
Ok(clips)
}
const MVC_PID: u16 = 0x1012;
fn resolve_playlist_streams(
playlist: &mut TsPlaylistFile,
metas: &[ClipMeta<'_>],
synthesize_mvc: bool,
) {
let Some(reference) = select_reference(metas) else {
return;
};
let mut streams: BTreeMap<u16, TsStream> = BTreeMap::new();
for (pid, stream) in reference.clip_streams {
streams.insert(*pid, stream.ts_clone());
}
if synthesize_mvc && reference.interleaved_file_present && !streams.contains_key(&MVC_PID) {
let mut mvc = TsStream::Video(TsVideoStream::default());
mvc.base_mut().pid = Pid::new(MVC_PID);
mvc.base_mut().stream_type = TsStreamType::MvcVideo;
streams.insert(MVC_PID, mvc);
}
if let Some(file) = reference.scanned {
if file.interleaved_file.is_some()
&& !streams.contains_key(&MVC_PID)
&& let Some(mvc) = file.streams.get(&MVC_PID)
{
streams.insert(MVC_PID, mvc.ts_clone());
}
merge_matching(&mut streams, &file.streams, |_| true);
}
let angle_count = usize::try_from(playlist.angle_count).unwrap_or(0);
let mut angle_streams: Vec<BTreeMap<u16, TsStream>> = vec![BTreeMap::new(); angle_count];
for (pid, stream) in &streams {
if stream.base().is_video_stream() {
for (index, angle) in angle_streams.iter_mut().enumerate() {
let mut clone = stream.ts_clone();
clone.base_mut().angle_index =
i32::try_from(index.wrapping_add(1)).unwrap_or(i32::MAX);
angle.insert(*pid, clone);
}
}
}
playlist.streams = streams;
playlist.angle_streams = angle_streams;
}
fn remerge_graphics_detail(playlist: &mut TsPlaylistFile, scanned: &BTreeMap<u16, TsStream>) {
merge_matching(&mut playlist.streams, scanned, |stream| {
matches!(stream, TsStream::Graphics(_))
});
}
fn clear_measurements(playlists: &mut [TsPlaylistFile]) {
for playlist in playlists.iter_mut() {
for clip in &mut playlist.stream_clips {
clip.payload_bytes = 0;
clip.packet_count = 0;
clip.packet_seconds = 0.0;
}
}
}
fn build_clip_summaries(
playlist: &TsPlaylistFile,
stream_files: &BTreeMap<String, u64>,
interleaved_files: &BTreeMap<String, u64>,
measured: &BTreeMap<String, TsStreamFile>,
) -> Vec<ClipSummary> {
let mut clips = Vec::new();
for clip in &playlist.stream_clips {
let file = measured.get(&clip.name);
let mut streams = Vec::new();
if let Some(file) = file {
for pid in &file.stream_order {
let Some(file_stream) = file.streams.get(pid) else {
continue;
};
if playlist.streams.contains_key(pid) {
streams.push(ClipStreamTally {
pid: Pid::new(*pid),
stream_type: file_stream.stream_type(),
codec_short_name: file_stream.codec_short_name().to_owned(),
payload_bytes: file_stream.base().payload_bytes,
packet_count: file_stream.base().packet_count,
});
}
}
}
let stem = clip_stem(&clip.name);
clips.push(ClipSummary {
name: clip.name.clone(),
display_name: file
.map_or_else(|| clip.name.clone(), |f| f.display_name(true).to_owned()),
file_size: stream_files.get(&clip.name).copied().unwrap_or(0),
interleaved_file_size: interleaved_files
.get(&format!("{stem}.SSIF"))
.copied()
.unwrap_or(0),
angle_index: clip.angle_index,
relative_time_in: clip.relative_time_in,
length: clip.length,
measured: file.is_some(),
payload_bytes: clip.payload_bytes,
packet_count: clip.packet_count,
packet_seconds: clip.packet_seconds,
file_seconds: file.map_or(0.0, |f| f.length),
streams,
});
}
clips
}
fn build_summary(
playlist: &TsPlaylistFile,
clip_files: &BTreeMap<String, TsStreamClipFile>,
stream_files: &BTreeMap<String, u64>,
interleaved_files: &BTreeMap<String, u64>,
scanned: &BTreeMap<String, TsStreamFile>,
measured: &BTreeMap<String, TsStreamFile>,
) -> Result<(PlaylistSummary, bool), BdError> {
let metas = collect_clip_metas(playlist, clip_files, stream_files, interleaved_files, scanned)?;
let mut file_size: u64 = 0;
let mut interleaved_file_size: u64 = 0;
let mut total_length: f64 = 0.0;
for clip in &playlist.stream_clips {
let stem = clip_stem(&clip.name);
let stream_file_present = stream_files.contains_key(&clip.name);
file_size = file_size.saturating_add(stream_files.get(&clip.name).copied().unwrap_or(0));
if stream_file_present {
interleaved_file_size = interleaved_file_size.saturating_add(
interleaved_files.get(&format!("{stem}.SSIF")).copied().unwrap_or(0),
);
}
if clip.angle_index == 0 {
total_length += clip.length;
}
}
let angle = usize::try_from(playlist.angle_count).unwrap_or(0);
let (streams, is_50hz) = select_reference(&metas).map_or_else(
|| (Vec::new(), false),
|r| {
(
build_sorted_streams(
r.clip_streams,
r.scanned.map(|f| &f.streams),
playlist,
angle,
),
r.has_50hz_video,
)
},
);
Ok((
PlaylistSummary {
name: playlist.name.clone(),
total_length,
file_size,
interleaved_file_size,
chapter_count: playlist.chapters.len(),
stream_count: streams.iter().filter(|stream| !stream.ssif_only).count(),
has_loops: playlist_has_loops(playlist),
angle_count: angle,
streams,
clips: build_clip_summaries(playlist, stream_files, interleaved_files, measured),
chapters: build_chapter_summaries(playlist, measured, total_length),
},
is_50hz,
))
}
fn build_chapter_summaries(
playlist: &TsPlaylistFile,
measured: &BTreeMap<String, TsStreamFile>,
total_length: f64,
) -> Vec<ChapterSummary> {
let diag_pid = playlist
.streams
.iter()
.find(|(_, stream)| stream.base().is_video_stream())
.map(|(pid, _)| *pid);
let clips: Vec<ChapterClip<'_>> = playlist
.stream_clips
.iter()
.map(|clip| ChapterClip {
angle_index: clip.angle_index,
time_in: clip.time_in,
relative_time_in: clip.relative_time_in,
diagnostics: diag_pid.and_then(|pid| {
measured
.get(&clip.name)
.and_then(|file| file.stream_diagnostics.get(&pid))
.map(Vec::as_slice)
}),
})
.collect();
walk_chapters(&playlist.chapters, total_length, &clips)
}
fn playlist_has_loops(playlist: &TsPlaylistFile) -> bool {
let mut seen = BTreeSet::new();
playlist
.stream_clips
.iter()
.filter(|clip| clip.angle_index == 0)
.any(|clip| !seen.insert((clip.name.as_str(), clip.time_in.to_bits())))
}
fn clip_has_50hz_video(streams: &BTreeMap<u16, TsStream>) -> bool {
streams.values().any(|stream| {
matches!(stream, TsStream::Video(video)
if matches!(video.frame_rate(), TsFrameRate::Framerate25 | TsFrameRate::Framerate50))
})
}
fn build_sorted_streams(
clip_streams: &BTreeMap<u16, TsStream>,
scanned: Option<&BTreeMap<u16, TsStream>>,
playlist: &TsPlaylistFile,
angle_count: usize,
) -> Vec<StreamSummary> {
let mut streams = clip_streams.clone();
if let Some(scanned) = scanned {
merge_matching(&mut streams, scanned, |_| true);
}
merge_matching(&mut streams, &playlist.streams, |stream| {
matches!(stream, TsStream::Graphics(_))
});
let ssif_pid = if !streams.contains_key(&MVC_PID)
&& let Some(mvc) = playlist.streams.get(&MVC_PID)
{
streams.insert(MVC_PID, mvc.ts_clone());
Some(MVC_PID)
} else {
None
};
let mut rows = Vec::new();
for (pid, stream) in &streams {
let ssif_only = ssif_pid == Some(*pid);
let is_hidden = !ssif_only && !playlist.playlist_streams.contains_key(pid);
rows.push(stream_row(stream, 0, is_hidden, ssif_only, playlist.streams.get(pid)));
if stream.base().is_video_stream() {
for index in 0..angle_count {
rows.push(stream_row(
stream,
index.saturating_add(1),
is_hidden,
ssif_only,
playlist.angle_streams.get(index).and_then(|angle| angle.get(pid)),
));
}
}
}
rows
}
fn stream_row(
stream: &TsStream,
angle_index: usize,
is_hidden: bool,
ssif_only: bool,
measured: Option<&TsStream>,
) -> StreamSummary {
let mut row = stream_summary(stream);
row.is_hidden = is_hidden;
row.ssif_only = ssif_only;
row.angle_index = angle_index;
if let Some(measured) = measured {
row.bitrate = measured.base().bit_rate;
row.active_bitrate = measured.base().active_bit_rate;
}
row.full_description = full_description(stream, &row);
row
}
fn full_description(stream: &TsStream, row: &StreamSummary) -> String {
match stream {
TsStream::Audio(audio) if row.bitrate > 0 => {
let mut patched = audio.clone();
patched.base.bit_rate = row.bitrate;
patched.description()
}
TsStream::Video(video) => video.full_description(),
_ => row.description.clone(),
}
}
pub(crate) fn backfill_playlist_detail(
playlist: &mut TsPlaylistFile,
streams: &BTreeMap<u16, TsStream>,
) {
merge_matching(&mut playlist.streams, streams, |_| true);
for angle in &mut playlist.angle_streams {
merge_matching(angle, streams, |_| true);
}
}
fn merge_matching(
dst: &mut BTreeMap<u16, TsStream>,
src: &BTreeMap<u16, TsStream>,
keep: fn(&TsStream) -> bool,
) {
for (pid, stream) in src {
if !keep(stream) {
continue;
}
if let Some(dst) = dst.get_mut(pid) {
merge_stream(dst, stream);
}
}
}
fn merge_stream(dst: &mut TsStream, src: &TsStream) {
if dst.base().stream_type != src.base().stream_type {
return;
}
let bit_rate = dst.base().bit_rate.max(src.base().bit_rate);
let is_vbr = src.base().is_vbr;
dst.base_mut().bit_rate = bit_rate;
dst.base_mut().is_vbr = is_vbr;
match (dst, src) {
(TsStream::Video(dst), TsStream::Video(src)) => {
dst.encoding_profile.clone_from(&src.encoding_profile);
dst.extended_data.clone_from(&src.extended_data);
}
(TsStream::Audio(dst), TsStream::Audio(src)) => {
dst.channel_count = dst.channel_count.max(src.channel_count);
dst.lfe = dst.lfe.max(src.lfe);
dst.sample_rate = dst.sample_rate.max(src.sample_rate);
dst.bit_depth = dst.bit_depth.max(src.bit_depth);
dst.dial_norm = dst.dial_norm.min(src.dial_norm);
if src.audio_mode != TsAudioMode::Unknown {
dst.audio_mode = src.audio_mode;
}
dst.has_extensions = src.has_extensions;
dst.ext_data.clone_from(&src.ext_data);
if src.core_stream.is_some() && dst.core_stream.is_none() {
dst.core_stream.clone_from(&src.core_stream);
}
}
(TsStream::Graphics(dst), TsStream::Graphics(src)) => {
dst.captions = src.captions;
dst.forced_captions = src.forced_captions;
dst.width = src.width;
dst.height = src.height;
dst.caption_ids.clone_from(&src.caption_ids);
}
_ => {}
}
}
fn stream_summary(stream: &TsStream) -> StreamSummary {
let description = stream.description();
let (channel_description, sample_rate, bit_depth, channel_count) = match stream {
TsStream::Audio(audio) => {
(audio.channel_description(), audio.sample_rate, audio.bit_depth, audio.channel_count)
}
_ => (String::new(), 0, 0, 0),
};
let height = match stream {
TsStream::Video(video) => video.height,
_ => 0,
};
StreamSummary {
pid: stream.pid(),
stream_type: stream.stream_type(),
codec_short_name: stream.codec_short_name().to_owned(),
codec_name: stream.codec_name().to_owned(),
codec_alt_name: stream.codec_alt_name(),
bitrate: 0,
active_bitrate: 0,
language_name: stream.base().language_name.clone().unwrap_or_default(),
language_code: stream.base().language_code().unwrap_or_default().to_owned(),
full_description: description.clone(),
description,
channel_description,
sample_rate,
bit_depth,
channel_count,
height,
angle_index: 0,
is_hidden: false,
ssif_only: false,
}
}
#[expect(
clippy::too_many_arguments,
reason = "the scan orchestration threads the per-open context (dirs, maps, selection, progress, sink) one level down"
)]
fn scan_stream_files(
stream_dir: Option<&dyn BdDir>,
ssif_dir: Option<&dyn BdDir>,
playlists: &mut [TsPlaylistFile],
scan_files: Option<&BTreeSet<String>>,
progress: &mut Progress<'_>,
sink: &mut Sink<'_>,
keep_partial: bool,
is_full_scan: bool,
backfill_detail: bool,
) -> Result<BTreeMap<String, TsStreamFile>, BdError> {
let mut scanned = BTreeMap::new();
let Some(dir) = stream_dir else {
return Ok(scanned);
};
let files = sink.absorb(
ScanStage::Discovery,
dir.name(),
Vec::new(),
vfs::find_files(dir, BdFileKind::Stream).map_err(BdError::from),
)?;
let mut ssif_files: BTreeMap<String, Box<dyn BdFile>> = BTreeMap::new();
if let Some(dir) = ssif_dir {
let found = sink.absorb(
ScanStage::Discovery,
dir.name(),
Vec::new(),
vfs::find_files(dir, BdFileKind::Interleaved).map_err(BdError::from),
)?;
for file in found {
ssif_files.insert(file.name().to_ascii_uppercase(), file);
}
}
for file in files {
let name = file.name().to_ascii_uppercase();
if scan_files.is_some_and(|selected| !selected.contains(&name)) {
continue;
}
let stem = clip_stem(&name);
let interleaved = ssif_files.remove(&format!("{stem}.SSIF"));
let source_size = interleaved.as_ref().map_or_else(|| file.length(), |f| f.length());
let target = progress.done.saturating_add(source_size);
if progress.cancel.load(Ordering::Relaxed) {
return Err(BdError::ScanCancelled);
}
progress.heartbeat(&name);
let scan = scan_one_stream_file(
&*file,
interleaved,
playlists,
is_full_scan,
backfill_detail,
progress,
sink,
);
if matches!(scan, Err(BdError::ScanCancelled)) {
return Err(BdError::ScanCancelled);
}
progress.finish_file(&name, target);
progress.emit_measured(&name, playlists);
let (fallback, demuxed) = match scan {
Ok(StreamScan::Complete(stream_file)) => (None, Ok(Some(stream_file))),
Ok(StreamScan::Partial(stream_file, reason)) => {
(keep_partial.then_some(stream_file), Err(reason))
}
Err(reason) => (None, Err(reason)),
};
if let Some(stream_file) =
sink.absorb(ScanStage::StreamFile, file.name(), fallback, demuxed)?
{
scanned.insert(name, stream_file);
}
}
Ok(scanned)
}
enum StreamScan {
Complete(TsStreamFile),
Partial(TsStreamFile, BdError),
}
fn scan_one_stream_file(
file: &dyn BdFile,
interleaved: Option<Box<dyn BdFile>>,
playlists: &mut [TsPlaylistFile],
is_full_scan: bool,
backfill_detail: bool,
progress: &mut Progress<'_>,
sink: &mut Sink<'_>,
) -> Result<StreamScan, BdError> {
let mut stream_file = TsStreamFile::new(file.name());
stream_file.backfill_detail = backfill_detail;
let interleaved_name = interleaved.as_ref().map_or_else(String::new, |s| s.name().to_owned());
stream_file.interleaved_file = interleaved.map(TsInterleavedFile::new);
let inner = match stream_file.interleaved_file.take() {
Some(interleaved) => match interleaved.open_read() {
Ok(reader) => {
stream_file.interleaved_file = Some(interleaved);
reader
}
Err(reason) => {
sink.absorb(
ScanStage::StreamFile,
&interleaved_name,
(),
Err(BdError::Io(reason)),
)?;
file.open_read()?
}
},
None => file.open_read()?,
};
let cancel = progress.cancel;
let mut measured = progress.measured.take();
let mut reader =
CountingReader { inner, name: file.name().to_ascii_uppercase(), progress: &mut *progress };
let scan = stream_file.scan_cancellable(
&mut reader,
playlists,
is_full_scan,
cancel,
measured.as_deref_mut(),
);
progress.measured = measured;
stream_file.release_scratch();
match scan {
Ok(()) => Ok(StreamScan::Complete(stream_file)),
Err(BdError::ScanCancelled) => Err(BdError::ScanCancelled),
Err(reason) => Ok(StreamScan::Partial(stream_file, reason)),
}
}
fn select_reference<'a, 'b>(clips: &'b [ClipMeta<'a>]) -> Option<&'b ClipMeta<'a>> {
let mut reference = clips.first()?;
for clip in clips {
if !reference.stream_file_present && clip.stream_file_present {
reference = clip;
}
if (clip.clip_streams.len() > reference.clip_streams.len() && clip.relative_length > 0.01)
|| (clip.length > reference.length && clip.stream_file_present)
{
reference = clip;
}
}
Some(reference)
}
fn clip_stem(name: &str) -> &str {
name.strip_suffix(".M2TS").or_else(|| name.strip_suffix(".FMTS")).unwrap_or(name)
}
type BackupFiles = BTreeMap<String, Box<dyn BdFile>>;
fn discover_backups(
bdmv: &dyn BdDir,
sink: &mut Sink<'_>,
) -> (BackupFiles, BackupFiles, BackupFiles) {
let empty = || (BackupFiles::new(), BackupFiles::new(), BackupFiles::new());
if sink.errors.is_none() {
return empty();
}
match collect_backups(bdmv) {
Ok(pools) => pools,
Err(reason) => {
sink.record(ScanStage::Discovery, "BACKUP", reason);
empty()
}
}
}
fn collect_backups(bdmv: &dyn BdDir) -> Result<(BackupFiles, BackupFiles, BackupFiles), BdError> {
let Some(backup) = vfs::find_directory(bdmv, BdmvDir::Backup)? else {
return Ok((BackupFiles::new(), BackupFiles::new(), BackupFiles::new()));
};
let index = list_backup_files(&*backup)?;
let playlist = backup_subdir_files(&*backup, BdmvDir::Playlist)?;
let clipinf = backup_subdir_files(&*backup, BdmvDir::ClipInf)?;
Ok((index, playlist, clipinf))
}
fn backup_subdir_files(backup: &dyn BdDir, which: BdmvDir) -> Result<BackupFiles, BdError> {
vfs::find_directory(backup, which)?
.map_or_else(|| Ok(BackupFiles::new()), |dir| list_backup_files(&*dir))
}
fn list_backup_files(dir: &dyn BdDir) -> Result<BackupFiles, BdError> {
let mut map = BackupFiles::new();
for file in dir.get_files()? {
map.insert(file.name().to_ascii_uppercase(), file);
}
Ok(map)
}
fn recover_backup<T>(
backup: &BackupFiles,
name: &str,
parse: impl FnOnce(&str, &[u8]) -> Result<T, BdError>,
) -> Option<Result<T, BdError>> {
let file = backup.get(&name.to_ascii_uppercase())?;
Some(read_file(&**file).and_then(|bytes| parse(file.name(), &bytes)))
}
const MAX_METADATA_BYTES: u64 = 64 << 20;
fn read_file(file: &dyn BdFile) -> Result<Vec<u8>, BdError> {
read_file_capped(file, MAX_METADATA_BYTES)
}
fn read_file_capped(file: &dyn BdFile, cap: u64) -> Result<Vec<u8>, BdError> {
let mut reader = file.open_read()?.take(cap.saturating_add(1));
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes)?;
if reader.limit() == 0 {
return Err(BdError::MetadataTooLarge { file: file.name().to_owned(), limit: cap });
}
Ok(bytes)
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, BTreeSet};
use std::io::{self, BufRead, BufReader, Cursor, Read, Seek, SeekFrom};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use proptest::prelude::{ProptestConfig, prop_assert, prop_assert_eq, proptest};
use super::{
ALIGNED_UNIT_BYTES, BdError, BdRom, ClipMeta, ClipSummary, CountingReader,
MAX_METADATA_BYTES, MVC_PID, MeasuredSnapshot, PlaylistFilter, PlaylistSummary, Progress,
SOURCE_PACKET_BYTES, ScanMode, ScanObservers, ScanOptions, ScanProgress, ScanReport,
ScanStage, Sink, TsPlaylistFile, TsStreamFile, backfill_playlist_detail,
backup_subdir_files, build_chapter_summaries, build_clip_summaries, build_sorted_streams,
clear_measurements, clip_has_50hz_video, clip_stem, collect_backups, directory_size,
fixtures, has_aacs_key_file, merge_stream, rate_over, read_disc_title, read_file,
read_file_capped, remove_mvc_placeholder, resolve_playlist_streams, scan_stream_files,
scan_total, select_reference, stream_content_encrypted, stream_summary,
unit_shows_encryption, walked_disc_root,
};
use crate::bdrom::clpi::TsStreamClip;
use crate::bdrom::clpi::clips::build_clpi;
use crate::bdrom::interleaved::{MemBdFile, TsInterleavedFile};
use crate::bdrom::m2ts::packets::{packet, pat_payload, pes_dts, pes_pts, pmt_payload};
use crate::bdrom::mpls::playlists::{build_item_codec, build_mpls, chapter_entry, pl_stream};
use crate::discovery::BdmvDir;
use crate::primitives::Pid;
use crate::stream::{
TsAudioMode, TsAudioStream, TsFrameRate, TsGraphicsStream, TsStream, TsStreamType,
TsTextStream, TsVideoFormat, TsVideoStream,
};
use crate::vfs::fs::{FsDir, FsFile};
use crate::vfs::{BdDir, BdFile, ReadSeek, SearchOption, extension_of_name};
fn open(root: &dyn BdDir, mode: ScanMode) -> Result<BdRom, BdError> {
BdRom::open(root, mode, ScanOptions::default(), None, ScanObservers::none())
}
fn open_resilient(root: &dyn BdDir, mode: ScanMode) -> Result<ScanReport, BdError> {
BdRom::open_resilient(root, mode, ScanOptions::default(), None, ScanObservers::none())
}
fn clpi(entries: &[(u16, u8, [u8; 4])]) -> Vec<u8> {
build_clpi(*b"HDMV0300", entries)
}
fn mpls(item_name: &str, in_t: u32, out_t: u32, chapters: &[(u8, u16, u32)]) -> Vec<u8> {
mpls_angles(item_name, in_t, out_t, &[], chapters)
}
fn mpls_angles(
item_name: &str,
in_t: u32,
out_t: u32,
angle_names: &[&str],
chapters: &[(u8, u16, u32)],
) -> Vec<u8> {
mpls_items(&[item_name], in_t, out_t, angle_names, &[], chapters)
}
fn mpls_declared(
item_name: &str,
in_t: u32,
out_t: u32,
declared_audio: &[u16],
chapters: &[(u8, u16, u32)],
) -> Vec<u8> {
mpls_items(&[item_name], in_t, out_t, &[], declared_audio, chapters)
}
fn mpls_items(
item_names: &[&str],
in_t: u32,
out_t: u32,
angle_names: &[&str],
declared_audio: &[u16],
chapters: &[(u8, u16, u32)],
) -> Vec<u8> {
mpls_items_codec(*b"M2TS", item_names, in_t, out_t, angle_names, declared_audio, chapters)
}
fn mpls_items_codec(
codec: [u8; 4],
item_names: &[&str],
in_t: u32,
out_t: u32,
angle_names: &[&str],
declared_audio: &[u16],
chapters: &[(u8, u16, u32)],
) -> Vec<u8> {
let mut stream_bytes = Vec::new();
for &pid in declared_audio {
stream_bytes.extend(pl_stream(1, pid, 0x81, [0x61, b'e', b'n', b'g']));
}
let counts = [0, u8::try_from(declared_audio.len()).unwrap(), 0, 0, 0, 0, 0];
let angles = (!angle_names.is_empty()).then_some(angle_names);
let items: Vec<Vec<u8>> = item_names
.iter()
.map(|name| build_item_codec(name, codec, in_t, out_t, angles, counts, &stream_bytes))
.collect();
let marks: Vec<Vec<u8>> = chapters
.iter()
.map(|&(chapter_type, file_index, tick)| chapter_entry(chapter_type, file_index, tick))
.collect();
build_mpls(0, &items, &marks)
}
fn empty_mpls() -> Vec<u8> {
build_mpls(0, &[], &[])
}
fn title_xml(name: &str) -> Vec<u8> {
format!(
"<disclib xmlns:di=\"urn:BDA:bdmv;discinfo\">\
<di:discinfo><di:title><di:name>{name}</di:name></di:title></di:discinfo></disclib>"
)
.into_bytes()
}
struct TempDisc {
root: PathBuf,
}
impl TempDisc {
fn build(dirs: &[&str], files: &[(&str, Vec<u8>)]) -> Self {
static COUNTER: AtomicU32 = AtomicU32::new(0);
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let mut root = std::env::temp_dir();
root.push(format!("bdinfo-rs-disc-{}-{unique}", std::process::id()));
let _ = std::fs::remove_dir_all(&root).is_ok();
std::fs::create_dir_all(&root).expect("create root");
for dir in dirs {
std::fs::create_dir_all(root.join(dir)).expect("create dir");
}
for (path, bytes) in files {
let full = root.join(path);
std::fs::create_dir_all(full.parent().expect("file has parent")).expect("parents");
std::fs::write(&full, bytes).expect("write file");
}
Self { root }
}
fn open(&self) -> Result<BdRom, BdError> {
open(&FsDir::new(self.root.clone()), ScanMode::Metadata)
}
fn open_scanned(&self) -> Result<BdRom, BdError> {
open(&FsDir::new(self.root.clone()), ScanMode::Full)
}
fn open_codecs(&self) -> Result<BdRom, BdError> {
open(&FsDir::new(self.root.clone()), ScanMode::Codecs)
}
}
impl Drop for TempDisc {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root).is_ok();
}
}
#[test]
fn read_disc_title_handles_every_case() {
let with_bom = format!("\u{feff}{}", String::from_utf8(title_xml("BROTHER")).unwrap());
assert_eq!(read_disc_title(&with_bom).as_deref(), Some("BROTHER"));
assert_eq!(
read_disc_title(&String::from_utf8(title_xml("")).unwrap()).as_deref(),
Some("")
);
assert_eq!(read_disc_title(&String::from_utf8(title_xml("Blu-ray")).unwrap()), None);
assert_eq!(read_disc_title("<not xml"), None);
assert_eq!(read_disc_title("<disclib></disclib>"), None);
assert_eq!(
read_disc_title(
"<disclib xmlns:di=\"urn:BDA:bdmv;discinfo\"><di:discinfo></di:discinfo></disclib>"
),
None
);
assert_eq!(
read_disc_title(
"<disclib xmlns:di=\"urn:BDA:bdmv;discinfo\">\
<di:discinfo><di:title></di:title></di:discinfo></disclib>"
),
None
);
}
fn dummy_streams(n: usize) -> BTreeMap<u16, TsStream> {
(0..n)
.map(|i| (u16::try_from(i).unwrap(), TsStream::Audio(TsAudioStream::default())))
.collect()
}
fn bare_playlist() -> TsPlaylistFile {
TsPlaylistFile {
file_type: "MPLS0300".to_owned(),
name: "00000.MPLS".to_owned(),
mvc_base_view_r: false,
chapters: Vec::new(),
playlist_streams: BTreeMap::new(),
streams: BTreeMap::new(),
angle_streams: Vec::new(),
stream_clips: Vec::new(),
angle_count: 0,
}
}
fn meta(
streams: &BTreeMap<u16, TsStream>,
length: f64,
relative: f64,
has_stream_file: bool,
) -> ClipMeta<'_> {
ClipMeta {
clip_streams: streams,
scanned: None,
has_50hz_video: false,
relative_length: relative,
length,
stream_file_present: has_stream_file,
interleaved_file_present: false,
}
}
#[test]
fn select_reference_picks_per_the_selection_rules() {
let s2 = dummy_streams(2);
let s5 = dummy_streams(5);
assert!(select_reference(&[]).is_none());
let one = [meta(&s2, 10.0, 1.0, true)];
assert_eq!(select_reference(&one).unwrap().clip_streams.len(), 2);
let step_a = [meta(&s2, 10.0, 1.0, false), meta(&s2, 10.0, 1.0, true)];
assert!(select_reference(&step_a).unwrap().stream_file_present);
let step_b_if = [meta(&s2, 10.0, 1.0, true), meta(&s5, 10.0, 1.0, true)];
assert_eq!(select_reference(&step_b_if).unwrap().clip_streams.len(), 5);
let at_threshold = [meta(&s2, 10.0, 1.0, true), meta(&s5, 10.0, 0.01, true)];
assert_eq!(select_reference(&at_threshold).unwrap().clip_streams.len(), 2);
let step_b_else = [meta(&s2, 10.0, 1.0, true), meta(&s2, 20.0, 1.0, true)];
assert_eq!(select_reference(&step_b_else).unwrap().length.to_bits(), 20.0_f64.to_bits());
let longer_no_file = [meta(&s2, 10.0, 1.0, true), meta(&s2, 20.0, 1.0, false)];
assert_eq!(select_reference(&longer_no_file).unwrap().length.to_bits(), 10.0_f64.to_bits());
}
#[test]
fn clip_has_50hz_video_detects_25_and_50fps() {
let mut at25 = BTreeMap::new();
let mut v = TsVideoStream::default();
v.set_frame_rate(TsFrameRate::Framerate25);
at25.insert(0x1011, TsStream::Video(v));
at25.insert(0x1100, TsStream::Audio(TsAudioStream::default()));
assert!(clip_has_50hz_video(&at25));
let mut at50 = BTreeMap::new();
let mut v = TsVideoStream::default();
v.set_frame_rate(TsFrameRate::Framerate50);
at50.insert(0x1011, TsStream::Video(v));
assert!(clip_has_50hz_video(&at50));
let mut at24 = BTreeMap::new();
let mut v = TsVideoStream::default();
v.set_frame_rate(TsFrameRate::Framerate24);
at24.insert(0x1011, TsStream::Video(v));
at24.insert(0x1100, TsStream::Audio(TsAudioStream::default()));
assert!(!clip_has_50hz_video(&at24));
}
fn as_video(s: &TsStream) -> Option<&TsVideoStream> {
if let TsStream::Video(video) = s { Some(video) } else { None }
}
#[test]
fn graphics_detail_remerges_after_the_full_pass() {
let mut playlist =
TsPlaylistFile::scan("00000.mpls", &mpls("00000", 0, 4_500_000, &[])).unwrap();
playlist.streams = BTreeMap::from([
(0x1200_u16, TsStream::Graphics(TsGraphicsStream::default())),
(0x1011, TsStream::Video(TsVideoStream::default())),
]);
let scanned_pgs = TsGraphicsStream {
captions: 837,
width: 1920,
height: 1080,
..TsGraphicsStream::default()
};
let mut scanned_video = TsVideoStream::default();
scanned_video.set_video_format(TsVideoFormat::Videoformat2160p);
scanned_video.encoding_profile = Some("High Profile 4.1".to_owned());
let scanned = BTreeMap::from([
(0x1200_u16, TsStream::Graphics(scanned_pgs)),
(0x1011, TsStream::Video(scanned_video)),
(0x1300, TsStream::Graphics(TsGraphicsStream::default())),
]);
super::remerge_graphics_detail(&mut playlist, &scanned);
let graphics = stream_summary(playlist.streams.get(&0x1200).unwrap());
assert_eq!(graphics.description, "1920x1080 / 837 Captions");
let video = as_video(playlist.streams.get(&0x1011).unwrap()).unwrap();
assert!(video.encoding_profile.is_none(), "non-graphics sources are not re-merged");
assert!(!playlist.streams.contains_key(&0x1300));
}
fn as_audio(s: &TsStream) -> Option<&TsAudioStream> {
if let TsStream::Audio(audio) = s { Some(audio) } else { None }
}
fn as_graphics(s: &TsStream) -> Option<&TsGraphicsStream> {
if let TsStream::Graphics(graphics) = s { Some(graphics) } else { None }
}
#[test]
fn merge_stream_copies_scanned_detail_per_kind() {
let mut dst = TsVideoStream::default();
dst.base.stream_type = TsStreamType::AvcVideo;
dst.set_video_format(TsVideoFormat::Videoformat1080p);
let mut src = TsVideoStream::default();
src.base.stream_type = TsStreamType::AvcVideo;
src.encoding_profile = Some("High Profile 4.1".to_owned());
src.base.bit_rate = 5;
src.base.is_vbr = true;
let mut video = TsStream::Video(dst);
merge_stream(&mut video, &TsStream::Video(src));
assert!(as_audio(&video).is_none());
let merged = as_video(&video).unwrap();
assert_eq!(merged.encoding_profile.as_deref(), Some("High Profile 4.1"));
assert_eq!(merged.height, 1080); assert_eq!(merged.base.bit_rate, 5); assert!(merged.base.is_vbr);
let mut dst = TsAudioStream::default();
dst.base.stream_type = TsStreamType::DtsHdMasterAudio;
dst.base.set_language_code("jpn");
let mut src = TsAudioStream::default();
src.base.stream_type = TsStreamType::DtsHdMasterAudio;
src.channel_count = 6;
src.lfe = 1;
src.sample_rate = 48_000;
src.bit_depth = 24;
src.dial_norm = -27;
src.audio_mode = TsAudioMode::Extended;
src.has_extensions = true;
src.ext_data = Some("x".to_owned());
src.core_stream = Some(Box::new(TsAudioStream::default()));
let mut audio = TsStream::Audio(dst);
merge_stream(&mut audio, &TsStream::Audio(src));
let merged = as_audio(&audio).unwrap();
assert_eq!((merged.channel_count, merged.lfe), (6, 1));
assert_eq!((merged.sample_rate, merged.bit_depth), (48_000, 24));
assert_eq!(merged.dial_norm, -27); assert_eq!(merged.audio_mode, TsAudioMode::Extended);
assert!(merged.has_extensions);
assert_eq!(merged.ext_data.as_deref(), Some("x"));
assert!(merged.core_stream.is_some());
assert_eq!(merged.base.language_name.as_deref(), Some("Japanese"));
let mut dst = TsGraphicsStream::default();
dst.base.stream_type = TsStreamType::PresentationGraphics;
let mut src = TsGraphicsStream::default();
src.base.stream_type = TsStreamType::PresentationGraphics;
src.width = 1920;
src.height = 1080;
src.captions = 5;
src.forced_captions = 1;
let mut graphics = TsStream::Graphics(dst);
merge_stream(&mut graphics, &TsStream::Graphics(src));
assert!(as_video(&audio).is_none());
assert!(as_graphics(&video).is_none());
let merged = as_graphics(&graphics).unwrap();
assert_eq!((merged.width, merged.height), (1920, 1080));
assert_eq!((merged.captions, merged.forced_captions), (5, 1));
}
#[test]
fn merge_stream_core_and_dialnorm_edge_cases() {
let mut dst = TsAudioStream::default();
dst.base.stream_type = TsStreamType::Ac3TrueHdAudio;
dst.dial_norm = -31; dst.core_stream =
Some(Box::new(TsAudioStream { channel_count: 9, ..TsAudioStream::default() }));
let mut src = TsAudioStream::default();
src.base.stream_type = TsStreamType::Ac3TrueHdAudio;
src.dial_norm = -27;
src.core_stream =
Some(Box::new(TsAudioStream { channel_count: 5, ..TsAudioStream::default() }));
let mut audio = TsStream::Audio(dst);
merge_stream(&mut audio, &TsStream::Audio(src));
let merged = as_audio(&audio).unwrap();
assert_eq!(merged.dial_norm, -31); assert_eq!(merged.core_stream.as_ref().unwrap().channel_count, 9);
let mut dst = TsAudioStream::default();
dst.base.stream_type = TsStreamType::DtsAudio;
let mut audio = TsStream::Audio(dst);
merge_stream(&mut audio, &TsStream::Audio(TsAudioStream::default()));
assert!(as_audio(&audio).unwrap().core_stream.is_none());
}
#[test]
fn merge_stream_ignores_text_and_type_mismatches() {
let mut dst = TsTextStream::default();
dst.base.stream_type = TsStreamType::Subtitle;
let mut src = TsTextStream::default();
src.base.stream_type = TsStreamType::Subtitle;
src.base.is_vbr = true;
let mut text = TsStream::Text(dst);
merge_stream(&mut text, &TsStream::Text(src));
assert!(text.base().is_vbr);
let mut dst = TsAudioStream::default();
dst.base.stream_type = TsStreamType::Ac3Audio;
let mut src = TsVideoStream::default();
src.base.stream_type = TsStreamType::AvcVideo;
src.base.bit_rate = 999;
let mut audio = TsStream::Audio(dst);
merge_stream(&mut audio, &TsStream::Video(src));
assert_eq!(audio.base().bit_rate, 0); }
#[test]
fn stream_summary_reads_each_variant() {
let mut video = TsVideoStream::default();
video.base.pid = Pid::new(0x1011);
video.base.stream_type = TsStreamType::AvcVideo;
video.height = 1080;
let summary = stream_summary(&TsStream::Video(video));
assert_eq!(summary.pid, Pid::new(0x1011));
assert_eq!(summary.height, 1080);
assert_eq!(summary.stream_type.name(), "AVC_VIDEO");
assert_eq!(summary.codec_short_name, "AVC");
assert_eq!(summary.codec_name, "MPEG-4 AVC Video");
assert_eq!(summary.codec_alt_name, "AVC");
assert_eq!(summary.channel_description, "");
assert_eq!((summary.sample_rate, summary.bit_depth, summary.channel_count), (0, 0, 0));
assert_eq!(summary.angle_index, 0);
assert_eq!(summary.language_code, "");
let mut audio = TsAudioStream::default();
audio.base.stream_type = TsStreamType::LpcmAudio;
audio.base.set_language_code("jpn");
audio.channel_count = 2;
audio.lfe = 0;
audio.sample_rate = 48_000;
audio.bit_depth = 16;
let summary = stream_summary(&TsStream::Audio(audio));
assert_eq!(summary.height, 0);
assert_eq!(summary.codec_short_name, "LPCM");
assert_eq!(summary.codec_alt_name, "LPCM");
assert_eq!(summary.language_code, "jpn");
assert_eq!(summary.language_name, "Japanese");
assert_eq!(summary.channel_description, "2.0");
assert_eq!(
(summary.sample_rate, summary.bit_depth, summary.channel_count),
(48_000, 16, 2)
);
let mut graphics = TsGraphicsStream::default();
graphics.base.stream_type = TsStreamType::PresentationGraphics;
let summary = stream_summary(&TsStream::Graphics(graphics));
assert_eq!(summary.codec_short_name, "PGS");
assert_eq!(summary.codec_alt_name, "PGS");
assert_eq!(summary.description, "");
let mut text = TsTextStream::default();
text.base.stream_type = TsStreamType::Subtitle;
text.base.set_language_code("eng");
let summary = stream_summary(&TsStream::Text(text));
assert_eq!(
(summary.codec_short_name.as_str(), summary.codec_name.as_str()),
("SUB", "Subtitle")
);
assert_eq!(summary.language_name, "English");
assert_eq!(summary.language_code, "eng");
assert_eq!(summary.codec_alt_name, "SUB");
}
#[test]
fn full_description_patches_audio_respells_video_and_falls_back() {
let mut audio = TsAudioStream::default();
audio.base.stream_type = TsStreamType::LpcmAudio;
audio.channel_count = 2;
audio.sample_rate = 48_000;
audio.bit_depth = 16;
audio.base.bit_rate = 512_000;
let mut patched = audio.clone();
patched.base.bit_rate = 256_000;
let respelled = patched.description();
let plain = audio.description();
assert_ne!(respelled, plain);
let stream = TsStream::Audio(audio);
let mut row = stream_summary(&stream);
row.bitrate = 256_000;
assert_eq!(super::full_description(&stream, &row), respelled);
row.bitrate = 0;
assert_eq!(super::full_description(&stream, &row), plain);
let mut video = TsVideoStream::default();
video.base.stream_type = TsStreamType::AvcVideo;
video.height = 1080;
let expected = video.full_description();
let stream = TsStream::Video(video);
let mut row = stream_summary(&stream);
row.description = "sentinel".to_owned();
assert_eq!(super::full_description(&stream, &row), expected);
let mut graphics = TsGraphicsStream::default();
graphics.base.stream_type = TsStreamType::PresentationGraphics;
let stream = TsStream::Graphics(graphics);
let mut row = stream_summary(&stream);
row.description = "kept".to_owned();
assert_eq!(super::full_description(&stream, &row), "kept");
}
#[test]
fn build_sorted_streams_takes_graphics_detail_from_the_resolved_streams() {
let mut clip = BTreeMap::new();
let mut graphics = TsGraphicsStream::default();
graphics.base.pid = Pid::new(0x1200);
graphics.base.stream_type = TsStreamType::PresentationGraphics;
graphics.base.set_language_code("deu");
clip.insert(0x1200, TsStream::Graphics(graphics));
let mut resolved = bare_playlist();
let mut presented = TsGraphicsStream {
captions: 837,
width: 1920,
height: 1080,
..TsGraphicsStream::default()
};
presented.base.pid = Pid::new(0x1200);
presented.base.stream_type = TsStreamType::PresentationGraphics;
resolved.streams = BTreeMap::from([(0x1200_u16, TsStream::Graphics(presented))]);
let rows = build_sorted_streams(&clip, None, &resolved, 0);
assert_eq!(rows.first().unwrap().description, "1920x1080 / 837 Captions");
let rows = build_sorted_streams(&clip, None, &bare_playlist(), 0);
assert_eq!(rows.first().unwrap().description, "");
}
#[test]
fn build_sorted_streams_orders_clones_and_merges() {
let mut clip = BTreeMap::new();
let mut video = TsVideoStream::default();
video.base.pid = Pid::new(0x1011);
video.base.stream_type = TsStreamType::AvcVideo;
video.set_video_format(TsVideoFormat::Videoformat1080p);
clip.insert(0x1011, TsStream::Video(video));
let mut audio = TsAudioStream::default();
audio.base.pid = Pid::new(0x1100);
audio.base.stream_type = TsStreamType::Ac3Audio;
audio.base.set_language_code("eng");
clip.insert(0x1100, TsStream::Audio(audio));
let unresolved = bare_playlist();
let rows = build_sorted_streams(&clip, None, &unresolved, 0);
assert_eq!(rows.len(), 2);
assert_eq!(rows.first().unwrap().pid, Pid::new(0x1011));
assert_eq!(rows.first().unwrap().stream_type.name(), "AVC_VIDEO");
assert_eq!(rows.first().unwrap().bitrate, 0);
assert_eq!(rows.get(1).unwrap().pid, Pid::new(0x1100));
assert_eq!(rows.get(1).unwrap().language_name, "English");
let rows = build_sorted_streams(&clip, None, &unresolved, 2);
assert_eq!(rows.len(), 4);
assert!(rows.iter().take(3).all(|r| r.pid == Pid::new(0x1011)));
assert_eq!(rows.get(3).unwrap().pid, Pid::new(0x1100));
let angles: Vec<usize> = rows.iter().map(|r| r.angle_index).collect();
assert_eq!(angles, [0, 1, 2, 0]);
let mut resolved = bare_playlist();
resolved.streams = clip.clone();
let main_video = resolved.streams.get_mut(&0x1011).expect("the video is presented");
main_video.base_mut().bit_rate = 26_030_000;
main_video.base_mut().active_bit_rate = 26_031_000;
resolved.streams.get_mut(&0x1100).expect("the audio is presented").base_mut().bit_rate =
640_000;
let mut angle = clip.clone();
let angle_video = angle.get_mut(&0x1011).expect("the angle video is presented");
angle_video.base_mut().bit_rate = 26_120_000;
angle_video.base_mut().active_bit_rate = 26_121_000;
resolved.angle_streams = vec![angle];
let rows = build_sorted_streams(&clip, None, &resolved, 1);
assert_eq!(rows.len(), 3);
assert_eq!(rows.first().unwrap().bitrate, 26_030_000);
assert_eq!(rows.first().unwrap().active_bitrate, 26_031_000);
assert_eq!(rows.get(1).unwrap().bitrate, 26_120_000);
assert_eq!(rows.get(1).unwrap().active_bitrate, 26_121_000);
assert_eq!(rows.get(2).unwrap().bitrate, 640_000);
assert_eq!(rows.get(2).unwrap().active_bitrate, 0);
let rows = build_sorted_streams(&clip, None, &resolved, 2);
assert_eq!(rows.len(), 4);
assert_eq!(rows.get(1).unwrap().bitrate, 26_120_000);
assert_eq!(rows.get(2).unwrap().bitrate, 0);
let mut scanned = BTreeMap::new();
let mut scanned_video = TsVideoStream::default();
scanned_video.base.pid = Pid::new(0x1011);
scanned_video.base.stream_type = TsStreamType::AvcVideo;
scanned_video.encoding_profile = Some("High Profile 4.1".to_owned());
scanned.insert(0x1011, TsStream::Video(scanned_video));
let rows = build_sorted_streams(&clip, Some(&scanned), &unresolved, 0);
assert!(rows.first().unwrap().description.contains("High Profile 4.1"));
let mut orphan = BTreeMap::new();
let mut orphan_audio = TsAudioStream::default();
orphan_audio.base.pid = Pid::new(0x9999);
orphan_audio.base.stream_type = TsStreamType::Ac3Audio;
orphan.insert(0x9999, TsStream::Audio(orphan_audio));
assert_eq!(build_sorted_streams(&clip, Some(&orphan), &unresolved, 0).len(), 2);
}
#[test]
fn build_sorted_streams_presents_the_resolved_dependent_view() {
let mut clip = BTreeMap::new();
let mut video = TsVideoStream::default();
video.base.pid = Pid::new(0x1011);
video.base.stream_type = TsStreamType::AvcVideo;
clip.insert(0x1011, TsStream::Video(video));
let mut resolved = bare_playlist();
resolved.streams = clip.clone();
let mut mvc = TsVideoStream::default();
mvc.base.pid = Pid::new(MVC_PID);
mvc.base.stream_type = TsStreamType::MvcVideo;
mvc.base.bit_rate = 7_579_000;
resolved.streams.insert(MVC_PID, TsStream::Video(mvc));
let rows = build_sorted_streams(&clip, None, &resolved, 0);
assert_eq!(rows.len(), 2);
let row = rows.get(1).unwrap();
assert_eq!(row.pid, Pid::new(MVC_PID));
assert_eq!(row.stream_type.name(), "MVC_VIDEO");
assert_eq!(
(row.codec_short_name.as_str(), row.codec_name.as_str()),
("MVC", "MPEG-4 MVC Video")
);
assert_eq!(row.description, "");
assert_eq!(row.bitrate, 7_579_000);
assert!(row.ssif_only);
assert!(!row.is_hidden, "the dependent view is never marked hidden");
assert!(rows.first().unwrap().is_hidden);
assert!(!rows.first().unwrap().ssif_only);
let mut declared = clip.clone();
let mut own = TsVideoStream::default();
own.base.pid = Pid::new(MVC_PID);
own.base.stream_type = TsStreamType::MvcVideo;
declared.insert(MVC_PID, TsStream::Video(own));
let rows = build_sorted_streams(&declared, None, &resolved, 0);
assert_eq!(rows.len(), 2);
assert!(!rows.get(1).unwrap().ssif_only);
assert!(rows.get(1).unwrap().is_hidden);
}
#[test]
fn open_scans_a_full_disc() {
let clip = clpi(&[
(0x1011, 0x1B, [0x63, 0x30, 0, 0]), (0x1100, 0x81, [0x61, b'e', b'n', b'g']), (0x1200, 0x90, [b'e', b'n', b'g', 0]), (0x1A00, 0x92, [0, b'f', b'r', b'a']), ]);
let play = mpls("00000", 2_700_000, 4_500_000, &[(1, 0, 2_700_000)]);
let index = b"INDX0300\x00\x00".to_vec();
let xml = title_xml("Movie");
let m2ts = vec![0_u8; 1000];
let ssif = vec![0_u8; 500];
let expected_size: u64 =
[index.len(), play.len(), clip.len(), m2ts.len(), xml.len(), 1, 1, 1]
.iter()
.map(|&n| u64::try_from(n).unwrap())
.sum();
let disc = TempDisc::build(
&["BDSVM"],
&[
("BDMV/index.bdmv", index),
("BDMV/PLAYLIST/00000.mpls", play),
("BDMV/CLIPINF/00000.clpi", clip),
("BDMV/STREAM/00000.m2ts", m2ts),
("BDMV/STREAM/SSIF/00000.ssif", ssif), ("BDMV/META/DL/bdmt_eng.xml", xml),
("BDMV/BDJO/00000.bdjo", vec![b'x']),
("SNP/clip.mnv", vec![b'x']),
("FilmIndex.xml", vec![b'x']),
],
);
let bd = disc.open().expect("scan full disc");
assert!(bd.volume_label.starts_with("bdinfo-rs-disc-"));
assert_eq!(bd.disc_title.as_deref(), Some("Movie"));
assert_eq!(bd.size, expected_size); assert!(bd.is_uhd);
assert!(bd.is_3d);
assert!(bd.is_bd_plus);
assert!(bd.is_bd_java);
assert!(bd.is_dbox);
assert!(bd.is_psp);
assert!(!bd.is_aacs_encrypted); assert!(bd.is_50hz);
assert_eq!(
bd.extra_features(),
[
"Ultra HD",
"BD-Java",
"50Hz Content",
"Blu-ray 3D",
"D-BOX Motion Code",
"PSP Digital Copy"
]
);
assert_eq!(bd.presentation_order(&PlaylistFilter::default()), [0]);
assert_eq!(bd.playlists.len(), 1);
let pl = bd.playlists.first().unwrap();
assert_eq!(pl.name, "00000.MPLS");
assert_eq!(pl.total_length.to_bits(), 40.0_f64.to_bits());
assert_eq!(pl.file_size, 1000);
assert_eq!(pl.interleaved_file_size, 500);
assert_eq!(pl.clips.len(), 1);
let clip0 = pl.clips.first().unwrap();
assert_eq!(clip0.file_size, 1000);
assert_eq!(clip0.interleaved_file_size, 500);
assert_eq!(pl.chapter_count, 1);
assert_eq!(pl.stream_count, 4);
assert_eq!(pl.streams.len(), 4);
let scanned = disc.open_scanned().expect("packet-scan the disc");
let pl = scanned.playlists.first().unwrap();
assert_eq!(pl.stream_count, 4);
let pids: Vec<u16> = pl.streams.iter().map(|s| s.pid.get()).collect();
assert_eq!(pids, vec![0x1011, 0x1100, 0x1200, 0x1A00]);
assert_eq!(pl.streams.first().unwrap().stream_type.name(), "AVC_VIDEO");
assert_eq!(pl.chapters.len(), 1);
let row = pl.chapters.first().unwrap();
assert_eq!(row.time_in.to_bits(), 0.0_f64.to_bits());
assert_eq!(row.length.to_bits(), 40.0_f64.to_bits());
assert_eq!(row.avg_rate.to_bits(), 0.0_f64.to_bits());
}
#[test]
fn open_scans_a_disc_with_empty_dirs_and_missing_stream() {
let clip = clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]); let play = mpls("00000", 0, 4_500_000, &[]); let disc = TempDisc::build(
&["BDMV/BDJO", "BDMV/STREAM/SSIF"], &[
("BDMV/PLAYLIST/00000.mpls", play),
("BDMV/CLIPINF/00000.clpi", clip),
("BDMV/META/note.txt", vec![b'x']), ("SNP/note.txt", vec![b'x']), ("readme.txt", vec![b'x']), ],
);
let bd = disc.open().expect("scan sparse disc");
assert!(!bd.is_uhd); assert!(!bd.is_3d); assert!(!bd.is_bd_plus); assert!(!bd.is_bd_java); assert!(!bd.is_dbox); assert!(!bd.is_psp); assert!(!bd.is_aacs_encrypted); assert!(bd.extra_features().is_empty()); assert!(!bd.is_50hz); assert_eq!(bd.disc_title, None);
let pl = bd.playlists.first().unwrap();
assert_eq!(pl.total_length.to_bits(), 100.0_f64.to_bits());
assert_eq!(pl.file_size, 0); assert_eq!(pl.interleaved_file_size, 0);
let clip0 = pl.clips.first().unwrap();
assert_eq!(clip0.file_size, 0);
assert_eq!(clip0.interleaved_file_size, 0);
assert_eq!(pl.chapter_count, 0);
assert_eq!(pl.stream_count, 1);
}
fn unit_with_syncs(present: &[bool; 32]) -> [u8; ALIGNED_UNIT_BYTES] {
let mut unit = [0xAA_u8; ALIGNED_UNIT_BYTES];
for (packet, &sync) in unit.chunks_exact_mut(SOURCE_PACKET_BYTES).zip(present) {
if sync && let Some(byte) = packet.get_mut(4) {
*byte = 0x47;
}
}
unit
}
fn encrypted_unit() -> [u8; ALIGNED_UNIT_BYTES] {
let mut present = [false; 32];
present[0] = true;
unit_with_syncs(&present)
}
fn encrypted_stream() -> Vec<u8> {
[encrypted_unit().as_slice(), encrypted_unit().as_slice()].concat()
}
fn clear_stream() -> Vec<u8> {
let unit = unit_with_syncs(&[true; 32]);
[unit.as_slice(), unit.as_slice()].concat()
}
#[test]
fn the_unit_fingerprint_needs_the_first_sync_and_tolerates_only_stray_ones() {
let mut present = [false; 32];
present[0] = true;
assert!(unit_shows_encryption(&unit_with_syncs(&present)));
present[7] = true;
present[15] = true;
present[31] = true;
assert!(unit_shows_encryption(&unit_with_syncs(&present)));
present[20] = true;
assert!(!unit_shows_encryption(&unit_with_syncs(&present)));
present[0] = false;
assert!(!unit_shows_encryption(&unit_with_syncs(&present)));
assert!(!unit_shows_encryption(&unit_with_syncs(&[false; 32])));
assert!(!unit_shows_encryption(&unit_with_syncs(&[true; 32])));
}
#[test]
fn an_encrypted_disc_sets_the_aacs_flag_without_a_scan_error() {
let disc = TempDisc::build(
&["BDMV/PLAYLIST", "BDMV/CLIPINF"],
&[
("AACS/Unit_Key_RO.inf", Vec::new()),
("BDMV/STREAM/00000.m2ts", encrypted_stream()),
("BDMV/STREAM/00001.m2ts", encrypted_stream()),
],
);
assert!(disc.open().expect("scan encrypted disc").is_aacs_encrypted);
let report = open_resilient(&FsDir::new(disc.root.clone()), ScanMode::Metadata)
.expect("resilient scan");
assert!(report.bdrom.is_aacs_encrypted);
assert!(report.errors.is_empty());
}
#[test]
fn the_aacs_marker_matches_case_insensitively_and_one_stream_file_suffices() {
let disc = TempDisc::build(
&["BDMV/PLAYLIST", "BDMV/CLIPINF"],
&[("aacs/UNIT_KEY_RO.INF", Vec::new()), ("BDMV/STREAM/00000.m2ts", encrypted_stream())],
);
assert!(disc.open().expect("scan encrypted disc").is_aacs_encrypted);
}
#[test]
fn a_decrypted_rip_keeping_the_aacs_folder_is_not_encrypted() {
let disc = TempDisc::build(
&["BDMV/PLAYLIST", "BDMV/CLIPINF"],
&[
("AACS/Unit_Key_RO.inf", Vec::new()),
("BDMV/STREAM/00000.m2ts", clear_stream()),
("BDMV/STREAM/00001.m2ts", clear_stream()),
],
);
assert!(!disc.open().expect("scan decrypted rip").is_aacs_encrypted);
}
#[test]
fn encrypted_looking_streams_without_the_key_file_stay_unflagged() {
let bare = TempDisc::build(
&["BDMV/PLAYLIST", "BDMV/CLIPINF"],
&[("BDMV/STREAM/00000.m2ts", encrypted_stream())],
);
assert!(!bare.open().expect("scan").is_aacs_encrypted);
let keyless = TempDisc::build(
&["BDMV/PLAYLIST", "BDMV/CLIPINF"],
&[("AACS/Content000.cer", vec![b'x']), ("BDMV/STREAM/00000.m2ts", encrypted_stream())],
);
assert!(!keyless.open().expect("scan").is_aacs_encrypted);
}
#[test]
fn a_keyed_disc_without_confirmable_stream_content_is_not_encrypted() {
let streamless = TempDisc::build(
&["BDMV/PLAYLIST", "BDMV/CLIPINF"],
&[("AACS/Unit_Key_RO.inf", Vec::new())],
);
assert!(!streamless.open().expect("scan").is_aacs_encrypted);
let empty = TempDisc::build(
&["BDMV/PLAYLIST", "BDMV/CLIPINF", "BDMV/STREAM"],
&[("AACS/Unit_Key_RO.inf", Vec::new())],
);
assert!(!empty.open().expect("scan").is_aacs_encrypted);
let short = TempDisc::build(
&["BDMV/PLAYLIST", "BDMV/CLIPINF"],
&[
("AACS/Unit_Key_RO.inf", Vec::new()),
("BDMV/STREAM/00000.m2ts", encrypted_unit().to_vec()),
],
);
assert!(!short.open().expect("scan").is_aacs_encrypted);
}
#[test]
fn any_clear_sample_resolves_to_not_encrypted() {
let mixed = TempDisc::build(
&["BDMV/PLAYLIST", "BDMV/CLIPINF"],
&[
("AACS/Unit_Key_RO.inf", Vec::new()),
("BDMV/STREAM/00000.m2ts", encrypted_stream()),
("BDMV/STREAM/00001.m2ts", clear_stream()),
],
);
assert!(!mixed.open().expect("scan").is_aacs_encrypted);
let clear_tail = [encrypted_unit(), unit_with_syncs(&[true; 32])].concat();
let tailed = TempDisc::build(
&["BDMV/PLAYLIST", "BDMV/CLIPINF"],
&[("AACS/Unit_Key_RO.inf", Vec::new()), ("BDMV/STREAM/00000.m2ts", clear_tail)],
);
assert!(!tailed.open().expect("scan").is_aacs_encrypted);
}
#[test]
fn the_probe_samples_the_two_largest_stream_files() {
let disc = TempDisc::build(
&["BDMV/PLAYLIST", "BDMV/CLIPINF"],
&[
("AACS/Unit_Key_RO.inf", Vec::new()),
("BDMV/STREAM/00000.m2ts", encrypted_stream()),
("BDMV/STREAM/00001.m2ts", encrypted_stream()),
("BDMV/STREAM/00002.m2ts", vec![0_u8; 100]),
],
);
assert!(disc.open().expect("scan").is_aacs_encrypted);
}
#[test]
fn equal_sized_samples_are_chosen_by_name_not_listing_order() {
let trip = Trip::new(usize::MAX);
let mk = |name: &str, bytes: Vec<u8>| MockFile {
name: name.to_owned(),
extension: ".m2ts".to_owned(),
bytes,
trip: trip.clone(),
fail_read_at: None,
reads: None,
};
let four_units = [encrypted_stream(), encrypted_stream()].concat();
let stream = MockDir {
name: "STREAM".to_owned(),
dirs: Vec::new(),
files: vec![
mk("00000.m2ts", four_units.clone()),
mk("00002.m2ts", encrypted_stream()),
mk("00001.m2ts", clear_stream()),
],
trip: trip.clone(),
};
assert!(!stream_content_encrypted(&stream));
let sized = MockDir {
name: "STREAM".to_owned(),
dirs: Vec::new(),
files: vec![
mk("00002.m2ts", vec![0_u8; 100]),
mk("00001.m2ts", encrypted_stream()),
mk("00000.m2ts", four_units),
],
trip: trip.clone(),
};
assert!(stream_content_encrypted(&sized));
}
#[test]
fn every_probe_io_failure_reads_as_not_encrypted() {
let ok = Trip::new(usize::MAX);
let key = |trip: &Trip| MockFile {
name: "Unit_Key_RO.inf".to_owned(),
extension: ".inf".to_owned(),
bytes: Vec::new(),
trip: trip.clone(),
fail_read_at: None,
reads: None,
};
let aacs = |trip: &Trip| MockDir {
name: "AACS".to_owned(),
dirs: Vec::new(),
files: vec![key(&ok)],
trip: trip.clone(),
};
let root = MockDir {
name: "disc".to_owned(),
dirs: vec![aacs(&ok)],
files: Vec::new(),
trip: Trip::new(0),
};
assert!(!has_aacs_key_file(&root));
let root = MockDir {
name: "disc".to_owned(),
dirs: vec![aacs(&Trip::new(0))],
files: Vec::new(),
trip: ok.clone(),
};
assert!(!has_aacs_key_file(&root));
let stream = MockDir {
name: "STREAM".to_owned(),
dirs: Vec::new(),
files: Vec::new(),
trip: Trip::new(0),
};
assert!(!stream_content_encrypted(&stream));
let unopenable = MockDir {
name: "STREAM".to_owned(),
dirs: Vec::new(),
files: vec![MockFile {
name: "00000.m2ts".to_owned(),
extension: ".m2ts".to_owned(),
bytes: encrypted_stream(),
trip: Trip::new(0),
fail_read_at: None,
reads: None,
}],
trip: ok.clone(),
};
assert!(!stream_content_encrypted(&unopenable));
let unreadable = MockDir {
name: "STREAM".to_owned(),
dirs: Vec::new(),
files: vec![MockFile {
name: "00000.m2ts".to_owned(),
extension: ".m2ts".to_owned(),
bytes: encrypted_stream(),
trip: ok.clone(),
fail_read_at: Some(0),
reads: None,
}],
trip: ok,
};
assert!(!stream_content_encrypted(&unreadable));
}
#[test]
fn open_scans_a_minimal_disc() {
let disc = TempDisc::build(
&["BDMV/PLAYLIST", "BDMV/CLIPINF"],
&[("BDMV/MovieObject.bdmv", vec![b'x'])], );
let bd = disc.open().expect("scan minimal disc");
assert_eq!(bd.size, 1);
assert!(!bd.is_uhd && !bd.is_3d && !bd.is_bd_plus && !bd.is_bd_java && !bd.is_psp);
assert_eq!(bd.disc_title, None);
assert!(bd.playlists.is_empty());
}
#[test]
fn open_handles_a_playlist_with_no_clips() {
let disc =
TempDisc::build(&["BDMV/CLIPINF"], &[("BDMV/PLAYLIST/00000.mpls", empty_mpls())]);
let bd = disc.open().expect("scan clip-less playlist");
let pl = bd.playlists.first().unwrap();
assert_eq!(pl.stream_count, 0);
assert!(pl.streams.is_empty());
assert_eq!(pl.total_length.to_bits(), 0.0_f64.to_bits());
assert!(!bd.is_50hz);
}
#[test]
fn open_marks_undeclared_streams_hidden() {
let clip = clpi(&[
(0x1011, 0x1B, [0x62, 0x30, 0, 0]),
(0x1100, 0x81, [0x61, b'e', b'n', b'g']),
(0x1101, 0x81, [0x61, b'f', b'r', b'a']),
]);
let play = mpls_declared("00000", 0, 4_500_000, &[0x1100], &[]);
let disc = TempDisc::build(
&[],
&[("BDMV/PLAYLIST/00000.mpls", play), ("BDMV/CLIPINF/00000.clpi", clip)],
);
let bd = disc.open().expect("scan disc with a declared stream");
let pl = bd.playlists.first().unwrap();
let hidden: Vec<(u16, bool)> =
pl.streams.iter().map(|s| (s.pid.get(), s.is_hidden)).collect();
assert_eq!(hidden, vec![(0x1011, true), (0x1100, false), (0x1101, true)]);
assert!(pl.has_hidden_streams());
}
#[test]
fn open_detects_a_looping_playlist() {
let clip = clpi(&[(0x1100, 0x81, [0x61, b'e', b'n', b'g'])]);
let looping = mpls_items(&["00000", "00000"], 0, 4_500_000, &[], &[], &[]);
let chained = mpls_items(&["00000", "00001"], 0, 4_500_000, &[], &[], &[]);
let disc = TempDisc::build(
&[],
&[
("BDMV/PLAYLIST/00000.mpls", looping),
("BDMV/PLAYLIST/00001.mpls", chained),
("BDMV/CLIPINF/00000.clpi", clip.clone()),
("BDMV/CLIPINF/00001.clpi", clip),
],
);
let bd = disc.open().expect("scan looping disc");
assert_eq!(bd.playlists.len(), 2);
assert!(bd.playlists.first().unwrap().has_loops);
assert!(!bd.playlists.get(1).unwrap().has_loops);
assert_eq!(bd.playlists.first().unwrap().total_length.to_bits(), 200.0_f64.to_bits());
assert_eq!(bd.presentation_order(&PlaylistFilter::default()), [1]);
assert_eq!(bd.presentation_order(&PlaylistFilter::everything()), [0, 1]);
}
#[test]
fn open_leaves_fully_declared_streams_visible() {
let clip = clpi(&[(0x1100, 0x81, [0x61, b'e', b'n', b'g'])]);
let play = mpls_declared("00000", 0, 4_500_000, &[0x1100], &[]);
let disc = TempDisc::build(
&[],
&[("BDMV/PLAYLIST/00000.mpls", play), ("BDMV/CLIPINF/00000.clpi", clip)],
);
let bd = disc.open().expect("scan disc with all streams declared");
let pl = bd.playlists.first().unwrap();
assert!(pl.streams.iter().all(|s| !s.is_hidden));
assert!(!pl.has_hidden_streams());
}
#[test]
fn open_with_packet_scan_and_no_stream_directory() {
let clip = clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]);
let play = mpls("00000", 0, 4_500_000, &[]);
let disc = TempDisc::build(
&[],
&[("BDMV/PLAYLIST/00000.mpls", play), ("BDMV/CLIPINF/00000.clpi", clip)],
);
let bd = disc.open_scanned().expect("packet-scan without a STREAM dir");
let pl = bd.playlists.first().unwrap();
assert_eq!(pl.stream_count, 1);
assert_eq!(pl.file_size, 0);
}
#[test]
fn open_counts_only_angle_zero_clips() {
let clip =
clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0]), (0x1100, 0x81, [0x61, b'e', b'n', b'g'])]);
let play = mpls_angles("00000", 2_700_000, 4_500_000, &["00010", "00021"], &[]);
let disc = TempDisc::build(
&[],
&[
("BDMV/PLAYLIST/00000.mpls", play),
("BDMV/CLIPINF/00000.clpi", clip),
("BDMV/STREAM/00000.m2ts", vec![0; 100]),
],
);
let bd = disc.open().expect("scan multi-angle disc");
let pl = bd.playlists.first().unwrap();
assert_eq!(pl.total_length.to_bits(), 40.0_f64.to_bits()); assert_eq!(pl.file_size, 100); assert_eq!(pl.stream_count, 4);
}
fn clip_summary(angle_index: i32, time_in: f64, length: f64, packets: u64) -> ClipSummary {
ClipSummary {
angle_index,
relative_time_in: time_in,
packet_count: packets,
..fixtures::clip("00000.M2TS", length)
}
}
fn playlist_summary(
total_length: f64,
angle_count: usize,
clips: Vec<ClipSummary>,
) -> PlaylistSummary {
PlaylistSummary { angle_count, ..fixtures::playlist("00000.MPLS", total_length, clips) }
}
#[test]
fn estimated_bytes_prefer_the_interleaved_size_then_the_m2ts_size() {
let sizes = |interleaved, m2ts| {
let clip = ClipSummary {
file_size: m2ts,
interleaved_file_size: interleaved,
..clip_summary(0, 0.0, 0.0, 0)
};
let playlist = PlaylistSummary {
file_size: m2ts,
interleaved_file_size: interleaved,
..playlist_summary(0.0, 0, Vec::new())
};
(playlist.estimated_bytes(), clip.estimated_bytes())
};
assert_eq!(sizes(2000, 500), (Some(2000), Some(2000)));
assert_eq!(sizes(0, 500), (Some(500), Some(500)));
assert_eq!(sizes(0, 0), (None, None));
}
#[test]
fn chapter_summaries_walk_the_first_video_pid_diagnostics() {
use crate::bdrom::m2ts::TsStreamDiagnostics;
let mut audio = TsAudioStream::default();
audio.base.stream_type = TsStreamType::Ac3Audio;
let mut video = TsVideoStream::default();
video.base.stream_type = TsStreamType::AvcVideo;
let mut playlist = TsPlaylistFile {
file_type: "MPLS0200".to_owned(),
name: "00000.MPLS".to_owned(),
mvc_base_view_r: false,
chapters: vec![0.0],
playlist_streams: BTreeMap::new(),
streams: BTreeMap::from([
(0x1010, TsStream::Audio(audio)),
(0x1011, TsStream::Video(video)),
]),
angle_streams: Vec::new(),
stream_clips: vec![
TsStreamClip { name: "00000.M2TS".to_owned(), ..TsStreamClip::default() },
TsStreamClip { name: "00001.M2TS".to_owned(), ..TsStreamClip::default() },
],
angle_count: 0,
};
let mut file = TsStreamFile::new("00000.m2ts");
file.stream_diagnostics.insert(
0x1011,
vec![
TsStreamDiagnostics {
bytes: 1000,
packets: 1,
marker: 1.0,
interval: 1.0,
tag: Some("I".to_owned()),
},
TsStreamDiagnostics {
bytes: 1000,
packets: 1,
marker: 2.0,
interval: 1.0,
tag: None,
},
],
);
let measured = BTreeMap::from([("00000.M2TS".to_owned(), file)]);
let rows = build_chapter_summaries(&playlist, &measured, 4.0);
assert_eq!(rows.len(), 1);
let row = rows.first().unwrap();
assert_eq!(row.length.to_bits(), 4.0_f64.to_bits());
assert_eq!(row.avg_rate.to_bits(), 4000.0_f64.to_bits()); assert_eq!(row.avg_frame_size.to_bits(), 2000.0_f64.to_bits());
playlist.streams.remove(&0x1011);
let rows = build_chapter_summaries(&playlist, &measured, 4.0);
assert_eq!(rows.len(), 1);
assert_eq!(rows.first().unwrap().avg_rate.to_bits(), 0.0_f64.to_bits());
assert_eq!(rows.first().unwrap().length.to_bits(), 4.0_f64.to_bits());
}
#[test]
fn clip_packet_size_and_bit_rate_derive_from_the_tallies() {
let mut clip = clip_summary(0, 0.0, 10.0, 6);
clip.packet_seconds = 4.0;
assert_eq!(clip.packet_size(), 1152); assert_eq!(clip.packet_bit_rate(), 2304);
clip.packet_seconds = 0.0;
assert_eq!(clip.packet_bit_rate(), 0);
clip.packet_count = 100;
clip.packet_seconds = 7.0;
assert_eq!(clip.packet_bit_rate(), 21_943);
}
#[test]
fn rate_over_guards_the_zero_duration() {
assert_eq!(rate_over(1152, 4.0), 2304);
assert_eq!(rate_over(1152, 0.0), 0);
assert_eq!(rate_over(1152, -1.0), 0);
}
#[test]
fn playlist_totals_split_main_and_angle_clips() {
let playlist = playlist_summary(
100.0,
2,
vec![
clip_summary(0, 0.0, 40.0, 10),
clip_summary(1, 0.0, 40.0, 30),
clip_summary(0, 40.0, 60.0, 20),
],
);
assert_eq!(playlist.total_packet_size(), 30 * 192); assert_eq!(playlist.total_angle_packet_size(), 60 * 192); assert_eq!(playlist.total_angle_length().to_bits(), 140.0_f64.to_bits());
assert_eq!(playlist.total_bit_rate(), 461);
assert_eq!(playlist.total_angle_bit_rate(), 658);
let angles = playlist.angle_totals();
assert_eq!(angles.len(), 2);
let one = angles.first().unwrap();
assert_eq!(one.length.to_bits(), 40.0_f64.to_bits());
assert_eq!(one.packet_size, 30 * 192);
assert_eq!(one.timeline_packet_size, 50 * 192);
let two = angles.get(1).unwrap();
assert_eq!(two.length.to_bits(), 0.0_f64.to_bits());
assert_eq!(two.packet_size, 0);
assert_eq!(two.timeline_packet_size, 30 * 192);
assert!(playlist_summary(100.0, 0, Vec::new()).angle_totals().is_empty());
}
#[test]
fn angle_totals_stop_at_the_widest_angle_table_a_playlist_can_declare() {
let clips = vec![clip_summary(0, 0.0, 40.0, 10), clip_summary(1, 0.0, 40.0, 30)];
assert_eq!(playlist_summary(100.0, 253, clips.clone()).angle_totals().len(), 253);
assert_eq!(playlist_summary(100.0, 254, clips.clone()).angle_totals().len(), 254);
assert_eq!(playlist_summary(100.0, 255, clips.clone()).angle_totals().len(), 254);
assert_eq!(playlist_summary(100.0, usize::MAX, clips).angle_totals().len(), 254);
}
proptest! {
#[test]
fn playlist_totals_invariants(
clips in proptest::collection::vec(
(0_i32..3, 0_u8..4, 1.0_f64..100.0, 0_u64..1000), 0..8,
),
angle_count in proptest::prop_oneof![0_usize..300, proptest::strategy::Just(usize::MAX)],
) {
let clips: Vec<ClipSummary> = clips
.into_iter()
.map(|(angle, slot, length, packets)| {
clip_summary(angle, f64::from(slot) * 50.0, length, packets)
})
.collect();
let playlist = playlist_summary(100.0, angle_count, clips);
let total = playlist.total_packet_size();
let total_angle = playlist.total_angle_packet_size();
prop_assert!(total <= total_angle);
let angles = playlist.angle_totals();
prop_assert_eq!(angles.len(), angle_count.min(254));
for angle in &angles {
prop_assert!(angle.packet_size <= angle.timeline_packet_size);
prop_assert!(angle.timeline_packet_size <= total_angle);
}
}
}
fn playlist_with_clips(angle_count: i32, clips: Vec<TsStreamClip>) -> TsPlaylistFile {
let mut playlist = bare_playlist();
playlist.angle_count = angle_count;
playlist.stream_clips = clips;
playlist
}
#[test]
fn clear_measurements_zeroes_the_clip_tallies() {
let playlist = playlist_with_clips(
0,
vec![TsStreamClip {
name: "00000.M2TS".to_owned(),
payload_bytes: 9,
packet_count: 8,
packet_seconds: 7.0,
..TsStreamClip::default()
}],
);
let mut playlists = vec![playlist];
clear_measurements(&mut playlists);
let clip = playlists.first().unwrap().stream_clips.first().unwrap();
assert_eq!(clip.payload_bytes, 0);
assert_eq!(clip.packet_count, 0);
assert_eq!(clip.packet_seconds.to_bits(), 0.0_f64.to_bits());
}
#[test]
fn resolve_playlist_streams_presents_reset_copies_and_angle_maps() {
let mut clip_streams = BTreeMap::new();
let mut video = TsVideoStream::default();
video.base.pid = Pid::new(0x1011);
video.base.stream_type = TsStreamType::AvcVideo;
clip_streams.insert(0x1011, TsStream::Video(video));
let mut audio = TsAudioStream::default();
audio.base.pid = Pid::new(0x1100);
audio.base.stream_type = TsStreamType::Ac3Audio;
audio.base.set_language_code("eng");
clip_streams.insert(0x1100, TsStream::Audio(audio));
let mut file = TsStreamFile::new("00000.m2ts");
let mut scanned_video = TsVideoStream::default();
scanned_video.base.pid = Pid::new(0x1011);
scanned_video.base.stream_type = TsStreamType::AvcVideo;
scanned_video.base.is_vbr = true;
scanned_video.base.payload_bytes = 999; scanned_video.encoding_profile = Some("High Profile 4.1".to_owned());
file.streams.insert(0x1011, TsStream::Video(scanned_video));
let metas = [ClipMeta {
clip_streams: &clip_streams,
scanned: Some(&file),
has_50hz_video: false,
relative_length: 1.0,
length: 10.0,
stream_file_present: true,
interleaved_file_present: false,
}];
let mut playlist = playlist_with_clips(2, Vec::new());
resolve_playlist_streams(&mut playlist, &metas, false);
assert_eq!(playlist.streams.len(), 2);
let video = playlist.streams.get(&0x1011).unwrap();
assert!(video.base().is_vbr);
assert_eq!(video.base().payload_bytes, 0);
let v = as_video(video).expect("video stays video");
assert_eq!(v.encoding_profile.as_deref(), Some("High Profile 4.1"));
assert_eq!(
playlist.streams.get(&0x1100).unwrap().base().language_name.as_deref(),
Some("English")
);
assert_eq!(playlist.angle_streams.len(), 2);
for (index, angle) in playlist.angle_streams.iter().enumerate() {
assert_eq!(angle.len(), 1);
let clone = angle.get(&0x1011).unwrap();
assert_eq!(clone.base().angle_index, i32::try_from(index).unwrap().wrapping_add(1));
assert!(clone.base().is_vbr);
}
let mut unresolved = playlist_with_clips(1, Vec::new());
resolve_playlist_streams(&mut unresolved, &[], false);
assert!(unresolved.streams.is_empty());
assert!(unresolved.angle_streams.is_empty());
}
#[test]
fn resolve_playlist_streams_presents_the_interleaved_dependent_view() {
let mut clip_streams = BTreeMap::new();
let mut video = TsVideoStream::default();
video.base.pid = Pid::new(0x1011);
video.base.stream_type = TsStreamType::AvcVideo;
clip_streams.insert(0x1011, TsStream::Video(video));
let mvc = |pid: u16| {
let mut stream = TsVideoStream::default();
stream.base.pid = Pid::new(pid);
stream.base.stream_type = TsStreamType::MvcVideo;
TsStream::Video(stream)
};
let interleaved =
|| TsInterleavedFile::new(Box::new(MemBdFile::new("00000.ssif", Vec::new(), false)));
let mut file = TsStreamFile::new("00000.m2ts");
file.interleaved_file = Some(interleaved());
file.streams.insert(MVC_PID, mvc(MVC_PID));
let mut playlist = playlist_with_clips(0, Vec::new());
let metas = [ClipMeta {
clip_streams: &clip_streams,
scanned: Some(&file),
has_50hz_video: false,
relative_length: 1.0,
length: 10.0,
stream_file_present: true,
interleaved_file_present: false,
}];
resolve_playlist_streams(&mut playlist, &metas, false);
assert!(playlist.streams.contains_key(&MVC_PID), "dependent view presented");
assert_eq!(playlist.streams.len(), 2);
let mut plain = TsStreamFile::new("00000.m2ts");
plain.streams.insert(MVC_PID, mvc(MVC_PID));
let mut playlist = playlist_with_clips(0, Vec::new());
let metas = [ClipMeta {
clip_streams: &clip_streams,
scanned: Some(&plain),
has_50hz_video: false,
relative_length: 1.0,
length: 10.0,
stream_file_present: true,
interleaved_file_present: false,
}];
resolve_playlist_streams(&mut playlist, &metas, false);
assert!(!playlist.streams.contains_key(&MVC_PID));
let mut declared = clip_streams.clone();
let mut own = TsAudioStream::default();
own.base.pid = Pid::new(MVC_PID);
own.base.stream_type = TsStreamType::Ac3Audio;
declared.insert(MVC_PID, TsStream::Audio(own));
let mut file = TsStreamFile::new("00000.m2ts");
file.interleaved_file = Some(interleaved());
file.streams.insert(MVC_PID, mvc(MVC_PID));
let metas = [ClipMeta {
clip_streams: &declared,
scanned: Some(&file),
has_50hz_video: false,
relative_length: 1.0,
length: 10.0,
stream_file_present: true,
interleaved_file_present: false,
}];
let mut playlist = playlist_with_clips(0, Vec::new());
resolve_playlist_streams(&mut playlist, &metas, false);
assert_eq!(playlist.streams.get(&MVC_PID).unwrap().stream_type(), TsStreamType::Ac3Audio);
}
fn sps_payload() -> Vec<u8> {
let mut data = vec![0x00, 0x00, 0x01, 0x67, 100, 0x00, 41];
data.resize(100, 0xAA);
data
}
#[test]
fn open_scanned_measures_clips_streams_and_totals() {
let clip =
clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0]), (0x1100, 0x81, [0x61, b'e', b'n', b'g'])]);
let play = mpls("00000", 0, 4_500_000, &[]); let mut m2ts = packet(0, true, &pat_payload(0x0100));
m2ts.extend(packet(
0x0100,
true,
&pmt_payload(&[(0x1B, 0x1011), (0x81, 0x1100), (0x90, 0x1200)]),
));
let audio_data = [0xBB_u8; 100];
m2ts.extend(packet(0x1100, true, &pes_pts(0xC0, 90_000, &audio_data)));
m2ts.extend(packet(0x1011, true, &pes_dts(0xE0, 90_000, 90_000, &sps_payload())));
m2ts.extend(packet(0x1011, true, &pes_dts(0xE0, 180_000, 180_000, &sps_payload())));
m2ts.extend(packet(0x1011, true, &pes_dts(0xE0, 270_000, 270_000, &sps_payload())));
let disc = TempDisc::build(
&[],
&[
("BDMV/PLAYLIST/00000.mpls", play),
("BDMV/CLIPINF/00000.clpi", clip),
("BDMV/STREAM/00000.m2ts", m2ts),
],
);
let bd = disc.open_scanned().expect("measured scan");
let pl = bd.playlists.first().unwrap();
let video = pl.streams.first().unwrap();
assert_eq!(video.bitrate, 400);
assert_eq!(video.active_bitrate, 800);
assert!(video.description.contains("High Profile 4.1"));
assert_eq!(pl.streams.get(1).unwrap().bitrate, 0);
assert_eq!(pl.clips.len(), 1);
let clip = pl.clips.first().unwrap();
assert_eq!(clip.name, "00000.M2TS");
assert_eq!(clip.display_name, "00000.M2TS");
assert_eq!(clip.angle_index, 0);
assert_eq!(clip.relative_time_in.to_bits(), 0.0_f64.to_bits());
assert_eq!(clip.length.to_bits(), 100.0_f64.to_bits());
assert_eq!(clip.file_seconds.to_bits(), 1.0_f64.to_bits());
assert_eq!(clip.payload_bytes, 300);
assert_eq!(clip.packet_count, 6);
assert_eq!(clip.packet_seconds.to_bits(), 4.0_f64.to_bits());
assert_eq!(clip.packet_size(), 1152);
assert_eq!(clip.packet_bit_rate(), 2304);
assert_eq!(clip.streams.len(), 2);
let video_tally = clip.streams.first().unwrap();
assert_eq!(video_tally.pid, Pid::new(0x1011));
assert_eq!((video_tally.payload_bytes, video_tally.packet_count), (200, 3));
let audio_tally = clip.streams.get(1).unwrap();
assert_eq!(audio_tally.pid, Pid::new(0x1100));
assert_eq!((audio_tally.payload_bytes, audio_tally.packet_count), (100, 1));
assert_eq!(pl.angle_count, 0);
assert_eq!(pl.total_packet_size(), 1152);
assert_eq!(pl.total_angle_packet_size(), 1152);
assert_eq!(pl.total_bit_rate(), 92); assert!(pl.angle_totals().is_empty());
}
#[test]
fn open_codecs_mode_reads_codec_detail_but_not_bitrate() {
let clip =
clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0]), (0x1100, 0x81, [0x61, b'e', b'n', b'g'])]);
let play = mpls("00000", 0, 4_500_000, &[]); let mut m2ts = packet(0, true, &pat_payload(0x0100));
m2ts.extend(packet(0x0100, true, &pmt_payload(&[(0x1B, 0x1011), (0x81, 0x1100)])));
m2ts.extend(packet(0x1100, true, &pes_pts(0xC0, 90_000, &[0xBB_u8; 100])));
m2ts.extend(packet(0x1011, true, &pes_dts(0xE0, 90_000, 90_000, &sps_payload())));
m2ts.extend(packet(0x1011, true, &pes_dts(0xE0, 180_000, 180_000, &sps_payload())));
m2ts.extend(packet(0x1011, true, &pes_dts(0xE0, 270_000, 270_000, &sps_payload())));
let disc = TempDisc::build(
&[],
&[
("BDMV/PLAYLIST/00000.mpls", play),
("BDMV/CLIPINF/00000.clpi", clip),
("BDMV/STREAM/00000.m2ts", m2ts),
],
);
let full = disc.open_scanned().expect("full scan");
let full_pl = full.playlists.first().unwrap();
let full_video = full_pl.streams.first().unwrap();
assert!(full_video.description.contains("High Profile 4.1"));
assert_eq!(full_video.bitrate, 400);
assert_eq!(full_pl.total_packet_size(), 1152);
let meta = disc.open().expect("metadata scan");
let meta_video = meta.playlists.first().unwrap().streams.first().unwrap();
assert!(
!meta_video.description.contains("High Profile 4.1"),
"metadata mode runs no packet scan, so no scanned codec detail"
);
assert_eq!(meta_video.bitrate, 0);
let codecs = disc.open_codecs().expect("codec-only scan");
let pl = codecs.playlists.first().unwrap();
let video = pl.streams.first().unwrap();
assert!(
video.description.contains("High Profile 4.1"),
"the quick pass fills the encoding profile"
);
assert_eq!(video.bitrate, 0, "no full pass ⇒ no measured bitrate");
assert_eq!(video.active_bitrate, 0);
assert_eq!(pl.total_packet_size(), 0, "no clip payload measured");
assert_eq!(pl.total_bit_rate(), 0);
let clip = pl.clips.first().unwrap();
assert_eq!(clip.packet_count, 0);
assert_eq!(clip.payload_bytes, 0);
}
#[test]
fn a_stream_file_that_ends_before_its_declared_span_is_reported_short() {
let clip = clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]);
let mut m2ts = packet(0, true, &pat_payload(0x0100));
m2ts.extend(packet(0x0100, true, &pmt_payload(&[(0x1B, 0x1011)])));
for ticks in [90_000_u64, 180_000, 270_000] {
m2ts.extend(packet(0x1011, true, &pes_dts(0xE0, ticks, ticks, &sps_payload())));
}
let build = |out_t: u32| {
TempDisc::build(
&[],
&[
("BDMV/PLAYLIST/00000.mpls", mpls("00000", 0, out_t, &[])),
("BDMV/CLIPINF/00000.clpi", clip.clone()),
("BDMV/STREAM/00000.m2ts", m2ts.clone()),
],
)
};
let short = build(4_500_000); let scanned = short.open_scanned().expect("measured scan");
let reported = scanned.short_stream_files();
assert_eq!(reported.len(), 1);
let file = reported.first().unwrap();
assert_eq!(file.file(), "00000.M2TS");
assert_eq!(file.declared_seconds().to_bits(), 100.0_f64.to_bits());
assert_eq!(file.measured_seconds().to_bits(), 4.0_f64.to_bits());
assert_eq!(file.missing_seconds().to_bits(), 96.0_f64.to_bits());
let whole = build(180_000).open_scanned().expect("measured scan");
assert!(whole.short_stream_files().is_empty());
assert!(short.open().expect("metadata scan").short_stream_files().is_empty());
assert!(short.open_codecs().expect("codec scan").short_stream_files().is_empty());
let mut reported = 0_usize;
let unselected = {
let mut progress = |_: ScanProgress<'_>| reported = reported.saturating_add(1);
progress(ScanProgress { file: "00000.M2TS", done: 0, total: 0 });
BdRom::open(
&FsDir::new(short.root.clone()),
ScanMode::Full,
ScanOptions::default(),
Some(&BTreeSet::new()),
ScanObservers::new(&mut progress, &AtomicBool::new(false)),
)
.expect("selective scan")
};
assert_eq!(reported, 1, "an empty selection reads no stream file");
assert!(unselected.short_stream_files().is_empty());
}
#[test]
fn a_stream_file_that_demuxes_to_nothing_at_all_is_reported_short() {
let clip = clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]);
for stub in [Vec::new(), vec![0xFF; 4096]] {
let bytes = stub.len();
let disc = TempDisc::build(
&[],
&[
("BDMV/PLAYLIST/00000.mpls", mpls("00000", 0, 4_500_000, &[])), ("BDMV/CLIPINF/00000.clpi", clip.clone()),
("BDMV/STREAM/00000.m2ts", stub),
],
);
let scanned = disc.open_scanned().expect("measured scan");
let summary = scanned
.playlists
.first()
.and_then(|pl| pl.clips.first())
.expect("the destroyed file's clip row");
assert!(summary.measured, "{bytes} bytes: the file was demuxed");
assert!(summary.streams.is_empty(), "{bytes} bytes: nothing registered");
let reported = scanned.short_stream_files();
assert_eq!(reported.len(), 1, "{bytes} bytes: {reported:?}");
assert_eq!(
reported.first().expect("the short file").notice(),
"00000.M2TS is shorter than declared: \
measured 0.0 s of 100.0 s (100.0 s missing)"
);
}
}
#[test]
fn clip_summaries_skip_a_registration_order_entry_without_a_stream() {
let mut audio = TsAudioStream::default();
audio.base.pid = Pid::new(0x1100);
audio.base.stream_type = TsStreamType::Ac3Audio;
audio.base.payload_bytes = 64;
audio.base.packet_count = 2;
let mut file = TsStreamFile::new("00000.m2ts");
file.stream_order = vec![0x1011, 0x1100];
file.streams = BTreeMap::from([(0x1100, TsStream::Audio(audio))]);
let playlist = TsPlaylistFile {
file_type: "MPLS0300".to_owned(),
name: "00000.MPLS".to_owned(),
mvc_base_view_r: false,
chapters: Vec::new(),
playlist_streams: BTreeMap::new(),
streams: BTreeMap::from([(0x1100, TsStream::Audio(TsAudioStream::default()))]),
angle_streams: Vec::new(),
stream_clips: vec![TsStreamClip {
name: "00000.M2TS".to_owned(),
..TsStreamClip::default()
}],
angle_count: 0,
};
let measured = BTreeMap::from([("00000.M2TS".to_owned(), file)]);
let stream_files = BTreeMap::from([("00000.M2TS".to_owned(), 4096_u64)]);
let interleaved_files = BTreeMap::from([("00000.SSIF".to_owned(), 8192_u64)]);
let clips = build_clip_summaries(&playlist, &stream_files, &interleaved_files, &measured);
let clip = clips.first().unwrap();
assert_eq!(clip.file_size, 4096);
assert_eq!(clip.interleaved_file_size, 8192);
let tallies = &clip.streams;
assert_eq!(tallies.len(), 1);
assert_eq!(tallies.first().unwrap().pid, Pid::new(0x1100));
assert_eq!(tallies.first().unwrap().codec_short_name, "AC3");
assert_eq!(tallies.first().unwrap().stream_type, TsStreamType::Ac3Audio);
}
#[test]
fn open_scanned_reads_the_interleaved_source_and_presents_the_dependent_view() {
let clip = clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]);
let play = mpls("00000", 0, 4_500_000, &[]);
let mut ssif = packet(0, true, &pat_payload(0x0100));
ssif.extend(packet(0x0100, true, &pmt_payload(&[(0x1B, 0x1011), (0x20, 0x1012)])));
ssif.extend(packet(0x1011, true, &pes_dts(0xE0, 90_000, 90_000, &sps_payload())));
ssif.extend(packet(0x1012, true, &pes_pts(0xE1, 90_000, &[0xCC; 50])));
ssif.extend(packet(0x1011, true, &pes_dts(0xE0, 180_000, 180_000, &sps_payload())));
ssif.extend(packet(0x1011, true, &pes_dts(0xE0, 270_000, 270_000, &sps_payload())));
let disc = TempDisc::build(
&[],
&[
("BDMV/PLAYLIST/00000.mpls", play),
("BDMV/CLIPINF/00000.clpi", clip),
("BDMV/STREAM/00000.m2ts", vec![0_u8; 192]),
("BDMV/STREAM/SSIF/00000.ssif", ssif),
],
);
let bd = disc.open_scanned().expect("interleaved scan");
let pl = bd.playlists.first().unwrap();
assert_eq!(pl.streams.len(), 2);
assert_eq!(pl.stream_count, 1);
let mvc_row = pl.streams.iter().find(|s| s.pid == Pid::new(0x1012)).unwrap();
assert_eq!(mvc_row.stream_type.name(), "MVC_VIDEO");
assert_eq!(mvc_row.codec_name, "MPEG-4 MVC Video");
assert_eq!(mvc_row.description, "");
assert!(mvc_row.ssif_only && !mvc_row.is_hidden);
assert!(mvc_row.bitrate > 0, "the dependent view carries its measured rate");
let clip = pl.clips.first().unwrap();
assert_eq!(clip.name, "00000.M2TS");
assert_eq!(clip.display_name, "00000.SSIF");
assert!(clip.streams.iter().any(|tally| tally.pid == Pid::new(0x1012)));
let base = clip.streams.iter().find(|t| t.pid == Pid::new(0x1011)).unwrap();
assert_eq!((base.payload_bytes, base.packet_count), (200, 3));
}
fn scan_both_ways(root: &dyn BdDir, mode: ScanMode) -> (BdRom, BdRom) {
let two_pass = BdRom::open(
root,
mode,
ScanOptions::default(),
None,
ScanObservers::new(&mut |_| {}, &never_cancel()),
)
.expect("the two-pass scan opens");
let options = ScanOptions { skip_quick_pass: true, ..ScanOptions::default() };
let skipped = BdRom::open(
root,
mode,
options,
None,
ScanObservers::new(&mut |_| {}, &never_cancel()),
)
.expect("the quick-pass-skipping scan opens");
(two_pass, skipped)
}
fn interleaved_ssif(with_mvc: bool) -> Vec<u8> {
let mut ssif = packet(0, true, &pat_payload(0x0100));
let mut streams = vec![(0x1B, 0x1011)];
if with_mvc {
streams.push((0x20, 0x1012));
}
ssif.extend(packet(0x0100, true, &pmt_payload(&streams)));
ssif.extend(packet(0x1011, true, &pes_dts(0xE0, 90_000, 90_000, &sps_payload())));
if with_mvc {
ssif.extend(packet(0x1012, true, &pes_pts(0xE1, 90_000, &[0xCC; 50])));
}
ssif.extend(packet(0x1011, true, &pes_dts(0xE0, 180_000, 180_000, &sps_payload())));
ssif.extend(packet(0x1011, true, &pes_dts(0xE0, 270_000, 270_000, &sps_payload())));
ssif
}
fn interleaved_disc(ssif: Vec<u8>) -> TempDisc {
TempDisc::build(
&[],
&[
("BDMV/PLAYLIST/00000.mpls", mpls("00000", 0, 4_500_000, &[])),
("BDMV/CLIPINF/00000.clpi", clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])])),
("BDMV/STREAM/00000.m2ts", vec![0_u8; 192]),
("BDMV/STREAM/SSIF/00000.ssif", ssif),
],
)
}
fn interleaved_mock_disc(ssif: &Trip, m2ts: &Trip) -> MockDir {
let trip = Trip::new(usize::MAX);
let file = |name: &str, bytes: Vec<u8>, trip: &Trip| MockFile {
name: name.to_owned(),
extension: extension_of_name(name),
bytes,
trip: trip.clone(),
fail_read_at: None,
reads: None,
};
let dir = |name: &str, dirs: Vec<MockDir>, files: Vec<MockFile>| MockDir {
name: name.to_owned(),
dirs,
files,
trip: trip.clone(),
};
dir(
"disc",
vec![dir(
"BDMV",
vec![
dir(
"CLIPINF",
vec![],
vec![file(
"00000.clpi",
clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]),
&trip,
)],
),
dir(
"PLAYLIST",
vec![],
vec![file("00000.mpls", mpls("00000", 0, 4_500_000, &[]), &trip)],
),
dir(
"STREAM",
vec![dir(
"SSIF",
vec![],
vec![file("00000.ssif", interleaved_ssif(true), ssif)],
)],
vec![file("00000.m2ts", interleaved_ssif(false), m2ts)],
),
],
vec![],
)],
vec![],
)
}
#[test]
fn an_unopenable_interleaved_source_falls_back_to_the_base_view() {
let intact = Trip::new(usize::MAX);
let healthy = open_resilient(&interleaved_mock_disc(&intact, &intact), ScanMode::Full)
.expect("healthy 3D scan");
assert!(healthy.errors.is_empty());
let control = healthy.bdrom.playlists.first().unwrap();
assert!(control.streams.iter().any(|s| s.pid == Pid::new(0x1012)), "the dependent view");
assert_eq!(control.clips.first().unwrap().display_name, "00000.SSIF");
let report =
open_resilient(&interleaved_mock_disc(&Trip::always(), &intact), ScanMode::Full)
.expect("the resilient scan degrades to the base view");
let recorded: Vec<(ScanStage, &str)> =
report.errors.iter().map(|e| (e.stage, e.file.as_str())).collect();
assert_eq!(recorded, vec![(ScanStage::StreamFile, "00000.ssif"); 2]);
let pl = report.bdrom.playlists.first().unwrap();
let base = pl.streams.iter().find(|s| s.pid == Pid::new(0x1011)).expect("the base view");
assert!(base.bitrate > 0, "the base view carries its measured rate");
assert!(pl.streams.iter().all(|s| s.pid != Pid::new(0x1012)), "no dependent-view row");
let clip = pl.clips.first().unwrap();
assert_eq!(clip.display_name, "00000.M2TS");
assert!(clip.streams.iter().any(|t| t.pid == Pid::new(0x1011)));
assert!(clip.file_seconds > 0.0, "the base view was demuxed");
}
#[test]
fn a_strict_scan_still_fails_on_an_unopenable_interleaved_source() {
let failed =
open(&interleaved_mock_disc(&Trip::always(), &Trip::new(usize::MAX)), ScanMode::Full)
.expect_err("strict mode propagates the failed interleaved open");
assert_eq!(failed.to_string(), "io error: injected io failure");
}
#[test]
fn a_clip_with_neither_source_openable_keeps_the_scan_going() {
let dead = Trip::always();
let report = open_resilient(&interleaved_mock_disc(&dead, &dead), ScanMode::Full)
.expect("the resilient scan continues past a clip it cannot read");
let recorded: Vec<(ScanStage, &str)> =
report.errors.iter().map(|e| (e.stage, e.file.as_str())).collect();
assert_eq!(
recorded,
vec![
(ScanStage::StreamFile, "00000.ssif"),
(ScanStage::StreamFile, "00000.m2ts"),
(ScanStage::StreamFile, "00000.ssif"),
(ScanStage::StreamFile, "00000.m2ts"),
]
);
let pl = report.bdrom.playlists.first().unwrap();
let base = pl.streams.iter().find(|s| s.pid == Pid::new(0x1011)).expect("the base view");
assert_eq!(base.bitrate, 0, "nothing was measured");
assert!(pl.clips.first().unwrap().streams.is_empty());
}
#[test]
fn a_full_scan_without_the_quick_pass_presents_the_two_pass_result() {
let disc = interleaved_disc(interleaved_ssif(true));
let root = FsDir::new(disc.root.clone());
let (two_pass, skipped) = scan_both_ways(&root, ScanMode::Full);
assert_eq!(skipped, two_pass);
let pl = skipped.playlists.first().unwrap();
let mvc = pl.streams.iter().find(|s| s.pid == Pid::new(0x1012)).unwrap();
assert!(mvc.ssif_only, "the dependent view is the SSIF-only row");
assert!(mvc.bitrate > 0, "the dependent view carries its measured rate");
let (codecs, codecs_skipped) = scan_both_ways(&root, ScanMode::Codecs);
assert_eq!(codecs_skipped, codecs);
let codec_pl = codecs.playlists.first().unwrap();
assert!(codec_pl.streams.iter().any(|s| s.pid == Pid::new(0x1012)));
}
#[test]
fn a_skipping_scan_withdraws_a_dependent_view_the_measurement_finds_absent() {
let disc = interleaved_disc(interleaved_ssif(false));
let root = FsDir::new(disc.root.clone());
let (two_pass, skipped) = scan_both_ways(&root, ScanMode::Full);
assert_eq!(skipped, two_pass);
let pl = skipped.playlists.first().unwrap();
assert!(pl.streams.iter().all(|s| s.pid != Pid::new(0x1012)), "no phantom row");
}
#[test]
fn a_skipping_scan_measures_shared_clips_like_the_two_pass_scan() {
let disc = shared_clip_disc();
let (two_pass, skipped) = scan_both_ways(&disc, ScanMode::Full);
assert_eq!(skipped, two_pass);
assert!(
two_pass.playlists.iter().all(|p| p.streams.iter().any(|s| s.bitrate > 0)),
"every playlist carries a measured rate"
);
}
#[test]
fn a_skipping_resilient_scan_keeps_partials_like_the_two_pass_scan() {
use crate::bdrom::m2ts::DATA_SIZE;
let m2ts = two_chunk_m2ts(&[(0x1B, 0x1011)], &[1, 2, 3], &[46, 47, 48]);
let scan = |options: ScanOptions| {
let disc = tripping_disc(m2ts.clone(), Some(DATA_SIZE));
BdRom::open_resilient(
&disc,
ScanMode::Full,
options,
None,
ScanObservers::new(&mut |_| {}, &never_cancel()),
)
.expect("the resilient scan continues")
};
let two_pass = scan(ScanOptions::default());
let skipped = scan(ScanOptions { skip_quick_pass: true, ..ScanOptions::default() });
assert_eq!(skipped.bdrom, two_pass.bdrom);
assert_eq!(two_pass.errors.len(), 1, "the mid-file failure is recorded");
assert_eq!(skipped.errors.len(), 1, "the skipping scan records it too");
let pl = skipped.bdrom.playlists.first().unwrap();
assert!(pl.streams.iter().any(|s| s.bitrate > 0), "the partial carries a rate");
}
fn logged_disc(m2ts: Vec<u8>, reads: &Arc<Mutex<Vec<usize>>>, aacs: bool) -> MockDir {
let trip = Trip::new(usize::MAX);
let file = |name: &str, bytes: Vec<u8>, reads: Option<Arc<Mutex<Vec<usize>>>>| MockFile {
name: name.to_owned(),
extension: extension_of_name(name),
bytes,
trip: trip.clone(),
fail_read_at: None,
reads,
};
let dir = |name: &str, dirs: Vec<MockDir>, files: Vec<MockFile>| MockDir {
name: name.to_owned(),
dirs,
files,
trip: trip.clone(),
};
let mut top = vec![dir(
"BDMV",
vec![
dir(
"CLIPINF",
vec![],
vec![file("00000.clpi", clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]), None)],
),
dir(
"PLAYLIST",
vec![],
vec![file("00000.mpls", mpls("00000", 0, 4_500_000, &[]), None)],
),
dir("STREAM", vec![], vec![file("00000.m2ts", m2ts, Some(Arc::clone(reads)))]),
],
vec![],
)];
if aacs {
top.push(dir("AACS", vec![], vec![file("Unit_Key_RO.inf", b"key".to_vec(), None)]));
}
dir("disc", top, vec![])
}
#[test]
fn a_skipping_full_scan_issues_no_quick_sized_reads() {
use crate::bdrom::m2ts::{DATA_SIZE, QUICK_DATA_SIZE};
let log = Arc::new(Mutex::new(Vec::new()));
let disc = logged_disc(video_m2ts(&[1, 2, 3]), &log, false);
let scan = |options: ScanOptions| {
log.lock().unwrap().clear();
drop(
BdRom::open(
&disc,
ScanMode::Full,
options,
None,
ScanObservers::new(&mut |_| {}, &never_cancel()),
)
.expect("the mock disc scans"),
);
log.lock().unwrap().clone()
};
let two_pass = scan(ScanOptions::default());
assert!(two_pass.contains(&QUICK_DATA_SIZE), "the quick pass requests its own chunk size");
assert!(two_pass.contains(&DATA_SIZE), "the measurement pass requests full chunks");
let skipped = scan(ScanOptions { skip_quick_pass: true, ..ScanOptions::default() });
assert_eq!(skipped.first(), Some(&DATA_SIZE), "the scan opens on a full chunk");
assert!(
skipped.iter().all(|&size| size > QUICK_DATA_SIZE),
"no quick-pass read remains: {skipped:?}"
);
}
#[test]
fn a_supplied_aacs_verdict_replaces_the_stream_head_probe() {
let log = Arc::new(Mutex::new(Vec::new()));
let disc = logged_disc(video_m2ts(&[1]), &log, true);
let progressed = AtomicBool::new(false);
let open = |mode: ScanMode, options: ScanOptions| {
log.lock().unwrap().clear();
let mut observe = |_: ScanProgress<'_>| progressed.store(true, Ordering::Relaxed);
BdRom::open(
&disc,
mode,
options,
None,
ScanObservers::new(&mut observe, &never_cancel()),
)
.expect("the mock disc opens")
};
let probed = open(ScanMode::Metadata, ScanOptions::default());
assert!(!probed.is_aacs_encrypted);
assert!(!log.lock().unwrap().is_empty(), "the probe reads the stream head");
let told =
|verdict| ScanOptions { aacs_encrypted: Some(verdict), ..ScanOptions::default() };
assert!(open(ScanMode::Metadata, told(true)).is_aacs_encrypted);
assert!(log.lock().unwrap().is_empty(), "no probe read with a supplied verdict");
assert!(!open(ScanMode::Metadata, told(false)).is_aacs_encrypted);
assert!(log.lock().unwrap().is_empty(), "no probe read either way");
assert!(!progressed.load(Ordering::Relaxed), "a Metadata open reports no progress");
let coded = open(ScanMode::Codecs, told(false));
assert!(!coded.is_aacs_encrypted);
assert!(!log.lock().unwrap().is_empty(), "the quick pass still reads packets");
assert!(progressed.load(Ordering::Relaxed), "the quick pass reports progress");
}
#[test]
fn a_logged_reader_seeks_through_to_its_source() {
let log = Arc::new(Mutex::new(Vec::new()));
let mut reader =
LoggedReads { inner: Box::new(Cursor::new(vec![1, 2, 3, 4])), log: Arc::clone(&log) };
assert_eq!(reader.seek(SeekFrom::Start(2)).unwrap(), 2);
let mut buf = [0_u8; 2];
assert_eq!(reader.read(&mut buf).unwrap(), 2);
assert_eq!(buf, [3, 4]);
assert_eq!(*log.lock().unwrap(), [2], "the seek is silent; the read is logged");
}
#[test]
fn the_pre_open_heartbeat_names_the_file_being_opened() {
let clip = || clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]);
let disc = tripping_disc_of(
&[
MockClip { stem: "00011", clip: clip(), m2ts: video_m2ts(&[1, 2]), serve: None },
MockClip { stem: "00022", clip: clip(), m2ts: video_m2ts(&[1]), serve: Some(0) },
],
&[("00000", &["00011"]), ("00001", &["00022"])],
);
let mut events: Vec<(String, u64)> = Vec::new();
let mut collect = |p: ScanProgress<'_>| events.push((p.file.to_owned(), p.done));
drop(
BdRom::open_resilient(
&disc,
ScanMode::Full,
ScanOptions::default(),
None,
ScanObservers::new(&mut collect, &never_cancel()),
)
.expect("the resilient scan continues over the failed read"),
);
let first_boundary = u64::try_from(video_m2ts(&[1, 2]).len()).unwrap();
let attributed = events
.iter()
.filter(|(file, done)| file == "00022.M2TS" && *done == first_boundary)
.count();
assert!(
attributed >= 2,
"pre-open and pre-read heartbeats both name the second file: {events:?}"
);
assert_eq!(events.last().map(|(file, _)| file.as_str()), Some("00022.M2TS"));
}
#[test]
fn remove_mvc_placeholder_withdraws_only_an_unconfirmed_synthetic_entry() {
let mvc = || {
let mut stream = TsVideoStream::default();
stream.base.pid = Pid::new(MVC_PID);
stream.base.stream_type = TsStreamType::MvcVideo;
TsStream::Video(stream)
};
let placeholder_playlist = || {
let mut playlist = playlist_with_clips(1, Vec::new());
playlist.streams = BTreeMap::from([(MVC_PID, mvc())]);
playlist.angle_streams = vec![BTreeMap::from([(MVC_PID, mvc())])];
playlist
};
let empty = BTreeMap::new();
let meta = |clip_streams, scanned| ClipMeta {
clip_streams,
scanned,
has_50hz_video: false,
relative_length: 1.0,
length: 10.0,
stream_file_present: true,
interleaved_file_present: true,
};
let unconfirmed = TsStreamFile::new("00000.m2ts");
let mut playlist = placeholder_playlist();
remove_mvc_placeholder(&mut playlist, &meta(&empty, Some(&unconfirmed)));
assert!(playlist.streams.is_empty());
assert!(playlist.angle_streams.iter().all(BTreeMap::is_empty));
let mut confirmed = TsStreamFile::new("00000.m2ts");
confirmed.streams.insert(MVC_PID, mvc());
let mut playlist = placeholder_playlist();
remove_mvc_placeholder(&mut playlist, &meta(&empty, Some(&confirmed)));
assert!(playlist.streams.contains_key(&MVC_PID));
let mut playlist = placeholder_playlist();
remove_mvc_placeholder(&mut playlist, &meta(&empty, None));
assert!(playlist.streams.contains_key(&MVC_PID));
let declared = BTreeMap::from([(MVC_PID, mvc())]);
let mut playlist = placeholder_playlist();
remove_mvc_placeholder(&mut playlist, &meta(&declared, Some(&unconfirmed)));
assert!(playlist.streams.contains_key(&MVC_PID));
}
#[test]
fn resolve_synthesizes_the_dependent_view_only_for_an_interleaved_clip() {
let mut clip_streams = BTreeMap::new();
let mut video = TsVideoStream::default();
video.base.pid = Pid::new(0x1011);
video.base.stream_type = TsStreamType::AvcVideo;
clip_streams.insert(0x1011, TsStream::Video(video));
let meta = |interleaved: bool| ClipMeta {
clip_streams: &clip_streams,
scanned: None,
has_50hz_video: false,
relative_length: 1.0,
length: 10.0,
stream_file_present: true,
interleaved_file_present: interleaved,
};
let mut playlist = playlist_with_clips(1, Vec::new());
resolve_playlist_streams(&mut playlist, &[meta(true)], true);
let placeholder = playlist.streams.get(&MVC_PID).expect("the placeholder is presented");
assert_eq!(placeholder.stream_type(), TsStreamType::MvcVideo);
assert_eq!(placeholder.pid(), Pid::new(MVC_PID));
assert!(playlist.angle_streams.first().is_some_and(|a| a.contains_key(&MVC_PID)));
let mut playlist = playlist_with_clips(0, Vec::new());
resolve_playlist_streams(&mut playlist, &[meta(false)], true);
assert!(!playlist.streams.contains_key(&MVC_PID));
let mut playlist = playlist_with_clips(0, Vec::new());
resolve_playlist_streams(&mut playlist, &[meta(true)], false);
assert!(!playlist.streams.contains_key(&MVC_PID));
}
#[test]
fn backfill_playlist_detail_merges_matching_pids_into_every_map() {
let clip_video = || {
let mut stream = TsVideoStream::default();
stream.base.pid = Pid::new(0x1011);
stream.base.stream_type = TsStreamType::AvcVideo;
TsStream::Video(stream)
};
let mut playlist = playlist_with_clips(1, Vec::new());
playlist.streams = BTreeMap::from([(0x1011, clip_video())]);
playlist.angle_streams = vec![BTreeMap::from([(0x1011, clip_video())])];
let mut scanned_video = TsVideoStream::default();
scanned_video.base.pid = Pid::new(0x1011);
scanned_video.base.stream_type = TsStreamType::AvcVideo;
scanned_video.base.is_vbr = true;
scanned_video.encoding_profile = Some("High Profile 4.1".to_owned());
let mut unpresented = TsVideoStream::default();
unpresented.base.pid = Pid::new(0x1012);
unpresented.base.stream_type = TsStreamType::MvcVideo;
let streams = BTreeMap::from([
(0x1011, TsStream::Video(scanned_video)),
(0x1012, TsStream::Video(unpresented)),
]);
backfill_playlist_detail(&mut playlist, &streams);
let main = playlist.streams.get(&0x1011).unwrap();
assert!(main.base().is_vbr, "the demux-read switch is back-filled");
assert_eq!(
as_video(main).and_then(|v| v.encoding_profile.as_deref()),
Some("High Profile 4.1")
);
let angle = playlist.angle_streams.first().and_then(|a| a.get(&0x1011)).unwrap();
assert!(angle.base().is_vbr, "angle copies are back-filled too");
assert!(!playlist.streams.contains_key(&0x1012));
}
#[test]
fn open_rejects_a_folder_without_bdmv() {
let disc = TempDisc::build(&["random"], &[]);
assert_eq!(disc.open().unwrap_err().to_string(), "unable to locate BD structure");
}
#[test]
fn open_rejects_bdmv_missing_clipinf_or_playlist() {
let disc = TempDisc::build(&["BDMV/PLAYLIST"], &[]);
assert_eq!(disc.open().unwrap_err().to_string(), "unable to locate BD structure");
}
fn open_at(disc: &TempDisc, rel: &str) -> Result<BdRom, BdError> {
open(&FsDir::new(disc.root.join(rel)), ScanMode::Metadata)
}
#[test]
fn open_accepts_the_bdmv_directory_itself() {
let disc = TempDisc::build(&["BDMV/PLAYLIST", "BDMV/CLIPINF"], &[]);
let bd = open_at(&disc, "BDMV").expect("scan from the BDMV directory");
assert!(bd.volume_label.starts_with("bdinfo-rs-disc-"));
}
#[test]
fn open_accepts_a_directory_inside_bdmv() {
let disc = TempDisc::build(&["BDMV/PLAYLIST", "BDMV/CLIPINF"], &[]);
let bd = open_at(&disc, "BDMV/PLAYLIST").expect("scan from inside BDMV");
assert!(bd.volume_label.starts_with("bdinfo-rs-disc-"));
}
#[test]
fn open_scans_a_disc_root_itself_named_bdmv() {
let disc = TempDisc::build(&["BDMV/BDMV/PLAYLIST", "BDMV/BDMV/CLIPINF"], &[]);
let bd = open_at(&disc, "BDMV").expect("scan a disc root named BDMV");
assert_eq!(bd.volume_label, "BDMV");
}
#[test]
fn a_stray_bdmv_ancestor_does_not_break_the_scan() {
let disc = TempDisc::build(&["BDMV/disc/BDMV/PLAYLIST", "BDMV/disc/BDMV/CLIPINF"], &[]);
let bd = open_at(&disc, "BDMV/disc").expect("scan under a stray BDMV ancestor");
assert_eq!(bd.volume_label, "disc");
}
#[test]
fn a_stray_bdmv_ancestor_with_only_clipinf_does_not_win() {
let disc = TempDisc::build(
&["BDMV/CLIPINF", "BDMV/disc/BDMV/PLAYLIST", "BDMV/disc/BDMV/CLIPINF"],
&[],
);
let bd = open_at(&disc, "BDMV/disc").expect("scan under a CLIPINF-only stray BDMV");
assert_eq!(bd.volume_label, "disc");
}
#[test]
fn a_stray_bdmv_ancestor_with_only_playlist_does_not_win() {
let disc = TempDisc::build(
&["BDMV/PLAYLIST", "BDMV/disc/BDMV/PLAYLIST", "BDMV/disc/BDMV/CLIPINF"],
&[],
);
let bd = open_at(&disc, "BDMV/disc").expect("scan under a PLAYLIST-only stray BDMV");
assert_eq!(bd.volume_label, "disc");
}
#[derive(Clone)]
struct WalkDir {
name: &'static str,
cyclic: bool,
fail_listing: bool,
}
impl BdDir for WalkDir {
fn name(&self) -> &str {
self.name
}
fn full_name(&self) -> &str {
self.name
}
fn parent(&self) -> Option<Box<dyn BdDir>> {
if self.cyclic {
let cycle: Box<dyn BdDir> = Box::new(self.clone());
Some(cycle)
} else {
None
}
}
fn get_files_pattern_option(
&self,
_pattern: &str,
_option: SearchOption,
) -> io::Result<Vec<Box<dyn BdFile>>> {
Ok(Vec::new())
}
fn get_directories(&self) -> io::Result<Vec<Box<dyn BdDir>>> {
if self.fail_listing { Err(io::Error::other("listing failed")) } else { Ok(Vec::new()) }
}
}
#[test]
fn walked_disc_root_survives_a_cyclic_parent_chain() {
let dir = WalkDir { name: "BDMV", cyclic: true, fail_listing: false };
assert!(walked_disc_root(&dir).is_none());
assert_eq!(
open(&dir, ScanMode::Metadata).unwrap_err().to_string(),
"unable to locate BD structure"
);
}
#[test]
fn walk_dir_fake_satisfies_the_full_seam() {
let dir = WalkDir { name: "BDMV", cyclic: false, fail_listing: false };
assert_eq!(dir.full_name(), "BDMV");
assert!(dir.get_files().expect("files").is_empty());
assert!(dir.get_files_pattern("*").expect("pattern files").is_empty());
assert!(
dir.get_files_pattern_option("*", SearchOption::TopDirectoryOnly)
.expect("optioned files")
.is_empty()
);
}
#[test]
fn walked_disc_root_treats_an_unreadable_candidate_as_not_scannable() {
let dir = WalkDir { name: "BDMV", cyclic: false, fail_listing: true };
assert!(walked_disc_root(&dir).is_none());
}
#[test]
fn open_reports_a_playlist_referencing_a_missing_clip_file() {
let play = mpls("00000", 0, 4_500_000, &[]);
let disc = TempDisc::build(
&["BDMV/CLIPINF"], &[("BDMV/PLAYLIST/00000.mpls", play)],
);
assert_eq!(
disc.open().unwrap_err().to_string(),
"referenced missing clip file: 00000.CLPI"
);
}
#[test]
fn open_propagates_a_malformed_clip_file() {
let disc = TempDisc::build(
&["BDMV/PLAYLIST"],
&[("BDMV/CLIPINF/00000.clpi", b"XXXX0100junk".to_vec())],
);
assert_eq!(disc.open().unwrap_err().to_string(), "unknown file type: XXXX0100");
}
#[test]
fn open_propagates_a_malformed_playlist() {
let disc = TempDisc::build(
&["BDMV/CLIPINF"],
&[("BDMV/PLAYLIST/00000.mpls", b"XXXXjunk".to_vec())],
);
assert_eq!(disc.open().unwrap_err().to_string(), "unknown file type: XXXXjunk");
}
#[test]
fn read_file_surfaces_io_errors() {
let disc = TempDisc::build(&[], &[("gone.bin", vec![1, 2, 3])]);
let file = FsFile::from_full_name(disc.root.join("gone.bin")).expect("handle");
std::fs::remove_file(disc.root.join("gone.bin")).expect("remove");
assert!(read_file(&file).is_err()); }
#[derive(Clone)]
struct Trip {
count: Arc<AtomicUsize>,
fail_at: usize,
always: bool,
}
impl Trip {
fn new(fail_at: usize) -> Self {
Self { count: Arc::new(AtomicUsize::new(0)), fail_at, always: false }
}
fn always() -> Self {
Self { count: Arc::new(AtomicUsize::new(0)), fail_at: usize::MAX, always: true }
}
fn tick(&self) -> io::Result<()> {
if self.count.fetch_add(1, Ordering::Relaxed) == self.fail_at || self.always {
Err(io::Error::other("injected io failure"))
} else {
Ok(())
}
}
fn used(&self) -> usize {
self.count.load(Ordering::Relaxed)
}
}
#[derive(Clone)]
struct MockFile {
name: String,
extension: String,
bytes: Vec<u8>,
trip: Trip,
fail_read_at: Option<usize>,
reads: Option<Arc<Mutex<Vec<usize>>>>,
}
impl BdFile for MockFile {
fn name(&self) -> &str {
&self.name
}
fn full_name(&self) -> &str {
&self.name
}
fn extension(&self) -> &str {
&self.extension
}
fn length(&self) -> u64 {
u64::try_from(self.bytes.len()).unwrap()
}
fn is_dir(&self) -> bool {
false
}
fn open_read(&self) -> io::Result<Box<dyn ReadSeek>> {
self.trip.tick()?;
let inner = self.fail_read_at.map_or_else(
|| -> Box<dyn ReadSeek> { Box::new(Cursor::new(self.bytes.clone())) },
|serve| {
Box::new(ServeThenFail {
bytes: Cursor::new(self.bytes.clone()),
remaining: serve,
})
},
);
Ok(match &self.reads {
Some(log) => Box::new(LoggedReads { inner, log: Arc::clone(log) }),
None => inner,
})
}
fn open_text(&self) -> io::Result<Box<dyn BufRead>> {
Ok(Box::new(BufReader::new(Cursor::new(self.bytes.clone()))))
}
}
struct LoggedReads {
inner: Box<dyn ReadSeek>,
log: Arc<Mutex<Vec<usize>>>,
}
impl Read for LoggedReads {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.log.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(buf.len());
self.inner.read(buf)
}
}
impl Seek for LoggedReads {
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
self.inner.seek(pos)
}
}
struct ServeThenFail {
bytes: Cursor<Vec<u8>>,
remaining: usize,
}
impl Read for ServeThenFail {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.remaining == 0 {
return Err(io::Error::other("read failed"));
}
let cap = self.remaining.min(buf.len());
let (dst, _) = buf.split_at_mut(cap);
let served = self.bytes.read(dst).unwrap_or(0);
self.remaining = self.remaining.saturating_sub(served);
Ok(served)
}
}
impl Seek for ServeThenFail {
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
self.bytes.seek(pos)
}
}
fn collect_matching(dir: &MockDir, pattern: &str, out: &mut Vec<Box<dyn BdFile>>) {
for file in &dir.files {
if file.name.eq_ignore_ascii_case(pattern) {
out.push(Box::new(file.clone()));
}
}
for sub in &dir.dirs {
collect_matching(sub, pattern, out);
}
}
#[derive(Clone)]
struct MockDir {
name: String,
dirs: Vec<Self>,
files: Vec<MockFile>,
trip: Trip,
}
impl BdDir for MockDir {
fn name(&self) -> &str {
&self.name
}
fn full_name(&self) -> &str {
&self.name
}
fn parent(&self) -> Option<Box<dyn BdDir>> {
None
}
fn get_files(&self) -> io::Result<Vec<Box<dyn BdFile>>> {
self.trip.tick()?;
Ok(self.files.iter().cloned().map(|f| -> Box<dyn BdFile> { Box::new(f) }).collect())
}
fn get_files_pattern(&self, _pattern: &str) -> io::Result<Vec<Box<dyn BdFile>>> {
self.get_files()
}
fn get_files_pattern_option(
&self,
pattern: &str,
_option: SearchOption,
) -> io::Result<Vec<Box<dyn BdFile>>> {
self.trip.tick()?;
let mut out: Vec<Box<dyn BdFile>> = Vec::new();
collect_matching(self, pattern, &mut out);
Ok(out)
}
fn get_directories(&self) -> io::Result<Vec<Box<dyn BdDir>>> {
self.trip.tick()?;
Ok(self.dirs.iter().cloned().map(|d| -> Box<dyn BdDir> { Box::new(d) }).collect())
}
}
fn mock_disc(trip: &Trip) -> MockDir {
let file = |name: &str, bytes: Vec<u8>| MockFile {
name: name.to_owned(),
extension: extension_of_name(name),
bytes,
trip: trip.clone(),
fail_read_at: None,
reads: None,
};
let dir = |name: &str, dirs: Vec<MockDir>, files: Vec<MockFile>| MockDir {
name: name.to_owned(),
dirs,
files,
trip: trip.clone(),
};
let clip0 =
clpi(&[(0x1011, 0x1B, [0x63, 0x30, 0, 0]), (0x1100, 0x81, [0x61, b'e', b'n', b'g'])]);
let clip1 = clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]);
dir(
"disc",
vec![
dir(
"BDMV",
vec![
dir(
"CLIPINF",
vec![],
vec![file("00000.clpi", clip0), file("00001.clpi", clip1)],
),
dir(
"PLAYLIST",
vec![],
vec![
file(
"00000.mpls",
mpls("00000", 2_700_000, 4_500_000, &[(1, 0, 2_700_000)]),
),
file("00001.mpls", mpls("00001", 0, 4_500_000, &[])),
],
),
dir(
"STREAM",
vec![dir("SSIF", vec![], vec![file("00000.ssif", vec![0; 10])])],
vec![file("00000.m2ts", vec![0; 100]), file("00001.m2ts", vec![0; 50])],
),
dir(
"META",
vec![dir(
"DL",
vec![],
vec![
file("thumb.jpg", vec![0xFF]),
file("bdmt_eng.xml", title_xml("Movie")),
],
)],
vec![],
),
dir("BDJO", vec![], vec![file("00000.bdjo", vec![b'x'])]),
],
vec![file("index.bdmv", b"INDX0300".to_vec())],
),
dir("SNP", vec![], vec![file("clip.mnv", vec![b'x'])]),
dir("BDSVM", vec![], vec![]),
],
vec![],
)
}
#[test]
fn directory_size_keeps_an_uppercase_ssif_out_of_the_disc_size() {
let trip = Trip::new(usize::MAX);
let file = |name: &str, len: usize| MockFile {
name: name.to_owned(),
extension: extension_of_name(name),
bytes: vec![0; len],
trip: trip.clone(),
fail_read_at: None,
reads: None,
};
let dir = MockDir {
name: "SSIF".to_owned(),
dirs: Vec::new(),
files: vec![file("00000.SSIF", 10), file("00001.ssif", 20), file("00000.m2ts", 5)],
trip,
};
assert_eq!(directory_size(&dir).expect("mock listing is infallible"), (5, 30));
}
#[test]
fn open_propagates_io_errors_at_every_step() {
let probe = Trip::new(usize::MAX);
let bd = open(&mock_disc(&probe), ScanMode::Full).expect("mock disc scans clean");
assert_eq!(bd.playlists.len(), 2); assert!(bd.is_uhd && bd.is_3d && bd.is_bd_plus && bd.is_bd_java && bd.is_psp);
let total = probe.used();
assert!(total > 12, "expected many io ops, got {total}");
let mut fail_open = 0_usize;
for fail_at in 0..total {
let trip = Trip::new(fail_at);
if let Ok(scanned) = open(&mock_disc(&trip), ScanMode::Full) {
assert_eq!(scanned, bd, "io failure at op {fail_at} altered the scan");
fail_open = fail_open.saturating_add(1);
}
}
assert_eq!(fail_open, 1);
}
#[test]
fn scan_stream_files_keeps_or_drops_a_partial_file_per_mode_and_switch() {
let trip = Trip::new(usize::MAX);
let m2ts = MockFile {
name: "00000.m2ts".to_owned(),
extension: ".m2ts".to_owned(),
bytes: vec![0_u8; 200],
trip: trip.clone(),
fail_read_at: Some(0),
reads: None,
};
let stream_dir =
MockDir { name: "STREAM".to_owned(), dirs: Vec::new(), files: vec![m2ts], trip };
let mut playlists =
vec![TsPlaylistFile::scan("00000.mpls", &mpls("00000", 0, 4_500_000, &[])).unwrap()];
let run = |playlists: &mut Vec<TsPlaylistFile>, sink: &mut Sink<'_>, keep_partial: bool| {
let mut noop = |_: ScanProgress<'_>| {};
let mut progress = Progress {
callback: &mut noop,
measured: None,
done: 0,
total: 200,
cancel: &AtomicBool::new(false),
};
let scanned = scan_stream_files(
Some(&stream_dir),
None,
playlists,
None,
&mut progress,
sink,
keep_partial,
false,
false,
);
assert_eq!(progress.done, 200, "the failed file's bytes are snapped past");
scanned
};
let strict = run(&mut playlists, &mut Sink { errors: None }, true);
assert!(strict.is_err());
let mut errors = Vec::new();
let kept = run(&mut playlists, &mut Sink { errors: Some(&mut errors) }, true)
.expect("resilient scan continues");
let partial = kept.get("00000.M2TS").expect("the partial file is kept");
assert!(partial.streams.is_empty(), "no packet demuxed before the failing read");
assert_eq!(errors.len(), 1);
let recorded = errors.first().expect("one recorded error");
assert_eq!(recorded.stage, ScanStage::StreamFile);
assert_eq!(recorded.file, "00000.m2ts");
let mut errors = Vec::new();
let dropped = run(&mut playlists, &mut Sink { errors: Some(&mut errors) }, false)
.expect("resilient scan continues");
assert!(dropped.is_empty());
assert_eq!(errors.len(), 1);
}
fn two_chunk_m2ts(
streams: &[(u8, u16)],
head_seconds: &[u64],
tail_seconds: &[u64],
) -> Vec<u8> {
use crate::bdrom::m2ts::DATA_SIZE;
let frame = |s: u64| {
let ticks = s.wrapping_mul(90_000);
packet(0x1011, true, &pes_dts(0xE0, ticks, ticks, &sps_payload()))
};
let mut m2ts = packet(0, true, &pat_payload(0x0100));
m2ts.extend(packet(0x0100, true, &pmt_payload(streams)));
for &s in head_seconds {
m2ts.extend(frame(s));
}
while m2ts.len() <= DATA_SIZE {
m2ts.extend(packet(0x1FFF, false, &[0_u8; 184]));
}
for &s in tail_seconds {
m2ts.extend(frame(s));
}
m2ts
}
struct MockClip {
stem: &'static str,
clip: Vec<u8>,
m2ts: Vec<u8>,
serve: Option<usize>,
}
fn tripping_disc_of(clips: &[MockClip], playlists: &[(&str, &[&str])]) -> MockDir {
let trip = Trip::new(usize::MAX);
let file = |name: &str, bytes: Vec<u8>, fail_read_at: Option<usize>| MockFile {
name: name.to_owned(),
extension: extension_of_name(name),
bytes,
trip: trip.clone(),
fail_read_at,
reads: None,
};
let dir = |name: &str, dirs: Vec<MockDir>, files: Vec<MockFile>| MockDir {
name: name.to_owned(),
dirs,
files,
trip: trip.clone(),
};
let clip_info: Vec<MockFile> = clips
.iter()
.map(|clip| file(&format!("{}.clpi", clip.stem), clip.clip.clone(), None))
.collect();
let streams: Vec<MockFile> = clips
.iter()
.map(|clip| file(&format!("{}.m2ts", clip.stem), clip.m2ts.clone(), clip.serve))
.collect();
let playlist_files: Vec<MockFile> = playlists
.iter()
.map(|&(stem, items)| {
file(
&format!("{stem}.mpls"),
mpls_items(items, 0, 4_500_000, &[], &[], &[(1, 0, 0), (1, 0, 2_025_000)]),
None,
)
})
.collect();
dir(
"disc",
vec![dir(
"BDMV",
vec![
dir("CLIPINF", vec![], clip_info),
dir("PLAYLIST", vec![], playlist_files),
dir("STREAM", vec![], streams),
],
vec![],
)],
vec![],
)
}
fn tripping_disc_with(clip: Vec<u8>, m2ts: Vec<u8>, serve: Option<usize>) -> MockDir {
tripping_disc_of(&[MockClip { stem: "00000", clip, m2ts, serve }], &[("00000", &["00000"])])
}
fn tripping_disc(m2ts: Vec<u8>, serve: Option<usize>) -> MockDir {
tripping_disc_with(clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]), m2ts, serve)
}
#[test]
fn open_resilient_keeps_a_mid_file_partial_scan_by_default() {
use crate::bdrom::m2ts::DATA_SIZE;
let m2ts = two_chunk_m2ts(&[(0x1B, 0x1011)], &[1, 2, 3], &[46, 47, 48]);
let healthy = open_resilient(&tripping_disc(m2ts.clone(), None), ScanMode::Full)
.expect("healthy scan");
assert!(healthy.errors.is_empty());
let control = healthy.bdrom.playlists.first().unwrap();
assert!(control.chapters.first().unwrap().avg_rate > 0.0);
assert!(control.chapters.get(1).unwrap().avg_rate > 0.0);
let report = open_resilient(&tripping_disc(m2ts, Some(DATA_SIZE)), ScanMode::Full)
.expect("resilient scan continues");
assert_eq!(report.errors.len(), 1);
let recorded = report.errors.first().unwrap();
assert_eq!((recorded.stage, recorded.file.as_str()), (ScanStage::StreamFile, "00000.m2ts"));
let pl = report.bdrom.playlists.first().unwrap();
assert_eq!(pl.chapters.len(), 2);
let first = pl.chapters.first().unwrap();
assert_eq!(first.time_in.to_bits(), 0.0_f64.to_bits());
assert_eq!(first.length.to_bits(), 45.0_f64.to_bits());
assert!(first.avg_rate > 0.0);
let second = pl.chapters.get(1).unwrap();
assert_eq!(second.time_in.to_bits(), 45.0_f64.to_bits());
assert_eq!(second.length.to_bits(), 55.0_f64.to_bits());
assert_eq!(second.avg_rate.to_bits(), 0.0_f64.to_bits());
let clip = pl.clips.first().unwrap();
assert!(clip.file_seconds > 0.0);
let tally = clip.streams.first().expect("the partial file's video tally");
assert!(tally.payload_bytes > 0);
assert!(pl.streams.first().unwrap().description.contains("High Profile 4.1"));
}
#[test]
fn keep_partial_off_drops_the_partial_scan_wholesale() {
use crate::bdrom::m2ts::DATA_SIZE;
let m2ts = two_chunk_m2ts(&[(0x1B, 0x1011)], &[1, 2, 3], &[46, 47, 48]);
let kept = open_resilient(&tripping_disc(m2ts.clone(), Some(DATA_SIZE)), ScanMode::Full)
.expect("resilient scan continues");
let off = BdRom::open_resilient(
&tripping_disc(m2ts, Some(DATA_SIZE)),
ScanMode::Full,
ScanOptions { keep_partial: false, ..ScanOptions::default() },
None,
ScanObservers::new(&mut |_| {}, &AtomicBool::new(false)),
)
.expect("resilient scan continues");
assert_eq!(off.errors.len(), kept.errors.len());
let pl = off.bdrom.playlists.first().unwrap();
let kept_pl = kept.bdrom.playlists.first().unwrap();
assert_eq!(pl.chapters.len(), 2);
assert_eq!(pl.chapters.first().unwrap().avg_rate.to_bits(), 0.0_f64.to_bits());
let clip = pl.clips.first().unwrap();
assert!(!clip.measured, "a dropped file is not in the full-pass map");
assert!(clip.streams.is_empty());
assert_eq!(clip.file_seconds.to_bits(), 0.0_f64.to_bits());
let kept_clip = kept_pl.clips.first().unwrap();
assert!(clip.packet_count > 0);
assert_eq!(clip.packet_count, kept_clip.packet_count);
assert_eq!(clip.payload_bytes, kept_clip.payload_bytes);
assert_eq!(
crate::report::text::render(&off.bdrom, &off.errors),
DAMAGED_OFF_REPORT.replace('\n', "\r\n").replace("<TAB>", "\t")
);
}
#[test]
fn a_file_that_died_partway_is_reported_short_only_while_its_partial_scan_is_kept() {
use crate::bdrom::m2ts::DATA_SIZE;
let m2ts = two_chunk_m2ts(&[(0x1B, 0x1011)], &[1, 2, 3], &[46, 47, 48]);
let kept = open_resilient(&tripping_disc(m2ts.clone(), Some(DATA_SIZE)), ScanMode::Full)
.expect("resilient scan continues");
let short = kept.bdrom.short_stream_files();
assert_eq!(short.len(), 1);
assert_eq!(short.first().unwrap().file(), "00000.M2TS");
assert_eq!(kept.errors.len(), 1);
assert_eq!(
kept.errors.first().unwrap().file.to_ascii_uppercase(),
short.first().unwrap().file()
);
let off = BdRom::open_resilient(
&tripping_disc(m2ts, Some(DATA_SIZE)),
ScanMode::Full,
ScanOptions { keep_partial: false, ..ScanOptions::default() },
None,
ScanObservers::new(&mut |_| {}, &AtomicBool::new(false)),
)
.expect("resilient scan continues");
assert_eq!(off.errors.len(), 1);
let dropped = off
.bdrom
.playlists
.first()
.and_then(|pl| pl.clips.first())
.expect("the dropped file's clip row");
assert!(!dropped.measured);
assert!(off.bdrom.short_stream_files().is_empty());
}
const DAMAGED_OFF_REPORT: &str = r"Disc Label: disc
Disc Size: 263,040 bytes
Protection: AACS
BDInfo: 0.8.0.1
Notes:
BDINFO HOME:
Cinema Squid (old)
http://www.cinemasquid.com/blu-ray/tools/bdinfo
UniqProject GitHub (new)
https://github.com/UniqProject/BDInfo
INCLUDES FORUMS REPORT FOR:
AVS Forum Blu-ray Audio and Video Specifications Thread
http://www.avsforum.com/avs-vb/showthread.php?t=1155731
WARNING: File errors were encountered during scan:
00000.m2ts<TAB>io error: read failed
********************
PLAYLIST: 00000.MPLS
********************
<--- BEGIN FORUMS PASTE --->
[code]
Total Video
Title Codec Length Movie Size Disc Size Bitrate Bitrate Main Audio Track Secondary Audio Track
----- ------ ------- -------------- ---------------- ----------- ----------- ------------------ ---------------------
00000.MPLS AVC 00:01:40 960 263,040 0.00 Mbps 0.00 Mbps
[/code]
[code]
DISC INFO:
Disc Label: disc
Disc Size: 263,040 bytes
Protection: AACS
BDInfo: 0.8.0.1b
PLAYLIST REPORT:
Name: 00000.MPLS
Length: 00:01:40.000 (h:m:s.ms)
Size: 960 bytes
Total Bitrate: 0.00 Mbps
(*) Indicates included stream hidden by this playlist.
VIDEO:
Codec Bitrate Description
--------------- ------------- -----------
* MPEG-4 AVC Video 0 kbps 1080p / 24 fps / 16:9 / High Profile 4.1
FILES:
Name Time In Length Size Total Bitrate
--------------- ------------- ------------- ------------- -------------
00000.M2TS 0:00:00.000 0:01:40.000 960 2 kbps
CHAPTERS:
Number Time In Length Avg Video Rate Max 1-Sec Rate Max 1-Sec Time Max 5-Sec Rate Max 5-Sec Time Max 10Sec Rate Max 10Sec Time Avg Frame Size Max Frame Size Max Frame Time
------ ------------- ------------- -------------- -------------- -------------- -------------- -------------- -------------- -------------- -------------- -------------- --------------
1 0:00:00.000 0:00:45.000 0 kbps 0 kbps 00:00:00.000 0 kbps 00:00:00.000 0 kbps 00:00:00.000 0 bytes 0 bytes 00:00:00.000
2 0:00:45.000 0:00:55.000 0 kbps 0 kbps 00:00:00.000 0 kbps 00:00:00.000 0 kbps 00:00:00.000 0 bytes 0 bytes 00:00:00.000
STREAM DIAGNOSTICS:
File PID Type Codec Language Seconds Bitrate Bytes Packets
---------- ------------- ----- ---------- ------------- -------------- --------------- -------------- -----------
[/code]
<---- END FORUMS PASTE ---->
QUICK SUMMARY:
Disc Label: disc
Disc Size: 263,040 bytes
Protection: AACS
Playlist: 00000.MPLS
Size: 960 bytes
Length: 00:01:40.000
Total Bitrate: 0.00 Mbps
* Video: MPEG-4 AVC Video / 0 kbps / 1080p / 24 fps / 16:9 / High Profile 4.1
";
#[test]
fn a_quick_pass_partial_keeps_its_initialised_codec_detail() {
use crate::bdrom::m2ts::DATA_SIZE;
let clip =
clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0]), (0x1100, 0x81, [0x61, b'e', b'n', b'g'])]);
let m2ts = two_chunk_m2ts(&[(0x1B, 0x1011), (0x81, 0x1100)], &[1, 2, 3], &[46, 47, 48]);
let report = open_resilient(
&tripping_disc_with(clip.clone(), m2ts.clone(), Some(DATA_SIZE)),
ScanMode::Full,
)
.expect("resilient scan continues");
assert_eq!(report.errors.len(), 2);
let pl = report.bdrom.playlists.first().unwrap();
assert!(pl.streams.first().unwrap().description.contains("High Profile 4.1"));
assert!(pl.chapters.first().unwrap().avg_rate > 0.0);
let off = BdRom::open_resilient(
&tripping_disc_with(clip, m2ts, Some(DATA_SIZE)),
ScanMode::Full,
ScanOptions { keep_partial: false, ..ScanOptions::default() },
None,
ScanObservers::new(&mut |_| {}, &AtomicBool::new(false)),
)
.expect("resilient scan continues");
assert_eq!(off.errors.len(), 2);
let off_pl = off.bdrom.playlists.first().unwrap();
assert!(!off_pl.streams.first().unwrap().description.contains("High Profile 4.1"));
}
#[test]
fn open_resilient_records_one_warning_per_pass_on_head_damage() {
let report = open_resilient(&tripping_disc(vec![0_u8; 400], Some(0)), ScanMode::Full)
.expect("resilient scan continues");
assert_eq!(report.errors.len(), 2);
for recorded in &report.errors {
assert_eq!(
(recorded.stage, recorded.file.as_str()),
(ScanStage::StreamFile, "00000.m2ts")
);
}
let pl = report.bdrom.playlists.first().unwrap();
assert_eq!(pl.chapters.len(), 2);
assert_eq!(pl.chapters.first().unwrap().avg_rate.to_bits(), 0.0_f64.to_bits());
assert!(pl.clips.first().unwrap().streams.is_empty());
}
#[test]
fn open_strict_aborts_wholesale_on_a_stream_read_error() {
assert!(open(&tripping_disc(vec![0_u8; 400], Some(0)), ScanMode::Full).is_err());
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(24))]
#[test]
fn a_tripped_reader_yields_a_total_scan_with_one_chapter_row_per_mark(
serve in 0_usize..crate::bdrom::m2ts::DATA_SIZE.saturating_add(10_000),
) {
let m2ts = two_chunk_m2ts(&[(0x1B, 0x1011)], &[1, 2, 3], &[46, 47]);
let report =
open_resilient(&tripping_disc(m2ts, Some(serve)), ScanMode::Full)
.expect("the resilient scan is total");
let pl = report.bdrom.playlists.first().unwrap();
prop_assert_eq!(pl.chapters.len(), 2);
let first = pl.chapters.first().unwrap();
prop_assert_eq!(first.time_in.to_bits(), 0.0_f64.to_bits());
prop_assert_eq!(first.length.to_bits(), 45.0_f64.to_bits());
let second = pl.chapters.get(1).unwrap();
prop_assert_eq!(second.time_in.to_bits(), 45.0_f64.to_bits());
prop_assert_eq!(second.length.to_bits(), 55.0_f64.to_bits());
for row in &pl.chapters {
prop_assert!(row.avg_rate.is_finite() && row.avg_rate >= 0.0);
prop_assert!(row.max_1sec_rate.is_finite() && row.max_1sec_rate >= 0.0);
}
}
}
#[test]
fn a_narrowed_selection_leaves_the_defective_playlists_rows_unchanged() {
use crate::bdrom::m2ts::DATA_SIZE;
let video = || clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]);
let m2ts = || two_chunk_m2ts(&[(0x1B, 0x1011)], &[1, 2, 3], &[46, 47, 48]);
let disc = tripping_disc_of(
&[
MockClip { stem: "00000", clip: video(), m2ts: m2ts(), serve: Some(DATA_SIZE) },
MockClip { stem: "00001", clip: video(), m2ts: m2ts(), serve: None },
MockClip { stem: "00002", clip: video(), m2ts: m2ts(), serve: None },
],
&[("00000", &["00000"]), ("00001", &["00001"]), ("00002", &["00002"])],
);
let scan = |selection: Option<&BTreeSet<String>>| {
BdRom::open_resilient(
&disc,
ScanMode::Full,
ScanOptions::default(),
selection,
ScanObservers::new(&mut |_| {}, &never_cancel()),
)
.expect("resilient scan continues")
};
let selection = |names: &[&str]| -> BTreeSet<String> {
names.iter().map(|name| (*name).to_owned()).collect()
};
let playlist = |report: &ScanReport, name: &str| -> PlaylistSummary {
report
.bdrom
.playlists
.iter()
.find(|summary| summary.name == name)
.expect("every playlist is summarised")
.clone()
};
let whole = scan(None);
let every = scan(Some(&selection(&["00000.M2TS", "00001.M2TS", "00002.M2TS"])));
let subset = scan(Some(&selection(&["00000.M2TS", "00001.M2TS"])));
let defective = playlist(&whole, "00000.MPLS");
assert!(defective.chapters.first().unwrap().avg_rate > 0.0);
assert_eq!(defective.chapters.get(1).unwrap().avg_rate.to_bits(), 0.0_f64.to_bits());
assert!(defective.clips.first().unwrap().payload_bytes > 0);
assert_eq!(playlist(&every, "00000.MPLS"), defective);
assert_eq!(playlist(&subset, "00000.MPLS"), defective);
for report in [&whole, &every, &subset] {
assert_eq!(report.errors.len(), 1);
let recorded = report.errors.first().unwrap();
assert_eq!(
(recorded.stage, recorded.file.as_str()),
(ScanStage::StreamFile, "00000.m2ts")
);
}
let third = playlist(&whole, "00002.MPLS");
assert!(third.chapters.first().unwrap().avg_rate > 0.0);
assert_eq!(playlist(&every, "00002.MPLS"), third);
let deselected = playlist(&subset, "00002.MPLS");
assert_eq!(deselected.chapters.first().unwrap().avg_rate.to_bits(), 0.0_f64.to_bits());
assert!(deselected.clips.first().unwrap().streams.is_empty());
assert_eq!(playlist(&subset, "00001.MPLS"), playlist(&whole, "00001.MPLS"));
}
#[test]
fn an_earlier_clip_without_diagnostics_shifts_the_chapter_rows_past_the_boundary() {
let video = || clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]);
let frames = || two_chunk_m2ts(&[(0x1B, 0x1011)], &[1, 2, 3], &[]);
let rows = |first: Option<usize>, second: Option<usize>| {
let disc = tripping_disc_of(
&[
MockClip { stem: "00000", clip: video(), m2ts: frames(), serve: first },
MockClip { stem: "00001", clip: video(), m2ts: frames(), serve: second },
],
&[("00000", &["00000", "00001"])],
);
open_resilient(&disc, ScanMode::Full)
.expect("resilient scan continues")
.bdrom
.playlists
.first()
.unwrap()
.chapters
.clone()
};
let shifted = rows(Some(0), None);
assert_eq!(shifted.len(), 2);
let first = shifted.first().unwrap();
assert!(first.avg_rate > 0.0);
assert_eq!(first.max_frame_size.to_bits(), 100.0_f64.to_bits());
assert_eq!(first.max_frame_time.to_bits(), 102.0_f64.to_bits());
let second = shifted.get(1).unwrap();
assert!(second.avg_rate > 0.0);
assert_eq!(second.max_frame_time.to_bits(), 103.0_f64.to_bits());
let collapsed = rows(None, Some(0));
let first = collapsed.first().unwrap();
assert!(
first.avg_rate > shifted.first().unwrap().avg_rate,
"chapter 1 absorbs both entries here, only the shifted one there"
);
assert_eq!(first.max_frame_time.to_bits(), 2.0_f64.to_bits());
let second = collapsed.get(1).unwrap();
assert_eq!(second.avg_rate.to_bits(), 0.0_f64.to_bits());
assert_eq!(second.max_frame_size.to_bits(), 0.0_f64.to_bits());
}
const DAMAGED_FAULT_AT: usize = 5_242_880;
fn damaged_disc_golden() -> String {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../bdinfo-rs/tests/fixtures/golden/damaged-multiplaylist.txt");
std::fs::read_to_string(path).expect("read the damaged-disc golden")
}
fn normalize_warning_reasons(report: &str) -> String {
report
.split("\r\n")
.map(|line| {
line.split_once('\t')
.map_or_else(|| line.to_owned(), |(file, _)| format!("{file}\t<reason>"))
})
.collect::<Vec<String>>()
.join("\r\n")
}
fn fixture_tree(name: &str, faults: &[(&str, usize)]) -> MockDir {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../bdinfo-rs/tests/fixtures")
.join(name);
load_fixture_dir(&root, name, faults)
}
fn load_fixture_dir(path: &std::path::Path, name: &str, faults: &[(&str, usize)]) -> MockDir {
let trip = Trip::new(usize::MAX);
let mut entries: Vec<PathBuf> = std::fs::read_dir(path)
.expect("list the fixture directory")
.map(|entry| entry.expect("read the directory entry").path())
.collect();
entries.sort();
let mut dirs = Vec::new();
let mut files = Vec::new();
for entry in entries {
let entry_name = entry
.file_name()
.expect("a fixture entry has a name")
.to_string_lossy()
.into_owned();
if entry.is_dir() {
dirs.push(load_fixture_dir(&entry, &entry_name, faults));
} else {
files.push(MockFile {
extension: extension_of_name(&entry_name),
bytes: std::fs::read(&entry).expect("read the fixture file"),
trip: trip.clone(),
fail_read_at: faults
.iter()
.find(|(file, _)| file.eq_ignore_ascii_case(&entry_name))
.map(|&(_, serve)| serve),
reads: None,
name: entry_name,
});
}
}
MockDir { name: name.to_owned(), dirs, files, trip }
}
#[test]
fn the_damaged_disc_report_matches_the_golden_the_browser_pins_too() {
let disc = fixture_tree("MultiPlaylist", &[("00011.m2ts", DAMAGED_FAULT_AT)]);
let report = open_resilient(&disc, ScanMode::Full).expect("resilient scan continues");
let rendered = crate::report::text::render(&report.bdrom, &report.errors);
assert_eq!(report.errors.len(), 1);
let recorded = report.errors.first().expect("the recorded failure");
assert_eq!((recorded.stage, recorded.file.as_str()), (ScanStage::StreamFile, "00011.m2ts"));
assert_eq!(normalize_warning_reasons(&rendered), damaged_disc_golden());
}
#[test]
fn scan_total_budgets_selected_sources_once_preferring_the_ssif() {
let stream_files = BTreeMap::from([
("00000.M2TS".to_owned(), 1000_u64),
("00001.M2TS".to_owned(), 500_u64),
]);
let interleaved_files = BTreeMap::from([("00000.SSIF".to_owned(), 4000_u64)]);
assert_eq!(scan_total(&stream_files, &interleaved_files, None), 4000 + 500);
let only_one = BTreeSet::from(["00001.M2TS".to_owned()]);
assert_eq!(scan_total(&stream_files, &interleaved_files, Some(&only_one)), 500);
assert_eq!(scan_total(&stream_files, &interleaved_files, Some(&BTreeSet::new())), 0);
assert_eq!(scan_total(&stream_files, &BTreeMap::new(), None), 1500);
}
#[test]
fn progress_advances_clamped_and_snaps_at_file_boundaries() {
let mut events: Vec<(String, u64, u64)> = Vec::new();
{
let mut callback =
|p: ScanProgress<'_>| events.push((p.file.to_owned(), p.done, p.total));
let mut progress = Progress {
callback: &mut callback,
measured: None,
done: 0,
total: 100,
cancel: &AtomicBool::new(false),
};
progress.advance("A.M2TS", 30);
progress.heartbeat("A.M2TS");
progress.advance("A.M2TS", 90);
progress.finish_file("A.M2TS", 50);
}
assert_eq!(
events,
[
("A.M2TS".to_owned(), 30, 100),
("A.M2TS".to_owned(), 30, 100),
("A.M2TS".to_owned(), 100, 100),
("A.M2TS".to_owned(), 100, 100),
]
);
events.clear();
{
let mut callback =
|p: ScanProgress<'_>| events.push((p.file.to_owned(), p.done, p.total));
let mut progress = Progress {
callback: &mut callback,
measured: None,
done: 0,
total: 100,
cancel: &AtomicBool::new(false),
};
progress.advance("B.M2TS", 10);
progress.finish_file("B.M2TS", 60);
}
assert_eq!(events, [("B.M2TS".to_owned(), 10, 100), ("B.M2TS".to_owned(), 60, 100)]);
}
#[test]
fn a_counting_reader_heartbeats_before_every_read_and_advances_after() {
let mut events: Vec<(String, u64, u64)> = Vec::new();
{
let mut callback =
|p: ScanProgress<'_>| events.push((p.file.to_owned(), p.done, p.total));
let mut progress = Progress {
callback: &mut callback,
measured: None,
done: 0,
total: 5,
cancel: &AtomicBool::new(false),
};
let mut reader = CountingReader {
inner: Box::new(Cursor::new(vec![0_u8; 5])),
name: "A.M2TS".to_owned(),
progress: &mut progress,
};
let mut sink = [0_u8; 8];
assert_eq!(reader.read(&mut sink).expect("serving read"), 5);
assert_eq!(reader.read(&mut sink).expect("EOF read"), 0);
}
assert_eq!(
events,
[
("A.M2TS".to_owned(), 0, 5),
("A.M2TS".to_owned(), 5, 5),
("A.M2TS".to_owned(), 5, 5),
("A.M2TS".to_owned(), 5, 5),
]
);
}
fn never_cancel() -> AtomicBool {
AtomicBool::new(false)
}
#[test]
fn an_observed_open_reports_progress_and_narrows_the_scan_to_selected_files() {
let disc = TempDisc::build(
&[],
&[
("BDMV/PLAYLIST/00000.mpls", mpls("00000", 2_700_000, 4_500_000, &[])),
("BDMV/CLIPINF/00000.clpi", clpi(&[(0x1011, 0x1B, [0x63, 0x30, 0, 0])])),
("BDMV/STREAM/00000.m2ts", vec![0_u8; 1000]),
("BDMV/PLAYLIST/00001.mpls", mpls("00001", 2_700_000, 4_500_000, &[])),
("BDMV/CLIPINF/00001.clpi", clpi(&[(0x1011, 0x1B, [0x63, 0x30, 0, 0])])),
("BDMV/STREAM/00001.m2ts", vec![0_u8; 500]),
],
);
let root = FsDir::new(disc.root.clone());
let mut events: Vec<(String, u64, u64)> = Vec::new();
let mut collect = |p: ScanProgress<'_>| events.push((p.file.to_owned(), p.done, p.total));
let bd = BdRom::open(
&root,
ScanMode::Full,
ScanOptions::default(),
None,
ScanObservers::new(&mut collect, &never_cancel()),
)
.expect("scan with progress");
assert_eq!(bd.playlists.len(), 2);
assert!(!events.is_empty());
assert_eq!(events.first().map(|(_, done, _)| *done), Some(0));
assert!(events.iter().all(|(_, _, total)| *total == 1000 + 500));
assert!(events.windows(2).all(|pair| {
pair.first().is_none_or(|(_, a, _)| pair.get(1).is_none_or(|(_, b, _)| a <= b))
}));
assert_eq!(events.last().map(|(_, done, _)| *done), Some(1500));
assert!(events.iter().any(|(file, _, _)| file == "00000.M2TS"));
assert!(events.iter().any(|(file, _, _)| file == "00001.M2TS"));
let selected = BTreeSet::from(["00000.M2TS".to_owned()]);
let mut events: Vec<(String, u64, u64)> = Vec::new();
let mut collect = |p: ScanProgress<'_>| events.push((p.file.to_owned(), p.done, p.total));
let bd = BdRom::open(
&root,
ScanMode::Full,
ScanOptions::default(),
Some(&selected),
ScanObservers::new(&mut collect, &never_cancel()),
)
.expect("scan the selection");
assert_eq!(bd.playlists.len(), 2);
assert!(events.iter().all(|(file, _, total)| file == "00000.M2TS" && *total == 1000));
assert_eq!(events.last().map(|(_, done, _)| *done), Some(1000));
let mut last_total = 0;
let mut observe = |p: ScanProgress<'_>| last_total = p.total;
let report = BdRom::open_resilient(
&root,
ScanMode::Full,
ScanOptions::default(),
Some(&selected),
ScanObservers::new(&mut observe, &never_cancel()),
)
.expect("resilient scan with progress");
assert!(report.errors.is_empty());
assert_eq!(last_total, 1000);
assert_eq!(report.bdrom, bd);
let mut fired = false;
let mut observe = |_: ScanProgress<'_>| fired = true;
drop(
BdRom::open(
&root,
ScanMode::Metadata,
ScanOptions::default(),
None,
ScanObservers::new(&mut observe, &never_cancel()),
)
.expect("metadata-only scan"),
);
assert!(!fired);
}
#[test]
fn a_codecs_open_reports_the_quick_pass_a_full_open_leaves_silent() {
use crate::bdrom::m2ts::DATA_SIZE;
let m2ts = two_chunk_m2ts(&[(0x1B, 0x1011)], &[1, 2, 3], &[46, 47, 48]);
let size = u64::try_from(m2ts.len()).expect("the fixture is far under u64::MAX");
let chunk = u64::try_from(DATA_SIZE).expect("the read chunk is far under u64::MAX");
let disc = tripping_disc(m2ts, None);
let trail = |mode| {
let mut events: Vec<(String, u64, u64)> = Vec::new();
let mut collect =
|p: ScanProgress<'_>| events.push((p.file.to_owned(), p.done, p.total));
drop(
BdRom::open(
&disc,
mode,
ScanOptions::default(),
None,
ScanObservers::new(&mut collect, &never_cancel()),
)
.expect("the healthy mock disc scans"),
);
events
};
let climbs = |events: &[(String, u64, u64)]| {
events.windows(2).all(|pair| {
pair.first().is_none_or(|(_, a, _)| pair.get(1).is_none_or(|(_, b, _)| a <= b))
})
};
let codecs = trail(ScanMode::Codecs);
assert!(codecs.iter().all(|(file, _, total)| file == "00000.M2TS" && *total == size));
assert_eq!(codecs.first().map(|(_, done, _)| *done), Some(0));
assert!(climbs(&codecs), "the count never goes backwards");
let last_read = codecs.iter().rev().nth(1).map(|(_, done, _)| *done);
assert!(last_read.is_some_and(|done| done < chunk), "the quick pass exits early");
assert_eq!(codecs.last().map(|(_, done, _)| *done), Some(size));
let full = trail(ScanMode::Full);
assert!(full.iter().all(|(file, _, total)| file == "00000.M2TS" && *total == size));
assert_eq!(full.first().map(|(_, done, _)| *done), Some(0));
assert_eq!(full.get(1).map(|(_, done, _)| *done), Some(0));
assert_eq!(full.get(2).map(|(_, done, _)| *done), Some(chunk));
assert!(climbs(&full), "the count never restarts");
assert_eq!(full.last().map(|(_, done, _)| *done), Some(size));
}
fn video_m2ts(seconds: &[u64]) -> Vec<u8> {
let mut m2ts = packet(0, true, &pat_payload(0x0100));
m2ts.extend(packet(0x0100, true, &pmt_payload(&[(0x1B, 0x1011)])));
for &second in seconds {
let ticks = second.wrapping_mul(90_000);
m2ts.extend(packet(0x1011, true, &pes_dts(0xE0, ticks, ticks, &sps_payload())));
}
m2ts
}
fn shared_clip_disc() -> MockDir {
let clip = || clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]);
tripping_disc_of(
&[
MockClip { stem: "00011", clip: clip(), m2ts: video_m2ts(&[1, 2, 3]), serve: None },
MockClip { stem: "00022", clip: clip(), m2ts: video_m2ts(&[1, 2]), serve: None },
MockClip { stem: "00033", clip: clip(), m2ts: video_m2ts(&[1]), serve: None },
],
&[("00000", &["00011"]), ("00001", &["00022"]), ("00002", &["00033", "00011"])],
)
}
fn observed_scan(disc: &MockDir, mode: ScanMode) -> (BdRom, Vec<MeasuredSnapshot>) {
let mut snapshots: Vec<MeasuredSnapshot> = Vec::new();
let bd = {
let mut watch = |snapshot: MeasuredSnapshot| snapshots.push(snapshot);
let mut collect = |_: ScanProgress<'_>| {};
BdRom::open(
disc,
mode,
ScanOptions::default(),
None,
ScanObservers::new(&mut collect, &never_cancel()).with_measured(&mut watch),
)
.expect("the healthy mock disc scans")
};
(bd, snapshots)
}
#[test]
fn a_measured_observer_sees_each_files_chunks_and_then_its_boundary() {
let (_, snapshots) = observed_scan(&shared_clip_disc(), ScanMode::Full);
let files: Vec<&str> = snapshots.iter().map(|s| s.file.as_str()).collect();
assert_eq!(
files,
["00011.M2TS", "00011.M2TS", "00022.M2TS", "00022.M2TS", "00033.M2TS", "00033.M2TS"]
);
let covered: Vec<Vec<&str>> = snapshots
.iter()
.map(|s| s.playlists.iter().map(|p| p.name.as_str()).collect())
.collect();
let per_file = [vec!["00000.MPLS", "00002.MPLS"], vec!["00001.MPLS"], vec!["00002.MPLS"]];
let expected: Vec<Vec<&str>> =
per_file.iter().flat_map(|playlists| [playlists.clone(), playlists.clone()]).collect();
assert_eq!(covered, expected);
}
#[test]
fn measured_snapshots_climb_and_end_on_the_finished_scans_numbers() {
let (bd, snapshots) = observed_scan(&shared_clip_disc(), ScanMode::Full);
let mut seen: BTreeMap<&str, u64> = BTreeMap::new();
for playlist in snapshots.iter().flat_map(|s| &s.playlists) {
let previous = seen.insert(&playlist.name, playlist.measured_bytes).unwrap_or(0);
assert!(
playlist.measured_bytes >= previous,
"{} fell from {previous} to {}",
playlist.name,
playlist.measured_bytes
);
}
let last = snapshots.last().expect("the pass snapshots its last file");
let live = last.playlists.first().expect("the last file completes 00002.MPLS");
let summary =
bd.playlists.iter().find(|p| p.name == live.name).expect("the playlist is summarised");
assert!(live.measured_bytes > 0, "the fixture demuxes packets");
assert_eq!(live.measured_bytes, summary.total_angle_packet_size());
let rows: Vec<(&str, i32, u64)> = summary
.clips
.iter()
.map(|clip| (clip.name.as_str(), clip.angle_index, clip.packet_size()))
.collect();
assert_eq!(
live.clips
.iter()
.map(|clip| (clip.name.as_str(), clip.angle_index, clip.measured_bytes))
.collect::<Vec<_>>(),
rows
);
let rates: Vec<(Pid, usize, i64, i64)> = summary
.streams
.iter()
.map(|stream| (stream.pid, stream.angle_index, stream.bitrate, stream.active_bitrate))
.collect();
assert!(rates.iter().any(|&(_, _, bitrate, _)| bitrate > 0), "the video row is measured");
assert_eq!(
live.streams
.iter()
.map(|stream| {
(stream.pid, stream.angle_index, stream.bitrate, stream.active_bitrate)
})
.collect::<Vec<_>>(),
rates
);
}
#[test]
fn a_scan_only_snapshots_when_it_measures_and_scans_the_same_either_way() {
let disc = shared_clip_disc();
let (codecs, none) = observed_scan(&disc, ScanMode::Codecs);
assert!(none.is_empty(), "the quick pass reports no measured tallies");
assert!(codecs.playlists.iter().all(|p| p.total_angle_packet_size() == 0));
let (observed, snapshots) = observed_scan(&disc, ScanMode::Full);
assert_eq!(snapshots.len(), 6);
let plain = open(&disc, ScanMode::Full).expect("the healthy mock disc scans");
assert_eq!(observed, plain);
}
#[test]
fn scan_observers_carry_both_callbacks_and_print_what_they_watch() {
let disc = shared_clip_disc();
let cancel = AtomicBool::new(false);
let events = AtomicUsize::new(0);
let snapshots = AtomicUsize::new(0);
let mut collect = |_: ScanProgress<'_>| {
events.fetch_add(1, Ordering::Relaxed);
};
let mut watch = |_: MeasuredSnapshot| {
snapshots.fetch_add(1, Ordering::Relaxed);
};
assert_eq!(
format!("{:?}", ScanObservers::new(&mut collect, &cancel)),
"ScanObservers { progress: true, measured: false, cancelled: false, .. }"
);
assert_eq!(
format!("{:?}", ScanObservers::none()),
"ScanObservers { progress: false, measured: false, cancelled: false, .. }"
);
drop(
BdRom::open(
&disc,
ScanMode::Full,
ScanOptions::default(),
None,
ScanObservers::new(&mut collect, &cancel).with_measured(&mut watch),
)
.expect("the healthy mock disc scans"),
);
assert!(events.load(Ordering::Relaxed) > 0, "the progress callback fired");
assert_eq!(
snapshots.load(Ordering::Relaxed),
6,
"one snapshot per parsed chunk and one per stream file"
);
cancel.store(true, Ordering::Relaxed);
assert_eq!(
format!("{:?}", ScanObservers::new(&mut collect, &cancel).with_measured(&mut watch)),
"ScanObservers { progress: true, measured: true, cancelled: true, .. }"
);
}
#[test]
fn a_cancelled_measurement_pass_keeps_only_the_chunks_it_parsed() {
let disc = shared_clip_disc();
let cancel = AtomicBool::new(false);
let snapshots = AtomicUsize::new(0);
let mut watch = |_: MeasuredSnapshot| {
snapshots.fetch_add(1, Ordering::Relaxed);
};
let mut quiet = |_: ScanProgress<'_>| {};
drop(
BdRom::open(
&disc,
ScanMode::Full,
ScanOptions::default(),
None,
ScanObservers::new(&mut quiet, &cancel).with_measured(&mut watch),
)
.expect("the healthy mock disc scans"),
);
assert_eq!(snapshots.swap(0, Ordering::Relaxed), 6);
let mut arm = |p: ScanProgress<'_>| {
if p.done > 0 {
cancel.store(true, Ordering::Relaxed);
}
};
let err = BdRom::open(
&disc,
ScanMode::Full,
ScanOptions::default(),
None,
ScanObservers::new(&mut arm, &cancel).with_measured(&mut watch),
)
.expect_err("the armed flag aborts the scan");
assert_eq!(err.to_string(), "scan cancelled");
assert_eq!(snapshots.load(Ordering::Relaxed), 1);
}
#[test]
fn a_file_spanning_chunks_snapshots_inside_the_file_and_at_its_boundary() {
let m2ts = two_chunk_m2ts(&[(0x1B, 0x1011)], &[1, 2, 3], &[46, 47, 48]);
let (bd, snapshots) = observed_scan(&tripping_disc(m2ts, None), ScanMode::Full);
assert_eq!(snapshots.len(), 3, "two chunk snapshots, then the boundary one");
assert!(snapshots.iter().all(|s| s.file == "00000.M2TS"));
let tallies: Vec<u64> = snapshots
.iter()
.map(|s| s.playlists.first().expect("the playlist plays the clip").measured_bytes)
.collect();
let [first, second, boundary]: [u64; 3] =
tallies.try_into().expect("two chunk snapshots and the boundary one");
assert!(first > 0, "the first chunk's snapshot already tallies");
assert!(first < second, "the tallies climb between the chunks");
assert!(second <= boundary, "the boundary snapshot never regresses");
let summary = bd.playlists.first().expect("the playlist is summarised");
assert_eq!(boundary, summary.total_angle_packet_size());
}
fn cancel_fixture() -> TempDisc {
TempDisc::build(
&[],
&[
("BDMV/PLAYLIST/00000.mpls", mpls("00000", 2_700_000, 4_500_000, &[])),
("BDMV/CLIPINF/00000.clpi", clpi(&[(0x1011, 0x1B, [0x63, 0x30, 0, 0])])),
("BDMV/STREAM/00000.m2ts", vec![0_u8; 1000]),
("BDMV/PLAYLIST/00001.mpls", mpls("00001", 2_700_000, 4_500_000, &[])),
("BDMV/CLIPINF/00001.clpi", clpi(&[(0x1011, 0x1B, [0x63, 0x30, 0, 0])])),
("BDMV/STREAM/00001.m2ts", vec![0_u8; 500]),
],
)
}
#[test]
fn a_cancel_flag_tripped_by_the_progress_callback_aborts_the_scan() {
let disc = cancel_fixture();
let root = FsDir::new(disc.root.clone());
let mut baseline: Vec<u64> = Vec::new();
let mut collect = |p: ScanProgress<'_>| baseline.push(p.done);
drop(
BdRom::open(
&root,
ScanMode::Full,
ScanOptions::default(),
None,
ScanObservers::new(&mut collect, &never_cancel()),
)
.expect("the uncancelled scan"),
);
let cancel = AtomicBool::new(false);
let mut events: Vec<u64> = Vec::new();
let mut trip = |p: ScanProgress<'_>| {
events.push(p.done);
cancel.store(true, Ordering::Relaxed);
};
let err = BdRom::open(
&root,
ScanMode::Full,
ScanOptions::default(),
None,
ScanObservers::new(&mut trip, &cancel),
)
.expect_err("the cancelled scan errors");
assert_eq!(err.to_string(), "scan cancelled");
assert!(events.len() < baseline.len(), "the cancelled scan must stop early");
assert!(events.len() <= 4, "the event trail stops at the in-flight chunk");
let cancel = AtomicBool::new(false);
let mut trip = |_: ScanProgress<'_>| cancel.store(true, Ordering::Relaxed);
let err = BdRom::open_resilient(
&root,
ScanMode::Full,
ScanOptions::default(),
None,
ScanObservers::new(&mut trip, &cancel),
)
.expect_err("the cancelled resilient scan errors");
assert_eq!(err.to_string(), "scan cancelled");
}
#[test]
fn a_preset_cancel_flag_aborts_the_packet_scan_before_any_progress() {
let disc = cancel_fixture();
let root = FsDir::new(disc.root.clone());
let cancel = AtomicBool::new(true);
for mode in [ScanMode::Codecs, ScanMode::Full] {
let mut fired = false;
let mut observe = |_: ScanProgress<'_>| fired = true;
let err = BdRom::open(
&root,
mode,
ScanOptions::default(),
None,
ScanObservers::new(&mut observe, &cancel),
)
.expect_err("the pre-cancelled scan errors");
assert_eq!(err.to_string(), "scan cancelled");
assert!(!fired, "no progress escapes a pre-cancelled scan");
}
let mut resilient_fired = false;
let mut observe = |_: ScanProgress<'_>| resilient_fired = true;
let err = BdRom::open_resilient(
&root,
ScanMode::Full,
ScanOptions::default(),
None,
ScanObservers::new(&mut observe, &cancel),
)
.expect_err("the pre-cancelled resilient scan errors");
assert_eq!(err.to_string(), "scan cancelled");
assert!(!resilient_fired, "no progress escapes the resilient abort either");
let mut metadata_fired = false;
let mut observe = |_: ScanProgress<'_>| metadata_fired = true;
let bd = BdRom::open(
&root,
ScanMode::Metadata,
ScanOptions::default(),
None,
ScanObservers::new(&mut observe, &cancel),
)
.expect("the metadata scan never polls the flag");
assert!(!metadata_fired, "a metadata scan reads no packets");
assert_eq!(bd.playlists.len(), 2);
}
#[test]
fn a_cancelled_resilient_scan_records_no_per_file_errors() {
let trip = Trip::new(usize::MAX);
let file = |name: &str| MockFile {
name: name.to_owned(),
extension: ".m2ts".to_owned(),
bytes: vec![0_u8; 200],
trip: trip.clone(),
fail_read_at: None,
reads: None,
};
let stream_dir = MockDir {
name: "STREAM".to_owned(),
dirs: Vec::new(),
files: vec![file("00000.m2ts"), file("00001.m2ts")],
trip,
};
let mut playlists =
vec![TsPlaylistFile::scan("00000.mpls", &mpls("00000", 0, 4_500_000, &[])).unwrap()];
let cancel = AtomicBool::new(false);
let mut arm = |_: ScanProgress<'_>| cancel.store(true, Ordering::Relaxed);
let mut progress =
Progress { callback: &mut arm, measured: None, done: 0, total: 400, cancel: &cancel };
let mut errors = Vec::new();
let result = scan_stream_files(
Some(&stream_dir),
None,
&mut playlists,
None,
&mut progress,
&mut Sink { errors: Some(&mut errors) },
true,
false,
false,
);
let message = result.err().map(|err| err.to_string());
assert_eq!(message.as_deref(), Some("scan cancelled"));
assert!(errors.is_empty(), "cancellation is never a per-file failure");
}
#[test]
fn open_resilient_equals_open_on_a_healthy_disc_with_no_errors() {
let clip = clpi(&[(0x1011, 0x1B, [0x63, 0x30, 0, 0])]);
let play = mpls("00000", 2_700_000, 4_500_000, &[(1, 0, 2_700_000)]);
let disc = TempDisc::build(
&[],
&[("BDMV/PLAYLIST/00000.mpls", play), ("BDMV/CLIPINF/00000.clpi", clip)],
);
let strict = disc.open().expect("strict scan");
let report = open_resilient(&FsDir::new(disc.root.clone()), ScanMode::Metadata)
.expect("resilient scan");
assert!(report.errors.is_empty());
assert_eq!(report.bdrom, strict);
}
#[test]
fn open_resilient_keeps_other_playlists_on_a_corrupt_clip_file() {
let good_clip = clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]);
let disc = TempDisc::build(
&[],
&[
("BDMV/PLAYLIST/00000.mpls", mpls("00000", 0, 4_500_000, &[])),
("BDMV/PLAYLIST/00001.mpls", mpls("00001", 0, 9_000_000, &[])),
("BDMV/CLIPINF/00000.clpi", good_clip),
("BDMV/CLIPINF/00001.clpi", b"XXXX0100junk".to_vec()),
],
);
assert!(disc.open().is_err(), "the strict scan aborts on the corrupt clip");
let report = open_resilient(&FsDir::new(disc.root.clone()), ScanMode::Metadata)
.expect("resilient scan continues");
let names: Vec<&str> = report.bdrom.playlists.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, vec!["00000.MPLS"]); assert_eq!(report.errors.len(), 2);
let clip_err = report.errors.first().expect("clip error");
assert_eq!(clip_err.stage, ScanStage::ClipInfo);
assert_eq!(clip_err.file, "00001.clpi");
assert_eq!(clip_err.reason.to_string(), "unknown file type: XXXX0100");
let playlist_err = report.errors.get(1).expect("playlist error");
assert_eq!(playlist_err.stage, ScanStage::Playlist);
assert_eq!(playlist_err.file, "00001.MPLS");
assert_eq!(playlist_err.reason.to_string(), "referenced missing clip file: 00001.CLPI");
}
#[test]
fn open_resilient_keeps_other_playlists_on_a_corrupt_playlist() {
let clip = clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]);
let disc = TempDisc::build(
&[],
&[
("BDMV/PLAYLIST/00000.mpls", mpls("00000", 0, 4_500_000, &[])),
("BDMV/PLAYLIST/00001.mpls", b"XXXXjunk".to_vec()),
("BDMV/CLIPINF/00000.clpi", clip),
],
);
assert!(disc.open().is_err(), "the strict scan aborts on the corrupt playlist");
let report = open_resilient(&FsDir::new(disc.root.clone()), ScanMode::Metadata)
.expect("resilient scan continues");
let names: Vec<&str> = report.bdrom.playlists.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, vec!["00000.MPLS"]);
assert_eq!(report.errors.len(), 1);
let err = report.errors.first().expect("playlist error");
assert_eq!((err.stage, err.file.as_str()), (ScanStage::Playlist, "00001.mpls"));
assert_eq!(err.reason.to_string(), "unknown file type: XXXXjunk");
}
#[test]
fn open_resilient_defaults_the_flags_on_failed_metadata_reads() {
let trip = Trip::new(usize::MAX);
let broken = |name: &str| MockFile {
name: name.to_owned(),
extension: name.rsplit_once('.').map_or_else(String::new, |(_, e)| format!(".{e}")),
bytes: vec![0],
trip: trip.clone(),
fail_read_at: Some(0),
reads: None,
};
let dir = |name: &str, dirs: Vec<MockDir>, files: Vec<MockFile>| MockDir {
name: name.to_owned(),
dirs,
files,
trip: trip.clone(),
};
let root = dir(
"disc",
vec![dir(
"BDMV",
vec![
dir("CLIPINF", vec![], vec![]),
dir("PLAYLIST", vec![], vec![]),
dir("META", vec![], vec![broken("bdmt_eng.xml")]),
],
vec![broken("index.bdmv")],
)],
vec![],
);
let report = open_resilient(&root, ScanMode::Metadata).expect("resilient scan continues");
assert!(!report.bdrom.is_uhd);
assert_eq!(report.bdrom.disc_title, None);
assert_eq!(report.errors.len(), 2);
let uhd = report.errors.first().expect("index error");
assert_eq!((uhd.stage, uhd.file.as_str()), (ScanStage::Discovery, "index.bdmv"));
let title = report.errors.get(1).expect("title error");
assert_eq!((title.stage, title.file.as_str()), (ScanStage::Discovery, "META"));
assert!(open(&root, ScanMode::Metadata).is_err());
}
#[test]
fn open_resilient_records_an_io_failure_at_every_step() {
let probe = Trip::new(usize::MAX);
let clean = open_resilient(&mock_disc(&probe), ScanMode::Full).expect("mock disc scans");
assert!(clean.errors.is_empty());
let total = probe.used();
let mut absorbed = 0_usize;
let mut fail_open = 0_usize;
for fail_at in 0..total {
let trip = Trip::new(fail_at);
if let Ok(report) = open_resilient(&mock_disc(&trip), ScanMode::Full) {
if report.errors.is_empty() {
assert_eq!(
report.bdrom, clean.bdrom,
"io failure at op {fail_at} was swallowed and altered the scan"
);
fail_open = fail_open.saturating_add(1);
} else {
absorbed = absorbed.saturating_add(1);
}
}
}
assert!(absorbed > total.saturating_div(2), "absorbed {absorbed} of {total}");
assert_eq!(fail_open, 1);
}
#[test]
fn read_file_surfaces_a_read_error_after_a_successful_open() {
let unreadable = MockFile {
name: "unreadable.bin".to_owned(),
extension: ".bin".to_owned(),
bytes: vec![1],
trip: Trip::new(usize::MAX),
fail_read_at: Some(0),
reads: None,
};
assert!(read_file(&unreadable).is_err()); let mut reader = unreadable.open_read().expect("open");
assert_eq!(reader.seek(SeekFrom::Start(0)).expect("seek"), 0);
}
struct EndlessReader;
impl Read for EndlessReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
buf.fill(0);
Ok(buf.len())
}
}
impl Seek for EndlessReader {
fn seek(&mut self, _: SeekFrom) -> io::Result<u64> {
Ok(0)
}
}
struct EndlessFile {
name: String,
extension: String,
len: u64,
}
impl BdFile for EndlessFile {
fn name(&self) -> &str {
&self.name
}
fn full_name(&self) -> &str {
&self.name
}
fn extension(&self) -> &str {
&self.extension
}
fn length(&self) -> u64 {
self.len
}
fn is_dir(&self) -> bool {
false
}
fn open_read(&self) -> io::Result<Box<dyn ReadSeek>> {
Ok(Box::new(EndlessReader))
}
fn open_text(&self) -> io::Result<Box<dyn BufRead>> {
Ok(Box::new(BufReader::new(EndlessReader)))
}
}
#[test]
fn read_file_capped_accepts_a_file_sitting_exactly_on_the_cap() {
let file = MockFile {
name: "00000.MPLS".to_owned(),
extension: ".mpls".to_owned(),
bytes: vec![7; 8],
trip: Trip::new(usize::MAX),
fail_read_at: None,
reads: None,
};
assert_eq!(read_file_capped(&file, 8).expect("at the cap"), vec![7; 8]);
}
#[test]
fn read_file_capped_rejects_a_file_one_byte_over_the_cap() {
let file = MockFile {
name: "00000.MPLS".to_owned(),
extension: ".mpls".to_owned(),
bytes: vec![7; 9],
trip: Trip::new(usize::MAX),
fail_read_at: None,
reads: None,
};
let err = read_file_capped(&file, 8).expect_err("one byte over the cap");
assert!(matches!(
err,
BdError::MetadataTooLarge { ref file, limit: 8 } if file == "00000.MPLS"
));
}
#[test]
fn read_file_caps_an_endless_reader_instead_of_buffering_it_forever() {
let file = EndlessFile {
name: "00000.MPLS".to_owned(),
extension: ".mpls".to_owned(),
len: u64::MAX,
};
let err = read_file(&file).expect_err("an endless file is refused");
assert!(matches!(
err,
BdError::MetadataTooLarge { limit, .. } if limit == MAX_METADATA_BYTES
));
assert_eq!(file.name(), "00000.MPLS");
assert_eq!(file.full_name(), "00000.MPLS");
assert_eq!(file.extension(), ".mpls");
assert_eq!(file.length(), u64::MAX);
assert!(!file.is_dir());
let mut one = [0xAA_u8; 1];
file.open_text().expect("open_text").read_exact(&mut one).expect("read");
assert_eq!(one, [0]);
assert_eq!(file.open_read().expect("open_read").seek(SeekFrom::End(0)).expect("seek"), 0);
}
#[test]
fn mock_trait_surface_is_exercised() {
let trip = Trip::new(usize::MAX);
let root = mock_disc(&trip);
assert_eq!(root.full_name(), "disc");
assert!(root.parent().is_none());
assert_eq!(root.get_files_pattern("*").expect("pattern").len(), root.files.len());
let file = root.get_directories().expect("dirs");
assert!(!file.is_empty());
let mock_file = MockFile {
name: "x".to_owned(),
extension: String::new(),
bytes: vec![1],
trip,
fail_read_at: None,
reads: None,
};
assert_eq!(mock_file.full_name(), "x");
assert!(!mock_file.is_dir());
let mut text = String::new();
mock_file.open_text().expect("open_text").read_to_string(&mut text).expect("read");
assert_eq!(text, "\u{1}");
}
#[test]
fn clip_stem_strips_either_stream_extension() {
assert_eq!(clip_stem("00000.M2TS"), "00000");
assert_eq!(clip_stem("00000.FMTS"), "00000");
assert_eq!(clip_stem("noext"), "noext");
}
#[test]
fn open_scanned_resolves_an_fmts_clip_stream_file() {
let clip = clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]);
let play = mpls_items_codec(*b"FMTS", &["00000"], 0, 4_500_000, &[], &[], &[]);
let disc = TempDisc::build(
&[],
&[
("BDMV/PLAYLIST/00000.mpls", play),
("BDMV/CLIPINF/00000.clpi", clip),
("BDMV/STREAM/00000.fmts", vec![0; 200]),
],
);
let bd = disc.open_scanned().expect("fmts disc scans");
let playlist = bd.playlists.first().expect("one playlist");
let clip = playlist.clips.first().expect("one clip");
assert_eq!(clip.name, "00000.FMTS"); assert_eq!(playlist.file_size, 200);
}
#[test]
fn open_resilient_recovers_a_playlist_from_backup() {
let clip = clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]);
let disc = TempDisc::build(
&[],
&[
("BDMV/PLAYLIST/00000.mpls", b"XXXXjunk".to_vec()),
("BDMV/BACKUP/PLAYLIST/00000.mpls", mpls("00000", 0, 4_500_000, &[])),
("BDMV/CLIPINF/00000.clpi", clip),
],
);
assert!(disc.open().is_err(), "strict open ignores BACKUP");
let report =
open_resilient(&FsDir::new(disc.root.clone()), ScanMode::Metadata).expect("recovers");
let names: Vec<&str> = report.bdrom.playlists.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, vec!["00000.MPLS"]); assert_eq!(report.errors.len(), 1);
let err = report.errors.first().expect("primary note");
assert_eq!((err.stage, err.file.as_str()), (ScanStage::Playlist, "00000.mpls"));
assert_eq!(err.reason.to_string(), "unknown file type: XXXXjunk");
}
#[test]
fn open_resilient_recovers_a_clip_from_backup() {
let good_clip = clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]);
let disc = TempDisc::build(
&[],
&[
("BDMV/PLAYLIST/00000.mpls", mpls("00000", 0, 4_500_000, &[])),
("BDMV/CLIPINF/00000.clpi", b"XXXX0100junk".to_vec()),
("BDMV/BACKUP/CLIPINF/00000.clpi", good_clip),
],
);
let report =
open_resilient(&FsDir::new(disc.root.clone()), ScanMode::Metadata).expect("recovers");
let names: Vec<&str> = report.bdrom.playlists.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, vec!["00000.MPLS"]); assert_eq!(report.errors.len(), 1);
let err = report.errors.first().expect("clip note");
assert_eq!((err.stage, err.file.as_str()), (ScanStage::ClipInfo, "00000.clpi"));
assert_eq!(err.reason.to_string(), "unknown file type: XXXX0100");
}
#[test]
fn open_resilient_recovers_the_index_from_backup() {
let trip = Trip::new(usize::MAX);
let mk = |name: &str, bytes: Vec<u8>, fail_read: bool| MockFile {
name: name.to_owned(),
extension: name.rsplit_once('.').map_or_else(String::new, |(_, e)| format!(".{e}")),
bytes,
trip: trip.clone(),
fail_read_at: fail_read.then_some(0),
reads: None,
};
let dir = |name: &str, dirs: Vec<MockDir>, files: Vec<MockFile>| MockDir {
name: name.to_owned(),
dirs,
files,
trip: trip.clone(),
};
let root = dir(
"disc",
vec![dir(
"BDMV",
vec![
dir("CLIPINF", vec![], vec![]),
dir("PLAYLIST", vec![], vec![]),
dir("BACKUP", vec![], vec![mk("index.bdmv", b"INDX0300".to_vec(), false)]),
],
vec![mk("index.bdmv", vec![0], true)], )],
vec![],
);
let report = open_resilient(&root, ScanMode::Metadata).expect("scans");
assert!(report.bdrom.is_uhd, "UHD flag recovered from BACKUP");
assert_eq!(report.errors.len(), 1);
let err = report.errors.first().expect("index note");
assert_eq!((err.stage, err.file.as_str()), (ScanStage::Discovery, "index.bdmv"));
}
#[test]
fn open_resilient_does_not_recover_from_a_corrupt_backup() {
let clip = clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]);
let disc = TempDisc::build(
&[],
&[
("BDMV/PLAYLIST/00000.mpls", b"XXXXjunk".to_vec()),
("BDMV/BACKUP/PLAYLIST/00000.mpls", b"YYYYjunk".to_vec()),
("BDMV/CLIPINF/00000.clpi", clip),
],
);
let report =
open_resilient(&FsDir::new(disc.root.clone()), ScanMode::Metadata).expect("scans");
assert!(report.bdrom.playlists.is_empty());
assert_eq!(report.errors.len(), 1);
assert_eq!(
report.errors.first().expect("primary note").reason.to_string(),
"unknown file type: XXXXjunk"
);
}
#[test]
fn open_resilient_does_not_recover_when_backup_lacks_the_file() {
let clip = clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]);
let disc = TempDisc::build(
&[],
&[
("BDMV/PLAYLIST/00000.mpls", b"XXXXjunk".to_vec()),
("BDMV/BACKUP/PLAYLIST/00001.mpls", mpls("00001", 0, 4_500_000, &[])),
("BDMV/CLIPINF/00000.clpi", clip),
],
);
let report =
open_resilient(&FsDir::new(disc.root.clone()), ScanMode::Metadata).expect("scans");
assert!(report.bdrom.playlists.is_empty());
assert_eq!(report.errors.len(), 1);
}
#[test]
fn backup_subdir_files_lists_present_and_empties_absent_directories() {
let ok = Trip::new(usize::MAX);
let mk = |name: &str| MockFile {
name: name.to_owned(),
extension: ".mpls".to_owned(),
bytes: vec![1],
trip: ok.clone(),
fail_read_at: None,
reads: None,
};
let playlist = MockDir {
name: "PLAYLIST".to_owned(),
dirs: Vec::new(),
files: vec![mk("00000.mpls")],
trip: ok.clone(),
};
let backup = MockDir {
name: "BACKUP".to_owned(),
dirs: vec![playlist],
files: Vec::new(),
trip: ok,
};
let pool = backup_subdir_files(&backup, BdmvDir::Playlist).expect("present");
assert!(pool.contains_key("00000.MPLS"));
assert!(backup_subdir_files(&backup, BdmvDir::ClipInf).expect("absent").is_empty());
}
#[test]
fn collect_backups_lists_all_pools_and_propagates_every_listing_failure() {
let build = |bt: &Trip| {
let mk = |name: &str| MockFile {
name: name.to_owned(),
extension: name.rsplit_once('.').map_or_else(String::new, |(_, e)| format!(".{e}")),
bytes: vec![1],
trip: bt.clone(),
fail_read_at: None,
reads: None,
};
let sub = |name: &str, file: &str| MockDir {
name: name.to_owned(),
dirs: Vec::new(),
files: vec![mk(file)],
trip: bt.clone(),
};
let backup = MockDir {
name: "BACKUP".to_owned(),
dirs: vec![sub("PLAYLIST", "00000.mpls"), sub("CLIPINF", "00000.clpi")],
files: vec![mk("index.bdmv")],
trip: bt.clone(),
};
MockDir {
name: "BDMV".to_owned(),
dirs: vec![backup],
files: Vec::new(),
trip: Trip::new(usize::MAX), }
};
let probe = Trip::new(usize::MAX);
let (index, playlist, clipinf) = collect_backups(&build(&probe)).expect("clean probe");
assert!(index.contains_key("INDEX.BDMV"));
assert!(playlist.contains_key("00000.MPLS"));
assert!(clipinf.contains_key("00000.CLPI"));
let total = probe.used();
assert!(total >= 5, "expected several BACKUP listing ops, got {total}");
for fail_at in 0..total {
let bt = Trip::new(fail_at);
assert!(collect_backups(&build(&bt)).is_err(), "listing failure at op {fail_at}");
}
}
#[test]
fn an_untagged_index_warns_in_resilient_and_is_tolerated_in_strict() {
let clip = clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]);
let make = |index: &[u8]| {
TempDisc::build(
&[],
&[
("BDMV/PLAYLIST/00000.mpls", mpls("00000", 0, 4_500_000, &[])),
("BDMV/CLIPINF/00000.clpi", clip.clone()),
("BDMV/index.bdmv", index.to_vec()),
],
)
};
let disc = make(b"XXXXjunk");
let report =
open_resilient(&FsDir::new(disc.root.clone()), ScanMode::Metadata).expect("scans");
assert!(!report.bdrom.is_uhd);
assert_eq!(report.errors.len(), 1);
let err = report.errors.first().expect("index warning");
assert_eq!((err.stage, err.file.as_str()), (ScanStage::Discovery, "index.bdmv"));
assert_eq!(err.reason.to_string(), "unknown file type: XXXXjunk");
assert!(!disc.open().expect("strict tolerates an untagged index").is_uhd);
let short = make(b"XX");
let report =
open_resilient(&FsDir::new(short.root.clone()), ScanMode::Metadata).expect("scans");
assert!(!report.bdrom.is_uhd);
assert_eq!(
report.errors.first().expect("short index warning").reason.to_string(),
"unknown file type: "
);
}
#[test]
fn an_untagged_index_falls_back_to_the_backup_copy() {
let clip = clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]);
let make = |backup: Option<&[u8]>| {
let mut files = vec![
("BDMV/PLAYLIST/00000.mpls", mpls("00000", 0, 4_500_000, &[])),
("BDMV/CLIPINF/00000.clpi", clip.clone()),
("BDMV/index.bdmv", b"XXXXjunk".to_vec()),
("BDMV/BACKUP/PLAYLIST/00000.mpls", mpls("00000", 0, 4_500_000, &[])),
];
if let Some(bytes) = backup {
files.push(("BDMV/BACKUP/index.bdmv", bytes.to_vec()));
}
TempDisc::build(&[], &files)
};
let scan = |disc: &TempDisc| {
open_resilient(&FsDir::new(disc.root.clone()), ScanMode::Metadata).expect("scans")
};
let recovered = make(Some(b"INDX0300"));
let report = scan(&recovered);
assert!(report.bdrom.is_uhd, "UHD flag recovered from an intact BACKUP index");
assert_eq!(report.errors.len(), 1, "the untagged primary warns exactly once");
let err = report.errors.first().expect("index warning");
assert_eq!((err.stage, err.file.as_str()), (ScanStage::Discovery, "index.bdmv"));
assert_eq!(err.reason.to_string(), "unknown file type: XXXXjunk");
assert!(!scan(&make(Some(b"INDX0200"))).bdrom.is_uhd);
assert!(!recovered.open().expect("strict tolerates an untagged index").is_uhd);
let report = scan(&make(Some(b"YYYYjunk")));
assert!(!report.bdrom.is_uhd);
assert_eq!(report.errors.len(), 1);
let report = scan(&make(None));
assert!(!report.bdrom.is_uhd);
assert_eq!(report.errors.len(), 1);
}
#[test]
fn an_unreadable_primary_index_recovered_as_garbage_stays_non_uhd() {
let trip = Trip::new(usize::MAX);
let mk = |name: &str, bytes: Vec<u8>, fail_read: bool| MockFile {
name: name.to_owned(),
extension: name.rsplit_once('.').map_or_else(String::new, |(_, e)| format!(".{e}")),
bytes,
trip: trip.clone(),
fail_read_at: fail_read.then_some(0),
reads: None,
};
let dir = |name: &str, dirs: Vec<MockDir>, files: Vec<MockFile>| MockDir {
name: name.to_owned(),
dirs,
files,
trip: trip.clone(),
};
let root = dir(
"disc",
vec![dir(
"BDMV",
vec![
dir("CLIPINF", vec![], vec![]),
dir("PLAYLIST", vec![], vec![]),
dir("BACKUP", vec![], vec![mk("index.bdmv", b"XXXXjunk".to_vec(), false)]),
],
vec![mk("index.bdmv", vec![0], true)], )],
vec![],
);
let report = open_resilient(&root, ScanMode::Metadata).expect("scans");
assert!(!report.bdrom.is_uhd);
assert_eq!(report.errors.len(), 2, "the unreadable primary, then its untagged stand-in");
for err in &report.errors {
assert_eq!((err.stage, err.file.as_str()), (ScanStage::Discovery, "index.bdmv"));
}
}
}