use std::collections::BTreeMap;
use super::clpi::TsStreamClip;
use super::{ascii, byte, u16_be, u32_be};
use crate::error::BdError;
use crate::primitives::Pid;
use crate::stream::{
TsAudioStream, TsChannelLayout, TsFrameRate, TsGraphicsStream, TsSampleRate, TsStream,
TsStreamType, TsTextStream, TsVideoFormat, TsVideoStream,
};
#[derive(Debug, Clone, PartialEq)]
pub struct TsPlaylistFile {
pub file_type: String,
pub name: String,
pub mvc_base_view_r: bool,
pub chapters: Vec<f64>,
pub playlist_streams: BTreeMap<u16, TsStream>,
pub streams: BTreeMap<u16, TsStream>,
pub angle_streams: Vec<BTreeMap<u16, TsStream>>,
pub stream_clips: Vec<TsStreamClip>,
pub angle_count: i32,
}
impl TsPlaylistFile {
#[expect(
clippy::too_many_lines,
reason = "the scan is one sequential walk of the playlist layout; splitting it would scatter the offset bookkeeping"
)]
pub fn scan(name: &str, data: &[u8]) -> Result<Self, BdError> {
let file_type = ascii(data, 0, 8)?;
if !matches!(file_type.as_str(), "MPLS0100" | "MPLS0200" | "MPLS0240" | "MPLS0300") {
return Err(BdError::UnknownFileType(file_type));
}
let playlist_offset = usize::try_from(u32_be(data, 8)?).unwrap_or(usize::MAX);
let chapters_offset = usize::try_from(u32_be(data, 12)?).unwrap_or(usize::MAX);
let misc_flags = byte(data, 0x38)?;
let mvc_base_view_r = (misc_flags & 0x10) != 0;
let item_count = u16_be(data, playlist_offset.saturating_add(6))?;
let mut pos = playlist_offset.saturating_add(10);
let mut stream_clips: Vec<TsStreamClip> = Vec::new();
let mut chapter_clips: Vec<usize> = Vec::new();
let mut playlist_streams: BTreeMap<u16, TsStream> = BTreeMap::new();
let mut angle_count: i32 = 0;
for _ in 0..item_count {
let item_start = pos;
let item_length = usize::from(u16_be(data, item_start)?);
let item_name = ascii(data, item_start.saturating_add(2), 5)?;
let codec_id = ascii(data, item_start.saturating_add(7), 4)?;
let flags = byte(data, item_start.saturating_add(12))?;
let is_multiangle = (flags.wrapping_shr(4) & 0x01) != 0;
let time_in = read_time(data, item_start.saturating_add(14))?;
let time_out = read_time(data, item_start.saturating_add(18))?;
let relative_time_in = total_length(&stream_clips);
let length = time_out - time_in;
let relative_time_out = relative_time_in + length;
let relative_length = length / relative_time_in;
let clip = TsStreamClip {
name: format!("{item_name}{}", clip_extension(&codec_id)),
time_in,
time_out,
length,
relative_time_in,
relative_time_out,
relative_length,
..TsStreamClip::default()
};
let main_index = stream_clips.len();
stream_clips.push(clip);
chapter_clips.push(main_index);
let mut pos_item = item_start.saturating_add(34);
if is_multiangle {
let angles = byte(data, pos_item)?;
pos_item = pos_item.saturating_add(2);
for angle in 0..angles.saturating_sub(1) {
let angle_name = ascii(data, pos_item, 5)?;
let angle_codec = ascii(data, pos_item.saturating_add(5), 4)?;
pos_item = pos_item.saturating_add(10);
stream_clips.push(TsStreamClip {
angle_index: i32::from(angle.wrapping_add(1)),
name: format!("{angle_name}{}", clip_extension(&angle_codec)),
time_in,
time_out,
relative_time_in,
relative_time_out,
length,
..TsStreamClip::default()
});
}
let extra_angles = i32::from(angles.saturating_sub(1));
angle_count = angle_count.max(extra_angles);
}
u16_be(data, pos_item)?;
let count_video = byte(data, pos_item.saturating_add(4))?;
let count_audio = byte(data, pos_item.saturating_add(5))?;
let count_presentation = byte(data, pos_item.saturating_add(6))?;
let count_interactive = byte(data, pos_item.saturating_add(7))?;
let count_secondary_audio = byte(data, pos_item.saturating_add(8))?;
let count_secondary_video = byte(data, pos_item.saturating_add(9))?;
let count_pip = byte(data, pos_item.saturating_add(10))?;
pos = pos_item.saturating_add(16);
for _ in 0..count_video {
pos = add_playlist_stream(data, pos, &mut playlist_streams, relative_length)?;
}
for _ in 0..count_audio {
pos = add_playlist_stream(data, pos, &mut playlist_streams, relative_length)?;
}
for _ in 0..count_presentation {
pos = add_playlist_stream(data, pos, &mut playlist_streams, relative_length)?;
}
for _ in 0..count_pip {
let (_, next) = create_playlist_stream(data, pos)?;
pos = next;
}
for _ in 0..count_interactive {
pos = add_playlist_stream(data, pos, &mut playlist_streams, relative_length)?;
}
for _ in 0..count_secondary_audio {
pos = add_playlist_stream(data, pos, &mut playlist_streams, relative_length)?;
pos = skip_comb_info(data, pos)?;
}
for _ in 0..count_secondary_video {
pos = add_playlist_stream(data, pos, &mut playlist_streams, relative_length)?;
pos = skip_comb_info(data, pos)?;
pos = skip_comb_info(data, pos)?;
}
pos = item_start.saturating_add(item_length).saturating_add(2);
}
let mut chapters: Vec<f64> = Vec::new();
let mut chap_pos = chapters_offset.saturating_add(4);
let chapter_count = u16_be(data, chap_pos)?;
chap_pos = chap_pos.saturating_add(2);
let final_total = total_length(&stream_clips);
for _ in 0..chapter_count {
let chapter_type = byte(data, chap_pos.saturating_add(1))?;
if chapter_type == 1 {
let stream_file_index = usize::from(u16_be(data, chap_pos.saturating_add(2))?);
let chapter_time = f64::from(u32_be(data, chap_pos.saturating_add(4))?);
let chapter_seconds = chapter_time / 45_000.0;
if let Some(clip) =
chapter_clips.get(stream_file_index).and_then(|&i| stream_clips.get_mut(i))
{
let relative_seconds = chapter_seconds - clip.time_in + clip.relative_time_in;
if final_total - relative_seconds > 1.0 {
clip.chapters.push(chapter_seconds);
chapters.push(relative_seconds);
}
}
}
chap_pos = chap_pos.saturating_add(14);
}
Ok(Self {
file_type,
name: name.to_ascii_uppercase(),
mvc_base_view_r,
chapters,
playlist_streams,
streams: BTreeMap::new(),
angle_streams: Vec::new(),
stream_clips,
angle_count,
})
}
}
fn total_length(clips: &[TsStreamClip]) -> f64 {
let mut length = 0.0;
for clip in clips {
if clip.angle_index == 0 {
length += clip.length;
}
}
length
}
fn clip_extension(codec_id: &str) -> &'static str {
if codec_id == "FMTS" { ".FMTS" } else { ".M2TS" }
}
fn read_time(data: &[u8], off: usize) -> Result<f64, BdError> {
let raw = u32_be(data, off)? & 0x7FFF_FFFF;
Ok(f64::from(raw) / 45_000.0)
}
fn skip_comb_info(data: &[u8], pos: usize) -> Result<usize, BdError> {
let count = byte(data, pos)?;
let padded = usize::from(count).saturating_add(usize::from(count & 1));
Ok(pos.saturating_add(2).saturating_add(padded))
}
fn add_playlist_stream(
data: &[u8],
pos: usize,
playlist_streams: &mut BTreeMap<u16, TsStream>,
relative_length: f64,
) -> Result<usize, BdError> {
let (stream, new_pos) = create_playlist_stream(data, pos)?;
if let Some(stream) = stream {
let pid = stream.base().pid.get();
if !playlist_streams.contains_key(&pid) || relative_length > 0.01 {
playlist_streams.insert(pid, stream);
}
}
Ok(new_pos)
}
fn create_playlist_stream(data: &[u8], pos: usize) -> Result<(Option<TsStream>, usize), BdError> {
let header_length = byte(data, pos)?;
let header_pos = pos.saturating_add(1);
let header_type = byte(data, header_pos)?;
let pid = match header_type {
1 => u16_be(data, header_pos.saturating_add(1))?,
2 => u16_be(data, header_pos.saturating_add(3))?,
3 | 4 => u16_be(data, header_pos.saturating_add(2))?,
_ => 0,
};
let length_pos = header_pos.saturating_add(usize::from(header_length));
let stream_length = byte(data, length_pos)?;
let stream_pos = length_pos.saturating_add(1);
let type_byte = byte(data, stream_pos)?;
let stream_type = TsStreamType::from_u8(type_byte);
let coding_pos = stream_pos.saturating_add(1);
let stream = build_playlist_stream(data, coding_pos, stream_type)?;
let new_pos = stream_pos.saturating_add(usize::from(stream_length));
let stream = stream.map(|mut stream| {
stream.base_mut().pid = Pid::new(pid);
stream.base_mut().stream_type = stream_type;
stream
});
Ok((stream, new_pos))
}
fn build_playlist_stream(
data: &[u8],
coding: usize,
stream_type: TsStreamType,
) -> Result<Option<TsStream>, BdError> {
Ok(match stream_type {
#[expect(
clippy::match_same_arms,
reason = "MVC is a recognised coding type, deliberately not the Unknown default"
)]
TsStreamType::MvcVideo => None,
TsStreamType::HevcVideo
| TsStreamType::AvcVideo
| TsStreamType::Mpeg1Video
| TsStreamType::Mpeg2Video
| TsStreamType::Vc1Video => {
let format = byte(data, coding)?;
let mut stream = TsVideoStream::default();
stream.set_video_format(TsVideoFormat::from_u8(format.wrapping_shr(4)));
stream.set_frame_rate(TsFrameRate::from_u8(format & 0x0F));
Some(TsStream::Video(stream))
}
TsStreamType::Ac3Audio
| TsStreamType::Ac3PlusAudio
| TsStreamType::Ac3PlusSecondaryAudio
| TsStreamType::Ac3TrueHdAudio
| TsStreamType::DtsAudio
| TsStreamType::DtsHdAudio
| TsStreamType::DtsHdMasterAudio
| TsStreamType::DtsHdSecondaryAudio
| TsStreamType::LpcmAudio
| TsStreamType::Mpeg1Audio
| TsStreamType::Mpeg2Audio
| TsStreamType::Mpeg2AacAudio
| TsStreamType::Mpeg4AacAudio => {
let format = byte(data, coding)?;
let language = ascii(data, coding.saturating_add(1), 3)?;
let mut stream = TsAudioStream {
channel_layout: TsChannelLayout::from_u8(format.wrapping_shr(4)),
sample_rate: TsAudioStream::convert_sample_rate(TsSampleRate::from_u8(
format & 0x0F,
)),
..TsAudioStream::default()
};
stream.base.set_language_code(&language);
Some(TsStream::Audio(stream))
}
TsStreamType::InteractiveGraphics | TsStreamType::PresentationGraphics => {
let language = ascii(data, coding, 3)?;
let mut stream = TsGraphicsStream::default();
stream.base.set_language_code(&language);
Some(TsStream::Graphics(stream))
}
TsStreamType::Subtitle => {
let language = ascii(data, coding.saturating_add(1), 3)?;
let mut stream = TsTextStream::default();
stream.base.set_language_code(&language);
Some(TsStream::Text(stream))
}
TsStreamType::Unknown => None,
})
}
#[cfg(test)]
mod tests {
use proptest::prelude::{any, proptest};
use super::TsPlaylistFile;
use crate::primitives::Pid;
use crate::stream::{
TsAspectRatio, TsAudioStream, TsChannelLayout, TsFrameRate, TsStream, TsStreamType,
TsVideoFormat, TsVideoStream,
};
fn pl_stream(header_type: u8, pid: u16, stream_type: u8, coding: [u8; 4]) -> Vec<u8> {
let [ph, pl] = pid.to_be_bytes();
let mut header = vec![header_type];
match header_type {
1 => header.extend_from_slice(&[ph, pl]),
2 => header.extend_from_slice(&[0, 0, ph, pl]),
3 | 4 => header.extend_from_slice(&[0, ph, pl]),
_ => {}
}
header.resize(9, 0);
let mut entry = vec![9]; entry.extend_from_slice(&header);
entry.push(5); entry.push(stream_type);
entry.extend_from_slice(&coding);
entry
}
fn comb_info(refs: &[u8]) -> Vec<u8> {
let mut block = vec![u8::try_from(refs.len()).unwrap(), 0];
block.extend_from_slice(refs);
if refs.len() % 2 == 1 {
block.push(0);
}
block
}
fn build_item(
name: &str,
in_time: u32,
out_time: u32,
angles: Option<&[&str]>,
counts: [u8; 7],
stream_bytes: &[u8],
) -> Vec<u8> {
build_item_codec(name, *b"M2TS", in_time, out_time, angles, counts, stream_bytes)
}
fn build_item_codec(
name: &str,
codec: [u8; 4],
in_time: u32,
out_time: u32,
angles: Option<&[&str]>,
counts: [u8; 7],
stream_bytes: &[u8],
) -> Vec<u8> {
let mut body = Vec::new();
let mut name_bytes = name.as_bytes().to_vec();
name_bytes.resize(5, 0);
body.extend_from_slice(&name_bytes); body.extend_from_slice(&codec); body.push(0); body.push(if angles.is_some() { 0x10 } else { 0 }); body.push(0); body.extend_from_slice(&in_time.to_be_bytes()); body.extend_from_slice(&out_time.to_be_bytes()); body.extend_from_slice(&[0_u8; 12]); if let Some(angle_names) = angles {
body.push(u8::try_from(angle_names.len().wrapping_add(1)).unwrap()); body.push(0); for angle_name in angle_names {
let mut ab = angle_name.as_bytes().to_vec();
ab.resize(5, 0);
body.extend_from_slice(&ab); body.extend_from_slice(&codec); body.push(0); }
}
body.extend_from_slice(&[0_u8; 2]); body.extend_from_slice(&[0_u8; 2]); body.extend_from_slice(&counts); body.extend_from_slice(&[0_u8; 5]); body.extend_from_slice(stream_bytes);
let mut item = u16::try_from(body.len()).unwrap().to_be_bytes().to_vec();
item.extend_from_slice(&body);
item
}
fn chapter_entry(chapter_type: u8, file_index: u16, chapter_time: u32) -> Vec<u8> {
let mut entry = vec![0, chapter_type];
entry.extend_from_slice(&file_index.to_be_bytes());
entry.extend_from_slice(&chapter_time.to_be_bytes());
entry.resize(14, 0);
entry
}
fn build_mpls(misc_flags: u8, items: &[Vec<u8>], chapters: &[Vec<u8>]) -> 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(&u16::try_from(items.len()).unwrap().to_be_bytes());
playlist.extend_from_slice(&[0_u8; 2]); for item in items {
playlist.extend_from_slice(item);
}
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 in chapters {
mark.extend_from_slice(chapter);
}
let mut buf = Vec::new();
buf.extend_from_slice(b"MPLS0300");
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(misc_flags); buf.resize(playlist_offset, 0);
buf.extend_from_slice(&playlist);
buf.extend_from_slice(&mark);
buf
}
fn comprehensive_mpls() -> Vec<u8> {
let mut streams0 = Vec::new();
streams0.extend(pl_stream(1, 0x1011, 0x1B, [0x62, 0x30, 0, 0])); streams0.extend(pl_stream(1, 0x1100, 0x86, [0x61, b'd', b'e', b'u'])); streams0.extend(pl_stream(3, 0x1200, 0x90, [b'e', b'n', b'g', 0])); streams0.extend(pl_stream(1, 0x1A00, 0x92, [0, b'j', b'p', b'n'])); streams0.extend(pl_stream(2, 0x1300, 0x91, [b'd', b'e', b'u', 0])); streams0.extend(pl_stream(4, 0x1101, 0x81, [0x31, b'f', b'r', b'a'])); streams0.extend(comb_info(&[])); streams0.extend(pl_stream(1, 0x1B01, 0x20, [0, 0, 0, 0])); streams0.extend(comb_info(&[])); streams0.extend(comb_info(&[])); let item0 =
build_item("00000", 2_700_000, 4_500_000, None, [1, 1, 2, 1, 1, 1, 0], &streams0);
let streams1 = pl_stream(1, 0x1011, 0x1B, [0x24, 0x20, 0, 0]); let item1 =
build_item("00017", 2_700_000, 3_600_000, None, [1, 0, 0, 0, 0, 0, 0], &streams1);
let chapters = [
chapter_entry(1, 0, 2_700_000), chapter_entry(1, 1, 2_700_000), chapter_entry(2, 0, 0), chapter_entry(1, 1, 3_555_000),
chapter_entry(1, 99, 2_700_000), ];
build_mpls(0x10, &[item0, item1], &chapters)
}
#[test]
fn scan_parses_a_full_playlist() {
let buf = comprehensive_mpls();
let file = TsPlaylistFile::scan("00000.mpls", &buf).unwrap();
assert_eq!(file.file_type, "MPLS0300");
assert_eq!(file.name, "00000.MPLS");
assert!(file.mvc_base_view_r); assert_eq!(file.angle_count, 0);
assert_eq!(file.stream_clips.len(), 2);
let clip0 = file.stream_clips.first().unwrap();
assert_eq!(clip0.name, "00000.M2TS");
assert_eq!(clip0.time_in.to_bits(), 60.0_f64.to_bits());
assert_eq!(clip0.time_out.to_bits(), 100.0_f64.to_bits());
assert_eq!(clip0.length.to_bits(), 40.0_f64.to_bits());
assert_eq!(clip0.relative_time_in.to_bits(), 0.0_f64.to_bits());
assert_eq!(clip0.relative_time_out.to_bits(), 40.0_f64.to_bits()); assert!(clip0.relative_length.is_infinite()); assert_eq!(clip0.chapters, vec![60.0]); let clip1 = file.stream_clips.get(1).unwrap();
assert_eq!(clip1.name, "00017.M2TS");
assert_eq!(clip1.relative_time_in.to_bits(), 40.0_f64.to_bits());
assert_eq!(clip1.relative_time_out.to_bits(), 60.0_f64.to_bits());
assert_eq!(clip1.relative_length.to_bits(), 0.5_f64.to_bits());
let pids: Vec<u16> = file.playlist_streams.keys().copied().collect();
assert_eq!(pids, vec![0x1011, 0x1100, 0x1101, 0x1200, 0x1300, 0x1A00]);
let kind = |pid: u16| file.playlist_streams.get(&pid).map(TsStream::stream_type);
assert_eq!(kind(0x1011), Some(TsStreamType::AvcVideo));
assert_eq!(kind(0x1100), Some(TsStreamType::DtsHdMasterAudio));
assert_eq!(kind(0x1101), Some(TsStreamType::Ac3Audio));
assert_eq!(kind(0x1200), Some(TsStreamType::PresentationGraphics));
assert_eq!(kind(0x1300), Some(TsStreamType::InteractiveGraphics));
assert_eq!(kind(0x1A00), Some(TsStreamType::Subtitle));
let mut audio = TsAudioStream {
channel_layout: TsChannelLayout::ChannellayoutMulti,
sample_rate: 48_000,
..TsAudioStream::default()
};
audio.base.set_language_code("deu");
audio.base.pid = Pid::new(0x1100);
audio.base.stream_type = TsStreamType::DtsHdMasterAudio;
assert_eq!(file.playlist_streams.get(&0x1100), Some(&TsStream::Audio(audio)));
let mut video = TsVideoStream::default();
video.set_video_format(TsVideoFormat::Videoformat576i);
video.set_frame_rate(TsFrameRate::Framerate29_97);
video.base.pid = Pid::new(0x1011);
video.base.stream_type = TsStreamType::AvcVideo;
assert_eq!(file.playlist_streams.get(&0x1011), Some(&TsStream::Video(video)));
assert_eq!(file.chapters, vec![0.0, 40.0]);
}
fn multiangle_mpls() -> Vec<u8> {
let v = pl_stream(1, 0x1011, 0x1B, [0x62, 0x30, 0, 0]);
let item0 = build_item(
"00000",
2_700_000,
4_500_000,
Some(&["00010", "00021"]),
[1, 0, 0, 0, 0, 0, 0],
&v,
);
let item1 =
build_item("00017", 2_700_000, 3_600_000, Some(&["00030"]), [1, 0, 0, 0, 0, 0, 0], &v);
build_mpls(0, &[item0, item1], &[])
}
#[test]
fn scan_handles_multiangle_items() {
let file = TsPlaylistFile::scan("p.mpls", &multiangle_mpls()).unwrap();
assert_eq!(file.angle_count, 2);
assert_eq!(file.stream_clips.len(), 5);
assert_eq!(file.stream_clips.get(1).unwrap().angle_index, 1);
assert_eq!(file.stream_clips.get(1).unwrap().name, "00010.M2TS");
let angle = file.stream_clips.get(4).unwrap();
assert_eq!(angle.angle_index, 1);
assert_eq!(angle.name, "00030.M2TS");
assert_eq!(angle.time_in.to_bits(), 60.0_f64.to_bits());
assert_eq!(angle.time_out.to_bits(), 80.0_f64.to_bits());
assert_eq!(angle.length.to_bits(), 20.0_f64.to_bits());
assert_eq!(angle.relative_time_in.to_bits(), 40.0_f64.to_bits());
assert_eq!(angle.relative_time_out.to_bits(), 60.0_f64.to_bits());
assert!(!file.mvc_base_view_r); }
#[test]
fn scan_resolves_fmts_clips_to_fmts_stream_files() {
let fmts = build_item_codec(
"00000",
*b"FMTS",
0,
4_500_000,
Some(&["00009"]),
[0, 0, 0, 0, 0, 0, 0],
&[],
);
let m2ts = build_item("00100", 0, 4_500_000, None, [0, 0, 0, 0, 0, 0, 0], &[]);
let file = TsPlaylistFile::scan("00000.mpls", &build_mpls(0, &[fmts, m2ts], &[])).unwrap();
assert_eq!(file.stream_clips.first().unwrap().name, "00000.FMTS");
assert_eq!(file.stream_clips.get(1).unwrap().name, "00009.FMTS"); assert_eq!(file.stream_clips.get(2).unwrap().name, "00100.M2TS"); }
#[test]
fn duplicate_pid_at_the_relative_length_threshold_is_not_overwritten() {
let v0 = pl_stream(1, 0x1011, 0x1B, [0x62, 0x30, 0, 0]); let item0 = build_item("00000", 0, 4_500_000, None, [1, 0, 0, 0, 0, 0, 0], &v0); let v1 = pl_stream(1, 0x1011, 0x1B, [0x24, 0x20, 0, 0]); let item1 = build_item("00017", 0, 45_000, None, [1, 0, 0, 0, 0, 0, 0], &v1); let buf = build_mpls(0, &[item0, item1], &[]);
let file = TsPlaylistFile::scan("p.mpls", &buf).unwrap();
let mut video = TsVideoStream::default();
video.set_video_format(TsVideoFormat::Videoformat1080p);
video.set_frame_rate(TsFrameRate::Framerate24);
video.base.pid = Pid::new(0x1011);
video.base.stream_type = TsStreamType::AvcVideo;
assert_eq!(file.playlist_streams.get(&0x1011), Some(&TsStream::Video(video)));
}
#[test]
fn header_types_select_the_pid_offset() {
let mut streams = Vec::new();
for (header_type, pid) in
[(1_u8, 0x1001_u16), (2, 0x1002), (3, 0x1003), (4, 0x1004), (7, 0x1005)]
{
streams.extend(pl_stream(header_type, pid, 0x1B, [0x62, 0x30, 0, 0]));
}
let item = build_item("00000", 2_700_000, 4_500_000, None, [5, 0, 0, 0, 0, 0, 0], &streams);
let buf = build_mpls(0, &[item], &[]);
let file = TsPlaylistFile::scan("p.mpls", &buf).unwrap();
let pids: Vec<u16> = file.playlist_streams.keys().copied().collect();
assert_eq!(pids, vec![0x0000, 0x1001, 0x1002, 0x1003, 0x1004]);
}
fn pip_pg_mpls() -> Vec<u8> {
let mut streams = Vec::new();
streams.extend(pl_stream(1, 0x1200, 0x90, [b'e', b'n', b'g', 0])); streams.extend(pl_stream(1, 0x1210, 0x90, [b'f', b'r', b'a', 0])); streams.extend(pl_stream(1, 0x1300, 0x91, [b'd', b'e', b'u', 0])); let item = build_item("00000", 2_700_000, 4_500_000, None, [0, 0, 1, 1, 0, 0, 1], &streams);
build_mpls(0, &[item], &[])
}
#[test]
fn pip_pg_entries_are_consumed_inside_the_pg_section() {
let file = TsPlaylistFile::scan("p.mpls", &pip_pg_mpls()).unwrap();
let pids: Vec<u16> = file.playlist_streams.keys().copied().collect();
assert_eq!(pids, vec![0x1200, 0x1300]);
let kind = |pid: u16| file.playlist_streams.get(&pid).map(TsStream::stream_type);
assert_eq!(kind(0x1200), Some(TsStreamType::PresentationGraphics));
assert_eq!(kind(0x1300), Some(TsStreamType::InteractiveGraphics));
}
#[test]
fn secondary_entries_skip_their_variable_comb_info_lists() {
let mut streams = Vec::new();
streams.extend(pl_stream(1, 0x1A10, 0xA1, [0x31, b'e', b'n', b'g']));
streams.extend(comb_info(&[1])); streams.extend(pl_stream(1, 0x1A11, 0xA2, [0x31, b'f', b'r', b'a']));
streams.extend(comb_info(&[])); streams.extend(pl_stream(1, 0x1B10, 0x1B, [0x62, 0x30, 0, 0]));
streams.extend(comb_info(&[1, 2])); streams.extend(comb_info(&[3])); streams.extend(pl_stream(1, 0x1B11, 0x1B, [0x62, 0x30, 0, 0]));
streams.extend(comb_info(&[]));
streams.extend(comb_info(&[]));
let item = build_item("00000", 2_700_000, 4_500_000, None, [0, 0, 0, 0, 2, 2, 0], &streams);
let buf = build_mpls(0, &[item], &[]);
let file = TsPlaylistFile::scan("p.mpls", &buf).unwrap();
let pids: Vec<u16> = file.playlist_streams.keys().copied().collect();
assert_eq!(pids, vec![0x1A10, 0x1A11, 0x1B10, 0x1B11]);
let kind = |pid: u16| file.playlist_streams.get(&pid).map(TsStream::stream_type);
assert_eq!(kind(0x1A10), Some(TsStreamType::Ac3PlusSecondaryAudio));
assert_eq!(kind(0x1A11), Some(TsStreamType::DtsHdSecondaryAudio));
assert_eq!(kind(0x1B10), Some(TsStreamType::AvcVideo));
assert_eq!(kind(0x1B11), Some(TsStreamType::AvcVideo));
}
#[test]
fn unknown_and_mvc_stream_types_yield_no_stream() {
let mut streams = pl_stream(1, 0x1500, 0x99, [0, 0, 0, 0]); streams.extend(pl_stream(1, 0x1600, 0x20, [0, 0, 0, 0])); let item = build_item("00000", 2_700_000, 4_500_000, None, [2, 0, 0, 0, 0, 0, 0], &streams);
let buf = build_mpls(0, &[item], &[]);
let file = TsPlaylistFile::scan("p.mpls", &buf).unwrap();
assert!(file.playlist_streams.is_empty());
assert_eq!(file.stream_clips.len(), 1);
}
#[test]
fn mpls_video_attributes_carry_no_aspect_ratio() {
let streams = pl_stream(1, 0x1011, 0x24, [0x62, 0x20, 0, 0]); let item = build_item("00000", 2_700_000, 4_500_000, None, [1, 0, 0, 0, 0, 0, 0], &streams);
let buf = build_mpls(0, &[item], &[]);
let file = TsPlaylistFile::scan("p.mpls", &buf).unwrap();
let mut video = TsVideoStream::default();
video.set_video_format(TsVideoFormat::Videoformat1080p);
video.set_frame_rate(TsFrameRate::Framerate24);
video.base.pid = Pid::new(0x1011);
video.base.stream_type = TsStreamType::HevcVideo;
assert_eq!(video.aspect_ratio, TsAspectRatio::Unknown);
assert_eq!(file.playlist_streams.get(&0x1011), Some(&TsStream::Video(video)));
}
#[test]
fn read_time_masks_the_sign_bit() {
let in_time = 0x8000_0000_u32 | 0x0029_32E0; let out_time = 0x8000_0000_u32 | 0x0044_AA20; let v = pl_stream(1, 0x1011, 0x1B, [0x62, 0x30, 0, 0]);
let item = build_item("00000", in_time, out_time, None, [1, 0, 0, 0, 0, 0, 0], &v);
let buf = build_mpls(0, &[item], &[]);
let file = TsPlaylistFile::scan("p.mpls", &buf).unwrap();
let clip = file.stream_clips.first().unwrap();
assert_eq!(clip.time_in.to_bits(), 60.0_f64.to_bits());
assert_eq!(clip.time_out.to_bits(), 100.0_f64.to_bits());
}
#[test]
fn scan_accepts_all_four_magics_and_rejects_others() {
for magic in ["MPLS0100", "MPLS0200", "MPLS0240", "MPLS0300"] {
let mut buf = comprehensive_mpls();
buf.splice(0..8, magic.bytes());
assert!(TsPlaylistFile::scan("p.mpls", &buf).is_ok(), "magic {magic}");
}
let mut bad = comprehensive_mpls();
bad.splice(0..8, *b"MPLSXXXX");
assert_eq!(
TsPlaylistFile::scan("p.mpls", &bad).unwrap_err().to_string(),
"unknown file type: MPLSXXXX"
);
}
#[test]
fn scan_never_panics_on_truncation() {
for full in [comprehensive_mpls(), multiangle_mpls(), pip_pg_mpls()] {
for len in 0..=full.len() {
let chunk = full.get(..len).unwrap();
drop(TsPlaylistFile::scan("t.mpls", chunk));
}
assert!(TsPlaylistFile::scan("t.mpls", &full).is_ok());
let half = full.get(..full.len() / 2).unwrap();
assert_eq!(
TsPlaylistFile::scan("t.mpls", half).unwrap_err().to_string(),
"unexpected end of input"
);
}
}
#[test]
fn scan_rejects_short_headers() {
let eof = "unexpected end of input";
assert_eq!(TsPlaylistFile::scan("e", &[]).unwrap_err().to_string(), eof);
assert_eq!(TsPlaylistFile::scan("e", b"MPLS0300").unwrap_err().to_string(), eof);
}
proptest! {
#[test]
fn scan_never_panics_on_arbitrary_input(data in any::<Vec<u8>>()) {
drop(TsPlaylistFile::scan("fuzz.mpls", &data));
}
#[test]
fn scan_round_trips_built_playlists(pid in any::<u16>(), in_time in 0_u32..0x7FFF_FFFF) {
let out_time = in_time.saturating_add(2_700_000);
let v = pl_stream(1, pid, 0x1B, [0x62, 0x30, 0, 0]);
let item = build_item("00000", in_time, out_time, None, [1, 0, 0, 0, 0, 0, 0], &v);
let buf = build_mpls(0, &[item], &[]);
let file = TsPlaylistFile::scan("p.mpls", &buf).unwrap();
let stream = file.playlist_streams.get(&pid).unwrap();
assert_eq!(stream.pid(), Pid::new(pid));
assert_eq!(stream.stream_type(), TsStreamType::AvcVideo);
}
}
}