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:
+118
@@ -1050,3 +1050,121 @@ fn format_samplerate(audio_rate: u8) -> String {
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Helper: build a DiscTitle with a single video stream at the given resolution.
|
||||
fn title_with_video(codec: Codec, resolution: &str) -> 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: ContentFormat::BdTs,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_format_uhd() {
|
||||
let titles = vec![title_with_video(Codec::Hevc, "2160p")];
|
||||
assert_eq!(Disc::detect_format(&titles), DiscFormat::Uhd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_format_bluray() {
|
||||
let titles = vec![title_with_video(Codec::H264, "1080p")];
|
||||
assert_eq!(Disc::detect_format(&titles), DiscFormat::BluRay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_format_dvd() {
|
||||
let titles = vec![title_with_video(Codec::Mpeg2, "480i")];
|
||||
assert_eq!(Disc::detect_format(&titles), DiscFormat::Dvd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_format_empty() {
|
||||
let titles: Vec<DiscTitle> = Vec::new();
|
||||
assert_eq!(Disc::detect_format(&titles), DiscFormat::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_format_default_bdts() {
|
||||
let t = title_with_video(Codec::H264, "1080p");
|
||||
assert_eq!(t.content_format, ContentFormat::BdTs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_format_dvd_mpegps() {
|
||||
let t = DiscTitle {
|
||||
content_format: ContentFormat::MpegPs,
|
||||
..title_with_video(Codec::Mpeg2, "480i")
|
||||
};
|
||||
assert_eq!(t.content_format, ContentFormat::MpegPs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_capacity_gb() {
|
||||
// Single-layer BD-25: ~12,219,392 sectors
|
||||
let disc = Disc {
|
||||
volume_id: String::new(),
|
||||
meta_title: None,
|
||||
format: DiscFormat::BluRay,
|
||||
capacity_sectors: 12_219_392,
|
||||
capacity_bytes: 12_219_392u64 * 2048,
|
||||
layers: 1,
|
||||
titles: Vec::new(),
|
||||
region: DiscRegion::Free,
|
||||
aacs: None,
|
||||
css: None,
|
||||
encrypted: false,
|
||||
content_format: ContentFormat::BdTs,
|
||||
};
|
||||
let gb = disc.capacity_gb();
|
||||
// 12,219,392 * 2048 / 1073741824 = ~23.3 GB
|
||||
assert!((gb - 23.3).abs() < 0.1, "expected ~23.3 GB, got {}", gb);
|
||||
|
||||
// Zero sectors
|
||||
let disc_zero = Disc {
|
||||
capacity_sectors: 0,
|
||||
capacity_bytes: 0,
|
||||
..disc
|
||||
};
|
||||
assert_eq!(disc_zero.capacity_gb(), 0.0);
|
||||
}
|
||||
|
||||
#[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
|
||||
t.duration_secs = 24.0 * 3600.0;
|
||||
assert_eq!(t.duration_display(), "24h 00m");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,3 +469,155 @@ fn write_fid(buf: &mut [u8], icb_lba: u32, name: &str, is_parent: bool) -> usize
|
||||
|
||||
padded
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Read a little-endian u16 from a byte slice at the given offset.
|
||||
fn le_u16(data: &[u8], off: usize) -> u16 {
|
||||
u16::from_le_bytes([data[off], data[off + 1]])
|
||||
}
|
||||
|
||||
/// Read a little-endian u32 from a byte slice at the given offset.
|
||||
fn le_u32(data: &[u8], off: usize) -> u32 {
|
||||
u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]])
|
||||
}
|
||||
|
||||
/// Read a little-endian u64 from a byte slice at the given offset.
|
||||
fn le_u64(data: &[u8], off: usize) -> u64 {
|
||||
u64::from_le_bytes([
|
||||
data[off],
|
||||
data[off + 1],
|
||||
data[off + 2],
|
||||
data[off + 3],
|
||||
data[off + 4],
|
||||
data[off + 5],
|
||||
data[off + 6],
|
||||
data[off + 7],
|
||||
])
|
||||
}
|
||||
|
||||
/// Get the sector at a given sector number from the output data.
|
||||
fn sector(data: &[u8], num: u32) -> &[u8] {
|
||||
let start = num as usize * SECTOR_SIZE as usize;
|
||||
&data[start..start + SECTOR_SIZE as usize]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isowriter_creates_valid_udf() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let mut w = IsoWriter::new(buf, "TEST_VOL", "00001.m2ts");
|
||||
w.start().unwrap();
|
||||
w.write_data(&[0xAA; 4096]).unwrap();
|
||||
w.finish().unwrap();
|
||||
let data = w.writer.into_inner();
|
||||
|
||||
// AVDP at sector 256 should have tag ID = 2
|
||||
let avdp = sector(&data, AVDP_SECTOR);
|
||||
assert_eq!(le_u16(avdp, 0), 2, "AVDP tag ID should be 2");
|
||||
|
||||
// VRS at sector 16 should contain "BEA01"
|
||||
let vrs = sector(&data, VRS_START);
|
||||
assert_eq!(
|
||||
&vrs[1..6],
|
||||
b"BEA01",
|
||||
"VRS sector 16 should contain BEA01"
|
||||
);
|
||||
|
||||
// FSD at metadata sector should have tag ID = 256
|
||||
let fsd = sector(&data, FSD_SECTOR);
|
||||
assert_eq!(le_u16(fsd, 0), 256, "FSD tag ID should be 256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isowriter_updates_file_size() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let mut w = IsoWriter::new(buf, "SIZE_TEST", "00001.m2ts");
|
||||
w.start().unwrap();
|
||||
|
||||
let test_data = vec![0x42u8; 8192]; // exactly 4 sectors
|
||||
let written = w.write_data(&test_data).unwrap();
|
||||
assert_eq!(written, 8192);
|
||||
|
||||
w.finish().unwrap();
|
||||
let data = w.writer.into_inner();
|
||||
|
||||
// Read m2ts ICB at M2TS_ICB_SECTOR and check information length at offset 56
|
||||
let icb = sector(&data, M2TS_ICB_SECTOR);
|
||||
let file_size = le_u64(icb, 56);
|
||||
assert_eq!(
|
||||
file_size, 8192,
|
||||
"m2ts ICB file size should match bytes written (8192), got {}",
|
||||
file_size
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isowriter_with_names() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let mut w = IsoWriter::new(buf, "MY_DISC", "00042.m2ts");
|
||||
w.start().unwrap();
|
||||
w.write_data(&[0x00; 2048]).unwrap();
|
||||
w.finish().unwrap();
|
||||
let data = w.writer.into_inner();
|
||||
|
||||
// Check PVD (sector 32) volume_id at offset 24 as d-string
|
||||
let pvd = sector(&data, VDS_START);
|
||||
// d-string: byte 0 = compression ID (8), then ASCII chars
|
||||
assert_eq!(pvd[24], 8, "PVD volume_id compression ID should be 8");
|
||||
assert_eq!(
|
||||
&pvd[25..32],
|
||||
b"MY_DISC",
|
||||
"PVD should contain volume_id 'MY_DISC'"
|
||||
);
|
||||
|
||||
// Check STREAM directory (sector 266) for m2ts filename in FID
|
||||
let stream_dir = sector(&data, STREAM_DIR_SECTOR);
|
||||
// The FID for the m2ts file should contain the filename after the parent entry.
|
||||
// Search for "00042.m2ts" in the sector data
|
||||
let name = b"00042.m2ts";
|
||||
let found = stream_dir
|
||||
.windows(name.len())
|
||||
.any(|w| w == name);
|
||||
assert!(
|
||||
found,
|
||||
"STREAM directory should contain m2ts filename '00042.m2ts'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isowriter_empty_content() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let mut w = IsoWriter::new(buf, "EMPTY", "00001.m2ts");
|
||||
w.start().unwrap();
|
||||
// No data written
|
||||
w.finish().unwrap();
|
||||
let data = w.writer.into_inner();
|
||||
|
||||
// Should still have valid UDF structure
|
||||
// AVDP at sector 256
|
||||
let avdp = sector(&data, AVDP_SECTOR);
|
||||
assert_eq!(le_u16(avdp, 0), 2, "AVDP tag should be present even with no data");
|
||||
|
||||
// VRS
|
||||
let vrs = sector(&data, VRS_START);
|
||||
assert_eq!(&vrs[1..6], b"BEA01");
|
||||
|
||||
// FSD
|
||||
let fsd = sector(&data, FSD_SECTOR);
|
||||
assert_eq!(le_u16(fsd, 0), 256);
|
||||
|
||||
// m2ts ICB should show 0 file size
|
||||
let icb = sector(&data, M2TS_ICB_SECTOR);
|
||||
let file_size = le_u64(icb, 56);
|
||||
assert_eq!(file_size, 0, "empty content should have 0 file size");
|
||||
|
||||
// Output should be at least DATA_START sectors (the header structure)
|
||||
assert!(
|
||||
data.len() >= DATA_START as usize * SECTOR_SIZE as usize,
|
||||
"output too small for valid UDF structure"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+224
@@ -423,3 +423,227 @@ fn parse_channels(s: &str) -> u8 {
|
||||
6
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Helper: search for a 4-byte big-endian EBML ID in a byte slice.
|
||||
fn find_id(data: &[u8], id: u32) -> Option<usize> {
|
||||
let bytes = id.to_be_bytes();
|
||||
// Determine how many leading zero bytes to skip
|
||||
let start = if bytes[0] != 0 {
|
||||
0
|
||||
} else if bytes[1] != 0 {
|
||||
1
|
||||
} else if bytes[2] != 0 {
|
||||
2
|
||||
} else {
|
||||
3
|
||||
};
|
||||
let needle = &bytes[start..];
|
||||
data.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
|
||||
fn make_video_track() -> MkvTrack {
|
||||
MkvTrack {
|
||||
track_type: ebml::TRACK_TYPE_VIDEO,
|
||||
codec_id: "V_MPEG4/ISO/AVC",
|
||||
language: "und".into(),
|
||||
name: String::new(),
|
||||
codec_private: Some(vec![0x00, 0x01, 0x02, 0x03]),
|
||||
is_default: true,
|
||||
is_forced: false,
|
||||
pixel_width: 1920,
|
||||
pixel_height: 1080,
|
||||
sample_rate: 0.0,
|
||||
channels: 0,
|
||||
bit_depth: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_audio_track() -> MkvTrack {
|
||||
MkvTrack {
|
||||
track_type: ebml::TRACK_TYPE_AUDIO,
|
||||
codec_id: "A_AC3",
|
||||
language: "eng".into(),
|
||||
name: "English".into(),
|
||||
codec_private: None,
|
||||
is_default: true,
|
||||
is_forced: false,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
sample_rate: 48000.0,
|
||||
channels: 6,
|
||||
bit_depth: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkv_writes_ebml_header() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let tracks = [make_video_track()];
|
||||
let muxer = MkvMuxer::new(buf, &tracks, Some("Test"), 120.0).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
// EBML header element ID: 0x1A45DFA3
|
||||
assert!(data.len() >= 4);
|
||||
assert_eq!(&data[0..4], &[0x1A, 0x45, 0xDF, 0xA3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkv_writes_segment() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let tracks = [make_video_track()];
|
||||
let muxer = MkvMuxer::new(buf, &tracks, None, 0.0).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
// Segment element ID: 0x18538067
|
||||
assert!(
|
||||
find_id(&data, ebml::SEGMENT).is_some(),
|
||||
"Segment element not found in output"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkv_write_frame_creates_cluster() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let tracks = [make_video_track()];
|
||||
let mut muxer = MkvMuxer::new(buf, &tracks, None, 60.0).unwrap();
|
||||
muxer
|
||||
.write_frame(0, 0, true, &[0xDE, 0xAD, 0xBE, 0xEF])
|
||||
.unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
assert!(
|
||||
find_id(&data, ebml::CLUSTER).is_some(),
|
||||
"Cluster element not found after write_frame"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkv_finish_writes_cues_element() {
|
||||
// Use a Vec wrapped in Cursor, then check after finish
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// We'll write to a Cursor, but finish() consumes self.
|
||||
// The trick: Cursor<Vec<u8>> - we can get data back via into_inner chain.
|
||||
// But MkvMuxer::finish consumes self and flushes writer.
|
||||
// We need a way to inspect the output. Let's use a wrapper.
|
||||
|
||||
struct SharedWriter(Arc<Mutex<Cursor<Vec<u8>>>>);
|
||||
impl Write for SharedWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.0.lock().unwrap().write(buf)
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.0.lock().unwrap().flush()
|
||||
}
|
||||
}
|
||||
impl Seek for SharedWriter {
|
||||
fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
|
||||
self.0.lock().unwrap().seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
|
||||
let writer = SharedWriter(shared.clone());
|
||||
let tracks = [make_video_track()];
|
||||
let mut muxer = MkvMuxer::new(writer, &tracks, Some("Cue Test"), 60.0).unwrap();
|
||||
muxer
|
||||
.write_frame(0, 0, true, &[0x01, 0x02, 0x03])
|
||||
.unwrap();
|
||||
muxer.finish().unwrap();
|
||||
|
||||
let data = shared.lock().unwrap().clone().into_inner();
|
||||
assert!(
|
||||
find_id(&data, ebml::CUES).is_some(),
|
||||
"Cues element (0x1C53BB6B) not found after finish()"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkv_multiple_tracks() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let tracks = [make_video_track(), make_audio_track()];
|
||||
let mut muxer = MkvMuxer::new(buf, &tracks, Some("Multi"), 120.0).unwrap();
|
||||
// Write frames to both tracks
|
||||
muxer
|
||||
.write_frame(0, 0, true, &[0x00, 0x00, 0x01])
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(1, 0, false, &[0x0B, 0x77, 0x00])
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(0, 40_000_000, false, &[0x00, 0x00, 0x01])
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(1, 32_000_000, false, &[0x0B, 0x77, 0x01])
|
||||
.unwrap();
|
||||
// Should not panic
|
||||
let data = muxer.writer.into_inner();
|
||||
assert!(data.len() > 100, "output too small for multi-track MKV");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkv_keyframe_flag() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let tracks = [make_video_track()];
|
||||
let mut muxer = MkvMuxer::new(buf, &tracks, None, 10.0).unwrap();
|
||||
|
||||
// Record position before first frame
|
||||
let pos_before_kf = muxer.writer.position();
|
||||
muxer
|
||||
.write_frame(0, 0, true, &[0xAA])
|
||||
.unwrap();
|
||||
let pos_after_kf = muxer.writer.position();
|
||||
|
||||
muxer
|
||||
.write_frame(0, 1_000_000, false, &[0xBB])
|
||||
.unwrap();
|
||||
let pos_after_nkf = muxer.writer.position();
|
||||
|
||||
let data = muxer.writer.into_inner();
|
||||
|
||||
// Extract the SimpleBlock regions
|
||||
let kf_region = &data[pos_before_kf as usize..pos_after_kf as usize];
|
||||
let nkf_region = &data[pos_after_kf as usize..pos_after_nkf as usize];
|
||||
|
||||
// In a SimpleBlock, after ID + size + track_vint + 2-byte timestamp,
|
||||
// the next byte is flags. Keyframe flag = 0x80, non-keyframe = 0x00.
|
||||
// Find the flags byte in each region: it's the byte after the 2-byte timestamp.
|
||||
// SimpleBlock ID is 0xA3. Find it and walk past ID + size + vint + ts.
|
||||
fn extract_flags(region: &[u8]) -> u8 {
|
||||
// Find 0xA3 (SimpleBlock ID)
|
||||
let sb_pos = region.iter().position(|&b| b == 0xA3).unwrap();
|
||||
// After ID: size (variable), track vint (1 byte for track<128), ts (2 bytes), flags (1 byte)
|
||||
// Size is 1 byte for small blocks (< 127 bytes)
|
||||
let after_id = sb_pos + 1;
|
||||
// Read VINT size: first byte has high bit set for 1-byte sizes
|
||||
let size_byte = region[after_id];
|
||||
let size_len = if size_byte & 0x80 != 0 { 1 } else { 2 };
|
||||
// Track VINT: 1 byte (track 1 = 0x81)
|
||||
let track_vint_pos = after_id + size_len;
|
||||
let track_vint_len = 1; // track 1 encoded as 0x81
|
||||
// 2-byte relative timestamp
|
||||
let ts_pos = track_vint_pos + track_vint_len;
|
||||
// flags byte
|
||||
let flags_pos = ts_pos + 2;
|
||||
region[flags_pos]
|
||||
}
|
||||
|
||||
let kf_flags = extract_flags(kf_region);
|
||||
let nkf_flags = extract_flags(nkf_region);
|
||||
|
||||
assert_eq!(
|
||||
kf_flags & 0x80,
|
||||
0x80,
|
||||
"keyframe flag should be set (0x80), got 0x{:02X}",
|
||||
kf_flags
|
||||
);
|
||||
assert_eq!(
|
||||
nkf_flags & 0x80,
|
||||
0x00,
|
||||
"non-keyframe flag should be clear, got 0x{:02X}",
|
||||
nkf_flags
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,3 +140,135 @@ impl Read for NetworkStream {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::disc::{
|
||||
AudioStream, Codec, ColorSpace, ContentFormat, HdrFormat, Stream, VideoStream,
|
||||
};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
|
||||
/// Build a DiscTitle with streams for metadata tests.
|
||||
fn sample_title() -> DiscTitle {
|
||||
DiscTitle {
|
||||
playlist: "NetworkTest".into(),
|
||||
playlist_id: 1,
|
||||
duration_secs: 3600.0,
|
||||
size_bytes: 0,
|
||||
clips: Vec::new(),
|
||||
streams: vec![
|
||||
Stream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec: Codec::Hevc,
|
||||
resolution: "2160p".into(),
|
||||
frame_rate: "23.976".into(),
|
||||
hdr: HdrFormat::Hdr10,
|
||||
color_space: ColorSpace::Bt2020,
|
||||
secondary: false,
|
||||
label: "Main".into(),
|
||||
}),
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1100,
|
||||
codec: Codec::TrueHd,
|
||||
channels: "7.1".into(),
|
||||
language: "eng".into(),
|
||||
sample_rate: "48kHz".into(),
|
||||
secondary: false,
|
||||
label: "English".into(),
|
||||
}),
|
||||
],
|
||||
extents: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // Requires TCP; may be flaky in CI environments
|
||||
fn network_listen_connect_roundtrip() {
|
||||
// Bind to OS-assigned port
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener); // Release so NetworkStream::listen can bind
|
||||
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
let addr_clone = addr.clone();
|
||||
|
||||
// Spawn listener in a thread
|
||||
let handle = std::thread::spawn(move || {
|
||||
let mut ns = NetworkStream::listen(&addr_clone).unwrap();
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let mut received = Vec::new();
|
||||
loop {
|
||||
match ns.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => received.extend_from_slice(&buf[..n]),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
received
|
||||
});
|
||||
|
||||
// Small delay to let the listener thread bind
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
// Connect and write data
|
||||
let dt = sample_title();
|
||||
let mut writer = NetworkStream::connect(&addr).unwrap().meta(&dt);
|
||||
let payload = b"Hello from the write side of the network stream!";
|
||||
writer.write_all(payload).unwrap();
|
||||
writer.finish().unwrap();
|
||||
|
||||
let received = handle.join().unwrap();
|
||||
// The received data should end with our payload (after the FMKV header)
|
||||
assert!(
|
||||
received.windows(payload.len()).any(|w| w == payload),
|
||||
"payload not found in received data (got {} bytes)",
|
||||
received.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // Requires TCP; may be flaky in CI environments
|
||||
fn network_metadata_flows() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
let addr_clone = addr.clone();
|
||||
|
||||
let handle = std::thread::spawn(move || {
|
||||
let ns = NetworkStream::listen(&addr_clone).unwrap();
|
||||
let info = ns.info().clone();
|
||||
info
|
||||
});
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
let dt = sample_title();
|
||||
let mut writer = NetworkStream::connect(&addr).unwrap().meta(&dt);
|
||||
// Must write at least one byte to trigger header send
|
||||
writer.write_all(&[0u8; 192]).unwrap();
|
||||
writer.finish().unwrap();
|
||||
|
||||
let info = handle.join().unwrap();
|
||||
assert_eq!(info.playlist, "NetworkTest");
|
||||
assert_eq!(info.duration_secs, 3600.0);
|
||||
assert_eq!(info.streams.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_empty_addr_errors() {
|
||||
let result = NetworkStream::connect("");
|
||||
assert!(result.is_err(), "empty address should fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_no_port_errors() {
|
||||
// Connecting to an address without a port should fail
|
||||
let result = NetworkStream::connect("127.0.0.1");
|
||||
assert!(result.is_err(), "address without port should fail");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user