Breaking: CopyOptions::on_progress + PatchOptions::on_progress now take Fn(bytes_good, pos, total). Consumers display `pos` for "% swept" — the true Pass 1 progress that advances through skip-forward bad zones, where bytes_good (Finished sectors only) freezes. v0.13.14 live trace proved the existing UI was lying for ~14 minutes about Dune 2 being "stuck at 30%" while Pass 1 was actually 83% through the disc via skip-forward. PatchOptions::reverse: walk bad ranges from highest LBA to lowest. For drives that wedge after a forward read of a bad sector, approaching the post-bad-zone NonTrimmed range from end-of-disc reads good sectors before the drive sees a bad one. Hypothesis informed by the BU40N + Initio bridge live data — Pass 2 forward saw zero successful reads in 7 min while Pass 1's pos walked all the way to end-of-disc. PatchOptions::wedged_threshold: > 0 → exit early after that many consecutive failures with zero successes in the same pass. Saves the wallclock budget for productive grinding when the drive has wedged on the bad zone for THIS pass; a different direction or block size in the next pass may still recover. New PatchResult::wedged_exit reports it. Trace: patch_start (block_sectors, recovery, reverse, wedged_threshold, num_ranges) and patch_done (blocks_attempted, blocks_read_ok, blocks_read_failed, wedged_exit, halted, bytes_recovered) at the freemkv::disc target.
1899 lines
63 KiB
Rust
1899 lines
63 KiB
Rust
//! Disc structure -- scan titles, streams, and sector ranges from a Blu-ray disc.
|
|
//!
|
|
//! This is the high-level API for disc content. The CLI calls this,
|
|
//! never parses MPLS/CLPI/UDF directly.
|
|
//!
|
|
//! Usage:
|
|
//! let disc = Disc::scan(&mut session)?;
|
|
//! for title in disc.titles() { ... }
|
|
//! for stream in title.streams() { ... }
|
|
|
|
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<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,
|
|
/// 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,
|
|
}
|
|
|
|
// ─── 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)
|
|
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<std::path::PathBuf>,
|
|
}
|
|
|
|
impl ScanOptions {
|
|
/// Resolve KEYDB path: explicit path first, then standard locations.
|
|
fn resolve_keydb(&self) -> Option<std::path::PathBuf> {
|
|
if let Some(p) = &self.keydb_path {
|
|
if p.exists() {
|
|
return Some(p.clone());
|
|
}
|
|
}
|
|
if let Some(home) = std::env::var_os("HOME").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<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)
|
|
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<Self> {
|
|
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<HandshakeResult>,
|
|
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 = 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<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 {
|
|
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<CopyResult> {
|
|
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,
|
|
};
|
|
|
|
// Skip-forward state.
|
|
let skip_init = 256 * 1024u64; // 256 KB
|
|
let skip_max = (total_bytes / 100).max(skip_init); // cap at 1% of disc
|
|
let mut skip_size = skip_init;
|
|
|
|
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;
|
|
tracing::trace!(
|
|
target: "freemkv::disc",
|
|
phase = "copy_start",
|
|
total_bytes,
|
|
batch,
|
|
skip_init,
|
|
skip_max,
|
|
"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 lba = (pos / 2048) as u32;
|
|
let count = (block_bytes / 2048) as u16;
|
|
let bytes = count as usize * 2048;
|
|
|
|
let recovery = !opts.skip_on_error; // fast reads when skipping
|
|
iter_count += 1;
|
|
let read_t0 = std::time::Instant::now();
|
|
let read_ok = reader
|
|
.read_sectors(lba, count, &mut buf[..bytes], recovery)
|
|
.is_ok();
|
|
let read_elapsed_ms = read_t0.elapsed().as_millis() as u64;
|
|
|
|
if read_ok {
|
|
read_ok_count += 1;
|
|
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 })?;
|
|
bytes_done = bytes_done.saturating_add(block_bytes);
|
|
skip_size = skip_init; // reset after success
|
|
pos += block_bytes;
|
|
} else if opts.skip_on_error {
|
|
read_err_count += 1;
|
|
// Zero-fill this block, mark non-trimmed for later patch trim.
|
|
buf[..bytes].fill(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::NonTrimmed)
|
|
.map_err(|e| Error::IoError { source: e })?;
|
|
pos += block_bytes;
|
|
|
|
if opts.skip_forward && pos < region_end {
|
|
// Skip ahead; mark skipped bytes as non-trimmed too.
|
|
let jump = skip_size.min(region_end - pos);
|
|
if jump > 0 {
|
|
map.record(pos, jump, mapfile::SectorStatus::NonTrimmed)
|
|
.map_err(|e| Error::IoError { source: e })?;
|
|
pos += jump;
|
|
}
|
|
skip_size = (skip_size * 2).min(skip_max);
|
|
}
|
|
} else {
|
|
// Current behavior (pre-0.11.21): abort on first bad sector.
|
|
return Err(Error::DiscRead { sector: lba as u64 });
|
|
}
|
|
|
|
// 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,
|
|
last_read_ms = read_elapsed_ms,
|
|
pos,
|
|
region_end,
|
|
skip_size,
|
|
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(cb) = opts.on_progress {
|
|
let stats = map.stats();
|
|
cb(stats.bytes_good, pos, total_bytes);
|
|
}
|
|
}
|
|
}
|
|
|
|
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<u16>,
|
|
/// 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,
|
|
/// Callback fired per inner-loop iteration with
|
|
/// `(bytes_good, pos, total_bytes)`. `pos` is the current sweep
|
|
/// position — true Pass 1 progress, including skipped-forward NonTrimmed
|
|
/// ranges. `bytes_good` is the count of `Finished` (clean) sectors,
|
|
/// which doesn't advance through bad zones. UI should display `pos`
|
|
/// for "swept" progress and `bytes_good` for "real data recovered".
|
|
pub on_progress: Option<&'a dyn Fn(u64, u64, u64)>,
|
|
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
|
}
|
|
|
|
/// 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<u16>,
|
|
/// 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,
|
|
/// Callback fired per inner-loop iteration with
|
|
/// `(bytes_good, pos, total_bytes)`. `pos` is the current LBA-byte
|
|
/// position within the patch walk; for reverse passes it counts down.
|
|
pub on_progress: Option<&'a dyn Fn(u64, u64, u64)>,
|
|
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
|
}
|
|
|
|
/// 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<PatchResult> {
|
|
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();
|
|
}
|
|
tracing::trace!(
|
|
target: "freemkv::disc",
|
|
phase = "patch_start",
|
|
block_sectors,
|
|
recovery,
|
|
reverse = opts.reverse,
|
|
wedged_threshold = opts.wedged_threshold,
|
|
num_ranges = bad_ranges.len(),
|
|
"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_ok = reader
|
|
.read_sectors(lba, count, &mut buf[..bytes], recovery)
|
|
.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;
|
|
}
|
|
|
|
if let Some(cb) = opts.on_progress {
|
|
let s = map.stats();
|
|
cb(s.bytes_good, pos, 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;
|
|
|
|
/// Detect the maximum transfer size in sectors for a device.
|
|
/// Reads /sys/block/<dev>/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::<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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// 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<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,
|
|
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");
|
|
}
|
|
}
|