From 8c2f3898b8dd36a3cf0d5310ef977fa737c66d92 Mon Sep 17 00:00:00 2001
From: MattJackson <1085847+MattJackson@users.noreply.github.com>
Date: Fri, 10 Apr 2026 19:13:53 -0700
Subject: [PATCH] Add IOStream trait and stream-based I/O architecture
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Introduce IOStream trait for uniform read/write across disc, file,
network, and null streams. Rename Title→DiscTitle, add stream URL
resolver, split old stream.rs into focused modules (m2ts, mkvstream,
network, disc, null, resolve, meta).
---
src/disc.rs | 25 ++-
src/labels/mod.rs | 4 +-
src/lib.rs | 9 +-
src/mux/codec/ac3.rs | 20 +-
src/mux/codec/h264.rs | 3 +-
src/mux/codec/hevc.rs | 3 +-
src/mux/codec/vc1.rs | 33 +--
src/mux/disc.rs | 130 +++++++++++
src/mux/ebml.rs | 152 ++++++++++++-
src/mux/m2ts.rs | 139 ++++++++++++
src/mux/meta.rs | 293 ++++++++++++++++++++++++
src/mux/mkv.rs | 7 +
src/mux/mkvstream.rs | 512 ++++++++++++++++++++++++++++++++++++++++++
src/mux/mod.rs | 61 +++--
src/mux/network.rs | 128 +++++++++++
src/mux/null.rs | 43 ++++
src/mux/resolve.rs | 130 +++++++++++
src/mux/stream.rs | 218 ------------------
src/mux/ts.rs | 267 +++++++++++++++++++++-
tests/streams.rs | 279 +++++++++++++++++++++++
20 files changed, 2188 insertions(+), 268 deletions(-)
create mode 100644 src/mux/disc.rs
create mode 100644 src/mux/m2ts.rs
create mode 100644 src/mux/meta.rs
create mode 100644 src/mux/mkvstream.rs
create mode 100644 src/mux/network.rs
create mode 100644 src/mux/null.rs
create mode 100644 src/mux/resolve.rs
delete mode 100644 src/mux/stream.rs
create mode 100644 tests/streams.rs
diff --git a/src/disc.rs b/src/disc.rs
index ecad532..92a1812 100644
--- a/src/disc.rs
+++ b/src/disc.rs
@@ -34,7 +34,7 @@ pub struct Disc {
/// Number of layers (1 = single, 2 = dual)
pub layers: u8,
/// Titles sorted by duration (longest first), then playlist name
- pub titles: Vec
,
+ pub titles: Vec,
/// Disc region
pub region: DiscRegion,
/// AACS state -- None if disc is unencrypted or keys unavailable
@@ -80,7 +80,7 @@ pub enum BdRegion {
/// A title (one MPLS playlist).
#[derive(Debug, Clone)]
-pub struct Title {
+pub struct DiscTitle {
/// Playlist filename (e.g. "00800.mpls")
pub playlist: String,
/// Playlist number (e.g. 800)
@@ -279,7 +279,20 @@ impl ColorSpace {
}
}
-impl Title {
+impl DiscTitle {
+ /// Empty DiscTitle with no streams.
+ pub fn empty() -> Self {
+ Self {
+ playlist: String::new(),
+ playlist_id: 0,
+ duration_secs: 0.0,
+ size_bytes: 0,
+ clips: Vec::new(),
+ streams: Vec::new(),
+ extents: Vec::new(),
+ }
+ }
+
/// Duration formatted as "Xh Ym"
pub fn duration_display(&self) -> String {
let hrs = (self.duration_secs / 3600.0) as u32;
@@ -627,7 +640,7 @@ impl Disc {
// ── Internal helpers ────────────────────────────────────────────────────
/// Detect disc format from the main title's video streams.
- fn detect_format(titles: &[Title]) -> DiscFormat {
+ fn detect_format(titles: &[DiscTitle]) -> DiscFormat {
for title in titles.iter().take(3) {
for stream in &title.streams {
if let Stream::Video(v) = stream {
@@ -695,7 +708,7 @@ impl Disc {
udf_fs: &udf::UdfFs,
filename: &str,
data: &[u8],
- ) -> Option {
+ ) -> Option {
let parsed = mpls::parse(data).ok()?;
// Calculate duration from play items
@@ -811,7 +824,7 @@ impl Disc {
let playlist_num = filename.trim_end_matches(".mpls").trim_end_matches(".MPLS");
let playlist_id = playlist_num.parse::().unwrap_or(0);
- Some(Title {
+ Some(DiscTitle {
playlist: filename.to_string(),
playlist_id,
duration_secs,
diff --git a/src/labels/mod.rs b/src/labels/mod.rs
index 8d46551..e59476f 100644
--- a/src/labels/mod.rs
+++ b/src/labels/mod.rs
@@ -15,7 +15,7 @@ pub mod vocab;
use crate::drive::DriveSession;
use crate::udf::UdfFs;
-use crate::disc::{Title, Stream};
+use crate::disc::{DiscTitle, Stream};
/// A stream label extracted from disc config files.
#[derive(Debug, Clone)]
@@ -79,7 +79,7 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
/// Search disc for config files, extract labels, apply to streams.
/// This is 100% optional — if anything fails, streams are untouched.
-pub fn apply(session: &mut DriveSession, udf: &UdfFs, titles: &mut [Title]) {
+pub fn apply(session: &mut DriveSession, udf: &UdfFs, titles: &mut [DiscTitle]) {
let labels = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
extract(session, udf)
})).unwrap_or_default();
diff --git a/src/lib.rs b/src/lib.rs
index a8614dd..cbd84c9 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -92,7 +92,14 @@ pub use profile::DriveProfile;
// Platform trait is pub(crate) -- callers use DriveSession, not Platform directly
pub use scsi::ScsiTransport;
pub use speed::DriveSpeed;
-pub use disc::{Disc, DiscFormat, Title, Clip, Stream, VideoStream, AudioStream, SubtitleStream,
+pub use disc::{Disc, DiscFormat, DiscTitle, Clip, Stream, VideoStream, AudioStream, SubtitleStream,
Codec, HdrFormat, ColorSpace,
Extent, ContentReader, AacsState, KeySource, ScanOptions};
+pub use mux::IOStream;
pub use mux::MkvStream;
+pub use mux::M2tsStream;
+pub use mux::NetworkStream;
+pub use mux::DiscStream;
+pub use mux::NullStream;
+pub use mux::DiscOptions;
+pub use mux::{open_input, open_output, parse_url, InputOptions};
diff --git a/src/mux/codec/ac3.rs b/src/mux/codec/ac3.rs
index 71a2cd6..bbea0cf 100644
--- a/src/mux/codec/ac3.rs
+++ b/src/mux/codec/ac3.rs
@@ -16,22 +16,34 @@ impl Ac3Parser {
impl CodecParser for Ac3Parser {
fn parse(&mut self, pes: &PesPacket) -> Vec {
- if pes.data.is_empty() {
+ if pes.data.len() < 2 {
return Vec::new();
}
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
- // AC3: each PES = one frame, always a keyframe
+ // Find AC3 syncword (0x0B77) — skip any garbage before it
+ let data = &pes.data;
+ let start = find_ac3_sync(data).unwrap_or(0);
+
vec![Frame {
pts_ns,
keyframe: true,
- data: pes.data.clone(),
+ data: data[start..].to_vec(),
}]
}
fn codec_private(&self) -> Option> {
- // AC3 doesn't need codecPrivate in MKV
None
}
}
+
+/// Find AC3 syncword (0x0B77) in data.
+fn find_ac3_sync(data: &[u8]) -> Option {
+ for i in 0..data.len().saturating_sub(1) {
+ if data[i] == 0x0B && data[i + 1] == 0x77 {
+ return Some(i);
+ }
+ }
+ None
+}
diff --git a/src/mux/codec/h264.rs b/src/mux/codec/h264.rs
index 259dcdd..3a916de 100644
--- a/src/mux/codec/h264.rs
+++ b/src/mux/codec/h264.rs
@@ -29,7 +29,8 @@ impl CodecParser for H264Parser {
return Vec::new();
}
- let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
+ // Use DTS when available (monotonic for B-frame content), fall back to PTS
+ let pts_ns = pes.dts.or(pes.pts).map(pts_to_ns).unwrap_or(0);
// Scan NAL units for SPS, PPS, and IDR detection
let mut keyframe = false;
diff --git a/src/mux/codec/hevc.rs b/src/mux/codec/hevc.rs
index 738da9b..971312c 100644
--- a/src/mux/codec/hevc.rs
+++ b/src/mux/codec/hevc.rs
@@ -34,7 +34,8 @@ impl CodecParser for HevcParser {
return Vec::new();
}
- let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
+ // Use DTS when available (monotonic for B-frame content), fall back to PTS
+ let pts_ns = pes.dts.or(pes.pts).map(pts_to_ns).unwrap_or(0);
let data = &pes.data;
let mut keyframe = false;
diff --git a/src/mux/codec/vc1.rs b/src/mux/codec/vc1.rs
index db75e2d..984542c 100644
--- a/src/mux/codec/vc1.rs
+++ b/src/mux/codec/vc1.rs
@@ -28,8 +28,10 @@ impl CodecParser for Vc1Parser {
return Vec::new();
}
- let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
- let mut keyframe = false;
+ // Use DTS when available (monotonic for B-frame content), fall back to PTS
+ let ts_ns = pes.dts.or(pes.pts).map(pts_to_ns).unwrap_or(0);
+ let mut has_seq_header = false;
+ let mut frame_start: Option = None;
// Scan for start codes (00 00 01 XX)
let data = &pes.data;
@@ -39,23 +41,18 @@ impl CodecParser for Vc1Parser {
let sc_type = data[i + 3];
match sc_type {
SC_SEQUENCE_HEADER => {
- // Capture everything from here to next start code
let end = find_next_sc(data, i + 4).unwrap_or(data.len());
self.seq_header = Some(data[i..end].to_vec());
+ has_seq_header = true;
}
SC_ENTRY_POINT => {
let end = find_next_sc(data, i + 4).unwrap_or(data.len());
self.entry_point = Some(data[i..end].to_vec());
}
SC_FRAME => {
- // First frame after sequence header + entry point is a keyframe
- if self.seq_header.is_some() && self.entry_point.is_some() {
- keyframe = true;
- }
- // Also check frame type from bitstream (bit after start code)
- if i + 4 < data.len() {
- // For Advanced profile: first 2 bits of frame data indicate type
- // But simpler: any frame preceded by seq+entry is I-frame
+ // Frame data starts at this start code
+ if frame_start.is_none() {
+ frame_start = Some(i);
}
}
_ => {}
@@ -66,10 +63,20 @@ impl CodecParser for Vc1Parser {
}
}
+ // Keyframe = this PES contains a sequence header (I-frame indicator in BD)
+ let keyframe = has_seq_header;
+
+ // Strip sequence header + entry point from frame data — those are in codecPrivate.
+ // Only include data from the frame start code onwards.
+ let frame_data = match frame_start {
+ Some(start) => &data[start..],
+ None => data, // no frame start code found, pass through entire PES
+ };
+
vec![Frame {
- pts_ns,
+ pts_ns: ts_ns,
keyframe,
- data: pes.data.clone(),
+ data: frame_data.to_vec(),
}]
}
diff --git a/src/mux/disc.rs b/src/mux/disc.rs
new file mode 100644
index 0000000..83c32af
--- /dev/null
+++ b/src/mux/disc.rs
@@ -0,0 +1,130 @@
+//! DiscStream — read BD-TS data from an optical disc drive.
+//!
+//! Read-only stream. Wraps DriveSession + Disc.
+//! Handles drive init, AACS decryption, and sector reading.
+
+use std::io::{self, Read, Write};
+use std::path::Path;
+use super::IOStream;
+use crate::disc::{DiscTitle, Disc};
+use crate::drive::DriveSession;
+use crate::error::Error;
+
+/// Options for opening a disc stream.
+pub struct DiscOptions {
+ /// Device path (e.g. "/dev/sg4"). None = auto-detect.
+ pub device: Option,
+ /// KEYDB.cfg path. None = search standard locations.
+ pub keydb_path: Option,
+ /// Which title to read (0-based). None = longest title.
+ pub title_index: Option,
+}
+
+impl Default for DiscOptions {
+ fn default() -> Self {
+ Self { device: None, keydb_path: None, title_index: None }
+ }
+}
+
+/// Optical disc stream. Read-only — yields decrypted BD-TS bytes.
+pub struct DiscStream {
+ disc_title: DiscTitle,
+ disc: Disc,
+ session: DriveSession,
+ title_index: usize,
+ // Read buffer: holds one batch from ContentReader
+ batch_buf: Vec,
+ batch_pos: usize,
+ started: bool,
+ eof: bool,
+}
+
+impl DiscStream {
+ /// Open the disc drive and scan disc metadata.
+ pub fn open(opts: DiscOptions) -> Result {
+ let device = match opts.device {
+ Some(ref d) => crate::drive::resolve_device(d)?.0,
+ None => crate::drive::find_drive()
+ .ok_or_else(|| Error::DeviceNotFound { path: String::new() })?,
+ };
+
+ let mut session = DriveSession::open(Path::new(&device))?;
+ session.wait_ready()?;
+ let _ = session.init();
+ let _ = session.probe_disc();
+
+ let scan_opts = match opts.keydb_path {
+ Some(ref kp) => crate::disc::ScanOptions::with_keydb(kp),
+ None => crate::disc::ScanOptions::default(),
+ };
+ let disc = Disc::scan(&mut session, &scan_opts)?;
+
+ let title_index = opts.title_index.unwrap_or(0);
+ if title_index >= disc.titles.len() {
+ return Err(Error::DiscTitleRange { index: title_index, count: disc.titles.len() });
+ }
+ let disc_title = disc.titles[title_index].clone();
+
+ Ok(Self {
+ disc_title, disc, session, title_index,
+ batch_buf: Vec::new(), batch_pos: 0,
+ started: false, eof: false,
+ })
+ }
+
+ /// Get the full Disc (for listing all titles, etc.)
+ pub fn disc(&self) -> &Disc { &self.disc }
+}
+
+impl IOStream for DiscStream {
+ fn info(&self) -> &DiscTitle { &self.disc_title }
+ fn finish(&mut self) -> io::Result<()> { Ok(()) }
+}
+
+impl Read for DiscStream {
+ fn read(&mut self, buf: &mut [u8]) -> io::Result {
+ // Drain buffer first
+ if self.batch_pos < self.batch_buf.len() {
+ let n = (self.batch_buf.len() - self.batch_pos).min(buf.len());
+ buf[..n].copy_from_slice(&self.batch_buf[self.batch_pos..self.batch_pos + n]);
+ self.batch_pos += n;
+ return Ok(n);
+ }
+
+ if self.eof { return Ok(0); }
+
+ // Open reader on first call
+ if !self.started {
+ self.started = true;
+ }
+
+ // Read next batch via a temporary ContentReader
+ // ContentReader borrows session and disc, so we create it inline
+ let mut reader = self.disc.open_title(&mut self.session, self.title_index)
+ .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
+
+ match reader.read_batch() {
+ Ok(Some(batch)) => {
+ let n = batch.len().min(buf.len());
+ buf[..n].copy_from_slice(&batch[..n]);
+ if batch.len() > n {
+ self.batch_buf = batch.to_vec();
+ self.batch_pos = n;
+ } else {
+ self.batch_buf.clear();
+ self.batch_pos = 0;
+ }
+ Ok(n)
+ }
+ Ok(None) => { self.eof = true; Ok(0) }
+ Err(e) => Err(io::Error::new(io::ErrorKind::Other, e.to_string())),
+ }
+ }
+}
+
+impl Write for DiscStream {
+ fn write(&mut self, _buf: &[u8]) -> io::Result {
+ Err(io::Error::new(io::ErrorKind::Unsupported, "disc is read-only"))
+ }
+ fn flush(&mut self) -> io::Result<()> { Ok(()) }
+}
diff --git a/src/mux/ebml.rs b/src/mux/ebml.rs
index 082cdd8..d567918 100644
--- a/src/mux/ebml.rs
+++ b/src/mux/ebml.rs
@@ -3,16 +3,16 @@
//! EBML uses variable-length integers for element IDs and sizes.
//! This module provides low-level writers for constructing MKV files.
-use std::io::{self, Write, Seek, SeekFrom};
+use std::io::{self, Read, Write, Seek, SeekFrom};
/// Write an EBML element ID (1-4 bytes, already encoded).
/// Element IDs are predefined constants — we write them verbatim.
pub fn write_id(w: &mut impl Write, id: u32) -> io::Result<()> {
- if id <= 0x7F {
+ if id <= 0xFF {
w.write_all(&[id as u8])
- } else if id <= 0x7FFF {
+ } else if id <= 0xFFFF {
w.write_all(&[(id >> 8) as u8, id as u8])
- } else if id <= 0x7F_FFFF {
+ } else if id <= 0xFF_FFFF {
w.write_all(&[(id >> 16) as u8, (id >> 8) as u8, id as u8])
} else {
w.write_all(&[(id >> 24) as u8, (id >> 16) as u8, (id >> 8) as u8, id as u8])
@@ -140,6 +140,149 @@ pub fn end_master(w: &mut W, size_pos: u64) -> io::Result<()> {
Ok(())
}
+// ============================================================
+// EBML Read primitives
+// ============================================================
+
+/// Read an EBML element ID. Returns (id, bytes_consumed).
+pub fn read_id(r: &mut impl Read) -> io::Result<(u32, usize)> {
+ let mut first = [0u8; 1];
+ r.read_exact(&mut first)?;
+ let b0 = first[0];
+
+ if b0 & 0x80 != 0 {
+ Ok((b0 as u32, 1))
+ } else if b0 & 0x40 != 0 {
+ let mut b = [0u8; 1];
+ r.read_exact(&mut b)?;
+ Ok((((b0 as u32) << 8) | b[0] as u32, 2))
+ } else if b0 & 0x20 != 0 {
+ let mut b = [0u8; 2];
+ r.read_exact(&mut b)?;
+ Ok((((b0 as u32) << 16) | (b[0] as u32) << 8 | b[1] as u32, 3))
+ } else if b0 & 0x10 != 0 {
+ let mut b = [0u8; 3];
+ r.read_exact(&mut b)?;
+ Ok((((b0 as u32) << 24) | (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32, 4))
+ } else {
+ Err(io::Error::new(io::ErrorKind::InvalidData, "invalid EBML ID"))
+ }
+}
+
+/// Read an EBML variable-length size. Returns (size, bytes_consumed).
+/// Size of u64::MAX means "unknown size".
+pub fn read_size(r: &mut impl Read) -> io::Result<(u64, usize)> {
+ let mut first = [0u8; 1];
+ r.read_exact(&mut first)?;
+ let b0 = first[0];
+
+ if b0 & 0x80 != 0 {
+ let val = (b0 & 0x7F) as u64;
+ if val == 0x7F { return Ok((u64::MAX, 1)); } // unknown
+ Ok((val, 1))
+ } else if b0 & 0x40 != 0 {
+ let mut b = [0u8; 1];
+ r.read_exact(&mut b)?;
+ let val = (((b0 & 0x3F) as u64) << 8) | b[0] as u64;
+ if val == 0x3FFF { return Ok((u64::MAX, 2)); }
+ Ok((val, 2))
+ } else if b0 & 0x20 != 0 {
+ let mut b = [0u8; 2];
+ r.read_exact(&mut b)?;
+ let val = (((b0 & 0x1F) as u64) << 16) | (b[0] as u64) << 8 | b[1] as u64;
+ if val == 0x1FFFFF { return Ok((u64::MAX, 3)); }
+ Ok((val, 3))
+ } else if b0 & 0x10 != 0 {
+ let mut b = [0u8; 3];
+ r.read_exact(&mut b)?;
+ let val = (((b0 & 0x0F) as u64) << 24) | (b[0] as u64) << 16 | (b[1] as u64) << 8 | b[2] as u64;
+ if val == 0x0FFFFFFF { return Ok((u64::MAX, 4)); }
+ Ok((val, 4))
+ } else if b0 & 0x08 != 0 {
+ let mut b = [0u8; 4];
+ r.read_exact(&mut b)?;
+ let val = (((b0 & 0x07) as u64) << 32) | (b[0] as u64) << 24 | (b[1] as u64) << 16 | (b[2] as u64) << 8 | b[3] as u64;
+ Ok((val, 5))
+ } else if b0 & 0x04 != 0 {
+ let mut b = [0u8; 5];
+ r.read_exact(&mut b)?;
+ let val = (((b0 & 0x03) as u64) << 40) | (b[0] as u64) << 32 | (b[1] as u64) << 24 | (b[2] as u64) << 16 | (b[3] as u64) << 8 | b[4] as u64;
+ Ok((val, 6))
+ } else if b0 & 0x02 != 0 {
+ let mut b = [0u8; 6];
+ r.read_exact(&mut b)?;
+ let val = (((b0 & 0x01) as u64) << 48) | (b[0] as u64) << 40 | (b[1] as u64) << 32 | (b[2] as u64) << 24 | (b[3] as u64) << 16 | (b[4] as u64) << 8 | b[5] as u64;
+ Ok((val, 7))
+ } else {
+ let mut b = [0u8; 7];
+ r.read_exact(&mut b)?;
+ let val = (b[0] as u64) << 48 | (b[1] as u64) << 40 | (b[2] as u64) << 32 | (b[3] as u64) << 24 | (b[4] as u64) << 16 | (b[5] as u64) << 8 | b[6] as u64;
+ if val == 0x00FFFFFFFFFFFFFF { return Ok((u64::MAX, 8)); }
+ Ok((val, 8))
+ }
+}
+
+/// Read an EBML element header (ID + size). Returns (id, data_size, header_bytes).
+pub fn read_element_header(r: &mut impl Read) -> io::Result<(u32, u64, usize)> {
+ let (id, id_len) = read_id(r)?;
+ let (size, size_len) = read_size(r)?;
+ Ok((id, size, id_len + size_len))
+}
+
+/// Read an unsigned integer value of `len` bytes.
+pub fn read_uint_val(r: &mut impl Read, len: usize) -> io::Result {
+ let mut buf = [0u8; 8];
+ r.read_exact(&mut buf[..len])?;
+ let mut val = 0u64;
+ for &b in &buf[..len] {
+ val = (val << 8) | b as u64;
+ }
+ Ok(val)
+}
+
+/// Read a float value (4 or 8 bytes).
+pub fn read_float_val(r: &mut impl Read, len: usize) -> io::Result {
+ if len == 4 {
+ let mut buf = [0u8; 4];
+ r.read_exact(&mut buf)?;
+ Ok(f32::from_be_bytes(buf) as f64)
+ } else {
+ let mut buf = [0u8; 8];
+ r.read_exact(&mut buf)?;
+ Ok(f64::from_be_bytes(buf))
+ }
+}
+
+/// Read a UTF-8 string value of `len` bytes.
+pub fn read_string_val(r: &mut impl Read, len: usize) -> io::Result {
+ let mut buf = vec![0u8; len];
+ r.read_exact(&mut buf)?;
+ // Strip trailing nulls
+ while buf.last() == Some(&0) { buf.pop(); }
+ String::from_utf8(buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
+}
+
+/// Read binary data of `len` bytes.
+pub fn read_binary_val(r: &mut impl Read, len: usize) -> io::Result> {
+ let mut buf = vec![0u8; len];
+ r.read_exact(&mut buf)?;
+ Ok(buf)
+}
+
+/// Read a VINT (track number) from a SimpleBlock. Returns (value, bytes_consumed).
+pub fn read_vint(r: &mut impl Read) -> io::Result<(u64, usize)> {
+ let mut first = [0u8; 1];
+ r.read_exact(&mut first)?;
+ let b0 = first[0];
+ if b0 & 0x80 != 0 { return Ok(((b0 & 0x7F) as u64, 1)); }
+ if b0 & 0x40 != 0 {
+ let mut b = [0u8; 1];
+ r.read_exact(&mut b)?;
+ return Ok(((((b0 & 0x3F) as u64) << 8) | b[0] as u64, 2));
+ }
+ Err(io::Error::new(io::ErrorKind::InvalidData, "unsupported VINT width"))
+}
+
// ============================================================
// Matroska Element IDs
// ============================================================
@@ -183,6 +326,7 @@ pub const FLAG_FORCED: u32 = 0x55AA;
pub const LANGUAGE: u32 = 0x22B59C;
pub const CODEC_ID: u32 = 0x86;
pub const CODEC_PRIVATE: u32 = 0x63A2;
+pub const TRACK_NAME: u32 = 0x536E;
pub const DEFAULT_DURATION: u32 = 0x23E383;
// Video
diff --git a/src/mux/m2ts.rs b/src/mux/m2ts.rs
new file mode 100644
index 0000000..f5d7050
--- /dev/null
+++ b/src/mux/m2ts.rs
@@ -0,0 +1,139 @@
+//! M2tsStream — BD transport stream with embedded metadata header.
+//!
+//! Write: prepends FMKV metadata header, then passes through BD-TS bytes.
+//! Read: extracts metadata header (or scans PMT), then yields BD-TS bytes.
+
+use std::io::{self, Read, Write, Seek, SeekFrom};
+use super::{IOStream, ReadSeek, meta, ts};
+use crate::disc::{DiscTitle, Stream as DiscStream};
+
+/// Size of initial scan buffer for PMT/stream detection.
+const SCAN_SIZE: usize = 1024 * 1024;
+
+enum Mode {
+ Write {
+ writer: Box,
+ header_written: bool,
+ },
+ Read {
+ reader: Box,
+ },
+}
+
+/// BD transport stream with embedded metadata.
+pub struct M2tsStream {
+ disc_title: DiscTitle,
+ mode: Mode,
+ finished: bool,
+}
+
+impl M2tsStream {
+ /// Create for writing. Metadata header is written on first write().
+ pub fn new(writer: impl Write + 'static) -> Self {
+ Self {
+ disc_title: DiscTitle::empty(),
+ mode: Mode::Write {
+ writer: Box::new(writer),
+ header_written: false,
+ },
+ finished: false,
+ }
+ }
+
+ /// Set stream metadata. Returns self for chaining.
+ pub fn meta(mut self, dt: &DiscTitle) -> Self {
+ self.disc_title = dt.clone();
+ self
+ }
+
+ /// Open an m2ts file for reading.
+ ///
+ /// Tries FMKV metadata header first. Falls back to PMT scan + PTS duration.
+ pub fn open(mut reader: impl Read + Seek + 'static) -> io::Result {
+ // Try FMKV metadata header
+ if let Ok(Some(m)) = meta::read_header(&mut reader) {
+ return Ok(Self {
+ disc_title: m.to_title(),
+ mode: Mode::Read { reader: Box::new(reader) },
+ finished: false,
+ });
+ }
+
+ // Fallback: scan PMT for streams, PTS for duration
+ reader.seek(SeekFrom::Start(0))?;
+ let mut buf = vec![0u8; SCAN_SIZE];
+ let n = reader.read(&mut buf)?;
+
+ let streams = ts::scan_streams(&buf[..n])
+ .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "no streams found"))?;
+
+ let video_pid = streams.iter().find_map(|s| match s {
+ DiscStream::Video(v) => Some(v.pid),
+ _ => None,
+ });
+ let duration = video_pid
+ .and_then(|pid| ts::scan_duration(&mut reader, pid))
+ .unwrap_or(0.0);
+
+ reader.seek(SeekFrom::Start(0))?;
+
+ Ok(Self {
+ disc_title: DiscTitle {
+ duration_secs: duration,
+ streams,
+ ..DiscTitle::empty()
+ },
+ mode: Mode::Read { reader: Box::new(reader) },
+ finished: false,
+ })
+ }
+}
+
+impl IOStream for M2tsStream {
+ fn info(&self) -> &DiscTitle { &self.disc_title }
+
+ fn finish(&mut self) -> io::Result<()> {
+ if self.finished { return Ok(()); }
+ self.finished = true;
+ if let Mode::Write { ref mut writer, .. } = self.mode {
+ writer.flush()
+ } else {
+ Ok(())
+ }
+ }
+}
+
+impl Write for M2tsStream {
+ fn write(&mut self, buf: &[u8]) -> io::Result {
+ match self.mode {
+ Mode::Write { ref mut writer, ref mut header_written } => {
+ if !*header_written {
+ if !self.disc_title.streams.is_empty() {
+ let m = meta::M2tsMeta::from_title(&self.disc_title);
+ meta::write_header(&mut *writer, &m)?;
+ }
+ *header_written = true;
+ }
+ writer.write(buf)
+ }
+ Mode::Read { .. } => Err(io::Error::new(io::ErrorKind::Unsupported, "stream opened for reading")),
+ }
+ }
+
+ fn flush(&mut self) -> io::Result<()> {
+ if let Mode::Write { ref mut writer, .. } = self.mode {
+ writer.flush()
+ } else {
+ Ok(())
+ }
+ }
+}
+
+impl Read for M2tsStream {
+ fn read(&mut self, buf: &mut [u8]) -> io::Result {
+ match self.mode {
+ Mode::Read { ref mut reader } => reader.read(buf),
+ Mode::Write { .. } => Err(io::Error::new(io::ErrorKind::Unsupported, "stream opened for writing")),
+ }
+ }
+}
diff --git a/src/mux/meta.rs b/src/mux/meta.rs
new file mode 100644
index 0000000..48983c8
--- /dev/null
+++ b/src/mux/meta.rs
@@ -0,0 +1,293 @@
+//! M2TS metadata header — embeds title/stream info in raw m2ts files.
+//!
+//! Format: [8B magic] [4B json_len] [JSON] [padding to 192B boundary] [BD-TS data...]
+//! Other tools skip the header during TS sync recovery (scan for 0x47).
+
+use std::io::{self, Read, Seek, SeekFrom, Write};
+use serde::{Serialize, Deserialize};
+use crate::disc::{DiscTitle, Stream, VideoStream, AudioStream, SubtitleStream,
+ Codec, HdrFormat, ColorSpace};
+
+/// Magic bytes: "FMKV" + version 1 + 2 reserved bytes.
+const MAGIC: [u8; 8] = [b'F', b'M', b'K', b'V', 0x00, 0x01, 0x00, 0x00];
+
+/// BD-TS packet size (header must be padded to this boundary).
+const PACKET_SIZE: usize = 192;
+
+/// Metadata embedded in an m2ts file.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct M2tsMeta {
+ /// Format version.
+ pub v: u8,
+ /// Title name (e.g. filename stem or disc title).
+ #[serde(default)]
+ pub title: String,
+ /// Duration in seconds.
+ #[serde(default)]
+ pub duration: f64,
+ /// Stream descriptors.
+ pub streams: Vec,
+}
+
+/// A single stream descriptor in the metadata.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "type")]
+pub enum MetaStream {
+ #[serde(rename = "video")]
+ Video {
+ pid: u16,
+ codec: String,
+ #[serde(default)] resolution: String,
+ #[serde(default)] frame_rate: String,
+ #[serde(default)] hdr: String,
+ #[serde(default)] label: String,
+ #[serde(default)] secondary: bool,
+ },
+ #[serde(rename = "audio")]
+ Audio {
+ pid: u16,
+ codec: String,
+ #[serde(default)] channels: String,
+ #[serde(default)] language: String,
+ #[serde(default)] sample_rate: String,
+ #[serde(default)] label: String,
+ #[serde(default)] secondary: bool,
+ },
+ #[serde(rename = "subtitle")]
+ Subtitle {
+ pid: u16,
+ codec: String,
+ #[serde(default)] language: String,
+ #[serde(default)] forced: bool,
+ },
+}
+
+impl M2tsMeta {
+ /// Build metadata from a disc Title.
+ pub fn from_title(title: &DiscTitle) -> Self {
+ let streams = title.streams.iter().map(|s| match s {
+ Stream::Video(v) => MetaStream::Video {
+ pid: v.pid,
+ codec: codec_to_str(v.codec),
+ resolution: v.resolution.clone(),
+ frame_rate: v.frame_rate.clone(),
+ hdr: hdr_to_str(v.hdr),
+ label: v.label.clone(),
+ secondary: v.secondary,
+ },
+ Stream::Audio(a) => MetaStream::Audio {
+ pid: a.pid,
+ codec: codec_to_str(a.codec),
+ channels: a.channels.clone(),
+ language: a.language.clone(),
+ sample_rate: a.sample_rate.clone(),
+ label: a.label.clone(),
+ secondary: a.secondary,
+ },
+ Stream::Subtitle(s) => MetaStream::Subtitle {
+ pid: s.pid,
+ codec: codec_to_str(s.codec),
+ language: s.language.clone(),
+ forced: s.forced,
+ },
+ }).collect();
+
+ Self {
+ v: 1,
+ title: title.playlist.clone(),
+ duration: title.duration_secs,
+ streams,
+ }
+ }
+
+ /// Convert back to a library Title (for remux).
+ pub fn to_title(&self) -> DiscTitle {
+ let streams = self.streams.iter().map(|s| match s {
+ MetaStream::Video { pid, codec, resolution, frame_rate, hdr, label, secondary } => {
+ Stream::Video(VideoStream {
+ pid: *pid,
+ codec: str_to_codec(codec),
+ resolution: resolution.clone(),
+ frame_rate: frame_rate.clone(),
+ hdr: str_to_hdr(hdr),
+ color_space: ColorSpace::Bt709,
+ secondary: *secondary,
+ label: label.clone(),
+ })
+ }
+ MetaStream::Audio { pid, codec, channels, language, sample_rate, label, secondary } => {
+ Stream::Audio(AudioStream {
+ pid: *pid,
+ codec: str_to_codec(codec),
+ channels: channels.clone(),
+ language: language.clone(),
+ sample_rate: sample_rate.clone(),
+ secondary: *secondary,
+ label: label.clone(),
+ })
+ }
+ MetaStream::Subtitle { pid, codec, language, forced } => {
+ Stream::Subtitle(SubtitleStream {
+ pid: *pid,
+ codec: str_to_codec(codec),
+ language: language.clone(),
+ forced: *forced,
+ })
+ }
+ }).collect();
+
+ DiscTitle {
+ playlist: self.title.clone(),
+ playlist_id: 0,
+ duration_secs: self.duration,
+ size_bytes: 0,
+ clips: Vec::new(),
+ streams,
+ extents: Vec::new(),
+ }
+ }
+}
+
+/// Write the metadata header to a writer. Padded to 192-byte boundary.
+pub fn write_header(w: &mut impl Write, meta: &M2tsMeta) -> io::Result<()> {
+ let json = serde_json::to_vec(meta)
+ .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
+
+ let json_len = json.len() as u32;
+ let raw_len = 8 + 4 + json.len(); // magic + len + json
+ let padded_len = ((raw_len + PACKET_SIZE - 1) / PACKET_SIZE) * PACKET_SIZE;
+ let padding = padded_len - raw_len;
+
+ w.write_all(&MAGIC)?;
+ w.write_all(&json_len.to_be_bytes())?;
+ w.write_all(&json)?;
+ if padding > 0 {
+ w.write_all(&vec![0u8; padding])?;
+ }
+ Ok(())
+}
+
+/// Try to read a metadata header from the start of an m2ts file.
+/// Returns None for bare m2ts files (no header).
+/// On success, leaves reader positioned at the first TS packet.
+/// On failure, seeks back to the start.
+pub fn read_header(r: &mut R) -> io::Result