v0.7.1: SectorReader trait, IsoStream, StdioStream, resolve_encryption
- SectorReader trait decouples disc scanning from SCSI - Disc::scan_image() for ISO and any sector source - resolve_encryption() handles AACS 1.0/2.0/none in one path - IsoStream: full UDF/MPLS/CLPI/labels pipeline from ISO files - StdioStream: stdin/stdout pipe - Strict scheme:// URL format with validation - Labels module refactored to SectorReader - 7 stream types total
This commit is contained in:
@@ -1,5 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.1 (2026-04-11)
|
||||
|
||||
### SectorReader trait
|
||||
|
||||
- **`SectorReader` trait** — decouples disc scanning from SCSI. UDF, MPLS, CLPI, labels, and AACS resolution now work with any sector source.
|
||||
- **`Disc::scan_image()`** — scan ISO images or any SectorReader. Full title/stream/label/AACS pipeline, no drive required.
|
||||
- **`resolve_encryption()`** — single function handles AACS 1.0, 2.0, or none. Uses whatever path works (KEYDB VUK, handshake, media key, device key).
|
||||
|
||||
### Stream types
|
||||
|
||||
- **7 stream types** — Disc, ISO, MKV, M2TS, Network, Stdio, Null
|
||||
- **`IsoStream`** — read/write Blu-ray ISO images. Uses `Disc::scan_image()` for full UDF parsing (not heuristic scanning).
|
||||
- **`StdioStream`** — stdin/stdout pipe, format-agnostic
|
||||
- **Strict URL format** — all URLs require `scheme://path`. Bare paths rejected with clear error messages.
|
||||
- **Validation** — empty paths, missing ports, read-only/write-only direction errors
|
||||
|
||||
### IOStream trait
|
||||
|
||||
- `IOStream` trait for all stream types (Read + Write + info + finish)
|
||||
- `open_input()` / `open_output()` resolve URL strings to stream instances
|
||||
|
||||
## 0.7.0 (2026-04-11)
|
||||
|
||||
### Stream I/O architecture
|
||||
|
||||
- **5 stream types** — Disc, MKV, M2TS, Network, Null
|
||||
- **`IOStream` trait** — common interface for all streams
|
||||
- **URL resolver** — `open_input()` / `open_output()` with scheme://path format
|
||||
- **FMKV metadata header** — JSON metadata embedded in M2TS and network streams
|
||||
- **Bidirectional MKV** — MkvStream reads and writes Matroska containers
|
||||
- **Network streaming** — TCP with metadata header, TCP_NODELAY
|
||||
- **BD-TS demuxer** — PAT/PMT scanning, PTS duration detection
|
||||
- **EBML reader** — parse existing MKV files for read-side MkvStream
|
||||
|
||||
## 0.6.0 (2026-04-10)
|
||||
|
||||
### API improvements
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
|
||||
+97
-59
@@ -10,6 +10,7 @@
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::drive::DriveSession;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::speed::DriveSpeed;
|
||||
use crate::udf;
|
||||
use crate::mpls;
|
||||
@@ -312,7 +313,16 @@ impl DiscTitle {
|
||||
}
|
||||
|
||||
|
||||
// ─── AACS state ─────────────────────────────────────────────────────────────
|
||||
// ─── Encryption ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Result of SCSI AACS handshake (ECDH authentication).
|
||||
/// Only available when scanning from a real drive, not ISO images.
|
||||
#[derive(Debug)]
|
||||
struct HandshakeResult {
|
||||
volume_id: [u8; 16],
|
||||
read_data_key: Option<[u8; 16]>,
|
||||
error: Option<crate::error::Error>,
|
||||
}
|
||||
|
||||
/// AACS decryption state for a disc.
|
||||
#[derive(Debug)]
|
||||
@@ -495,19 +505,34 @@ impl Disc {
|
||||
/// The session must be open and unlocked (DriveSession::open handles this).
|
||||
/// All disc reads use standard READ(10) via UDF -- no vendor SCSI commands.
|
||||
pub fn scan(session: &mut DriveSession, opts: &ScanOptions) -> Result<Self> {
|
||||
// 1. Capacity
|
||||
let capacity = Self::read_capacity(session)?;
|
||||
let handshake = Self::do_handshake(session, opts);
|
||||
Self::scan_with(session, capacity, handshake, opts)
|
||||
}
|
||||
|
||||
// 2. UDF filesystem
|
||||
let udf_fs = udf::read_filesystem(session)?;
|
||||
/// Scan a disc image (ISO or any SectorReader). No SCSI, no handshake.
|
||||
/// AACS resolution uses KEYDB VUK lookup only.
|
||||
pub fn scan_image(reader: &mut dyn SectorReader, capacity: u32, opts: &ScanOptions) -> Result<Self> {
|
||||
Self::scan_with(reader, capacity, None, opts)
|
||||
}
|
||||
|
||||
// 3. AACS -- read files from disc via UDF, resolve keys via KEYDB
|
||||
/// Core scan pipeline — works with any SectorReader.
|
||||
fn scan_with(
|
||||
reader: &mut dyn SectorReader,
|
||||
capacity: u32,
|
||||
handshake: Option<HandshakeResult>,
|
||||
opts: &ScanOptions,
|
||||
) -> Result<Self> {
|
||||
// 1. UDF filesystem
|
||||
let udf_fs = udf::read_filesystem(reader)?;
|
||||
|
||||
// 2. Resolve encryption (AACS, CSS, or none)
|
||||
let encrypted = udf_fs.find_dir("/AACS").is_some()
|
||||
|| udf_fs.find_dir("/BDMV/AACS").is_some();
|
||||
|
||||
let aacs = if encrypted {
|
||||
if let Some(keydb_path) = opts.resolve_keydb() {
|
||||
Self::resolve_aacs(&udf_fs, session, &keydb_path).ok()
|
||||
Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref()).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -515,14 +540,14 @@ impl Disc {
|
||||
None
|
||||
};
|
||||
|
||||
// 4. Playlists
|
||||
// 3. Playlists
|
||||
let mut titles = Vec::new();
|
||||
if let Some(playlist_dir) = udf_fs.find_dir("/BDMV/PLAYLIST") {
|
||||
for entry in &playlist_dir.entries {
|
||||
if !entry.is_dir && entry.name.to_lowercase().ends_with(".mpls") {
|
||||
let path = format!("/BDMV/PLAYLIST/{}", entry.name);
|
||||
if let Ok(mpls_data) = udf_fs.read_file(session, &path) {
|
||||
if let Some(title) = Self::parse_playlist(session, &udf_fs, &entry.name, &mpls_data) {
|
||||
if let Ok(mpls_data) = udf_fs.read_file(reader, &path) {
|
||||
if let Some(title) = Self::parse_playlist(reader, &udf_fs, &entry.name, &mpls_data) {
|
||||
titles.push(title);
|
||||
}
|
||||
}
|
||||
@@ -531,11 +556,11 @@ impl Disc {
|
||||
}
|
||||
titles.sort_by(|a, b| b.duration_secs.partial_cmp(&a.duration_secs).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
// 5. Metadata + labels
|
||||
let meta_title = Self::read_meta_title(session, &udf_fs);
|
||||
crate::labels::apply(session, &udf_fs, &mut titles);
|
||||
// 4. Metadata + labels
|
||||
let meta_title = Self::read_meta_title(reader, &udf_fs);
|
||||
crate::labels::apply(reader, &udf_fs, &mut titles);
|
||||
|
||||
// 6. Derive format, layers, region
|
||||
// 5. Derive format, layers, region
|
||||
let format = Self::detect_format(&titles);
|
||||
let layers = if capacity > 24_000_000 { 2 } else { 1 };
|
||||
let region = if format == DiscFormat::Uhd { DiscRegion::Free } else { DiscRegion::Free };
|
||||
@@ -554,61 +579,74 @@ impl Disc {
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve AACS keys from disc files + KEYDB. No SCSI commands.
|
||||
/// Reads Unit_Key_RO.inf, Content Certificate, and MKB from UDF.
|
||||
fn resolve_aacs(
|
||||
udf_fs: &udf::UdfFs,
|
||||
session: &mut DriveSession,
|
||||
keydb_path: &std::path::Path,
|
||||
) -> Result<AacsState> {
|
||||
/// SCSI handshake result — volume ID and bus keys from ECDH authentication.
|
||||
/// Only available when scanning from a real drive (not ISO images).
|
||||
fn do_handshake(session: &mut DriveSession, opts: &ScanOptions) -> Option<HandshakeResult> {
|
||||
use crate::aacs::{self, KeyDb};
|
||||
|
||||
let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad { path: keydb_path.display().to_string() })?;
|
||||
|
||||
// Read AACS files from disc via UDF (standard READ(10), no vendor commands)
|
||||
let uk_ro_data = udf_fs.read_file(session, "/AACS/Unit_Key_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(session, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
|
||||
let cc_data = udf_fs.read_file(session, "/AACS/Content000.cer")
|
||||
.or_else(|_| udf_fs.read_file(session, "/AACS/Content001.cer"))
|
||||
.ok();
|
||||
|
||||
let mkb_data = udf_fs.read_file(session, "/AACS/MKB_RW.inf")
|
||||
.or_else(|_| udf_fs.read_file(session, "/AACS/MKB_RO.inf"))
|
||||
.ok();
|
||||
let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version);
|
||||
|
||||
// AACS SCSI handshake — get Volume ID (and read data key for AACS 2.0)
|
||||
let mut volume_id = [0u8; 16];
|
||||
let mut read_data_key = None;
|
||||
let mut handshake_error = None;
|
||||
let keydb_path = opts.resolve_keydb()?;
|
||||
let keydb = KeyDb::load(&keydb_path).ok()?;
|
||||
|
||||
for hc in &keydb.host_certs {
|
||||
match aacs::handshake::aacs_authenticate(
|
||||
session, &hc.private_key, &hc.certificate,
|
||||
) {
|
||||
Ok(mut auth) => {
|
||||
// Read Volume ID (needed for MK → VUK derivation)
|
||||
if let Ok(vid) = aacs::handshake::read_volume_id(session, &mut auth) {
|
||||
volume_id = vid;
|
||||
}
|
||||
|
||||
// Read data keys for bus decryption (AACS 2.0 / UHD)
|
||||
if let Ok((rdk, _wdk)) = aacs::handshake::read_data_keys(session, &mut auth) {
|
||||
read_data_key = Some(rdk);
|
||||
}
|
||||
|
||||
handshake_error = None;
|
||||
break;
|
||||
let volume_id = aacs::handshake::read_volume_id(session, &mut auth)
|
||||
.unwrap_or([0u8; 16]);
|
||||
let read_data_key = aacs::handshake::read_data_keys(session, &mut auth)
|
||||
.ok().map(|(rdk, _)| rdk);
|
||||
return Some(HandshakeResult { volume_id, read_data_key, error: None });
|
||||
}
|
||||
Err(e) => {
|
||||
handshake_error = Some(e);
|
||||
// Try next host cert
|
||||
return Some(HandshakeResult {
|
||||
volume_id: [0u8; 16],
|
||||
read_data_key: None,
|
||||
error: Some(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// Resolve: disc hash → KEYDB lookup → VUK → unit keys
|
||||
/// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none.
|
||||
///
|
||||
/// Reads AACS files from UDF (via SectorReader), resolves keys through
|
||||
/// whatever path works: KEYDB VUK lookup, media key derivation, processing
|
||||
/// keys, device keys. Uses handshake result (volume ID, bus key) if available.
|
||||
fn resolve_encryption(
|
||||
udf_fs: &udf::UdfFs,
|
||||
reader: &mut dyn SectorReader,
|
||||
keydb_path: &std::path::Path,
|
||||
handshake: Option<&HandshakeResult>,
|
||||
) -> Result<AacsState> {
|
||||
use crate::aacs::{self, KeyDb};
|
||||
|
||||
let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad { path: keydb_path.display().to_string() })?;
|
||||
|
||||
// Read AACS files from disc/image via UDF
|
||||
let uk_ro_data = udf_fs.read_file(reader, "/AACS/Unit_Key_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
|
||||
let cc_data = udf_fs.read_file(reader, "/AACS/Content000.cer")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
|
||||
.ok();
|
||||
|
||||
let mkb_data = udf_fs.read_file(reader, "/AACS/MKB_RW.inf")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RO.inf"))
|
||||
.ok();
|
||||
let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version);
|
||||
|
||||
// Use handshake volume ID if available, otherwise zeros
|
||||
// (KEYDB VUK lookup by disc hash works without volume ID)
|
||||
let volume_id = handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]);
|
||||
let read_data_key = handshake.and_then(|h| h.read_data_key);
|
||||
let handshake_error = None;
|
||||
|
||||
// Resolve: tries all available paths — KEYDB VUK, media key, processing key, device key
|
||||
let resolved = aacs::resolve_keys(
|
||||
&uk_ro_data,
|
||||
cc_data.as_deref(),
|
||||
@@ -662,7 +700,7 @@ impl Disc {
|
||||
/// Read disc title from META/DL/bdmt_eng.xml (Blu-ray Disc Meta Table).
|
||||
/// Prefers English, falls back to first available language.
|
||||
/// Returns None if META directory is empty or XML has no usable title.
|
||||
fn read_meta_title(session: &mut DriveSession, udf_fs: &udf::UdfFs) -> Option<String> {
|
||||
fn read_meta_title(reader: &mut dyn SectorReader, udf_fs: &udf::UdfFs) -> Option<String> {
|
||||
let meta_dir = udf_fs.find_dir("/BDMV/META")?;
|
||||
for sub in &meta_dir.entries {
|
||||
if !sub.is_dir { continue; }
|
||||
@@ -677,7 +715,7 @@ impl Disc {
|
||||
|
||||
if let Some(entry) = target {
|
||||
let path = format!("{}/{}", dl_path, entry.name);
|
||||
if let Ok(data) = udf_fs.read_file(session, &path) {
|
||||
if let Ok(data) = udf_fs.read_file(reader, &path) {
|
||||
let xml = String::from_utf8_lossy(&data);
|
||||
if let Some(start) = xml.find("<di:name>") {
|
||||
let s = start + "<di:name>".len();
|
||||
@@ -704,7 +742,7 @@ impl Disc {
|
||||
}
|
||||
|
||||
fn parse_playlist(
|
||||
session: &mut DriveSession,
|
||||
reader: &mut dyn SectorReader,
|
||||
udf_fs: &udf::UdfFs,
|
||||
filename: &str,
|
||||
data: &[u8],
|
||||
@@ -732,7 +770,7 @@ impl Disc {
|
||||
let mut pkt_count: u32 = 0;
|
||||
|
||||
let clpi_path = format!("/BDMV/CLIPINF/{}.clpi", play_item.clip_id);
|
||||
if let Ok(clpi_data) = udf_fs.read_file(session, &clpi_path) {
|
||||
if let Ok(clpi_data) = udf_fs.read_file(reader, &clpi_path) {
|
||||
if let Ok(clip_info) = clpi::parse(&clpi_data) {
|
||||
pkt_count = clip_info.source_packet_count;
|
||||
total_size += pkt_count as u64 * 192;
|
||||
@@ -740,7 +778,7 @@ impl Disc {
|
||||
// Get m2ts file start LBA and compute extent from packet count.
|
||||
// BD-ROM m2ts files are contiguous on disc (mastering requirement).
|
||||
let m2ts_path = format!("/BDMV/STREAM/{}.m2ts", play_item.clip_id);
|
||||
let file_lba = udf_fs.file_start_lba(session, &m2ts_path).unwrap_or(0);
|
||||
let file_lba = udf_fs.file_start_lba(reader, &m2ts_path).unwrap_or(0);
|
||||
let total_bytes = pkt_count as u64 * 192;
|
||||
let total_sectors = ((total_bytes + 2047) / 2048) as u32;
|
||||
if total_sectors > 0 && file_lba > 0 {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
use std::path::Path;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::scsi::ScsiTransport;
|
||||
use crate::identity::DriveId;
|
||||
use crate::profile::{self, DriveProfile};
|
||||
@@ -160,6 +161,12 @@ impl DriveSession {
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for DriveSession {
|
||||
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
self.read_disc(lba, count, buf)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
let mut drives = Vec::new();
|
||||
for i in 0..16 {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Clean structured XML with Content/Qualifier per stream and
|
||||
//! stream number mapping via playbackconfig.
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
|
||||
use std::collections::HashMap;
|
||||
@@ -12,8 +12,8 @@ pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "streamproperties.xml")
|
||||
}
|
||||
|
||||
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let sp_data = super::read_jar_file(session, udf, "streamproperties.xml")?;
|
||||
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let sp_data = super::read_jar_file(reader, udf, "streamproperties.xml")?;
|
||||
let sp_text = std::str::from_utf8(&sp_data).ok()?;
|
||||
|
||||
let stream_infos = parse_stream_infos(sp_text);
|
||||
@@ -21,7 +21,7 @@ pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>
|
||||
|
||||
// Stream number mapping from playbackconfig.xml
|
||||
let mut stream_map: HashMap<String, u16> = HashMap::new();
|
||||
if let Some(pc_data) = super::read_jar_file(session, udf, "playbackconfig.xml") {
|
||||
if let Some(pc_data) = super::read_jar_file(reader, udf, "playbackconfig.xml") {
|
||||
if let Ok(pc_text) = std::str::from_utf8(&pc_data) {
|
||||
parse_playback_config(pc_text, &mut stream_map);
|
||||
}
|
||||
|
||||
+8
-8
@@ -4,7 +4,7 @@
|
||||
//! When both exist, language_streams.txt provides structured types while
|
||||
//! menu_base.prop provides stream number → button name mapping.
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab};
|
||||
use std::collections::HashMap;
|
||||
@@ -14,12 +14,12 @@ pub fn detect(udf: &UdfFs) -> bool {
|
||||
|| super::jar_file_exists(udf, "language_streams.txt")
|
||||
}
|
||||
|
||||
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
// Try language_streams.txt first (richer structured data)
|
||||
let ls_labels = parse_language_streams(session, udf);
|
||||
let ls_labels = parse_language_streams(reader, udf);
|
||||
|
||||
// Try menu_base.prop (stream numbers + key names)
|
||||
let mb_labels = parse_menu_base(session, udf);
|
||||
let mb_labels = parse_menu_base(reader, udf);
|
||||
|
||||
// If we have both, merge: language_streams for structure, menu_base for names
|
||||
match (ls_labels, mb_labels) {
|
||||
@@ -48,8 +48,8 @@ fn merge(ls: Vec<StreamLabel>, mb: Vec<StreamLabel>) -> Vec<StreamLabel> {
|
||||
|
||||
// ── language_streams.txt parser ────────────────────────────────────────────
|
||||
|
||||
fn parse_language_streams(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(session, udf, "language_streams.txt")?;
|
||||
fn parse_language_streams(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(reader, udf, "language_streams.txt")?;
|
||||
let text = std::str::from_utf8(&data).ok()?;
|
||||
|
||||
let mut labels = Vec::new();
|
||||
@@ -123,8 +123,8 @@ fn parse_language_streams(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec
|
||||
|
||||
// ── menu_base.prop parser ──────────────────────────────────────────────────
|
||||
|
||||
fn parse_menu_base(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(session, udf, "menu_base.prop")?;
|
||||
fn parse_menu_base(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(reader, udf, "menu_base.prop")?;
|
||||
let text = std::str::from_utf8(&data).ok()?;
|
||||
|
||||
// Parse key=value, group by prefix
|
||||
|
||||
+9
-9
@@ -4,7 +4,7 @@
|
||||
//! To add a new format:
|
||||
//! 1. Create `src/labels/myformat.rs`
|
||||
//! 2. Implement `pub fn detect(udf: &UdfFs) -> bool`
|
||||
//! 3. Implement `pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>>`
|
||||
//! 3. Implement `pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>>`
|
||||
//! 4. Add `mod myformat;` below and one line to `PARSERS` array
|
||||
|
||||
mod paramount;
|
||||
@@ -13,7 +13,7 @@ mod pixelogic;
|
||||
mod ctrm;
|
||||
pub mod vocab;
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
use crate::disc::{DiscTitle, Stream};
|
||||
|
||||
@@ -67,7 +67,7 @@ pub enum LabelQualifier {
|
||||
// Order = priority. First match wins. Highest quality output first.
|
||||
|
||||
type DetectFn = fn(&UdfFs) -> bool;
|
||||
type ParseFn = fn(&mut DriveSession, &UdfFs) -> Option<Vec<StreamLabel>>;
|
||||
type ParseFn = fn(&mut dyn SectorReader, &UdfFs) -> Option<Vec<StreamLabel>>;
|
||||
|
||||
const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
|
||||
("paramount", paramount::detect, paramount::parse),
|
||||
@@ -79,9 +79,9 @@ 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 [DiscTitle]) {
|
||||
pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle]) {
|
||||
let labels = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
extract(session, udf)
|
||||
extract(reader, udf)
|
||||
})).unwrap_or_default();
|
||||
if labels.is_empty() { return; }
|
||||
|
||||
@@ -133,10 +133,10 @@ pub fn apply(session: &mut DriveSession, udf: &UdfFs, titles: &mut [DiscTitle])
|
||||
}
|
||||
}
|
||||
|
||||
fn extract(session: &mut DriveSession, udf: &UdfFs) -> Vec<StreamLabel> {
|
||||
fn extract(reader: &mut dyn SectorReader, udf: &UdfFs) -> Vec<StreamLabel> {
|
||||
for (_name, detect, parse) in PARSERS {
|
||||
if detect(udf) {
|
||||
if let Some(labels) = parse(session, udf) {
|
||||
if let Some(labels) = parse(reader, udf) {
|
||||
return labels;
|
||||
}
|
||||
}
|
||||
@@ -169,7 +169,7 @@ pub(crate) fn find_jar_file(udf: &UdfFs, filename: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
/// Read a file from any BDMV/JAR subdirectory by filename.
|
||||
pub(crate) fn read_jar_file(session: &mut DriveSession, udf: &UdfFs, filename: &str) -> Option<Vec<u8>> {
|
||||
pub(crate) fn read_jar_file(reader: &mut dyn SectorReader, udf: &UdfFs, filename: &str) -> Option<Vec<u8>> {
|
||||
let path = find_jar_file(udf, filename)?;
|
||||
udf.read_file(session, &path).ok().filter(|d| !d.is_empty())
|
||||
udf.read_file(reader, &path).ok().filter(|d| !d.is_empty())
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! sub_com1_idx="23,24,25" />
|
||||
//! ```
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
|
||||
|
||||
@@ -20,8 +20,8 @@ pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "playlists.xml")
|
||||
}
|
||||
|
||||
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(session, udf, "playlists.xml")?;
|
||||
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(reader, udf, "playlists.xml")?;
|
||||
let text = std::str::from_utf8(&data).ok()?;
|
||||
|
||||
// Find the feature playlist — longest duration or name="Feature"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! Token format: `{lang}_{codec?}_{purpose?}_{region?}_`
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab};
|
||||
|
||||
@@ -18,8 +18,8 @@ pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "bluray_project.bin")
|
||||
}
|
||||
|
||||
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(session, udf, "bluray_project.bin")?;
|
||||
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(reader, udf, "bluray_project.bin")?;
|
||||
let strings = extract_strings(&data);
|
||||
|
||||
let mut labels = Vec::new();
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
//! | E7xxx | AACS errors |
|
||||
|
||||
pub mod error;
|
||||
pub mod sector;
|
||||
pub mod scsi;
|
||||
pub mod profile;
|
||||
pub mod platform;
|
||||
@@ -90,6 +91,7 @@ pub use drive::{DriveSession, find_drive, find_drives, resolve_device};
|
||||
pub use identity::DriveId;
|
||||
pub use profile::DriveProfile;
|
||||
// Platform trait is pub(crate) -- callers use DriveSession, not Platform directly
|
||||
pub use sector::SectorReader;
|
||||
pub use scsi::ScsiTransport;
|
||||
pub use speed::DriveSpeed;
|
||||
pub use disc::{Disc, DiscFormat, DiscTitle, Clip, Stream, VideoStream, AudioStream, SubtitleStream,
|
||||
|
||||
+124
-141
@@ -1,54 +1,117 @@
|
||||
//! IsoStream — read BD-TS data from a Blu-ray ISO image file.
|
||||
//! IsoStream — read/write Blu-ray ISO disc images.
|
||||
//!
|
||||
//! Read-only. Parses the UDF filesystem inside the ISO to find
|
||||
//! BDMV/STREAM/*.m2ts files, then streams the BD-TS bytes.
|
||||
//! Read: parses UDF filesystem inside the ISO using the same pipeline as
|
||||
//! DiscStream (titles, streams, labels, AACS). An ISO is a flat image of
|
||||
//! 2048-byte sectors — sector N starts at byte offset N * 2048.
|
||||
//!
|
||||
//! An ISO file is a flat image of 2048-byte sectors — the same
|
||||
//! layout as on a real disc. Sector N starts at byte offset N * 2048.
|
||||
//! Write: creates a sector-by-sector disc image from a SectorReader source.
|
||||
|
||||
use std::io::{self, Read, Write, Seek, SeekFrom};
|
||||
use std::fs::File;
|
||||
use std::path::Path;
|
||||
use super::IOStream;
|
||||
use crate::disc::DiscTitle;
|
||||
use crate::disc::{Disc, DiscTitle, ScanOptions};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
const SECTOR_SIZE: u64 = 2048;
|
||||
|
||||
/// Blu-ray ISO image stream. Read-only.
|
||||
/// File-backed sector reader for ISO images.
|
||||
pub struct IsoSectorReader {
|
||||
file: File,
|
||||
capacity: u32,
|
||||
}
|
||||
|
||||
impl IsoSectorReader {
|
||||
pub fn open(path: &str) -> io::Result<Self> {
|
||||
let file = File::open(Path::new(path))
|
||||
.map_err(|e| io::Error::new(e.kind(), format!("iso://{}: {}", path, e)))?;
|
||||
let size = file.metadata()?.len();
|
||||
let capacity = (size / SECTOR_SIZE) as u32;
|
||||
Ok(Self { file, capacity })
|
||||
}
|
||||
|
||||
pub fn capacity(&self) -> u32 { self.capacity }
|
||||
}
|
||||
|
||||
impl SectorReader for IsoSectorReader {
|
||||
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
let bytes = count as usize * SECTOR_SIZE as usize;
|
||||
self.file.seek(SeekFrom::Start(lba as u64 * SECTOR_SIZE))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.file.read_exact(&mut buf[..bytes])
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Blu-ray ISO image stream.
|
||||
///
|
||||
/// Opens an ISO file, parses UDF to locate BDMV playlists and streams,
|
||||
/// then reads the m2ts content sectors in order.
|
||||
/// Read: opens ISO, parses UDF (same as DiscStream), streams BD-TS content.
|
||||
/// Write: receives sector data and writes to ISO file.
|
||||
pub struct IsoStream {
|
||||
disc_title: DiscTitle,
|
||||
file: File,
|
||||
disc: Option<Disc>,
|
||||
reader: Option<IsoSectorReader>,
|
||||
writer: Option<io::BufWriter<File>>,
|
||||
/// Sector ranges to read: (start_lba, sector_count)
|
||||
extents: Vec<(u64, u64)>,
|
||||
/// Current extent index
|
||||
extents: Vec<(u32, u32)>,
|
||||
extent_idx: usize,
|
||||
/// Sectors remaining in current extent
|
||||
sectors_remaining: u64,
|
||||
/// Read buffer for one sector
|
||||
sectors_remaining: u32,
|
||||
sector_buf: [u8; SECTOR_SIZE as usize],
|
||||
/// Position within current sector buffer
|
||||
buf_pos: usize,
|
||||
/// Bytes valid in sector buffer
|
||||
buf_len: usize,
|
||||
eof: bool,
|
||||
}
|
||||
|
||||
impl IsoStream {
|
||||
/// Open an ISO file and scan its contents.
|
||||
///
|
||||
/// Parses UDF filesystem, finds playlists and stream extents.
|
||||
/// The title_index selects which title to read (0-based, default: longest).
|
||||
pub fn open(path: &str, title_index: Option<usize>) -> io::Result<Self> {
|
||||
let file = File::open(Path::new(path))
|
||||
.map_err(|e| io::Error::new(e.kind(),
|
||||
format!("iso://{}: {}", path, e)))?;
|
||||
/// Open an ISO file for reading. Parses UDF, scans titles, streams, labels.
|
||||
pub fn open(path: &str, title_index: Option<usize>, opts: &ScanOptions) -> io::Result<Self> {
|
||||
let mut reader = IsoSectorReader::open(path)?;
|
||||
let capacity = reader.capacity();
|
||||
|
||||
let mut stream = IsoStream {
|
||||
let disc = Disc::scan_image(&mut reader, capacity, opts)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
|
||||
|
||||
let idx = title_index.unwrap_or(0).min(disc.titles.len().saturating_sub(1));
|
||||
let disc_title = if disc.titles.is_empty() {
|
||||
return Err(io::Error::new(io::ErrorKind::NotFound, "no titles found in ISO image"));
|
||||
} else {
|
||||
disc.titles[idx].clone()
|
||||
};
|
||||
|
||||
let extents: Vec<(u32, u32)> = disc_title.extents.iter()
|
||||
.map(|e| (e.start_lba, e.sector_count))
|
||||
.collect();
|
||||
|
||||
let sectors_remaining = extents.first().map(|e| e.1).unwrap_or(0);
|
||||
|
||||
Ok(IsoStream {
|
||||
disc_title,
|
||||
disc: Some(disc),
|
||||
reader: Some(reader),
|
||||
writer: None,
|
||||
extents,
|
||||
extent_idx: 0,
|
||||
sectors_remaining,
|
||||
sector_buf: [0u8; SECTOR_SIZE as usize],
|
||||
buf_pos: 0,
|
||||
buf_len: 0,
|
||||
eof: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create an ISO file for writing. Receives raw sector data.
|
||||
pub fn create(path: &str) -> io::Result<Self> {
|
||||
let file = File::create(Path::new(path))
|
||||
.map_err(|e| io::Error::new(e.kind(), format!("iso://{}: {}", path, e)))?;
|
||||
let writer = io::BufWriter::with_capacity(4 * 1024 * 1024, file);
|
||||
|
||||
Ok(IsoStream {
|
||||
disc_title: DiscTitle::empty(),
|
||||
file,
|
||||
disc: None,
|
||||
reader: None,
|
||||
writer: Some(writer),
|
||||
extents: Vec::new(),
|
||||
extent_idx: 0,
|
||||
sectors_remaining: 0,
|
||||
@@ -56,128 +119,35 @@ impl IsoStream {
|
||||
buf_pos: 0,
|
||||
buf_len: 0,
|
||||
eof: false,
|
||||
};
|
||||
|
||||
stream.scan_iso(title_index)?;
|
||||
Ok(stream)
|
||||
})
|
||||
}
|
||||
|
||||
/// Scan the ISO: parse UDF, build title metadata, extract extent map.
|
||||
fn scan_iso(&mut self, title_index: Option<usize>) -> io::Result<()> {
|
||||
// Read AVDP at sector 256 to verify this is a UDF disc image
|
||||
let avdp = self.read_sector(256)?;
|
||||
let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]);
|
||||
if tag_id != 2 {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData,
|
||||
"not a valid UDF image — no AVDP at sector 256"));
|
||||
/// Set metadata (for write mode).
|
||||
pub fn meta(mut self, dt: &DiscTitle) -> Self {
|
||||
self.disc_title = dt.clone();
|
||||
self
|
||||
}
|
||||
|
||||
// For now, scan BDMV/PLAYLIST and BDMV/STREAM directories
|
||||
// by searching for MPLS and M2TS markers in the UDF metadata.
|
||||
//
|
||||
// Full UDF parsing (AVDP → VDS → metadata → FSD → root → files)
|
||||
// will be refactored out of udf.rs to work with both DriveSession
|
||||
// and file-backed sector reads. For now, find the main m2ts file
|
||||
// by scanning for the stream file extents in the UDF file entries.
|
||||
|
||||
// Find all .m2ts file extents from UDF metadata
|
||||
let disc_size = self.file.seek(SeekFrom::End(0))?;
|
||||
let total_sectors = disc_size / SECTOR_SIZE;
|
||||
self.file.seek(SeekFrom::Start(0))?;
|
||||
|
||||
// Scan UDF partition for BDMV structure
|
||||
let titles = self.find_stream_extents(total_sectors)?;
|
||||
|
||||
if titles.is_empty() {
|
||||
return Err(io::Error::new(io::ErrorKind::NotFound,
|
||||
"no BD stream files found in ISO image"));
|
||||
}
|
||||
|
||||
// Select title
|
||||
let idx = title_index.unwrap_or(0).min(titles.len() - 1);
|
||||
let (title, extents) = &titles[idx];
|
||||
|
||||
self.disc_title = title.clone();
|
||||
self.extents = extents.clone();
|
||||
|
||||
if !self.extents.is_empty() {
|
||||
self.sectors_remaining = self.extents[0].1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a single sector from the ISO file.
|
||||
fn read_sector(&mut self, lba: u64) -> io::Result<Vec<u8>> {
|
||||
let mut buf = vec![0u8; SECTOR_SIZE as usize];
|
||||
self.file.seek(SeekFrom::Start(lba * SECTOR_SIZE))?;
|
||||
self.file.read_exact(&mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Scan the ISO for BD stream file extents.
|
||||
///
|
||||
/// Returns: Vec of (DiscTitle, Vec<(start_lba, sector_count)>)
|
||||
///
|
||||
/// This is a simplified scanner that finds m2ts content by looking
|
||||
/// for 192-byte BD-TS packet boundaries (0x47 sync byte at offset 4).
|
||||
/// Full UDF parsing will replace this once udf.rs is decoupled from DriveSession.
|
||||
fn find_stream_extents(&mut self, total_sectors: u64) -> io::Result<Vec<(DiscTitle, Vec<(u64, u64)>)>> {
|
||||
// Strategy: scan the UDF file entry area for allocation descriptors
|
||||
// pointing to large contiguous regions (m2ts files are large).
|
||||
//
|
||||
// For a BD-ROM ISO, the main m2ts typically starts after the BDMV
|
||||
// metadata (around sector 1000-5000) and runs contiguously to the end.
|
||||
//
|
||||
// Quick approach: find first sector with BD-TS sync (0x47 at byte 4)
|
||||
// and treat everything from there to the end as one extent.
|
||||
|
||||
let probe_start = 256u64; // skip lead-in
|
||||
let probe_end = total_sectors.min(10000); // probe first 20 MB
|
||||
|
||||
let mut stream_start: Option<u64> = None;
|
||||
|
||||
for lba in probe_start..probe_end {
|
||||
let sector = self.read_sector(lba)?;
|
||||
// BD-TS: 192-byte packets, sync byte 0x47 at offset 4 of each packet
|
||||
// A sector (2048 bytes) holds partial packets, but the sync pattern
|
||||
// should appear at regular intervals
|
||||
if sector.len() >= 196 && sector[4] == 0x47 {
|
||||
// Verify: check for another sync at offset 196 (4 + 192)
|
||||
if sector[196] == 0x47 {
|
||||
stream_start = Some(lba);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match stream_start {
|
||||
Some(start) => {
|
||||
let sector_count = total_sectors - start;
|
||||
let size_bytes = sector_count * SECTOR_SIZE;
|
||||
|
||||
let mut title = DiscTitle::empty();
|
||||
title.playlist = "Main Title".into();
|
||||
title.size_bytes = size_bytes;
|
||||
|
||||
Ok(vec![(title, vec![(start, sector_count)])])
|
||||
}
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
/// Get the full Disc (for listing all titles).
|
||||
pub fn disc(&self) -> Option<&Disc> { self.disc.as_ref() }
|
||||
|
||||
/// Read the next sector from the current extent.
|
||||
fn read_next_sector(&mut self) -> io::Result<bool> {
|
||||
let reader = match self.reader.as_mut() {
|
||||
Some(r) => r,
|
||||
None => return Ok(false),
|
||||
};
|
||||
|
||||
if self.extent_idx >= self.extents.len() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let (start_lba, _) = self.extents[self.extent_idx];
|
||||
let offset = self.extents[self.extent_idx].1 - self.sectors_remaining;
|
||||
let (start_lba, total) = self.extents[self.extent_idx];
|
||||
let offset = total - self.sectors_remaining;
|
||||
let lba = start_lba + offset;
|
||||
|
||||
self.file.seek(SeekFrom::Start(lba * SECTOR_SIZE))?;
|
||||
self.file.read_exact(&mut self.sector_buf)?;
|
||||
reader.read_sectors(lba, 1, &mut self.sector_buf)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
|
||||
self.buf_pos = 0;
|
||||
self.buf_len = SECTOR_SIZE as usize;
|
||||
|
||||
@@ -195,7 +165,12 @@ impl IsoStream {
|
||||
|
||||
impl IOStream for IsoStream {
|
||||
fn info(&self) -> &DiscTitle { &self.disc_title }
|
||||
fn finish(&mut self) -> io::Result<()> { Ok(()) }
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
if let Some(ref mut w) = self.writer {
|
||||
w.flush()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for IsoStream {
|
||||
@@ -224,9 +199,17 @@ impl Read for IsoStream {
|
||||
}
|
||||
|
||||
impl Write for IsoStream {
|
||||
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported,
|
||||
"iso:// is read-only"))
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
match self.writer.as_mut() {
|
||||
Some(w) => w.write(buf),
|
||||
None => Err(io::Error::new(io::ErrorKind::Unsupported,
|
||||
"iso:// opened for reading — cannot write")),
|
||||
}
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
match self.writer.as_mut() {
|
||||
Some(w) => w.flush(),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> { Ok(()) }
|
||||
}
|
||||
|
||||
+7
-3
@@ -141,7 +141,11 @@ pub fn open_input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn IOStream
|
||||
}
|
||||
"iso" => {
|
||||
validate_file_path(&parsed.path, "iso")?;
|
||||
Ok(Box::new(IsoStream::open(&parsed.path, opts.title_index)?))
|
||||
let scan_opts = match &opts.keydb_path {
|
||||
Some(p) => crate::disc::ScanOptions::with_keydb(p),
|
||||
None => crate::disc::ScanOptions::default(),
|
||||
};
|
||||
Ok(Box::new(IsoStream::open(&parsed.path, opts.title_index, &scan_opts)?))
|
||||
}
|
||||
"null" => {
|
||||
Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
@@ -166,8 +170,8 @@ pub fn open_output(url: &str, meta: &DiscTitle) -> io::Result<Box<dyn IOStream>>
|
||||
"disc:// is read-only — cannot use as output"))
|
||||
}
|
||||
"iso" => {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported,
|
||||
"iso:// is read-only — cannot use as output"))
|
||||
validate_file_path(&parsed.path, "iso")?;
|
||||
Ok(Box::new(IsoStream::create(&parsed.path)?.meta(meta)))
|
||||
}
|
||||
"null" => {
|
||||
Ok(Box::new(NullStream::new().meta(meta)))
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//! SectorReader — trait for reading 2048-byte disc sectors.
|
||||
//!
|
||||
//! Implemented by DriveSession (SCSI) and IsoFile (file-backed).
|
||||
//! Used by UDF parser, disc scanner, label parsers — anything that
|
||||
//! reads sectors doesn't need to know where they come from.
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
/// Read 2048-byte sectors from a disc or disc image.
|
||||
pub trait SectorReader {
|
||||
/// Read `count` sectors starting at `lba` into `buf`.
|
||||
/// `buf` must be at least `count * 2048` bytes.
|
||||
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize>;
|
||||
}
|
||||
+33
-33
@@ -19,7 +19,7 @@
|
||||
//! BD-ROM Part 3 — Blu-ray filesystem profile
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::drive::DriveSession;
|
||||
use crate::sector::SectorReader;
|
||||
|
||||
/// A UDF filesystem parsed from disc.
|
||||
#[derive(Debug)]
|
||||
@@ -74,7 +74,7 @@ impl UdfFs {
|
||||
/// Reads sector by sector from disc — no buffering.
|
||||
/// Get the absolute starting LBA of a file on disc.
|
||||
/// Used by the rip pipeline to locate m2ts content sectors.
|
||||
pub fn file_start_lba(&self, session: &mut DriveSession, path: &str) -> Result<u32> {
|
||||
pub fn file_start_lba(&self, reader: &mut dyn SectorReader, path: &str) -> Result<u32> {
|
||||
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
|
||||
let mut current = &self.root;
|
||||
for part in &parts[..parts.len() - 1] {
|
||||
@@ -91,11 +91,11 @@ impl UdfFs {
|
||||
!e.is_dir && e.name.eq_ignore_ascii_case(filename)
|
||||
}).ok_or_else(|| Error::UdfNotFound { path: path.to_string() }
|
||||
)?;
|
||||
let (data_lba, _) = self.read_icb_extent(session, entry.meta_lba)?;
|
||||
let (data_lba, _) = self.read_icb_extent(reader, entry.meta_lba)?;
|
||||
Ok(self.partition_start + data_lba)
|
||||
}
|
||||
|
||||
pub fn read_file(&self, session: &mut DriveSession, path: &str) -> Result<Vec<u8>> {
|
||||
pub fn read_file(&self, reader: &mut dyn SectorReader, path: &str) -> Result<Vec<u8>> {
|
||||
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
|
||||
let mut current = &self.root;
|
||||
|
||||
@@ -118,7 +118,7 @@ impl UdfFs {
|
||||
)?;
|
||||
|
||||
// Read the file's ICB to get its data extent
|
||||
let (data_lba, data_len) = self.read_icb_extent(session, entry.meta_lba)?;
|
||||
let (data_lba, data_len) = self.read_icb_extent(reader, entry.meta_lba)?;
|
||||
|
||||
// Read file data sector by sector
|
||||
// File DATA is in the physical partition (partition_start + lba),
|
||||
@@ -129,7 +129,7 @@ impl UdfFs {
|
||||
|
||||
for i in 0..sector_count {
|
||||
let offset = (i as usize) * 2048;
|
||||
read_sector(session, abs_start + i, &mut data[offset..offset + 2048])?;
|
||||
read_sector(reader, abs_start + i, &mut data[offset..offset + 2048])?;
|
||||
}
|
||||
|
||||
data.truncate(entry.size as usize);
|
||||
@@ -145,7 +145,7 @@ impl UdfFs {
|
||||
///
|
||||
/// Skips: STREAM/ (video), BACKUP/, DUPLICATE/,
|
||||
/// MKB_RO.inf, ContentHash*, ContentRevocation*
|
||||
pub fn metadata_sector_ranges(&self, session: &mut DriveSession) -> Result<Vec<(u32, u32)>> {
|
||||
pub fn metadata_sector_ranges(&self, reader: &mut dyn SectorReader) -> Result<Vec<(u32, u32)>> {
|
||||
let mut ranges = Vec::new();
|
||||
|
||||
// UDF structure: sector 0 through end of metadata partition
|
||||
@@ -154,7 +154,7 @@ impl UdfFs {
|
||||
ranges.push((0, meta_end));
|
||||
|
||||
// Walk tree, collect ranges for each metadata file
|
||||
self.collect_file_ranges(session, &self.root, &mut ranges)?;
|
||||
self.collect_file_ranges(reader, &self.root, &mut ranges)?;
|
||||
|
||||
// Merge overlapping/adjacent ranges and sort
|
||||
ranges.sort_by_key(|r| r.0);
|
||||
@@ -162,14 +162,14 @@ impl UdfFs {
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
fn collect_file_ranges(&self, session: &mut DriveSession, entry: &DirEntry, ranges: &mut Vec<(u32, u32)>) -> Result<()> {
|
||||
fn collect_file_ranges(&self, reader: &mut dyn SectorReader, entry: &DirEntry, ranges: &mut Vec<(u32, u32)>) -> Result<()> {
|
||||
for child in &entry.entries {
|
||||
if child.is_dir {
|
||||
// Only skip STREAM — those are the multi-GB video files
|
||||
if child.name.eq_ignore_ascii_case("STREAM") {
|
||||
continue;
|
||||
}
|
||||
self.collect_file_ranges(session, child, ranges)?;
|
||||
self.collect_file_ranges(reader, child, ranges)?;
|
||||
} else {
|
||||
// Include the ICB sector itself (in metadata partition)
|
||||
ranges.push((self.meta_to_abs(child.meta_lba), 1));
|
||||
@@ -179,7 +179,7 @@ impl UdfFs {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok((data_lba, data_len)) = self.read_icb_extent(session, child.meta_lba) {
|
||||
if let Ok((data_lba, data_len)) = self.read_icb_extent(reader, child.meta_lba) {
|
||||
let abs_start = self.partition_start + data_lba;
|
||||
let sector_count = (data_len + 2047) / 2048;
|
||||
ranges.push((abs_start, sector_count));
|
||||
@@ -197,17 +197,17 @@ impl UdfFs {
|
||||
/// Read an Extended File Entry (tag 266) or File Entry (tag 261)
|
||||
/// and return its first allocation extent: (data_lba, data_length).
|
||||
/// The data_lba is partition-relative.
|
||||
fn read_icb_extent(&self, session: &mut DriveSession, meta_lba: u32) -> Result<(u32, u32)> {
|
||||
let extents = self.read_icb_extents(session, meta_lba)?;
|
||||
fn read_icb_extent(&self, reader: &mut dyn SectorReader, meta_lba: u32) -> Result<(u32, u32)> {
|
||||
let extents = self.read_icb_extents(reader, meta_lba)?;
|
||||
extents.first().copied().ok_or_else(|| Error::DiscRead { sector: 0 })
|
||||
}
|
||||
|
||||
/// Read ALL allocation extents for a file from its ICB.
|
||||
/// Returns Vec of (partition_relative_lba, byte_length) pairs.
|
||||
/// Handles files with many extents (e.g. 88 GB m2ts files have ~90 extents).
|
||||
fn read_icb_extents(&self, session: &mut DriveSession, meta_lba: u32) -> Result<Vec<(u32, u32)>> {
|
||||
fn read_icb_extents(&self, reader: &mut dyn SectorReader, meta_lba: u32) -> Result<Vec<(u32, u32)>> {
|
||||
let mut icb = [0u8; 2048];
|
||||
read_sector(session, self.meta_to_abs(meta_lba), &mut icb)?;
|
||||
read_sector(reader, self.meta_to_abs(meta_lba), &mut icb)?;
|
||||
|
||||
let tag = u16::from_le_bytes([icb[0], icb[1]]);
|
||||
|
||||
@@ -255,7 +255,7 @@ impl UdfFs {
|
||||
|
||||
/// Get all absolute disc sector extents for a file.
|
||||
/// Returns Vec of (absolute_lba, sector_count) covering the entire file.
|
||||
pub fn file_extents(&self, session: &mut DriveSession, path: &str) -> Result<Vec<(u32, u32)>> {
|
||||
pub fn file_extents(&self, reader: &mut dyn SectorReader, path: &str) -> Result<Vec<(u32, u32)>> {
|
||||
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
|
||||
let mut current = &self.root;
|
||||
for part in &parts[..parts.len() - 1] {
|
||||
@@ -273,7 +273,7 @@ impl UdfFs {
|
||||
}).ok_or_else(|| Error::UdfNotFound { path: path.to_string() }
|
||||
)?;
|
||||
|
||||
let alloc_extents = self.read_icb_extents(session, entry.meta_lba)?;
|
||||
let alloc_extents = self.read_icb_extents(reader, entry.meta_lba)?;
|
||||
let mut disc_extents = Vec::new();
|
||||
for (lba, byte_len) in alloc_extents {
|
||||
let abs_lba = self.partition_start + lba;
|
||||
@@ -293,11 +293,11 @@ impl UdfFs {
|
||||
/// 3. Metadata partition file → metadata content location
|
||||
/// 4. FSD → root directory ICB
|
||||
/// 5. Root directory → file tree
|
||||
pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result<UdfFs> {
|
||||
// Step 1: Anchor Volume Descriptor Pointer at sector 256
|
||||
// ECMA-167 §10.2 — always at sector 256
|
||||
let mut avdp = [0u8; 2048];
|
||||
read_sector(session, 256, &mut avdp)?;
|
||||
read_sector(reader, 256, &mut avdp)?;
|
||||
|
||||
let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]);
|
||||
if tag_id != 2 {
|
||||
@@ -317,7 +317,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
|
||||
for i in 32..64 {
|
||||
let mut desc = [0u8; 2048];
|
||||
read_sector(session, i, &mut desc)?;
|
||||
read_sector(reader, i, &mut desc)?;
|
||||
|
||||
let desc_tag = u16::from_le_bytes([desc[0], desc[1]]);
|
||||
match desc_tag {
|
||||
@@ -352,7 +352,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
|
||||
// Read LVD to check partition map type
|
||||
let mut lvd = [0u8; 2048];
|
||||
read_sector(session, lvd_sec, &mut lvd)?;
|
||||
read_sector(reader, lvd_sec, &mut lvd)?;
|
||||
|
||||
// Parse partition maps starting at offset 440
|
||||
// Map 0 = Type 1 (physical), Map 1 = Type 2 (metadata)
|
||||
@@ -368,7 +368,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
// Read it to find where the metadata content starts
|
||||
let meta_file_lba = partition_start; // lba 0 of partition
|
||||
let mut meta_icb = [0u8; 2048];
|
||||
read_sector(session, meta_file_lba, &mut meta_icb)?;
|
||||
read_sector(reader, meta_file_lba, &mut meta_icb)?;
|
||||
|
||||
let meta_tag = u16::from_le_bytes([meta_icb[0], meta_icb[1]]);
|
||||
if meta_tag == 266 {
|
||||
@@ -401,7 +401,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
// Step 4: Read File Set Descriptor from metadata partition
|
||||
// FSD is at metadata-relative lba 0 (first sector of metadata content)
|
||||
let mut fsd = [0u8; 2048];
|
||||
read_sector(session, metadata_start, &mut fsd)?;
|
||||
read_sector(reader, metadata_start, &mut fsd)?;
|
||||
|
||||
let fsd_tag = u16::from_le_bytes([fsd[0], fsd[1]]);
|
||||
if fsd_tag != 256 {
|
||||
@@ -413,7 +413,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
let root_lba = u32::from_le_bytes([fsd[404], fsd[405], fsd[406], fsd[407]]);
|
||||
|
||||
// Step 5: Read root directory and build file tree
|
||||
let root = read_directory(session, partition_start, metadata_start, root_lba, "", 0)?;
|
||||
let root = read_directory(reader, partition_start, metadata_start, root_lba, "", 0)?;
|
||||
|
||||
let metadata_sectors = (metadata_size_bytes + 2047) / 2048;
|
||||
|
||||
@@ -432,7 +432,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
/// containing File Identifier Descriptors (FIDs). Each FID names a file/subdir
|
||||
/// and points to its ICB.
|
||||
fn read_directory(
|
||||
session: &mut DriveSession,
|
||||
reader: &mut dyn SectorReader,
|
||||
part_start: u32,
|
||||
meta_start: u32,
|
||||
meta_lba: u32,
|
||||
@@ -441,7 +441,7 @@ fn read_directory(
|
||||
) -> Result<DirEntry> {
|
||||
// Read ICB for this directory
|
||||
let mut icb = [0u8; 2048];
|
||||
read_sector(session, meta_start + meta_lba, &mut icb)?;
|
||||
read_sector(reader, meta_start + meta_lba, &mut icb)?;
|
||||
|
||||
let tag = u16::from_le_bytes([icb[0], icb[1]]);
|
||||
|
||||
@@ -477,7 +477,7 @@ fn read_directory(
|
||||
let sector_count = ((ad_len + 2047) / 2048).min(64);
|
||||
let mut dir_data = vec![0u8; sector_count as usize * 2048];
|
||||
for i in 0..sector_count {
|
||||
read_sector(session, dir_abs + i,
|
||||
read_sector(reader, dir_abs + i,
|
||||
&mut dir_data[(i as usize) * 2048..(i as usize + 1) * 2048])?;
|
||||
}
|
||||
|
||||
@@ -512,11 +512,11 @@ fn read_directory(
|
||||
|
||||
if !entry_name.is_empty() {
|
||||
// Read the ICB to get file size
|
||||
let file_size = read_file_size(session, meta_start, icb_lba).unwrap_or(0);
|
||||
let file_size = read_file_size(reader, meta_start, icb_lba).unwrap_or(0);
|
||||
|
||||
if is_dir && depth < 3 {
|
||||
// Recurse into subdirectory (max 3 levels: BDMV/PLAYLIST/*.mpls)
|
||||
let subdir = read_directory(session, part_start, meta_start, icb_lba, &entry_name, depth + 1)?;
|
||||
let subdir = read_directory(reader, part_start, meta_start, icb_lba, &entry_name, depth + 1)?;
|
||||
entries.push(subdir);
|
||||
} else {
|
||||
entries.push(DirEntry {
|
||||
@@ -545,9 +545,9 @@ fn read_directory(
|
||||
}
|
||||
|
||||
/// Read file size (info_length) from an Extended File Entry ICB.
|
||||
fn read_file_size(session: &mut DriveSession, meta_start: u32, meta_lba: u32) -> Result<u64> {
|
||||
fn read_file_size(reader: &mut dyn SectorReader, meta_start: u32, meta_lba: u32) -> Result<u64> {
|
||||
let mut icb = [0u8; 2048];
|
||||
read_sector(session, meta_start + meta_lba, &mut icb)?;
|
||||
read_sector(reader, meta_start + meta_lba, &mut icb)?;
|
||||
|
||||
let tag = u16::from_le_bytes([icb[0], icb[1]]);
|
||||
match tag {
|
||||
@@ -642,7 +642,7 @@ fn parse_dstring(data: &[u8]) -> String {
|
||||
|
||||
/// Read a single 2048-byte sector from the drive.
|
||||
/// Uses standard READ(10) — no unlock required.
|
||||
fn read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8]) -> Result<()> {
|
||||
session.read_disc(lba, 1, buf)?;
|
||||
fn read_sector(reader: &mut dyn SectorReader, lba: u32, buf: &mut [u8]) -> Result<()> {
|
||||
reader.read_sectors(lba, 1, buf)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user