use super::network::NetworkStream;
use super::null::NullStream;
use super::pipelined_stream::PipelinedPesStream;
use super::stdio::StdioStream;
use super::{M2tsStream, MkvStream};
use crate::disc::{ContentFormat, DiscTitle};
use crate::sector::SectorSource;
use std::io;
use std::path::{Path, PathBuf};
const IO_BUF_SIZE: usize = 4 * 1024 * 1024;
#[derive(Debug, Clone)]
pub enum StreamUrl {
Disc { device: Option<PathBuf> },
M2ts { path: PathBuf },
Mkv { path: PathBuf },
Network { addr: String },
Stdio,
Iso { path: PathBuf },
Dir { path: PathBuf },
Null,
Demux { dir: PathBuf },
Fvi { path: PathBuf },
Unknown { raw: String },
}
impl StreamUrl {
pub fn scheme(&self) -> &str {
match self {
StreamUrl::Disc { .. } => "disc",
StreamUrl::M2ts { .. } => "m2ts",
StreamUrl::Mkv { .. } => "mkv",
StreamUrl::Network { .. } => "network",
StreamUrl::Stdio => "stdio",
StreamUrl::Iso { .. } => "iso",
StreamUrl::Dir { .. } => "dir",
StreamUrl::Null => "null",
StreamUrl::Demux { .. } => "demux",
StreamUrl::Fvi { .. } => "fvi",
StreamUrl::Unknown { .. } => "unknown",
}
}
pub fn path_str(&self) -> &str {
match self {
StreamUrl::Disc { device: Some(p) } => p.to_str().unwrap_or(""),
StreamUrl::Disc { device: None } => "",
StreamUrl::M2ts { path }
| StreamUrl::Mkv { path }
| StreamUrl::Iso { path }
| StreamUrl::Dir { path }
| StreamUrl::Demux { dir: path }
| StreamUrl::Fvi { path } => path.to_str().unwrap_or(""),
StreamUrl::Network { addr } => addr,
StreamUrl::Stdio | StreamUrl::Null => "",
StreamUrl::Unknown { raw } => raw,
}
}
pub fn is_disc_source(&self) -> bool {
matches!(self, StreamUrl::Disc { .. } | StreamUrl::Iso { .. })
}
}
pub fn parse_url(url: &str) -> StreamUrl {
if let Some(rest) = url
.strip_prefix("disc://")
.or_else(|| url.strip_prefix("disk://"))
{
return if rest.is_empty() {
StreamUrl::Disc { device: None }
} else {
StreamUrl::Disc {
device: Some(PathBuf::from(rest)),
}
};
}
if let Some(rest) = url.strip_prefix("m2ts://") {
return StreamUrl::M2ts {
path: PathBuf::from(rest),
};
}
if let Some(rest) = url.strip_prefix("mkv://") {
return StreamUrl::Mkv {
path: PathBuf::from(rest),
};
}
if let Some(rest) = url.strip_prefix("network://") {
return StreamUrl::Network {
addr: rest.to_string(),
};
}
if let Some(rest) = url.strip_prefix("null://") {
if rest.is_empty() {
return StreamUrl::Null;
}
}
if let Some(rest) = url.strip_prefix("stdio://") {
if rest.is_empty() {
return StreamUrl::Stdio;
}
}
if let Some(rest) = url.strip_prefix("iso://") {
return StreamUrl::Iso {
path: PathBuf::from(rest),
};
}
if let Some(rest) = url.strip_prefix("dir://") {
return StreamUrl::Dir {
path: PathBuf::from(rest),
};
}
if let Some(rest) = url.strip_prefix("demux://") {
return StreamUrl::Demux {
dir: PathBuf::from(rest),
};
}
if let Some(rest) = url.strip_prefix("fvi://") {
return StreamUrl::Fvi {
path: PathBuf::from(rest),
};
}
StreamUrl::Unknown {
raw: url.to_string(),
}
}
fn validate_file_path(path: &Path, scheme: &str) -> io::Result<()> {
if path.as_os_str().is_empty() {
return Err(crate::error::Error::StreamUrlMissingPath {
scheme: scheme.to_string(),
}
.into());
}
if path.file_name().is_none() {
return Err(crate::error::Error::StreamUrlInvalid {
url: format!("{scheme}://{}", path.display()),
}
.into());
}
Ok(())
}
fn validate_network_addr(addr: &str) -> io::Result<()> {
if addr.is_empty() {
return Err(crate::error::Error::StreamUrlMissingPath {
scheme: "network".to_string(),
}
.into());
}
if addr.parse::<std::net::IpAddr>().is_ok() {
return Err(crate::error::Error::StreamUrlMissingPort {
addr: addr.to_string(),
}
.into());
}
if !addr.contains(':') {
return Err(crate::error::Error::StreamUrlMissingPort {
addr: addr.to_string(),
}
.into());
}
let port = match addr.rsplit_once(':') {
Some((_host, port)) => port,
None => {
return Err(crate::error::Error::StreamUrlMissingPort {
addr: addr.to_string(),
}
.into());
}
};
if port.is_empty() || port.parse::<u16>().is_err() {
return Err(crate::error::Error::StreamUrlInvalid {
url: addr.to_string(),
}
.into());
}
Ok(())
}
#[derive(Clone, Default)]
pub struct InputOptions {
pub unit_keys: Vec<(u32, [u8; 16])>,
pub title_index: Option<usize>,
pub raw: bool,
pub key_fetch: Option<crate::sector::KeyFetch>,
}
impl std::fmt::Debug for InputOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InputOptions")
.field("unit_keys", &self.unit_keys.len())
.field("title_index", &self.title_index)
.field("raw", &self.raw)
.field("key_fetch", &self.key_fetch.is_some())
.finish()
}
}
pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::Stream>> {
let parsed = parse_url(url);
match parsed {
StreamUrl::Disc { .. } => {
Err(crate::error::Error::DiscUrlNotDirect.into())
}
StreamUrl::Iso { ref path } => {
validate_file_path(path, "iso")?;
let mut reader = crate::io::file_sector_source::FileSectorSource::open(path)?;
let capacity = reader.capacity_sectors();
let mut disc = crate::disc::Disc::scan_image(
&mut reader,
capacity,
&crate::disc::ScanOptions::default(),
)
.map_err(|e| -> io::Error { e.into() })?;
if !opts.unit_keys.is_empty() {
disc.decrypt_with(crate::disc::Key::Unit(opts.unit_keys.clone()), &[])
.map_err(|e| -> io::Error { e.into() })?;
}
disc.ensure_decryptable(opts.raw)
.map_err(|e| -> io::Error { e.into() })?;
if disc.titles.is_empty() {
return Err(crate::error::Error::NoStreams.into());
}
let idx = opts.title_index.unwrap_or(0);
if idx >= disc.titles.len() {
return Err(crate::error::Error::DiscTitleRange {
index: idx,
count: disc.titles.len(),
}
.into());
}
let (keys, title_is_clear) =
match crate::io::file_sector_source::FileSectorSource::open(path) {
Ok(mut crack_reader) => {
disc.decrypt_keys_for_title_checked(idx, &mut crack_reader, 64)
}
Err(_) => (disc.decrypt_keys(), false),
};
disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear)
.map_err(|e| -> io::Error { e.into() })?;
if !opts.raw {
match crate::io::file_sector_source::FileSectorSource::open(path) {
Ok(probe) => {
let mut dec =
crate::sector::DecryptingSectorSource::new(probe, keys.clone());
crate::disc::correct_truehd_channels(&mut dec, &mut disc.titles[idx]);
}
Err(e) => {
tracing::debug!(
target: "mux",
"TrueHD channel-correction probe re-open failed: {e}"
);
}
}
}
let title = disc.titles[idx].clone();
let format = disc.content_format;
const ISO_MUX_BATCH_SECTORS: u16 = 8192;
let effective_keys = if opts.raw {
crate::decrypt::DecryptKeys::None
} else {
keys
};
let fetch = if opts.raw {
None
} else {
opts.key_fetch.clone()
};
let stream = build_iso_pipeline(
reader,
title,
effective_keys,
ISO_MUX_BATCH_SECTORS,
format,
None,
None,
fetch,
)?;
Ok(Box::new(stream))
}
StreamUrl::M2ts { ref path } => {
validate_file_path(path, "m2ts")?;
let file = std::fs::File::open(path)?;
let reader = std::io::BufReader::with_capacity(IO_BUF_SIZE, file);
let stream = build_m2ts_pipeline(reader)?;
Ok(Box::new(stream))
}
StreamUrl::Mkv { ref path } => {
validate_file_path(path, "mkv")?;
let file = std::fs::File::open(path)?;
let reader = std::io::BufReader::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(MkvStream::open(reader)?))
}
StreamUrl::Network { ref addr } => {
validate_network_addr(addr)?;
Ok(Box::new(NetworkStream::listen(addr)?))
}
StreamUrl::Stdio => Ok(Box::new(StdioStream::input())),
StreamUrl::Dir { .. } => Err(crate::error::Error::StreamWriteOnly.into()),
StreamUrl::Null => Err(crate::error::Error::StreamWriteOnly.into()),
StreamUrl::Demux { .. } => Err(crate::error::Error::StreamWriteOnly.into()),
StreamUrl::Fvi { .. } => Err(crate::error::Error::StreamWriteOnly.into()),
StreamUrl::Unknown { ref raw } => {
Err(crate::error::Error::StreamUrlInvalid { url: raw.clone() }.into())
}
}
}
pub fn output(
url: &str,
title: &crate::disc::DiscTitle,
) -> io::Result<Box<dyn crate::pes::Stream>> {
let parsed = parse_url(url);
match parsed {
StreamUrl::Mkv { ref path } => {
validate_file_path(path, "mkv")?;
let writer: Box<dyn super::WriteSeek + Send> =
Box::new(std::io::BufWriter::with_capacity(
IO_BUF_SIZE,
crate::io::WritebackFile::create_with_size_hint(path, title.size_bytes)?,
));
Ok(Box::new(MkvStream::create_at(writer, title, Some(path))?))
}
StreamUrl::M2ts { ref path } => {
validate_file_path(path, "m2ts")?;
let writer = std::io::BufWriter::with_capacity(
IO_BUF_SIZE,
crate::io::WritebackFile::create_with_size_hint(path, title.size_bytes)?,
);
Ok(Box::new(M2tsStream::create(writer, title)?))
}
StreamUrl::Network { ref addr } => {
validate_network_addr(addr)?;
Ok(Box::new(NetworkStream::connect(addr)?.meta(title)))
}
StreamUrl::Stdio => Ok(Box::new(StdioStream::output(title))),
StreamUrl::Null => Ok(Box::new(NullStream::new(title))),
StreamUrl::Disc { .. } => Err(crate::error::Error::StreamReadOnly.into()),
StreamUrl::Iso { .. } => Err(crate::error::Error::StreamReadOnly.into()),
StreamUrl::Dir { .. } => Err(crate::error::Error::StreamReadOnly.into()),
StreamUrl::Demux { ref dir } => {
validate_file_path(dir, "demux")?;
let mut opts = super::demux_sink::DemuxOptions::default();
if !title.playlist.is_empty() {
opts.base = title.playlist.clone();
}
Ok(Box::new(super::demux_sink::DemuxSink::create(
dir, title, &opts,
)?))
}
StreamUrl::Fvi { ref path } => {
validate_file_path(path, "fvi")?;
Ok(Box::new(super::fvi_sink::FviSink::create(
path,
title,
path.to_string_lossy().into_owned(),
0,
)?))
}
StreamUrl::Unknown { ref raw } => {
Err(crate::error::Error::StreamUrlInvalid { url: raw.clone() }.into())
}
}
}
type DemuxState = (
Vec<(u16, Box<dyn super::codec::CodecParser>)>,
Vec<(u16, usize)>,
Option<super::ts::TsDemuxer>,
Option<super::ps::PsDemuxer>,
);
fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
let mut pids = Vec::new();
let mut parsers = Vec::new();
let mut pid_to_track = Vec::new();
for (idx, s) in title.streams.iter().enumerate() {
let (pid, codec) = match s {
crate::disc::Stream::Video(v) => (v.pid, v.codec),
crate::disc::Stream::Audio(a) => (a.pid, a.codec),
crate::disc::Stream::Subtitle(s) => (s.pid, s.codec),
};
pids.push(pid);
pid_to_track.push((pid, idx));
let is_dvd_ps = matches!(format, ContentFormat::MpegPs);
parsers.push((pid, super::codec::parser_for_codec(codec, None, is_dvd_ps)));
}
let (ts, ps) = match format {
ContentFormat::MpegPs => (None, Some(super::ps::PsDemuxer::new())),
ContentFormat::BdTs => {
if pids.is_empty() {
(None, None)
} else {
(Some(super::ts::TsDemuxer::new(&pids)), None)
}
}
};
(parsers, pid_to_track, ts, ps)
}
#[allow(clippy::too_many_arguments)]
pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
reader: S,
title: DiscTitle,
keys: crate::decrypt::DecryptKeys,
batch_sectors: u16,
format: ContentFormat,
halt: Option<crate::halt::Halt>,
event_fn: Option<crate::sector::prefetched::EventFn>,
fetch: Option<crate::sector::KeyFetch>,
) -> io::Result<PipelinedPesStream> {
let extents = title.extents.clone();
let unit_align: u16 = match &keys {
crate::decrypt::DecryptKeys::Aacs { .. } => 3,
_ => 1,
};
let mut decrypting =
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
if let Some(cb) = fetch {
decrypting = decrypting.with_key_fetch(cb);
}
let decrypt_loss = decrypting.decrypt_loss();
let mut title = title;
crate::disc::dvd_audio_probe::probe_and_remap(&mut decrypting, &mut title);
decrypting.set_unit_base(0);
let prefetched = crate::sector::PrefetchedSectorSource::new_with_events(
decrypting,
extents,
batch_sectors,
unit_align,
halt.clone(),
event_fn,
)
.map_err(|e| -> io::Error { e.into() })?;
let (rx, recycle_tx, shell) = prefetched.into_channels();
let (parsers, pid_to_track, ts, ps) = build_demux_state(&title, format);
let (demux_thread, demux_rx) =
super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, halt, ts, ps)
.map_err(|e| -> io::Error { e.into() })?;
Ok(
PipelinedPesStream::new(demux_thread, demux_rx, title, parsers, pid_to_track)
.with_decrypt_loss(decrypt_loss),
)
}
fn build_m2ts_pipeline<R: std::io::Read + Send + 'static>(
mut reader: R,
) -> io::Result<PipelinedPesStream> {
use super::meta;
use std::io::Read;
const M2TS_SCAN_BYTES: usize = 1024 * 1024;
let mut head = vec![0u8; M2TS_SCAN_BYTES];
let head_len = {
let mut filled = 0;
while filled < head.len() {
match reader.read(&mut head[filled..])? {
0 => break,
n => filled += n,
}
}
filled
};
head.truncate(head_len);
let mut cursor = io::Cursor::new(&head);
let (title, head_consumed) = match meta::read_header(&mut cursor)? {
Some(m) => {
let t = m.to_title();
if t.streams.is_empty() {
return Err(crate::error::Error::NoStreams.into());
}
(t, cursor.position() as usize)
}
None => {
let streams = super::ts::scan_streams(&head)
.ok_or_else(|| -> io::Error { crate::error::Error::NoStreams.into() })?;
let t = DiscTitle {
duration_secs: 0.0,
streams,
..DiscTitle::empty()
};
(t, 0)
}
};
let remaining_head = head[head_consumed..].to_vec();
let chained: Box<dyn Read + Send> = Box::new(io::Cursor::new(remaining_head).chain(reader));
let prefetcher = crate::io::byte_prefetcher::BytePrefetcher::new(
chained,
crate::io::byte_prefetcher::DEFAULT_CHUNK_BYTES,
None,
)?;
let (rx, recycle_tx, shell) = prefetcher.into_channels();
let (parsers, pid_to_track, ts, ps) = build_demux_state(&title, ContentFormat::BdTs);
let (demux_thread, demux_rx) =
super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, None, ts, ps)
.map_err(|e| -> io::Error { e.into() })?;
Ok(PipelinedPesStream::new(
demux_thread,
demux_rx,
title,
parsers,
pid_to_track,
))
}
#[cfg(test)]
mod tests {
use super::StreamUrl;
use super::parse_url;
use super::validate_network_addr;
use super::{build_demux_state, build_iso_pipeline, input, output};
use crate::decrypt::DecryptKeys;
use crate::disc::{ContentFormat, DiscTitle, Extent};
use crate::pes::Stream as _;
use crate::sector::SectorSource;
use std::path::PathBuf;
#[test]
fn parse_url_never_panics_on_adversarial_input() {
let mut cases: Vec<String> = vec![
String::new(),
"://".into(),
"//".into(),
":".into(),
"disc".into(),
"disc:/".into(),
"disc:://".into(),
"disc://disc://".into(),
"iso://iso://x".into(),
"mkv://mkv://mkv://".into(),
"iso://\0/etc".into(), "iso://日本語/フィルム.iso".into(), "network://[::1]:9000".into(),
"ftp://host/x".into(),
format!("iso://{}", "a".repeat(100_000)), "\u{feff}disc://".into(), ];
for b in 0u8..=255 {
cases.push(String::from_utf8_lossy(&[b]).into_owned());
cases.push(format!("iso://{}", String::from_utf8_lossy(&[b])));
}
for c in &cases {
let u = parse_url(c);
let _ = u.scheme();
let _ = u.path_str();
let _ = u.is_disc_source();
}
}
#[test]
fn disk_scheme_is_alias_for_disc() {
match (parse_url("disk://"), parse_url("disc://")) {
(StreamUrl::Disc { device: a }, StreamUrl::Disc { device: b }) => {
assert_eq!(a, None);
assert_eq!(b, None);
}
other => panic!("disk:// / disc:// must both be Disc, got {other:?}"),
}
match (parse_url("disk://i:"), parse_url("disc://i:")) {
(StreamUrl::Disc { device: a }, StreamUrl::Disc { device: b }) => {
assert_eq!(a, Some(PathBuf::from("i:")));
assert_eq!(b, Some(PathBuf::from("i:")));
assert_eq!(a, b, "disk:// device must match disc:// device");
}
other => panic!("disk://i: / disc://i: must both be Disc, got {other:?}"),
}
}
#[test]
fn validate_network_addr_rejects_portless() {
assert!(validate_network_addr("").is_err());
assert!(validate_network_addr("127.0.0.1").is_err());
assert!(validate_network_addr("::1").is_err());
assert!(validate_network_addr("2001:db8::1").is_err());
assert!(validate_network_addr("127.0.0.1:9000").is_ok());
assert!(validate_network_addr("host:9000").is_ok());
}
#[test]
fn validate_network_addr_requires_numeric_port() {
assert!(validate_network_addr("host:").is_err());
assert!(validate_network_addr("127.0.0.1:").is_err());
assert!(validate_network_addr("host:abc").is_err());
assert!(validate_network_addr("host:99x").is_err());
assert!(validate_network_addr("host:70000").is_err());
assert!(validate_network_addr("[2001:db8::1]:9000").is_ok());
assert!(validate_network_addr("[2001:db8::1]").is_err());
assert!(validate_network_addr("host:0").is_ok());
assert!(validate_network_addr("host:65535").is_ok());
}
fn input_err_kind(url: &str) -> std::io::ErrorKind {
match input(url, &Default::default()) {
Ok(_) => panic!("expected input({url}) to error"),
Err(e) => e.kind(),
}
}
fn output_err_kind(url: &str, t: &DiscTitle) -> std::io::ErrorKind {
match output(url, t) {
Ok(_) => panic!("expected output({url}) to error"),
Err(e) => e.kind(),
}
}
#[test]
fn input_disc_url_is_not_direct() {
assert_eq!(input_err_kind("disc://"), std::io::ErrorKind::Unsupported);
}
#[test]
fn input_null_url_is_write_only() {
assert_eq!(input_err_kind("null://"), std::io::ErrorKind::Unsupported);
}
#[test]
fn input_unknown_url_is_invalid() {
assert_eq!(
input_err_kind("ftp://host/x"),
std::io::ErrorKind::InvalidInput
);
}
#[test]
fn input_iso_empty_path_missing_path_error() {
assert_eq!(input_err_kind("iso://"), std::io::ErrorKind::InvalidInput);
}
#[test]
fn output_disc_and_iso_are_read_only() {
let t = DiscTitle::empty();
assert_eq!(
output_err_kind("disc://", &t),
std::io::ErrorKind::Unsupported
);
assert_eq!(
output_err_kind("iso://x.iso", &t),
std::io::ErrorKind::Unsupported
);
}
#[test]
fn output_unknown_url_is_invalid() {
let t = DiscTitle::empty();
assert_eq!(
output_err_kind("gopher://x", &t),
std::io::ErrorKind::InvalidInput
);
}
#[test]
fn parse_dir_url_is_sink_not_disc_source() {
match parse_url("dir://out/movie/") {
StreamUrl::Dir { path } => {
assert_eq!(path, PathBuf::from("out/movie/"));
}
other => panic!("dir:// must parse to Dir, got {other:?}"),
}
assert_eq!(parse_url("dir://x").scheme(), "dir");
assert_eq!(parse_url("dir://x/y").path_str(), "x/y");
assert_eq!(parse_url("demux://out/movie/").path_str(), "out/movie/");
assert_eq!(parse_url("demux://x").scheme(), "demux");
assert!(
!parse_url("demux://x").is_disc_source(),
"demux:// is a sink, never a disc source"
);
assert!(
!parse_url("dir://x").is_disc_source(),
"dir:// is a sink, never a disc source"
);
match parse_url("fvi://out/movie.fvi") {
StreamUrl::Fvi { path } => {
assert_eq!(path, PathBuf::from("out/movie.fvi"));
}
other => panic!("fvi:// must parse to Fvi, got {other:?}"),
}
assert_eq!(parse_url("fvi://x").scheme(), "fvi");
assert_eq!(parse_url("fvi://x/y.fvi").path_str(), "x/y.fvi");
assert!(
!parse_url("fvi://x").is_disc_source(),
"fvi:// is a sink, never a disc source"
);
}
#[test]
fn input_fvi_url_is_write_only() {
assert_eq!(
input_err_kind("fvi://out/movie.fvi"),
std::io::ErrorKind::Unsupported
);
}
#[test]
fn dir_url_is_not_a_pes_stream_either_direction() {
assert_eq!(
input_err_kind("dir://out/"),
std::io::ErrorKind::Unsupported
);
let t = DiscTitle::empty();
assert_eq!(
output_err_kind("dir://out/", &t),
std::io::ErrorKind::Unsupported
);
}
#[test]
fn output_network_missing_port_invalid() {
let t = DiscTitle::empty();
assert_eq!(
output_err_kind("network://127.0.0.1", &t),
std::io::ErrorKind::InvalidInput
);
}
#[test]
fn output_mkv_empty_path_missing_path_error() {
let t = DiscTitle::empty();
assert_eq!(
output_err_kind("mkv://", &t),
std::io::ErrorKind::InvalidInput
);
}
fn aac_audio_title(pid: u16) -> DiscTitle {
use crate::disc::{AudioChannels, AudioStream, Codec, LabelPurpose, SampleRate, Stream};
let mut t = DiscTitle::empty();
t.streams.push(Stream::Audio(AudioStream {
pid,
codec: Codec::Aac, channels: AudioChannels::Stereo,
language: "eng".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: LabelPurpose::Normal,
label: String::new(),
}));
t
}
#[test]
fn build_demux_state_bdts_builds_ts_demuxer_and_pid_table() {
let t = aac_audio_title(0x1100);
let (parsers, pid_to_track, ts, ps) = build_demux_state(&t, ContentFormat::BdTs);
assert_eq!(parsers.len(), 1);
assert_eq!(parsers[0].0, 0x1100, "parser keyed by the stream PID");
assert_eq!(pid_to_track, vec![(0x1100u16, 0usize)]);
assert!(ts.is_some(), "BdTs → TsDemuxer");
assert!(ps.is_none());
}
#[test]
fn build_demux_state_mpegps_builds_ps_demuxer() {
let t = aac_audio_title(0xBD80);
let (_parsers, _p2t, ts, ps) = build_demux_state(&t, ContentFormat::MpegPs);
assert!(ts.is_none());
assert!(ps.is_some(), "MpegPs → PsDemuxer");
}
#[test]
fn build_demux_state_bdts_empty_streams_builds_no_demuxer() {
let t = DiscTitle::empty();
let (parsers, pid_to_track, ts, ps) = build_demux_state(&t, ContentFormat::BdTs);
assert!(parsers.is_empty());
assert!(pid_to_track.is_empty());
assert!(ts.is_none(), "no PIDs → no TsDemuxer");
assert!(ps.is_none());
}
struct MemSource {
data: Vec<u8>,
}
impl SectorSource for MemSource {
fn capacity_sectors(&self) -> u32 {
(self.data.len() / 2048) as u32
}
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
let start = lba as usize * 2048;
let want = count as usize * 2048;
for (i, b) in buf[..want].iter_mut().enumerate() {
*b = self.data.get(start + i).copied().unwrap_or(0);
}
Ok(want)
}
}
fn bdts_data_packet(pid: u16, pusi: bool, payload: &[u8]) -> [u8; 192] {
let mut pkt = [0u8; 192];
pkt[4] = 0x47; pkt[5] = ((pid >> 8) as u8) & 0x1F;
if pusi {
pkt[5] |= 0x40; }
pkt[6] = (pid & 0xFF) as u8;
pkt[7] = 0x10; let room = 184; let n = payload.len().min(room);
pkt[8..8 + n].copy_from_slice(&payload[..n]);
pkt
}
fn audio_pes(es: &[u8]) -> Vec<u8> {
let mut v = vec![0x00, 0x00, 0x01, 0xC0];
let len = (3 + es.len()) as u16; v.extend_from_slice(&len.to_be_bytes());
v.extend_from_slice(&[0x80, 0x00, 0x00]);
v.extend_from_slice(es);
v
}
#[test]
fn build_iso_pipeline_empty_extents_clean_eof() {
let title = aac_audio_title(0x1100); let mut stream = build_iso_pipeline(
MemSource { data: Vec::new() },
title,
DecryptKeys::None,
8192,
ContentFormat::BdTs,
None,
None,
None,
)
.expect("pipeline builds");
let first = stream.read().expect("read must not error on clean EOF");
assert!(
first.is_none(),
"no extents → immediate clean end-of-stream"
);
assert!(stream.read().unwrap().is_none());
}
#[test]
fn build_iso_pipeline_delivers_one_frame_then_eof() {
let es = [0xDE, 0xAD, 0xBE, 0xEF, 0x11, 0x22];
let pes = audio_pes(&es);
let pkt = bdts_data_packet(0x1100, true, &pes);
let mut data = vec![0u8; 3 * 2048];
data[..192].copy_from_slice(&pkt);
let mut title = aac_audio_title(0x1100);
title.extents = vec![Extent {
start_lba: 0,
sector_count: 3,
}];
let mut stream = build_iso_pipeline(
MemSource { data },
title,
DecryptKeys::None,
8192,
ContentFormat::BdTs,
None,
None,
None,
)
.expect("pipeline builds");
let frame = stream
.read()
.expect("read ok")
.expect("one frame emitted from the single PES");
assert_eq!(frame.track, 0);
assert_eq!(
frame.data.len(),
184 - 9,
"frame spans the full TS payload after the PES header"
);
assert_eq!(
&frame.data[..es.len()],
&es[..],
"ES payload prefix delivered intact and in order"
);
assert!(
frame.data[es.len()..].iter().all(|&b| b == 0),
"remainder is the packet's zero padding, not foreign data"
);
assert!(
stream.read().unwrap().is_none(),
"clean EOF after the frame"
);
}
#[test]
fn build_iso_pipeline_zero_batch_rejected() {
let title = aac_audio_title(0x1100);
let res = build_iso_pipeline(
MemSource { data: Vec::new() },
title,
DecryptKeys::None,
0,
ContentFormat::BdTs,
None,
None,
None,
);
assert!(res.is_err(), "zero batch_sectors must be rejected");
}
}