Add 113 tests, update CI to checkout@v5, add FEATURES.md
Test suite: 64 → 177 tests - MPLS parser: 6 tests (synthetic binary, streams, errors) - CLPI parser: 6 tests (EP map, PTS/SPN math, errors) - H.264: 12 tests (NAL parsing, SPS/PPS, keyframes) - HEVC: 13 tests (VPS/SPS/PPS, IRAP range, codec private) - AC3: 12 tests (syncword, frame extraction) - VC1: 15 tests (BITMAPINFOHEADER, start codes) - DTS: 5, TrueHD: 4, PGS: 4 tests - EBML: 6 tests (size/ID/string/float roundtrips) - UDF: 10 tests (MockSectorReader, filesystem parsing, error paths) - Disc: 8 tests (scan_image, DiscTitle helpers) - Streams: 5 new (meta roundtrip, MkvStream) - NullStream: 4, StdioStream: 2, IsoSectorReader: 2 CI: actions/checkout@v4 → v5 (all workflows) FEATURES.md: created for v0.7.1
This commit is contained in:
@@ -9,13 +9,13 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo test
|
||||
|
||||
check-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo check
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Verify Cargo.toml version matches tag
|
||||
run: |
|
||||
CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')"
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo test
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Publish to crates.io
|
||||
run: cargo publish
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.ORG_DISPATCH_TOKEN }}
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# libfreemkv — Feature List
|
||||
|
||||
## v0.7.1 (current)
|
||||
|
||||
### Done
|
||||
- [x] Drive access: open, identify, unlock, firmware upload, speed calibration, eject
|
||||
- [x] 206 bundled drive profiles (MediaTek MT1959 A + B variants)
|
||||
- [x] SCSI transport: Linux SG_IO, macOS IOKit
|
||||
- [x] UDF 2.50 filesystem parser (metadata partitions, Blu-ray profile)
|
||||
- [x] MPLS playlist parser (play items, STN table, secondary streams)
|
||||
- [x] CLPI clip info parser (EP map, sector extents)
|
||||
- [x] AACS 1.0 decryption (4 VUK paths: KEYDB, media key, processing key, device key)
|
||||
- [x] AACS 2.0 SCSI handshake (P-256/SHA-256 ECDH, bus decryption, read data key)
|
||||
- [x] KEYDB.cfg download, verify, save (raw TCP, zero HTTP deps)
|
||||
- [x] Content reading with adaptive batch sizing, error recovery, 12+ MB/s
|
||||
- [x] Stream labels: 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
|
||||
- [x] MKV muxer: 8 codec parsers (H.264, HEVC, AC-3, DTS, TrueHD, PGS, VC-1, LPCM)
|
||||
- [x] SectorReader trait: decouples disc scanning from SCSI
|
||||
- [x] 7 stream types: Disc, ISO, MKV, M2TS, Network, Stdio, Null
|
||||
- [x] IOStream trait with URL-based resolver (scheme://path)
|
||||
- [x] FMKV metadata header for M2TS and network streams
|
||||
- [x] Numeric error codes only (no English text in library)
|
||||
- [x] Event system for progress callbacks
|
||||
|
||||
### Planned
|
||||
- [ ] Windows SCSI transport (SPTI)
|
||||
- [ ] Pioneer Renesas platform support (48 drives, need GET_CONFIG 010C)
|
||||
- [ ] DVD CSS decryption
|
||||
- [ ] TranscodeStream (ffmpeg integration)
|
||||
- [ ] ISO write with BD-compliant UDF structure
|
||||
+247
@@ -268,3 +268,250 @@ fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
|
||||
|
||||
Ok((ep_coarse, ep_fine))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a minimal CLPI binary.
|
||||
/// `cpi_data` is the raw CPI section bytes (starting with the 4-byte CPI length).
|
||||
fn build_clpi(source_packet_count: u32, cpi_data: Option<&[u8]>) -> Vec<u8> {
|
||||
// We need at least 60 bytes for the header area.
|
||||
// Offsets:
|
||||
// 0..4: "HDMV"
|
||||
// 4..8: "0200"
|
||||
// 8..12: seq_info_start (unused, set to 0)
|
||||
// 12..16: prog_info_start (unused, set to 0)
|
||||
// 16..20: cpi_start
|
||||
// 20..40: reserved/padding
|
||||
// 40..56: ClipInfo section area (length + stuff before source_packet_count)
|
||||
// 56..60: source_packet_count
|
||||
|
||||
let cpi_start: u32 = if cpi_data.is_some() { 60 } else { 0 };
|
||||
|
||||
let mut buf = vec![0u8; 60];
|
||||
// Magic + version
|
||||
buf[0..4].copy_from_slice(b"HDMV");
|
||||
buf[4..8].copy_from_slice(b"0200");
|
||||
// seq_info_start = 0
|
||||
// prog_info_start = 0
|
||||
// cpi_start
|
||||
buf[16..20].copy_from_slice(&cpi_start.to_be_bytes());
|
||||
// source_packet_count at offset 56
|
||||
buf[56..60].copy_from_slice(&source_packet_count.to_be_bytes());
|
||||
|
||||
if let Some(cpi) = cpi_data {
|
||||
buf.extend_from_slice(cpi);
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
/// Build a CPI section with one stream's EP map.
|
||||
/// coarse_entries: Vec<(ref_to_fine_id, pts_coarse, spn_coarse)>
|
||||
/// fine_entries: Vec<(pts_fine, spn_fine)>
|
||||
fn build_cpi(
|
||||
stream_pid: u16,
|
||||
coarse_entries: &[(u32, u32, u32)],
|
||||
fine_entries: &[(u32, u32)],
|
||||
) -> Vec<u8> {
|
||||
// CPI section layout:
|
||||
// [0..4] cpi_length (u32 BE)
|
||||
// [4..6] reserved/type (2 bytes)
|
||||
// [6..] EP map
|
||||
//
|
||||
// EP map layout (relative to byte 6 of CPI):
|
||||
// [0] reserved
|
||||
// [1] num_streams (1)
|
||||
// [2..4] stream_PID (u16 BE)
|
||||
// [4..14] 80 bits: reserved(10) + EP_stream_type(4) + num_coarse(16) + num_fine(18) + EP_map_start(32)
|
||||
// [14..] (next stream entry, if any)
|
||||
//
|
||||
// Stream EP map (at EP_map_start relative to EP map start):
|
||||
// [0..4] fine_start (relative to stream EP map start)
|
||||
// [4..] coarse entries, 8 bytes each
|
||||
// [fine_start..] fine entries, 4 bytes each
|
||||
|
||||
let num_coarse = coarse_entries.len() as u32;
|
||||
let num_fine = fine_entries.len() as u32;
|
||||
|
||||
// EP_map_start: offset from ep_map start where the stream EP data begins.
|
||||
// ep_map has: reserved(1) + num_streams(1) + stream_header(12) = 14 bytes
|
||||
// So EP_map_start = 14 (first stream data right after the header)
|
||||
let ep_map_start: u32 = 14;
|
||||
|
||||
// Build the 80-bit stream PID entry (10 bytes: ep_map[4..14])
|
||||
// Bits: reserved(10) + EP_stream_type(4) + num_coarse(16) + num_fine(18) + EP_map_start(32)
|
||||
// Total: 80 bits = 10 bytes
|
||||
//
|
||||
// Pack into a u128 for convenience then extract 10 bytes
|
||||
let ep_stream_type: u32 = 1; // video
|
||||
let packed: u128 = ((0u128) << 70) // reserved: 10 bits
|
||||
| ((ep_stream_type as u128) << 66) // EP_stream_type: 4 bits
|
||||
| ((num_coarse as u128) << 50) // num_coarse: 16 bits
|
||||
| ((num_fine as u128) << 32) // num_fine: 18 bits
|
||||
| (ep_map_start as u128); // EP_map_start: 32 bits
|
||||
let packed_bytes = packed.to_be_bytes(); // 16 bytes, we want the last 10
|
||||
let stream_header_bits = &packed_bytes[6..16];
|
||||
|
||||
// Build stream EP data
|
||||
// fine_start = 4 (header) + num_coarse * 8
|
||||
let fine_start: u32 = 4 + num_coarse * 8;
|
||||
let mut stream_ep = Vec::new();
|
||||
stream_ep.extend_from_slice(&fine_start.to_be_bytes());
|
||||
|
||||
// Coarse entries: 8 bytes each
|
||||
// dword0 = (ref_to_fine_id << 14) | (pts_coarse & 0x3FFF)
|
||||
// dword1 = spn_coarse
|
||||
for &(ref_id, pts_c, spn_c) in coarse_entries {
|
||||
let dword0 = (ref_id << 14) | (pts_c & 0x3FFF);
|
||||
stream_ep.extend_from_slice(&dword0.to_be_bytes());
|
||||
stream_ep.extend_from_slice(&spn_c.to_be_bytes());
|
||||
}
|
||||
|
||||
// Fine entries: 4 bytes each
|
||||
// dword = (is_angle(1) + i_end_offset(3) + pts_fine(11) + spn_fine(17))
|
||||
for &(pts_f, spn_f) in fine_entries {
|
||||
let dword: u32 = ((pts_f & 0x7FF) << 17) | (spn_f & 0x1FFFF);
|
||||
stream_ep.extend_from_slice(&dword.to_be_bytes());
|
||||
}
|
||||
|
||||
// Assemble EP map
|
||||
let mut ep_map = Vec::new();
|
||||
ep_map.push(0); // reserved
|
||||
ep_map.push(1); // num_streams = 1
|
||||
ep_map.extend_from_slice(&stream_pid.to_be_bytes());
|
||||
ep_map.extend_from_slice(stream_header_bits);
|
||||
ep_map.extend_from_slice(&stream_ep);
|
||||
|
||||
// Assemble CPI section
|
||||
let mut cpi = Vec::new();
|
||||
let cpi_length = (2 + ep_map.len()) as u32; // reserved/type(2) + ep_map
|
||||
cpi.extend_from_slice(&cpi_length.to_be_bytes());
|
||||
cpi.extend_from_slice(&[0u8; 2]); // reserved/type
|
||||
cpi.extend_from_slice(&ep_map);
|
||||
|
||||
cpi
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_valid_clpi() {
|
||||
let cpi = build_cpi(
|
||||
0x1011,
|
||||
&[(0, 100, 0x00020000)], // 1 coarse
|
||||
&[(50, 1024)], // 1 fine
|
||||
);
|
||||
let data = build_clpi(500_000, Some(&cpi));
|
||||
|
||||
let clip = parse(&data).expect("should parse valid CLPI");
|
||||
assert_eq!(clip.version, "0200");
|
||||
assert_eq!(clip.source_packet_count, 500_000);
|
||||
assert_eq!(clip.ep_coarse.len(), 1);
|
||||
assert_eq!(clip.ep_fine.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ep_map() {
|
||||
let cpi = build_cpi(
|
||||
0x1011,
|
||||
&[
|
||||
(0, 100, 0x00020000), // coarse 0: fine starts at 0, pts_coarse=100, spn_coarse=0x20000
|
||||
(2, 200, 0x00040000), // coarse 1: fine starts at 2, pts_coarse=200, spn_coarse=0x40000
|
||||
],
|
||||
&[
|
||||
(50, 1024), // fine 0
|
||||
(100, 2048), // fine 1
|
||||
(25, 512), // fine 2
|
||||
(75, 1536), // fine 3
|
||||
],
|
||||
);
|
||||
let data = build_clpi(1_000_000, Some(&cpi));
|
||||
|
||||
let clip = parse(&data).expect("should parse EP map");
|
||||
assert_eq!(clip.ep_coarse.len(), 2);
|
||||
assert_eq!(clip.ep_fine.len(), 4);
|
||||
|
||||
// Verify coarse entries
|
||||
assert_eq!(clip.ep_coarse[0].ref_to_fine_id, 0);
|
||||
assert_eq!(clip.ep_coarse[0].pts_coarse, 100);
|
||||
assert_eq!(clip.ep_coarse[0].spn_coarse, 0x00020000);
|
||||
assert_eq!(clip.ep_coarse[1].ref_to_fine_id, 2);
|
||||
assert_eq!(clip.ep_coarse[1].pts_coarse, 200);
|
||||
assert_eq!(clip.ep_coarse[1].spn_coarse, 0x00040000);
|
||||
|
||||
// Verify fine entries
|
||||
assert_eq!(clip.ep_fine[0].pts_fine, 50);
|
||||
assert_eq!(clip.ep_fine[0].spn_fine, 1024);
|
||||
assert_eq!(clip.ep_fine[1].pts_fine, 100);
|
||||
assert_eq!(clip.ep_fine[1].spn_fine, 2048);
|
||||
assert_eq!(clip.ep_fine[2].pts_fine, 25);
|
||||
assert_eq!(clip.ep_fine[2].spn_fine, 512);
|
||||
assert_eq!(clip.ep_fine[3].pts_fine, 75);
|
||||
assert_eq!(clip.ep_fine[3].spn_fine, 1536);
|
||||
|
||||
// Verify resolved EP map assigns fine entries to coarse correctly
|
||||
let resolved = clip.resolved_ep_map();
|
||||
assert_eq!(resolved.len(), 4);
|
||||
// First two fines belong to coarse 0, last two to coarse 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_pts_calculation() {
|
||||
let coarse = EpCoarse { ref_to_fine_id: 0, pts_coarse: 100, spn_coarse: 0 };
|
||||
let fine = EpFine { pts_fine: 50, spn_fine: 0 };
|
||||
// full_pts = (100 << 19) + (50 << 8) = 52_428_800 + 12_800 = 52_441_600
|
||||
let pts = ClipInfo::full_pts(&coarse, &fine);
|
||||
assert_eq!(pts, (100 << 19) + (50 << 8));
|
||||
assert_eq!(pts, 52_441_600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_spn_calculation() {
|
||||
let coarse = EpCoarse { ref_to_fine_id: 0, pts_coarse: 0, spn_coarse: 0x00FE0000 };
|
||||
let fine = EpFine { pts_fine: 0, spn_fine: 0x1234 };
|
||||
// full_spn = (0x00FE0000 & 0xFFFE0000) + 0x1234 = 0x00FE0000 + 0x1234 = 0x00FE1234
|
||||
let spn = ClipInfo::full_spn(&coarse, &fine);
|
||||
assert_eq!(spn, 0x00FE0000 + 0x1234);
|
||||
assert_eq!(spn, 0x00FE1234);
|
||||
|
||||
// Test that the low bit of spn_coarse is masked out
|
||||
let coarse2 = EpCoarse { ref_to_fine_id: 0, pts_coarse: 0, spn_coarse: 0x00FF0000 };
|
||||
let spn2 = ClipInfo::full_spn(&coarse2, &fine);
|
||||
// 0x00FF0000 & 0xFFFE0000 = 0x00FE0000, so low 17 bits of coarse are zeroed
|
||||
assert_eq!(spn2, 0x00FE0000 + 0x1234);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_magic() {
|
||||
let mut data = build_clpi(1000, None);
|
||||
data[0] = b'X';
|
||||
data[1] = b'X';
|
||||
data[2] = b'X';
|
||||
data[3] = b'X';
|
||||
assert!(parse(&data).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_ep_map() {
|
||||
// cpi_start = 0 means no CPI section
|
||||
let data = build_clpi(100_000, None);
|
||||
let clip = parse(&data).expect("should parse with no EP map");
|
||||
assert_eq!(clip.source_packet_count, 100_000);
|
||||
assert!(clip.ep_coarse.is_empty());
|
||||
assert!(clip.ep_fine.is_empty());
|
||||
|
||||
// Also test: CPI section present but with zero streams
|
||||
let mut cpi = Vec::new();
|
||||
let cpi_length: u32 = 6; // reserved/type(2) + ep_map(reserved(1) + num_streams=0(1) + 2 padding)
|
||||
cpi.extend_from_slice(&cpi_length.to_be_bytes());
|
||||
cpi.extend_from_slice(&[0u8; 2]); // reserved/type
|
||||
cpi.push(0); // reserved
|
||||
cpi.push(0); // num_streams = 0
|
||||
cpi.extend_from_slice(&[0u8; 4]); // padding
|
||||
|
||||
let data2 = build_clpi(100_000, Some(&cpi));
|
||||
let clip2 = parse(&data2).expect("should parse with zero-stream EP map");
|
||||
assert!(clip2.ep_coarse.is_empty());
|
||||
assert!(clip2.ep_fine.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+305
@@ -321,3 +321,308 @@ fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(Strea
|
||||
secondary: false,
|
||||
}, sa_end))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a minimal MPLS binary with given play items and STN streams on the first item.
|
||||
/// STN counts: (n_video, n_audio, n_pg, n_ig, n_sec_audio, n_sec_video, n_pip_pg, n_dv)
|
||||
fn build_mpls(
|
||||
play_items_data: &[(/*clip_id*/&[u8;5], /*conn*/u8, /*in_time*/u32, /*out_time*/u32)],
|
||||
stn_counts: (u8, u8, u8, u8, u8, u8, u8, u8),
|
||||
stream_entries: &[Vec<u8>], // raw stream entry + attributes bytes for each stream
|
||||
) -> Vec<u8> {
|
||||
let playlist_start: u32 = 40; // right after the 40-byte header
|
||||
let mut buf = Vec::new();
|
||||
|
||||
// File header: "MPLS" + version + playlist_start + 3 more offsets (unused)
|
||||
buf.extend_from_slice(b"MPLS0200");
|
||||
buf.extend_from_slice(&playlist_start.to_be_bytes());
|
||||
// mark_start, extension_start (unused by parser), padding to 40 bytes
|
||||
buf.extend_from_slice(&[0u8; 28]);
|
||||
|
||||
// PlayList section starts here (offset 40)
|
||||
// PlayList: length(4) + reserved(2) + num_play_items(2) + num_sub_paths(2) = 10 header bytes
|
||||
let pl_start = buf.len();
|
||||
buf.extend_from_slice(&[0u8; 4]); // length placeholder
|
||||
buf.extend_from_slice(&[0u8; 2]); // reserved
|
||||
buf.extend_from_slice(&(play_items_data.len() as u16).to_be_bytes());
|
||||
buf.extend_from_slice(&[0u8; 2]); // num_sub_paths
|
||||
|
||||
for (idx, (clip_id, conn, in_time, out_time)) in play_items_data.iter().enumerate() {
|
||||
// Build play item content
|
||||
let mut item = Vec::new();
|
||||
// [0..5] clip_id
|
||||
item.extend_from_slice(*clip_id);
|
||||
// [5..9] codec_id ("M2TS")
|
||||
item.extend_from_slice(b"M2TS");
|
||||
// [9] connection_condition in low nibble
|
||||
item.push(*conn & 0x0F);
|
||||
// [10..12] reserved
|
||||
item.extend_from_slice(&[0u8; 2]);
|
||||
// [12..16] in_time
|
||||
item.extend_from_slice(&in_time.to_be_bytes());
|
||||
// [16..20] out_time
|
||||
item.extend_from_slice(&out_time.to_be_bytes());
|
||||
// [20..28] UO_mask_table
|
||||
item.extend_from_slice(&[0u8; 8]);
|
||||
// [28] misc flags
|
||||
item.push(0);
|
||||
// [29] still_mode
|
||||
item.push(0);
|
||||
// [30..32] still_time
|
||||
item.extend_from_slice(&[0u8; 2]);
|
||||
|
||||
// STN table (only for the first play item)
|
||||
if idx == 0 {
|
||||
// STN header: length(2) + reserved(2) + counts(8) + reserved(4) = 16 bytes
|
||||
let stn_header_start = item.len();
|
||||
item.extend_from_slice(&[0u8; 2]); // STN length placeholder
|
||||
item.extend_from_slice(&[0u8; 2]); // reserved
|
||||
item.push(stn_counts.0); // n_video
|
||||
item.push(stn_counts.1); // n_audio
|
||||
item.push(stn_counts.2); // n_pg
|
||||
item.push(stn_counts.3); // n_ig
|
||||
item.push(stn_counts.4); // n_sec_audio
|
||||
item.push(stn_counts.5); // n_sec_video
|
||||
item.push(stn_counts.6); // n_pip_pg
|
||||
item.push(stn_counts.7); // n_dv
|
||||
item.extend_from_slice(&[0u8; 4]); // reserved
|
||||
|
||||
// Stream entries
|
||||
for se in stream_entries {
|
||||
item.extend_from_slice(se);
|
||||
}
|
||||
|
||||
// Patch STN length
|
||||
let stn_len = (item.len() - stn_header_start - 2) as u16;
|
||||
let stn_len_bytes = stn_len.to_be_bytes();
|
||||
item[stn_header_start] = stn_len_bytes[0];
|
||||
item[stn_header_start + 1] = stn_len_bytes[1];
|
||||
}
|
||||
|
||||
// Write item_length(2) + item
|
||||
let item_length = item.len() as u16;
|
||||
buf.extend_from_slice(&item_length.to_be_bytes());
|
||||
buf.extend_from_slice(&item);
|
||||
}
|
||||
|
||||
// Patch PlayList length
|
||||
let pl_len = (buf.len() - pl_start - 4) as u32;
|
||||
let pl_len_bytes = pl_len.to_be_bytes();
|
||||
buf[pl_start] = pl_len_bytes[0];
|
||||
buf[pl_start + 1] = pl_len_bytes[1];
|
||||
buf[pl_start + 2] = pl_len_bytes[2];
|
||||
buf[pl_start + 3] = pl_len_bytes[3];
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
/// Build a stream entry (stream_entry part + stream_attributes part).
|
||||
/// stream_entry: type=0x01 (PlayItem stream), PID given.
|
||||
/// For video: attrs = coding_type(1) + format_rate(1) [+ hdr_byte if HEVC]
|
||||
/// For audio: attrs = coding_type(1) + format_rate(1) + language(3)
|
||||
/// For PG: attrs = coding_type(1) + language(3)
|
||||
fn build_stream_entry_video(pid: u16, coding_type: u8, format: u8, rate: u8, hdr: Option<u8>) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
// Stream entry: length(1) + sub_path_type(1) + pid(2)
|
||||
out.push(3); // se_len = 3 bytes (type + pid_hi + pid_lo)
|
||||
out.push(0x01); // type: PlayItem stream
|
||||
out.extend_from_slice(&pid.to_be_bytes());
|
||||
// Stream attributes
|
||||
let mut attrs = vec![coding_type, (format << 4) | rate];
|
||||
if let Some(h) = hdr {
|
||||
attrs.push(h);
|
||||
}
|
||||
out.push(attrs.len() as u8); // sa_len
|
||||
out.extend_from_slice(&attrs);
|
||||
out
|
||||
}
|
||||
|
||||
fn build_stream_entry_audio(pid: u16, coding_type: u8, ch_layout: u8, sample_rate: u8, lang: &[u8; 3]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.push(3);
|
||||
out.push(0x01);
|
||||
out.extend_from_slice(&pid.to_be_bytes());
|
||||
// attrs: coding_type(1) + format_rate(1) + language(3)
|
||||
let attrs = vec![coding_type, (ch_layout << 4) | sample_rate, lang[0], lang[1], lang[2]];
|
||||
out.push(attrs.len() as u8);
|
||||
out.extend_from_slice(&attrs);
|
||||
out
|
||||
}
|
||||
|
||||
fn build_stream_entry_pg(pid: u16, coding_type: u8, lang: &[u8; 3]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.push(3);
|
||||
out.push(0x01);
|
||||
out.extend_from_slice(&pid.to_be_bytes());
|
||||
// attrs: coding_type(1) + language(3)
|
||||
let attrs = vec![coding_type, lang[0], lang[1], lang[2]];
|
||||
out.push(attrs.len() as u8);
|
||||
out.extend_from_slice(&attrs);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_valid_mpls() {
|
||||
let in_time: u32 = 90000; // 2 seconds at 45kHz
|
||||
let out_time: u32 = 4500000; // 100 seconds
|
||||
|
||||
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None); // H264, 1080p, 23.976
|
||||
let audio = build_stream_entry_audio(0x1100, 0x83, 6, 1, b"eng"); // TrueHD, 5.1, 48kHz
|
||||
let pg = build_stream_entry_pg(0x1200, 0x90, b"eng"); // PGS subtitle
|
||||
|
||||
let data = build_mpls(
|
||||
&[(b"00001", 1, in_time, out_time)],
|
||||
(1, 1, 1, 0, 0, 0, 0, 0),
|
||||
&[video, audio, pg],
|
||||
);
|
||||
|
||||
let playlist = parse(&data).expect("should parse valid MPLS");
|
||||
assert_eq!(playlist.version, "0200");
|
||||
assert_eq!(playlist.play_items.len(), 1);
|
||||
assert_eq!(playlist.play_items[0].clip_id, "00001");
|
||||
assert_eq!(playlist.play_items[0].in_time, in_time);
|
||||
assert_eq!(playlist.play_items[0].out_time, out_time);
|
||||
assert_eq!(playlist.play_items[0].connection_condition, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_streams() {
|
||||
let video = build_stream_entry_video(0x1011, 0x24, 8, 1, Some(0x12)); // HEVC, 2160p, 23.976, HDR10+BT.2020
|
||||
let audio = build_stream_entry_audio(0x1100, 0x83, 6, 1, b"eng");
|
||||
let pg = build_stream_entry_pg(0x1200, 0x90, b"fra");
|
||||
|
||||
let data = build_mpls(
|
||||
&[(b"00001", 1, 0, 9000000)],
|
||||
(1, 1, 1, 0, 0, 0, 0, 0),
|
||||
&[video, audio, pg],
|
||||
);
|
||||
|
||||
let playlist = parse(&data).expect("should parse");
|
||||
assert_eq!(playlist.streams.len(), 3);
|
||||
|
||||
// Video stream
|
||||
let v = &playlist.streams[0];
|
||||
assert_eq!(v.stream_type, 1);
|
||||
assert_eq!(v.pid, 0x1011);
|
||||
assert_eq!(v.coding_type, 0x24); // HEVC
|
||||
assert_eq!(v.video_format, 8); // 2160p
|
||||
assert_eq!(v.video_rate, 1); // 23.976
|
||||
assert_eq!(v.dynamic_range, 1); // HDR10
|
||||
assert_eq!(v.color_space, 2); // BT.2020
|
||||
assert!(!v.secondary);
|
||||
|
||||
// Audio stream
|
||||
let a = &playlist.streams[1];
|
||||
assert_eq!(a.stream_type, 2);
|
||||
assert_eq!(a.pid, 0x1100);
|
||||
assert_eq!(a.coding_type, 0x83); // TrueHD
|
||||
assert_eq!(a.audio_format, 6); // 5.1
|
||||
assert_eq!(a.audio_rate, 1); // 48kHz
|
||||
assert_eq!(a.language, "eng");
|
||||
assert!(!a.secondary);
|
||||
|
||||
// PG subtitle stream
|
||||
let s = &playlist.streams[2];
|
||||
assert_eq!(s.stream_type, 3);
|
||||
assert_eq!(s.pid, 0x1200);
|
||||
assert_eq!(s.coding_type, 0x90); // PGS
|
||||
assert_eq!(s.language, "fra");
|
||||
assert!(!s.secondary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_magic() {
|
||||
let mut data = build_mpls(
|
||||
&[(b"00001", 1, 0, 9000000)],
|
||||
(0, 0, 0, 0, 0, 0, 0, 0),
|
||||
&[],
|
||||
);
|
||||
data[0] = b'X';
|
||||
data[1] = b'X';
|
||||
data[2] = b'X';
|
||||
data[3] = b'X';
|
||||
assert!(parse(&data).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_truncated() {
|
||||
// Less than 40 bytes
|
||||
assert!(parse(&[0u8; 10]).is_err());
|
||||
assert!(parse(b"MPLS0200").is_err());
|
||||
assert!(parse(&[0u8; 39]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multiple_play_items() {
|
||||
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
|
||||
|
||||
let data = build_mpls(
|
||||
&[
|
||||
(b"00001", 1, 90000, 4500000),
|
||||
(b"00002", 5, 4500000, 9000000),
|
||||
(b"00003", 6, 9000000, 13500000),
|
||||
],
|
||||
(1, 0, 0, 0, 0, 0, 0, 0),
|
||||
&[video],
|
||||
);
|
||||
|
||||
let playlist = parse(&data).expect("should parse multiple play items");
|
||||
assert_eq!(playlist.play_items.len(), 3);
|
||||
assert_eq!(playlist.play_items[0].clip_id, "00001");
|
||||
assert_eq!(playlist.play_items[0].connection_condition, 1);
|
||||
assert_eq!(playlist.play_items[1].clip_id, "00002");
|
||||
assert_eq!(playlist.play_items[1].connection_condition, 5);
|
||||
assert_eq!(playlist.play_items[1].in_time, 4500000);
|
||||
assert_eq!(playlist.play_items[2].clip_id, "00003");
|
||||
assert_eq!(playlist.play_items[2].connection_condition, 6);
|
||||
assert_eq!(playlist.play_items[2].out_time, 13500000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_secondary_streams() {
|
||||
// Primary video
|
||||
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
|
||||
// Secondary audio (stream_type 5): build as audio, parser overrides type to 5
|
||||
let sec_audio_se = build_stream_entry_audio(0x1A00, 0x83, 3, 1, b"eng");
|
||||
// Need ref bytes after secondary audio: num_refs(1) + reserved(1) = 2 bytes min
|
||||
let mut sec_audio_with_refs = sec_audio_se;
|
||||
sec_audio_with_refs.push(0); // num_refs = 0
|
||||
sec_audio_with_refs.push(0); // reserved
|
||||
|
||||
// Secondary video (stream_type 6): build as video, parser overrides type to 6
|
||||
let sec_video_se = build_stream_entry_video(0x1B00, 0x1B, 4, 1, None);
|
||||
// Need ref bytes: n_arefs(1) + reserved(1) + n_prefs(1) + reserved(1) = 4 bytes
|
||||
let mut sec_video_with_refs = sec_video_se;
|
||||
sec_video_with_refs.push(0); // n_arefs = 0
|
||||
sec_video_with_refs.push(0); // reserved
|
||||
sec_video_with_refs.push(0); // n_prefs = 0
|
||||
sec_video_with_refs.push(0); // reserved
|
||||
|
||||
let data = build_mpls(
|
||||
&[(b"00001", 1, 0, 9000000)],
|
||||
(1, 0, 0, 0, 1, 1, 0, 0), // 1 video, 0 audio, 0 pg, 0 ig, 1 sec_audio, 1 sec_video
|
||||
&[video, sec_audio_with_refs, sec_video_with_refs],
|
||||
);
|
||||
|
||||
let playlist = parse(&data).expect("should parse secondary streams");
|
||||
// Should have 3 streams: primary video, secondary audio, secondary video
|
||||
assert_eq!(playlist.streams.len(), 3);
|
||||
|
||||
// Primary video
|
||||
assert_eq!(playlist.streams[0].stream_type, 1);
|
||||
assert!(!playlist.streams[0].secondary);
|
||||
|
||||
// Secondary audio
|
||||
assert_eq!(playlist.streams[1].stream_type, 5);
|
||||
assert!(playlist.streams[1].secondary);
|
||||
assert_eq!(playlist.streams[1].pid, 0x1A00);
|
||||
|
||||
// Secondary video
|
||||
assert_eq!(playlist.streams[2].stream_type, 6);
|
||||
assert!(playlist.streams[2].secondary);
|
||||
assert_eq!(playlist.streams[2].pid, 0x1B00);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,3 +47,137 @@ fn find_ac3_sync(data: &[u8]) -> Option<usize> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mux::ts::PesPacket;
|
||||
|
||||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||
PesPacket { pid: 0x1100, pts, dts: None, data }
|
||||
}
|
||||
|
||||
// --- syncword detection ---
|
||||
|
||||
#[test]
|
||||
fn find_ac3_sync_at_start() {
|
||||
let data = [0x0B, 0x77, 0x01, 0x02, 0x03];
|
||||
assert_eq!(find_ac3_sync(&data), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_ac3_sync_with_garbage_prefix() {
|
||||
let data = [0xFF, 0xFE, 0x0B, 0x77, 0x01, 0x02];
|
||||
assert_eq!(find_ac3_sync(&data), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_ac3_sync_none() {
|
||||
let data = [0x0B, 0x78, 0x00, 0x00];
|
||||
assert_eq!(find_ac3_sync(&data), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_ac3_sync_empty() {
|
||||
let data: [u8; 0] = [];
|
||||
assert_eq!(find_ac3_sync(&data), None);
|
||||
}
|
||||
|
||||
// --- parse syncword → frame extracted ---
|
||||
|
||||
#[test]
|
||||
fn parse_syncword() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
|
||||
// AC3 frame starting with syncword
|
||||
let data = vec![0x0B, 0x77, 0x44, 0x55, 0x66, 0x77, 0x88];
|
||||
let pes = make_pes(data.clone(), Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data, data);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_syncword_with_garbage_prefix() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
|
||||
// Garbage bytes before syncword
|
||||
let data = vec![0xFF, 0xFE, 0x0B, 0x77, 0x44, 0x55];
|
||||
let pes = make_pes(data, Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
// Data should start from the syncword
|
||||
assert_eq!(frames[0].data[0], 0x0B);
|
||||
assert_eq!(frames[0].data[1], 0x77);
|
||||
assert_eq!(frames[0].data.len(), 4); // syncword + 2 payload bytes
|
||||
}
|
||||
|
||||
// --- all frames are keyframes ---
|
||||
|
||||
#[test]
|
||||
fn all_keyframes() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
|
||||
for i in 0..5 {
|
||||
let data = vec![0x0B, 0x77, 0x00, i];
|
||||
let pes = make_pes(data, Some(90000 * i as i64));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(frames[0].keyframe, "AC3 frame {} should be a keyframe", i);
|
||||
}
|
||||
}
|
||||
|
||||
// --- codec_private is None ---
|
||||
|
||||
#[test]
|
||||
fn codec_private_none() {
|
||||
let parser = Ac3Parser::new();
|
||||
assert!(parser.codec_private().is_none());
|
||||
}
|
||||
|
||||
// --- empty / too-short PES ---
|
||||
|
||||
#[test]
|
||||
fn parse_empty_pes() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
let pes = make_pes(Vec::new(), Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert!(frames.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_single_byte_pes() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
let pes = make_pes(vec![0x0B], Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert!(frames.is_empty());
|
||||
}
|
||||
|
||||
// --- PTS conversion ---
|
||||
|
||||
#[test]
|
||||
fn pts_conversion() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
let data = vec![0x0B, 0x77, 0x00, 0x01];
|
||||
// 45000 ticks = 0.5 seconds → 500_000_000 ns
|
||||
let pes = make_pes(data, Some(45000));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 500_000_000);
|
||||
}
|
||||
|
||||
// --- None PTS ---
|
||||
|
||||
#[test]
|
||||
fn no_pts() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
let data = vec![0x0B, 0x77, 0x00, 0x01];
|
||||
let pes = make_pes(data, None);
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,3 +24,60 @@ impl CodecParser for DtsParser {
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> { None }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mux::ts::PesPacket;
|
||||
|
||||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||
PesPacket { pid: 0x1100, pts, dts: None, data }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_basic_frame() {
|
||||
let mut parser = DtsParser::new();
|
||||
// DTS core syncword: 7F FE 80 01 + payload
|
||||
let data = vec![0x7F, 0xFE, 0x80, 0x01, 0xAA, 0xBB, 0xCC];
|
||||
let pes = make_pes(data.clone(), Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data, data);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_keyframes() {
|
||||
let mut parser = DtsParser::new();
|
||||
for i in 0..3 {
|
||||
let data = vec![0x7F, 0xFE, 0x80, 0x01, i];
|
||||
let pes = make_pes(data, Some(90000 * i as i64));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(frames[0].keyframe, "DTS frame should always be keyframe");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none() {
|
||||
let parser = DtsParser::new();
|
||||
assert!(parser.codec_private().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_pes() {
|
||||
let mut parser = DtsParser::new();
|
||||
let pes = make_pes(Vec::new(), Some(0));
|
||||
assert!(parser.parse(&pes).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pts() {
|
||||
let mut parser = DtsParser::new();
|
||||
let pes = make_pes(vec![0x7F, 0xFE, 0x80, 0x01], None);
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,3 +189,253 @@ pub fn skip_start_code(data: &[u8], pos: usize) -> Option<usize> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mux::ts::PesPacket;
|
||||
|
||||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||
PesPacket { pid: 0x1011, pts, dts: None, data }
|
||||
}
|
||||
|
||||
// --- find_start_code tests ---
|
||||
|
||||
#[test]
|
||||
fn find_start_code_3byte() {
|
||||
let data = [0x00, 0x00, 0x01, 0x65];
|
||||
assert_eq!(find_start_code(&data, 0), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_start_code_4byte() {
|
||||
let data = [0x00, 0x00, 0x00, 0x01, 0x65];
|
||||
// find_start_code looks for 00 00 01 pattern, which starts at offset 1 in a 4-byte start code
|
||||
assert_eq!(find_start_code(&data, 0), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_start_code_offset() {
|
||||
let data = [0xFF, 0xFF, 0x00, 0x00, 0x01, 0x09];
|
||||
assert_eq!(find_start_code(&data, 0), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_start_code_none() {
|
||||
let data = [0x00, 0x00, 0x00, 0x00];
|
||||
assert_eq!(find_start_code(&data, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_start_code_too_short() {
|
||||
let data = [0x00, 0x00];
|
||||
assert_eq!(find_start_code(&data, 0), None);
|
||||
}
|
||||
|
||||
// --- parse SPS+PPS → codec_private ---
|
||||
|
||||
#[test]
|
||||
fn parse_sps_pps() {
|
||||
let mut parser = H264Parser::new();
|
||||
|
||||
// Build PES with SPS (type 7) + PPS (type 8) + IDR slice (type 5)
|
||||
// SPS NAL: 0x67 = 0_11_00111 (nal_type = 7), followed by profile/compat/level + payload
|
||||
// PPS NAL: 0x68 = 0_11_01000 (nal_type = 8)
|
||||
let mut data = Vec::new();
|
||||
// SPS: 00 00 01 [67 42 00 1E <payload>]
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x67); // SPS
|
||||
data.extend_from_slice(&[0x42, 0x00, 0x1E, 0xAB, 0xCD]); // profile=0x42, compat=0x00, level=0x1E
|
||||
// PPS: 00 00 01 [68 <payload>]
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x68); // PPS
|
||||
data.extend_from_slice(&[0xCE, 0x01]);
|
||||
// IDR slice: 00 00 01 [65 <payload>]
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x65); // IDR
|
||||
data.extend_from_slice(&[0x88, 0x00, 0x10]);
|
||||
|
||||
let pes = make_pes(data, Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
// codec_private should now be available
|
||||
let cp = parser.codec_private();
|
||||
assert!(cp.is_some(), "codec_private should be Some after seeing SPS+PPS");
|
||||
let cp = cp.unwrap();
|
||||
|
||||
// AVCDecoderConfigurationRecord checks
|
||||
assert_eq!(cp[0], 1, "configurationVersion");
|
||||
assert_eq!(cp[1], 0x42, "profile from SPS[1]");
|
||||
assert_eq!(cp[2], 0x00, "compatibility from SPS[2]");
|
||||
assert_eq!(cp[3], 0x1E, "level from SPS[3]");
|
||||
assert_eq!(cp[4], 0xFF, "reserved + lengthSizeMinusOne=3");
|
||||
assert_eq!(cp[5], 0xE1, "reserved + numSPS=1");
|
||||
|
||||
// Frames should have been produced
|
||||
assert_eq!(frames.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none_before_sps_pps() {
|
||||
let parser = H264Parser::new();
|
||||
assert!(parser.codec_private().is_none());
|
||||
}
|
||||
|
||||
// --- IDR keyframe detection ---
|
||||
|
||||
#[test]
|
||||
fn parse_idr_keyframe() {
|
||||
let mut parser = H264Parser::new();
|
||||
|
||||
// PES with IDR NAL (type 5 = 0x65)
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x65); // IDR slice (nal_type = 5)
|
||||
data.extend_from_slice(&[0x88, 0x00, 0x10, 0x20]);
|
||||
|
||||
let pes = make_pes(data, Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(frames[0].keyframe, "IDR slice should be detected as keyframe");
|
||||
}
|
||||
|
||||
// --- non-IDR → not keyframe ---
|
||||
|
||||
#[test]
|
||||
fn parse_non_idr() {
|
||||
let mut parser = H264Parser::new();
|
||||
|
||||
// PES with non-IDR slice (type 1 = 0x61 or 0x41)
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x41); // non-IDR coded slice (nal_type = 1)
|
||||
data.extend_from_slice(&[0x9A, 0x00, 0x10]);
|
||||
|
||||
let pes = make_pes(data, Some(180000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(!frames[0].keyframe, "non-IDR slice should not be keyframe");
|
||||
}
|
||||
|
||||
// --- length prefix conversion ---
|
||||
|
||||
#[test]
|
||||
fn length_prefix_conversion() {
|
||||
let mut parser = H264Parser::new();
|
||||
|
||||
// PES with a single non-IDR NAL
|
||||
let nal_payload = [0x41, 0xAA, 0xBB, 0xCC, 0xDD]; // type 1, 5 bytes
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&nal_payload);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
let frame_data = &frames[0].data;
|
||||
|
||||
// Should start with 4-byte big-endian length prefix
|
||||
assert!(frame_data.len() >= 4, "frame data should have length prefix");
|
||||
let length = u32::from_be_bytes([frame_data[0], frame_data[1], frame_data[2], frame_data[3]]);
|
||||
assert_eq!(length as usize, nal_payload.len(), "length prefix should match NAL size");
|
||||
|
||||
// Followed by the NAL data itself
|
||||
assert_eq!(&frame_data[4..], &nal_payload);
|
||||
|
||||
// No start code (00 00 01) should appear in the output
|
||||
for i in 0..frame_data.len().saturating_sub(2) {
|
||||
let is_sc = frame_data[i] == 0x00 && frame_data[i + 1] == 0x00 && frame_data[i + 2] == 0x01;
|
||||
assert!(!is_sc, "output should not contain Annex B start codes");
|
||||
}
|
||||
}
|
||||
|
||||
// --- SPS/PPS/AUD are stripped from frame data ---
|
||||
|
||||
#[test]
|
||||
fn sps_pps_aud_stripped_from_frame_data() {
|
||||
let mut parser = H264Parser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
// AUD (type 9)
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x09);
|
||||
data.push(0xF0);
|
||||
// SPS (type 7)
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x67);
|
||||
data.extend_from_slice(&[0x42, 0x00, 0x1E, 0xAB]);
|
||||
// PPS (type 8)
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x68);
|
||||
data.extend_from_slice(&[0xCE, 0x01]);
|
||||
// IDR (type 5) - only this should appear in frame data
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x65);
|
||||
data.extend_from_slice(&[0x88, 0x00]);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
|
||||
// Frame data should only contain the IDR NAL (length-prefixed)
|
||||
let fd = &frames[0].data;
|
||||
let length = u32::from_be_bytes([fd[0], fd[1], fd[2], fd[3]]);
|
||||
// IDR NAL is 0x65, 0x88 (trailing 0x00 is stripped as potential start code prefix)
|
||||
assert_eq!(length, 2);
|
||||
assert_eq!(fd[4], 0x65); // IDR NAL type byte
|
||||
}
|
||||
|
||||
// --- PTS conversion ---
|
||||
|
||||
#[test]
|
||||
fn pts_conversion() {
|
||||
let mut parser = H264Parser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x41);
|
||||
data.extend_from_slice(&[0x00, 0x10]);
|
||||
|
||||
// PTS = 90000 (1 second at 90kHz) → 1_000_000_000 ns
|
||||
let pes = make_pes(data, Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
|
||||
// --- empty PES ---
|
||||
|
||||
#[test]
|
||||
fn parse_empty_pes() {
|
||||
let mut parser = H264Parser::new();
|
||||
let pes = make_pes(Vec::new(), Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert!(frames.is_empty());
|
||||
}
|
||||
|
||||
// --- DTS preferred over PTS when present ---
|
||||
|
||||
#[test]
|
||||
fn dts_preferred_over_pts() {
|
||||
let mut parser = H264Parser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x41);
|
||||
data.extend_from_slice(&[0x00, 0x10]);
|
||||
|
||||
let pes = PesPacket {
|
||||
pid: 0x1011,
|
||||
pts: Some(180000), // 2 seconds
|
||||
dts: Some(90000), // 1 second
|
||||
data,
|
||||
};
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
// DTS should be used, not PTS
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,3 +169,259 @@ impl CodecParser for HevcParser {
|
||||
Some(record)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mux::ts::PesPacket;
|
||||
|
||||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||
PesPacket { pid: 0x1011, pts, dts: None, data }
|
||||
}
|
||||
|
||||
/// Build an HEVC NAL header (2 bytes). Type is bits 1-6 of first byte.
|
||||
/// Format: forbidden(1) | type(6) | layer_id_high(1) || layer_id_low(5) | tid(3)
|
||||
fn hevc_nal_header(nal_type: u8) -> [u8; 2] {
|
||||
[(nal_type & 0x3F) << 1, 0x01] // tid=1
|
||||
}
|
||||
|
||||
// --- VPS+SPS+PPS → codec_private ---
|
||||
|
||||
#[test]
|
||||
fn parse_vps_sps_pps() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
// VPS (type 32)
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
let vps_hdr = hevc_nal_header(32);
|
||||
data.extend_from_slice(&vps_hdr);
|
||||
data.extend_from_slice(&[0xAA, 0xBB, 0xCC]); // VPS payload
|
||||
|
||||
// SPS (type 33)
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
let sps_hdr = hevc_nal_header(33);
|
||||
data.extend_from_slice(&sps_hdr);
|
||||
data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
|
||||
0x09, 0x0A, 0x0B, 0x0C, 0x0D]); // SPS payload (>12 bytes for level)
|
||||
|
||||
// PPS (type 34)
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
let pps_hdr = hevc_nal_header(34);
|
||||
data.extend_from_slice(&pps_hdr);
|
||||
data.extend_from_slice(&[0xDD, 0xEE]); // PPS payload
|
||||
|
||||
// IRAP slice (type 19 = IDR_W_RADL) so a frame is emitted
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
let idr_hdr = hevc_nal_header(19);
|
||||
data.extend_from_slice(&idr_hdr);
|
||||
data.extend_from_slice(&[0x10, 0x20, 0x30]);
|
||||
|
||||
let pes = make_pes(data, Some(90000));
|
||||
let _frames = parser.parse(&pes);
|
||||
|
||||
let cp = parser.codec_private();
|
||||
assert!(cp.is_some(), "codec_private should be Some after VPS+SPS+PPS");
|
||||
|
||||
let cp = cp.unwrap();
|
||||
// configurationVersion = 1
|
||||
assert_eq!(cp[0], 1);
|
||||
// numOfArrays = 3 (VPS, SPS, PPS)
|
||||
assert_eq!(cp[22], 3);
|
||||
// Should be longer than the minimal header (23 bytes) + array entries
|
||||
assert!(cp.len() > 23, "codec_private should contain VPS+SPS+PPS data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none_before_params() {
|
||||
let parser = HevcParser::new();
|
||||
assert!(parser.codec_private().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none_missing_pps() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
// Only VPS + SPS, no PPS
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(32));
|
||||
data.extend_from_slice(&[0xAA, 0xBB]);
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(33));
|
||||
data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]);
|
||||
// Add a slice so parse doesn't return empty
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(1)); // TRAIL_R
|
||||
data.extend_from_slice(&[0x10, 0x20]);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
parser.parse(&pes);
|
||||
assert!(parser.codec_private().is_none(), "should be None without PPS");
|
||||
}
|
||||
|
||||
// --- IRAP keyframe detection ---
|
||||
|
||||
#[test]
|
||||
fn parse_irap_keyframe_idr_w_radl() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
// IDR_W_RADL = type 19
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(19));
|
||||
data.extend_from_slice(&[0x10, 0x20, 0x30]);
|
||||
|
||||
let pes = make_pes(data, Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(frames[0].keyframe, "IDR_W_RADL (type 19) should be keyframe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_irap_keyframe_bla() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
// BLA_W_LP = type 16
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(16));
|
||||
data.extend_from_slice(&[0x10, 0x20]);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(frames[0].keyframe, "BLA_W_LP (type 16) should be keyframe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_irap_keyframe_cra() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
// CRA_NUT = type 21
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(21));
|
||||
data.extend_from_slice(&[0x10, 0x20]);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(frames[0].keyframe, "CRA (type 21) should be keyframe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_irap_type_23() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
// RSV_IRAP_VCL23 = type 23 (upper boundary)
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(23));
|
||||
data.extend_from_slice(&[0x10, 0x20]);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(frames[0].keyframe, "type 23 should be keyframe");
|
||||
}
|
||||
|
||||
// --- non-IRAP (trailing) → not keyframe ---
|
||||
|
||||
#[test]
|
||||
fn parse_trailing_not_keyframe() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
// TRAIL_R = type 1
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(1));
|
||||
data.extend_from_slice(&[0x10, 0x20, 0x30]);
|
||||
|
||||
let pes = make_pes(data, Some(180000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(!frames[0].keyframe, "TRAIL_R (type 1) should not be keyframe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tsa_not_keyframe() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
// TSA_N = type 2
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(2));
|
||||
data.extend_from_slice(&[0x10, 0x20]);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(!frames[0].keyframe, "TSA_N (type 2) should not be keyframe");
|
||||
}
|
||||
|
||||
// --- VPS/SPS/PPS stripped from frame data ---
|
||||
|
||||
#[test]
|
||||
fn param_sets_stripped_from_frame() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
// VPS
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(32));
|
||||
data.extend_from_slice(&[0xAA]);
|
||||
// SPS
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(33));
|
||||
data.extend_from_slice(&[0xBB]);
|
||||
// PPS
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(34));
|
||||
data.extend_from_slice(&[0xCC]);
|
||||
// IDR slice
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
let idr_hdr = hevc_nal_header(19);
|
||||
data.extend_from_slice(&idr_hdr);
|
||||
data.extend_from_slice(&[0x10, 0x20]);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
|
||||
// Frame data should only have the IDR NAL (length-prefixed)
|
||||
let fd = &frames[0].data;
|
||||
let length = u32::from_be_bytes([fd[0], fd[1], fd[2], fd[3]]);
|
||||
// IDR NAL = 2 bytes header + 2 bytes payload = 4 bytes
|
||||
assert_eq!(length as usize + 4, fd.len(), "frame should contain exactly one length-prefixed NAL");
|
||||
}
|
||||
|
||||
// --- empty PES ---
|
||||
|
||||
#[test]
|
||||
fn parse_empty_pes() {
|
||||
let mut parser = HevcParser::new();
|
||||
let pes = make_pes(Vec::new(), Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert!(frames.is_empty());
|
||||
}
|
||||
|
||||
// --- PTS conversion ---
|
||||
|
||||
#[test]
|
||||
fn pts_conversion() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(1));
|
||||
data.extend_from_slice(&[0x10, 0x20]);
|
||||
|
||||
let pes = make_pes(data, Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,3 +23,51 @@ impl CodecParser for PgsParser {
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> { None }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mux::ts::PesPacket;
|
||||
|
||||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||
PesPacket { pid: 0x1200, pts, dts: None, data }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_basic_segment() {
|
||||
let mut parser = PgsParser::new();
|
||||
// PGS segment data (PCS = presentation composition segment)
|
||||
let data = vec![0x16, 0x00, 0x00, 0x11, 0x01, 0x02, 0x03];
|
||||
let pes = make_pes(data.clone(), Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data, data);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_keyframes() {
|
||||
let mut parser = PgsParser::new();
|
||||
for i in 0..3 {
|
||||
let data = vec![0x16, 0x00, i];
|
||||
let pes = make_pes(data, Some(90000 * i as i64));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(frames[0].keyframe, "PGS segment should always be keyframe");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none() {
|
||||
let parser = PgsParser::new();
|
||||
assert!(parser.codec_private().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_pes() {
|
||||
let mut parser = PgsParser::new();
|
||||
let pes = make_pes(Vec::new(), Some(0));
|
||||
assert!(parser.parse(&pes).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,3 +25,51 @@ impl CodecParser for TrueHdParser {
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> { None }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mux::ts::PesPacket;
|
||||
|
||||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||
PesPacket { pid: 0x1100, pts, dts: None, data }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_basic_frame() {
|
||||
let mut parser = TrueHdParser::new();
|
||||
// TrueHD major sync: F8 72 6F BA (at 4-byte aligned position) + payload
|
||||
let data = vec![0xF8, 0x72, 0x6F, 0xBA, 0x01, 0x02, 0x03, 0x04];
|
||||
let pes = make_pes(data.clone(), Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data, data);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_keyframes() {
|
||||
let mut parser = TrueHdParser::new();
|
||||
for i in 0..3 {
|
||||
let data = vec![0xF8, 0x72, 0x6F, 0xBA, i];
|
||||
let pes = make_pes(data, Some(90000 * i as i64));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(frames[0].keyframe, "TrueHD frame should always be keyframe");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none() {
|
||||
let parser = TrueHdParser::new();
|
||||
assert!(parser.codec_private().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_pes() {
|
||||
let mut parser = TrueHdParser::new();
|
||||
let pes = make_pes(Vec::new(), Some(0));
|
||||
assert!(parser.parse(&pes).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,3 +120,225 @@ fn find_next_sc(data: &[u8], from: usize) -> Option<usize> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mux::ts::PesPacket;
|
||||
|
||||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||
PesPacket { pid: 0x1011, pts, dts: None, data }
|
||||
}
|
||||
|
||||
/// Build a VC-1 PES with sequence header + entry point + frame start code.
|
||||
fn build_vc1_iframe_pes() -> Vec<u8> {
|
||||
let mut data = Vec::new();
|
||||
// Sequence header: 00 00 01 0F + payload
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_SEQUENCE_HEADER]);
|
||||
data.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
|
||||
// Entry point: 00 00 01 0E + payload
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_ENTRY_POINT]);
|
||||
data.extend_from_slice(&[0x11, 0x22, 0x33, 0x44]);
|
||||
// Frame: 00 00 01 0D + payload
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
|
||||
data.extend_from_slice(&[0x55, 0x66, 0x77, 0x88, 0x99]);
|
||||
data
|
||||
}
|
||||
|
||||
// --- sequence header detection ---
|
||||
|
||||
#[test]
|
||||
fn parse_sequence_header() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
let data = build_vc1_iframe_pes();
|
||||
let pes = make_pes(data, Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
// Sequence header present → keyframe
|
||||
assert!(frames[0].keyframe, "PES with sequence header should be keyframe");
|
||||
// seq_header should be stored internally
|
||||
assert!(parser.seq_header.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_entry_point() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
let data = build_vc1_iframe_pes();
|
||||
let pes = make_pes(data, Some(0));
|
||||
parser.parse(&pes);
|
||||
|
||||
assert!(parser.entry_point.is_some());
|
||||
}
|
||||
|
||||
// --- codec_private is BITMAPINFOHEADER (40+ bytes) ---
|
||||
|
||||
#[test]
|
||||
fn codec_private_bitmapinfoheader() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
let data = build_vc1_iframe_pes();
|
||||
let pes = make_pes(data, Some(0));
|
||||
parser.parse(&pes);
|
||||
|
||||
let cp = parser.codec_private();
|
||||
assert!(cp.is_some(), "codec_private should be Some after seq header + entry point");
|
||||
|
||||
let cp = cp.unwrap();
|
||||
// BITMAPINFOHEADER is 40 bytes + extra data
|
||||
assert!(cp.len() >= 40, "codec_private should be at least 40 bytes (BITMAPINFOHEADER)");
|
||||
|
||||
// biSize (first 4 bytes, little-endian) should equal total length
|
||||
let bi_size = u32::from_le_bytes([cp[0], cp[1], cp[2], cp[3]]);
|
||||
assert_eq!(bi_size as usize, cp.len(), "biSize should match total codec_private length");
|
||||
|
||||
// biCompression = "WVC1" at offset 16
|
||||
assert_eq!(&cp[16..20], b"WVC1", "FOURCC should be WVC1");
|
||||
|
||||
// biWidth at offset 4 (little-endian u32) = 1920
|
||||
let width = u32::from_le_bytes([cp[4], cp[5], cp[6], cp[7]]);
|
||||
assert_eq!(width, 1920);
|
||||
|
||||
// biHeight at offset 8 (little-endian u32) = 1080
|
||||
let height = u32::from_le_bytes([cp[8], cp[9], cp[10], cp[11]]);
|
||||
assert_eq!(height, 1080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none_before_data() {
|
||||
let parser = Vc1Parser::new();
|
||||
assert!(parser.codec_private().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none_missing_entry_point() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
// Only sequence header, no entry point
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_SEQUENCE_HEADER]);
|
||||
data.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
|
||||
data.extend_from_slice(&[0x55, 0x66]);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
parser.parse(&pes);
|
||||
|
||||
assert!(parser.codec_private().is_none(), "should be None without entry point");
|
||||
}
|
||||
|
||||
// --- frame without sequence header → not keyframe ---
|
||||
|
||||
#[test]
|
||||
fn parse_non_keyframe() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
// PES with only a frame start code (no sequence header)
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
|
||||
data.extend_from_slice(&[0x55, 0x66, 0x77]);
|
||||
|
||||
let pes = make_pes(data, Some(180000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(!frames[0].keyframe, "frame without sequence header should not be keyframe");
|
||||
}
|
||||
|
||||
// --- frame data starts from frame start code ---
|
||||
|
||||
#[test]
|
||||
fn frame_data_starts_at_frame_sc() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
let data = build_vc1_iframe_pes();
|
||||
let pes = make_pes(data.clone(), Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
// Frame data should start with the frame start code (00 00 01 0D)
|
||||
assert!(frames[0].data.len() >= 4);
|
||||
assert_eq!(&frames[0].data[0..4], &[0x00, 0x00, 0x01, SC_FRAME]);
|
||||
}
|
||||
|
||||
// --- empty PES ---
|
||||
|
||||
#[test]
|
||||
fn parse_empty_pes() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
let pes = make_pes(Vec::new(), Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
assert!(frames.is_empty());
|
||||
}
|
||||
|
||||
// --- PTS conversion ---
|
||||
|
||||
#[test]
|
||||
fn pts_conversion() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
|
||||
data.extend_from_slice(&[0x55, 0x66]);
|
||||
|
||||
let pes = make_pes(data, Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
|
||||
// --- DTS preferred over PTS ---
|
||||
|
||||
#[test]
|
||||
fn dts_preferred_over_pts() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
|
||||
data.extend_from_slice(&[0x55, 0x66]);
|
||||
|
||||
let pes = PesPacket {
|
||||
pid: 0x1011,
|
||||
pts: Some(180000),
|
||||
dts: Some(90000),
|
||||
data,
|
||||
};
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
|
||||
// --- find_next_sc utility ---
|
||||
|
||||
#[test]
|
||||
fn find_next_sc_basic() {
|
||||
let data = [0xAA, 0x00, 0x00, 0x01, 0x0D, 0xBB];
|
||||
assert_eq!(find_next_sc(&data, 0), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_next_sc_none() {
|
||||
let data = [0xAA, 0xBB, 0xCC];
|
||||
assert_eq!(find_next_sc(&data, 0), None);
|
||||
}
|
||||
|
||||
// --- codec_private extra data contains seq header + entry point ---
|
||||
|
||||
#[test]
|
||||
fn codec_private_contains_extra_data() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
let data = build_vc1_iframe_pes();
|
||||
let pes = make_pes(data, Some(0));
|
||||
parser.parse(&pes);
|
||||
|
||||
let cp = parser.codec_private().unwrap();
|
||||
// After the 40-byte BITMAPINFOHEADER, we should have seq_header + entry_point data
|
||||
let extra = &cp[40..];
|
||||
assert!(!extra.is_empty(), "extra data after BITMAPINFOHEADER should not be empty");
|
||||
// Extra data should start with the sequence header start code
|
||||
assert_eq!(&extra[0..4], &[0x00, 0x00, 0x01, SC_SEQUENCE_HEADER]);
|
||||
}
|
||||
}
|
||||
|
||||
+121
@@ -413,4 +413,125 @@ mod tests {
|
||||
// EBML header: 1A 45 DF A3, then 8-byte size, then content
|
||||
assert_eq!(&data[0..4], &[0x1A, 0x45, 0xDF, 0xA3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_read_id_roundtrip() {
|
||||
// 1-byte IDs have high bit set (0x80..=0xFF)
|
||||
for &id in &[0x80u32, 0xA3, 0xFF] {
|
||||
let mut buf = Vec::new();
|
||||
write_id(&mut buf, id).unwrap();
|
||||
assert_eq!(buf.len(), 1);
|
||||
let mut cursor = Cursor::new(&buf);
|
||||
let (read_back, consumed) = read_id(&mut cursor).unwrap();
|
||||
assert_eq!(read_back, id, "1-byte ID roundtrip failed for 0x{:X}", id);
|
||||
assert_eq!(consumed, 1);
|
||||
}
|
||||
// 2-byte IDs (0x4000..=0x7FFF)
|
||||
for &id in &[0x4286u32, 0x4282, 0x7FFF] {
|
||||
let mut buf = Vec::new();
|
||||
write_id(&mut buf, id).unwrap();
|
||||
assert_eq!(buf.len(), 2);
|
||||
let mut cursor = Cursor::new(&buf);
|
||||
let (read_back, consumed) = read_id(&mut cursor).unwrap();
|
||||
assert_eq!(read_back, id, "2-byte ID roundtrip failed for 0x{:X}", id);
|
||||
assert_eq!(consumed, 2);
|
||||
}
|
||||
// 3-byte IDs (0x200000..=0x3FFFFF)
|
||||
for &id in &[0x22B59Cu32, 0x23E383] {
|
||||
let mut buf = Vec::new();
|
||||
write_id(&mut buf, id).unwrap();
|
||||
assert_eq!(buf.len(), 3);
|
||||
let mut cursor = Cursor::new(&buf);
|
||||
let (read_back, consumed) = read_id(&mut cursor).unwrap();
|
||||
assert_eq!(read_back, id, "3-byte ID roundtrip failed for 0x{:X}", id);
|
||||
assert_eq!(consumed, 3);
|
||||
}
|
||||
// 4-byte IDs (0x10000000..=0x1FFFFFFF)
|
||||
for &id in &[EBML, SEGMENT, TRACKS, CLUSTER] {
|
||||
let mut buf = Vec::new();
|
||||
write_id(&mut buf, id).unwrap();
|
||||
assert_eq!(buf.len(), 4);
|
||||
let mut cursor = Cursor::new(&buf);
|
||||
let (read_back, consumed) = read_id(&mut cursor).unwrap();
|
||||
assert_eq!(read_back, id, "4-byte ID roundtrip failed for 0x{:X}", id);
|
||||
assert_eq!(consumed, 4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_read_size_roundtrip() {
|
||||
let test_sizes: &[u64] = &[0, 1, 0x7E, 127, 128, 0x3FFE, 16383, 16384, 0x1FFFFE, 0x0FFFFFFE, 0x1_0000_0000];
|
||||
for &size in test_sizes {
|
||||
let mut buf = Vec::new();
|
||||
write_size(&mut buf, size).unwrap();
|
||||
let mut cursor = Cursor::new(&buf);
|
||||
let (read_back, _consumed) = read_size(&mut cursor).unwrap();
|
||||
assert_eq!(read_back, size, "size roundtrip failed for {}", size);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_read_uint_roundtrip() {
|
||||
let test_vals: &[u64] = &[0, 1, 127, 255, 256, 0xFFFF, 0xFF_FFFF, 0xFFFF_FFFF, 1_000_000_000_000];
|
||||
let test_id = EBML_VERSION;
|
||||
for &val in test_vals {
|
||||
let mut buf = Vec::new();
|
||||
write_uint(&mut buf, test_id, val).unwrap();
|
||||
let mut cursor = Cursor::new(&buf);
|
||||
let (id, id_len) = read_id(&mut cursor).unwrap();
|
||||
assert_eq!(id, test_id);
|
||||
let (size, _) = read_size(&mut cursor).unwrap();
|
||||
let read_val = read_uint_val(&mut cursor, size as usize).unwrap();
|
||||
assert_eq!(read_val, val, "uint roundtrip failed for {}", val);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_read_string_roundtrip() {
|
||||
let test_strings = &["", "matroska", "freemkv", "Hello, World!", "unicode: \u{1F600}"];
|
||||
let test_id = EBML_DOC_TYPE;
|
||||
for &s in test_strings {
|
||||
let mut buf = Vec::new();
|
||||
write_string(&mut buf, test_id, s).unwrap();
|
||||
let mut cursor = Cursor::new(&buf);
|
||||
let (id, _) = read_id(&mut cursor).unwrap();
|
||||
assert_eq!(id, test_id);
|
||||
let (size, _) = read_size(&mut cursor).unwrap();
|
||||
let read_s = read_string_val(&mut cursor, size as usize).unwrap();
|
||||
assert_eq!(read_s, s, "string roundtrip failed for {:?}", s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_read_float_roundtrip() {
|
||||
let test_vals: &[f64] = &[0.0, 1.0, -1.0, 3.14159265358979, 48000.0, 7200000.0, f64::MIN, f64::MAX];
|
||||
let test_id = DURATION;
|
||||
for &val in test_vals {
|
||||
let mut buf = Vec::new();
|
||||
write_float(&mut buf, test_id, val).unwrap();
|
||||
let mut cursor = Cursor::new(&buf);
|
||||
let (id, _) = read_id(&mut cursor).unwrap();
|
||||
assert_eq!(id, test_id);
|
||||
let (size, _) = read_size(&mut cursor).unwrap();
|
||||
assert_eq!(size, 8);
|
||||
let read_val = read_float_val(&mut cursor, size as usize).unwrap();
|
||||
assert_eq!(read_val.to_bits(), val.to_bits(), "float roundtrip failed for {}", val);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_size() {
|
||||
let mut buf = Vec::new();
|
||||
write_unknown_size(&mut buf).unwrap();
|
||||
assert_eq!(buf.len(), 8);
|
||||
assert_eq!(buf[0], 0x01);
|
||||
for &b in &buf[1..] {
|
||||
assert_eq!(b, 0xFF, "unknown size bytes should all be 0xFF after first byte");
|
||||
}
|
||||
// Reading it back should yield u64::MAX
|
||||
let mut cursor = Cursor::new(&buf);
|
||||
let (size, consumed) = read_size(&mut cursor).unwrap();
|
||||
assert_eq!(size, u64::MAX);
|
||||
assert_eq!(consumed, 8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +198,81 @@ impl Read for IsoStream {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use crate::sector::SectorReader;
|
||||
|
||||
#[test]
|
||||
fn iso_reader_read_sectors() {
|
||||
// Create a temp file with known sector data
|
||||
let dir = std::env::temp_dir();
|
||||
let path = dir.join("libfreemkv_test_iso_sectors.iso");
|
||||
let path_str = path.to_str().unwrap();
|
||||
|
||||
// Write 4 sectors of known data
|
||||
{
|
||||
let mut f = File::create(&path).unwrap();
|
||||
for sector_idx in 0u8..4 {
|
||||
let mut sector = [sector_idx; SECTOR_SIZE as usize];
|
||||
sector[0] = sector_idx;
|
||||
sector[2047] = sector_idx.wrapping_mul(0x37);
|
||||
f.write_all(§or).unwrap();
|
||||
}
|
||||
f.flush().unwrap();
|
||||
}
|
||||
|
||||
let mut reader = IsoSectorReader::open(path_str).unwrap();
|
||||
assert_eq!(reader.capacity(), 4);
|
||||
|
||||
// Read sector 0
|
||||
let mut buf = [0u8; SECTOR_SIZE as usize];
|
||||
let n = reader.read_sectors(0, 1, &mut buf).unwrap();
|
||||
assert_eq!(n, SECTOR_SIZE as usize);
|
||||
assert_eq!(buf[0], 0);
|
||||
assert_eq!(buf[2047], 0u8.wrapping_mul(0x37));
|
||||
|
||||
// Read sector 2
|
||||
let n = reader.read_sectors(2, 1, &mut buf).unwrap();
|
||||
assert_eq!(n, SECTOR_SIZE as usize);
|
||||
assert_eq!(buf[0], 2);
|
||||
assert_eq!(buf[1], 2); // filled with sector_idx
|
||||
assert_eq!(buf[2047], 2u8.wrapping_mul(0x37));
|
||||
|
||||
// Read 2 sectors at once (sectors 1 and 2)
|
||||
let mut buf2 = [0u8; SECTOR_SIZE as usize * 2];
|
||||
let n = reader.read_sectors(1, 2, &mut buf2).unwrap();
|
||||
assert_eq!(n, SECTOR_SIZE as usize * 2);
|
||||
assert_eq!(buf2[0], 1); // sector 1 first byte
|
||||
assert_eq!(buf2[SECTOR_SIZE as usize], 2); // sector 2 first byte
|
||||
|
||||
// Clean up
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_reader_capacity() {
|
||||
let dir = std::env::temp_dir();
|
||||
let path = dir.join("libfreemkv_test_iso_capacity.iso");
|
||||
let path_str = path.to_str().unwrap();
|
||||
|
||||
// Write exactly 10 sectors
|
||||
{
|
||||
let mut f = File::create(&path).unwrap();
|
||||
let data = vec![0u8; SECTOR_SIZE as usize * 10];
|
||||
f.write_all(&data).unwrap();
|
||||
f.flush().unwrap();
|
||||
}
|
||||
|
||||
let reader = IsoSectorReader::open(path_str).unwrap();
|
||||
assert_eq!(reader.capacity(), 10);
|
||||
|
||||
// Clean up
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for IsoStream {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
match self.writer.as_mut() {
|
||||
|
||||
@@ -41,3 +41,48 @@ impl Read for NullStream {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "null stream is write-only"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn null_counts_bytes() {
|
||||
let mut ns = NullStream::new();
|
||||
assert_eq!(ns.bytes_written(), 0);
|
||||
ns.write_all(&[0u8; 100]).unwrap();
|
||||
assert_eq!(ns.bytes_written(), 100);
|
||||
ns.write_all(&[1u8; 50]).unwrap();
|
||||
assert_eq!(ns.bytes_written(), 150);
|
||||
// Single write returns correct count
|
||||
let n = ns.write(&[0u8; 200]).unwrap();
|
||||
assert_eq!(n, 200);
|
||||
assert_eq!(ns.bytes_written(), 350);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_read_errors() {
|
||||
let mut ns = NullStream::new();
|
||||
let mut buf = [0u8; 10];
|
||||
let err = ns.read(&mut buf).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_finish_ok() {
|
||||
let mut ns = NullStream::new();
|
||||
ns.write_all(&[0u8; 1000]).unwrap();
|
||||
ns.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_implements_iostream() {
|
||||
let ns = NullStream::new();
|
||||
let mut boxed: Box<dyn IOStream> = Box::new(ns);
|
||||
boxed.write_all(&[0u8; 50]).unwrap();
|
||||
let info = boxed.info();
|
||||
assert_eq!(info.streams.len(), 0);
|
||||
boxed.finish().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,3 +75,26 @@ impl Write for StdioStream {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
#[test]
|
||||
fn stdio_output_write_errors_on_read() {
|
||||
let mut stream = StdioStream::output();
|
||||
let mut buf = [0u8; 10];
|
||||
let err = stream.read(&mut buf).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
|
||||
assert!(err.to_string().contains("cannot read"), "got: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdio_input_read_errors_on_write() {
|
||||
let mut stream = StdioStream::input();
|
||||
let err = stream.write(&[0u8; 10]).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
|
||||
assert!(err.to_string().contains("cannot write"), "got: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Disc scanning pipeline tests.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use libfreemkv::error::Result;
|
||||
use libfreemkv::sector::SectorReader;
|
||||
use libfreemkv::{Disc, DiscTitle, ScanOptions};
|
||||
|
||||
const SECTOR_SIZE: usize = 2048;
|
||||
|
||||
/// Minimal mock sector reader for disc scan tests.
|
||||
struct MockSectorReader {
|
||||
sectors: HashMap<u32, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MockSectorReader {
|
||||
fn new() -> Self {
|
||||
Self { sectors: HashMap::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for MockSectorReader {
|
||||
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
let total = count as usize * SECTOR_SIZE;
|
||||
for i in 0..count as u32 {
|
||||
let offset = i as usize * SECTOR_SIZE;
|
||||
if let Some(data) = self.sectors.get(&(lba + i)) {
|
||||
buf[offset..offset + SECTOR_SIZE].copy_from_slice(data);
|
||||
} else {
|
||||
buf[offset..offset + SECTOR_SIZE].fill(0);
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
}
|
||||
|
||||
// ── scan_image tests ───────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn scan_image_empty_reader() {
|
||||
// An empty reader has no AVDP at sector 256 -> UDF parse fails
|
||||
let mut reader = MockSectorReader::new();
|
||||
let opts = ScanOptions::default();
|
||||
let result = Disc::scan_image(&mut reader, 0, &opts);
|
||||
assert!(result.is_err(), "scan_image should fail with empty reader (no AVDP)");
|
||||
}
|
||||
|
||||
// ── DiscTitle tests ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn disc_title_empty() {
|
||||
let t = DiscTitle::empty();
|
||||
assert_eq!(t.playlist, "");
|
||||
assert_eq!(t.playlist_id, 0);
|
||||
assert_eq!(t.duration_secs, 0.0);
|
||||
assert_eq!(t.size_bytes, 0);
|
||||
assert!(t.clips.is_empty());
|
||||
assert!(t.streams.is_empty());
|
||||
assert!(t.extents.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_title_duration_display() {
|
||||
let mut t = DiscTitle::empty();
|
||||
|
||||
// 2 hours 15 minutes = 8100 seconds
|
||||
t.duration_secs = 8100.0;
|
||||
assert_eq!(t.duration_display(), "2h 15m");
|
||||
|
||||
// 0 hours 5 minutes = 300 seconds
|
||||
t.duration_secs = 300.0;
|
||||
assert_eq!(t.duration_display(), "0h 05m");
|
||||
|
||||
// Exact hour boundary
|
||||
t.duration_secs = 3600.0;
|
||||
assert_eq!(t.duration_display(), "1h 00m");
|
||||
|
||||
// Large value: 10 hours 30 minutes
|
||||
t.duration_secs = 37800.0;
|
||||
assert_eq!(t.duration_display(), "10h 30m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_title_size_gb() {
|
||||
let mut t = DiscTitle::empty();
|
||||
|
||||
// Exactly 1 GiB
|
||||
t.size_bytes = 1024 * 1024 * 1024;
|
||||
assert!((t.size_gb() - 1.0).abs() < 0.001);
|
||||
|
||||
// 50 GiB (typical BD)
|
||||
t.size_bytes = 50 * 1024 * 1024 * 1024;
|
||||
assert!((t.size_gb() - 50.0).abs() < 0.001);
|
||||
|
||||
// Zero
|
||||
t.size_bytes = 0;
|
||||
assert_eq!(t.size_gb(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_title_total_sectors() {
|
||||
let mut t = DiscTitle::empty();
|
||||
assert_eq!(t.total_sectors(), 0);
|
||||
|
||||
t.extents.push(libfreemkv::Extent { start_lba: 0, sector_count: 100 });
|
||||
t.extents.push(libfreemkv::Extent { start_lba: 200, sector_count: 50 });
|
||||
assert_eq!(t.total_sectors(), 150);
|
||||
}
|
||||
|
||||
// ── ScanOptions tests ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn scan_options_default() {
|
||||
let opts = ScanOptions::default();
|
||||
assert!(opts.keydb_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_options_with_keydb() {
|
||||
let opts = ScanOptions::with_keydb("/tmp/KEYDB.cfg");
|
||||
assert_eq!(
|
||||
opts.keydb_path.as_ref().unwrap().to_str().unwrap(),
|
||||
"/tmp/KEYDB.cfg"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_options_with_keydb_pathbuf() {
|
||||
let path = std::path::PathBuf::from("/home/user/.config/aacs/KEYDB.cfg");
|
||||
let opts = ScanOptions::with_keydb(path.clone());
|
||||
assert_eq!(opts.keydb_path.unwrap(), path);
|
||||
}
|
||||
@@ -347,3 +347,185 @@ fn disc_title_empty() {
|
||||
assert_eq!(dt.duration_secs, 0.0);
|
||||
assert!(dt.playlist.is_empty());
|
||||
}
|
||||
|
||||
// ── Meta codec roundtrip ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn meta_codec_roundtrip() {
|
||||
// Test that all codec types survive from_title -> to_title
|
||||
let codecs_video = &[Codec::Hevc, Codec::H264, Codec::Vc1, Codec::Mpeg2];
|
||||
let codecs_audio = &[Codec::Ac3, Codec::Ac3Plus, Codec::TrueHd, Codec::DtsHdMa, Codec::DtsHdHr, Codec::Dts, Codec::Lpcm];
|
||||
let codecs_sub = &[Codec::Pgs];
|
||||
|
||||
let mut streams = Vec::new();
|
||||
for (i, &codec) in codecs_video.iter().enumerate() {
|
||||
streams.push(Stream::Video(VideoStream {
|
||||
pid: (0x1011 + i) as u16, codec,
|
||||
resolution: "1080p".into(), frame_rate: "23.976".into(),
|
||||
hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709,
|
||||
secondary: false, label: String::new(),
|
||||
}));
|
||||
}
|
||||
for (i, &codec) in codecs_audio.iter().enumerate() {
|
||||
streams.push(Stream::Audio(AudioStream {
|
||||
pid: (0x1100 + i) as u16, codec,
|
||||
channels: "5.1".into(), language: "eng".into(),
|
||||
sample_rate: "48kHz".into(), secondary: false,
|
||||
label: String::new(),
|
||||
}));
|
||||
}
|
||||
for (i, &codec) in codecs_sub.iter().enumerate() {
|
||||
streams.push(Stream::Subtitle(SubtitleStream {
|
||||
pid: (0x1200 + i) as u16, codec,
|
||||
language: "eng".into(), forced: false,
|
||||
}));
|
||||
}
|
||||
|
||||
let dt = DiscTitle {
|
||||
playlist: "Codec Test".into(),
|
||||
playlist_id: 0,
|
||||
duration_secs: 100.0,
|
||||
size_bytes: 0,
|
||||
clips: Vec::new(),
|
||||
streams,
|
||||
extents: Vec::new(),
|
||||
};
|
||||
|
||||
let meta = M2tsMeta::from_title(&dt);
|
||||
let restored = meta.to_title();
|
||||
|
||||
assert_eq!(restored.streams.len(), dt.streams.len());
|
||||
for (orig, rest) in dt.streams.iter().zip(restored.streams.iter()) {
|
||||
match (orig, rest) {
|
||||
(Stream::Video(o), Stream::Video(r)) => assert_eq!(o.codec, r.codec, "video codec mismatch"),
|
||||
(Stream::Audio(o), Stream::Audio(r)) => assert_eq!(o.codec, r.codec, "audio codec mismatch"),
|
||||
(Stream::Subtitle(o), Stream::Subtitle(r)) => assert_eq!(o.codec, r.codec, "subtitle codec mismatch"),
|
||||
_ => panic!("stream type mismatch"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_empty_streams() {
|
||||
let dt = DiscTitle {
|
||||
playlist: "Empty".into(),
|
||||
playlist_id: 0,
|
||||
duration_secs: 0.0,
|
||||
size_bytes: 0,
|
||||
clips: Vec::new(),
|
||||
streams: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
};
|
||||
|
||||
let meta = M2tsMeta::from_title(&dt);
|
||||
assert_eq!(meta.streams.len(), 0);
|
||||
let restored = meta.to_title();
|
||||
assert_eq!(restored.streams.len(), 0);
|
||||
assert_eq!(restored.playlist, "Empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_all_stream_types() {
|
||||
let dt = DiscTitle {
|
||||
playlist: "Full".into(),
|
||||
playlist_id: 0,
|
||||
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::Bt709,
|
||||
secondary: false, label: "Primary".into(),
|
||||
}),
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1100, codec: Codec::TrueHd,
|
||||
channels: "7.1".into(), language: "eng".into(),
|
||||
sample_rate: "48kHz".into(), secondary: false,
|
||||
label: "Primary Audio".into(),
|
||||
}),
|
||||
Stream::Subtitle(SubtitleStream {
|
||||
pid: 0x1200, codec: Codec::Pgs,
|
||||
language: "fra".into(), forced: true,
|
||||
}),
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1110, codec: Codec::Ac3,
|
||||
channels: "stereo".into(), language: "eng".into(),
|
||||
sample_rate: "48kHz".into(), secondary: true,
|
||||
label: "Commentary".into(),
|
||||
}),
|
||||
],
|
||||
extents: Vec::new(),
|
||||
};
|
||||
|
||||
let meta = M2tsMeta::from_title(&dt);
|
||||
let restored = meta.to_title();
|
||||
|
||||
assert_eq!(restored.streams.len(), 4);
|
||||
|
||||
// Video preserved
|
||||
if let Stream::Video(v) = &restored.streams[0] {
|
||||
assert_eq!(v.codec, Codec::Hevc);
|
||||
assert_eq!(v.resolution, "2160p");
|
||||
assert_eq!(v.label, "Primary");
|
||||
assert!(!v.secondary);
|
||||
} else { panic!("expected video"); }
|
||||
|
||||
// Primary audio preserved
|
||||
if let Stream::Audio(a) = &restored.streams[1] {
|
||||
assert_eq!(a.codec, Codec::TrueHd);
|
||||
assert_eq!(a.channels, "7.1");
|
||||
assert!(!a.secondary);
|
||||
} else { panic!("expected audio"); }
|
||||
|
||||
// Subtitle preserved (forced flag)
|
||||
if let Stream::Subtitle(s) = &restored.streams[2] {
|
||||
assert_eq!(s.language, "fra");
|
||||
assert!(s.forced);
|
||||
} else { panic!("expected subtitle"); }
|
||||
|
||||
// Secondary audio preserved
|
||||
if let Stream::Audio(a) = &restored.streams[3] {
|
||||
assert_eq!(a.codec, Codec::Ac3);
|
||||
assert!(a.secondary);
|
||||
assert_eq!(a.label, "Commentary");
|
||||
} else { panic!("expected secondary audio"); }
|
||||
}
|
||||
|
||||
// ── MkvStream tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn mkvstream_write_finish() {
|
||||
let output = Cursor::new(Vec::new());
|
||||
let dt = sample_disc_title();
|
||||
let mut stream = MkvStream::new(output).meta(&dt).max_buffer(1024 * 1024);
|
||||
|
||||
// Write some fake BD-TS packets (they won't produce valid MKV frames
|
||||
// since there is no real codec data, but it should not panic)
|
||||
for i in 0..20u8 {
|
||||
let mut pkt = [0u8; 192];
|
||||
pkt[4] = 0x47;
|
||||
// PID 0x1011 (video)
|
||||
pkt[5] = 0x10;
|
||||
pkt[6] = 0x11;
|
||||
pkt[7] = 0x10;
|
||||
pkt[8] = i;
|
||||
stream.write_all(&pkt).unwrap();
|
||||
}
|
||||
|
||||
// finish should not panic even without valid codec data
|
||||
stream.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkvstream_meta_sets_title() {
|
||||
let output = Cursor::new(Vec::new());
|
||||
let dt = sample_disc_title();
|
||||
let stream = MkvStream::new(output).meta(&dt);
|
||||
|
||||
let info = stream.info();
|
||||
assert_eq!(info.playlist, "Test Movie");
|
||||
assert_eq!(info.duration_secs, 7200.0);
|
||||
assert_eq!(info.streams.len(), 4);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
//! UDF parser tests using a MockSectorReader.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use libfreemkv::error::Result;
|
||||
use libfreemkv::sector::SectorReader;
|
||||
use libfreemkv::udf;
|
||||
|
||||
const SECTOR_SIZE: usize = 2048;
|
||||
|
||||
/// In-memory sector reader backed by a HashMap<LBA, sector_data>.
|
||||
/// Any LBA not in the map returns zeroed sectors.
|
||||
struct MockSectorReader {
|
||||
sectors: HashMap<u32, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MockSectorReader {
|
||||
fn new() -> Self {
|
||||
Self { sectors: HashMap::new() }
|
||||
}
|
||||
|
||||
/// Write a full 2048-byte sector at the given LBA.
|
||||
fn set_sector(&mut self, lba: u32, data: Vec<u8>) {
|
||||
assert_eq!(data.len(), SECTOR_SIZE, "sector data must be exactly 2048 bytes");
|
||||
self.sectors.insert(lba, data);
|
||||
}
|
||||
|
||||
/// Write partial data into a sector (rest is zeroed).
|
||||
fn set_sector_partial(&mut self, lba: u32, data: &[u8]) {
|
||||
let mut sector = vec![0u8; SECTOR_SIZE];
|
||||
let len = data.len().min(SECTOR_SIZE);
|
||||
sector[..len].copy_from_slice(&data[..len]);
|
||||
self.sectors.insert(lba, sector);
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for MockSectorReader {
|
||||
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
let total = count as usize * SECTOR_SIZE;
|
||||
assert!(buf.len() >= total, "buffer too small");
|
||||
for i in 0..count as u32 {
|
||||
let offset = i as usize * SECTOR_SIZE;
|
||||
if let Some(data) = self.sectors.get(&(lba + i)) {
|
||||
buf[offset..offset + SECTOR_SIZE].copy_from_slice(data);
|
||||
} else {
|
||||
// Return zeros for unmapped sectors
|
||||
buf[offset..offset + SECTOR_SIZE].fill(0);
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper: build raw sector data ──────────────────────────────────────────
|
||||
|
||||
/// 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];
|
||||
// Tag ID = 2 (AVDP) at bytes [0..2]
|
||||
s[0..2].copy_from_slice(&2u16.to_le_bytes());
|
||||
// Main VDS extent location at bytes [16..20]
|
||||
s[16..20].copy_from_slice(&vds_lba.to_le_bytes());
|
||||
// Main VDS extent length at bytes [20..24] (arbitrary, say 6 sectors)
|
||||
s[20..24].copy_from_slice(&(6u32 * SECTOR_SIZE as u32).to_le_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
/// Build a Primary Volume Descriptor (tag_id=1) with the given volume ID.
|
||||
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());
|
||||
// Volume ID at offset 24, d-string format:
|
||||
// compression_id(1 byte) + ASCII chars + length byte at position 55
|
||||
if !volume_id.is_empty() {
|
||||
let id_bytes = volume_id.as_bytes();
|
||||
s[24] = 8; // compression ID = ASCII
|
||||
let copy_len = id_bytes.len().min(30);
|
||||
s[25..25 + copy_len].copy_from_slice(&id_bytes[..copy_len]);
|
||||
// d-string length byte at end of 32-byte field (offset 55)
|
||||
s[55] = (1 + copy_len) as u8; // compression byte + chars
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Build a Partition Descriptor (tag_id=5) with partition_start.
|
||||
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());
|
||||
// Partition start at bytes [188..192]
|
||||
s[188..192].copy_from_slice(&partition_start.to_le_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
/// Build a Logical Volume Descriptor (tag_id=6) with a single partition map
|
||||
/// (no metadata partition -- simplest case).
|
||||
fn make_lvd_sector_simple() -> Vec<u8> {
|
||||
let mut s = vec![0u8; SECTOR_SIZE];
|
||||
s[0..2].copy_from_slice(&6u16.to_le_bytes());
|
||||
// num_partition_maps at bytes [268..272] = 1 (no metadata partition)
|
||||
s[268..272].copy_from_slice(&1u32.to_le_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
/// Build a Terminating Descriptor (tag_id=8).
|
||||
fn make_terminator() -> Vec<u8> {
|
||||
let mut s = vec![0u8; SECTOR_SIZE];
|
||||
s[0..2].copy_from_slice(&8u16.to_le_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
/// Build a File Set Descriptor (tag_id=256) with root ICB at the given meta LBA.
|
||||
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());
|
||||
// Root directory ICB long_ad at offset 400:
|
||||
// [400..404] = extent_length (one sector = 2048)
|
||||
s[400..404].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes());
|
||||
// [404..408] = extent_location (LBA within metadata partition)
|
||||
s[404..408].copy_from_slice(&root_meta_lba.to_le_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
/// Build an Extended File Entry (tag_id=266) for a directory with the given
|
||||
/// allocation extent pointing to directory data.
|
||||
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());
|
||||
// info_length at offset 56 (u64)
|
||||
s[56..64].copy_from_slice(&(data_len as u64).to_le_bytes());
|
||||
// L_EA at offset 208 = 0 (no extended attributes)
|
||||
s[208..212].copy_from_slice(&0u32.to_le_bytes());
|
||||
// L_AD at offset 212 = 8 (one short allocation descriptor)
|
||||
s[212..216].copy_from_slice(&8u32.to_le_bytes());
|
||||
// Short allocation descriptor at offset 216:
|
||||
// [216..220] = extent_length (type=0 in top 2 bits, length in low 30 bits)
|
||||
s[216..220].copy_from_slice(&data_len.to_le_bytes());
|
||||
// [220..224] = extent_position (LBA within metadata partition)
|
||||
s[220..224].copy_from_slice(&data_meta_lba.to_le_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
/// Build a File Identifier Descriptor (tag_id=257) for a named entry.
|
||||
/// Returns raw bytes (not padded to full sector).
|
||||
fn make_fid(name: &str, icb_meta_lba: u32, is_dir: bool) -> Vec<u8> {
|
||||
// FID name: compression_id(1) + ASCII bytes
|
||||
let mut name_bytes = vec![8u8]; // compression ID = 8 (ASCII)
|
||||
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 };
|
||||
|
||||
// Fixed header = 38 bytes, L_IU = 0
|
||||
let fid_len = ((38 + 0 + l_fi as usize + 3) & !3) as usize; // 4-byte aligned
|
||||
let mut fid = vec![0u8; fid_len];
|
||||
// tag_id = 257
|
||||
fid[0..2].copy_from_slice(&257u16.to_le_bytes());
|
||||
// file_characteristics at offset 18
|
||||
fid[18] = file_chars;
|
||||
// L_FI at offset 19
|
||||
fid[19] = l_fi;
|
||||
// ICB long_ad at offset 20:
|
||||
// [20..24] = extent_length (2048)
|
||||
fid[20..24].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes());
|
||||
// [24..28] = extent_location (LBA)
|
||||
fid[24..28].copy_from_slice(&icb_meta_lba.to_le_bytes());
|
||||
// L_IU at offset 36 = 0
|
||||
fid[36..38].copy_from_slice(&0u16.to_le_bytes());
|
||||
// Name starts at offset 38
|
||||
fid[38..38 + name_bytes.len()].copy_from_slice(&name_bytes);
|
||||
fid
|
||||
}
|
||||
|
||||
/// Build a parent FID (file_chars = 0x08, no name).
|
||||
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; // parent
|
||||
fid[19] = 0; // L_FI = 0
|
||||
fid
|
||||
}
|
||||
|
||||
/// Build a File Entry ICB (tag_id=261) for a file, with the given extent.
|
||||
fn make_file_icb(data_lba: u32, data_len: u32, file_size: u64) -> Vec<u8> {
|
||||
let mut s = vec![0u8; SECTOR_SIZE];
|
||||
s[0..2].copy_from_slice(&261u16.to_le_bytes());
|
||||
// info_length at offset 56 (u64)
|
||||
s[56..64].copy_from_slice(&file_size.to_le_bytes());
|
||||
// L_EA at offset 168 = 0
|
||||
s[168..172].copy_from_slice(&0u32.to_le_bytes());
|
||||
// L_AD at offset 172 = 8
|
||||
s[172..176].copy_from_slice(&8u32.to_le_bytes());
|
||||
// Short allocation descriptor at offset 176:
|
||||
s[176..180].copy_from_slice(&data_len.to_le_bytes());
|
||||
s[180..184].copy_from_slice(&data_lba.to_le_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn mock_sector_reader_roundtrip() {
|
||||
let mut reader = MockSectorReader::new();
|
||||
|
||||
// Write a recognizable pattern to sector 100
|
||||
let mut data = vec![0u8; SECTOR_SIZE];
|
||||
data[0] = 0xAB;
|
||||
data[1] = 0xCD;
|
||||
data[2047] = 0xFF;
|
||||
reader.set_sector(100, data.clone());
|
||||
|
||||
// Read it back
|
||||
let mut buf = vec![0u8; SECTOR_SIZE];
|
||||
let n = reader.read_sectors(100, 1, &mut buf).unwrap();
|
||||
assert_eq!(n, SECTOR_SIZE);
|
||||
assert_eq!(buf[0], 0xAB);
|
||||
assert_eq!(buf[1], 0xCD);
|
||||
assert_eq!(buf[2047], 0xFF);
|
||||
|
||||
// Reading an unmapped sector returns zeros
|
||||
let mut buf2 = vec![0xFFu8; SECTOR_SIZE];
|
||||
let n2 = reader.read_sectors(999, 1, &mut buf2).unwrap();
|
||||
assert_eq!(n2, SECTOR_SIZE);
|
||||
assert_eq!(buf2[0], 0);
|
||||
assert_eq!(buf2[2047], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_sector_reader_multi_sector() {
|
||||
let mut reader = MockSectorReader::new();
|
||||
|
||||
let mut s10 = vec![0u8; SECTOR_SIZE];
|
||||
s10[0] = 10;
|
||||
reader.set_sector(10, s10);
|
||||
|
||||
let mut s11 = vec![0u8; SECTOR_SIZE];
|
||||
s11[0] = 11;
|
||||
reader.set_sector(11, s11);
|
||||
|
||||
// Read 2 consecutive sectors
|
||||
let mut buf = vec![0u8; SECTOR_SIZE * 2];
|
||||
let n = reader.read_sectors(10, 2, &mut buf).unwrap();
|
||||
assert_eq!(n, SECTOR_SIZE * 2);
|
||||
assert_eq!(buf[0], 10);
|
||||
assert_eq!(buf[SECTOR_SIZE], 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_filesystem_no_avdp() {
|
||||
// Empty reader — sector 256 is all zeros, tag_id=0 != 2
|
||||
let mut reader = MockSectorReader::new();
|
||||
let result = udf::read_filesystem(&mut reader);
|
||||
assert!(result.is_err(), "should fail when no AVDP at sector 256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_filesystem_bad_avdp_tag() {
|
||||
// Put a sector at 256 with wrong tag_id
|
||||
let mut reader = MockSectorReader::new();
|
||||
let mut bad = vec![0u8; SECTOR_SIZE];
|
||||
bad[0..2].copy_from_slice(&99u16.to_le_bytes()); // tag_id=99, not 2
|
||||
reader.set_sector(256, bad);
|
||||
|
||||
let result = udf::read_filesystem(&mut reader);
|
||||
assert!(result.is_err(), "should fail when AVDP tag_id is not 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_filesystem_no_partition_descriptor() {
|
||||
// Valid AVDP but VDS has no Partition Descriptor (tag 5)
|
||||
let mut reader = MockSectorReader::new();
|
||||
reader.set_sector(256, make_avdp_sector(32));
|
||||
// Put a terminator immediately at sector 32
|
||||
reader.set_sector(32, make_terminator());
|
||||
|
||||
let result = udf::read_filesystem(&mut reader);
|
||||
assert!(result.is_err(), "should fail when no partition descriptor in VDS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_filesystem_bad_fsd_tag() {
|
||||
// Valid AVDP + VDS with partition desc + LVD, but FSD at metadata_start has wrong tag
|
||||
let mut reader = MockSectorReader::new();
|
||||
let partition_start = 512;
|
||||
|
||||
reader.set_sector(256, make_avdp_sector(32));
|
||||
reader.set_sector(32, make_pvd_sector("TEST_DISC"));
|
||||
reader.set_sector(33, make_partition_desc(partition_start));
|
||||
reader.set_sector(34, make_lvd_sector_simple());
|
||||
reader.set_sector(35, make_terminator());
|
||||
// With 1 partition map, metadata_start = partition_start.
|
||||
// FSD should be at sector partition_start but we leave it as zeros (tag_id=0 != 256).
|
||||
|
||||
let result = udf::read_filesystem(&mut reader);
|
||||
assert!(result.is_err(), "should fail when FSD tag_id is not 256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_filesystem_minimal_valid() {
|
||||
// Build a minimal valid UDF image: AVDP -> VDS -> FSD -> empty root dir
|
||||
let mut reader = MockSectorReader::new();
|
||||
let partition_start: u32 = 512;
|
||||
let root_icb_meta_lba: u32 = 1; // relative to metadata_start
|
||||
let root_data_meta_lba: u32 = 2;
|
||||
|
||||
// Sector 256: AVDP
|
||||
reader.set_sector(256, make_avdp_sector(32));
|
||||
|
||||
// VDS at sectors 32..35
|
||||
reader.set_sector(32, make_pvd_sector("MY_DISC"));
|
||||
reader.set_sector(33, make_partition_desc(partition_start));
|
||||
reader.set_sector(34, make_lvd_sector_simple());
|
||||
reader.set_sector(35, make_terminator());
|
||||
|
||||
// FSD at partition_start (since single partition map, metadata_start = partition_start)
|
||||
reader.set_sector(partition_start, make_fsd_sector(root_icb_meta_lba));
|
||||
|
||||
// Root directory ICB at metadata_start + root_icb_meta_lba
|
||||
// Points to directory data at root_data_meta_lba, length = 40 (one parent FID)
|
||||
let parent_fid = make_parent_fid();
|
||||
let dir_data_len = parent_fid.len() as u32;
|
||||
reader.set_sector(
|
||||
partition_start + root_icb_meta_lba,
|
||||
make_dir_icb(root_data_meta_lba, dir_data_len),
|
||||
);
|
||||
|
||||
// Root directory data: just a parent FID (empty directory)
|
||||
reader.set_sector_partial(partition_start + root_data_meta_lba, &parent_fid);
|
||||
|
||||
let fs = udf::read_filesystem(&mut reader).expect("should parse minimal UDF");
|
||||
assert_eq!(fs.volume_id, "MY_DISC");
|
||||
assert!(fs.root.is_dir);
|
||||
assert!(fs.root.entries.is_empty(), "root should have no children");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_filesystem_with_subdirectory() {
|
||||
// Build a UDF image with root -> BDMV (dir) -> test.mpls (file)
|
||||
let mut reader = MockSectorReader::new();
|
||||
let partition_start: u32 = 512;
|
||||
|
||||
// Layout (all relative to partition_start which equals metadata_start):
|
||||
// meta LBA 0 = FSD
|
||||
// meta LBA 1 = root ICB
|
||||
// meta LBA 2 = root dir data
|
||||
// meta LBA 3 = BDMV ICB
|
||||
// meta LBA 4 = BDMV dir data
|
||||
// meta LBA 5 = test.mpls file ICB
|
||||
// meta LBA 10 = test.mpls file data (partition-relative)
|
||||
|
||||
reader.set_sector(256, make_avdp_sector(32));
|
||||
reader.set_sector(32, make_pvd_sector("DISC_WITH_BDMV"));
|
||||
reader.set_sector(33, make_partition_desc(partition_start));
|
||||
reader.set_sector(34, make_lvd_sector_simple());
|
||||
reader.set_sector(35, make_terminator());
|
||||
|
||||
// FSD -> root ICB at meta LBA 1
|
||||
reader.set_sector(partition_start + 0, make_fsd_sector(1));
|
||||
|
||||
// Root dir: parent FID + BDMV dir FID
|
||||
let parent_fid = make_parent_fid();
|
||||
let bdmv_fid = make_fid("BDMV", 3, true);
|
||||
let mut root_data = Vec::new();
|
||||
root_data.extend_from_slice(&parent_fid);
|
||||
root_data.extend_from_slice(&bdmv_fid);
|
||||
let root_data_len = root_data.len() as u32;
|
||||
|
||||
reader.set_sector(partition_start + 1, make_dir_icb(2, root_data_len));
|
||||
reader.set_sector_partial(partition_start + 2, &root_data);
|
||||
|
||||
// BDMV dir: parent FID + test.mpls file FID
|
||||
let bdmv_parent = make_parent_fid();
|
||||
let mpls_fid = make_fid("test.mpls", 5, false);
|
||||
let mut bdmv_data = Vec::new();
|
||||
bdmv_data.extend_from_slice(&bdmv_parent);
|
||||
bdmv_data.extend_from_slice(&mpls_fid);
|
||||
let bdmv_data_len = bdmv_data.len() as u32;
|
||||
|
||||
reader.set_sector(partition_start + 3, make_dir_icb(4, bdmv_data_len));
|
||||
reader.set_sector_partial(partition_start + 4, &bdmv_data);
|
||||
|
||||
// test.mpls file ICB (File Entry tag 261)
|
||||
reader.set_sector(partition_start + 5, make_file_icb(10, 1024, 1024));
|
||||
|
||||
let fs = udf::read_filesystem(&mut reader).expect("should parse UDF with subdir");
|
||||
assert_eq!(fs.volume_id, "DISC_WITH_BDMV");
|
||||
|
||||
// Root should have one child: BDMV
|
||||
assert_eq!(fs.root.entries.len(), 1);
|
||||
let bdmv = &fs.root.entries[0];
|
||||
assert_eq!(bdmv.name, "BDMV");
|
||||
assert!(bdmv.is_dir);
|
||||
|
||||
// BDMV should have one child: test.mpls
|
||||
assert_eq!(bdmv.entries.len(), 1);
|
||||
let mpls = &bdmv.entries[0];
|
||||
assert_eq!(mpls.name, "test.mpls");
|
||||
assert!(!mpls.is_dir);
|
||||
assert_eq!(mpls.size, 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_dir_case_insensitive() {
|
||||
// Build a UDF image with BDMV/PLAYLIST directories, then search with various cases
|
||||
let mut reader = MockSectorReader::new();
|
||||
let partition_start: u32 = 512;
|
||||
|
||||
reader.set_sector(256, make_avdp_sector(32));
|
||||
reader.set_sector(32, make_pvd_sector("CASE_TEST"));
|
||||
reader.set_sector(33, make_partition_desc(partition_start));
|
||||
reader.set_sector(34, make_lvd_sector_simple());
|
||||
reader.set_sector(35, make_terminator());
|
||||
|
||||
// FSD
|
||||
reader.set_sector(partition_start + 0, make_fsd_sector(1));
|
||||
|
||||
// Root -> BDMV
|
||||
let root_data = [make_parent_fid(), make_fid("BDMV", 3, true)].concat();
|
||||
reader.set_sector(partition_start + 1, make_dir_icb(2, root_data.len() as u32));
|
||||
reader.set_sector_partial(partition_start + 2, &root_data);
|
||||
|
||||
// BDMV -> PLAYLIST
|
||||
let bdmv_data = [make_parent_fid(), make_fid("PLAYLIST", 5, true)].concat();
|
||||
reader.set_sector(partition_start + 3, make_dir_icb(4, bdmv_data.len() as u32));
|
||||
reader.set_sector_partial(partition_start + 4, &bdmv_data);
|
||||
|
||||
// PLAYLIST (empty)
|
||||
let playlist_data = make_parent_fid();
|
||||
reader.set_sector(partition_start + 5, make_dir_icb(6, playlist_data.len() as u32));
|
||||
reader.set_sector_partial(partition_start + 6, &playlist_data);
|
||||
|
||||
let fs = udf::read_filesystem(&mut reader).expect("should parse");
|
||||
|
||||
// Exact case
|
||||
assert!(fs.find_dir("BDMV/PLAYLIST").is_some());
|
||||
// Lower case
|
||||
assert!(fs.find_dir("bdmv/playlist").is_some());
|
||||
// Mixed case
|
||||
assert!(fs.find_dir("Bdmv/Playlist").is_some());
|
||||
assert!(fs.find_dir("BdMv/PlayList").is_some());
|
||||
// Leading/trailing slashes
|
||||
assert!(fs.find_dir("/BDMV/PLAYLIST/").is_some());
|
||||
// Nonexistent
|
||||
assert!(fs.find_dir("BDMV/STREAM").is_none());
|
||||
assert!(fs.find_dir("NONEXISTENT").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sector_reader_is_object_safe() {
|
||||
// Verify SectorReader can be used as a trait object
|
||||
let mut reader = MockSectorReader::new();
|
||||
reader.set_sector(0, vec![42u8; SECTOR_SIZE]);
|
||||
|
||||
let dyn_reader: &mut dyn SectorReader = &mut reader;
|
||||
let mut buf = vec![0u8; SECTOR_SIZE];
|
||||
let n = dyn_reader.read_sectors(0, 1, &mut buf).unwrap();
|
||||
assert_eq!(n, SECTOR_SIZE);
|
||||
assert_eq!(buf[0], 42);
|
||||
}
|
||||
Reference in New Issue
Block a user