use std::collections::{BTreeMap, BTreeSet};
use std::io::{self, Read};
use std::sync::atomic::AtomicBool;
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::mpls::TsPlaylistFile;
use super::order::{self, PlaylistFilter};
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};
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>,
}
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 fn angle_totals(&self) -> Vec<AngleTotals> {
let mut totals = Vec::new();
for angle in 1..=self.angle_count {
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
}
}
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 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 {
self.packet_count.wrapping_mul(192)
}
#[must_use]
pub fn packet_bit_rate(&self) -> u64 {
rate_over(self.packet_size(), self.packet_seconds)
}
}
#[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,
}
impl AngleTotals {
#[must_use]
pub fn bit_rate(&self) -> u64 {
rate_over(self.packet_size, self.length)
}
#[must_use]
pub fn timeline_bit_rate(&self, total_length: f64) -> u64 {
rate_over(self.timeline_packet_size, total_length)
}
}
#[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 seven flags are independent disc properties (3D/50Hz/UHD/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_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,
}
struct Progress<'a> {
callback: &'a mut dyn FnMut(ScanProgress<'_>),
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.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.callback)(ScanProgress { file, done: self.done, total: self.total });
}
}
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> {
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,
}
#[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)
}
}
impl BdRom {
pub fn open(root: &dyn BdDir, mode: ScanMode) -> Result<Self, BdError> {
Self::open_with(root, mode, None, &mut |_| {}, &AtomicBool::new(false))
}
pub fn open_with(
root: &dyn BdDir,
mode: ScanMode,
scan_files: Option<&BTreeSet<String>>,
progress: &mut dyn FnMut(ScanProgress<'_>),
cancel: &AtomicBool,
) -> Result<Self, BdError> {
Self::open_impl(root, mode, scan_files, progress, cancel, &mut Sink { errors: None })
}
pub fn open_resilient(root: &dyn BdDir, mode: ScanMode) -> Result<ScanReport, BdError> {
Self::open_resilient_with(root, mode, None, &mut |_| {}, &AtomicBool::new(false))
}
pub fn open_resilient_with(
root: &dyn BdDir,
mode: ScanMode,
scan_files: Option<&BTreeSet<String>>,
progress: &mut dyn FnMut(ScanProgress<'_>),
cancel: &AtomicBool,
) -> Result<ScanReport, BdError> {
let mut errors = Vec::new();
let bdrom = Self::open_impl(
root,
mode,
scan_files,
progress,
cancel,
&mut Sink { errors: Some(&mut errors) },
)?;
Ok(ScanReport { bdrom, errors })
}
fn open_impl(
root: &dyn BdDir,
mode: ScanMode,
scan_files: Option<&BTreeSet<String>>,
progress: &mut dyn FnMut(ScanProgress<'_>),
cancel: &AtomicBool,
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_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(),
progress,
cancel,
sink,
)?
} else {
(BTreeMap::new(), BTreeMap::new())
};
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,
&scanned,
&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_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()
}
}
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) {
sink.record(
ScanStage::Discovery,
"index.bdmv",
BdError::UnknownFileType(index::read_index_version(&bytes).unwrap_or_default()),
);
}
Ok(index::is_uhd(&bytes))
}
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)
}
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>;
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, progress, 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,
callback: &mut dyn FnMut(ScanProgress<'_>),
cancel: &AtomicBool,
sink: &mut Sink<'_>,
) -> Result<(ScannedFiles, ScannedFiles), BdError> {
let mut warmup = |_: ScanProgress<'_>| {};
let quick_progress = &mut Progress { callback: &mut warmup, done: 0, total: 0, cancel };
let quick = scan_stream_files(stream, ssif, parsed, scan_files, quick_progress, sink, false)?;
for playlist in parsed.iter_mut() {
if let Ok(metas) = collect_clip_metas(playlist, clip_files, stream_files, &quick) {
resolve_playlist_streams(playlist, &metas);
}
}
clear_measurements(parsed);
if !measure {
return Ok((quick, BTreeMap::new()));
}
let progress = &mut Progress {
callback,
done: 0,
total: scan_total(stream_files, interleaved_files, scan_files),
cancel,
};
let full = scan_stream_files(stream, ssif, parsed, scan_files, progress, sink, true)?;
for playlist in parsed.iter_mut() {
if let Ok(metas) = collect_clip_metas(playlist, clip_files, stream_files, &full)
&& let Some(reference) = select_reference(&metas)
&& let Some(file) = reference.scanned
{
remerge_graphics_detail(playlist, &file.streams);
}
}
Ok((quick, full))
}
fn collect_clip_metas<'a>(
playlist: &TsPlaylistFile,
clip_files: &'a BTreeMap<String, TsStreamClipFile>,
stream_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),
});
}
Ok(clips)
}
const MVC_PID: u16 = 0x1012;
fn resolve_playlist_streams(playlist: &mut TsPlaylistFile, metas: &[ClipMeta<'_>]) {
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 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());
}
for (pid, src) in &file.streams {
if let Some(dst) = streams.get_mut(pid) {
merge_stream(dst, src);
}
}
}
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>) {
for (pid, src) in scanned {
if !matches!(src, TsStream::Graphics(_)) {
continue;
}
if let Some(dst) = playlist.streams.get_mut(pid) {
merge_stream(dst, src);
}
}
}
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,
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, 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 {
for (pid, src) in scanned {
if let Some(dst) = streams.get_mut(pid) {
merge_stream(dst, src);
}
}
}
for (pid, dst) in &mut streams {
if !matches!(dst, TsStream::Graphics(_)) {
continue;
}
if let Some(src) = playlist.streams.get(pid) {
merge_stream(dst, src);
}
}
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);
let mut row = stream_summary(stream);
row.is_hidden = is_hidden;
row.ssif_only = ssif_only;
set_measured_rates(&mut row, playlist.streams.get(pid));
row.full_description = full_description(stream, &row);
rows.push(row);
if stream.base().is_video_stream() {
for index in 0..angle_count {
let mut row = stream_summary(stream);
row.is_hidden = is_hidden;
row.ssif_only = ssif_only;
row.angle_index = index.saturating_add(1);
set_measured_rates(
&mut row,
playlist.angle_streams.get(index).and_then(|angle| angle.get(pid)),
);
row.full_description = full_description(stream, &row);
rows.push(row);
}
}
}
rows
}
const fn set_measured_rates(row: &mut StreamSummary, stream: Option<&TsStream>) {
if let Some(stream) = stream {
row.bitrate = stream.base().bit_rate;
row.active_bitrate = stream.base().active_bit_rate;
}
}
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(),
}
}
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 (codec_short_name, codec_name, description) = match stream {
TsStream::Video(video) => (
video.codec_short_name().to_owned(),
video.codec_name().to_owned(),
video.description(),
),
TsStream::Audio(audio) => (
audio.codec_short_name().to_owned(),
audio.codec_name().to_owned(),
audio.description(),
),
TsStream::Graphics(graphics) => (
graphics.codec_short_name().to_owned(),
graphics.codec_name().to_owned(),
graphics.description(),
),
TsStream::Text(text) => {
(text.codec_short_name().to_owned(), text.codec_name().to_owned(), text.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,
codec_name,
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,
}
}
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<'_>,
is_full_scan: 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);
let scan = scan_one_stream_file(&*file, interleaved, playlists, is_full_scan, progress);
if matches!(scan, Err(BdError::ScanCancelled)) {
return Err(BdError::ScanCancelled);
}
progress.finish_file(&name, target);
if let Some(stream_file) =
sink.absorb(ScanStage::StreamFile, file.name(), None, scan.map(Some))?
{
scanned.insert(name, stream_file);
}
}
Ok(scanned)
}
fn scan_one_stream_file(
file: &dyn BdFile,
interleaved: Option<Box<dyn BdFile>>,
playlists: &mut [TsPlaylistFile],
is_full_scan: bool,
progress: &mut Progress<'_>,
) -> Result<TsStreamFile, BdError> {
let mut stream_file = TsStreamFile::new(file.name());
stream_file.interleaved_file = interleaved.map(TsInterleavedFile::new);
let inner = match &stream_file.interleaved_file {
Some(interleaved) => interleaved.open_read().map_err(BdError::Io)?,
None => file.open_read()?,
};
let cancel = progress.cancel;
let mut reader = CountingReader { inner, name: file.name().to_ascii_uppercase(), progress };
stream_file.scan_cancellable(&mut reader, playlists, is_full_scan, cancel)?;
Ok(stream_file)
}
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)))
}
fn read_file(file: &dyn BdFile) -> Result<Vec<u8>, BdError> {
let mut reader = file.open_read()?;
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes)?;
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::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
use proptest::prelude::{prop_assert, prop_assert_eq, proptest};
use super::{
BdError, BdRom, ClipMeta, ClipSummary, MVC_PID, PlaylistFilter, PlaylistSummary, Progress,
ScanMode, ScanProgress, ScanStage, Sink, TsPlaylistFile, TsStreamFile, backup_subdir_files,
build_chapter_summaries, build_clip_summaries, build_sorted_streams, clear_measurements,
clip_has_50hz_video, clip_stem, collect_backups, merge_stream, rate_over, read_disc_title,
read_file, resolve_playlist_streams, scan_stream_files, scan_total, select_reference,
stream_summary, walked_disc_root,
};
use crate::bdrom::clpi::TsStreamClip;
use crate::bdrom::interleaved::{MemBdFile, TsInterleavedFile};
use crate::bdrom::m2ts::packets::{packet, pat_payload, pes_dts, pes_pts, pmt_payload};
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};
fn clpi(entries: &[(u16, u8, [u8; 4])]) -> Vec<u8> {
let mut clip_data = vec![0_u8, 1]; clip_data.extend_from_slice(&[0_u8; 6]); clip_data.push(u8::try_from(entries.len()).unwrap()); clip_data.push(0); for &(pid, coding_type, payload) in entries {
clip_data.extend_from_slice(&pid.to_be_bytes());
clip_data.push(5); clip_data.push(coding_type);
clip_data.extend_from_slice(&payload);
}
let mut buf = b"HDMV0300".to_vec();
buf.extend_from_slice(&[0_u8; 4]); buf.extend_from_slice(&16_u32.to_be_bytes()); buf.extend_from_slice(&u32::try_from(clip_data.len()).unwrap().to_be_bytes());
buf.extend_from_slice(&clip_data);
buf
}
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 items = Vec::new();
for item_name in item_names {
let mut body = Vec::new();
let mut name = item_name.as_bytes().to_vec();
name.resize(5, 0);
body.extend_from_slice(&name); body.extend_from_slice(&codec); body.push(0); body.push(if angle_names.is_empty() { 0 } else { 0x10 }); body.push(0); body.extend_from_slice(&in_t.to_be_bytes()); body.extend_from_slice(&out_t.to_be_bytes()); body.extend_from_slice(&[0_u8; 12]); if !angle_names.is_empty() {
body.push(u8::try_from(angle_names.len().wrapping_add(1)).unwrap()); body.push(0); for angle in angle_names {
let mut angle_bytes = angle.as_bytes().to_vec();
angle_bytes.resize(5, 0);
body.extend_from_slice(&angle_bytes); body.extend_from_slice(&codec); body.push(0); }
}
body.extend_from_slice(&[0_u8; 2]); body.extend_from_slice(&[0_u8; 2]); body.push(0); body.push(u8::try_from(declared_audio.len()).unwrap()); body.extend_from_slice(&[0_u8; 5]); body.extend_from_slice(&[0_u8; 5]); for pid in declared_audio {
body.push(3); body.push(1); body.extend_from_slice(&pid.to_be_bytes());
body.push(5); body.push(0x81); body.push(0x61); body.extend_from_slice(b"eng");
}
items.extend_from_slice(&u16::try_from(body.len()).unwrap().to_be_bytes());
items.extend_from_slice(&body);
}
let playlist_offset: usize = 0x3C;
let mut playlist = Vec::new();
playlist.extend_from_slice(&[0_u8; 4]); playlist.extend_from_slice(&[0_u8; 2]); playlist.extend_from_slice(&u16::try_from(item_names.len()).unwrap().to_be_bytes());
playlist.extend_from_slice(&[0_u8; 2]); playlist.extend_from_slice(&items);
let chapters_offset = playlist_offset.wrapping_add(playlist.len());
let mut mark = Vec::new();
mark.extend_from_slice(&[0_u8; 4]); mark.extend_from_slice(&u16::try_from(chapters.len()).unwrap().to_be_bytes());
for &(chapter_type, file_index, tick) in chapters {
let mut entry = vec![0_u8, chapter_type];
entry.extend_from_slice(&file_index.to_be_bytes());
entry.extend_from_slice(&tick.to_be_bytes());
entry.resize(14, 0);
mark.extend_from_slice(&entry);
}
let mut buf = b"MPLS0300".to_vec();
buf.extend_from_slice(&u32::try_from(playlist_offset).unwrap().to_be_bytes());
buf.extend_from_slice(&u32::try_from(chapters_offset).unwrap().to_be_bytes());
buf.extend_from_slice(&[0_u8; 4]); buf.resize(0x38, 0);
buf.push(0); buf.resize(playlist_offset, 0);
buf.extend_from_slice(&playlist);
buf.extend_from_slice(&mark);
buf
}
fn empty_mpls() -> Vec<u8> {
let playlist_offset: usize = 0x3C;
let mut playlist = Vec::new();
playlist.extend_from_slice(&[0_u8; 4]); playlist.extend_from_slice(&[0_u8; 2]); playlist.extend_from_slice(&0_u16.to_be_bytes()); playlist.extend_from_slice(&[0_u8; 2]); let chapters_offset = playlist_offset.wrapping_add(playlist.len());
let mut mark = Vec::new();
mark.extend_from_slice(&[0_u8; 4]); mark.extend_from_slice(&0_u16.to_be_bytes());
let mut buf = b"MPLS0300".to_vec();
buf.extend_from_slice(&u32::try_from(playlist_offset).unwrap().to_be_bytes());
buf.extend_from_slice(&u32::try_from(chapters_offset).unwrap().to_be_bytes());
buf.extend_from_slice(&[0_u8; 4]); buf.resize(0x38, 0);
buf.push(0); buf.resize(playlist_offset, 0);
buf.extend_from_slice(&playlist);
buf.extend_from_slice(&mark);
buf
}
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()));
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> {
BdRom::open(&FsDir::new(self.root.clone()), ScanMode::Metadata)
}
fn open_scanned(&self) -> Result<BdRom, BdError> {
BdRom::open(&FsDir::new(self.root.clone()), ScanMode::Full)
}
fn open_codecs(&self) -> Result<BdRom, BdError> {
BdRom::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,
}
}
#[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);
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 = stream_summary(playlist.streams.get(&0x1011).unwrap());
assert_eq!(video.height, 0, "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_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.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);
}
#[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 {
name: "00000.M2TS".to_owned(),
display_name: "00000.M2TS".to_owned(),
file_size: 0,
interleaved_file_size: 0,
angle_index,
relative_time_in: time_in,
length,
payload_bytes: 0,
packet_count: packets,
packet_seconds: 0.0,
file_seconds: 0.0,
streams: Vec::new(),
}
}
fn playlist_summary(
total_length: f64,
angle_count: usize,
clips: Vec<ClipSummary>,
) -> PlaylistSummary {
PlaylistSummary {
name: "00000.MPLS".to_owned(),
total_length,
file_size: 0,
interleaved_file_size: 0,
chapter_count: 0,
stream_count: 0,
angle_count,
has_loops: false,
streams: Vec::new(),
clips,
chapters: Vec::new(),
}
}
#[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());
assert_eq!(one.bit_rate(), 1152);
assert_eq!(one.timeline_bit_rate(100.0), 768);
assert_eq!(two.bit_rate(), 0);
assert_eq!(two.timeline_bit_rate(0.0), 0);
}
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,
),
) {
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, 2, 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(), 2);
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,
}];
let mut playlist = playlist_with_clips(2, Vec::new());
resolve_playlist_streams(&mut playlist, &metas);
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, &[]);
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,
}];
resolve_playlist_streams(&mut playlist, &metas);
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,
}];
resolve_playlist_streams(&mut playlist, &metas);
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,
}];
let mut playlist = playlist_with_clips(0, Vec::new());
resolve_playlist_streams(&mut playlist, &metas);
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 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));
}
#[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> {
BdRom::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(&self) -> io::Result<Vec<Box<dyn BdFile>>> {
Ok(Vec::new())
}
fn get_files_pattern(&self, _pattern: &str) -> io::Result<Vec<Box<dyn BdFile>>> {
Ok(Vec::new())
}
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!(
BdRom::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,
}
impl Trip {
fn new(fail_at: usize) -> Self {
Self { count: Arc::new(AtomicUsize::new(0)), fail_at }
}
fn tick(&self) -> io::Result<()> {
if self.count.fetch_add(1, Ordering::Relaxed) == self.fail_at {
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: bool,
}
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()?;
if self.fail_read {
Ok(Box::new(FailingReader))
} else {
Ok(Box::new(Cursor::new(self.bytes.clone())))
}
}
fn open_text(&self) -> io::Result<Box<dyn BufRead>> {
Ok(Box::new(BufReader::new(Cursor::new(self.bytes.clone()))))
}
}
struct FailingReader;
impl Read for FailingReader {
fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
Err(io::Error::other("read failed"))
}
}
impl Seek for FailingReader {
fn seek(&mut self, _pos: SeekFrom) -> io::Result<u64> {
Ok(0)
}
}
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>| {
let extension =
name.rsplit_once('.').map_or_else(String::new, |(_, ext)| format!(".{ext}"));
MockFile {
name: name.to_owned(),
extension,
bytes,
trip: trip.clone(),
fail_read: false,
}
};
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 open_propagates_io_errors_at_every_step() {
let probe = Trip::new(usize::MAX);
let bd = BdRom::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}");
for fail_at in 0..total {
let trip = Trip::new(fail_at);
assert!(
BdRom::open(&mock_disc(&trip), ScanMode::Full).is_err(),
"io failure at op {fail_at}"
);
}
}
#[test]
fn scan_stream_files_propagates_a_reader_error() {
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: true,
};
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 mut noop = |_: ScanProgress<'_>| {};
let mut progress =
Progress { callback: &mut noop, done: 0, total: 200, cancel: &AtomicBool::new(false) };
let strict = scan_stream_files(
Some(&stream_dir),
None,
&mut playlists,
None,
&mut progress,
&mut Sink { errors: None },
false,
);
assert!(strict.is_err());
let mut errors = Vec::new();
let mut noop = |_: ScanProgress<'_>| {};
let mut progress =
Progress { callback: &mut noop, done: 0, total: 200, cancel: &AtomicBool::new(false) };
let resilient = scan_stream_files(
Some(&stream_dir),
None,
&mut playlists,
None,
&mut progress,
&mut Sink { errors: Some(&mut errors) },
false,
)
.expect("resilient scan continues");
assert_eq!(progress.done, 200, "the failed file's bytes are snapped past");
assert!(resilient.is_empty());
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");
}
#[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,
done: 0,
total: 100,
cancel: &AtomicBool::new(false),
};
progress.advance("A.M2TS", 30);
progress.advance("A.M2TS", 90);
progress.finish_file("A.M2TS", 50);
}
assert_eq!(
events,
[
("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,
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)]);
}
fn never_cancel() -> AtomicBool {
AtomicBool::new(false)
}
#[test]
fn open_with_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_with(&root, ScanMode::Full, None, &mut collect, &never_cancel())
.expect("scan with progress");
assert_eq!(bd.playlists.len(), 2);
assert!(!events.is_empty());
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_with(&root, ScanMode::Full, Some(&selected), &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_with(
&root,
ScanMode::Full,
Some(&selected),
&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_with(&root, ScanMode::Metadata, None, &mut observe, &never_cancel())
.expect("metadata-only scan"),
);
assert!(!fired);
}
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_with(&root, ScanMode::Full, None, &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_with(&root, ScanMode::Full, None, &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() <= 3, "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_with(&root, ScanMode::Full, None, &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_with(&root, mode, None, &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_with(&root, ScanMode::Full, None, &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_with(&root, ScanMode::Metadata, None, &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: false,
};
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, 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) },
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 = BdRom::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 = BdRom::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 = BdRom::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: true,
};
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 =
BdRom::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!(BdRom::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 =
BdRom::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;
for fail_at in 0..total {
let trip = Trip::new(fail_at);
if let Ok(report) = BdRom::open_resilient(&mock_disc(&trip), ScanMode::Full) {
assert!(
!report.errors.is_empty(),
"io failure at op {fail_at} was swallowed without a record"
);
absorbed = absorbed.saturating_add(1);
}
}
assert!(absorbed > total.saturating_div(2), "absorbed {absorbed} of {total}");
}
#[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: true,
};
assert!(read_file(&unreadable).is_err()); let mut reader = unreadable.open_read().expect("open");
assert_eq!(reader.seek(SeekFrom::Start(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: false,
};
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 = BdRom::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 = BdRom::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,
};
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 = BdRom::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 = BdRom::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 = BdRom::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: false,
};
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: false,
};
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 = BdRom::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 = BdRom::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: "
);
}
}