Compare commits

...
8 Commits
Author SHA1 Message Date
MattJackson 077e4d018f Bump to 0.8.1, make profile module public, fix unused import 2026-04-13 00:17:57 +00:00
MattJackson f8b5a1eaf1 API: Drive object, typed StreamUrl, tray lock/unlock, Send traits
- Rename DriveSession → Drive across entire codebase
- find_drives() returns Vec<Drive>, find_drive() returns Option<Drive>
- resolve_device() now pub(crate) — internal only
- StreamUrl is now a typed enum (Disc, Mkv, M2ts, Iso, Network, Stdio, Null)
  with scheme() and path_str() accessors, replacing struct of Strings
- Add lock_tray() / unlock_tray() for safe disc access during rips
- Improve reset() with eject cycle that clears LibreDrive stuck state
- Add Send bounds to ScsiTransport and PlatformDriver traits
- DiscOptions uses PathBuf instead of String for device/keydb paths
- Update doc example to use new Drive API
2026-04-13 00:13:41 +00:00
MattJackson fb1c35e653 DriveStatus API + reset() + wait_ready with fallback
- DriveStatus enum: TrayOpen, NoDisc, DiscPresent, NotReady, Unknown
- drive_status(): GET EVENT STATUS NOTIFICATION with TUR fallback
- reset(): PREVENT ALLOW → START STOP → init() escalation
- wait_ready(): tries reset on Illegal Request, falls back to drive_status
- Remaining: standard READ(10) still fails in LibreDrive stuck state
2026-04-12 23:36:16 +00:00
MattJackson 9268ea2fdb WIP: LibreDrive stuck state detection in wait_ready + scan
- wait_ready: detects MMkv vendor probe when TUR returns Illegal Request
- scan: capacity fallback to 0 when READ CAPACITY fails
- Still needs: re-init to restore standard SCSI commands, or use raw reads for UDF
2026-04-11 22:09:26 +00:00
MattJackson 9078aa8718 Fix UHD remux: skip DV EL from video_pending, dynamic lookahead
- Secondary video streams (Dolby Vision EL) no longer block codec detection
- Lookahead buffer: 10 MB default, 100 MB for UHD (>15 streams)
- Verified: Dune UHD 84.6 GB remux completes at 101 MB/s
- Verified: V for Vendetta BD 21.3 GB remux completes at 131 MB/s
2026-04-11 21:43:51 +00:00
MattJackson c55e6991b8 Doc comments, format string inlining, long literal separators
- Doc comments on DriveSession, find_drives, all Error variants, Result type
- 24 format! strings inlined (clippy pedantic)
- 25 long hex literals with separators (0xFFFFFFFF → 0xFFFF_FFFF)
- README install example updated to 0.8
2026-04-11 21:04:44 +00:00
MattJackson ca931b6522 Doc comments on public API, README version fix, format string cleanup 2026-04-11 21:01:16 +00:00
MattJackson d4d98ce593 Granular SCSI query methods on DriveSession, capture uses them
- get_config_feature(code) → Option<Vec<u8>>
- report_key_rpc_state() → Option<Vec<u8>>
- mode_sense_page(page) → Option<Vec<u8>>
- read_buffer(mode, buf_id, length) → Option<Vec<u8>>

