Files
libfreemkv/src/disc.rs
T
MattJackson 4949199ffc Make unlock non-fatal in open_title — BD discs work without it
BD drives reject the MediaTek unlock command (sense 0x05).
Unlock is only needed for UHD raw access. Standard BD reads
work with standard READ(10) without vendor unlock.
2026-04-08 17:30:09 -07:00

1123 lines
40 KiB
Rust

//! Disc structure — scan titles, streams, and sector ranges from a Blu-ray disc.
//!
//! This is the high-level API for disc content. The CLI calls this,
//! never parses MPLS/CLPI/UDF directly.
//!
//! Usage:
//! let disc = Disc::scan(&mut session)?;
//! for title in disc.titles() { ... }
//! for stream in title.streams() { ... }
use crate::error::{Error, Result};
use crate::drive::DriveSession;
use crate::udf;
use crate::mpls;
use crate::clpi;
// ─── Public types ───────────────────────────────────────────────────────────
/// A scanned Blu-ray disc.
#[derive(Debug)]
pub struct Disc {
/// UDF Volume Identifier from Primary Volume Descriptor (always present)
pub volume_id: String,
/// Disc title from META/DL/bdmt_eng.xml (None if disc has no metadata)
pub meta_title: Option<String>,
/// Disc format (BD, UHD, DVD)
pub format: DiscFormat,
/// Disc capacity in sectors
pub capacity_sectors: u32,
/// Disc capacity in bytes
pub capacity_bytes: u64,
/// Number of layers (1 = single, 2 = dual)
pub layers: u8,
/// Titles sorted by duration (longest first), then playlist name
pub titles: Vec<Title>,
/// Disc region
pub region: DiscRegion,
/// AACS state — None if disc is unencrypted or keys unavailable
pub aacs: Option<AacsState>,
/// Whether this disc requires AACS decryption
pub encrypted: bool,
}
/// Disc format.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DiscFormat {
/// 4K UHD Blu-ray (HEVC 2160p)
Uhd,
/// Standard Blu-ray (1080p/1080i)
BluRay,
/// DVD
Dvd,
/// Unknown
Unknown,
}
/// Disc playback region.
#[derive(Debug, Clone, PartialEq)]
pub enum DiscRegion {
/// Region-free (all UHD discs, some BD/DVD)
Free,
/// Blu-ray regions (A/B/C or combination)
BluRay(Vec<BdRegion>),
/// DVD regions (1-8 or combination)
Dvd(Vec<u8>),
}
/// Blu-ray region codes.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BdRegion {
/// Region A/1 — Americas, East Asia (Japan, Korea, Southeast Asia)
A,
/// Region B/2 — Europe, Africa, Australia, Middle East
B,
/// Region C/3 — Central/South Asia, China, Russia
C,
}
/// A title (one MPLS playlist).
#[derive(Debug, Clone)]
pub struct Title {
/// Playlist filename (e.g. "00800.mpls")
pub playlist: String,
/// Playlist number (e.g. 800)
pub playlist_id: u16,
/// Duration in seconds
pub duration_secs: f64,
/// Total size in bytes
pub size_bytes: u64,
/// Clip references in playback order
pub clips: Vec<Clip>,
/// All streams (video, audio, subtitle, etc.)
pub streams: Vec<Stream>,
/// Sector extents for ripping (clip LBA ranges)
pub extents: Vec<Extent>,
}
/// A clip reference within a title.
#[derive(Debug, Clone)]
pub struct Clip {
/// Clip filename without extension (e.g. "00001")
pub clip_id: String,
/// In-time in 45kHz ticks
pub in_time: u32,
/// Out-time in 45kHz ticks
pub out_time: u32,
/// Duration in seconds
pub duration_secs: f64,
/// Source packet count (from CLPI, 0 if unavailable)
pub source_packets: u32,
}
/// A stream within a title.
#[derive(Debug, Clone)]
pub enum Stream {
Video(VideoStream),
Audio(AudioStream),
Subtitle(SubtitleStream),
}
/// A video stream.
#[derive(Debug, Clone)]
pub struct VideoStream {
/// MPEG-TS packet ID
pub pid: u16,
/// Codec (HEVC, H.264, VC-1, MPEG-2)
pub codec: Codec,
/// Resolution (e.g. "2160p", "1080p", "1080i")
pub resolution: String,
/// Frame rate (e.g. "23.976", "25")
pub frame_rate: String,
/// HDR format
pub hdr: HdrFormat,
/// Color space
pub color_space: ColorSpace,
/// Whether this is a secondary stream (PiP, Dolby Vision EL)
pub secondary: bool,
/// Extra label (e.g. "Dolby Vision EL")
pub label: String,
}
/// An audio stream.
#[derive(Debug, Clone)]
pub struct AudioStream {
/// MPEG-TS packet ID
pub pid: u16,
/// Codec (TrueHD, DTS-HD MA, DD, LPCM, etc.)
pub codec: Codec,
/// Channel layout (e.g. "5.1", "7.1", "stereo", "mono")
pub channels: String,
/// ISO 639-2 language code (e.g. "eng", "fra")
pub language: String,
/// Sample rate (e.g. "48kHz", "96kHz")
pub sample_rate: String,
/// Whether this is a secondary stream (commentary)
pub secondary: bool,
/// Extra label
pub label: String,
}
/// A subtitle stream.
#[derive(Debug, Clone)]
pub struct SubtitleStream {
/// MPEG-TS packet ID
pub pid: u16,
/// Codec (PGS)
pub codec: Codec,
/// ISO 639-2 language code (e.g. "eng", "fra")
pub language: String,
/// Whether this is a forced subtitle
pub forced: bool,
}
/// Video/audio codec.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Codec {
// Video
Hevc,
H264,
Vc1,
Mpeg2,
// Audio
TrueHd,
DtsHdMa,
DtsHdHr,
Dts,
Ac3,
Ac3Plus,
Lpcm,
// Subtitle
Pgs,
// Unknown
Unknown(u8),
}
/// HDR format.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum HdrFormat {
Sdr,
Hdr10,
DolbyVision,
}
/// Color space.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ColorSpace {
Bt709,
Bt2020,
Unknown,
}
/// A contiguous range of sectors on disc.
#[derive(Debug, Clone, Copy)]
pub struct Extent {
pub start_lba: u32,
pub sector_count: u32,
}
// ─── Display helpers ────────────────────────────────────────────────────────
impl Codec {
pub fn name(&self) -> &'static str {
match self {
Codec::Hevc => "HEVC",
Codec::H264 => "H.264",
Codec::Vc1 => "VC-1",
Codec::Mpeg2 => "MPEG-2",
Codec::TrueHd => "TrueHD",
Codec::DtsHdMa => "DTS-HD MA",
Codec::DtsHdHr => "DTS-HD HR",
Codec::Dts => "DTS",
Codec::Ac3 => "AC-3",
Codec::Ac3Plus => "AC-3+",
Codec::Lpcm => "LPCM",
Codec::Pgs => "PGS",
Codec::Unknown(_) => "Unknown",
}
}
fn from_coding_type(ct: u8) -> Self {
match ct {
0x24 => Codec::Hevc,
0x1B => Codec::H264,
0xEA => Codec::Vc1,
0x02 => Codec::Mpeg2,
0x83 => Codec::TrueHd,
0x86 => Codec::DtsHdMa,
0x85 => Codec::DtsHdHr,
0x82 => Codec::Dts,
0x81 => Codec::Ac3,
0x84 | 0xA1 => Codec::Ac3Plus,
0x80 => Codec::Lpcm,
0xA2 => Codec::DtsHdHr,
0x90 | 0x91 => Codec::Pgs,
ct => Codec::Unknown(ct),
}
}
}
impl HdrFormat {
pub fn name(&self) -> &'static str {
match self {
HdrFormat::Sdr => "SDR",
HdrFormat::Hdr10 => "HDR10",
HdrFormat::DolbyVision => "Dolby Vision",
}
}
}
impl ColorSpace {
pub fn name(&self) -> &'static str {
match self {
ColorSpace::Bt709 => "BT.709",
ColorSpace::Bt2020 => "BT.2020",
ColorSpace::Unknown => "",
}
}
}
impl Title {
/// Duration formatted as "Xh Ym"
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)
}
/// Size in GB
pub fn size_gb(&self) -> f64 {
self.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0)
}
/// Total sectors across all extents
pub fn total_sectors(&self) -> u64 {
self.extents.iter().map(|e| e.sector_count as u64).sum()
}
}
// ─── AACS state ─────────────────────────────────────────────────────────────
/// AACS decryption state for a disc.
#[derive(Debug)]
pub struct AacsState {
/// AACS version (1 or 2)
pub version: u8,
/// Whether bus encryption is enabled (always true for AACS 2.0 / UHD)
pub bus_encryption: bool,
/// MKB version from disc (e.g. 68, 77)
pub mkb_version: Option<u32>,
/// Disc hash (SHA1 of Unit_Key_RO.inf) — hex string with 0x prefix
pub disc_hash: String,
/// How keys were resolved
pub key_source: KeySource,
/// Volume Unique Key (16 bytes)
pub vuk: [u8; 16],
/// Decrypted unit keys (CPS unit number, key)
pub unit_keys: Vec<(u32, [u8; 16])>,
/// Read data key for AACS 2.0 bus decryption — None for AACS 1.0
pub read_data_key: Option<[u8; 16]>,
/// Volume ID (16 bytes) — from SCSI handshake
pub volume_id: [u8; 16],
}
/// How AACS keys were resolved.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum KeySource {
/// VUK found directly in KEYDB by disc hash
KeyDb,
/// Media key + Volume ID from KEYDB → derived VUK
KeyDbDerived,
/// MKB + processing keys → media key → VUK
ProcessingKey,
/// MKB + device keys → subset-difference tree → VUK
DeviceKey,
}
impl KeySource {
pub fn name(&self) -> &'static str {
match self {
KeySource::KeyDb => "KEYDB",
KeySource::KeyDbDerived => "KEYDB (derived)",
KeySource::ProcessingKey => "MKB + processing key",
KeySource::DeviceKey => "MKB + device key",
}
}
}
// ─── Disc scanning ──────────────────────────────────────────────────────────
/// Standard KEYDB.cfg search locations (compatible with libaacs).
const KEYDB_SEARCH_PATHS: &[&str] = &[
".config/aacs/KEYDB.cfg", // relative to $HOME
];
const KEYDB_SYSTEM_PATH: &str = "/etc/aacs/KEYDB.cfg";
/// Options for disc scanning.
pub struct ScanOptions {
/// Path to KEYDB.cfg for AACS key lookup.
/// If None, searches standard locations ($HOME/.config/aacs/ and /etc/aacs/).
pub keydb_path: Option<std::path::PathBuf>,
}
impl Default for ScanOptions {
fn default() -> Self {
ScanOptions { keydb_path: None }
}
}
impl ScanOptions {
/// Create options with a specific KEYDB path.
pub fn with_keydb(path: impl Into<std::path::PathBuf>) -> Self {
ScanOptions { keydb_path: Some(path.into()) }
}
/// Resolve KEYDB path: explicit path first, then standard locations.
fn resolve_keydb(&self) -> Option<std::path::PathBuf> {
if let Some(p) = &self.keydb_path {
if p.exists() { return Some(p.clone()); }
}
if let Some(home) = std::env::var_os("HOME") {
for relative in KEYDB_SEARCH_PATHS {
let p = std::path::PathBuf::from(&home).join(relative);
if p.exists() { return Some(p); }
}
}
let p = std::path::PathBuf::from(KEYDB_SYSTEM_PATH);
if p.exists() { return Some(p); }
None
}
}
impl Disc {
/// Disc capacity in GB
pub fn capacity_gb(&self) -> f64 {
self.capacity_sectors as f64 * 2048.0 / (1024.0 * 1024.0 * 1024.0)
}
/// Scan a disc — parse filesystem, playlists, streams, and set up AACS decryption.
///
/// This is the main entry point. After scan(), the Disc is ready:
/// - titles are populated with streams
/// - AACS keys are derived (if KEYDB available)
/// - content can be read and decrypted transparently
///
/// ```no_run
/// use libfreemkv::{DriveSession, Disc};
/// use libfreemkv::disc::ScanOptions;
/// use std::path::Path;
///
/// let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
/// let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
/// for title in &disc.titles {
/// println!("{} — {} streams", title.duration_display(), title.streams.len());
/// }
/// ```
/// Scan a disc. One pipeline, one order:
/// 1. Read capacity
/// 2. Read UDF filesystem
/// 3. Resolve AACS keys (all via UDF, no SCSI commands)
/// 4. Parse playlists + streams
/// 5. Apply labels
///
/// 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> {
use crate::aacs::{self, KeyDb};
// 1. Capacity
let capacity = Self::read_capacity(session)?;
// 2. UDF filesystem
let udf_fs = udf::read_filesystem(session)?;
// 3. AACS — read files from disc via UDF, resolve keys via KEYDB
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()
} else {
None
}
} else {
None
};
// 4. 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) {
titles.push(title);
}
}
}
}
}
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);
// 6. 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 };
Ok(Disc {
volume_id: udf_fs.volume_id.clone(),
meta_title,
format,
capacity_sectors: capacity,
capacity_bytes: capacity as u64 * 2048,
layers,
titles,
region,
aacs,
encrypted,
})
}
/// 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> {
use crate::aacs::{self, KeyDb};
let keydb = KeyDb::load(keydb_path).map_err(|e| Error::AacsError {
detail: format!("failed to load KEYDB: {}", e),
})?;
// 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::AacsError {
detail: "Unit_Key_RO.inf not found on disc".into(),
})?;
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);
// Resolve: disc hash → KEYDB lookup → VUK → unit keys
let vid_zero = [0u8; 16];
let resolved = aacs::resolve_keys(
&uk_ro_data,
cc_data.as_deref(),
&vid_zero,
&keydb,
mkb_data.as_deref(),
).ok_or_else(|| Error::AacsError {
detail: "disc not in KEYDB".into(),
})?;
Ok(AacsState {
version: if resolved.aacs2 { 2 } else { 1 },
bus_encryption: resolved.bus_encryption,
mkb_version: mkb_ver,
disc_hash: aacs::disc_hash_hex(&resolved.disc_hash),
key_source: match resolved.key_source {
1 => KeySource::KeyDb,
2 => KeySource::KeyDbDerived,
3 => KeySource::ProcessingKey,
4 => KeySource::DeviceKey,
_ => KeySource::KeyDb,
},
vuk: resolved.vuk,
unit_keys: resolved.unit_keys,
read_data_key: None,
volume_id: [0u8; 16],
})
}
// ── Internal helpers ────────────────────────────────────────────────────
/// Detect disc format from the main title's video streams.
fn detect_format(titles: &[Title]) -> DiscFormat {
for title in titles.iter().take(3) {
for stream in &title.streams {
if let Stream::Video(v) = stream {
if v.resolution.contains("2160") {
return DiscFormat::Uhd;
}
if v.resolution.contains("1080") || v.resolution.contains("720") {
return DiscFormat::BluRay;
}
if v.resolution.contains("480") || v.resolution.contains("576") {
return DiscFormat::Dvd;
}
}
}
}
DiscFormat::Unknown
}
/// 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> {
let meta_dir = udf_fs.find_dir("/BDMV/META")?;
for sub in &meta_dir.entries {
if !sub.is_dir { continue; }
let dl_path = format!("/BDMV/META/{}", sub.name);
if let Some(dl_dir) = udf_fs.find_dir(&dl_path) {
let xml_files: Vec<_> = dl_dir.entries.iter()
.filter(|e| !e.is_dir && e.name.to_lowercase().ends_with(".xml"))
.collect();
let eng = xml_files.iter().find(|e| e.name.to_lowercase().contains("eng"));
let target = eng.or_else(|| xml_files.first());
if let Some(entry) = target {
let path = format!("{}/{}", dl_path, entry.name);
if let Ok(data) = udf_fs.read_file(session, &path) {
let xml = String::from_utf8_lossy(&data);
if let Some(start) = xml.find("<di:name>") {
let s = start + "<di:name>".len();
if let Some(end) = xml[s..].find("</di:name>") {
let title = xml[s..s + end].trim().to_string();
if !title.is_empty() && title != "Blu-ray" {
return Some(title);
}
}
}
}
}
}
}
None
}
fn read_capacity(session: &mut DriveSession) -> Result<u32> {
let cdb = [0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
let mut buf = [0u8; 8];
session.scsi_execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000)?;
let lba = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
Ok(lba + 1)
}
fn parse_playlist(
session: &mut DriveSession,
udf_fs: &udf::UdfFs,
filename: &str,
data: &[u8],
) -> Option<Title> {
let parsed = mpls::parse(data).ok()?;
// Calculate duration from play items
let duration_ticks: u64 = parsed.play_items.iter()
.map(|pi| (pi.out_time.saturating_sub(pi.in_time)) as u64)
.sum();
let duration_secs = duration_ticks as f64 / 45000.0;
// Skip very short playlists (< 30 seconds)
if duration_secs < 30.0 {
return None;
}
// Parse each clip for size, duration, and sector extents
let mut extents = Vec::new();
let mut total_size: u64 = 0;
let mut clips = Vec::with_capacity(parsed.play_items.len());
for play_item in &parsed.play_items {
let clip_dur = play_item.out_time.saturating_sub(play_item.in_time) as f64 / 45000.0;
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(clip_info) = clpi::parse(&clpi_data) {
pkt_count = clip_info.source_packet_count;
total_size += pkt_count as u64 * 192;
// 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 total_bytes = pkt_count as u64 * 192;
let total_sectors = ((total_bytes + 2047) / 2048) as u32;
if total_sectors > 0 && file_lba > 0 {
extents.push(Extent {
start_lba: file_lba,
sector_count: total_sectors,
});
}
}
}
clips.push(Clip {
clip_id: play_item.clip_id.clone(),
in_time: play_item.in_time,
out_time: play_item.out_time,
duration_secs: clip_dur,
source_packets: pkt_count,
});
}
// Build streams from STN table
let streams: Vec<Stream> = parsed.streams.iter().map(|s| {
let codec = Codec::from_coding_type(s.coding_type);
match s.stream_type {
1 | 6 | 7 => Stream::Video(VideoStream {
pid: s.pid,
codec,
resolution: format_resolution(s.video_format, s.video_rate),
frame_rate: format_framerate(s.video_rate),
hdr: match s.dynamic_range {
1 => HdrFormat::Hdr10,
2 => HdrFormat::DolbyVision,
_ => HdrFormat::Sdr,
},
color_space: match s.color_space {
1 => ColorSpace::Bt709,
2 => ColorSpace::Bt2020,
_ => ColorSpace::Unknown,
},
secondary: s.secondary,
label: match s.stream_type {
7 => "Dolby Vision EL".to_string(),
_ => String::new(),
},
}),
2 | 5 => Stream::Audio(AudioStream {
pid: s.pid,
codec,
channels: format_channels(s.audio_format),
language: s.language.clone(),
sample_rate: format_samplerate(s.audio_rate),
secondary: s.stream_type == 5,
label: String::new(),
}),
3 => Stream::Subtitle(SubtitleStream {
pid: s.pid,
codec,
language: s.language.clone(),
forced: false, // TODO: parse from MPLS stream attributes
}),
_ => Stream::Video(VideoStream {
pid: s.pid,
codec,
resolution: String::new(),
frame_rate: String::new(),
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Unknown,
secondary: false,
label: String::new(),
}),
}
}).collect();
let playlist_num = filename.trim_end_matches(".mpls").trim_end_matches(".MPLS");
let playlist_id = playlist_num.parse::<u16>().unwrap_or(0);
Some(Title {
playlist: filename.to_string(),
playlist_id,
duration_secs,
size_bytes: total_size,
clips,
streams,
extents,
})
}
}
// ─── Decrypted reader ──────────────────────────────────────────────────────
/// A reader that reads m2ts content, decrypting transparently if needed.
///
/// Adaptive read strategy:
/// - Starts at max batch size (510 sectors ≈ 1MB) and full disc speed
/// - On read error: halves batch size, brief pause for drive recovery
/// - On repeated errors: reduces disc spin speed (scratched region)
/// - 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,
aacs: Option<&'a AacsState>,
extents: Vec<Extent>,
current_extent: usize,
current_offset: u32,
unit_key_idx: usize,
read_buf: Vec<u8>,
buf_pos: usize,
buf_len: usize,
/// Current batch size in sectors (adapts on errors)
batch_sectors: u16,
/// Consecutive successful batch reads
ok_streak: u32,
/// Consecutive errors at current position
error_streak: u32,
/// Current speed tier index (0 = max, higher = slower)
speed_tier: usize,
/// Last time maintain_speed was called
last_speed_maintain: std::time::Instant,
/// Total read errors encountered
pub errors: u32,
}
impl Disc {
/// Open a title for reading. Decryption is automatic — if the disc
/// is encrypted and keys were found during scan(), content is decrypted
/// on the fly. Unencrypted discs pass through unchanged.
///
/// ```no_run
/// # use libfreemkv::{DriveSession, Disc};
/// # use libfreemkv::disc::ScanOptions;
/// # use std::path::Path;
/// # let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
/// let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
/// let mut reader = disc.open_title(&mut session, 0).unwrap();
/// while let Some(unit) = reader.read_unit().unwrap() {
/// // unit is 6144 bytes of decrypted content
/// }
/// ```
pub fn open_title<'a>(&'a self, session: &'a mut DriveSession, title_idx: usize) -> Result<ContentReader<'a>> {
let title = self.titles.get(title_idx).ok_or_else(|| Error::DiscError {
detail: format!("title index {} out of range (have {})", title_idx, self.titles.len()),
})?;
// Try to unlock for raw reads. Non-fatal — BD discs work without unlock.
if !session.is_unlocked() {
let _ = session.unlock();
}
// Set max read speed — nothing else. No calibration, no probes.
let speed_cdb = crate::scsi::build_set_cd_speed(0xFFFF);
let mut dummy = [0u8; 0];
let _ = session.scsi_execute(&speed_cdb, crate::scsi::DataDirection::None, &mut dummy, 5_000);
Ok(ContentReader {
session,
aacs: self.aacs.as_ref(),
extents: title.extents.clone(),
current_extent: 0,
current_offset: 0,
unit_key_idx: 0,
read_buf: Vec::with_capacity(MAX_BATCH_SECTORS as usize * 2048),
buf_pos: 0,
buf_len: 0,
batch_sectors: MAX_BATCH_SECTORS,
ok_streak: 0,
error_streak: 0,
speed_tier: 0,
last_speed_maintain: std::time::Instant::now(),
errors: 0,
})
}
}
/// Detect the maximum transfer size in sectors for a device.
/// Reads /sys/block/<dev>/queue/max_hw_sectors_kb on Linux.
/// Returns a value aligned to 3 sectors (one aligned unit).
fn detect_max_batch_sectors(device_path: &str) -> u16 {
// Extract block device name: /dev/sr0 → sr0
let dev_name = device_path.rsplit('/').next().unwrap_or("");
if !dev_name.is_empty() {
let sysfs_path = format!("/sys/block/{}/queue/max_hw_sectors_kb", dev_name);
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 on disc = 2048 bytes)
let sectors = (kb / 2) as u16;
// Align down to 3 (one aligned unit) and cap at a reasonable max
let aligned = (sectors / 3) * 3;
if aligned >= MIN_BATCH_SECTORS {
return aligned.min(MAX_BATCH_SECTORS);
}
}
}
}
// Fallback: conservative default
MAX_BATCH_SECTORS
}
/// Read strategy constants
const MAX_BATCH_SECTORS: u16 = 510; // 170 aligned units ≈ 1MB (kernel caps to hw limit)
const MIN_BATCH_SECTORS: u16 = 3; // 1 aligned unit = 6KB (error recovery)
const RAMP_BATCH_AFTER: u32 = 5; // successes before doubling batch size
const RAMP_SPEED_AFTER: u32 = 50; // successes at max batch before restoring speed
const SLOW_SPEED_AFTER: u32 = 3; // consecutive errors before reducing disc speed
/// Disc speed tiers (KB/s for SET CD SPEED).
/// Blu-ray: 1x=4500, 2x=9000, 4x=18000, 8x=36000, 12x=54000
const SPEED_TIERS: &[u16] = &[
0xFFFF, // tier 0: max (drive decides, typically 8-12x)
36000, // tier 1: 8x BD (~36 MB/s)
18000, // tier 2: 4x BD (~18 MB/s)
9000, // tier 3: 2x BD (~9 MB/s)
4500, // tier 4: 1x BD (~4.5 MB/s) — last resort
];
impl<'a> ContentReader<'a> {
/// Total bytes across all extents (for progress display).
pub fn total_bytes(&self) -> u64 {
self.extents.iter().map(|e| e.sector_count as u64 * 2048).sum()
}
/// Read the next aligned unit (6144 bytes).
/// Automatically decrypted if AACS keys are available.
/// Returns None when all extents are exhausted.
pub fn read_unit(&mut self) -> Result<Option<Vec<u8>>> {
// Refill buffer if empty
if self.buf_pos >= self.buf_len {
if !self.fill_buffer()? {
return Ok(None);
}
}
// Extract one aligned unit from buffer
let start = self.buf_pos * crate::aacs::ALIGNED_UNIT_LEN;
let end = start + crate::aacs::ALIGNED_UNIT_LEN;
let mut unit = self.read_buf[start..end].to_vec();
// Decrypt if needed
self.decrypt_unit(&mut unit);
self.buf_pos += 1;
Ok(Some(unit))
}
/// Read the next batch of aligned units, decrypted in-place.
/// Returns the decrypted data as a single contiguous slice.
/// More efficient than read_unit() — one write_all() per batch instead of per unit.
/// Returns None when all extents are exhausted.
pub fn read_batch(&mut self) -> Result<Option<&[u8]>> {
if !self.fill_buffer()? {
return Ok(None);
}
// Decrypt all units in the buffer in-place
let unit_len = crate::aacs::ALIGNED_UNIT_LEN;
if let Some(aacs) = &self.aacs {
let uk = aacs.unit_keys.get(self.unit_key_idx)
.map(|(_, k)| *k)
.unwrap_or([0u8; 16]);
let rdk = aacs.read_data_key.as_ref();
for i in 0..self.buf_len {
let start = i * unit_len;
let end = start + unit_len;
let unit = &mut self.read_buf[start..end];
if crate::aacs::is_unit_encrypted(unit) {
crate::aacs::decrypt_unit_full(unit, &uk, rdk);
}
}
}
let total_bytes = self.buf_len * unit_len;
self.buf_pos = self.buf_len; // mark fully consumed
Ok(Some(&self.read_buf[..total_bytes]))
}
/// Decrypt a single aligned unit in-place if needed.
fn decrypt_unit(&self, unit: &mut [u8]) {
if let Some(aacs) = &self.aacs {
if crate::aacs::is_unit_encrypted(unit) {
let uk = aacs.unit_keys.get(self.unit_key_idx)
.map(|(_, k)| *k)
.unwrap_or([0u8; 16]);
crate::aacs::decrypt_unit_full(
unit,
&uk,
aacs.read_data_key.as_ref(),
);
}
}
}
/// Read sectors via standard READ(10) 0x00.
/// calibration primers. Standard reads are faster on most drives.
fn read_sectors(&mut self, lba: u32, count: u16) -> Result<()> {
self.session.read_content(lba, count, &mut self.read_buf)?;
Ok(())
}
/// Set disc spin speed via SCSI SET CD SPEED.
fn set_speed(&mut self, tier: usize) {
let tier = tier.min(SPEED_TIERS.len() - 1);
if tier != self.speed_tier {
self.speed_tier = tier;
let speed_kbs = SPEED_TIERS[tier];
let cdb = crate::scsi::build_set_cd_speed(speed_kbs);
let mut dummy = [0u8; 0];
let _ = self.session.scsi_execute(
&cdb, crate::scsi::DataDirection::None, &mut dummy, 5_000,
);
}
}
/// Read a batch of sectors into the internal buffer.
///
/// Adaptive strategy:
/// 1. Read at current batch size
/// 2. On success: ramp batch up (double after 5 successes),
/// then restore disc speed (after 50 at max batch)
/// 3. On error: halve batch, pause. After 3 consecutive errors,
/// also reduce disc spin speed (scratched/damaged region).
/// 4. At min batch + still failing: retry once, then skip + zero-fill.
fn fill_buffer(&mut self) -> Result<bool> {
loop {
if self.current_extent >= self.extents.len() {
return Ok(false);
}
let ext_start = self.extents[self.current_extent].start_lba;
let ext_sectors = self.extents[self.current_extent].sector_count;
let remaining = ext_sectors - self.current_offset;
// Align to 3 sectors (one aligned unit)
let sectors_to_read = remaining.min(self.batch_sectors as u32) as u16;
let sectors_to_read = sectors_to_read - (sectors_to_read % 3);
if sectors_to_read == 0 {
self.current_extent += 1;
self.current_offset = 0;
continue;
}
let lba = ext_start + self.current_offset;
let byte_count = sectors_to_read as usize * 2048;
self.read_buf.resize(byte_count, 0);
match self.read_sectors(lba, sectors_to_read) {
Ok(_) => {
self.buf_len = sectors_to_read as usize / 3;
self.buf_pos = 0;
self.current_offset += sectors_to_read as u32;
self.error_streak = 0;
if self.current_offset >= ext_sectors {
self.current_extent += 1;
self.current_offset = 0;
}
// Ramp up: batch size first, then disc speed
self.ok_streak += 1;
if self.batch_sectors < MAX_BATCH_SECTORS {
if self.ok_streak >= RAMP_BATCH_AFTER {
self.batch_sectors = (self.batch_sectors * 2).min(MAX_BATCH_SECTORS);
self.ok_streak = 0;
}
} else if self.speed_tier > 0 && self.ok_streak >= RAMP_SPEED_AFTER {
// At max batch for a while — try faster disc speed
self.set_speed(self.speed_tier - 1);
self.ok_streak = 0;
}
return Ok(true);
}
Err(_) => {
self.errors += 1;
self.error_streak += 1;
self.ok_streak = 0;
// Reduce disc speed after repeated errors (physical problem)
if self.error_streak >= SLOW_SPEED_AFTER
&& self.speed_tier < SPEED_TIERS.len() - 1
{
self.set_speed(self.speed_tier + 1);
self.error_streak = 0; // reset — give new speed a chance
}
if self.batch_sectors > MIN_BATCH_SECTORS {
// Shrink batch and retry
self.batch_sectors = (self.batch_sectors / 2).max(MIN_BATCH_SECTORS);
std::thread::sleep(std::time::Duration::from_millis(100));
} else {
// At minimum batch — retry once with longer pause
std::thread::sleep(std::time::Duration::from_millis(500));
self.read_buf.resize(MIN_BATCH_SECTORS as usize * 2048, 0);
if self.read_sectors(lba, MIN_BATCH_SECTORS).is_ok() {
self.buf_len = 1;
self.buf_pos = 0;
self.error_streak = 0;
self.current_offset += MIN_BATCH_SECTORS as u32;
if self.current_offset >= ext_sectors {
self.current_extent += 1;
self.current_offset = 0;
}
return Ok(true);
}
// Still failing — skip this unit (zero-fill)
self.current_offset += 3;
if self.current_offset >= ext_sectors {
self.current_extent += 1;
self.current_offset = 0;
}
self.read_buf.resize(crate::aacs::ALIGNED_UNIT_LEN, 0);
self.read_buf.fill(0);
self.buf_len = 1;
self.buf_pos = 0;
return Ok(true);
}
}
}
}
}
}
// ─── Format helpers ────────────────────────────────────────────────────────
fn format_resolution(video_format: u8, _video_rate: u8) -> String {
match video_format {
1 => "480i".into(),
2 => "576i".into(),
3 => "480p".into(),
4 => "1080i".into(),
5 => "720p".into(),
6 => "1080p".into(),
7 => "576p".into(),
8 => "2160p".into(),
_ => String::new(),
}
}
fn format_framerate(video_rate: u8) -> String {
match video_rate {
1 => "23.976".into(),
2 => "24".into(),
3 => "25".into(),
4 => "29.97".into(),
6 => "50".into(),
7 => "59.94".into(),
_ => String::new(),
}
}
fn format_channels(audio_format: u8) -> String {
match audio_format {
1 => "mono".into(),
3 => "stereo".into(),
6 => "5.1".into(),
12 => "7.1".into(),
_ if audio_format > 0 => format!("{}ch", audio_format),
_ => String::new(),
}
}
fn format_samplerate(audio_rate: u8) -> String {
match audio_rate {
1 => "48kHz".into(),
4 => "96kHz".into(),
5 => "192kHz".into(),
12 => "48/192kHz".into(),
14 => "48/96kHz".into(),
_ => String::new(),
}
}