//! 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; use crate::drive::Drive; use crate::error::{Error, Result}; use crate::sector::SectorReader; 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, /// 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, /// Disc region pub region: DiscRegion, /// AACS state -- None if disc is unencrypted or keys unavailable pub aacs: Option, /// CSS state -- None if not a CSS-encrypted DVD pub css: Option, /// Whether this disc requires decryption (AACS or CSS) pub encrypted: bool, /// 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), /// DVD regions (1-8 or combination) Dvd(Vec), } /// 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, /// All streams (video, audio, subtitle, etc.) pub streams: Vec, /// Chapter points pub chapters: Vec, /// Sector extents for ripping (clip LBA ranges) pub extents: Vec, /// 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>>, } /// 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>, } /// 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, } // ─── 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 { 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 { 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 { 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, /// 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", // 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, } impl ScanOptions { /// Resolve KEYDB path: explicit path first, then standard locations. fn resolve_keydb(&self) -> Option { 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. "V_FOR_VENDETTA") pub volume_id: String, /// Disc title from META/DL/bdmt_eng.xml (e.g. "V for Vendetta") pub meta_title: Option, /// 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 { 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 { // AACS handshake (Blu-ray/UHD) let handshake = 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, 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. if disc.css.is_none() && disc.content_format == ContentFormat::MpegPs && !disc.titles.is_empty() { let lba = disc.titles[0].extents.iter().find_map(|ext| { let mut buf = vec![0u8; 2048]; if session .read_sectors(ext.start_lba, 1, &mut buf, true) .is_ok() && crate::css::is_scrambled(&buf) { return Some(ext.start_lba); } None }); if let Some(lba) = lba { if let Ok(title_key) = crate::css::auth::authenticate_and_read_title_key(session, lba) { disc.css = Some(crate::css::CssState { title_key }); disc.encrypted = true; } } } Ok(disc) } /// Scan a disc image (ISO or any SectorReader). No SCSI, no handshake. /// AACS resolution uses KEYDB VUK lookup only. pub fn scan_image( reader: &mut dyn SectorReader, capacity: u32, opts: &ScanOptions, ) -> Result { let udf_fs = udf::read_filesystem(reader)?; Self::scan_with(reader, capacity, None, opts, udf_fs) } /// Core scan pipeline — works with any SectorReader. fn scan_with( reader: &mut dyn SectorReader, capacity: u32, handshake: Option, opts: &ScanOptions, udf_fs: udf::UdfFs, ) -> Result { // 2. Resolve encryption (AACS, CSS, or none) let encrypted = udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some(); let aacs = if encrypted { if let Some(keydb_path) = opts.resolve_keydb() { Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref()).ok() } else { None } } else { None }; // 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) }; titles.sort_by(|a, b| { b.duration_secs .partial_cmp(&a.duration_secs) .unwrap_or(std::cmp::Ordering::Equal) }); // 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 let css = if content_format == ContentFormat::MpegPs && !titles.is_empty() { crate::css::crack_key(reader, &titles[0].extents) } 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, content_format, }) } // ── Internal helpers ──────────────────────────────────────────────────── /// Detect disc format from the main title's video streams. 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 { 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 { 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 } } /// Raw sector copy — write the entire disc image to a file. /// /// NOT a stream operation. Copies sectors 0→capacity 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. /// /// # Options /// - **default** (all false): behavior matches pre-v0.11.21 — uses full /// drive recovery (may take minutes per bad sector), aborts on error. /// Mapfile is produced as a side-effect. /// - **skip_on_error**: zero-fill bad blocks in the ISO, mark them in the /// mapfile, and continue. Uses fast reads (no drive-level recovery loop). /// - **skip_forward** (implies skip_on_error): on block failure, also skip /// forward by an exponentially-growing amount, marking the jumped region /// as `non-trimmed` for later trimming/scraping by `Disc::patch`. /// - **resume**: if the mapfile exists, resume from its state — only /// `non-tried` ranges are read. Without `resume`, a fresh mapfile is /// written and the ISO recreated from scratch. pub fn copy( &self, reader: &mut dyn SectorReader, path: &std::path::Path, opts: &CopyOptions, ) -> Result { use std::io::{Seek, SeekFrom, Write}; let total_bytes = self.capacity_sectors as u64 * 2048; let keys = if opts.decrypt { self.decrypt_keys() } else { crate::decrypt::DecryptKeys::None }; // Mapfile: load if resuming, else wipe + recreate. let mapfile_path = mapfile_path_for(path); if !opts.resume { let _ = std::fs::remove_file(&mapfile_path); } let mut map = mapfile::Mapfile::open_or_create(&mapfile_path, total_bytes, env!("CARGO_PKG_VERSION")) .map_err(|e| Error::IoError { source: e })?; // 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 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 })?; f.set_len(total_bytes) .map_err(|e| Error::IoError { source: e })?; f }; let mut file = file; let batch: u16 = match opts.batch_sectors { Some(b) => b, None if opts.skip_forward => 32, // 64 KB = BD ECC block size None => DEFAULT_BATCH_SECTORS, }; 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; // Hysteresis state (0.13.22): Block(batch) <-> Single(bpt=1). // mode_single=false means we're in Block mode (default). On a // multi-sector read failure we flip to Single, walk the failed // range sector-by-sector, and stay in Single until we hit // BPT1_EXIT_THRESHOLD consecutive good reads — then back to Block. let mut mode_single = false; let mut consecutive_good: u64 = 0; // Wallclock cadence for the progress callback. Default outer-loop // tick is per-block, which is once every few ms in clean territory // but can be tens of seconds (or minutes) in dense single-mode // recovery. Fire the callback on a wallclock interval too so the // UI shows movement even mid-cluster. let mut last_progress_t = std::time::Instant::now(); const PROGRESS_TICK: std::time::Duration = std::time::Duration::from_secs(2); tracing::trace!( target: "freemkv::disc", phase = "copy_start", total_bytes, batch, bpt1_exit_threshold = BPT1_EXIT_THRESHOLD, "Disc::copy entered" ); // Iterate over not-yet-finished regions from the mapfile. We re-read the // mapfile after each block because record() mutates the region list. 'outer: loop { let regions_to_do = map.ranges_with(&[ mapfile::SectorStatus::NonTried, mapfile::SectorStatus::NonTrimmed, mapfile::SectorStatus::NonScraped, ]); tracing::trace!( target: "freemkv::disc", phase = "outer_loop", regions_remaining = regions_to_do.len(), "Disc::copy outer iter" ); if regions_to_do.is_empty() { break; } // Only process the first NonTried range per outer pass; skip_forward // may turn others into NonTrimmed which we DO NOT re-enter here — // Disc::patch handles those. let Some((region_pos, region_size)) = map.next_with(0, mapfile::SectorStatus::NonTried) else { break; }; 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; // Hysteresis state machine (0.13.22, replaces v0.13.21 // bisect-on-fail): two states. // // Block(batch): try the full block. On success, write, // advance, stay in Block. On failure, switch to Single // and retry the same range one sector at a time. // // Single: read at bpt=1. Each success increments // consecutive_good; once that reaches BPT1_EXIT_THRESHOLD // we switch back to Block. Each failure marks the sector // NonTrimmed and resets consecutive_good = 0. // // Why not bisection: the v0.13.21 bisect-on-fail descended // log2(batch) levels on every multi-sector failure, paying // a ~5 s kernel timeout at every level. For a 60-block with // 1 bad sector the cost was ~30 s. Direct drop to bpt=1 // pays ~10 s for the same outcome. And once we're inside a // bad cluster we stay there at bpt=1 instead of repeatedly // re-trying bpt=batch (each fail = another ~5 s wasted). // // Empirical justification: // freemkv-private/docs/audits/2026-04-26-bisect-on-fail-empirical-findings.md let block_t0 = std::time::Instant::now(); let read_t0 = block_t0; let block_bytes_usz = block_bytes as usize; iter_count += 1; let block_result = reader.read_sectors( block_lba, block_count, &mut buf[..block_bytes_usz], recovery, ); if block_result.is_ok() { // Fast path — full block read cleanly. read_ok_count += 1; if opts.decrypt { crate::decrypt::decrypt_sectors( &mut buf[..block_bytes_usz], &keys, 0, )?; } file.seek(SeekFrom::Start(pos)) .map_err(|e| Error::IoError { source: e })?; file.write_all(&buf[..block_bytes_usz]) .map_err(|e| Error::IoError { source: e })?; map.record(pos, block_bytes, mapfile::SectorStatus::Finished) .map_err(|e| Error::IoError { source: e })?; bytes_done = bytes_done.saturating_add(block_bytes); } else if !opts.skip_on_error { // Strict mode (skip_on_error=false): abort on first bad. return Err(Error::DiscRead { sector: block_lba as u64, }); } else if !block_result .as_ref() .err() .map(Error::is_marginal_read) .unwrap_or(false) { // 0.13.23: SCSI sense-aware dispatch. Block read failed // with a sense class outside the marginal-read set // (real transport failure, HARDWARE ERROR, DATA // PROTECT, UNIT ATTENTION, NOT READY, ILLEGAL // REQUEST, kernel IoError). Bail with the full sense // triple preserved — caller (autorip) surfaces // "physical replug needed" / "drive failing" / // "media changed" / etc to the user. Hysteresis // would just hammer the same failure for thousands // of sectors at 1.4 sec each. let err = block_result.err().unwrap(); tracing::trace!( target: "freemkv::disc", phase = "bail", lba = block_lba, error = %err, "block read failed with non-recoverable sense; bailing" ); return Err(err); } else { // Block failed → drop to Single and read this range // sector-by-sector. Stay in Single across subsequent // outer-loop blocks until we hit // BPT1_EXIT_THRESHOLD consecutive good single-sector // reads, then return to Block mode. if !mode_single { tracing::trace!( target: "freemkv::disc", phase = "mode_change", from = "Block", to = "Single", lba = block_lba, block_elapsed_ms = read_t0.elapsed().as_millis() as u64, "block read failed; switching to bpt=1" ); mode_single = true; consecutive_good = 0; } // Walk the failed block one sector at a time. for s in 0..block_count { if let Some(ref h) = opts.halt { if h.load(std::sync::atomic::Ordering::Relaxed) { halt_requested = true; break 'outer; } } iter_count += 1; let s_lba = block_lba + s as u32; let s_pos = pos + (s as u64) * 2048; let one_bytes = 2048usize; let one_result = reader.read_sectors(s_lba, 1, &mut buf[..one_bytes], recovery); // 0.13.23: same sense-aware dispatch inside Single // mode. If a single-sector read fails with a // non-marginal sense (transport / hardware / // DATA PROTECT / UNIT ATTENTION / NOT READY / // ILLEGAL REQUEST / kernel IoError), the drive // isn't going to start succeeding for the next // 60 sectors either — bail with full sense info // rather than chewing through bpt=1 timeouts. if let Err(ref e) = one_result { if !e.is_marginal_read() { let err = one_result.err().unwrap(); tracing::trace!( target: "freemkv::disc", phase = "bail", lba = s_lba, error = %err, "bpt=1 read failed with non-marginal sense; bailing" ); return Err(err); } } let one_ok = one_result.is_ok(); if one_ok { read_ok_count += 1; consecutive_good = consecutive_good.saturating_add(1); if opts.decrypt { crate::decrypt::decrypt_sectors( &mut buf[..one_bytes], &keys, 0, )?; } file.seek(SeekFrom::Start(s_pos)) .map_err(|e| Error::IoError { source: e })?; file.write_all(&buf[..one_bytes]) .map_err(|e| Error::IoError { source: e })?; map.record(s_pos, 2048, mapfile::SectorStatus::Finished) .map_err(|e| Error::IoError { source: e })?; bytes_done = bytes_done.saturating_add(2048); if consecutive_good >= BPT1_EXIT_THRESHOLD { tracing::trace!( target: "freemkv::disc", phase = "mode_change", from = "Single", to = "Block", lba = s_lba + 1, consecutive_good, "exit threshold reached; returning to bpt=batch" ); mode_single = false; consecutive_good = 0; } } else { read_err_count += 1; consecutive_good = 0; buf[..one_bytes].fill(0); file.seek(SeekFrom::Start(s_pos)) .map_err(|e| Error::IoError { source: e })?; file.write_all(&buf[..one_bytes]) .map_err(|e| Error::IoError { source: e })?; map.record(s_pos, 2048, mapfile::SectorStatus::NonTrimmed) .map_err(|e| Error::IoError { source: e })?; } // Wallclock-cadence progress callback during // single-mode grinding. Without this, the UI // appears frozen for tens of seconds while we // chew through bad sectors. if last_progress_t.elapsed() >= PROGRESS_TICK { last_progress_t = std::time::Instant::now(); if let Some(reporter) = opts.progress { let stats = map.stats(); reporter.report(&crate::progress::PassProgress { kind: crate::progress::PassKind::Sweep, work_done: pos + (s as u64 + 1) * 2048, work_total: total_bytes, bytes_good_total: stats.bytes_good, bytes_total_disc: total_bytes, }); } } } } pos += block_bytes; // Throttled iter telemetry — every 100 inner iterations. if iter_count - last_log_iter >= 100 { last_log_iter = iter_count; let stats = map.stats(); tracing::trace!( target: "freemkv::disc", phase = "iter_progress", iter_count, read_ok_count, read_err_count, pos, region_end, bytes_good = stats.bytes_good, bytes_pending = stats.bytes_pending, copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64, "Disc::copy inner iter" ); } if let Some(reporter) = opts.progress { let stats = map.stats(); reporter.report(&crate::progress::PassProgress { kind: crate::progress::PassKind::Sweep, work_done: pos, work_total: total_bytes, bytes_good_total: stats.bytes_good, bytes_total_disc: total_bytes, }); last_progress_t = std::time::Instant::now(); } } } file.sync_all().map_err(|e| Error::IoError { source: e })?; let stats = map.stats(); tracing::trace!( target: "freemkv::disc", phase = "copy_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::copy returning" ); Ok(CopyResult { bytes_total: total_bytes, bytes_good: stats.bytes_good, bytes_unreadable: stats.bytes_unreadable, bytes_pending: stats.bytes_pending, complete: stats.bytes_pending == 0 && !halt_requested, halted: halt_requested, }) } } /// Options for `Disc::copy`. All fields default to the pre-v0.11.21 behavior /// (recovery reads, abort on bad sector). #[derive(Default)] pub struct CopyOptions<'a> { pub decrypt: bool, /// Resume from existing mapfile + ISO if present. Without this, any /// existing mapfile is wiped and the ISO recreated. pub resume: bool, /// Override the default block size in sectors. Callers should resolve /// this with `detect_max_batch_sectors(device_path)` for live drives. /// When `None`, falls back to 32 sectors (64 KB BD ECC block) in /// `skip_forward` mode or `DEFAULT_BATCH_SECTORS=60` otherwise. pub batch_sectors: Option, /// Zero-fill bad blocks in the ISO, mark them in the mapfile, continue. /// Uses fast reads (no drive-level recovery loop). pub skip_on_error: bool, /// ddrescue-style exponential skip-forward on block failure. Implies /// `skip_on_error`. The skipped region is marked `non-trimmed` for later /// trimming/scraping by `Disc::patch`. pub skip_forward: bool, /// Per-iteration progress reporter. v0.13.16 architecture: the library /// emits a single `PassProgress` shape via the `Progress` trait; /// consumers compute their own derived percentages / ETAs from it. No /// more positional `(bytes_good, pos, total)` callbacks. pub progress: Option<&'a dyn crate::progress::Progress>, pub halt: Option>, } /// Result of `Disc::copy`. `complete=true` means every byte reached a terminal /// state (Finished or Unreadable). `complete=false` means there's still pending /// work (halt, abort, or non-tried ranges) that `Disc::patch` or a resumed /// `Disc::copy` would continue. #[derive(Debug, Clone, Copy)] pub struct CopyResult { pub bytes_total: u64, pub bytes_good: u64, pub bytes_unreadable: u64, pub bytes_pending: u64, pub complete: bool, pub halted: bool, } /// Sidecar mapfile path for a given ISO path — `foo.iso` → `foo.iso.mapfile`. 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) } /// Options for `Disc::patch`. Idempotent — each call is one patch attempt. #[derive(Default)] pub struct PatchOptions<'a> { pub decrypt: bool, /// Sector-granularity block size for retries. Defaults to 1 sector (2 KB). pub block_sectors: Option, /// Use full drive-level recovery on each read (slow but thorough). Defaults /// to true — patch is the pass where we *want* the drive to try hard. pub full_recovery: bool, /// Walk bad ranges in reverse order, and within each range walk sectors /// from high to low LBA. Useful for drives that wedge after a forward /// read of a bad sector — approaching the post-bad-zone from end-of-disc /// reads good sectors before the drive sees a bad one. pub reverse: bool, /// Bail out early if this many consecutive read failures occur with zero /// successful reads in the same pass — i.e. the drive is wedged on the /// bad zone and won't recover during this attempt. `0` disables the /// guard (run to completion or halt). pub wedged_threshold: u64, /// Per-iteration progress reporter. See `CopyOptions::progress`. pub progress: Option<&'a dyn crate::progress::Progress>, pub halt: Option>, } /// Result of `Disc::patch` — how many bad bytes were recovered, plus /// per-block counters for diagnosing why a pass made or didn't make progress. #[derive(Debug, Clone, Copy)] pub struct PatchResult { 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, /// Total inner-loop iterations this pass (one per block attempted). pub blocks_attempted: u64, /// Reads that returned Ok and were promoted to `Finished`. pub blocks_read_ok: u64, /// Reads that returned Err and were marked `Unreadable`. pub blocks_read_failed: u64, /// Pass exited early because `wedged_threshold` consecutive failures /// occurred with zero successful reads — drive appears wedged on the /// bad zone for this pass. pub wedged_exit: bool, } impl Disc { /// Patch an existing ISO using its sidecar mapfile. Re-reads every range /// that's not yet `+` (Finished) and writes successful bytes into the ISO /// at their exact offsets. Updates mapfile entries as it goes. /// /// Idempotent — call repeatedly to apply more retry attempts. Stops early /// if a pass recovered zero bytes (no point continuing). pub fn patch( &self, reader: &mut dyn SectorReader, path: &std::path::Path, opts: &PatchOptions, ) -> Result { use std::io::{Seek, SeekFrom, Write}; let mapfile_path = mapfile_path_for(path); let mut map = mapfile::Mapfile::load(&mapfile_path).map_err(|e| Error::IoError { source: e })?; let total_bytes = map.total_size(); let keys = if opts.decrypt { self.decrypt_keys() } else { crate::decrypt::DecryptKeys::None }; let mut file = std::fs::OpenOptions::new() .write(true) .open(path) .map_err(|e| Error::IoError { source: e })?; let block_sectors = opts.block_sectors.unwrap_or(1); let recovery = opts.full_recovery; let bytes_good_before = map.stats().bytes_good; let mut halted = false; let mut wedged_exit = false; let mut blocks_attempted: u64 = 0; let mut blocks_read_ok: u64 = 0; let mut blocks_read_failed: u64 = 0; let mut consecutive_failures: u64 = 0; let mut buf = vec![0u8; block_sectors as usize * 2048]; // Collect bad ranges up front. Iterating while mutating is fragile; // each recorded change is persisted, so resume works even if we crash // mid-loop. let mut bad_ranges = map.ranges_with(&[ mapfile::SectorStatus::NonTried, mapfile::SectorStatus::NonTrimmed, mapfile::SectorStatus::NonScraped, mapfile::SectorStatus::Unreadable, ]); // Reverse mode: walk ranges from highest LBA to lowest. if opts.reverse { bad_ranges.reverse(); } // work_total = sum of all bad-range bytes. on_progress's third arg // is this value; the second arg (work_done) is incremented per block // attempted. UI consumers (autorip) compute pass_progress_pct = // work_done / work_total — true 0..100% per pass per RIP_DESIGN.md §16. let work_total: u64 = bad_ranges.iter().map(|(_, sz)| *sz).sum(); let mut work_done: u64 = 0; tracing::trace!( target: "freemkv::disc", phase = "patch_start", block_sectors, recovery, reverse = opts.reverse, wedged_threshold = opts.wedged_threshold, num_ranges = bad_ranges.len(), work_total, "Disc::patch entered" ); 'outer: for (range_pos, range_size) in bad_ranges { let end = range_pos + range_size; // In reverse mode, walk this range from end - block_bytes back to range_pos. // Each iteration emits the block ending at `block_end` (so reads land on // increasing LBAs internally; we just choose blocks back-to-front). let mut block_end = if opts.reverse { end } else { range_pos }; loop { if let Some(ref h) = opts.halt { if h.load(std::sync::atomic::Ordering::Relaxed) { halted = true; break 'outer; } } // Compute block boundaries based on direction. let (pos, block_bytes) = if opts.reverse { if block_end <= range_pos { break; } let span = (block_end - range_pos).min(block_sectors as u64 * 2048); (block_end - span, span) } else { if block_end >= end { break; } let span = (end - block_end).min(block_sectors as u64 * 2048); (block_end, span) }; let lba = (pos / 2048) as u32; let count = (block_bytes / 2048) as u16; let bytes = count as usize * 2048; blocks_attempted += 1; let read_result = reader.read_sectors(lba, count, &mut buf[..bytes], recovery); // 0.13.23: parity with Disc::copy — bail immediately on a // non-marginal SCSI sense (transport failure, HARDWARE // ERROR, DATA PROTECT, UNIT ATTENTION, NOT READY, // ILLEGAL REQUEST, kernel IoError). The wedged_threshold // counter is a defense-in-depth backstop for ~50 // consecutive marginal failures, but a single // non-marginal error already proves the drive can't // produce data this pass — don't waste 50× the timeout // budget proving it again. if let Err(ref e) = read_result { if !e.is_marginal_read() { let err = read_result.err().unwrap(); tracing::trace!( target: "freemkv::disc", phase = "patch_bail", lba, error = %err, "patch read failed with non-marginal sense; bailing" ); return Err(err); } } let read_ok = read_result.is_ok(); if read_ok { blocks_read_ok += 1; consecutive_failures = 0; if opts.decrypt { crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?; } file.seek(SeekFrom::Start(pos)) .map_err(|e| Error::IoError { source: e })?; file.write_all(&buf[..bytes]) .map_err(|e| Error::IoError { source: e })?; map.record(pos, block_bytes, mapfile::SectorStatus::Finished) .map_err(|e| Error::IoError { source: e })?; } else { blocks_read_failed += 1; consecutive_failures += 1; map.record(pos, block_bytes, mapfile::SectorStatus::Unreadable) .map_err(|e| Error::IoError { source: e })?; } // Advance block_end in chosen direction. if opts.reverse { block_end = block_end.saturating_sub(block_bytes); } else { block_end += block_bytes; } // Wedged-drive early-exit: many consecutive failures with zero // recovered bytes this pass means the drive is stuck and won't // produce data this pass. Save the wallclock budget for productive // grinding; future passes (with smaller block size, reverse, or // after settle) may still recover. if opts.wedged_threshold > 0 && consecutive_failures >= opts.wedged_threshold && blocks_read_ok == 0 { tracing::trace!( target: "freemkv::disc", phase = "patch_wedged_exit", consecutive_failures, blocks_read_failed, "Disc::patch giving up — drive appears wedged" ); wedged_exit = true; break 'outer; } // Track work done in this pass for the per-pass progress bar. // Each block iterated counts as work, regardless of read // outcome — a failed retry is still progress through the // bad-range walk. work_done = work_done.saturating_add(block_bytes); if let Some(reporter) = opts.progress { let s = map.stats(); let kind = if block_sectors == 1 { crate::progress::PassKind::Scrape { reverse: opts.reverse, } } else { crate::progress::PassKind::Trim { reverse: opts.reverse, } }; reporter.report(&crate::progress::PassProgress { kind, work_done, work_total, bytes_good_total: s.bytes_good, bytes_total_disc: total_bytes, }); } } } file.sync_all().map_err(|e| Error::IoError { source: e })?; let stats = map.stats(); tracing::trace!( target: "freemkv::disc", phase = "patch_done", blocks_attempted, blocks_read_ok, blocks_read_failed, wedged_exit, halted, bytes_recovered = stats.bytes_good.saturating_sub(bytes_good_before), "Disc::patch returning" ); Ok(PatchResult { bytes_total: total_bytes, bytes_good: stats.bytes_good, bytes_unreadable: stats.bytes_unreadable, bytes_pending: stats.bytes_pending, bytes_recovered_this_pass: stats.bytes_good.saturating_sub(bytes_good_before), halted, blocks_attempted, blocks_read_ok, blocks_read_failed, wedged_exit, }) } } const MAX_BATCH_SECTORS: u16 = 510; const DEFAULT_BATCH_SECTORS: u16 = 60; const MIN_BATCH_SECTORS: u16 = 3; /// Number of consecutive good single-sector reads required to exit /// `Single` mode (bpt=1) and return to `Block` mode (bpt=batch). 10 000 /// sectors ≈ 20 MB of clean data — long enough that we don't bounce in /// and out of bpt=1 inside a sparse-bad cluster, short enough that we /// don't waste much time reading clean territory at bpt=1 after the /// damaged region ends. Tunable; calibrated from the 2026-04-26 BU40N /// live test. const BPT1_EXIT_THRESHOLD: u64 = 10_000; /// 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, /// 1–50 bad sectors AND <1 sec lost. Likely unnoticeable. Cosmetic, /// 51–500 sectors OR 1–30 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. /// Reads /sys/block//queue/max_hw_sectors_kb on Linux. /// For sg devices, resolves the corresponding block device via sysfs. /// Returns a value aligned to 3 sectors (one aligned unit). 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; } // 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::() { // 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); } } } } // Fallback: safe default well under typical kernel limits DEFAULT_BATCH_SECTORS } // ─── 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(), } } #[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 = 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, 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"); } }