Files
libfreemkv/src/disc/mod.rs
T
MattJackson a9195824ff Trim MKB to real record length; empty unit-keys means no keys
- mkb_content_len walks the MKB record stream and returns where it ends,
  so callers can drop the trailing zero padding. MKB files are allocated
  to a fixed size (~128 MiB) with records at the front; both MKB_RO and
  MKB_RW can be padded. read_aacs_inputs* now trim to the real records
  (~few MB) instead of shipping ~128 MiB of zeros.
- decrypt_keys() returns None when unit_keys is empty: an AACS state that
  carries only a VID (out-of-band resolution, pre-key) is 'encrypted, no
  keys', not a usable empty key set.
2026-06-02 15:41:27 -07:00

2910 lines
106 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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() { ... }
mod bluray;
mod dvd;
mod encrypt;
pub mod mapfile;
mod patch;
pub mod read_error;
mod sweep;
use crate::drive::{Drive, extract_scsi_context};
use crate::error::{Error, Result};
use crate::sector::SectorSource;
use crate::udf;
use encrypt::HandshakeResult;
// Re-export label classification enums alongside AudioStream / SubtitleStream
// so the public surface keeps the structured metadata together. Callers map
// these to display text in their own locale.
pub use crate::labels::{LabelPurpose, LabelQualifier};
// ─── 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<DiscTitle>,
/// Disc region
pub region: DiscRegion,
/// AACS state -- None if disc is unencrypted or keys unavailable
pub aacs: Option<AacsState>,
/// CSS state -- None if not a CSS-encrypted DVD
pub css: Option<crate::css::CssState>,
/// Whether this disc requires decryption (AACS or CSS)
pub encrypted: bool,
/// AACS resolution error when `encrypted` is true and `aacs` is None.
/// Lets callers distinguish "no KEYDB found", "KEYDB failed to parse",
/// "disc hash not in KEYDB", etc. None when AACS resolution wasn't
/// attempted (unencrypted disc) or succeeded.
pub aacs_error: Option<crate::error::Error>,
/// Content format (BD transport stream vs DVD program stream)
pub content_format: ContentFormat,
}
/// Content format — determines how sectors are interpreted downstream.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ContentFormat {
/// Blu-ray BD Transport Stream (192-byte packets)
BdTs,
/// DVD MPEG-2 Program Stream (VOB)
MpegPs,
}
/// 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 DiscTitle {
/// 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>,
/// Chapter points
pub chapters: Vec<Chapter>,
/// Sector extents for ripping (clip LBA ranges)
pub extents: Vec<Extent>,
/// Content format for this title
pub content_format: ContentFormat,
/// Codec initialization data per stream (SPS/PPS, etc).
/// Index matches `streams`. None for streams without codec init data.
pub codec_privates: Vec<Option<Vec<u8>>>,
}
/// 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
pub resolution: Resolution,
/// Frame rate
pub frame_rate: FrameRate,
/// 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
pub channels: AudioChannels,
/// ISO 639-2 language code (e.g. "eng", "fra")
pub language: String,
/// Sample rate
pub sample_rate: SampleRate,
/// Whether this is a secondary stream (commentary)
pub secondary: bool,
/// Stream purpose (commentary / descriptive / score / IME / normal).
/// Callers translate this to display text in their own locale.
pub purpose: LabelPurpose,
/// Codec / variant text (e.g. "Dolby TrueHD 5.1", "(US)").
/// NEVER contains English purpose words — see `purpose` for that.
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,
/// Subtitle qualifier (SDH / descriptive service / forced / none).
/// Callers translate this to display text in their own locale.
pub qualifier: LabelQualifier,
/// Pre-formatted codec private data (e.g. VobSub .idx palette header)
pub codec_data: Option<Vec<u8>>,
}
/// Video/audio codec.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Codec {
// Video
Hevc,
H264,
Vc1,
Mpeg2,
Mpeg1,
Av1,
// Audio
TrueHd,
DtsHdMa,
DtsHdHr,
Dts,
Ac3,
Ac3Plus,
Lpcm,
Aac,
Mp2,
Mp3,
Flac,
Opus,
// Subtitle
Pgs,
DvdSub,
Srt,
Ssa,
// Unknown
Unknown(u8),
}
/// Video resolution.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Resolution {
/// 480i (720x480 interlaced) — NTSC DVD
R480i,
/// 480p (720x480 progressive)
R480p,
/// 576i (720x576 interlaced) — PAL DVD
R576i,
/// 576p (720x576 progressive)
R576p,
/// 720p (1280x720 progressive) — some Blu-rays
R720p,
/// 1080i (1920x1080 interlaced) — broadcast, some BD
R1080i,
/// 1080p (1920x1080 progressive) — standard Blu-ray
R1080p,
/// 2160p (3840x2160 progressive) — 4K UHD Blu-ray
R2160p,
/// 4320p (7680x4320 progressive) — 8K, future-proof
R4320p,
/// Unknown resolution
Unknown,
}
/// Video frame rate.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum FrameRate {
/// 23.976 fps — film-based BD/UHD (NTSC pulldown)
F23_976,
/// 24.000 fps — true film rate
F24,
/// 25.000 fps — PAL standard
F25,
/// 29.970 fps — NTSC standard
F29_97,
/// 30.000 fps
F30,
/// 50.000 fps — PAL high frame rate
F50,
/// 59.940 fps — NTSC high frame rate
F59_94,
/// 60.000 fps
F60,
/// Unknown frame rate
Unknown,
}
/// Audio channel layout.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioChannels {
/// 1.0 mono
Mono,
/// 2.0 stereo
Stereo,
/// 2.1 (stereo + LFE)
Stereo21,
/// 4.0 quadraphonic
Quad,
/// 5.0 surround (no LFE)
Surround50,
/// 5.1 surround — standard BD/DVD surround
Surround51,
/// 6.1 surround (DTS-ES, Dolby EX)
Surround61,
/// 7.1 surround — UHD Atmos beds, DTS:X
Surround71,
/// Unknown channel layout
Unknown,
}
/// Audio sample rate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SampleRate {
/// 44.1 kHz — CD audio (rare on disc)
S44_1,
/// 48 kHz — standard BD/DVD/UHD audio
S48,
/// 96 kHz — high-res BD audio
S96,
/// 192 kHz — highest BD audio (LPCM)
S192,
/// 48/96 kHz combo (secondary audio resampled)
S48_96,
/// 48/192 kHz combo (secondary audio resampled)
S48_192,
/// Unknown sample rate
Unknown,
}
/// HDR format.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum HdrFormat {
Sdr,
Hdr10,
Hdr10Plus,
DolbyVision,
Hlg,
}
/// Color space.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ColorSpace {
Bt709,
Bt2020,
Unknown,
}
/// A chapter point within a title.
#[derive(Debug, Clone)]
pub struct Chapter {
/// Chapter start time in seconds
pub time_secs: f64,
/// Chapter name (e.g. "Chapter 1", "Chapter 2")
pub name: String,
}
/// A contiguous range of sectors on disc.
#[derive(Debug, Clone, Copy)]
pub struct Extent {
pub start_lba: u32,
pub sector_count: u32,
}
/// Calculate how many bytes of bad/unreadable data fall within a title's extents.
/// `pub(crate)` so autorip can use it for main-movie lost_ms computation.
pub fn bytes_bad_in_title(title: &DiscTitle, bad_ranges: &[(u64, u64)]) -> u64 {
if bad_ranges.is_empty() || title.extents.is_empty() {
return 0;
}
let t_start = title.extents.first().map(|e| (e.start_lba as u64) * 2048);
let t_end = title
.extents
.last()
.map(|e| ((e.start_lba as u64) + (e.sector_count as u64)) * 2048);
let (Some(ts), Some(te)) = (t_start, t_end) else {
return 0;
};
bad_ranges
.iter()
.map(|(pos, size)| {
let r_start = *pos;
let r_end = *pos + *size;
let overlap_start = r_start.max(ts);
let overlap_end = r_end.min(te);
overlap_end.saturating_sub(overlap_start)
})
.sum()
}
// ─── Display helpers ────────────────────────────────────────────────────────
impl Codec {
/// Human-readable display name.
pub fn name(&self) -> &'static str {
for (_, name, v) in Self::ALL_CODECS {
if v == self {
return name;
}
}
"Unknown"
}
/// Compact identifier for serialization (lowercase, no spaces).
pub fn id(&self) -> &'static str {
for (id, _, v) in Self::ALL_CODECS {
if v == self {
return id;
}
}
"unknown"
}
const ALL_CODECS: &[(&'static str, &'static str, Codec)] = &[
("hevc", "HEVC", Codec::Hevc),
("h264", "H.264", Codec::H264),
("vc1", "VC-1", Codec::Vc1),
("mpeg2", "MPEG-2", Codec::Mpeg2),
("mpeg1", "MPEG-1", Codec::Mpeg1),
("av1", "AV1", Codec::Av1),
("truehd", "TrueHD", Codec::TrueHd),
("dtshd_ma", "DTS-HD MA", Codec::DtsHdMa),
("dtshd_hr", "DTS-HD HR", Codec::DtsHdHr),
("dts", "DTS", Codec::Dts),
("ac3", "AC-3", Codec::Ac3),
("eac3", "EAC-3", Codec::Ac3Plus),
("lpcm", "LPCM", Codec::Lpcm),
("aac", "AAC", Codec::Aac),
("mp2", "MP2", Codec::Mp2),
("mp3", "MP3", Codec::Mp3),
("flac", "FLAC", Codec::Flac),
("opus", "Opus", Codec::Opus),
("pgs", "PGS", Codec::Pgs),
("dvdsub", "DVD Subtitle", Codec::DvdSub),
("srt", "SRT", Codec::Srt),
("ssa", "SSA", Codec::Ssa),
];
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 std::fmt::Display for Codec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
impl Resolution {
/// Parse from MPLS video_format byte.
pub fn from_video_format(vf: u8) -> Self {
match vf {
1 => Resolution::R480i,
2 => Resolution::R576i,
3 => Resolution::R480p,
4 => Resolution::R1080i,
5 => Resolution::R720p,
6 => Resolution::R1080p,
7 => Resolution::R576p,
8 => Resolution::R2160p,
_ => Resolution::Unknown,
}
}
/// Pixel dimensions (width, height).
pub fn pixels(&self) -> (u32, u32) {
match self {
Resolution::R480i | Resolution::R480p => (720, 480),
Resolution::R576i | Resolution::R576p => (720, 576),
Resolution::R720p => (1280, 720),
Resolution::R1080i | Resolution::R1080p => (1920, 1080),
Resolution::R2160p => (3840, 2160),
Resolution::R4320p => (7680, 4320),
Resolution::Unknown => (1920, 1080),
}
}
/// True if this is a UHD (4K+) resolution.
pub fn is_uhd(&self) -> bool {
matches!(self, Resolution::R2160p | Resolution::R4320p)
}
/// True if this is an HD (720p+) resolution.
pub fn is_hd(&self) -> bool {
!matches!(
self,
Resolution::R480i
| Resolution::R480p
| Resolution::R576i
| Resolution::R576p
| Resolution::Unknown
)
}
/// True if this is an SD (480/576) resolution.
pub fn is_sd(&self) -> bool {
matches!(
self,
Resolution::R480i | Resolution::R480p | Resolution::R576i | Resolution::R576p
)
}
/// Parse from pixel height (e.g. from MKV track).
pub fn from_height(h: u32) -> Self {
match h {
0..=480 => Resolution::R480p,
481..=576 => Resolution::R576p,
577..=720 => Resolution::R720p,
721..=1080 => Resolution::R1080p,
1081..=2160 => Resolution::R2160p,
_ => Resolution::R4320p,
}
}
}
// Display for Resolution is generated by enum_str! macro
impl FrameRate {
/// Parse from MPLS video_rate byte.
pub fn from_video_rate(vr: u8) -> Self {
match vr {
1 => FrameRate::F23_976,
2 => FrameRate::F24,
3 => FrameRate::F25,
4 => FrameRate::F29_97,
5 => FrameRate::F30,
6 => FrameRate::F50,
7 => FrameRate::F59_94,
8 => FrameRate::F60,
_ => FrameRate::Unknown,
}
}
/// Frame rate as (numerator, denominator) for precise representation.
pub fn as_fraction(&self) -> (u32, u32) {
match self {
FrameRate::F23_976 => (24000, 1001),
FrameRate::F24 => (24, 1),
FrameRate::F25 => (25, 1),
FrameRate::F29_97 => (30000, 1001),
FrameRate::F30 => (30, 1),
FrameRate::F50 => (50, 1),
FrameRate::F59_94 => (60000, 1001),
FrameRate::F60 => (60, 1),
FrameRate::Unknown => (0, 1),
}
}
}
// Display for FrameRate is generated by enum_str! macro
impl AudioChannels {
/// Parse from MPLS audio_format byte.
pub fn from_audio_format(af: u8) -> Self {
match af {
1 => AudioChannels::Mono,
3 => AudioChannels::Stereo,
6 => AudioChannels::Surround51,
12 => AudioChannels::Surround71,
_ if af > 0 => AudioChannels::Unknown,
_ => AudioChannels::Unknown,
}
}
/// Channel count as a number.
pub fn count(&self) -> u8 {
match self {
AudioChannels::Mono => 1,
AudioChannels::Stereo => 2,
AudioChannels::Stereo21 => 3,
AudioChannels::Quad => 4,
AudioChannels::Surround50 => 5,
AudioChannels::Surround51 => 6,
AudioChannels::Surround61 => 7,
AudioChannels::Surround71 => 8,
AudioChannels::Unknown => 6,
}
}
/// Parse from channel count number.
pub fn from_count(n: u8) -> Self {
match n {
1 => AudioChannels::Mono,
2 => AudioChannels::Stereo,
3 => AudioChannels::Stereo21,
4 => AudioChannels::Quad,
5 => AudioChannels::Surround50,
6 => AudioChannels::Surround51,
7 => AudioChannels::Surround61,
8 => AudioChannels::Surround71,
_ => AudioChannels::Unknown,
}
}
}
// Display for AudioChannels is generated by enum_str! macro
impl SampleRate {
/// Parse from MPLS audio_rate byte.
pub fn from_audio_rate(ar: u8) -> Self {
match ar {
1 => SampleRate::S48,
4 => SampleRate::S96,
5 => SampleRate::S192,
12 => SampleRate::S48_192,
14 => SampleRate::S48_96,
_ => SampleRate::Unknown,
}
}
/// Sample rate in Hz (primary rate for combo rates).
pub fn hz(&self) -> f64 {
match self {
SampleRate::S44_1 => 44100.0,
SampleRate::S48 | SampleRate::S48_96 | SampleRate::S48_192 => 48000.0,
SampleRate::S96 => 96000.0,
SampleRate::S192 => 192000.0,
SampleRate::Unknown => 48000.0,
}
}
/// Parse from Hz value.
pub fn from_hz(hz: u32) -> Self {
match hz {
44100 => SampleRate::S44_1,
48000 => SampleRate::S48,
96000 => SampleRate::S96,
192000 => SampleRate::S192,
_ => SampleRate::Unknown,
}
}
}
// Display for SampleRate is generated by enum_str! macro
impl HdrFormat {
pub fn name(&self) -> &'static str {
match self {
HdrFormat::Sdr => "SDR",
HdrFormat::Hdr10 => "HDR10",
HdrFormat::Hdr10Plus => "HDR10+",
HdrFormat::DolbyVision => "Dolby Vision",
HdrFormat::Hlg => "HLG",
}
}
const ALL_HDR: &[(&'static str, HdrFormat)] = &[
("sdr", HdrFormat::Sdr),
("hdr10", HdrFormat::Hdr10),
("hdr10+", HdrFormat::Hdr10Plus),
("dv", HdrFormat::DolbyVision),
("hlg", HdrFormat::Hlg),
];
/// Compact identifier for serialization.
pub fn id(&self) -> &'static str {
for (id, v) in Self::ALL_HDR {
if v == self {
return id;
}
}
"sdr"
}
}
impl std::fmt::Display for HdrFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
impl ColorSpace {
pub fn name(&self) -> &'static str {
match self {
ColorSpace::Bt709 => "BT.709",
ColorSpace::Bt2020 => "BT.2020",
ColorSpace::Unknown => "",
}
}
}
impl std::fmt::Display for ColorSpace {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
// ─── FromStr impls — single source of truth via ALL_* arrays ───────────────
//
// Each enum defines a const array of (str, variant) pairs. Display, FromStr,
// and id() all derive from this one table — no string appears twice.
macro_rules! enum_str {
($name:ident, $default:expr, [ $( ($s:expr, $v:expr) ),* $(,)? ]) => {
impl $name {
const ALL: &[(&'static str, $name)] = &[ $( ($s, $v), )* ];
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for (s, v) in $name::ALL {
if v == self { return f.write_str(s); }
}
f.write_str("")
}
}
impl std::str::FromStr for $name {
type Err = ();
fn from_str(s: &str) -> std::result::Result<Self, ()> {
for (k, v) in $name::ALL {
if *k == s { return Ok(*v); }
}
Ok($default)
}
}
};
}
enum_str!(
Resolution,
Resolution::Unknown,
[
("480i", Resolution::R480i),
("480p", Resolution::R480p),
("576i", Resolution::R576i),
("576p", Resolution::R576p),
("720p", Resolution::R720p),
("1080i", Resolution::R1080i),
("1080p", Resolution::R1080p),
("2160p", Resolution::R2160p),
("4320p", Resolution::R4320p),
]
);
enum_str!(
FrameRate,
FrameRate::Unknown,
[
("23.976", FrameRate::F23_976),
("24", FrameRate::F24),
("25", FrameRate::F25),
("29.97", FrameRate::F29_97),
("30", FrameRate::F30),
("50", FrameRate::F50),
("59.94", FrameRate::F59_94),
("60", FrameRate::F60),
]
);
enum_str!(
AudioChannels,
AudioChannels::Unknown,
[
("mono", AudioChannels::Mono),
("stereo", AudioChannels::Stereo),
("2.1", AudioChannels::Stereo21),
("4.0", AudioChannels::Quad),
("5.0", AudioChannels::Surround50),
("5.1", AudioChannels::Surround51),
("6.1", AudioChannels::Surround61),
("7.1", AudioChannels::Surround71),
]
);
enum_str!(
SampleRate,
SampleRate::Unknown,
[
("44.1kHz", SampleRate::S44_1),
("48kHz", SampleRate::S48),
("96kHz", SampleRate::S96),
("192kHz", SampleRate::S192),
("48/96kHz", SampleRate::S48_96),
("48/192kHz", SampleRate::S48_192),
]
);
impl std::str::FromStr for Codec {
type Err = ();
fn from_str(s: &str) -> std::result::Result<Self, ()> {
for (id, _, v) in Codec::ALL_CODECS {
if *id == s {
return Ok(*v);
}
}
Ok(Codec::Unknown(0))
}
}
impl std::str::FromStr for HdrFormat {
type Err = ();
fn from_str(s: &str) -> std::result::Result<Self, ()> {
for (id, v) in HdrFormat::ALL_HDR {
if *id == s {
return Ok(*v);
}
}
// Also accept display names
for (_id, v) in HdrFormat::ALL_HDR {
if HdrFormat::name(v) == s {
return Ok(*v);
}
}
Ok(HdrFormat::Sdr)
}
}
impl DiscTitle {
/// Empty DiscTitle with no streams.
pub fn empty() -> Self {
Self {
playlist: String::new(),
playlist_id: 0,
duration_secs: 0.0,
size_bytes: 0,
clips: Vec::new(),
streams: Vec::new(),
chapters: Vec::new(),
extents: Vec::new(),
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
}
}
/// 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!("{hrs}h {mins:02}m")
}
/// 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()
}
}
// ─── Encryption ─────────────────────────────────────────────────────────────
/// 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). `None` when keys were resolved
/// via the [`KeySource::KeyDbUnitKeys`] path — that source delivers
/// pre-decrypted unit keys without a VUK to derive them from.
pub vuk: Option<[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. Variants are ordered root-of-trust →
/// per-disc-leaf, matching the resolver's path-try order: the resolver
/// attempts derivation from the strongest input it has first and falls
/// back toward pre-computed per-disc material.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum KeySource {
/// MKB + device keys → subset-difference tree → VUK
DeviceKey,
/// MKB + processing keys → media key → VUK
ProcessingKey,
/// Media key + Volume ID from KEYDB → derived VUK
KeyDbDerived,
/// VUK found directly in KEYDB by disc hash
KeyDb,
/// Pre-decrypted unit keys taken directly from KEYDB by disc hash.
/// No VUK present in the entry — `AacsState::vuk` is `None`.
KeyDbUnitKeys,
/// Unit key supplied directly by the caller (the external Unit Key path).
/// No keydb, no derivation — `AacsState::vuk` is `None`.
ExternalUk,
}
impl KeySource {
pub fn name(&self) -> &'static str {
match self {
KeySource::DeviceKey => "MKB + device key",
KeySource::ProcessingKey => "MKB + processing key",
KeySource::KeyDbDerived => "KEYDB (derived)",
KeySource::KeyDb => "KEYDB",
KeySource::KeyDbUnitKeys => "KEYDB (unit keys)",
KeySource::ExternalUk => "external UK",
}
}
}
// ─── Disc scanning ──────────────────────────────────────────────────────────
/// Standard KEYDB.cfg search locations (compatible with libaacs).
const KEYDB_SEARCH_PATHS: &[&str] = &[
".config/aacs/KEYDB.cfg", // libaacs standard path
".config/freemkv/keydb.cfg", // freemkv download path
];
const KEYDB_SYSTEM_PATH: &str = "/etc/aacs/KEYDB.cfg";
/// Options for disc scanning.
#[derive(Default)]
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>,
/// Caller-supplied Unit Key — an alternative to keydb lookup. When set,
/// libfreemkv skips keydb lookup and all derivation and uses this key
/// directly to decrypt; it takes precedence over `keydb_path`. The caller
/// obtains the key however it likes; libfreemkv stays free of any network
/// dependency.
pub unit_key: Option<[u8; 16]>,
/// Disable KEYDB entirely: skip both the explicit `keydb_path` and the
/// standard-location search, so no keydb is loaded for this scan. A caller
/// that resolves keys out-of-band (e.g. a remote key service) sets this so
/// a keydb that merely happens to sit in a default location does not shadow
/// the out-of-band path. `unit_key` still takes precedence over everything.
pub disable_keydb: bool,
}
impl ScanOptions {
/// Resolve KEYDB path: explicit path first, then standard locations.
/// Returns `None` when `disable_keydb` is set — no keydb is consulted.
fn resolve_keydb(&self) -> Option<std::path::PathBuf> {
if self.disable_keydb {
return None;
}
if let Some(p) = &self.keydb_path {
if p.exists() {
return Some(p.clone());
}
}
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
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
}
}
/// Quick disc identification — name, format, capacity. No title/stream parsing.
#[derive(Debug)]
pub struct DiscId {
/// UDF Volume Identifier (always present, e.g. "SAMPLE_FILM")
pub volume_id: String,
/// Disc title from META/DL/bdmt_eng.xml (e.g. "Sample Film")
pub meta_title: Option<String>,
/// Disc format (BD, UHD, DVD) — UHD vs BD requires full scan to confirm
pub format: DiscFormat,
/// Disc capacity in sectors
pub capacity_sectors: u32,
/// Whether AACS directory exists (disc is likely encrypted)
pub encrypted: bool,
/// Number of layers
pub layers: u8,
}
impl DiscId {
/// Best available name: meta_title, then formatted volume_id.
pub fn name(&self) -> &str {
self.meta_title.as_deref().unwrap_or(&self.volume_id)
}
}
impl Disc {
/// Fast disc identification — reads only UDF metadata for name and format.
/// No AACS handshake, no playlist parsing, no CLPI, no labels.
/// Typically completes in 2-3 seconds on USB drives.
pub fn identify(session: &mut Drive) -> Result<DiscId> {
let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
let meta_title = Self::read_meta_title(&mut buffered, &udf_fs);
let format = if udf_fs.find_dir("/BDMV").is_some() {
DiscFormat::BluRay // full scan distinguishes UHD vs BD
} else if udf_fs.find_dir("/VIDEO_TS").is_some() {
DiscFormat::Dvd
} else {
DiscFormat::Unknown
};
let encrypted =
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
let layers = if capacity > 24_000_000 { 2 } else { 1 };
Ok(DiscId {
volume_id: udf_fs.volume_id,
meta_title,
format,
capacity_sectors: capacity,
encrypted,
layers,
})
}
/// Disc capacity in GB
pub fn capacity_gb(&self) -> f64 {
self.capacity_sectors as f64 * 2048.0 / (1024.0 * 1024.0 * 1024.0)
}
/// Read UDF filesystem and set up buffered reader with metadata prefetched.
/// Shared setup for both identify() and scan().
fn read_udf(session: &mut Drive) -> Result<(u32, udf::BufferedSectorReader<'_>, udf::UdfFs)> {
let capacity = Self::read_capacity(session).unwrap_or(0);
let batch = detect_max_batch_sectors(session.device_path());
let mut buffered = udf::BufferedSectorReader::new(session, batch);
let udf_fs = udf::read_filesystem(&mut buffered)?;
buffered.prefetch(udf_fs.metadata_start(), udf_fs.metadata_sectors());
Ok((capacity, buffered, udf_fs))
}
/// 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
///
/// Scan a disc. One pipeline, one order:
/// 1. Read capacity + UDF filesystem
/// 2. AACS handshake + key resolution
/// 3. Parse playlists + streams
/// 4. Apply labels
///
/// 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 Drive, opts: &ScanOptions) -> Result<Self> {
// AACS handshake (Blu-ray/UHD). Routes through Disc::read_vid,
// which prefers the per-drive OEM CDB path when the drive is
// in the extended-access state and falls back to cert-based
// mutual auth otherwise.
let (handshake, handshake_error) = Self::do_handshake(session, opts);
// Request max read speed — removes riplock on DVD
// (BD/UHD speed is set by firmware init, but DVD needs explicit SET CD SPEED)
session.set_speed(0xFFFF);
// Read UDF filesystem with buffered sector reader
let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
// Pre-read all small file sectors (AACS, MPLS, CLPI, META, *.bdmv).
// Without this, each read_file() triggers individual SCSI commands at 500ms each.
if let Ok(ranges) = udf_fs.metadata_sector_ranges(&mut buffered) {
buffered.prefetch_ranges(&ranges);
}
let mut disc = Self::scan_with(
&mut buffered,
capacity,
handshake,
handshake_error,
opts,
udf_fs,
)?;
// CSS key extraction for DVDs (bus auth → disc key → title key).
// Must be a single auth session — can't call authenticate() separately.
// Route through the DRM dispatcher: probe a title sector, detect
// CSS if scrambled, then load via the SCSI auth path.
if disc.css.is_none()
&& disc.content_format == ContentFormat::MpegPs
&& !disc.titles.is_empty()
{
let mut probe_buf = vec![0u8; 2048];
let auth_lba = disc.titles[0].extents.iter().find_map(|ext| {
if session
.read_sectors(ext.start_lba, 1, &mut probe_buf, true)
.is_ok()
{
let probe = crate::drm::DrmProbe {
dvd_sample_sector: Some(&probe_buf),
content_cert: None,
mkb: None,
};
if crate::drm::DrmScheme::detect(&probe) == Some(crate::drm::DrmScheme::Css) {
return Some(ext.start_lba);
}
}
None
});
if let Some(lba) = auth_lba {
let css_ctx = crate::css::CssContext {
drive: Some(session),
auth_lba: Some(lba),
reader: None,
extents: None,
};
let mut ctx = crate::drm::DrmContext {
aacs: None,
css: Some(css_ctx),
};
if let Some(crate::drm::ResolvedScheme::Css(state)) =
crate::drm::DrmScheme::Css.load(&mut ctx)
{
disc.css = Some(state);
disc.encrypted = true;
}
}
}
Ok(disc)
}
/// Scan a disc image (ISO or any SectorSource). No SCSI, no handshake.
/// AACS resolution uses KEYDB VUK lookup only.
pub fn scan_image(
reader: &mut dyn SectorSource,
capacity: u32,
opts: &ScanOptions,
) -> Result<Self> {
let udf_fs = udf::read_filesystem(reader)?;
Self::scan_with(reader, capacity, None, None, opts, udf_fs)
}
/// Read a disc's AACS key-input files from an ISO image: returns
/// `(Unit_Key_RO.inf, MKB)` raw bytes. For callers that resolve a Unit Key
/// out-of-band: obtain the key however you like, then scan with
/// `ScanOptions { unit_key: Some(uk), .. }`. libfreemkv never makes a
/// network call.
pub fn read_aacs_inputs(iso_path: &std::path::Path) -> Result<(Vec<u8>, Vec<u8>)> {
let mut reader = crate::io::file_sector_source::FileSectorSource::open(iso_path)
.map_err(|_| Error::AacsNoKeys)?;
let udf_fs = udf::read_filesystem(&mut reader)?;
let inf = udf_fs
.read_file(&mut reader, "/AACS/Unit_Key_RO.inf")
.or_else(|_| udf_fs.read_file(&mut reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
.map_err(|_| Error::AacsNoKeys)?;
// Prefer MKB_RO, fall back to MKB_RW, then TRIM to the real record
// length. Both files are allocated to a fixed ~128 MiB and zero-padded,
// so reading either ships up to ~124 MiB of nothing — trim to the
// record stream so callers send/store a few MB, not 128 MiB.
let mut mkb = udf_fs
.read_file(&mut reader, "/AACS/MKB_RO.inf")
.or_else(|_| udf_fs.read_file(&mut reader, "/AACS/MKB_RW.inf"))
.map_err(|_| Error::AacsNoKeys)?;
let n = crate::aacs::mkb_content_len(&mkb);
if n > 0 && n < mkb.len() {
mkb.truncate(n);
}
Ok((inf, mkb))
}
/// Same as [`Disc::read_aacs_inputs`] but reads from a live drive. The
/// out-of-band Unit Key path fetches the disc's key files from the drive,
/// resolves a key from them however it likes, then scans with
/// `ScanOptions { unit_key: Some(uk), .. }`. These files are plaintext UDF
/// metadata — no AACS handshake or keys are required to read them.
pub fn read_aacs_inputs_from_drive(drive: &mut Drive) -> Result<(Vec<u8>, Vec<u8>)> {
let (_, mut reader, udf_fs) = Self::read_udf(drive)?;
let inf = udf_fs
.read_file(&mut reader, "/AACS/Unit_Key_RO.inf")
.or_else(|_| udf_fs.read_file(&mut reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
.map_err(|_| Error::AacsNoKeys)?;
// Prefer MKB_RO, fall back to MKB_RW, then TRIM to the real record
// length. Both files are allocated to a fixed ~128 MiB and zero-padded,
// so reading either ships up to ~124 MiB of nothing — trim to the
// record stream so callers send/store a few MB, not 128 MiB.
let mut mkb = udf_fs
.read_file(&mut reader, "/AACS/MKB_RO.inf")
.or_else(|_| udf_fs.read_file(&mut reader, "/AACS/MKB_RW.inf"))
.map_err(|_| Error::AacsNoKeys)?;
let n = crate::aacs::mkb_content_len(&mkb);
if n > 0 && n < mkb.len() {
mkb.truncate(n);
}
Ok((inf, mkb))
}
/// Core scan pipeline — works with any SectorSource.
///
/// `handshake_error` is plumbed from `do_handshake` so failures
/// (cert rejected, raw-read unsupported, VID read failed) are
/// preserved as `disc.aacs_error` for callers to render. When key
/// resolution succeeds despite the handshake failure (built-in
/// keys + disc-hash lookup hit) the error is dropped.
fn scan_with(
reader: &mut dyn SectorSource,
capacity: u32,
handshake: Option<HandshakeResult>,
handshake_error: Option<Error>,
opts: &ScanOptions,
udf_fs: udf::UdfFs,
) -> Result<Self> {
// 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, aacs_error) = if !encrypted {
(None, None)
} else if let Some(unit_key) = opts.unit_key {
// Second key source: caller supplied the Unit Key directly
// (external Unit Key). Skip keydb entirely.
match Self::resolve_encryption_static(&udf_fs, reader, unit_key, handshake.as_ref()) {
Ok(state) => (Some(state), None),
Err(e) => (None, Some(e)),
}
} else if opts.disable_keydb {
// Keydb disabled: keys are resolved out-of-band. Still capture the
// VID (read during the handshake) so the out-of-band path has it;
// carry no keys (disc reports "encrypted, no keys" until re-scanned
// with a resolved Unit Key).
match Self::resolve_vid_only(&udf_fs, reader, handshake.as_ref()) {
Ok(state) => (Some(state), None),
Err(e) => (None, Some(e)),
}
} else {
match opts.resolve_keydb() {
Some(keydb_path) => {
match Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref())
{
Ok(state) => (Some(state), None),
Err(e) => {
// When the handshake itself failed AND resolution
// bottomed out at "no keys", surface the upstream
// handshake failure — it's more actionable than
// the generic AacsNoKeys.
let final_err = match (&e, handshake_error.as_ref()) {
(
Error::AacsNoKeys
| Error::AacsVukNotInKeydb
| Error::AacsVidUnavailable,
Some(_),
) => handshake_error.unwrap(),
_ => e,
};
tracing::warn!(
target: "freemkv::disc",
phase = "scan_aacs_resolve_failed",
error_code = final_err.code(),
keydb = %keydb_path.display(),
handshake_ok = handshake.is_some(),
"AACS key resolution failed"
);
(None, Some(final_err))
}
}
}
None => {
tracing::warn!(
target: "freemkv::disc",
phase = "scan_aacs_no_keydb",
"encrypted disc but no KEYDB found in search paths"
);
// Sentinel path string lets autorip's message switch
// distinguish "no keydb found anywhere" from "keydb at
// <path> failed to parse".
let final_err =
handshake_error.unwrap_or_else(|| crate::error::Error::KeydbLoad {
path: String::from("<no keydb in search paths>"),
});
(None, Some(final_err))
}
}
};
// 3. Titles — BD (MPLS playlists) or DVD (IFO title sets)
let (mut titles, content_format) = if udf_fs.find_dir("/BDMV").is_some() {
(
Self::scan_bluray_titles(reader, &udf_fs),
ContentFormat::BdTs,
)
} else if udf_fs.find_dir("/VIDEO_TS").is_some() {
(
Self::scan_dvd_titles(reader, &udf_fs),
ContentFormat::MpegPs,
)
} else {
(Vec::new(), ContentFormat::BdTs)
};
// Title ordering: titles[0] should be the canonical main feature.
//
// Naive "longest duration first" misranks branching UHDs (see
// `canonical_title_order` for the full rationale). Sort the
// titles so the consumer-side `-t 1` / autorip's main-feature
// picker / `disc.titles.first()` all converge on the actual
// movie instead of the virtual play-all composite.
let capacity_bytes = capacity as u64 * 2048;
titles.sort_by(|a, b| Self::canonical_title_order(a, b, capacity_bytes));
// 4. Metadata + labels
let meta_title = Self::read_meta_title(reader, &udf_fs);
crate::labels::apply(reader, &udf_fs, &mut titles);
crate::labels::fill_defaults(&mut titles);
// 5. Derive format, layers, region
let format = Self::detect_format(&titles);
let layers = if capacity > 24_000_000 { 2 } else { 1 };
let region = DiscRegion::Free;
// 6. CSS detection for DVDs — route through the DRM dispatcher.
// Detection from a single probe sector would miss
// DVDs whose first sector is unscrambled, so we go straight
// to `DrmScheme::Css.load` with the crack-path context; the
// crack path scans extents internally and bottoms out at
// None on unencrypted media.
let css = if content_format == ContentFormat::MpegPs && !titles.is_empty() {
let css_ctx = crate::css::CssContext {
drive: None,
auth_lba: None,
reader: Some(reader),
extents: Some(&titles[0].extents),
};
let mut ctx = crate::drm::DrmContext {
aacs: None,
css: Some(css_ctx),
};
match crate::drm::DrmScheme::Css.load(&mut ctx) {
Some(crate::drm::ResolvedScheme::Css(s)) => Some(s),
_ => None,
}
} else {
None
};
let encrypted = encrypted || css.is_some();
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,
css,
encrypted,
aacs_error,
content_format,
})
}
// ── Internal helpers ────────────────────────────────────────────────────
/// Detect disc format from the main title's video streams.
/// Total ordering used to sort `Disc::titles` so `titles[0]` is the
/// canonical main feature.
///
/// **Why not just sort by duration descending?** Branching UHDs
/// (and some BD authoring) ship a "play-all" virtual playlist that
/// references the same source clips multiple times for seamless
/// alternate-angle / alternate-ending playback. Those playlists
/// report an inflated `duration_secs` (often 4+ hours) and an
/// inflated `size_bytes` greater than the disc's physical
/// capacity. Example seen in the wild — *The Amateur (2025)* UHD,
/// 58.5 GB BD-100 disc:
///
/// | Title | Playlist | Duration | Size | Clips |
/// |-------|--------------|----------|---------|-------|
/// | 1 | 00020.mpls | 4h 13m | 92.4 GB | 253 |
/// | 2 | 00800.mpls | 2h 02m | 57.2 GB | 1 |
///
/// Title 1's 92.4 GB cannot fit on a 58.5 GB disc unless the same
/// clip data is referenced multiple times — proof it's a virtual
/// composite. A duration-only sort would put it at `titles[0]`,
/// so `freemkv -t 1`, `disc.titles.first()`, and autorip's
/// main-feature picker all grab the 4-hour composite instead of
/// the 2-hour movie that actually matches TMDB.
///
/// **Sort priority (titles[0] = most likely main feature):**
/// 1. Real titles (`size_bytes ≤ capacity_bytes`) before virtual
/// composites. The capacity check is a hard "physically
/// possible data on this disc" gate.
/// 2. Among real titles, fewer clips first. A 1-clip playlist is
/// the canonical main feature; multi-clip playlists are either
/// chapter-stitched (small count) or virtual composites
/// (large count). Fewer wins.
/// 3. Tiebreak on longer duration first.
///
/// **Effect on non-branching discs:** unchanged — the main movie
/// is already the longest 1-clip title.
/// **Effect on branching UHDs:** the virtual play-all playlist is
/// pushed to the back, the actual movie surfaces at index 0.
pub fn canonical_title_order(
a: &DiscTitle,
b: &DiscTitle,
capacity_bytes: u64,
) -> std::cmp::Ordering {
let a_oversize = a.size_bytes > capacity_bytes;
let b_oversize = b.size_bytes > capacity_bytes;
a_oversize
.cmp(&b_oversize)
.then_with(|| a.clips.len().cmp(&b.clips.len()))
.then_with(|| b.duration_secs.total_cmp(&a.duration_secs))
}
fn detect_format(titles: &[DiscTitle]) -> DiscFormat {
for title in titles.iter().take(3) {
for stream in &title.streams {
if let Stream::Video(v) = stream {
if v.resolution.is_uhd() {
return DiscFormat::Uhd;
}
if v.resolution.is_hd() {
return DiscFormat::BluRay;
}
if v.resolution.is_sd() {
return DiscFormat::Dvd;
}
}
}
}
DiscFormat::Unknown
}
fn read_capacity(session: &mut Drive) -> Result<u32> {
let cdb = [
crate::scsi::SCSI_READ_CAPACITY,
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)
}
}
impl Disc {
/// Get the resolved decryption keys for this disc.
/// Used by disc-to-ISO and other full-disc operations.
pub fn decrypt_keys(&self) -> crate::decrypt::DecryptKeys {
if let Some(ref aacs) = self.aacs {
// An AACS state with NO unit keys is "encrypted, no keys" — e.g.
// the VID-only state from out-of-band resolution before a Unit Key
// is supplied. Report None so callers treat it as missing keys
// (not a usable, empty key set).
if aacs.unit_keys.is_empty() {
return crate::decrypt::DecryptKeys::None;
}
crate::decrypt::DecryptKeys::Aacs {
unit_keys: aacs.unit_keys.clone(),
read_data_key: aacs.read_data_key,
}
} else if let Some(ref css) = self.css {
crate::decrypt::DecryptKeys::Css {
title_key: css.title_key,
}
} else {
crate::decrypt::DecryptKeys::None
}
}
/// Copy disc sectors to an ISO image file.
///
/// NOT a stream operation. Copies sectors byte-for-byte producing a valid
/// ISO/UDF image. Records progress in a ddrescue-format mapfile at
/// `path + ".mapfile"` — flushed every block for crash-safe resume.
///
/// Auto-detects the pass based on mapfile state:
/// - **No mapfile** → Pass 1 (sweep): sequential read of the entire disc,
/// ECC-aligned batches, damage-jump on contiguous failures, marks bad
/// blocks as NonTrimmed. No drive-level recovery — fast.
/// - **Mapfile with bad ranges** → Pass N (patch): re-reads only bad ranges
/// sector-by-sector with full drive-level recovery. Marks recovered
/// sectors as Finished, failed as Unreadable (terminal).
/// - **Mapfile clean** → no-op: all sectors are Finished.
///
/// Without `multipass`: aborts on the first read error (legacy single-pass).
pub fn copy(
&self,
reader: &mut dyn SectorSource,
path: &std::path::Path,
opts: &CopyOptions,
) -> Result<CopyResult> {
if opts.multipass {
let mf_path = self.mapfile_for(path);
if mf_path.exists() {
let map =
mapfile::Mapfile::load(&mf_path).map_err(|e| Error::IoError { source: e })?;
let stats = map.stats();
let disc_size = self.capacity_bytes;
let covers_disc = map.total_size() == disc_size;
let bad_bytes = stats.bytes_pending + stats.bytes_unreadable;
tracing::info!(
"copy dispatch: disc={} map={} covers={} good={} nontried={} pending={} unreadable={}",
disc_size,
map.total_size(),
covers_disc,
stats.bytes_good,
stats.bytes_nontried,
stats.bytes_pending,
stats.bytes_unreadable,
);
if covers_disc && bad_bytes == 0 {
return Ok(CopyResult {
bytes_total: disc_size,
bytes_good: stats.bytes_good,
bytes_unreadable: stats.bytes_unreadable,
bytes_pending: 0,
recovered_this_pass: 0,
complete: true,
halted: false,
});
}
if !covers_disc {
tracing::info!("copy dispatch: → sweep (covers_disc={})", covers_disc,);
return self.sweep_internal(reader, path, opts, true);
}
if stats.bytes_retryable > 0 {
tracing::info!(
"copy dispatch: → patch (retryable={})",
stats.bytes_retryable,
);
return self.patch_internal(reader, path, opts);
}
tracing::info!("copy dispatch: → sweep (resume)");
return self.sweep_internal(reader, path, opts, true);
}
}
self.sweep_internal(reader, path, opts, false)
}
fn sweep_internal(
&self,
reader: &mut dyn SectorSource,
path: &std::path::Path,
opts: &CopyOptions,
resume: bool,
) -> Result<CopyResult> {
let sweep_opts = SweepOptions {
decrypt: opts.decrypt,
resume,
batch_sectors: None,
skip_on_error: opts.multipass,
progress: opts.progress,
halt: opts.halt.clone(),
vid: opts.vid,
};
self.sweep(reader, path, &sweep_opts)
}
fn patch_internal(
&self,
reader: &mut dyn SectorSource,
path: &std::path::Path,
opts: &CopyOptions,
) -> Result<CopyResult> {
let patch_opts = PatchOptions {
decrypt: opts.decrypt,
// 0.18.13: adaptive batching. patch() reads at 32 sectors
// when the drive is healthy, drops to 1 on failure to
// probe each sector individually, then climbs back after
// 16 consecutive clean singles. Walks NonTrimmed regions
// ~32x faster in clean stretches without sacrificing any
// per-sector recovery quality — the drop-to-1 retry from
// the same position guarantees every sector in a failed
// batch is individually probed. See Disc::patch body.
block_sectors: Some(32),
full_recovery: true,
reverse: true,
wedged_threshold: 50,
progress: opts.progress,
halt: opts.halt.clone(),
};
let pr = self.patch(reader, path, &patch_opts)?;
tracing::info!(
target: "freemkv::disc",
phase = "patch_done",
blocks_attempted = pr.blocks_attempted,
blocks_read_ok = pr.blocks_read_ok,
blocks_read_failed = pr.blocks_read_failed,
bytes_recovered = pr.bytes_recovered_this_pass,
halted = pr.halted,
wedged_exit = pr.wedged_exit,
"Patch completed"
);
Ok(CopyResult {
bytes_total: pr.bytes_total,
bytes_good: pr.bytes_good,
bytes_unreadable: pr.bytes_unreadable,
bytes_pending: pr.bytes_pending,
recovered_this_pass: pr.bytes_recovered_this_pass,
complete: pr.bytes_pending == 0,
halted: pr.halted,
})
}
/// Pass 1 of a multipass rip: walk the disc forward, write
/// every readable sector into `path`, and record the result
/// in the sidecar mapfile. With `skip_on_error: true`, a bad
/// sector zero-fills + marks `NonTrimmed` and the sweep keeps
/// going (jumping ahead through dense damage); without it,
/// the first read failure aborts.
///
/// 0.18: this is one of the two flat verbs the library exposes
/// for rip orchestration. Multipass + retry decisions are the
/// caller's job — see [`PatchOptions`] for the retry primitive.
pub fn sweep(
&self,
reader: &mut dyn SectorSource,
path: &std::path::Path,
opts: &SweepOptions,
) -> Result<CopyResult> {
use crate::io::{DEFAULT_PIPELINE_DEPTH, Pipeline};
use crate::sector::{DecryptingSectorSource, SectorSource};
use sweep::{ProgressSnapshot, SweepSink, WorkItem, try_recv_progress};
let total_bytes = self.capacity_sectors as u64 * 2048;
let keys = if opts.decrypt {
self.decrypt_keys()
} else {
crate::decrypt::DecryptKeys::None
};
// Wrap the producer-side reader once so every read_sectors call
// yields plaintext. `DecryptKeys::None` makes the decorator a
// pass-through, so the wrapping is cheap when --raw / unencrypted
// discs are being swept and we keep the pipeline shape uniform.
// Replaces the inline `decrypt::decrypt_sectors` calls that used
// to live in this loop and in the bisect inner loop below.
let mut reader = DecryptingSectorSource::new(reader, keys);
let reader = &mut reader;
// Mapfile: load if resuming, else wipe + recreate.
let mapfile_path = self.mapfile_for(path);
if !opts.resume {
let _ = std::fs::remove_file(&mapfile_path);
}
let mut map = mapfile::Mapfile::open_or_create(
&mapfile_path,
total_bytes,
concat!("libfreemkv v", env!("CARGO_PKG_VERSION")),
)
.map_err(|e| Error::IoError { source: e })?;
// Persist the disc's AACS Volume ID into the mapfile header so it
// survives to deferred-mux / resume. ddrescue-safe (comment line);
// does not touch the ISO payload. On a resume-load the VID is
// already present, but re-setting it (idempotent) covers the case
// where Pass 1 created the mapfile before the VID was known.
if let Some(vid) = opts.vid {
map.set_vid(vid);
}
// ISO file: if resuming and mapfile has Finished ranges, open existing;
// otherwise create fresh and pre-size to total_bytes (sparse holes for
// non-tried regions).
let is_regular = std::fs::metadata(path)
.map(|m| m.file_type().is_file())
.unwrap_or(false);
let file = if opts.resume
&& std::fs::metadata(path)
.map(|m| m.len() > 0)
.unwrap_or(false)
{
std::fs::OpenOptions::new()
.write(true)
.open(path)
.map_err(|e| Error::IoError { source: e })?
} else {
let f = std::fs::File::create(path).map_err(|e| Error::IoError { source: e })?;
if is_regular {
f.set_len(total_bytes)
.map_err(|e| Error::IoError { source: e })?;
}
f
};
// Wrap the raw `File` in our bounded-cache `WritebackFile`
// (drains dirty pages continuously instead of bursting; see
// `crate::io`). The `WritebackFile` moves into the consumer
// thread.
let file = crate::io::WritebackFile::new(file).map_err(|e| Error::IoError { source: e })?;
let batch: u16 = match opts.batch_sectors {
Some(b) => b,
None if opts.skip_on_error => ecc_sectors(self.format),
None => DEFAULT_BATCH_SECTORS_OPTICAL,
};
// Pre-compute the list of NonTried regions before handing the
// mapfile to the consumer thread. Each region is processed by
// the producer in order; the consumer mutates the mapfile per
// work-item. Any regions left as NonTrimmed/Unreadable after
// sweep finishes are the patch pass's job.
let regions: Vec<(u64, u64)> = map.ranges_with(&[mapfile::SectorStatus::NonTried]);
// Spawn the consumer. It owns WritebackFile + Mapfile; the producer
// (this thread) keeps `reader`, `read_ctx`, halt + set_speed.
// The thread name is preserved from the 0.17.x sweep_pipeline so it
// stays identifiable in stack traces / `top -H`.
let (sink, prog_rx) = SweepSink::new(file, map, is_regular);
let pipe: Pipeline<WorkItem, sweep::ConsumerSummary> =
Pipeline::spawn_named("freemkv-sweep-consumer", DEFAULT_PIPELINE_DEPTH, sink)?;
// Translate `Pipeline::send` failure (consumer gone) into the
// same `Error` shape the 0.17.x `send_or_abort` produced, so
// the producer-error semantics are unchanged.
fn consumer_gone() -> Error {
Error::IoError {
source: std::io::Error::other("sweep consumer terminated unexpectedly"),
}
}
let mut buf = vec![0u8; batch as usize * 2048];
let mut bytes_done = 0u64;
let mut halt_requested = false;
let copy_t0 = std::time::Instant::now();
let mut iter_count: u64 = 0;
let mut read_ok_count: u64 = 0;
let mut read_err_count: u64 = 0;
let mut last_log_iter: u64 = 0;
let mut read_ctx = read_error::ReadCtx::for_sweep(batch);
let mut in_damage_zone = false;
const DAMAGE_ZONE_EXIT_THRESHOLD: u64 = 16;
let mut cached_snapshot: Option<ProgressSnapshot> = None;
let mut producer_err: Option<Error> = None;
tracing::trace!(
target: "freemkv::disc",
phase = "copy_start",
total_bytes,
batch,
skip_on_error = opts.skip_on_error,
regions = regions.len(),
"Disc::sweep entered (producer/consumer)"
);
'outer: for (region_pos, region_size) in regions {
let region_end = region_pos + region_size;
let mut pos = region_pos;
tracing::trace!(
target: "freemkv::disc",
phase = "region_enter",
region_pos,
region_size,
region_end,
"entering NonTried region"
);
while pos < region_end {
if let Some(ref h) = opts.halt {
if h.load(std::sync::atomic::Ordering::Relaxed) {
halt_requested = true;
break 'outer;
}
}
let block_bytes = (region_end - pos).min(batch as u64 * 2048);
let block_lba = (pos / 2048) as u32;
let block_count = (block_bytes / 2048) as u16;
let recovery = !opts.skip_on_error;
let read_result = reader.read_sectors(
block_lba,
block_count,
&mut buf[..block_bytes as usize],
recovery,
);
match read_result {
Ok(_) => {
read_ok_count += 1;
read_ctx.on_success();
if read_ctx.consecutive_good >= DAMAGE_ZONE_EXIT_THRESHOLD {
read_ctx.jump_multiplier = 1;
if in_damage_zone {
in_damage_zone = false;
reader.set_speed(0xFFFF);
tracing::debug!(
target: "freemkv::disc",
phase = "damage_exit",
lba = block_lba,
"Exited damage zone; restoring max read speed"
);
}
}
read_ctx.bridge_degradation_count = 0;
// Plaintext: the wrapped reader (DecryptingSectorSource)
// applied AACS / CSS in-place during read_sectors above.
// The consumer thread sees decrypted bytes; the
// pre-0.18 inline decrypt_sectors call lived here.
// Move the batch into the channel via fresh
// owned Vec. The producer's `buf` is reused
// for the next read.
let send_buf = buf[..block_bytes as usize].to_vec();
if pipe.send(WorkItem::Good { pos, buf: send_buf }).is_err() {
producer_err = Some(consumer_gone());
break 'outer;
}
bytes_done = bytes_done.saturating_add(block_bytes);
pos += block_bytes;
}
Err(err) if !opts.skip_on_error => {
let (status, sense) = extract_scsi_context(&err);
producer_err = Some(Error::DiscRead {
sector: block_lba as u64,
status: Some(status),
sense,
});
break 'outer;
}
Err(err) => {
read_err_count += 1;
let action = read_error::handle_read_error(&err, &mut read_ctx);
match action {
read_error::ReadAction::Retry { pause_secs } => {
sleep_secs_or_halt(pause_secs, opts.halt.as_ref());
}
read_error::ReadAction::Bisect => {
read_ctx.bisecting = true;
let saved_batch = read_ctx.batch;
read_ctx.batch = 1;
let mut bisect_aborted = false;
for sector_offset in 0..block_count {
if let Some(ref h) = opts.halt {
if h.load(std::sync::atomic::Ordering::Relaxed) {
halt_requested = true;
bisect_aborted = true;
break;
}
}
let sector_lba = block_lba + (sector_offset as u32);
let mut sector_buf = [0u8; 2048];
let write_pos = pos + (sector_offset as u64 * 2048);
match reader.read_sectors(
sector_lba,
1,
&mut sector_buf[..],
true,
) {
Ok(_) => {
read_ctx.on_success();
// Plaintext via the wrapping
// DecryptingSectorSource — same
// decrypt path the batch read takes.
if pipe
.send(WorkItem::BisectGood {
pos: write_pos,
buf: Box::new(sector_buf),
})
.is_err()
{
producer_err = Some(consumer_gone());
bisect_aborted = true;
break;
}
}
Err(inner_err) => {
let _ = read_error::handle_read_error(
&inner_err,
&mut read_ctx,
);
if pipe
.send(WorkItem::BisectBad { pos: write_pos })
.is_err()
{
producer_err = Some(consumer_gone());
bisect_aborted = true;
break;
}
}
}
}
read_ctx.bisecting = false;
read_ctx.batch = saved_batch;
if bisect_aborted {
break 'outer;
}
bytes_done = bytes_done.saturating_add(block_bytes);
pos += block_bytes;
}
read_error::ReadAction::SkipBlock { pause_secs } => {
if pipe
.send(WorkItem::SkipFill {
pos,
len: block_bytes,
})
.is_err()
{
producer_err = Some(consumer_gone());
break 'outer;
}
bytes_done = bytes_done.saturating_add(block_bytes);
sleep_secs_or_halt(pause_secs, opts.halt.as_ref());
pos += block_bytes;
}
read_error::ReadAction::JumpAhead {
sectors,
pause_secs,
} => {
if pipe
.send(WorkItem::SkipFill {
pos,
len: block_bytes,
})
.is_err()
{
producer_err = Some(consumer_gone());
break 'outer;
}
bytes_done = bytes_done.saturating_add(block_bytes);
if !in_damage_zone {
in_damage_zone = true;
reader.set_speed(0x0000);
tracing::debug!(
target: "freemkv::disc",
phase = "damage_enter",
lba = block_lba,
"Entered damage zone; dropping to minimum read speed"
);
}
let jump_pos = (pos + block_bytes + sectors * 2048).min(region_end);
let gap_start = pos + block_bytes;
let gap_bytes = jump_pos.saturating_sub(gap_start);
if gap_bytes > 0 {
if pipe
.send(WorkItem::GapFill {
pos: gap_start,
len: gap_bytes,
})
.is_err()
{
producer_err = Some(consumer_gone());
break 'outer;
}
bytes_done = bytes_done.saturating_add(gap_bytes);
}
tracing::warn!(
target: "freemkv::disc",
phase = "damage_jump",
from_lba = block_lba,
to_lba = (jump_pos / 2048) as u32,
jump_mb = gap_bytes / 1_048_576,
"damage-jump"
);
pos = jump_pos;
sleep_secs_or_halt(pause_secs, opts.halt.as_ref());
}
read_error::ReadAction::AbortPass => {
let (status, sense) = extract_scsi_context(&err);
producer_err = Some(Error::DiscRead {
sector: block_lba as u64,
status: Some(status),
sense,
});
break 'outer;
}
}
}
}
iter_count += 1;
// Drain any consumer-side stats snapshot.
if let Some(snap) = try_recv_progress(&prog_rx) {
cached_snapshot = Some(snap);
}
if iter_count - last_log_iter >= 100 {
last_log_iter = iter_count;
if let Some(ref snap) = cached_snapshot {
tracing::trace!(
target: "freemkv::disc",
phase = "iter_progress",
iter_count,
read_ok_count,
read_err_count,
pos,
region_end,
bytes_good = snap.stats.bytes_good,
bytes_pending = snap.stats.bytes_pending,
copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64,
"Disc::sweep inner iter"
);
}
// Throttled stats refresh request — best-effort
// try_send so a busy consumer doesn't stall the
// producer; the cached snapshot stays current
// enough for one more iteration.
let _ = pipe.try_send(WorkItem::StatsRequest);
}
if let Some(reporter) = opts.progress {
// Use the latest consumer snapshot if we have
// one; otherwise synthesise a producer-side
// placeholder. On a fresh sweep, before the
// first stats round-trip lands, this means
// bytes_good ≈ bytes_done (producer's notion of
// good-so-far) and the bad-range list is empty —
// close enough for an early UI tick; the next
// real snapshot replaces it.
let main_title = self.titles.first();
let main_title_bad = match &cached_snapshot {
Some(snap) => self
.titles
.first()
.map(|t| bytes_bad_in_title(t, &snap.bad_ranges))
.unwrap_or(0),
None => 0,
};
// The consumer's snapshot is the source of truth for
// bytes_unreadable / bytes_pending (the producer doesn't
// see them), but its bytes_good lags producer-side
// `bytes_done` whenever the consumer is behind on draining
// the work channel. Take the max so the user-visible
// counter never regresses below what the producer has
// already sent — Anomaly B in the 0.18.1 prod test was
// this regression: a stale early snapshot pinned the
// display to 0 GB while bytes_done was already advancing.
let (bytes_good, bytes_unreadable, bytes_pending) = match &cached_snapshot {
Some(snap) => (
snap.stats.bytes_good.max(bytes_done),
snap.stats.bytes_unreadable,
snap.stats.bytes_pending,
),
None => (bytes_done, 0u64, total_bytes.saturating_sub(bytes_done)),
};
let pp = crate::progress::PassProgress {
kind: crate::progress::PassKind::Sweep,
work_done: pos,
work_total: total_bytes,
bytes_good_total: bytes_good,
bytes_unreadable_total: bytes_unreadable,
bytes_pending_total: bytes_pending,
bytes_total_disc: total_bytes,
disc_duration_secs: main_title.map(|t| t.duration_secs),
bytes_bad_in_main_title: main_title_bad,
main_title_duration_secs: main_title.map(|t| t.duration_secs),
main_title_size_bytes: main_title.map(|t| t.size_bytes),
};
if !reporter.report(&pp) {
halt_requested = true;
break 'outer;
}
}
}
}
// Producer side is done. Drop the channel and let the
// consumer drain whatever's still in flight, then run its
// close() (drain writeback, fsync, mapfile.flush) and return
// the final stats. On consumer panic `pipe.finish` returns
// the wrapped panic message via Error::IoError — same shape
// the previous `consumer_handle.join().map_err(...)` produced.
let summary = pipe.finish();
// Producer-side error wins over consumer-side (the read failure
// is what motivated quitting; the consumer's flush error, if
// any, is downstream).
if let Some(e) = producer_err {
// Drop the consumer's result if we already have a producer
// error, but propagate consumer-panic on top of nothing
// since that's strictly informative.
let _ = summary;
return Err(e);
}
let summary = summary?;
let stats = summary.stats;
tracing::debug!(
target: "freemkv::disc",
phase = "sweep_done",
iter_count,
read_ok_count,
read_err_count,
bytes_good = stats.bytes_good,
bytes_pending = stats.bytes_pending,
halted = halt_requested,
copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64,
"Disc::sweep returning"
);
// End-of-pass diagnostic summary (added 2026-05-10 alongside
// the per-error timing instrumentation in read_error.rs).
// One INFO line per sweep that lets a post-mortem analyst tell
// at a glance how much damage the disc + drive saw, without
// grepping through the per-error WARN log. The PassSummary
// counters come from `ReadCtx`'s accumulated state.
let pass_sum = read_ctx.pass_summary();
tracing::info!(
target: "freemkv::disc",
phase = "pass1_summary",
total_reads_ok = pass_sum.total_reads_ok,
total_errors = pass_sum.total_errors,
zones_entered = pass_sum.zones_entered,
jumps_taken = pass_sum.jumps_taken,
bytes_good = stats.bytes_good,
bytes_pending = stats.bytes_pending,
copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64,
"Pass 1 complete"
);
Ok(CopyResult {
bytes_total: total_bytes,
bytes_good: stats.bytes_good,
bytes_unreadable: stats.bytes_unreadable,
bytes_pending: stats.bytes_pending,
recovered_this_pass: 0,
complete: stats.bytes_pending == 0 && !halt_requested,
halted: halt_requested,
})
}
}
#[derive(Default)]
pub struct CopyOptions<'a> {
pub decrypt: bool,
pub multipass: bool,
pub progress: Option<&'a dyn crate::progress::Progress>,
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
/// AACS Volume ID (16 bytes) to persist into the mapfile during
/// Pass 1 so it survives to deferred-mux / resume. `None` for
/// unencrypted / non-AACS discs. Caller wires this from
/// `Disc::aacs.volume_id`.
pub vid: Option<[u8; 16]>,
}
#[derive(Debug, Clone, Copy)]
pub struct CopyResult {
pub bytes_total: u64,
pub bytes_good: u64,
pub bytes_unreadable: u64,
pub bytes_pending: u64,
pub recovered_this_pass: u64,
pub complete: bool,
pub halted: bool,
}
/// Options for [`Disc::sweep`] (Pass 1 / forward sequential pass).
pub struct SweepOptions<'a> {
pub decrypt: bool,
pub resume: bool,
pub batch_sectors: Option<u16>,
pub skip_on_error: bool,
pub progress: Option<&'a dyn crate::progress::Progress>,
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
/// AACS Volume ID (16 bytes) persisted into the mapfile when the
/// sweep creates / opens it. `None` for unencrypted discs.
pub vid: Option<[u8; 16]>,
}
/// Options for [`Disc::patch`] (Pass N retry pass over bad ranges).
pub struct PatchOptions<'a> {
pub decrypt: bool,
pub block_sectors: Option<u16>,
pub full_recovery: bool,
pub reverse: bool,
pub wedged_threshold: u64,
pub progress: Option<&'a dyn crate::progress::Progress>,
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
}
/// Result returned by [`Disc::patch`].
pub struct PatchOutcome {
pub bytes_total: u64,
pub bytes_good: u64,
pub bytes_unreadable: u64,
pub bytes_pending: u64,
pub bytes_recovered_this_pass: u64,
pub halted: bool,
pub blocks_attempted: u64,
pub blocks_read_ok: u64,
pub blocks_read_failed: u64,
pub wedged_exit: bool,
pub wedged_threshold: u64,
}
/// Sleep `secs` seconds, but break early if `halt` flips to true.
/// Used by Pass 1's wedge-avoidance inter-error pause so halt
/// remains responsive regardless of how long the pause is.
/// Polling granularity 100 ms — bounded latency on halt regardless
/// of pause length.
pub(crate) fn sleep_secs_or_halt(
secs: u64,
halt: Option<&std::sync::Arc<std::sync::atomic::AtomicBool>>,
) {
if secs == 0 {
return;
}
let Some(h) = halt else {
std::thread::sleep(std::time::Duration::from_secs(secs));
return;
};
let total = std::time::Duration::from_secs(secs);
let slice = std::time::Duration::from_millis(100);
let start = std::time::Instant::now();
while start.elapsed() < total {
if h.load(std::sync::atomic::Ordering::Relaxed) {
return;
}
let remaining = total.saturating_sub(start.elapsed());
std::thread::sleep(remaining.min(slice));
}
}
pub fn mapfile_path_for(iso_path: &std::path::Path) -> std::path::PathBuf {
let mut s = iso_path.as_os_str().to_os_string();
s.push(".mapfile");
std::path::PathBuf::from(s)
}
impl Disc {
/// Path to the mapfile for a given output path.
///
/// For `/dev/null` output, returns `/tmp/{volume_id_or_title}.mapfile`.
/// For regular files, returns `{path}.mapfile`.
pub fn mapfile_for(&self, path: &std::path::Path) -> std::path::PathBuf {
if path.as_os_str() == "/dev/null" {
let name: String = self
.meta_title
.as_deref()
.unwrap_or(&self.volume_id)
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
std::path::PathBuf::from(format!("/tmp/{name}.mapfile"))
} else {
mapfile_path_for(path)
}
}
}
const MAX_BATCH_SECTORS: u16 = 510;
const DEFAULT_BATCH_SECTORS_OPTICAL: u16 = 60;
const DEFAULT_BATCH_SECTORS_BLOCK: u16 = 8192;
const MIN_BATCH_SECTORS: u16 = 3;
pub(crate) fn ecc_sectors(format: DiscFormat) -> u16 {
match format {
DiscFormat::Uhd | DiscFormat::BluRay => 32,
DiscFormat::Dvd => 16,
DiscFormat::Unknown => 32,
}
}
/// Coarse damage tier for a finished or in-progress rip. Maps the
/// observable signals (bad sector count + lost wallclock playback time)
/// onto a small discrete classification so UIs can render a colored badge
/// and operators can decide whether to rescan / replug / accept.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DamageSeverity {
/// No bad sectors at all.
Clean,
/// 150 bad sectors AND <1 sec lost. Likely unnoticeable.
Cosmetic,
/// 51500 sectors OR 130 sec lost. Visible artifacts possible.
Moderate,
/// 500+ sectors OR 30+ sec lost. Significant damage; consider rescan
/// or different drive.
Serious,
}
/// Classify damage severity from raw counters. `bad_sectors` is the
/// number of sectors marked unreadable (or NonTrimmed pending Pass 2);
/// `lost_ms` is the cumulative wallclock playback time those sectors
/// represent (computed from the title's bytes-per-sec).
pub fn classify_damage(bad_sectors: u64, lost_ms: f64) -> DamageSeverity {
if bad_sectors == 0 {
return DamageSeverity::Clean;
}
if bad_sectors >= 500 || lost_ms >= 30_000.0 {
return DamageSeverity::Serious;
}
if bad_sectors >= 51 || lost_ms >= 1_000.0 {
return DamageSeverity::Moderate;
}
DamageSeverity::Cosmetic
}
#[cfg(test)]
mod severity_tests {
use super::*;
#[test]
fn clean_when_no_damage() {
assert_eq!(classify_damage(0, 0.0), DamageSeverity::Clean);
}
#[test]
fn cosmetic_for_a_handful() {
assert_eq!(classify_damage(1, 5.0), DamageSeverity::Cosmetic);
assert_eq!(classify_damage(50, 999.0), DamageSeverity::Cosmetic);
}
#[test]
fn moderate_threshold_by_sectors() {
assert_eq!(classify_damage(51, 0.0), DamageSeverity::Moderate);
}
#[test]
fn moderate_threshold_by_time() {
assert_eq!(classify_damage(10, 1_000.0), DamageSeverity::Moderate);
}
#[test]
fn serious_threshold_by_sectors() {
assert_eq!(classify_damage(500, 0.0), DamageSeverity::Serious);
}
#[test]
fn serious_threshold_by_time() {
assert_eq!(classify_damage(10, 30_000.0), DamageSeverity::Serious);
}
}
/// Detect the maximum transfer size in sectors for a device.
pub fn detect_max_batch_sectors(device_path: &str) -> u16 {
let dev_name = device_path.rsplit('/').next().unwrap_or("");
if dev_name.is_empty() {
return DEFAULT_BATCH_SECTORS_OPTICAL;
}
// Check if optical drive (0x05 = CD/DVD)
let is_optical = (|| -> bool {
use std::path::Path;
let scsi_device_dir = "/sys/class/scsi_device/".to_string();
if let Ok(entries) = std::fs::read_dir(&scsi_device_dir) {
for entry in entries.flatten() {
let device_type_path = entry.path().join("device/type");
if Path::new(&device_type_path).exists() {
if let Ok(content) = std::fs::read_to_string(&device_type_path) {
// Type 0x05 (decimal 5) = CD/DVD drive
if content.trim().parse::<u32>() == Ok(5) {
return true;
}
}
}
}
}
false
})();
if is_optical {
// 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/{dev_name}/device/block");
std::fs::read_dir(&block_dir)
.ok()
.and_then(|mut entries| entries.next())
.and_then(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
} else {
Some(dev_name.to_string())
};
if let Some(bname) = block_name {
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)
let sectors = (kb / 2) as u16;
// Align down to 3 (one aligned unit)
let aligned = (sectors / 3) * 3;
if aligned >= MIN_BATCH_SECTORS {
return aligned.min(MAX_BATCH_SECTORS);
}
}
}
}
DEFAULT_BATCH_SECTORS_OPTICAL
} else {
DEFAULT_BATCH_SECTORS_BLOCK
}
}
// ─── Format helpers ────────────────────────────────────────────────────────
// Old format_* functions replaced by Resolution/FrameRate/AudioChannels/SampleRate enums
#[cfg(test)]
mod tests {
use super::*;
/// Helper: build a DiscTitle with a single video stream at the given resolution.
fn title_with_video(codec: Codec, resolution: Resolution) -> DiscTitle {
DiscTitle {
playlist: "00800.mpls".into(),
playlist_id: 800,
duration_secs: 7200.0,
size_bytes: 0,
clips: Vec::new(),
streams: vec![Stream::Video(VideoStream {
pid: 0x1011,
codec,
resolution,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
secondary: false,
label: String::new(),
})],
chapters: Vec::new(),
extents: Vec::new(),
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
}
}
/// Build a DiscTitle with full control over the fields the title
/// sorter cares about. Used by the canonical-title-order tests.
fn title_with(
playlist: &str,
duration_secs: f64,
size_bytes: u64,
n_clips: usize,
) -> DiscTitle {
let mut t = title_with_video(Codec::Hevc, Resolution::R2160p);
t.playlist = playlist.into();
t.duration_secs = duration_secs;
t.size_bytes = size_bytes;
t.clips = (0..n_clips)
.map(|i| Clip {
clip_id: format!("{i:05}"),
in_time: 0,
out_time: 1,
duration_secs: 1.0,
source_packets: 0,
})
.collect();
t
}
/// Regression for branching-UHD title ordering. Mirrors the live
/// observed *The Amateur (2025)* layout: a 4h13m / 92.4 GB / 253-clip
/// virtual play-all playlist alongside the real 2h02m / 57.2 GB /
/// 1-clip main feature. Disc capacity 58.5 GB. After sorting,
/// titles[0] must be the main feature, not the virtual composite.
#[test]
fn canonical_order_pushes_oversize_play_all_behind_real_main() {
const CAPACITY: u64 = 58_500_000_000; // 58.5 GB
let mut titles = vec![
// Title 1 in the raw MPLS order — virtual play-all
title_with(
"00020.mpls",
4.0 * 3600.0 + 13.0 * 60.0,
92_400_000_000,
253,
),
// Title 2 — actual movie
title_with("00800.mpls", 2.0 * 3600.0 + 2.0 * 60.0, 57_200_000_000, 1),
];
titles.sort_by(|a, b| Disc::canonical_title_order(a, b, CAPACITY));
assert_eq!(
titles[0].playlist, "00800.mpls",
"main feature should land at index 0"
);
assert_eq!(
titles[1].playlist, "00020.mpls",
"virtual play-all should be pushed back"
);
}
/// Non-branching disc: longest 1-clip title is the movie. Sort
/// must not change behaviour — the existing "duration descending"
/// expectation holds when no titles overflow capacity.
#[test]
fn canonical_order_preserves_natural_ranking_on_normal_disc() {
const CAPACITY: u64 = 60_000_000_000;
let mut titles = vec![
title_with("00100.mpls", 600.0, 5_000_000_000, 1), // 10 min menu
title_with("00800.mpls", 7320.0, 55_000_000_000, 1), // 2h02m main feature
title_with("00200.mpls", 1800.0, 2_000_000_000, 1), // 30 min extra
];
titles.sort_by(|a, b| Disc::canonical_title_order(a, b, CAPACITY));
assert_eq!(
titles[0].playlist, "00800.mpls",
"longest valid title still wins"
);
assert_eq!(titles[1].playlist, "00200.mpls");
assert_eq!(titles[2].playlist, "00100.mpls");
}
/// Tiebreak: equal duration + equal capacity-validity → fewer
/// clips wins. A chapter-stitched 3-clip movie should beat a
/// 50-clip virtual composite of the same duration.
#[test]
fn canonical_order_fewer_clips_wins_tiebreak() {
const CAPACITY: u64 = 100_000_000_000;
let mut titles = vec![
title_with("00050.mpls", 7200.0, 50_000_000_000, 50),
title_with("00800.mpls", 7200.0, 50_000_000_000, 3),
];
titles.sort_by(|a, b| Disc::canonical_title_order(a, b, CAPACITY));
assert_eq!(titles[0].playlist, "00800.mpls");
assert_eq!(titles[1].playlist, "00050.mpls");
}
#[test]
fn detect_format_uhd() {
let titles = vec![title_with_video(Codec::Hevc, Resolution::R2160p)];
assert_eq!(Disc::detect_format(&titles), DiscFormat::Uhd);
}
#[test]
fn detect_format_bluray() {
let titles = vec![title_with_video(Codec::H264, Resolution::R1080p)];
assert_eq!(Disc::detect_format(&titles), DiscFormat::BluRay);
}
#[test]
fn detect_format_dvd() {
let titles = vec![title_with_video(Codec::Mpeg2, Resolution::R480i)];
assert_eq!(Disc::detect_format(&titles), DiscFormat::Dvd);
}
#[test]
fn detect_format_empty() {
let titles: Vec<DiscTitle> = Vec::new();
assert_eq!(Disc::detect_format(&titles), DiscFormat::Unknown);
}
#[test]
fn content_format_default_bdts() {
let t = title_with_video(Codec::H264, Resolution::R1080p);
assert_eq!(t.content_format, ContentFormat::BdTs);
}
#[test]
fn content_format_dvd_mpegps() {
let t = DiscTitle {
content_format: ContentFormat::MpegPs,
..title_with_video(Codec::Mpeg2, Resolution::R480i)
};
assert_eq!(t.content_format, ContentFormat::MpegPs);
}
#[test]
fn disc_capacity_gb() {
// Single-layer BD-25: ~12,219,392 sectors
let disc = Disc {
volume_id: String::new(),
meta_title: None,
format: DiscFormat::BluRay,
capacity_sectors: 12_219_392,
capacity_bytes: 12_219_392u64 * 2048,
layers: 1,
titles: Vec::new(),
region: DiscRegion::Free,
aacs: None,
css: None,
encrypted: false,
aacs_error: None,
content_format: ContentFormat::BdTs,
};
let gb = disc.capacity_gb();
// 12,219,392 * 2048 / 1073741824 = ~23.3 GB
assert!((gb - 23.3).abs() < 0.1, "expected ~23.3 GB, got {}", gb);
// Zero sectors
let disc_zero = Disc {
capacity_sectors: 0,
capacity_bytes: 0,
..disc
};
assert_eq!(disc_zero.capacity_gb(), 0.0);
}
#[test]
fn disc_title_duration_display_edge_cases() {
let mut t = DiscTitle::empty();
// 0 seconds
t.duration_secs = 0.0;
assert_eq!(t.duration_display(), "0h 00m");
// 1 second
t.duration_secs = 1.0;
assert_eq!(t.duration_display(), "0h 00m");
// 59 minutes
t.duration_secs = 59.0 * 60.0;
assert_eq!(t.duration_display(), "0h 59m");
// 24 hours
t.duration_secs = 24.0 * 3600.0;
assert_eq!(t.duration_display(), "24h 00m");
}
struct MockReader {
total_sectors: u32,
bad_sectors: std::collections::HashSet<u32>,
}
impl crate::sector::SectorSource for MockReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
let n = count as usize * 2048;
for i in 0..count {
if self.bad_sectors.contains(&(lba + i as u32)) {
return Err(crate::error::Error::DiscRead {
sector: (lba + i as u32) as u64,
status: Some(0x02),
sense: Some(crate::scsi::ScsiSense {
sense_key: 0x02,
asc: 0x04,
ascq: 0x3E,
}),
});
}
}
buf[..n].fill(0xAA);
Ok(n)
}
fn capacity_sectors(&self) -> u32 {
self.total_sectors
}
}
fn make_test_disc(sectors: u32, name: &str) -> Disc {
Disc {
volume_id: name.into(),
meta_title: Some(name.into()),
format: DiscFormat::Uhd,
capacity_sectors: sectors,
capacity_bytes: sectors as u64 * 2048,
layers: 1,
titles: Vec::new(),
region: DiscRegion::Free,
aacs: None,
css: None,
encrypted: false,
aacs_error: None,
content_format: ContentFormat::BdTs,
}
}
#[test]
fn sweep_to_dev_null_no_enodev() {
let tmp = tempfile::tempdir().unwrap();
let iso_path = tmp.path().join("test.iso");
let sectors: u32 = 1000;
let bad: std::collections::HashSet<u32> = [500u32, 501, 502].into_iter().collect();
let mut reader = MockReader {
total_sectors: sectors,
bad_sectors: bad,
};
let disc = make_test_disc(sectors, "T1");
let opts = CopyOptions {
decrypt: false,
multipass: true,
progress: None,
halt: None,
vid: None,
};
let result = disc.copy(&mut reader, &iso_path, &opts);
assert!(
result.is_ok(),
"sweep to regular file should succeed: {:?}",
result.err()
);
}
#[test]
fn sweep_to_dev_null_real() {
let _cleanup = CleanupGuard(std::path::PathBuf::from("/tmp/T2.mapfile"));
let sectors: u32 = 1000;
let bad: std::collections::HashSet<u32> = [500u32, 501, 502].into_iter().collect();
let mut reader = MockReader {
total_sectors: sectors,
bad_sectors: bad,
};
let disc = make_test_disc(sectors, "T2");
let opts = CopyOptions {
decrypt: false,
multipass: true,
progress: None,
halt: None,
vid: None,
};
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
assert!(
result.is_ok(),
"sweep to /dev/null should not fail with ENODEV: {:?}",
result.err()
);
}
struct CleanupGuard(std::path::PathBuf);
impl Drop for CleanupGuard {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
#[test]
fn sweep_dev_null_full_good() {
let _cleanup = CleanupGuard(std::path::PathBuf::from("/tmp/T3.mapfile"));
let sectors: u32 = 2000;
let mut reader = MockReader {
total_sectors: sectors,
bad_sectors: std::collections::HashSet::new(),
};
let disc = make_test_disc(sectors, "T3");
let opts = CopyOptions {
decrypt: false,
multipass: false,
progress: None,
halt: None,
vid: None,
};
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
assert!(
result.is_ok(),
"full-good sweep to /dev/null should succeed: {:?}",
result.err()
);
let r = result.unwrap();
assert!(r.complete, "should be complete");
assert_eq!(r.bytes_good, sectors as u64 * 2048);
}
#[test]
fn patch_dev_null_after_sweep() {
let tmp = tempfile::tempdir().unwrap();
let iso_path = tmp.path().join("test.iso");
let sectors: u32 = 500;
let bad: std::collections::HashSet<u32> = [100u32, 200, 300].into_iter().collect();
let mut reader = MockReader {
total_sectors: sectors,
bad_sectors: bad.clone(),
};
let disc = make_test_disc(sectors, "T4");
let sweep_opts = CopyOptions {
decrypt: false,
multipass: true,
progress: None,
halt: None,
vid: None,
};
let sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts);
assert!(
sweep_result.is_ok(),
"sweep should succeed: {:?}",
sweep_result.err()
);
let mut reader2 = MockReader {
total_sectors: sectors,
bad_sectors: std::collections::HashSet::new(),
};
let patch_opts = CopyOptions {
decrypt: false,
multipass: true,
progress: None,
halt: None,
vid: None,
};
let patch_result = disc.copy(&mut reader2, &iso_path, &patch_opts);
assert!(
patch_result.is_ok(),
"patch should succeed: {:?}",
patch_result.err()
);
let pr = patch_result.unwrap();
assert!(
pr.complete,
"patch should complete: bytes_pending={}",
pr.bytes_pending
);
}
#[test]
fn patch_dev_null_direct() {
let tmp = tempfile::tempdir().unwrap();
let iso_path = tmp.path().join("test.iso");
let sectors: u32 = 500;
let bad: std::collections::HashSet<u32> = [100u32, 200, 300].into_iter().collect();
let mut reader = MockReader {
total_sectors: sectors,
bad_sectors: bad.clone(),
};
let disc = make_test_disc(sectors, "T5");
let sweep_opts = CopyOptions {
decrypt: false,
multipass: true,
progress: None,
halt: None,
vid: None,
};
let _sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts).unwrap();
let mut reader2 = MockReader {
total_sectors: sectors,
bad_sectors: std::collections::HashSet::new(),
};
let patch_opts = CopyOptions {
decrypt: false,
multipass: true,
progress: None,
halt: None,
vid: None,
};
let patch_result = disc.copy(&mut reader2, std::path::Path::new("/dev/null"), &patch_opts);
assert!(
patch_result.is_ok(),
"patch to /dev/null should succeed: {:?}",
patch_result.err()
);
}
/// Synthetic regression test for the 0.18 SweepSink + Pipeline
/// migration. ~100 batches of clean reads (6000 sectors at the
/// default 60-sector single-pass batch size); verifies all bytes
/// land in the ISO and the consumer's final stats match the input.
/// The throughput regression check (vs 0.17.13) is a separate
/// manual / live-drive concern; here we only assert correctness.
#[test]
fn sweep_pipeline_full_good_100_batches() {
let tmp = tempfile::tempdir().unwrap();
let iso_path = tmp.path().join("test.iso");
// 6000 sectors / 60-sector default batch = exactly 100
// produce/consume cycles through the pipeline.
let sectors: u32 = 6000;
let mut reader = MockReader {
total_sectors: sectors,
bad_sectors: std::collections::HashSet::new(),
};
let disc = make_test_disc(sectors, "TPipeline100");
let opts = CopyOptions {
decrypt: false,
multipass: false,
progress: None,
halt: None,
vid: None,
};
let result = disc.copy(&mut reader, &iso_path, &opts);
let r = result.expect("100-batch clean sweep should succeed");
assert!(r.complete, "complete=true expected");
assert!(!r.halted, "halted=false expected");
assert_eq!(
r.bytes_good,
sectors as u64 * 2048,
"all sectors must be marked good after a 100% clean sweep"
);
assert_eq!(
r.bytes_pending, 0,
"no pending bytes expected after a clean sweep"
);
// The ISO file must end up the right size — the consumer
// wrote everything before fsync.
let meta = std::fs::metadata(&iso_path).unwrap();
assert_eq!(meta.len(), sectors as u64 * 2048);
}
}