capture.rs now uses these methods — zero raw CDB construction.
CLI info.rs has zero SCSI references.
autorip ejects via library, not shell command.
2026-04-11 20:54:04 +00:00
30 changed files with 627 additions and 252 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.8.0"
version = "0.8.1"
edition = "2021"
license = "AGPL-3.0-only"
description = "Open source raw disc access library for optical drives"
+8 -8
View File
@@ -18,7 +18,7 @@
//! - AACS 2.0: drives accept AACS 1.0 host certs for backward compatibility
//! (full P-256/SHA-256 AACS 2.0 handshake prepared but rarely needed)
use crate::drive::DriveSession;
use crate::drive::Drive;
use crate::error::{Error, Result};
use crate::scsi::DataDirection;
use num_bigint::BigUint;
@@ -26,14 +26,14 @@ use num_traits::{One, Zero};
use sha1::{Digest, Sha1};
/// Execute a SCSI command that reads data from the device.
fn scsi_read(session: &mut DriveSession, cdb: &[u8], len: usize) -> Result<Vec<u8>> {
fn scsi_read(session: &mut Drive, cdb: &[u8], len: usize) -> Result<Vec<u8>> {
let mut buf = vec![0u8; len];
session.scsi_execute(cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
Ok(buf)
}
/// Execute a SCSI command that writes data to the device.
fn scsi_write(session: &mut DriveSession, cdb: &[u8], data: &[u8]) -> Result<()> {
fn scsi_write(session: &mut Drive, cdb: &[u8], data: &[u8]) -> Result<()> {
let mut buf = data.to_vec();
session.scsi_execute(cdb, DataDirection::ToDevice, &mut buf, 5_000)?;
Ok(())
@@ -765,7 +765,7 @@ pub struct AacsAuth {
/// Requires a host private key (20 bytes) and host certificate (92 bytes)
/// from the KEYDB.cfg HC entry.
pub fn aacs_authenticate(
session: &mut DriveSession,
session: &mut Drive,
host_priv_key: &[u8; 20],
host_cert: &[u8],
) -> Result<AacsAuth> {
@@ -890,7 +890,7 @@ pub fn aacs_authenticate(
/// Falls back to aacs_authenticate (AACS 1.0) if AACS 2.0 host credentials
/// are not available.
pub fn aacs2_authenticate(
session: &mut DriveSession,
session: &mut Drive,
host_priv_key_v1: &[u8; 20],
host_cert_v1: &[u8],
host_priv_key_v2: Option<&[u8; 32]>,
@@ -914,7 +914,7 @@ pub fn aacs2_authenticate(
/// Native AACS 2.0 handshake using P-256/SHA-256.
/// Same SCSI protocol, larger payloads (32-byte keys, 132-byte certs).
fn aacs2_authenticate_p256(
session: &mut DriveSession,
session: &mut Drive,
host_priv_key: &[u8; 32],
host_cert: &[u8],
) -> Result<AacsAuth> {
@@ -1029,7 +1029,7 @@ fn aacs2_authenticate_p256(
}
/// Read Volume ID after successful authentication.
pub fn read_volume_id(session: &mut DriveSession, auth: &mut AacsAuth) -> Result<[u8; 16]> {
pub fn read_volume_id(session: &mut Drive, auth: &mut AacsAuth) -> Result<[u8; 16]> {
// REPORT DISC STRUCTURE format 0x80
let cdb = cdb_report_disc_structure(auth.agid, 0x80, 36);
let response = scsi_read(session, &cdb, 36).map_err(|_| Error::AacsVidRead)?;
@@ -1051,7 +1051,7 @@ pub fn read_volume_id(session: &mut DriveSession, auth: &mut AacsAuth) -> Result
/// Read data keys after successful authentication (for AACS 2.0 bus encryption).
pub fn read_data_keys(
session: &mut DriveSession,
session: &mut Drive,
auth: &mut AacsAuth,
) -> Result<([u8; 16], [u8; 16])> {
// REPORT DISC STRUCTURE format 0x84
+2 -2
View File
@@ -167,7 +167,7 @@ impl KeyDb {
.to_string();
// Try with 0x prefix and without
self.disc_entries
.get(&format!("0x{}", hash))
.get(&format!("0x{hash}"))
.or_else(|| self.disc_entries.get(&hash))
.and_then(|e| e.vuk)
}
@@ -180,7 +180,7 @@ impl KeyDb {
.trim_start_matches("0x")
.to_string();
self.disc_entries
.get(&format!("0x{}", hash))
.get(&format!("0x{hash}"))
.or_else(|| self.disc_entries.get(&hash))
}
+5 -5
View File
@@ -55,7 +55,7 @@ pub fn disc_hash_hex(hash: &[u8; 20]) -> String {
let mut s = String::with_capacity(42);
s.push_str("0x");
for b in hash {
s.push_str(&format!("{:02X}", b));
s.push_str(&format!("{b:02X}"));
}
s
}
@@ -341,7 +341,7 @@ fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
/// Compute v_mask from a UV value.
fn calc_v_mask(uv: u32) -> u32 {
let mut v_mask: u32 = 0xFFFFFFFF;
let mut v_mask: u32 = 0xFFFF_FFFF;
while (uv & !v_mask) == 0 && v_mask != 0 {
v_mask <<= 1;
}
@@ -411,7 +411,7 @@ pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option
continue;
}
let u_mask: u32 = 0xFFFFFFFF << u_mask_shift;
let u_mask: u32 = 0xFFFF_FFFF << u_mask_shift;
let v_mask = calc_v_mask(uv);
if ((device_number & u_mask) == (uv & u_mask))
@@ -419,7 +419,7 @@ pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option
{
// Found matching subset-difference — find the right device key
let dev_key_v_mask = calc_v_mask(dk.uv);
let dev_key_u_mask: u32 = 0xFFFFFFFF << dk.u_mask_shift;
let dev_key_u_mask: u32 = 0xFFFF_FFFF << dk.u_mask_shift;
if u_mask == dev_key_u_mask && (uv & dev_key_v_mask) == (dk.uv & dev_key_v_mask) {
// Derive processing key via tree traversal
@@ -449,7 +449,7 @@ const MKB_PACK_SIZE: usize = 32772;
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
/// Returns the concatenated MKB data from all packs.
pub fn read_mkb_from_drive(
session: &mut crate::drive::DriveSession,
session: &mut crate::drive::Drive,
) -> crate::error::Result<Vec<u8>> {
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE};
+1 -1
View File
@@ -46,7 +46,7 @@ impl ClipInfo {
/// Reconstruct full SPN from coarse + fine entry.
pub fn full_spn(coarse: &EpCoarse, fine: &EpFine) -> u32 {
(coarse.spn_coarse & 0xFFFE0000) + fine.spn_fine
(coarse.spn_coarse & 0xFFFE_0000) + fine.spn_fine
}
/// Get all EP entries as (PTS, SPN) pairs, fully resolved.
+1 -1
View File
@@ -59,7 +59,7 @@ impl Disc {
2 => "stereo".to_string(),
6 => "5.1".to_string(),
8 => "7.1".to_string(),
n => format!("{}ch", n),
n => format!("{n}ch"),
};
let sample_rate = match a.sample_rate {
48000 => "48kHz".to_string(),
+1 -1
View File
@@ -17,7 +17,7 @@ impl Disc {
/// SCSI handshake result — volume ID and bus keys from ECDH authentication.
/// Only available when scanning from a real drive (not ISO images).
pub(super) fn do_handshake(
session: &mut crate::drive::DriveSession,
session: &mut crate::drive::Drive,
opts: &ScanOptions,
) -> Option<HandshakeResult> {
use crate::aacs::{self, KeyDb};
+14 -13
View File
@@ -12,7 +12,7 @@ mod bluray;
mod dvd;
mod encrypt;
use crate::drive::DriveSession;
use crate::drive::Drive;
use crate::error::{Error, Result};
use crate::sector::SectorReader;
use crate::speed::DriveSpeed;
@@ -333,7 +333,7 @@ impl DiscTitle {
pub fn duration_display(&self) -> String {
let hrs = (self.duration_secs / 3600.0) as u32;
let mins = ((self.duration_secs % 3600.0) / 60.0) as u32;
format!("{}h {:02}m", hrs, mins)
format!("{hrs}h {mins:02}m")
}
/// Size in GB
@@ -449,7 +449,7 @@ impl ScanOptions {
/// Created by `Disc::open()`. Provides `rip()` to read title data.
pub struct OpenDisc {
pub disc: Disc,
pub session: DriveSession,
pub session: Drive,
}
impl OpenDisc {
@@ -459,7 +459,7 @@ impl OpenDisc {
pub fn open(device: &str, keydb_path: Option<&str>) -> Result<Self> {
use std::path::Path;
let mut session = DriveSession::open(Path::new(device))?;
let mut session = Drive::open(Path::new(device))?;
session.wait_ready()?;
// Init (unlock + firmware) -- non-fatal if fails
@@ -530,10 +530,11 @@ impl Disc {
/// 4. Parse playlists + streams
/// 5. Apply labels
///
/// The session must be open and unlocked (DriveSession::open handles this).
/// The session must be open and unlocked (Drive::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> {
let capacity = Self::read_capacity(session)?;
pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result<Self> {
// READ CAPACITY may fail in LibreDrive mode — proceed with 0 and estimate later
let capacity = Self::read_capacity(session).unwrap_or(0);
let handshake = Self::do_handshake(session, opts);
Self::scan_with(session, capacity, handshake, opts)
}
@@ -647,7 +648,7 @@ impl Disc {
DiscFormat::Unknown
}
fn read_capacity(session: &mut DriveSession) -> Result<u32> {
fn read_capacity(session: &mut Drive) -> Result<u32> {
let cdb = [
crate::scsi::SCSI_READ_CAPACITY,
0x00,
@@ -683,7 +684,7 @@ impl Disc {
/// - On success streak: ramps batch back up, then restores disc speed
/// - At minimum batch + still failing: retries once, then skips + zero-fills
pub struct ContentReader<'a> {
session: &'a mut DriveSession,
session: &'a mut Drive,
aacs: Option<&'a AacsState>,
css: Option<&'a crate::css::CssState>,
extents: Vec<Extent>,
@@ -714,7 +715,7 @@ impl Disc {
///
pub fn open_title<'a>(
&'a self,
session: &'a mut DriveSession,
session: &'a mut Drive,
title_idx: usize,
) -> Result<ContentReader<'a>> {
let title = self.titles.get(title_idx).ok_or(Error::DiscTitleRange {
@@ -761,7 +762,7 @@ pub(crate) fn detect_max_batch_sectors(device_path: &str) -> u16 {
// For sg devices, find the corresponding block device name
let block_name = if dev_name.starts_with("sg") {
let block_dir = format!("/sys/class/scsi_generic/{}/device/block", dev_name);
let block_dir = format!("/sys/class/scsi_generic/{dev_name}/device/block");
std::fs::read_dir(&block_dir)
.ok()
.and_then(|mut entries| entries.next())
@@ -772,7 +773,7 @@ pub(crate) fn detect_max_batch_sectors(device_path: &str) -> u16 {
};
if let Some(bname) = block_name {
let sysfs_path = format!("/sys/block/{}/queue/max_hw_sectors_kb", bname);
let sysfs_path = format!("/sys/block/{bname}/queue/max_hw_sectors_kb");
if let Ok(content) = std::fs::read_to_string(&sysfs_path) {
if let Ok(kb) = content.trim().parse::<u32>() {
// Convert KB to sectors (1 sector = 2 KB = 2048 bytes)
@@ -1045,7 +1046,7 @@ fn format_channels(audio_format: u8) -> String {
3 => "stereo".into(),
6 => "5.1".into(),
12 => "7.1".into(),
_ if audio_format > 0 => format!("{}ch", audio_format),
_ if audio_format > 0 => format!("{audio_format}ch"),
_ => String::new(),
}
}
+104
View File
@@ -0,0 +1,104 @@
//! Drive data capture — read hardware information via SCSI.
use crate::drive::Drive;
use crate::error::Result;
/// Raw data captured from a drive's SCSI responses.
#[derive(Debug, Clone)]
pub struct DriveCapture {
/// Raw INQUIRY response (96 bytes)
pub inquiry: Vec<u8>,
/// Raw GET_CONFIG 010C response
pub gc_010c: Vec<u8>,
/// GET_CONFIG feature responses: (feature_code, feature_name, data)
pub features: Vec<CapturedFeature>,
/// REPORT_KEY RPC state
pub rpc_state: Option<Vec<u8>>,
/// MODE SENSE page 2A (capabilities)
pub mode_2a: Option<Vec<u8>>,
/// READ_BUFFER 0xF1 (Pioneer vendor data)
pub rb_f1: Option<Vec<u8>>,
/// READ_BUFFER mode 6 (MTK vendor data)
pub rb_mode6: Option<Vec<u8>>,
}
/// A single GET CONFIGURATION feature response from the drive.
#[derive(Debug, Clone)]
pub struct CapturedFeature {
pub code: u16,
pub name: &'static str,
pub data: Vec<u8>,
}
/// Feature codes to capture.
const FEATURES: &[(u16, &str)] = &[
(0x0000, "Profile List"),
(0x0001, "Core"),
(0x0003, "Removable Medium"),
(0x0010, "Random Readable"),
(0x001D, "Multi-Read"),
(0x001E, "CD Read"),
(0x001F, "DVD Read"),
(0x0040, "BD Read"),
(0x0041, "BD Write"),
(0x0100, "Power Management"),
(0x0102, "Embedded Changer"),
(0x0107, "Real Time Streaming"),
(0x0108, "Serial Number"),
(0x010C, "Firmware Information"),
(0x010D, "AACS"),
];
/// Capture all available drive data via SCSI commands.
/// Returns raw responses — no formatting, no zipping, no presentation.
pub fn capture_drive_data(session: &mut Drive) -> Result<DriveCapture> {
let id = &session.drive_id;
// Already have INQUIRY from drive open
let inquiry = id.raw_inquiry.clone();
let gc_010c = id.raw_gc_010c.clone();
// Capture GET_CONFIG features using Drive's query methods
let mut features = Vec::new();
for &(code, name) in FEATURES {
if let Some(data) = session.get_config_feature(code) {
features.push(CapturedFeature { code, name, data });
}
}
// Vendor-specific READ_BUFFER queries
let rb_f1 = session.read_buffer(0x02, 0xF1, 48); // Pioneer
let rb_mode6 = session.read_buffer(0x06, 0x00, 32); // MTK
// Standard queries
let rpc_state = session.report_key_rpc_state();
let mode_2a = session.mode_sense_page(0x2A);
Ok(DriveCapture {
inquiry,
gc_010c,
features,
rpc_state,
mode_2a,
rb_f1,
rb_mode6,
})
}
/// Mask a string for privacy (letters->A, digits->0).
pub fn mask_string(s: &str) -> String {
s.chars().map(|c| {
if c.is_ascii_alphabetic() { 'A' }
else if c.is_ascii_digit() { '0' }
else { c }
}).collect()
}
/// Mask bytes for privacy.
pub fn mask_bytes(data: &[u8]) -> Vec<u8> {
data.iter().map(|&b| {
if b.is_ascii_alphabetic() { b'A' }
else if b.is_ascii_digit() { b'0' }
else { b }
}).collect()
}
+3 -5
View File
@@ -6,7 +6,7 @@ use crate::identity::DriveId;
pub fn find_drives() -> Vec<(String, DriveId)> {
let mut drives = Vec::new();
for i in 0..16 {
let path = format!("/dev/sg{}", i);
let path = format!("/dev/sg{i}");
if !std::path::Path::new(&path).exists() {
continue;
}
@@ -40,8 +40,7 @@ pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
&& sg_id.serial_number == sr_id.serial_number
{
let warning = format!(
"{} is a block device (sr) — using {} (sg) for raw access",
path, sg_path
"{path} is a block device (sr) — using {sg_path} (sg) for raw access"
);
return Ok((sg_path, Some(warning)));
}
@@ -49,8 +48,7 @@ pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
return Ok((
path.to_string(),
Some(format!(
"{} is a block device (sr) — no matching sg device found",
path
"{path} is a block device (sr) — no matching sg device found"
)),
));
}
+240 -23
View File
@@ -6,6 +6,8 @@
//! 3. `init()` — activate custom firmware. Removes riplock.
//! 4. `probe_disc()` — probe disc surface. Drive learns optimal speeds.
pub mod capture;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
@@ -22,7 +24,23 @@ use crate::scsi::ScsiTransport;
use crate::sector::SectorReader;
use std::path::Path;
pub struct DriveSession {
/// Physical state of the drive tray and disc.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DriveStatus {
/// Tray is open
TrayOpen,
/// Tray closed, no disc
NoDisc,
/// Tray closed, disc present and ready
DiscPresent,
/// Drive is loading or spinning up
NotReady,
/// Could not determine status
Unknown,
}
/// Optical disc drive session -- open, identify, unlock, and read.
pub struct Drive {
scsi: Box<dyn ScsiTransport>,
driver: Option<Box<dyn PlatformDriver>>,
pub profile: Option<DriveProfile>,
@@ -31,7 +49,7 @@ pub struct DriveSession {
device_path: String,
}
impl DriveSession {
impl Drive {
pub fn open(device: &Path) -> Result<Self> {
let mut transport = crate::scsi::open(device)?;
let profiles = profile::load_bundled()?;
@@ -47,7 +65,7 @@ impl DriveSession {
None => (None, None, None),
};
Ok(DriveSession {
Ok(Drive {
scsi: transport,
driver,
platform,
@@ -64,15 +82,31 @@ impl DriveSession {
pub fn wait_ready(&mut self) -> Result<()> {
let tur = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
let mut tried_reset = false;
for _ in 0..60 {
let mut buf = [0u8; 0];
if self
.scsi
.as_mut()
.execute(&tur, crate::scsi::DataDirection::None, &mut buf, 5000)
.is_ok()
{
return Ok(());
match self.scsi.as_mut().execute(
&tur,
crate::scsi::DataDirection::None,
&mut buf,
5_000,
) {
Ok(_) => return Ok(()),
Err(Error::ScsiError { sense_key: 5, .. }) if !tried_reset => {
// Illegal Request on TUR — drive may be stuck from a previous session.
// Try reset() which attempts multiple recovery approaches.
tried_reset = true;
if self.reset().is_ok() {
return Ok(());
}
// If reset failed but disc is present, proceed anyway —
// the scan path will handle errors individually.
if self.drive_status() == DriveStatus::DiscPresent {
return Ok(());
}
}
Err(_) => {}
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
@@ -81,6 +115,118 @@ impl DriveSession {
})
}
/// Query the physical state of the drive — disc present, tray open, etc.
/// Uses GET EVENT STATUS NOTIFICATION which works regardless of firmware state.
pub fn drive_status(&mut self) -> DriveStatus {
// GET EVENT STATUS NOTIFICATION: polled, media event class (0x10)
let cdb = [0x4Au8, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x08, 0x00];
let mut buf = [0u8; 8];
match self.scsi.as_mut().execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
) {
Ok(r) if r.bytes_transferred >= 6 => {
let media_status = buf[5];
// Bits 1-0: door/tray state
// Bit 1: media present, Bit 0: tray open
match media_status & 0x03 {
0x00 => DriveStatus::NoDisc, // tray closed, no disc
0x01 => DriveStatus::TrayOpen, // tray open
0x02 => DriveStatus::DiscPresent, // tray closed, disc present
0x03 => DriveStatus::DiscPresent, // tray closed, disc present
_ => DriveStatus::Unknown,
}
}
_ => {
// Fallback: try TUR
let tur = [0x00u8, 0x00, 0x00, 0x00, 0x00, 0x00];
let mut empty = [0u8; 0];
match self.scsi.as_mut().execute(
&tur,
crate::scsi::DataDirection::None,
&mut empty,
5_000,
) {
Ok(_) => DriveStatus::DiscPresent,
Err(Error::ScsiError { sense_key: 2, .. }) => DriveStatus::NotReady,
Err(Error::ScsiError { sense_key: 6, .. }) => DriveStatus::NotReady, // UNIT ATTENTION
_ => DriveStatus::Unknown,
}
}
}
}
/// Attempt to reset the drive to a clean state.
///
/// Escalates through increasingly aggressive recovery:
/// 1. Unlock tray + stop/start — handles normal stuck states
/// 2. Eject cycle — clears LibreDrive firmware stuck state (proven on BU40N)
/// 3. Re-init — firmware re-upload if profile available
///
/// Note: step 2 physically ejects the tray. On slimline drives the user
/// must push it back in manually. Returns Ok(()) if TUR succeeds after
/// any step, even if the drive reports "tray open" (that's a valid state).
pub fn reset(&mut self) -> Result<()> {
let mut buf = [0u8; 0];
let tur = [0x00u8, 0x00, 0x00, 0x00, 0x00, 0x00];
// 1. Unlock + stop/start
self.unlock_tray();
let stop = [0x1Bu8, 0x00, 0x00, 0x00, 0x00, 0x00];
let _ = self.scsi.as_mut().execute(
&stop, crate::scsi::DataDirection::None, &mut buf, 5_000,
);
std::thread::sleep(std::time::Duration::from_millis(500));
let start = [0x1Bu8, 0x00, 0x00, 0x00, 0x01, 0x00];
let _ = self.scsi.as_mut().execute(
&start, crate::scsi::DataDirection::None, &mut buf, 5_000,
);
std::thread::sleep(std::time::Duration::from_millis(2000));
if self.scsi.as_mut().execute(
&tur, crate::scsi::DataDirection::None, &mut buf, 5_000,
).is_ok() {
return Ok(());
}
// 2. Eject cycle — clears MT1959 LibreDrive stuck state.
// After eject, TUR returning "Not Ready — tray open" (sense key 2)
// counts as success: the drive is functional, just needs disc reinserted.
self.unlock_tray();
let eject = [0x1Bu8, 0x00, 0x00, 0x00, 0x02, 0x00];
let _ = self.scsi.as_mut().execute(
&eject, crate::scsi::DataDirection::None, &mut buf, 30_000,
);
std::thread::sleep(std::time::Duration::from_millis(2000));
match self.scsi.as_mut().execute(
&tur, crate::scsi::DataDirection::None, &mut buf, 5_000,
) {
Ok(_) => return Ok(()),
Err(Error::ScsiError { sense_key: 2, .. }) => return Ok(()), // tray open = valid
_ => {}
}
// 3. If still stuck and we have a profile, try re-init
if self.driver.is_some() {
self.init()?;
std::thread::sleep(std::time::Duration::from_millis(1000));
match self.scsi.as_mut().execute(
&tur, crate::scsi::DataDirection::None, &mut buf, 5_000,
) {
Ok(_) => return Ok(()),
Err(Error::ScsiError { sense_key: 2, .. }) => return Ok(()),
_ => {}
}
}
Err(Error::DeviceNotFound {
path: format!("{}: drive reset failed", self.device_path),
})
}
pub fn platform_name(&self) -> &str {
match self.platform {
Some(ref p) => p.name(),
@@ -119,6 +265,51 @@ impl DriveSession {
}
}
/// Query a specific GET CONFIGURATION feature by code.
/// Returns the feature data (without the 8-byte header), or None if not available.
pub fn get_config_feature(&mut self, feature_code: u16) -> Option<Vec<u8>> {
let cdb = [
crate::scsi::SCSI_GET_CONFIGURATION, 0x02,
(feature_code >> 8) as u8, feature_code as u8,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
];
let mut buf = vec![0u8; 256];
let r = self.scsi.as_mut()
.execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000).ok()?;
if r.bytes_transferred > 8 {
Some(buf[8..r.bytes_transferred].to_vec())
} else {
None
}
}
/// Read REPORT KEY RPC state (region playback control).
pub fn report_key_rpc_state(&mut self) -> Option<Vec<u8>> {
let cdb = [0xA4u8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x08, 0x00];
let mut buf = vec![0u8; 8];
let r = self.scsi.as_mut()
.execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000).ok()?;
if r.bytes_transferred > 0 { Some(buf[..r.bytes_transferred].to_vec()) } else { None }
}
/// Read MODE SENSE page data.
pub fn mode_sense_page(&mut self, page: u8) -> Option<Vec<u8>> {
let cdb = [0x5Au8, 0x00, page, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0x00];
let mut buf = vec![0u8; 252];
let r = self.scsi.as_mut()
.execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000).ok()?;
if r.bytes_transferred > 0 { Some(buf[..r.bytes_transferred].to_vec()) } else { None }
}
/// Read vendor-specific READ BUFFER data.
pub fn read_buffer(&mut self, mode: u8, buffer_id: u8, length: u16) -> Option<Vec<u8>> {
let cdb = crate::scsi::build_read_buffer(mode, buffer_id, 0, length as u32);
let mut buf = vec![0u8; length as usize];
let r = self.scsi.as_mut()
.execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000).ok()?;
if r.bytes_transferred > 0 { Some(buf[..r.bytes_transferred].to_vec()) } else { None }
}
pub fn is_ready(&self) -> bool {
match self.driver {
Some(ref d) => d.is_ready(),
@@ -174,16 +365,29 @@ impl DriveSession {
let _ = self.scsi_execute(&cdb, crate::scsi::DataDirection::None, &mut dummy, 5_000);
}
pub fn eject(&mut self) -> Result<()> {
let allow_cdb = [0x1Eu8, 0, 0, 0, 0x00, 0];
/// Lock the tray so the disc cannot be ejected during a rip.
pub fn lock_tray(&mut self) {
let prevent = [0x1Eu8, 0x00, 0x00, 0x00, 0x01, 0x00];
let mut buf = [0u8; 0];
let _ = self.scsi.as_mut().execute(
&allow_cdb,
crate::scsi::DataDirection::None,
&mut buf,
5_000,
&prevent, crate::scsi::DataDirection::None, &mut buf, 5_000,
);
}
/// Unlock the tray so the user can manually eject the disc.
pub fn unlock_tray(&mut self) {
let allow = [0x1Eu8, 0x00, 0x00, 0x00, 0x00, 0x00];
let mut buf = [0u8; 0];
let _ = self.scsi.as_mut().execute(
&allow, crate::scsi::DataDirection::None, &mut buf, 5_000,
);
}
/// Eject the disc tray. Unlocks first, then ejects.
pub fn eject(&mut self) -> Result<()> {
self.unlock_tray();
let eject_cdb = [0x1Bu8, 0, 0, 0, 0x02, 0];
let mut buf = [0u8; 0];
self.scsi.as_mut().execute(
&eject_cdb,
crate::scsi::DataDirection::None,
@@ -204,13 +408,29 @@ impl DriveSession {
}
}
impl SectorReader for DriveSession {
impl SectorReader for Drive {
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)> {
/// Find all optical drives connected to this system.
/// Returns opened Drive objects ready for use.
pub fn find_drives() -> Vec<Drive> {
discover_drives()
.into_iter()
.filter_map(|(path, _)| Drive::open(std::path::Path::new(&path)).ok())
.collect()
}
/// Find the first optical drive.
/// Returns an opened Drive ready for use.
pub fn find_drive() -> Option<Drive> {
find_drives().into_iter().next()
}
/// Internal: discover drive paths + IDs without opening full Drive objects.
fn discover_drives() -> Vec<(String, DriveId)> {
#[cfg(target_os = "linux")]
{
linux::find_drives()
@@ -225,11 +445,8 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
}
}
pub fn find_drive() -> Option<String> {
find_drives().into_iter().next().map(|(path, _)| path)
}
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
/// Resolve a device path to its raw SCSI device, with optional warning message.
pub(crate) fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
#[cfg(target_os = "linux")]
{
linux::resolve_device(path)
+43 -9
View File
@@ -71,102 +71,135 @@ pub const E_MUX_WRITE: u16 = 9001;
/// Structured error with numeric code and context data. No English text.
#[derive(Debug)]
pub enum Error {
// Device
/// Device not found at the given path.
DeviceNotFound {
path: String,
},
/// Insufficient permissions to open the device.
DevicePermission {
path: String,
},
// Profile
/// Drive model is not in the profile database.
UnsupportedDrive {
vendor_id: String,
product_id: String,
product_revision: String,
},
/// No matching firmware profile found for this drive revision.
ProfileNotFound {
vendor_id: String,
product_revision: String,
vendor_specific: String,
},
/// Failed to parse the bundled profile database.
ProfileParse,
// Unlock
/// Drive unlock (firmware upload) failed.
UnlockFailed,
/// Firmware signature verification failed.
SignatureMismatch {
expected: [u8; 4],
got: [u8; 4],
},
/// Operation requires an unlocked drive.
NotUnlocked,
/// Operation requires a calibrated drive.
NotCalibrated,
// SCSI
/// SCSI command returned an error status.
ScsiError {
opcode: u8,
status: u8,
sense_key: u8,
},
/// SCSI command timed out.
ScsiTimeout {
opcode: u8,
},
// I/O
/// Underlying I/O error.
IoError {
source: std::io::Error,
},
/// Write operation failed.
WriteError,
// Disc format
/// Failed to read disc sector.
DiscRead {
sector: u64,
},
/// MPLS playlist parsing failed.
MplsParse,
/// CLPI clip info parsing failed.
ClpiParse,
/// File not found on the UDF filesystem.
UdfNotFound {
path: String,
},
/// Disc contains no playable titles.
DiscNoTitles,
/// Title index out of range.
DiscTitleRange {
index: usize,
count: usize,
},
/// Title has no sector extents to read.
DiscNoExtents,
/// DVD IFO file parsing failed.
IfoParse,
// AACS
/// No AACS decryption keys available for this disc.
AacsNoKeys,
/// Host certificate too short.
AacsCertShort,
/// Failed to allocate AGID for AACS handshake.
AacsAgidAlloc,
/// Drive rejected the host certificate.
AacsCertRejected,
/// Failed to read drive certificate.
AacsCertRead,
/// Drive certificate verification failed.
AacsCertVerify,
/// Failed to read host key from drive.
AacsKeyRead,
/// Drive rejected the host key.
AacsKeyRejected,
/// Host key verification failed.
AacsKeyVerify,
/// Failed to read Volume ID.
AacsVidRead,
/// Volume ID MAC verification failed.
AacsVidMac,
/// Failed to derive the data key.
AacsDataKey,
/// Failed to derive the Volume Unique Key.
AacsVukDerive,
// Keydb
/// Failed to connect to the KEYDB server.
KeydbConnect {
host: String,
},
/// KEYDB server returned an HTTP error.
KeydbHttp {
status: u16,
},
/// Downloaded KEYDB file is invalid (no entries found).
KeydbInvalid,
/// Failed to write KEYDB to disk.
KeydbWrite {
path: String,
},
/// Failed to parse KEYDB file.
KeydbParse,
/// Failed to load KEYDB from disk.
KeydbLoad {
path: String,
},
// Mux
/// Lookahead buffer exhausted before codec headers found.
MuxLookahead,
/// Muxer write failed.
MuxWrite,
}
@@ -306,4 +339,5 @@ impl From<std::io::Error> for Error {
}
}
/// Convenience alias for `Result<T, Error>`.
pub type Result<T> = std::result::Result<T, Error>;
+1 -1
View File
@@ -265,7 +265,7 @@ fn parse_vts(
vts_number: u8,
titles_info: &[(u16, u8)],
) -> Result<DvdTitleSet> {
let path = format!("/VIDEO_TS/VTS_{:02}_0.IFO", vts_number);
let path = format!("/VIDEO_TS/VTS_{vts_number:02}_0.IFO");
let vts_data = udf.read_file(reader, &path)?;
// Validate VTS magic
+3 -3
View File
@@ -71,6 +71,7 @@ pub fn save(data: &[u8]) -> Result<UpdateResult> {
})
}
/// Result of a KEYDB update -- path written, entry count, and byte size.
#[derive(Debug)]
pub struct UpdateResult {
pub path: PathBuf,
@@ -82,7 +83,7 @@ fn http_get(url: &str) -> Result<Vec<u8>> {
let (mut host, mut port, mut path) = parse_url(url)?;
for _ in 0..5 {
let addr = format!("{}:{}", host, port);
let addr = format!("{host}:{port}");
let mut stream =
TcpStream::connect(&addr).map_err(|_| Error::KeydbConnect { host: host.clone() })?;
stream
@@ -90,8 +91,7 @@ fn http_get(url: &str) -> Result<Vec<u8>> {
.ok();
let request = format!(
"GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nAccept-Encoding: identity\r\n\r\n",
path, host
"GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nAccept-Encoding: identity\r\n\r\n"
);
stream
.write_all(request.as_bytes())
+2 -2
View File
@@ -174,8 +174,8 @@ fn parse_playback_config(xml: &str, map: &mut HashMap<String, u16>) {
}
fn extract_tag(xml: &str, tag: &str) -> Option<String> {
let open = format!("<{}>", tag);
let close = format!("</{}>", tag);
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = xml.find(&open)? + open.len();
let end = xml[start..].find(&close)? + start;
Some(xml[start..end].trim().to_string())
+1 -1
View File
@@ -140,7 +140,7 @@ fn find_feature_playlist(xml: &str) -> Option<String> {
/// Extract an XML attribute value from an element string.
fn extract_attr(element: &str, name: &str) -> Option<String> {
let needle = format!("{}=\"", name);
let needle = format!("{name}=\"");
let start = element.find(&needle)? + needle.len();
let end = element[start..].find('"')? + start;
Some(element[start..end].to_string())
+10 -11
View File
@@ -6,19 +6,17 @@
//! # Quick Start
//!
//! ```no_run
//! use libfreemkv::{DriveSession, Disc, ScanOptions, find_drive};
//! use std::path::Path;
//! use libfreemkv::{Drive, Disc, ScanOptions, find_drive};
//!
//! let device = find_drive().expect("no optical drive found");
//! let mut session = DriveSession::open(Path::new(&device)).unwrap();
//! let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
//! let mut drive = find_drive().expect("no optical drive found");
//! let disc = Disc::scan(&mut drive, &ScanOptions::default()).unwrap();
//!
//! for title in &disc.titles {
//! println!("{} -- {} streams", title.duration_display(), title.streams.len());
//! }
//!
//! // Read content (decrypted automatically if AACS keys available)
//! let mut reader = disc.open_title(&mut session, 0).unwrap();
//! let mut reader = disc.open_title(&mut drive, 0).unwrap();
//! while let Some(unit) = reader.read_unit().unwrap() {
//! // 6144 bytes of decrypted content per unit
//! }
@@ -27,7 +25,7 @@
//! # Architecture
//!
//! ```text
//! DriveSession -- open, identify, unlock, read sectors
//! Drive -- open, identify, unlock, read sectors
//! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS)
//! ├── DriveProfile -- per-drive unlock parameters (206 bundled)
//! ├── DriveId -- INQUIRY + GET_CONFIG identification
@@ -81,18 +79,19 @@ pub(crate) mod labels;
pub(crate) mod mpls;
pub mod mux;
pub(crate) mod platform;
pub(crate) mod profile;
pub mod profile;
pub mod scsi;
pub(crate) mod sector;
pub(crate) mod speed;
pub(crate) mod udf;
pub use drive::{find_drive, find_drives, resolve_device, DriveSession};
pub use drive::{find_drive, find_drives, Drive, DriveStatus};
pub use drive::capture::{DriveCapture, CapturedFeature, capture_drive_data, mask_string, mask_bytes};
pub use error::{Error, Result};
pub use event::{Event, EventKind};
pub use identity::DriveId;
pub use profile::DriveProfile;
// Platform trait is pub(crate) -- callers use DriveSession, not Platform directly
// Platform trait is pub(crate) -- callers use Drive, not Platform directly
pub use disc::{
AacsState, AudioStream, Clip, Codec, ColorSpace, ContentFormat, ContentReader, Disc,
DiscFormat, DiscTitle, Extent, HdrFormat, KeySource, ScanOptions, Stream, SubtitleStream,
@@ -107,7 +106,7 @@ pub use mux::MkvStream;
pub use mux::NetworkStream;
pub use mux::NullStream;
pub use mux::StdioStream;
pub use mux::{open_input, open_output, parse_url, InputOptions};
pub use mux::{open_input, open_output, parse_url, InputOptions, StreamUrl};
pub use scsi::ScsiTransport;
pub use sector::SectorReader;
pub use speed::DriveSpeed;
+1 -1
View File
@@ -87,7 +87,7 @@ pub fn format_palette(palette: &[[u8; 4]]) -> Vec<u8> {
let mut parts: Vec<String> = Vec::with_capacity(palette.len());
for color in palette {
let [r, g, b] = ycbcr_to_rgb(color);
parts.push(format!("{:02x}{:02x}{:02x}", r, g, b));
parts.push(format!("{r:02x}{g:02x}{b:02x}"));
}
let line = format!("palette: {}\n", parts.join(", "));
line.into_bytes()
+8 -11
View File
@@ -1,6 +1,6 @@
//! DiscStream — read BD-TS data from an optical disc drive.
//!
//! Read-only stream. Wraps DriveSession + Disc.
//! Read-only stream. Wraps Drive + Disc.
//! Handles drive init, AACS decryption, and sector reading.
//!
//! Reading state (extent index, offset, batch size, error recovery) is stored
@@ -12,11 +12,10 @@ use crate::disc::{
detect_max_batch_sectors, ContentFormat, Disc, DiscTitle, Extent, MIN_BATCH_SECTORS,
RAMP_BATCH_AFTER, RAMP_SPEED_AFTER, SLOW_SPEED_AFTER,
};
use crate::drive::DriveSession;
use crate::drive::Drive;
use crate::error::Error;
use crate::speed::DriveSpeed;
use std::io::{self, Read, Write};
use std::path::Path;
/// AACS decryption parameters needed at read time.
/// Extracted from `AacsState` so we don't need `Clone` on the full struct.
@@ -29,9 +28,9 @@ struct AacsDecrypt {
#[derive(Default)]
pub struct DiscOptions {
/// Device path (e.g. "/dev/sg4"). None = auto-detect.
pub device: Option<String>,
pub device: Option<std::path::PathBuf>,
/// KEYDB.cfg path. None = search standard locations.
pub keydb_path: Option<String>,
pub keydb_path: Option<std::path::PathBuf>,
/// Which title to read (0-based). None = longest title.
pub title_index: Option<usize>,
}
@@ -43,7 +42,7 @@ pub struct DiscOptions {
pub struct DiscStream {
disc_title: DiscTitle,
disc: Disc,
session: DriveSession,
session: Drive,
// Read buffer: holds one decoded batch
batch_buf: Vec<u8>,
batch_pos: usize,
@@ -74,20 +73,18 @@ pub struct DiscStream {
impl DiscStream {
/// Open the disc drive and scan disc metadata.
pub fn open(opts: DiscOptions) -> Result<Self, Error> {
let device = match opts.device {
Some(ref d) => crate::drive::resolve_device(d)?.0,
let mut session = match opts.device {
Some(ref d) => Drive::open(d)?,
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),
Some(ref kp) => crate::disc::ScanOptions::with_keydb(kp.clone()),
None => crate::disc::ScanOptions::default(),
};
let disc = Disc::scan(&mut session, &scan_opts)?;
+14 -14
View File
@@ -203,7 +203,7 @@ pub fn read_size(r: &mut impl Read) -> io::Result<(u64, usize)> {
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 {
if val == 0x1F_FFFF {
return Ok((u64::MAX, 3));
}
Ok((val, 3))
@@ -212,7 +212,7 @@ pub fn read_size(r: &mut impl Read) -> io::Result<(u64, usize)> {
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 {
if val == 0x0FFF_FFFF {
return Ok((u64::MAX, 4));
}
Ok((val, 4))
@@ -256,7 +256,7 @@ pub fn read_size(r: &mut impl Read) -> io::Result<(u64, usize)> {
| (b[4] as u64) << 16
| (b[5] as u64) << 8
| b[6] as u64;
if val == 0x00FFFFFFFFFFFFFF {
if val == 0x00FF_FFFF_FFFF_FFFF {
return Ok((u64::MAX, 8));
}
Ok((val, 8))
@@ -336,7 +336,7 @@ pub fn read_vint(r: &mut impl Read) -> io::Result<(u64, usize)> {
// ============================================================
// EBML Header
pub const EBML: u32 = 0x1A45DFA3;
pub const EBML: u32 = 0x1A45_DFA3;
pub const EBML_VERSION: u32 = 0x4286;
pub const EBML_READ_VERSION: u32 = 0x42F7;
pub const EBML_MAX_ID_LENGTH: u32 = 0x42F2;
@@ -346,24 +346,24 @@ pub const EBML_DOC_TYPE_VERSION: u32 = 0x4287;
pub const EBML_DOC_TYPE_READ_VERSION: u32 = 0x4285;
// Segment
pub const SEGMENT: u32 = 0x18538067;
pub const SEGMENT: u32 = 0x1853_8067;
// Seek Head
pub const SEEK_HEAD: u32 = 0x114D9B74;
pub const SEEK_HEAD: u32 = 0x114D_9B74;
pub const SEEK: u32 = 0x4DBB;
pub const SEEK_ID: u32 = 0x53AB;
pub const SEEK_POSITION: u32 = 0x53AC;
// Segment Info
pub const INFO: u32 = 0x1549A966;
pub const TIMESTAMP_SCALE: u32 = 0x2AD7B1;
pub const INFO: u32 = 0x1549_A966;
pub const TIMESTAMP_SCALE: u32 = 0x2A_D7B1;
pub const DURATION: u32 = 0x4489;
pub const MUXING_APP: u32 = 0x4D80;
pub const WRITING_APP: u32 = 0x5741;
pub const TITLE: u32 = 0x7BA9;
// Tracks
pub const TRACKS: u32 = 0x1654AE6B;
pub const TRACKS: u32 = 0x1654_AE6B;
pub const TRACK_ENTRY: u32 = 0xAE;
pub const TRACK_NUMBER: u32 = 0xD7;
pub const TRACK_UID: u32 = 0x73C5;
@@ -371,11 +371,11 @@ pub const TRACK_TYPE: u32 = 0x83;
pub const FLAG_LACING: u32 = 0x9C;
pub const FLAG_DEFAULT: u32 = 0x88;
pub const FLAG_FORCED: u32 = 0x55AA;
pub const LANGUAGE: u32 = 0x22B59C;
pub const LANGUAGE: u32 = 0x22_B59C;
pub const CODEC_ID: u32 = 0x86;
pub const CODEC_PRIVATE: u32 = 0x63A2;
pub const TRACK_NAME: u32 = 0x536E;
pub const DEFAULT_DURATION: u32 = 0x23E383;
pub const DEFAULT_DURATION: u32 = 0x23_E383;
// Video
pub const VIDEO: u32 = 0xE0;
@@ -396,12 +396,12 @@ pub const CHANNELS: u32 = 0x9F;
pub const BIT_DEPTH: u32 = 0x6264;
// Cluster
pub const CLUSTER: u32 = 0x1F43B675;
pub const CLUSTER: u32 = 0x1F43_B675;
pub const CLUSTER_TIMESTAMP: u32 = 0xE7;
pub const SIMPLE_BLOCK: u32 = 0xA3;
// Cues
pub const CUES: u32 = 0x1C53BB6B;
pub const CUES: u32 = 0x1C53_BB6B;
pub const CUE_POINT: u32 = 0xBB;
pub const CUE_TIME: u32 = 0xB3;
pub const CUE_TRACK_POSITIONS: u32 = 0xB7;
@@ -409,7 +409,7 @@ pub const CUE_TRACK: u32 = 0xF7;
pub const CUE_CLUSTER_POSITION: u32 = 0xF1;
// Chapters
pub const CHAPTERS: u32 = 0x1043A770;
pub const CHAPTERS: u32 = 0x1043_A770;
pub const EDITION_ENTRY: u32 = 0x45B9;
pub const CHAPTER_ATOM: u32 = 0xB6;
pub const CHAPTER_UID: u32 = 0x73C4;
+2 -2
View File
@@ -30,7 +30,7 @@ pub struct IsoSectorReader {
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)))?;
.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 })
@@ -123,7 +123,7 @@ impl IsoStream {
/// Create an ISO file for writing.
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)))?;
.map_err(|e| io::Error::new(e.kind(), format!("iso://{path}: {e}")))?;
let buf_writer = io::BufWriter::with_capacity(4 * 1024 * 1024, file);
let iso_writer = IsoWriter::new(buf_writer, "FREEMKV", "00001.m2ts");
+1 -1
View File
@@ -197,7 +197,7 @@ impl<W: Write + Seek> IsoWriter<W> {
// Partition starting location at offset 188
pd[188..192].copy_from_slice(&PARTITION_START.to_le_bytes());
// Partition length (large enough for everything)
let part_len: u32 = 0xFFFFFFFF;
let part_len: u32 = 0xFFFF_FFFF;
pd[192..196].copy_from_slice(&part_len.to_le_bytes());
self.writer.write_all(&pd)?;
+2 -2
View File
@@ -183,7 +183,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
for (i, track) in tracks.iter().enumerate() {
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
ebml::write_uint(&mut writer, ebml::TRACK_NUMBER, (i + 1) as u64)?;
ebml::write_uint(&mut writer, ebml::TRACK_UID, (i + 1) as u64 | 0x1000000)?;
ebml::write_uint(&mut writer, ebml::TRACK_UID, (i + 1) as u64 | 0x100_0000)?;
ebml::write_uint(&mut writer, ebml::TRACK_TYPE, track.track_type)?;
ebml::write_uint(&mut writer, ebml::FLAG_LACING, 0)?;
ebml::write_string(&mut writer, ebml::CODEC_ID, track.codec_id)?;
@@ -432,7 +432,7 @@ fn parse_resolution(s: &str) -> (u32, u32) {
fn parse_sample_rate(s: &str) -> f64 {
if s.contains("192") {
192000.0
192_000.0
} else if s.contains("96") {
96000.0
} else {
+6 -2
View File
@@ -84,7 +84,11 @@ impl MkvStream {
for s in &dt.streams {
let (pid, track, parser) = match s {
crate::disc::Stream::Video(v) => {
ws.video_pending += 1;
// Only count primary video as pending — secondary streams
// (Dolby Vision EL, PiP) may never produce codec headers
if !v.secondary {
ws.video_pending += 1;
}
(v.pid, MkvTrack::video(v), codec::parser_for_codec(v.codec))
}
crate::disc::Stream::Audio(a) => {
@@ -512,7 +516,7 @@ fn parse_track(r: &mut (impl Read + Seek), size: u64) -> io::Result<Option<crate
"S_VOBSUB" => Codec::DvdSub,
_ => Codec::Unknown(0),
};
let res = format!("{}p", ph);
let res = format!("{ph}p");
let chs: String = match ch {
8 => "7.1",
6 => "5.1",
+129 -108
View File
@@ -22,101 +22,124 @@ use super::stdio::StdioStream;
use super::{IOStream, M2tsStream, MkvStream};
use crate::disc::DiscTitle;
use std::io::{self, BufReader, BufWriter};
use std::path::Path;
use std::path::{Path, PathBuf};
/// I/O buffer size for file streams.
const IO_BUF_SIZE: usize = 4 * 1024 * 1024;
/// MKV lookahead buffer size.
const MKV_LOOKAHEAD: usize = 10 * 1024 * 1024;
/// Default MKV lookahead buffer size.
/// Dynamically increased for UHD content (many streams delay video codec headers).
const MKV_LOOKAHEAD_DEFAULT: usize = 10 * 1024 * 1024;
const MKV_LOOKAHEAD_UHD: usize = 100 * 1024 * 1024;
/// Parsed stream URL.
pub struct StreamUrl {
pub scheme: String,
pub path: String,
pub enum StreamUrl {
/// Optical disc drive. Device path is optional (auto-detect if None).
Disc { device: Option<PathBuf> },
/// MPEG-2 transport stream file.
M2ts { path: PathBuf },
/// Matroska container file.
Mkv { path: PathBuf },
/// Network stream (host:port).
Network { addr: String },
/// Standard I/O (stdin/stdout).
Stdio,
/// ISO disc image file.
Iso { path: PathBuf },
/// Null sink (write-only, discards data).
Null,
/// Unrecognized URL.
Unknown { raw: String },
}
/// Parse a URL string into scheme + path.
impl StreamUrl {
/// The scheme name (e.g. "disc", "mkv", "null").
pub fn scheme(&self) -> &str {
match self {
StreamUrl::Disc { .. } => "disc",
StreamUrl::M2ts { .. } => "m2ts",
StreamUrl::Mkv { .. } => "mkv",
StreamUrl::Network { .. } => "network",
StreamUrl::Stdio => "stdio",
StreamUrl::Iso { .. } => "iso",
StreamUrl::Null => "null",
StreamUrl::Unknown { .. } => "unknown",
}
}
/// The path/address component, or empty string for scheme-only URLs.
pub fn path_str(&self) -> &str {
match self {
StreamUrl::Disc { device: Some(p) } => p.to_str().unwrap_or(""),
StreamUrl::Disc { device: None } => "",
StreamUrl::M2ts { path } | StreamUrl::Mkv { path } | StreamUrl::Iso { path } => {
path.to_str().unwrap_or("")
}
StreamUrl::Network { addr } => addr,
StreamUrl::Stdio | StreamUrl::Null => "",
StreamUrl::Unknown { raw } => raw,
}
}
/// Whether this URL represents a disc source (disc:// or iso://).
pub fn is_disc_source(&self) -> bool {
matches!(self, StreamUrl::Disc { .. } | StreamUrl::Iso { .. })
}
}
/// Parse a URL string into a typed StreamUrl.
///
/// All URLs must use the `scheme://path` format. Bare paths are not supported.
///
/// ```text
/// disc:// → scheme="disc", path=""
/// disc:///dev/sg4 → scheme="disc", path="/dev/sg4"
/// m2ts:///tmp/Dune.m2ts → scheme="m2ts", path="/tmp/Dune.m2ts"
/// mkv://Dune.mkv → scheme="mkv", path="Dune.mkv"
/// network://10.0.0.1:9000 → scheme="network", path="10.0.0.1:9000"
/// null:// → scheme="null", path=""
/// disc:// → Disc { device: None }
/// disc:///dev/sg4 → Disc { device: Some("/dev/sg4") }
/// m2ts:///tmp/Dune.m2ts → M2ts { path: "/tmp/Dune.m2ts" }
/// mkv://Dune.mkv → Mkv { path: "Dune.mkv" }
/// network://10.0.0.1:9000 → Network { addr: "10.0.0.1:9000" }
/// null:// → Null
/// ```
pub fn parse_url(url: &str) -> StreamUrl {
if let Some(rest) = url.strip_prefix("disc://") {
return StreamUrl {
scheme: "disc".into(),
path: rest.to_string(),
return if rest.is_empty() {
StreamUrl::Disc { device: None }
} else {
StreamUrl::Disc { device: Some(PathBuf::from(rest)) }
};
}
if let Some(rest) = url.strip_prefix("m2ts://") {
return StreamUrl {
scheme: "m2ts".into(),
path: rest.to_string(),
};
return StreamUrl::M2ts { path: PathBuf::from(rest) };
}
if let Some(rest) = url.strip_prefix("mkv://") {
return StreamUrl {
scheme: "mkv".into(),
path: rest.to_string(),
};
return StreamUrl::Mkv { path: PathBuf::from(rest) };
}
if let Some(rest) = url.strip_prefix("network://") {
return StreamUrl {
scheme: "network".into(),
path: rest.to_string(),
};
return StreamUrl::Network { addr: rest.to_string() };
}
if url == "null://" || url.starts_with("null://") {
return StreamUrl {
scheme: "null".into(),
path: String::new(),
};
return StreamUrl::Null;
}
if url == "stdio://" || url.starts_with("stdio://") {
return StreamUrl {
scheme: "stdio".into(),
path: String::new(),
};
return StreamUrl::Stdio;
}
if let Some(rest) = url.strip_prefix("iso://") {
return StreamUrl {
scheme: "iso".into(),
path: rest.to_string(),
};
}
StreamUrl {
scheme: "unknown".into(),
path: url.to_string(),
return StreamUrl::Iso { path: PathBuf::from(rest) };
}
StreamUrl::Unknown { raw: url.to_string() }
}
/// Validate that a file path is non-empty and has a filename component.
fn validate_file_path(path: &str, scheme: &str) -> io::Result<()> {
if path.is_empty() {
fn validate_file_path(path: &Path, scheme: &str) -> io::Result<()> {
if path.as_os_str().is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"{}:// requires a file path (e.g. {}://movie.{})",
scheme, scheme, scheme
),
format!("{scheme}:// requires a file path (e.g. {scheme}://movie.{scheme})"),
));
}
let p = Path::new(path);
if p.file_name().is_none() {
if path.file_name().is_none() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"{}://{} is not a valid file path — must include a filename",
scheme, path
),
format!("{scheme}://{} is not a valid file path — must include a filename", path.display()),
));
}
Ok(())
@@ -133,10 +156,7 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
if !addr.contains(':') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"network://{} missing port — use network://{}:PORT",
addr, addr
),
format!("network://{addr} missing port — use network://{addr}:PORT"),
));
}
Ok(())
@@ -146,58 +166,56 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
pub fn open_input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn IOStream>> {
let parsed = parse_url(url);
match parsed.scheme.as_str() {
"disc" => {
match parsed {
StreamUrl::Disc { device } => {
let disc_opts = DiscOptions {
device: if parsed.path.is_empty() { None } else { Some(parsed.path) },
keydb_path: opts.keydb_path.clone(),
device,
keydb_path: opts.keydb_path.as_ref().map(|p| p.into()),
title_index: opts.title_index,
};
let stream = DiscStream::open(disc_opts)
.map_err(|e| io::Error::other(e.to_string()))?;
Ok(Box::new(stream))
}
"m2ts" => {
validate_file_path(&parsed.path, "m2ts")?;
let file = std::fs::File::open(&parsed.path)
StreamUrl::M2ts { ref path } => {
validate_file_path(path, "m2ts")?;
let file = std::fs::File::open(path)
.map_err(|e| io::Error::new(e.kind(),
format!("m2ts://{}: {}", parsed.path, e)))?;
format!("m2ts://{}: {}", path.display(), e)))?;
let reader = BufReader::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(M2tsStream::open(reader)?))
}
"mkv" => {
validate_file_path(&parsed.path, "mkv")?;
let file = std::fs::File::open(&parsed.path)
StreamUrl::Mkv { ref path } => {
validate_file_path(path, "mkv")?;
let file = std::fs::File::open(path)
.map_err(|e| io::Error::new(e.kind(),
format!("mkv://{}: {}", parsed.path, e)))?;
format!("mkv://{}: {}", path.display(), e)))?;
let reader = BufReader::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(MkvStream::open(reader)?))
}
"network" => {
validate_network_addr(&parsed.path)?;
Ok(Box::new(NetworkStream::listen(&parsed.path)?))
StreamUrl::Network { ref addr } => {
validate_network_addr(addr)?;
Ok(Box::new(NetworkStream::listen(addr)?))
}
"stdio" => {
StreamUrl::Stdio => {
Ok(Box::new(StdioStream::input()))
}
"iso" => {
validate_file_path(&parsed.path, "iso")?;
StreamUrl::Iso { ref path } => {
validate_file_path(path, "iso")?;
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)?))
Ok(Box::new(IsoStream::open(&path.to_string_lossy(), opts.title_index, &scan_opts)?))
}
"null" => {
StreamUrl::Null => {
Err(io::Error::new(io::ErrorKind::InvalidInput,
"null:// is write-only — cannot use as input"))
}
"unknown" => {
StreamUrl::Unknown { ref raw } => {
Err(io::Error::new(io::ErrorKind::InvalidInput,
format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, disc://, m2ts://movie.m2ts)", parsed.path)))
format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, disc://, m2ts://movie.m2ts)", raw)))
}
_ => Err(io::Error::new(io::ErrorKind::InvalidInput,
format!("unknown scheme: {}://", parsed.scheme))),
}
}
@@ -205,47 +223,50 @@ pub fn open_input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn IOStream
pub fn open_output(url: &str, meta: &DiscTitle) -> io::Result<Box<dyn IOStream>> {
let parsed = parse_url(url);
match parsed.scheme.as_str() {
"disc" => {
match parsed {
StreamUrl::Disc { .. } => {
Err(io::Error::new(io::ErrorKind::Unsupported,
"disc:// is read-only — cannot use as output"))
}
"iso" => {
validate_file_path(&parsed.path, "iso")?;
Ok(Box::new(IsoStream::create(&parsed.path)?.meta(meta)))
StreamUrl::Iso { ref path } => {
validate_file_path(path, "iso")?;
Ok(Box::new(IsoStream::create(&path.to_string_lossy())?.meta(meta)))
}
"null" => {
StreamUrl::Null => {
Ok(Box::new(NullStream::new().meta(meta)))
}
"stdio" => {
StreamUrl::Stdio => {
Ok(Box::new(StdioStream::output().meta(meta)))
}
"m2ts" => {
validate_file_path(&parsed.path, "m2ts")?;
let file = std::fs::File::create(&parsed.path)
StreamUrl::M2ts { ref path } => {
validate_file_path(path, "m2ts")?;
let file = std::fs::File::create(path)
.map_err(|e| io::Error::new(e.kind(),
format!("m2ts://{}: {}", parsed.path, e)))?;
format!("m2ts://{}: {}", path.display(), e)))?;
let writer = BufWriter::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(M2tsStream::new(writer).meta(meta)))
}
"mkv" => {
validate_file_path(&parsed.path, "mkv")?;
let file = std::fs::File::create(&parsed.path)
StreamUrl::Mkv { ref path } => {
validate_file_path(path, "mkv")?;
let file = std::fs::File::create(path)
.map_err(|e| io::Error::new(e.kind(),
format!("mkv://{}: {}", parsed.path, e)))?;
format!("mkv://{}: {}", path.display(), e)))?;
let writer = BufWriter::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(MkvStream::new(writer).meta(meta).max_buffer(MKV_LOOKAHEAD)))
let lookahead = if meta.streams.len() > 15 {
MKV_LOOKAHEAD_UHD
} else {
MKV_LOOKAHEAD_DEFAULT
};
Ok(Box::new(MkvStream::new(writer).meta(meta).max_buffer(lookahead)))
}
"network" => {
validate_network_addr(&parsed.path)?;
Ok(Box::new(NetworkStream::connect(&parsed.path)?.meta(meta)))
StreamUrl::Network { ref addr } => {
validate_network_addr(addr)?;
Ok(Box::new(NetworkStream::connect(addr)?.meta(meta)))
}
"unknown" => {
StreamUrl::Unknown { ref raw } => {
Err(io::Error::new(io::ErrorKind::InvalidInput,
format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, m2ts://movie.m2ts, null://)", parsed.path)))
format!("'{}' is not a valid stream URL — use scheme://path (e.g. mkv://movie.mkv, m2ts://movie.m2ts, null://)", raw)))
}
_ => Err(io::Error::new(io::ErrorKind::InvalidInput,
format!("unknown scheme: {}://", parsed.scheme))),
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ pub mod mt1959;
use crate::error::Result;
use crate::scsi::ScsiTransport;
pub(crate) trait PlatformDriver {
pub(crate) trait PlatformDriver: Send {
/// Unlock drive + upload firmware if needed.
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
+1 -1
View File
@@ -50,7 +50,7 @@ pub struct ScsiResult {
}
/// Low-level SCSI transport — one implementation per platform.
pub trait ScsiTransport {
pub trait ScsiTransport: Send {
fn execute(
&mut self,
cdb: &[u8],
+1 -1
View File
@@ -1,6 +1,6 @@
//! SectorReader — trait for reading 2048-byte disc sectors.
//!
//! Implemented by DriveSession (SCSI) and IsoFile (file-backed).
//! Implemented by Drive (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.
+4 -4
View File
@@ -288,7 +288,7 @@ impl UdfFs {
let raw_len = u32::from_le_bytes([icb[off], icb[off + 1], icb[off + 2], icb[off + 3]]);
let extent_type = raw_len >> 30;
let data_len = raw_len & 0x3FFFFFFF;
let data_len = raw_len & 0x3FFF_FFFF;
let data_lba =
u32::from_le_bytes([icb[off + 4], icb[off + 5], icb[off + 6], icb[off + 7]]);
@@ -455,7 +455,7 @@ pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result<UdfFs> {
meta_icb[ad_off + 1],
meta_icb[ad_off + 2],
meta_icb[ad_off + 3],
]) & 0x3FFFFFFF;
]) & 0x3FFF_FFFF;
metadata_size_bytes = ad_len;
let ad_pos = u32::from_le_bytes([
meta_icb[ad_off + 4],
@@ -543,7 +543,7 @@ fn read_directory(
icb[ad_off + 1],
icb[ad_off + 2],
icb[ad_off + 3],
]) & 0x3FFFFFFF;
]) & 0x3FFF_FFFF;
let pos = u32::from_le_bytes([
icb[ad_off + 4],
icb[ad_off + 5],
@@ -565,7 +565,7 @@ fn read_directory(
icb[ad_off + 1],
icb[ad_off + 2],
icb[ad_off + 3],
]) & 0x3FFFFFFF;
]) & 0x3FFF_FFFF;
let pos = u32::from_le_bytes([
icb[ad_off + 4],
icb[ad_off + 5],
+17 -17
View File
@@ -59,56 +59,56 @@ fn sample_disc_title() -> DiscTitle {
#[test]
fn parse_url_disc() {
let u = parse_url("disc://");
assert_eq!(u.scheme, "disc");
assert_eq!(u.path, "");
assert_eq!(u.scheme(), "disc");
assert_eq!(u.path_str(), "");
}
#[test]
fn parse_url_disc_device() {
let u = parse_url("disc:///dev/sg4");
assert_eq!(u.scheme, "disc");
assert_eq!(u.path, "/dev/sg4");
assert_eq!(u.scheme(), "disc");
assert_eq!(u.path_str(), "/dev/sg4");
}
#[test]
fn parse_url_mkv() {
let u = parse_url("mkv://Dune.mkv");
assert_eq!(u.scheme, "mkv");
assert_eq!(u.path, "Dune.mkv");
assert_eq!(u.scheme(), "mkv");
assert_eq!(u.path_str(), "Dune.mkv");
}
#[test]
fn parse_url_network() {
let u = parse_url("network://10.0.0.1:9000");
assert_eq!(u.scheme, "network");
assert_eq!(u.path, "10.0.0.1:9000");
assert_eq!(u.scheme(), "network");
assert_eq!(u.path_str(), "10.0.0.1:9000");
}
#[test]
fn parse_url_bare_path_rejected() {
let u = parse_url("Dune.mkv");
assert_eq!(u.scheme, "unknown");
assert_eq!(u.scheme(), "unknown");
}
#[test]
fn parse_url_null() {
let u = parse_url("null://");
assert_eq!(u.scheme, "null");
assert_eq!(u.path, "");
assert_eq!(u.scheme(), "null");
assert_eq!(u.path_str(), "");
}
#[test]
fn parse_url_m2ts_with_path() {
let u = parse_url("m2ts:///tmp/Dune.m2ts");
assert_eq!(u.scheme, "m2ts");
assert_eq!(u.path, "/tmp/Dune.m2ts");
assert_eq!(u.scheme(), "m2ts");
assert_eq!(u.path_str(), "/tmp/Dune.m2ts");
}
#[test]
fn parse_url_m2ts_relative() {
let u = parse_url("m2ts://Dune.m2ts");
assert_eq!(u.scheme, "m2ts");
assert_eq!(u.path, "Dune.m2ts");
assert_eq!(u.scheme(), "m2ts");
assert_eq!(u.path_str(), "Dune.m2ts");
}
#[test]
@@ -182,8 +182,8 @@ fn open_input_network_no_port_errors() {
#[test]
fn parse_url_stdio() {
let u = parse_url("stdio://");
assert_eq!(u.scheme, "stdio");
assert_eq!(u.path, "");
assert_eq!(u.scheme(), "stdio");
assert_eq!(u.path_str(), "");
}
// ── M2TS metadata roundtrip ───────────────────────────────────