Add MKV muxer, IsoWriter, disc pipeline, and network tests

- MKV muxer: EBML header, segment, cluster, cues, multi-track, keyframe flags — 6 tests
- MkvStream: BD-TS roundtrip, metadata preservation — 2 tests
- IsoWriter: valid UDF, file size update, custom names, empty content — 4 tests
- Disc pipeline: format detection (UHD/BD/DVD), content format, capacity, duration — 5 tests
- Network: listen/connect roundtrip, metadata flow — 2 tests (ignored for CI)
- Encryption: no AACS dir, no keydb — 2 tests
- 297 tests total, all passing
This commit is contained in:
MattJackson
2026-04-11 17:27:36 +00:00
parent 515f2f6bc2
commit cd575b7221
6 changed files with 1001 additions and 0 deletions
+236
View File
@@ -140,3 +140,239 @@ fn scan_options_with_keydb_pathbuf() {
let opts = ScanOptions::with_keydb(path.clone());
assert_eq!(opts.keydb_path.unwrap(), path);
}
// ── detect_format integration tests ───────────────────────────────────────
use libfreemkv::{Codec, ColorSpace, ContentFormat, HdrFormat, Stream, VideoStream};
fn title_with_video(codec: Codec, resolution: &str, content_format: ContentFormat) -> DiscTitle {
DiscTitle {
playlist: "00800.mpls".into(),
playlist_id: 800,
duration_secs: 7200.0,
size_bytes: 0,
clips: Vec::new(),
streams: vec![Stream::Video(VideoStream {
pid: 0x1011,
codec,
resolution: resolution.into(),
frame_rate: "23.976".into(),
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
secondary: false,
label: String::new(),
})],
extents: Vec::new(),
content_format,
}
}
#[test]
fn disc_title_duration_display_edge_cases() {
let mut t = DiscTitle::empty();
// 0 seconds
t.duration_secs = 0.0;
assert_eq!(t.duration_display(), "0h 00m");
// 1 second
t.duration_secs = 1.0;
assert_eq!(t.duration_display(), "0h 00m");
// 59 minutes
t.duration_secs = 59.0 * 60.0;
assert_eq!(t.duration_display(), "0h 59m");
// 24 hours exactly
t.duration_secs = 24.0 * 3600.0;
assert_eq!(t.duration_display(), "24h 00m");
}
#[test]
fn content_format_default_bdts() {
let t = title_with_video(Codec::H264, "1080p", ContentFormat::BdTs);
assert_eq!(t.content_format, ContentFormat::BdTs);
}
#[test]
fn content_format_dvd_mpegps() {
let t = title_with_video(Codec::Mpeg2, "480i", ContentFormat::MpegPs);
assert_eq!(t.content_format, ContentFormat::MpegPs);
}
// ── UDF helpers for encryption resolution tests ───────────────────────────
/// Build an AVDP sector (tag_id=2) pointing to VDS at the given LBA.
fn make_avdp_sector(vds_lba: u32) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&2u16.to_le_bytes());
s[16..20].copy_from_slice(&vds_lba.to_le_bytes());
s[20..24].copy_from_slice(&(6u32 * SECTOR_SIZE as u32).to_le_bytes());
s
}
fn make_pvd_sector(volume_id: &str) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&1u16.to_le_bytes());
if !volume_id.is_empty() {
let id_bytes = volume_id.as_bytes();
s[24] = 8;
let copy_len = id_bytes.len().min(30);
s[25..25 + copy_len].copy_from_slice(&id_bytes[..copy_len]);
s[55] = (1 + copy_len) as u8;
}
s
}
fn make_partition_desc(partition_start: u32) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&5u16.to_le_bytes());
s[188..192].copy_from_slice(&partition_start.to_le_bytes());
s
}
fn make_lvd_sector_simple() -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&6u16.to_le_bytes());
s[268..272].copy_from_slice(&1u32.to_le_bytes());
s
}
fn make_terminator() -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&8u16.to_le_bytes());
s
}
fn make_fsd_sector(root_meta_lba: u32) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&256u16.to_le_bytes());
s[400..404].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes());
s[404..408].copy_from_slice(&root_meta_lba.to_le_bytes());
s
}
fn make_dir_icb(data_meta_lba: u32, data_len: u32) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&266u16.to_le_bytes());
s[56..64].copy_from_slice(&(data_len as u64).to_le_bytes());
s[208..212].copy_from_slice(&0u32.to_le_bytes());
s[212..216].copy_from_slice(&8u32.to_le_bytes());
s[216..220].copy_from_slice(&data_len.to_le_bytes());
s[220..224].copy_from_slice(&data_meta_lba.to_le_bytes());
s
}
fn make_parent_fid() -> Vec<u8> {
let fid_len = ((38 + 0 + 0 + 3) & !3) as usize;
let mut fid = vec![0u8; fid_len];
fid[0..2].copy_from_slice(&257u16.to_le_bytes());
fid[18] = 0x08;
fid[19] = 0;
fid
}
fn make_fid(name: &str, icb_meta_lba: u32, is_dir: bool) -> Vec<u8> {
let mut name_bytes = vec![8u8];
name_bytes.extend_from_slice(name.as_bytes());
let l_fi = name_bytes.len() as u8;
let file_chars: u8 = if is_dir { 0x02 } else { 0x00 };
let fid_len = ((38 + 0 + l_fi as usize + 3) & !3) as usize;
let mut fid = vec![0u8; fid_len];
fid[0..2].copy_from_slice(&257u16.to_le_bytes());
fid[18] = file_chars;
fid[19] = l_fi;
fid[20..24].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes());
fid[24..28].copy_from_slice(&icb_meta_lba.to_le_bytes());
fid[36..38].copy_from_slice(&0u16.to_le_bytes());
fid[38..38 + name_bytes.len()].copy_from_slice(&name_bytes);
fid
}
/// Build a minimal UDF image with an empty root directory (no /AACS).
fn build_minimal_udf(reader: &mut MockSectorReader) {
let partition_start: u32 = 512;
reader.sectors.insert(256, make_avdp_sector(32));
reader.sectors.insert(32, make_pvd_sector("TEST_DISC"));
reader.sectors.insert(33, make_partition_desc(partition_start));
reader.sectors.insert(34, make_lvd_sector_simple());
reader.sectors.insert(35, make_terminator());
reader.sectors.insert(partition_start, make_fsd_sector(1));
let parent_fid = make_parent_fid();
let dir_data_len = parent_fid.len() as u32;
reader
.sectors
.insert(partition_start + 1, make_dir_icb(2, dir_data_len));
let mut sector = vec![0u8; SECTOR_SIZE];
sector[..parent_fid.len()].copy_from_slice(&parent_fid);
reader.sectors.insert(partition_start + 2, sector);
}
/// Build a UDF image with an /AACS directory (empty).
fn build_udf_with_aacs_dir(reader: &mut MockSectorReader) {
let partition_start: u32 = 512;
reader.sectors.insert(256, make_avdp_sector(32));
reader.sectors.insert(32, make_pvd_sector("ENCRYPTED_DISC"));
reader.sectors.insert(33, make_partition_desc(partition_start));
reader.sectors.insert(34, make_lvd_sector_simple());
reader.sectors.insert(35, make_terminator());
reader.sectors.insert(partition_start, make_fsd_sector(1));
// Root -> AACS (dir)
let parent_fid = make_parent_fid();
let aacs_fid = make_fid("AACS", 3, true);
let mut root_data = Vec::new();
root_data.extend_from_slice(&parent_fid);
root_data.extend_from_slice(&aacs_fid);
let root_data_len = root_data.len() as u32;
reader
.sectors
.insert(partition_start + 1, make_dir_icb(2, root_data_len));
let mut sector = vec![0u8; SECTOR_SIZE];
sector[..root_data.len()].copy_from_slice(&root_data);
reader.sectors.insert(partition_start + 2, sector);
// AACS dir (empty)
let aacs_parent = make_parent_fid();
let aacs_data_len = aacs_parent.len() as u32;
reader
.sectors
.insert(partition_start + 3, make_dir_icb(4, aacs_data_len));
let mut sector2 = vec![0u8; SECTOR_SIZE];
sector2[..aacs_parent.len()].copy_from_slice(&aacs_parent);
reader.sectors.insert(partition_start + 4, sector2);
}
#[test]
fn resolve_encryption_no_aacs_dir() {
// A UDF image with no /AACS directory should result in no encryption
let mut reader = MockSectorReader::new();
build_minimal_udf(&mut reader);
let opts = ScanOptions::default();
let disc = Disc::scan_image(&mut reader, 1000, &opts).unwrap();
assert!(!disc.encrypted, "disc without /AACS should not be encrypted");
assert!(disc.aacs.is_none(), "aacs should be None without /AACS dir");
}
#[test]
fn resolve_encryption_no_keydb() {
// A UDF image with /AACS directory but no keydb path -> aacs is None
let mut reader = MockSectorReader::new();
build_udf_with_aacs_dir(&mut reader);
// No keydb configured and no standard keydb on the system
let opts = ScanOptions::with_keydb("/nonexistent/path/KEYDB.cfg");
let disc = Disc::scan_image(&mut reader, 1000, &opts).unwrap();
// The disc detects encryption but can't resolve keys without a keydb
assert!(
disc.aacs.is_none(),
"aacs should be None when keydb is unavailable"
);
}
+139
View File
@@ -616,3 +616,142 @@ fn mkvstream_meta_sets_title() {
assert_eq!(info.duration_secs, 7200.0);
assert_eq!(info.streams.len(), 4);
}
// ── MkvStream additional tests ──────────────────────────────
#[test]
fn mkvstream_roundtrip_bdts() {
// Write BD-TS packets through MkvStream and verify the pipeline works.
// Without real codec headers (SPS/PPS), the muxer stays in scanning phase.
// With a title that has no video streams (audio-only), codec scanning is
// skipped and the muxer enters streaming mode immediately, producing EBML output.
let dt = DiscTitle {
playlist: "Audio Only".into(),
playlist_id: 0,
duration_secs: 60.0,
size_bytes: 0,
clips: Vec::new(),
streams: vec![Stream::Audio(AudioStream {
pid: 0x1100,
codec: Codec::Ac3,
channels: "5.1".into(),
language: "eng".into(),
sample_rate: "48kHz".into(),
secondary: false,
label: "English".into(),
})],
extents: Vec::new(),
content_format: ContentFormat::BdTs,
};
let output = Cursor::new(Vec::new());
let mut stream = MkvStream::new(output).meta(&dt).max_buffer(1024 * 1024);
// Write BD-TS packets targeting the audio PID 0x1100
for i in 0..10u8 {
let mut pkt = [0u8; 192];
pkt[4] = 0x47;
// PID 0x1100
pkt[5] = 0x11;
pkt[6] = 0x00;
pkt[7] = 0x10;
pkt[8] = i;
stream.write_all(&pkt).unwrap();
}
stream.finish().unwrap();
// Verify the info is correct
let info = stream.info();
assert_eq!(info.streams.len(), 1);
assert_eq!(info.playlist, "Audio Only");
}
#[test]
fn mkvstream_meta_preserves_all_streams() {
let dt = DiscTitle {
playlist: "Stream Test".into(),
playlist_id: 0,
duration_secs: 3600.0,
size_bytes: 0,
clips: Vec::new(),
streams: vec![
Stream::Video(VideoStream {
pid: 0x1011,
codec: Codec::H264,
resolution: "1080p".into(),
frame_rate: "23.976".into(),
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
secondary: false,
label: "Main Video".into(),
}),
Stream::Audio(AudioStream {
pid: 0x1100,
codec: Codec::Ac3,
channels: "5.1".into(),
language: "eng".into(),
sample_rate: "48kHz".into(),
secondary: false,
label: "English".into(),
}),
Stream::Audio(AudioStream {
pid: 0x1101,
codec: Codec::DtsHdMa,
channels: "7.1".into(),
language: "fra".into(),
sample_rate: "48kHz".into(),
secondary: false,
label: "French".into(),
}),
Stream::Subtitle(SubtitleStream {
pid: 0x1200,
codec: Codec::Pgs,
language: "eng".into(),
forced: false,
}),
Stream::Subtitle(SubtitleStream {
pid: 0x1201,
codec: Codec::Pgs,
language: "fra".into(),
forced: true,
}),
],
extents: Vec::new(),
content_format: ContentFormat::BdTs,
};
let output = Cursor::new(Vec::new());
let stream = MkvStream::new(output).meta(&dt);
let info = stream.info();
assert_eq!(info.streams.len(), 5, "all 5 streams should be preserved");
assert_eq!(info.playlist, "Stream Test");
assert_eq!(info.duration_secs, 3600.0);
// Verify stream types preserved in order
assert!(matches!(&info.streams[0], Stream::Video(_)));
assert!(matches!(&info.streams[1], Stream::Audio(_)));
assert!(matches!(&info.streams[2], Stream::Audio(_)));
assert!(matches!(&info.streams[3], Stream::Subtitle(_)));
assert!(matches!(&info.streams[4], Stream::Subtitle(_)));
// Check specific attributes
if let Stream::Video(v) = &info.streams[0] {
assert_eq!(v.codec, Codec::H264);
}
if let Stream::Audio(a) = &info.streams[1] {
assert_eq!(a.language, "eng");
}
if let Stream::Audio(a) = &info.streams[2] {
assert_eq!(a.codec, Codec::DtsHdMa);
assert_eq!(a.language, "fra");
}
if let Stream::Subtitle(s) = &info.streams[3] {
assert!(!s.forced);
}
if let Stream::Subtitle(s) = &info.streams[4] {
assert!(s.forced);
}
}