Skip to main content

libfreemkv/disc/
mod.rs

1//! Disc structure -- scan titles, streams, and sector ranges from a Blu-ray disc.
2//!
3//! This is the high-level API for disc content. The CLI calls this,
4//! never parses MPLS/CLPI/UDF directly.
5//!
6//! Usage:
7//!   let disc = Disc::scan(&mut session)?;
8//!   for title in disc.titles() { ... }
9//!   for stream in title.streams() { ... }
10
11mod bluray;
12mod dvd;
13mod encrypt;
14pub mod mapfile;
15
16use crate::drive::Drive;
17use crate::error::{Error, Result};
18use crate::sector::SectorReader;
19use crate::udf;
20
21use encrypt::HandshakeResult;
22
23// Re-export label classification enums alongside AudioStream / SubtitleStream
24// so the public surface keeps the structured metadata together. Callers map
25// these to display text in their own locale.
26pub use crate::labels::{LabelPurpose, LabelQualifier};
27
28// ─── Public types ───────────────────────────────────────────────────────────
29
30/// A scanned Blu-ray disc.
31#[derive(Debug)]
32pub struct Disc {
33    /// UDF Volume Identifier from Primary Volume Descriptor (always present)
34    pub volume_id: String,
35    /// Disc title from META/DL/bdmt_eng.xml (None if disc has no metadata)
36    pub meta_title: Option<String>,
37    /// Disc format (BD, UHD, DVD)
38    pub format: DiscFormat,
39    /// Disc capacity in sectors
40    pub capacity_sectors: u32,
41    /// Disc capacity in bytes
42    pub capacity_bytes: u64,
43    /// Number of layers (1 = single, 2 = dual)
44    pub layers: u8,
45    /// Titles sorted by duration (longest first), then playlist name
46    pub titles: Vec<DiscTitle>,
47    /// Disc region
48    pub region: DiscRegion,
49    /// AACS state -- None if disc is unencrypted or keys unavailable
50    pub aacs: Option<AacsState>,
51    /// CSS state -- None if not a CSS-encrypted DVD
52    pub css: Option<crate::css::CssState>,
53    /// Whether this disc requires decryption (AACS or CSS)
54    pub encrypted: bool,
55    /// Content format (BD transport stream vs DVD program stream)
56    pub content_format: ContentFormat,
57}
58
59/// Content format — determines how sectors are interpreted downstream.
60#[derive(Debug, Clone, Copy, PartialEq)]
61pub enum ContentFormat {
62    /// Blu-ray BD Transport Stream (192-byte packets)
63    BdTs,
64    /// DVD MPEG-2 Program Stream (VOB)
65    MpegPs,
66}
67
68/// Disc format.
69#[derive(Debug, Clone, Copy, PartialEq)]
70pub enum DiscFormat {
71    /// 4K UHD Blu-ray (HEVC 2160p)
72    Uhd,
73    /// Standard Blu-ray (1080p/1080i)
74    BluRay,
75    /// DVD
76    Dvd,
77    /// Unknown
78    Unknown,
79}
80
81/// Disc playback region.
82#[derive(Debug, Clone, PartialEq)]
83pub enum DiscRegion {
84    /// Region-free (all UHD discs, some BD/DVD)
85    Free,
86    /// Blu-ray regions (A/B/C or combination)
87    BluRay(Vec<BdRegion>),
88    /// DVD regions (1-8 or combination)
89    Dvd(Vec<u8>),
90}
91
92/// Blu-ray region codes.
93#[derive(Debug, Clone, Copy, PartialEq)]
94pub enum BdRegion {
95    /// Region A/1 -- Americas, East Asia (Japan, Korea, Southeast Asia)
96    A,
97    /// Region B/2 -- Europe, Africa, Australia, Middle East
98    B,
99    /// Region C/3 -- Central/South Asia, China, Russia
100    C,
101}
102
103/// A title (one MPLS playlist).
104#[derive(Debug, Clone)]
105pub struct DiscTitle {
106    /// Playlist filename (e.g. "00800.mpls")
107    pub playlist: String,
108    /// Playlist number (e.g. 800)
109    pub playlist_id: u16,
110    /// Duration in seconds
111    pub duration_secs: f64,
112    /// Total size in bytes
113    pub size_bytes: u64,
114    /// Clip references in playback order
115    pub clips: Vec<Clip>,
116    /// All streams (video, audio, subtitle, etc.)
117    pub streams: Vec<Stream>,
118    /// Chapter points
119    pub chapters: Vec<Chapter>,
120    /// Sector extents for ripping (clip LBA ranges)
121    pub extents: Vec<Extent>,
122    /// Content format for this title
123    pub content_format: ContentFormat,
124    /// Codec initialization data per stream (SPS/PPS, etc).
125    /// Index matches `streams`. None for streams without codec init data.
126    pub codec_privates: Vec<Option<Vec<u8>>>,
127}
128
129/// A clip reference within a title.
130#[derive(Debug, Clone)]
131pub struct Clip {
132    /// Clip filename without extension (e.g. "00001")
133    pub clip_id: String,
134    /// In-time in 45kHz ticks
135    pub in_time: u32,
136    /// Out-time in 45kHz ticks
137    pub out_time: u32,
138    /// Duration in seconds
139    pub duration_secs: f64,
140    /// Source packet count (from CLPI, 0 if unavailable)
141    pub source_packets: u32,
142}
143
144/// A stream within a title.
145#[derive(Debug, Clone)]
146pub enum Stream {
147    Video(VideoStream),
148    Audio(AudioStream),
149    Subtitle(SubtitleStream),
150}
151
152/// A video stream.
153#[derive(Debug, Clone)]
154pub struct VideoStream {
155    /// MPEG-TS packet ID
156    pub pid: u16,
157    /// Codec (HEVC, H.264, VC-1, MPEG-2)
158    pub codec: Codec,
159    /// Resolution
160    pub resolution: Resolution,
161    /// Frame rate
162    pub frame_rate: FrameRate,
163    /// HDR format
164    pub hdr: HdrFormat,
165    /// Color space
166    pub color_space: ColorSpace,
167    /// Whether this is a secondary stream (PiP, Dolby Vision EL)
168    pub secondary: bool,
169    /// Extra label (e.g. "Dolby Vision EL")
170    pub label: String,
171}
172
173/// An audio stream.
174#[derive(Debug, Clone)]
175pub struct AudioStream {
176    /// MPEG-TS packet ID
177    pub pid: u16,
178    /// Codec (TrueHD, DTS-HD MA, DD, LPCM, etc.)
179    pub codec: Codec,
180    /// Channel layout
181    pub channels: AudioChannels,
182    /// ISO 639-2 language code (e.g. "eng", "fra")
183    pub language: String,
184    /// Sample rate
185    pub sample_rate: SampleRate,
186    /// Whether this is a secondary stream (commentary)
187    pub secondary: bool,
188    /// Stream purpose (commentary / descriptive / score / IME / normal).
189    /// Callers translate this to display text in their own locale.
190    pub purpose: LabelPurpose,
191    /// Codec / variant text (e.g. "Dolby TrueHD 5.1", "(US)").
192    /// NEVER contains English purpose words — see `purpose` for that.
193    pub label: String,
194}
195
196/// A subtitle stream.
197#[derive(Debug, Clone)]
198pub struct SubtitleStream {
199    /// MPEG-TS packet ID
200    pub pid: u16,
201    /// Codec (PGS)
202    pub codec: Codec,
203    /// ISO 639-2 language code (e.g. "eng", "fra")
204    pub language: String,
205    /// Whether this is a forced subtitle
206    pub forced: bool,
207    /// Subtitle qualifier (SDH / descriptive service / forced / none).
208    /// Callers translate this to display text in their own locale.
209    pub qualifier: LabelQualifier,
210    /// Pre-formatted codec private data (e.g. VobSub .idx palette header)
211    pub codec_data: Option<Vec<u8>>,
212}
213
214/// Video/audio codec.
215#[derive(Debug, Clone, Copy, PartialEq)]
216pub enum Codec {
217    // Video
218    Hevc,
219    H264,
220    Vc1,
221    Mpeg2,
222    Mpeg1,
223    Av1,
224    // Audio
225    TrueHd,
226    DtsHdMa,
227    DtsHdHr,
228    Dts,
229    Ac3,
230    Ac3Plus,
231    Lpcm,
232    Aac,
233    Mp2,
234    Mp3,
235    Flac,
236    Opus,
237    // Subtitle
238    Pgs,
239    DvdSub,
240    Srt,
241    Ssa,
242    // Unknown
243    Unknown(u8),
244}
245
246/// Video resolution.
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
248pub enum Resolution {
249    /// 480i (720x480 interlaced) — NTSC DVD
250    R480i,
251    /// 480p (720x480 progressive)
252    R480p,
253    /// 576i (720x576 interlaced) — PAL DVD
254    R576i,
255    /// 576p (720x576 progressive)
256    R576p,
257    /// 720p (1280x720 progressive) — some Blu-rays
258    R720p,
259    /// 1080i (1920x1080 interlaced) — broadcast, some BD
260    R1080i,
261    /// 1080p (1920x1080 progressive) — standard Blu-ray
262    R1080p,
263    /// 2160p (3840x2160 progressive) — 4K UHD Blu-ray
264    R2160p,
265    /// 4320p (7680x4320 progressive) — 8K, future-proof
266    R4320p,
267    /// Unknown resolution
268    Unknown,
269}
270
271/// Video frame rate.
272#[derive(Debug, Clone, Copy, PartialEq)]
273pub enum FrameRate {
274    /// 23.976 fps — film-based BD/UHD (NTSC pulldown)
275    F23_976,
276    /// 24.000 fps — true film rate
277    F24,
278    /// 25.000 fps — PAL standard
279    F25,
280    /// 29.970 fps — NTSC standard
281    F29_97,
282    /// 30.000 fps
283    F30,
284    /// 50.000 fps — PAL high frame rate
285    F50,
286    /// 59.940 fps — NTSC high frame rate
287    F59_94,
288    /// 60.000 fps
289    F60,
290    /// Unknown frame rate
291    Unknown,
292}
293
294/// Audio channel layout.
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub enum AudioChannels {
297    /// 1.0 mono
298    Mono,
299    /// 2.0 stereo
300    Stereo,
301    /// 2.1 (stereo + LFE)
302    Stereo21,
303    /// 4.0 quadraphonic
304    Quad,
305    /// 5.0 surround (no LFE)
306    Surround50,
307    /// 5.1 surround — standard BD/DVD surround
308    Surround51,
309    /// 6.1 surround (DTS-ES, Dolby EX)
310    Surround61,
311    /// 7.1 surround — UHD Atmos beds, DTS:X
312    Surround71,
313    /// Unknown channel layout
314    Unknown,
315}
316
317/// Audio sample rate.
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319pub enum SampleRate {
320    /// 44.1 kHz — CD audio (rare on disc)
321    S44_1,
322    /// 48 kHz — standard BD/DVD/UHD audio
323    S48,
324    /// 96 kHz — high-res BD audio
325    S96,
326    /// 192 kHz — highest BD audio (LPCM)
327    S192,
328    /// 48/96 kHz combo (secondary audio resampled)
329    S48_96,
330    /// 48/192 kHz combo (secondary audio resampled)
331    S48_192,
332    /// Unknown sample rate
333    Unknown,
334}
335
336/// HDR format.
337#[derive(Debug, Clone, Copy, PartialEq)]
338pub enum HdrFormat {
339    Sdr,
340    Hdr10,
341    Hdr10Plus,
342    DolbyVision,
343    Hlg,
344}
345
346/// Color space.
347#[derive(Debug, Clone, Copy, PartialEq)]
348pub enum ColorSpace {
349    Bt709,
350    Bt2020,
351    Unknown,
352}
353
354/// A chapter point within a title.
355#[derive(Debug, Clone)]
356pub struct Chapter {
357    /// Chapter start time in seconds
358    pub time_secs: f64,
359    /// Chapter name (e.g. "Chapter 1", "Chapter 2")
360    pub name: String,
361}
362
363/// A contiguous range of sectors on disc.
364#[derive(Debug, Clone, Copy)]
365pub struct Extent {
366    pub start_lba: u32,
367    pub sector_count: u32,
368}
369
370// ─── Display helpers ────────────────────────────────────────────────────────
371
372impl Codec {
373    /// Human-readable display name.
374    pub fn name(&self) -> &'static str {
375        for (_, name, v) in Self::ALL_CODECS {
376            if v == self {
377                return name;
378            }
379        }
380        "Unknown"
381    }
382
383    /// Compact identifier for serialization (lowercase, no spaces).
384    pub fn id(&self) -> &'static str {
385        for (id, _, v) in Self::ALL_CODECS {
386            if v == self {
387                return id;
388            }
389        }
390        "unknown"
391    }
392
393    const ALL_CODECS: &[(&'static str, &'static str, Codec)] = &[
394        ("hevc", "HEVC", Codec::Hevc),
395        ("h264", "H.264", Codec::H264),
396        ("vc1", "VC-1", Codec::Vc1),
397        ("mpeg2", "MPEG-2", Codec::Mpeg2),
398        ("mpeg1", "MPEG-1", Codec::Mpeg1),
399        ("av1", "AV1", Codec::Av1),
400        ("truehd", "TrueHD", Codec::TrueHd),
401        ("dtshd_ma", "DTS-HD MA", Codec::DtsHdMa),
402        ("dtshd_hr", "DTS-HD HR", Codec::DtsHdHr),
403        ("dts", "DTS", Codec::Dts),
404        ("ac3", "AC-3", Codec::Ac3),
405        ("eac3", "EAC-3", Codec::Ac3Plus),
406        ("lpcm", "LPCM", Codec::Lpcm),
407        ("aac", "AAC", Codec::Aac),
408        ("mp2", "MP2", Codec::Mp2),
409        ("mp3", "MP3", Codec::Mp3),
410        ("flac", "FLAC", Codec::Flac),
411        ("opus", "Opus", Codec::Opus),
412        ("pgs", "PGS", Codec::Pgs),
413        ("dvdsub", "DVD Subtitle", Codec::DvdSub),
414        ("srt", "SRT", Codec::Srt),
415        ("ssa", "SSA", Codec::Ssa),
416    ];
417
418    fn from_coding_type(ct: u8) -> Self {
419        match ct {
420            0x24 => Codec::Hevc,
421            0x1B => Codec::H264,
422            0xEA => Codec::Vc1,
423            0x02 => Codec::Mpeg2,
424            0x83 => Codec::TrueHd,
425            0x86 => Codec::DtsHdMa,
426            0x85 => Codec::DtsHdHr,
427            0x82 => Codec::Dts,
428            0x81 => Codec::Ac3,
429            0x84 | 0xA1 => Codec::Ac3Plus,
430            0x80 => Codec::Lpcm,
431            0xA2 => Codec::DtsHdHr,
432            0x90 | 0x91 => Codec::Pgs,
433            ct => Codec::Unknown(ct),
434        }
435    }
436}
437
438impl std::fmt::Display for Codec {
439    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
440        f.write_str(self.name())
441    }
442}
443
444impl Resolution {
445    /// Parse from MPLS video_format byte.
446    pub fn from_video_format(vf: u8) -> Self {
447        match vf {
448            1 => Resolution::R480i,
449            2 => Resolution::R576i,
450            3 => Resolution::R480p,
451            4 => Resolution::R1080i,
452            5 => Resolution::R720p,
453            6 => Resolution::R1080p,
454            7 => Resolution::R576p,
455            8 => Resolution::R2160p,
456            _ => Resolution::Unknown,
457        }
458    }
459
460    /// Pixel dimensions (width, height).
461    pub fn pixels(&self) -> (u32, u32) {
462        match self {
463            Resolution::R480i | Resolution::R480p => (720, 480),
464            Resolution::R576i | Resolution::R576p => (720, 576),
465            Resolution::R720p => (1280, 720),
466            Resolution::R1080i | Resolution::R1080p => (1920, 1080),
467            Resolution::R2160p => (3840, 2160),
468            Resolution::R4320p => (7680, 4320),
469            Resolution::Unknown => (1920, 1080),
470        }
471    }
472
473    /// True if this is a UHD (4K+) resolution.
474    pub fn is_uhd(&self) -> bool {
475        matches!(self, Resolution::R2160p | Resolution::R4320p)
476    }
477
478    /// True if this is an HD (720p+) resolution.
479    pub fn is_hd(&self) -> bool {
480        !matches!(
481            self,
482            Resolution::R480i
483                | Resolution::R480p
484                | Resolution::R576i
485                | Resolution::R576p
486                | Resolution::Unknown
487        )
488    }
489
490    /// True if this is an SD (480/576) resolution.
491    pub fn is_sd(&self) -> bool {
492        matches!(
493            self,
494            Resolution::R480i | Resolution::R480p | Resolution::R576i | Resolution::R576p
495        )
496    }
497
498    /// Parse from pixel height (e.g. from MKV track).
499    pub fn from_height(h: u32) -> Self {
500        match h {
501            0..=480 => Resolution::R480p,
502            481..=576 => Resolution::R576p,
503            577..=720 => Resolution::R720p,
504            721..=1080 => Resolution::R1080p,
505            1081..=2160 => Resolution::R2160p,
506            _ => Resolution::R4320p,
507        }
508    }
509}
510
511// Display for Resolution is generated by enum_str! macro
512
513impl FrameRate {
514    /// Parse from MPLS video_rate byte.
515    pub fn from_video_rate(vr: u8) -> Self {
516        match vr {
517            1 => FrameRate::F23_976,
518            2 => FrameRate::F24,
519            3 => FrameRate::F25,
520            4 => FrameRate::F29_97,
521            5 => FrameRate::F30,
522            6 => FrameRate::F50,
523            7 => FrameRate::F59_94,
524            8 => FrameRate::F60,
525            _ => FrameRate::Unknown,
526        }
527    }
528
529    /// Frame rate as (numerator, denominator) for precise representation.
530    pub fn as_fraction(&self) -> (u32, u32) {
531        match self {
532            FrameRate::F23_976 => (24000, 1001),
533            FrameRate::F24 => (24, 1),
534            FrameRate::F25 => (25, 1),
535            FrameRate::F29_97 => (30000, 1001),
536            FrameRate::F30 => (30, 1),
537            FrameRate::F50 => (50, 1),
538            FrameRate::F59_94 => (60000, 1001),
539            FrameRate::F60 => (60, 1),
540            FrameRate::Unknown => (0, 1),
541        }
542    }
543}
544
545// Display for FrameRate is generated by enum_str! macro
546
547impl AudioChannels {
548    /// Parse from MPLS audio_format byte.
549    pub fn from_audio_format(af: u8) -> Self {
550        match af {
551            1 => AudioChannels::Mono,
552            3 => AudioChannels::Stereo,
553            6 => AudioChannels::Surround51,
554            12 => AudioChannels::Surround71,
555            _ if af > 0 => AudioChannels::Unknown,
556            _ => AudioChannels::Unknown,
557        }
558    }
559
560    /// Channel count as a number.
561    pub fn count(&self) -> u8 {
562        match self {
563            AudioChannels::Mono => 1,
564            AudioChannels::Stereo => 2,
565            AudioChannels::Stereo21 => 3,
566            AudioChannels::Quad => 4,
567            AudioChannels::Surround50 => 5,
568            AudioChannels::Surround51 => 6,
569            AudioChannels::Surround61 => 7,
570            AudioChannels::Surround71 => 8,
571            AudioChannels::Unknown => 6,
572        }
573    }
574
575    /// Parse from channel count number.
576    pub fn from_count(n: u8) -> Self {
577        match n {
578            1 => AudioChannels::Mono,
579            2 => AudioChannels::Stereo,
580            3 => AudioChannels::Stereo21,
581            4 => AudioChannels::Quad,
582            5 => AudioChannels::Surround50,
583            6 => AudioChannels::Surround51,
584            7 => AudioChannels::Surround61,
585            8 => AudioChannels::Surround71,
586            _ => AudioChannels::Unknown,
587        }
588    }
589}
590
591// Display for AudioChannels is generated by enum_str! macro
592
593impl SampleRate {
594    /// Parse from MPLS audio_rate byte.
595    pub fn from_audio_rate(ar: u8) -> Self {
596        match ar {
597            1 => SampleRate::S48,
598            4 => SampleRate::S96,
599            5 => SampleRate::S192,
600            12 => SampleRate::S48_192,
601            14 => SampleRate::S48_96,
602            _ => SampleRate::Unknown,
603        }
604    }
605
606    /// Sample rate in Hz (primary rate for combo rates).
607    pub fn hz(&self) -> f64 {
608        match self {
609            SampleRate::S44_1 => 44100.0,
610            SampleRate::S48 | SampleRate::S48_96 | SampleRate::S48_192 => 48000.0,
611            SampleRate::S96 => 96000.0,
612            SampleRate::S192 => 192000.0,
613            SampleRate::Unknown => 48000.0,
614        }
615    }
616
617    /// Parse from Hz value.
618    pub fn from_hz(hz: u32) -> Self {
619        match hz {
620            44100 => SampleRate::S44_1,
621            48000 => SampleRate::S48,
622            96000 => SampleRate::S96,
623            192000 => SampleRate::S192,
624            _ => SampleRate::Unknown,
625        }
626    }
627}
628
629// Display for SampleRate is generated by enum_str! macro
630
631impl HdrFormat {
632    pub fn name(&self) -> &'static str {
633        match self {
634            HdrFormat::Sdr => "SDR",
635            HdrFormat::Hdr10 => "HDR10",
636            HdrFormat::Hdr10Plus => "HDR10+",
637            HdrFormat::DolbyVision => "Dolby Vision",
638            HdrFormat::Hlg => "HLG",
639        }
640    }
641
642    const ALL_HDR: &[(&'static str, HdrFormat)] = &[
643        ("sdr", HdrFormat::Sdr),
644        ("hdr10", HdrFormat::Hdr10),
645        ("hdr10+", HdrFormat::Hdr10Plus),
646        ("dv", HdrFormat::DolbyVision),
647        ("hlg", HdrFormat::Hlg),
648    ];
649
650    /// Compact identifier for serialization.
651    pub fn id(&self) -> &'static str {
652        for (id, v) in Self::ALL_HDR {
653            if v == self {
654                return id;
655            }
656        }
657        "sdr"
658    }
659}
660
661impl std::fmt::Display for HdrFormat {
662    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
663        f.write_str(self.name())
664    }
665}
666
667impl ColorSpace {
668    pub fn name(&self) -> &'static str {
669        match self {
670            ColorSpace::Bt709 => "BT.709",
671            ColorSpace::Bt2020 => "BT.2020",
672            ColorSpace::Unknown => "",
673        }
674    }
675}
676
677impl std::fmt::Display for ColorSpace {
678    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
679        f.write_str(self.name())
680    }
681}
682
683// ─── FromStr impls — single source of truth via ALL_* arrays ───────────────
684//
685// Each enum defines a const array of (str, variant) pairs. Display, FromStr,
686// and id() all derive from this one table — no string appears twice.
687
688macro_rules! enum_str {
689    ($name:ident, $default:expr, [ $( ($s:expr, $v:expr) ),* $(,)? ]) => {
690        impl $name {
691            const ALL: &[(&'static str, $name)] = &[ $( ($s, $v), )* ];
692        }
693        impl std::fmt::Display for $name {
694            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
695                for (s, v) in $name::ALL {
696                    if v == self { return f.write_str(s); }
697                }
698                f.write_str("")
699            }
700        }
701        impl std::str::FromStr for $name {
702            type Err = ();
703            fn from_str(s: &str) -> std::result::Result<Self, ()> {
704                for (k, v) in $name::ALL {
705                    if *k == s { return Ok(*v); }
706                }
707                Ok($default)
708            }
709        }
710    };
711}
712
713enum_str!(
714    Resolution,
715    Resolution::Unknown,
716    [
717        ("480i", Resolution::R480i),
718        ("480p", Resolution::R480p),
719        ("576i", Resolution::R576i),
720        ("576p", Resolution::R576p),
721        ("720p", Resolution::R720p),
722        ("1080i", Resolution::R1080i),
723        ("1080p", Resolution::R1080p),
724        ("2160p", Resolution::R2160p),
725        ("4320p", Resolution::R4320p),
726    ]
727);
728
729enum_str!(
730    FrameRate,
731    FrameRate::Unknown,
732    [
733        ("23.976", FrameRate::F23_976),
734        ("24", FrameRate::F24),
735        ("25", FrameRate::F25),
736        ("29.97", FrameRate::F29_97),
737        ("30", FrameRate::F30),
738        ("50", FrameRate::F50),
739        ("59.94", FrameRate::F59_94),
740        ("60", FrameRate::F60),
741    ]
742);
743
744enum_str!(
745    AudioChannels,
746    AudioChannels::Unknown,
747    [
748        ("mono", AudioChannels::Mono),
749        ("stereo", AudioChannels::Stereo),
750        ("2.1", AudioChannels::Stereo21),
751        ("4.0", AudioChannels::Quad),
752        ("5.0", AudioChannels::Surround50),
753        ("5.1", AudioChannels::Surround51),
754        ("6.1", AudioChannels::Surround61),
755        ("7.1", AudioChannels::Surround71),
756    ]
757);
758
759enum_str!(
760    SampleRate,
761    SampleRate::Unknown,
762    [
763        ("44.1kHz", SampleRate::S44_1),
764        ("48kHz", SampleRate::S48),
765        ("96kHz", SampleRate::S96),
766        ("192kHz", SampleRate::S192),
767        ("48/96kHz", SampleRate::S48_96),
768        ("48/192kHz", SampleRate::S48_192),
769    ]
770);
771
772impl std::str::FromStr for Codec {
773    type Err = ();
774    fn from_str(s: &str) -> std::result::Result<Self, ()> {
775        for (id, _, v) in Codec::ALL_CODECS {
776            if *id == s {
777                return Ok(*v);
778            }
779        }
780        Ok(Codec::Unknown(0))
781    }
782}
783
784impl std::str::FromStr for HdrFormat {
785    type Err = ();
786    fn from_str(s: &str) -> std::result::Result<Self, ()> {
787        for (id, v) in HdrFormat::ALL_HDR {
788            if *id == s {
789                return Ok(*v);
790            }
791        }
792        // Also accept display names
793        for (_id, v) in HdrFormat::ALL_HDR {
794            if HdrFormat::name(v) == s {
795                return Ok(*v);
796            }
797        }
798        Ok(HdrFormat::Sdr)
799    }
800}
801
802impl DiscTitle {
803    /// Empty DiscTitle with no streams.
804    pub fn empty() -> Self {
805        Self {
806            playlist: String::new(),
807            playlist_id: 0,
808            duration_secs: 0.0,
809            size_bytes: 0,
810            clips: Vec::new(),
811            streams: Vec::new(),
812            chapters: Vec::new(),
813            extents: Vec::new(),
814            content_format: ContentFormat::BdTs,
815            codec_privates: Vec::new(),
816        }
817    }
818
819    /// Duration formatted as "Xh Ym"
820    pub fn duration_display(&self) -> String {
821        let hrs = (self.duration_secs / 3600.0) as u32;
822        let mins = ((self.duration_secs % 3600.0) / 60.0) as u32;
823        format!("{hrs}h {mins:02}m")
824    }
825
826    /// Size in GB
827    pub fn size_gb(&self) -> f64 {
828        self.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0)
829    }
830
831    /// Total sectors across all extents
832    pub fn total_sectors(&self) -> u64 {
833        self.extents.iter().map(|e| e.sector_count as u64).sum()
834    }
835}
836
837// ─── Encryption ─────────────────────────────────────────────────────────────
838
839/// AACS decryption state for a disc.
840#[derive(Debug)]
841pub struct AacsState {
842    /// AACS version (1 or 2)
843    pub version: u8,
844    /// Whether bus encryption is enabled (always true for AACS 2.0 / UHD)
845    pub bus_encryption: bool,
846    /// MKB version from disc (e.g. 68, 77)
847    pub mkb_version: Option<u32>,
848    /// Disc hash (SHA1 of Unit_Key_RO.inf) -- hex string with 0x prefix
849    pub disc_hash: String,
850    /// How keys were resolved
851    pub key_source: KeySource,
852    /// Volume Unique Key (16 bytes)
853    pub vuk: [u8; 16],
854    /// Decrypted unit keys (CPS unit number, key)
855    pub unit_keys: Vec<(u32, [u8; 16])>,
856    /// Read data key for AACS 2.0 bus decryption -- None for AACS 1.0
857    pub read_data_key: Option<[u8; 16]>,
858    /// Volume ID (16 bytes) -- from SCSI handshake
859    pub volume_id: [u8; 16],
860}
861
862/// How AACS keys were resolved.
863#[derive(Debug, Clone, Copy, PartialEq)]
864pub enum KeySource {
865    /// VUK found directly in KEYDB by disc hash
866    KeyDb,
867    /// Media key + Volume ID from KEYDB → derived VUK
868    KeyDbDerived,
869    /// MKB + processing keys → media key → VUK
870    ProcessingKey,
871    /// MKB + device keys → subset-difference tree → VUK
872    DeviceKey,
873}
874
875impl KeySource {
876    pub fn name(&self) -> &'static str {
877        match self {
878            KeySource::KeyDb => "KEYDB",
879            KeySource::KeyDbDerived => "KEYDB (derived)",
880            KeySource::ProcessingKey => "MKB + processing key",
881            KeySource::DeviceKey => "MKB + device key",
882        }
883    }
884}
885
886// ─── Disc scanning ──────────────────────────────────────────────────────────
887
888/// Standard KEYDB.cfg search locations (compatible with libaacs).
889const KEYDB_SEARCH_PATHS: &[&str] = &[
890    ".config/aacs/KEYDB.cfg",    // libaacs standard path
891    ".config/freemkv/keydb.cfg", // freemkv download path
892];
893const KEYDB_SYSTEM_PATH: &str = "/etc/aacs/KEYDB.cfg";
894
895/// Options for disc scanning.
896#[derive(Default)]
897pub struct ScanOptions {
898    /// Path to KEYDB.cfg for AACS key lookup.
899    /// If None, searches standard locations ($HOME/.config/aacs/ and /etc/aacs/).
900    pub keydb_path: Option<std::path::PathBuf>,
901}
902
903impl ScanOptions {
904    /// Resolve KEYDB path: explicit path first, then standard locations.
905    fn resolve_keydb(&self) -> Option<std::path::PathBuf> {
906        if let Some(p) = &self.keydb_path {
907            if p.exists() {
908                return Some(p.clone());
909            }
910        }
911        if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
912            for relative in KEYDB_SEARCH_PATHS {
913                let p = std::path::PathBuf::from(&home).join(relative);
914                if p.exists() {
915                    return Some(p);
916                }
917            }
918        }
919        let p = std::path::PathBuf::from(KEYDB_SYSTEM_PATH);
920        if p.exists() {
921            return Some(p);
922        }
923        None
924    }
925}
926
927/// Quick disc identification — name, format, capacity. No title/stream parsing.
928#[derive(Debug)]
929pub struct DiscId {
930    /// UDF Volume Identifier (always present, e.g. "V_FOR_VENDETTA")
931    pub volume_id: String,
932    /// Disc title from META/DL/bdmt_eng.xml (e.g. "V for Vendetta")
933    pub meta_title: Option<String>,
934    /// Disc format (BD, UHD, DVD) — UHD vs BD requires full scan to confirm
935    pub format: DiscFormat,
936    /// Disc capacity in sectors
937    pub capacity_sectors: u32,
938    /// Whether AACS directory exists (disc is likely encrypted)
939    pub encrypted: bool,
940    /// Number of layers
941    pub layers: u8,
942}
943
944impl DiscId {
945    /// Best available name: meta_title, then formatted volume_id.
946    pub fn name(&self) -> &str {
947        self.meta_title.as_deref().unwrap_or(&self.volume_id)
948    }
949}
950
951impl Disc {
952    /// Fast disc identification — reads only UDF metadata for name and format.
953    /// No AACS handshake, no playlist parsing, no CLPI, no labels.
954    /// Typically completes in 2-3 seconds on USB drives.
955    pub fn identify(session: &mut Drive) -> Result<DiscId> {
956        let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
957
958        let meta_title = Self::read_meta_title(&mut buffered, &udf_fs);
959        let format = if udf_fs.find_dir("/BDMV").is_some() {
960            DiscFormat::BluRay // full scan distinguishes UHD vs BD
961        } else if udf_fs.find_dir("/VIDEO_TS").is_some() {
962            DiscFormat::Dvd
963        } else {
964            DiscFormat::Unknown
965        };
966        let encrypted =
967            udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
968        let layers = if capacity > 24_000_000 { 2 } else { 1 };
969
970        Ok(DiscId {
971            volume_id: udf_fs.volume_id,
972            meta_title,
973            format,
974            capacity_sectors: capacity,
975            encrypted,
976            layers,
977        })
978    }
979
980    /// Disc capacity in GB
981    pub fn capacity_gb(&self) -> f64 {
982        self.capacity_sectors as f64 * 2048.0 / (1024.0 * 1024.0 * 1024.0)
983    }
984
985    /// Read UDF filesystem and set up buffered reader with metadata prefetched.
986    /// Shared setup for both identify() and scan().
987    fn read_udf(session: &mut Drive) -> Result<(u32, udf::BufferedSectorReader<'_>, udf::UdfFs)> {
988        let capacity = Self::read_capacity(session).unwrap_or(0);
989        let batch = detect_max_batch_sectors(session.device_path());
990        let mut buffered = udf::BufferedSectorReader::new(session, batch);
991        let udf_fs = udf::read_filesystem(&mut buffered)?;
992        buffered.prefetch(udf_fs.metadata_start(), udf_fs.metadata_sectors());
993        Ok((capacity, buffered, udf_fs))
994    }
995
996    /// Scan a disc -- parse filesystem, playlists, streams, and set up AACS decryption.
997    ///
998    /// This is the main entry point. After scan(), the Disc is ready:
999    ///   - titles are populated with streams
1000    ///   - AACS keys are derived (if KEYDB available)
1001    ///   - content can be read and decrypted transparently
1002    ///
1003    /// Scan a disc. One pipeline, one order:
1004    ///   1. Read capacity + UDF filesystem
1005    ///   2. AACS handshake + key resolution
1006    ///   3. Parse playlists + streams
1007    ///   4. Apply labels
1008    ///
1009    /// The session must be open and unlocked (Drive::open handles this).
1010    /// All disc reads use standard READ(10) via UDF -- no vendor SCSI commands.
1011    pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result<Self> {
1012        // AACS handshake (Blu-ray/UHD)
1013        let handshake = Self::do_handshake(session, opts);
1014
1015        // Request max read speed — removes riplock on DVD
1016        // (BD/UHD speed is set by firmware init, but DVD needs explicit SET CD SPEED)
1017        session.set_speed(0xFFFF);
1018
1019        // Read UDF filesystem with buffered sector reader
1020        let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
1021
1022        // Pre-read all small file sectors (AACS, MPLS, CLPI, META, *.bdmv).
1023        // Without this, each read_file() triggers individual SCSI commands at 500ms each.
1024        if let Ok(ranges) = udf_fs.metadata_sector_ranges(&mut buffered) {
1025            buffered.prefetch_ranges(&ranges);
1026        }
1027
1028        let mut disc = Self::scan_with(&mut buffered, capacity, handshake, opts, udf_fs)?;
1029
1030        // CSS key extraction for DVDs (bus auth → disc key → title key).
1031        // Must be a single auth session — can't call authenticate() separately.
1032        if disc.css.is_none()
1033            && disc.content_format == ContentFormat::MpegPs
1034            && !disc.titles.is_empty()
1035        {
1036            let lba = disc.titles[0].extents.iter().find_map(|ext| {
1037                let mut buf = vec![0u8; 2048];
1038                if session
1039                    .read_sectors(ext.start_lba, 1, &mut buf, true)
1040                    .is_ok()
1041                    && crate::css::is_scrambled(&buf)
1042                {
1043                    return Some(ext.start_lba);
1044                }
1045                None
1046            });
1047
1048            if let Some(lba) = lba {
1049                if let Ok(title_key) =
1050                    crate::css::auth::authenticate_and_read_title_key(session, lba)
1051                {
1052                    disc.css = Some(crate::css::CssState { title_key });
1053                    disc.encrypted = true;
1054                }
1055            }
1056        }
1057
1058        Ok(disc)
1059    }
1060
1061    /// Scan a disc image (ISO or any SectorReader). No SCSI, no handshake.
1062    /// AACS resolution uses KEYDB VUK lookup only.
1063    pub fn scan_image(
1064        reader: &mut dyn SectorReader,
1065        capacity: u32,
1066        opts: &ScanOptions,
1067    ) -> Result<Self> {
1068        let udf_fs = udf::read_filesystem(reader)?;
1069        Self::scan_with(reader, capacity, None, opts, udf_fs)
1070    }
1071
1072    /// Core scan pipeline — works with any SectorReader.
1073    fn scan_with(
1074        reader: &mut dyn SectorReader,
1075        capacity: u32,
1076        handshake: Option<HandshakeResult>,
1077        opts: &ScanOptions,
1078        udf_fs: udf::UdfFs,
1079    ) -> Result<Self> {
1080        // 2. Resolve encryption (AACS, CSS, or none)
1081        let encrypted =
1082            udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
1083
1084        let aacs = if encrypted {
1085            if let Some(keydb_path) = opts.resolve_keydb() {
1086                Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref()).ok()
1087            } else {
1088                None
1089            }
1090        } else {
1091            None
1092        };
1093
1094        // 3. Titles — BD (MPLS playlists) or DVD (IFO title sets)
1095        let (mut titles, content_format) = if udf_fs.find_dir("/BDMV").is_some() {
1096            (
1097                Self::scan_bluray_titles(reader, &udf_fs),
1098                ContentFormat::BdTs,
1099            )
1100        } else if udf_fs.find_dir("/VIDEO_TS").is_some() {
1101            (
1102                Self::scan_dvd_titles(reader, &udf_fs),
1103                ContentFormat::MpegPs,
1104            )
1105        } else {
1106            (Vec::new(), ContentFormat::BdTs)
1107        };
1108        titles.sort_by(|a, b| {
1109            b.duration_secs
1110                .partial_cmp(&a.duration_secs)
1111                .unwrap_or(std::cmp::Ordering::Equal)
1112        });
1113
1114        // 4. Metadata + labels
1115        let meta_title = Self::read_meta_title(reader, &udf_fs);
1116        crate::labels::apply(reader, &udf_fs, &mut titles);
1117        crate::labels::fill_defaults(&mut titles);
1118
1119        // 5. Derive format, layers, region
1120        let format = Self::detect_format(&titles);
1121        let layers = if capacity > 24_000_000 { 2 } else { 1 };
1122        let region = DiscRegion::Free;
1123
1124        // 6. CSS detection for DVDs
1125        let css = if content_format == ContentFormat::MpegPs && !titles.is_empty() {
1126            crate::css::crack_key(reader, &titles[0].extents)
1127        } else {
1128            None
1129        };
1130        let encrypted = encrypted || css.is_some();
1131
1132        Ok(Disc {
1133            volume_id: udf_fs.volume_id.clone(),
1134            meta_title,
1135            format,
1136            capacity_sectors: capacity,
1137            capacity_bytes: capacity as u64 * 2048,
1138            layers,
1139            titles,
1140            region,
1141            aacs,
1142            css,
1143            encrypted,
1144            content_format,
1145        })
1146    }
1147
1148    // ── Internal helpers ────────────────────────────────────────────────────
1149
1150    /// Detect disc format from the main title's video streams.
1151    fn detect_format(titles: &[DiscTitle]) -> DiscFormat {
1152        for title in titles.iter().take(3) {
1153            for stream in &title.streams {
1154                if let Stream::Video(v) = stream {
1155                    if v.resolution.is_uhd() {
1156                        return DiscFormat::Uhd;
1157                    }
1158                    if v.resolution.is_hd() {
1159                        return DiscFormat::BluRay;
1160                    }
1161                    if v.resolution.is_sd() {
1162                        return DiscFormat::Dvd;
1163                    }
1164                }
1165            }
1166        }
1167        DiscFormat::Unknown
1168    }
1169
1170    fn read_capacity(session: &mut Drive) -> Result<u32> {
1171        let cdb = [
1172            crate::scsi::SCSI_READ_CAPACITY,
1173            0x00,
1174            0x00,
1175            0x00,
1176            0x00,
1177            0x00,
1178            0x00,
1179            0x00,
1180            0x00,
1181            0x00,
1182        ];
1183        let mut buf = [0u8; 8];
1184        session.scsi_execute(
1185            &cdb,
1186            crate::scsi::DataDirection::FromDevice,
1187            &mut buf,
1188            5_000,
1189        )?;
1190        let lba = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
1191        Ok(lba + 1)
1192    }
1193}
1194
1195impl Disc {
1196    /// Get the resolved decryption keys for this disc.
1197    /// Used by disc-to-ISO and other full-disc operations.
1198    pub fn decrypt_keys(&self) -> crate::decrypt::DecryptKeys {
1199        if let Some(ref aacs) = self.aacs {
1200            crate::decrypt::DecryptKeys::Aacs {
1201                unit_keys: aacs.unit_keys.clone(),
1202                read_data_key: aacs.read_data_key,
1203            }
1204        } else if let Some(ref css) = self.css {
1205            crate::decrypt::DecryptKeys::Css {
1206                title_key: css.title_key,
1207            }
1208        } else {
1209            crate::decrypt::DecryptKeys::None
1210        }
1211    }
1212
1213    /// Raw sector copy — write the entire disc image to a file.
1214    ///
1215    /// NOT a stream operation. Copies sectors 0→capacity byte-for-byte producing
1216    /// a valid ISO/UDF image. Records progress in a ddrescue-format mapfile at
1217    /// `path + ".mapfile"` — flushed every block for crash-safe resume.
1218    ///
1219    /// # Options
1220    /// - **default** (all false): behavior matches pre-v0.11.21 — uses full
1221    ///   drive recovery (may take minutes per bad sector), aborts on error.
1222    ///   Mapfile is produced as a side-effect.
1223    /// - **skip_on_error**: zero-fill bad blocks in the ISO, mark them in the
1224    ///   mapfile, and continue. Uses fast reads (no drive-level recovery loop).
1225    /// - **skip_forward** (implies skip_on_error): on block failure, also skip
1226    ///   forward by an exponentially-growing amount, marking the jumped region
1227    ///   as `non-trimmed` for later trimming/scraping by `Disc::patch`.
1228    /// - **resume**: if the mapfile exists, resume from its state — only
1229    ///   `non-tried` ranges are read. Without `resume`, a fresh mapfile is
1230    ///   written and the ISO recreated from scratch.
1231    pub fn copy(
1232        &self,
1233        reader: &mut dyn SectorReader,
1234        path: &std::path::Path,
1235        opts: &CopyOptions,
1236    ) -> Result<CopyResult> {
1237        use std::io::{Seek, SeekFrom, Write};
1238
1239        let total_bytes = self.capacity_sectors as u64 * 2048;
1240        let keys = if opts.decrypt {
1241            self.decrypt_keys()
1242        } else {
1243            crate::decrypt::DecryptKeys::None
1244        };
1245
1246        // Mapfile: load if resuming, else wipe + recreate.
1247        let mapfile_path = mapfile_path_for(path);
1248        if !opts.resume {
1249            let _ = std::fs::remove_file(&mapfile_path);
1250        }
1251        let mut map =
1252            mapfile::Mapfile::open_or_create(&mapfile_path, total_bytes, env!("CARGO_PKG_VERSION"))
1253                .map_err(|e| Error::IoError { source: e })?;
1254
1255        // ISO file: if resuming and mapfile has Finished ranges, open existing;
1256        // otherwise create fresh and pre-size to total_bytes (sparse holes for
1257        // non-tried regions).
1258        let file = if opts.resume
1259            && std::fs::metadata(path)
1260                .map(|m| m.len() > 0)
1261                .unwrap_or(false)
1262        {
1263            std::fs::OpenOptions::new()
1264                .write(true)
1265                .open(path)
1266                .map_err(|e| Error::IoError { source: e })?
1267        } else {
1268            let f = std::fs::File::create(path).map_err(|e| Error::IoError { source: e })?;
1269            f.set_len(total_bytes)
1270                .map_err(|e| Error::IoError { source: e })?;
1271            f
1272        };
1273
1274        let mut file = file;
1275        let batch: u16 = match opts.batch_sectors {
1276            Some(b) => b,
1277            None if opts.skip_forward => 32, // 64 KB = BD ECC block size
1278            None => DEFAULT_BATCH_SECTORS,
1279        };
1280
1281        // Skip-forward state.
1282        let skip_init = 256 * 1024u64; // 256 KB
1283        let skip_max = (total_bytes / 100).max(skip_init); // cap at 1% of disc
1284        let mut skip_size = skip_init;
1285
1286        let mut buf = vec![0u8; batch as usize * 2048];
1287        let mut bytes_done = 0u64;
1288        let mut halt_requested = false;
1289
1290        // Iterate over not-yet-finished regions from the mapfile. We re-read the
1291        // mapfile after each block because record() mutates the region list.
1292        'outer: loop {
1293            let regions_to_do = map.ranges_with(&[
1294                mapfile::SectorStatus::NonTried,
1295                mapfile::SectorStatus::NonTrimmed,
1296                mapfile::SectorStatus::NonScraped,
1297            ]);
1298            if regions_to_do.is_empty() {
1299                break;
1300            }
1301            // Only process the first NonTried range per outer pass; skip_forward
1302            // may turn others into NonTrimmed which we DO NOT re-enter here —
1303            // Disc::patch handles those.
1304            let Some((region_pos, region_size)) = map.next_with(0, mapfile::SectorStatus::NonTried)
1305            else {
1306                break;
1307            };
1308            let region_end = region_pos + region_size;
1309            let mut pos = region_pos;
1310
1311            while pos < region_end {
1312                if let Some(ref h) = opts.halt {
1313                    if h.load(std::sync::atomic::Ordering::Relaxed) {
1314                        halt_requested = true;
1315                        break 'outer;
1316                    }
1317                }
1318                let block_bytes = (region_end - pos).min(batch as u64 * 2048);
1319                let lba = (pos / 2048) as u32;
1320                let count = (block_bytes / 2048) as u16;
1321                let bytes = count as usize * 2048;
1322
1323                let recovery = !opts.skip_on_error; // fast reads when skipping
1324                let read_ok = reader
1325                    .read_sectors(lba, count, &mut buf[..bytes], recovery)
1326                    .is_ok();
1327
1328                if read_ok {
1329                    if opts.decrypt {
1330                        crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?;
1331                    }
1332                    file.seek(SeekFrom::Start(pos))
1333                        .map_err(|e| Error::IoError { source: e })?;
1334                    file.write_all(&buf[..bytes])
1335                        .map_err(|e| Error::IoError { source: e })?;
1336                    map.record(pos, block_bytes, mapfile::SectorStatus::Finished)
1337                        .map_err(|e| Error::IoError { source: e })?;
1338                    bytes_done = bytes_done.saturating_add(block_bytes);
1339                    skip_size = skip_init; // reset after success
1340                    pos += block_bytes;
1341                } else if opts.skip_on_error {
1342                    // Zero-fill this block, mark non-trimmed for later patch trim.
1343                    buf[..bytes].fill(0);
1344                    file.seek(SeekFrom::Start(pos))
1345                        .map_err(|e| Error::IoError { source: e })?;
1346                    file.write_all(&buf[..bytes])
1347                        .map_err(|e| Error::IoError { source: e })?;
1348                    map.record(pos, block_bytes, mapfile::SectorStatus::NonTrimmed)
1349                        .map_err(|e| Error::IoError { source: e })?;
1350                    pos += block_bytes;
1351
1352                    if opts.skip_forward && pos < region_end {
1353                        // Skip ahead; mark skipped bytes as non-trimmed too.
1354                        let jump = skip_size.min(region_end - pos);
1355                        if jump > 0 {
1356                            map.record(pos, jump, mapfile::SectorStatus::NonTrimmed)
1357                                .map_err(|e| Error::IoError { source: e })?;
1358                            pos += jump;
1359                        }
1360                        skip_size = (skip_size * 2).min(skip_max);
1361                    }
1362                } else {
1363                    // Current behavior (pre-0.11.21): abort on first bad sector.
1364                    return Err(Error::DiscRead { sector: lba as u64 });
1365                }
1366
1367                if let Some(cb) = opts.on_progress {
1368                    let stats = map.stats();
1369                    cb(stats.bytes_good, total_bytes);
1370                }
1371            }
1372        }
1373
1374        file.sync_all().map_err(|e| Error::IoError { source: e })?;
1375        let stats = map.stats();
1376        Ok(CopyResult {
1377            bytes_total: total_bytes,
1378            bytes_good: stats.bytes_good,
1379            bytes_unreadable: stats.bytes_unreadable,
1380            bytes_pending: stats.bytes_pending,
1381            complete: stats.bytes_pending == 0 && !halt_requested,
1382            halted: halt_requested,
1383        })
1384    }
1385}
1386
1387/// Options for `Disc::copy`. All fields default to the pre-v0.11.21 behavior
1388/// (recovery reads, abort on bad sector).
1389#[derive(Default)]
1390pub struct CopyOptions<'a> {
1391    pub decrypt: bool,
1392    /// Resume from existing mapfile + ISO if present. Without this, any
1393    /// existing mapfile is wiped and the ISO recreated.
1394    pub resume: bool,
1395    /// Override the default block size. Defaults to 32 sectors (64 KB) in
1396    /// `skip_forward` mode, `DEFAULT_BATCH_SECTORS` otherwise.
1397    pub batch_sectors: Option<u16>,
1398    /// Zero-fill bad blocks in the ISO, mark them in the mapfile, continue.
1399    /// Uses fast reads (no drive-level recovery loop).
1400    pub skip_on_error: bool,
1401    /// ddrescue-style exponential skip-forward on block failure. Implies
1402    /// `skip_on_error`. The skipped region is marked `non-trimmed` for later
1403    /// trimming/scraping by `Disc::patch`.
1404    pub skip_forward: bool,
1405    pub on_progress: Option<&'a dyn Fn(u64, u64)>,
1406    pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
1407}
1408
1409/// Result of `Disc::copy`. `complete=true` means every byte reached a terminal
1410/// state (Finished or Unreadable). `complete=false` means there's still pending
1411/// work (halt, abort, or non-tried ranges) that `Disc::patch` or a resumed
1412/// `Disc::copy` would continue.
1413#[derive(Debug, Clone, Copy)]
1414pub struct CopyResult {
1415    pub bytes_total: u64,
1416    pub bytes_good: u64,
1417    pub bytes_unreadable: u64,
1418    pub bytes_pending: u64,
1419    pub complete: bool,
1420    pub halted: bool,
1421}
1422
1423/// Sidecar mapfile path for a given ISO path — `foo.iso` → `foo.iso.mapfile`.
1424pub fn mapfile_path_for(iso_path: &std::path::Path) -> std::path::PathBuf {
1425    let mut s = iso_path.as_os_str().to_os_string();
1426    s.push(".mapfile");
1427    std::path::PathBuf::from(s)
1428}
1429
1430/// Options for `Disc::patch`. Idempotent — each call is one patch attempt.
1431#[derive(Default)]
1432pub struct PatchOptions<'a> {
1433    pub decrypt: bool,
1434    /// Sector-granularity block size for retries. Defaults to 1 sector (2 KB).
1435    pub block_sectors: Option<u16>,
1436    /// Use full drive-level recovery on each read (slow but thorough). Defaults
1437    /// to true — patch is the pass where we *want* the drive to try hard.
1438    pub full_recovery: bool,
1439    pub on_progress: Option<&'a dyn Fn(u64, u64)>,
1440    pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
1441}
1442
1443/// Result of `Disc::patch` — how many bad bytes were recovered.
1444#[derive(Debug, Clone, Copy)]
1445pub struct PatchResult {
1446    pub bytes_total: u64,
1447    pub bytes_good: u64,
1448    pub bytes_unreadable: u64,
1449    pub bytes_pending: u64,
1450    pub bytes_recovered_this_pass: u64,
1451    pub halted: bool,
1452}
1453
1454impl Disc {
1455    /// Patch an existing ISO using its sidecar mapfile. Re-reads every range
1456    /// that's not yet `+` (Finished) and writes successful bytes into the ISO
1457    /// at their exact offsets. Updates mapfile entries as it goes.
1458    ///
1459    /// Idempotent — call repeatedly to apply more retry attempts. Stops early
1460    /// if a pass recovered zero bytes (no point continuing).
1461    pub fn patch(
1462        &self,
1463        reader: &mut dyn SectorReader,
1464        path: &std::path::Path,
1465        opts: &PatchOptions,
1466    ) -> Result<PatchResult> {
1467        use std::io::{Seek, SeekFrom, Write};
1468
1469        let mapfile_path = mapfile_path_for(path);
1470        let mut map =
1471            mapfile::Mapfile::load(&mapfile_path).map_err(|e| Error::IoError { source: e })?;
1472        let total_bytes = map.total_size();
1473        let keys = if opts.decrypt {
1474            self.decrypt_keys()
1475        } else {
1476            crate::decrypt::DecryptKeys::None
1477        };
1478
1479        let mut file = std::fs::OpenOptions::new()
1480            .write(true)
1481            .open(path)
1482            .map_err(|e| Error::IoError { source: e })?;
1483
1484        let block_sectors = opts.block_sectors.unwrap_or(1);
1485        // Patch always reads with full drive recovery — this is the pass where
1486        // we want the drive's ECC retry machinery. Consumers who want fast-fail
1487        // use Disc::copy with skip_on_error instead.
1488        let _ = opts.full_recovery;
1489
1490        let bytes_good_before = map.stats().bytes_good;
1491        let mut halted = false;
1492        let mut buf = vec![0u8; block_sectors as usize * 2048];
1493
1494        // Collect bad ranges up front. Iterating while mutating is fragile;
1495        // each recorded change is persisted, so resume works even if we crash
1496        // mid-loop.
1497        let bad_ranges = map.ranges_with(&[
1498            mapfile::SectorStatus::NonTried,
1499            mapfile::SectorStatus::NonTrimmed,
1500            mapfile::SectorStatus::NonScraped,
1501            mapfile::SectorStatus::Unreadable,
1502        ]);
1503
1504        'outer: for (range_pos, range_size) in bad_ranges {
1505            let mut pos = range_pos;
1506            let end = range_pos + range_size;
1507            while pos < end {
1508                if let Some(ref h) = opts.halt {
1509                    if h.load(std::sync::atomic::Ordering::Relaxed) {
1510                        halted = true;
1511                        break 'outer;
1512                    }
1513                }
1514                let block_bytes = (end - pos).min(block_sectors as u64 * 2048);
1515                let lba = (pos / 2048) as u32;
1516                let count = (block_bytes / 2048) as u16;
1517                let bytes = count as usize * 2048;
1518                let read_ok = reader
1519                    .read_sectors(lba, count, &mut buf[..bytes], true)
1520                    .is_ok();
1521                if read_ok {
1522                    if opts.decrypt {
1523                        crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?;
1524                    }
1525                    file.seek(SeekFrom::Start(pos))
1526                        .map_err(|e| Error::IoError { source: e })?;
1527                    file.write_all(&buf[..bytes])
1528                        .map_err(|e| Error::IoError { source: e })?;
1529                    map.record(pos, block_bytes, mapfile::SectorStatus::Finished)
1530                        .map_err(|e| Error::IoError { source: e })?;
1531                } else {
1532                    map.record(pos, block_bytes, mapfile::SectorStatus::Unreadable)
1533                        .map_err(|e| Error::IoError { source: e })?;
1534                }
1535                pos += block_bytes;
1536
1537                if let Some(cb) = opts.on_progress {
1538                    let s = map.stats();
1539                    cb(s.bytes_good, total_bytes);
1540                }
1541            }
1542        }
1543
1544        file.sync_all().map_err(|e| Error::IoError { source: e })?;
1545        let stats = map.stats();
1546        Ok(PatchResult {
1547            bytes_total: total_bytes,
1548            bytes_good: stats.bytes_good,
1549            bytes_unreadable: stats.bytes_unreadable,
1550            bytes_pending: stats.bytes_pending,
1551            bytes_recovered_this_pass: stats.bytes_good.saturating_sub(bytes_good_before),
1552            halted,
1553        })
1554    }
1555}
1556
1557const MAX_BATCH_SECTORS: u16 = 510;
1558const DEFAULT_BATCH_SECTORS: u16 = 60;
1559const MIN_BATCH_SECTORS: u16 = 3;
1560
1561/// Detect the maximum transfer size in sectors for a device.
1562/// Reads /sys/block/<dev>/queue/max_hw_sectors_kb on Linux.
1563/// For sg devices, resolves the corresponding block device via sysfs.
1564/// Returns a value aligned to 3 sectors (one aligned unit).
1565pub fn detect_max_batch_sectors(device_path: &str) -> u16 {
1566    let dev_name = device_path.rsplit('/').next().unwrap_or("");
1567    if dev_name.is_empty() {
1568        return DEFAULT_BATCH_SECTORS;
1569    }
1570
1571    // For sg devices, find the corresponding block device name
1572    let block_name = if dev_name.starts_with("sg") {
1573        let block_dir = format!("/sys/class/scsi_generic/{dev_name}/device/block");
1574        std::fs::read_dir(&block_dir)
1575            .ok()
1576            .and_then(|mut entries| entries.next())
1577            .and_then(|e| e.ok())
1578            .map(|e| e.file_name().to_string_lossy().to_string())
1579    } else {
1580        Some(dev_name.to_string())
1581    };
1582
1583    if let Some(bname) = block_name {
1584        let sysfs_path = format!("/sys/block/{bname}/queue/max_hw_sectors_kb");
1585        if let Ok(content) = std::fs::read_to_string(&sysfs_path) {
1586            if let Ok(kb) = content.trim().parse::<u32>() {
1587                // Convert KB to sectors (1 sector = 2 KB = 2048 bytes)
1588                let sectors = (kb / 2) as u16;
1589                // Align down to 3 (one aligned unit)
1590                let aligned = (sectors / 3) * 3;
1591                if aligned >= MIN_BATCH_SECTORS {
1592                    return aligned.min(MAX_BATCH_SECTORS);
1593                }
1594            }
1595        }
1596    }
1597    // Fallback: safe default well under typical kernel limits
1598    DEFAULT_BATCH_SECTORS
1599}
1600
1601// ─── Format helpers ────────────────────────────────────────────────────────
1602
1603// Old format_* functions replaced by Resolution/FrameRate/AudioChannels/SampleRate enums
1604
1605#[cfg(test)]
1606mod tests {
1607    use super::*;
1608
1609    /// Helper: build a DiscTitle with a single video stream at the given resolution.
1610    fn title_with_video(codec: Codec, resolution: Resolution) -> DiscTitle {
1611        DiscTitle {
1612            playlist: "00800.mpls".into(),
1613            playlist_id: 800,
1614            duration_secs: 7200.0,
1615            size_bytes: 0,
1616            clips: Vec::new(),
1617            streams: vec![Stream::Video(VideoStream {
1618                pid: 0x1011,
1619                codec,
1620                resolution,
1621                frame_rate: FrameRate::F23_976,
1622                hdr: HdrFormat::Sdr,
1623                color_space: ColorSpace::Bt709,
1624                secondary: false,
1625                label: String::new(),
1626            })],
1627            chapters: Vec::new(),
1628            extents: Vec::new(),
1629            content_format: ContentFormat::BdTs,
1630            codec_privates: Vec::new(),
1631        }
1632    }
1633
1634    #[test]
1635    fn detect_format_uhd() {
1636        let titles = vec![title_with_video(Codec::Hevc, Resolution::R2160p)];
1637        assert_eq!(Disc::detect_format(&titles), DiscFormat::Uhd);
1638    }
1639
1640    #[test]
1641    fn detect_format_bluray() {
1642        let titles = vec![title_with_video(Codec::H264, Resolution::R1080p)];
1643        assert_eq!(Disc::detect_format(&titles), DiscFormat::BluRay);
1644    }
1645
1646    #[test]
1647    fn detect_format_dvd() {
1648        let titles = vec![title_with_video(Codec::Mpeg2, Resolution::R480i)];
1649        assert_eq!(Disc::detect_format(&titles), DiscFormat::Dvd);
1650    }
1651
1652    #[test]
1653    fn detect_format_empty() {
1654        let titles: Vec<DiscTitle> = Vec::new();
1655        assert_eq!(Disc::detect_format(&titles), DiscFormat::Unknown);
1656    }
1657
1658    #[test]
1659    fn content_format_default_bdts() {
1660        let t = title_with_video(Codec::H264, Resolution::R1080p);
1661        assert_eq!(t.content_format, ContentFormat::BdTs);
1662    }
1663
1664    #[test]
1665    fn content_format_dvd_mpegps() {
1666        let t = DiscTitle {
1667            content_format: ContentFormat::MpegPs,
1668            ..title_with_video(Codec::Mpeg2, Resolution::R480i)
1669        };
1670        assert_eq!(t.content_format, ContentFormat::MpegPs);
1671    }
1672
1673    #[test]
1674    fn disc_capacity_gb() {
1675        // Single-layer BD-25: ~12,219,392 sectors
1676        let disc = Disc {
1677            volume_id: String::new(),
1678            meta_title: None,
1679            format: DiscFormat::BluRay,
1680            capacity_sectors: 12_219_392,
1681            capacity_bytes: 12_219_392u64 * 2048,
1682            layers: 1,
1683            titles: Vec::new(),
1684            region: DiscRegion::Free,
1685            aacs: None,
1686            css: None,
1687            encrypted: false,
1688            content_format: ContentFormat::BdTs,
1689        };
1690        let gb = disc.capacity_gb();
1691        // 12,219,392 * 2048 / 1073741824 = ~23.3 GB
1692        assert!((gb - 23.3).abs() < 0.1, "expected ~23.3 GB, got {}", gb);
1693
1694        // Zero sectors
1695        let disc_zero = Disc {
1696            capacity_sectors: 0,
1697            capacity_bytes: 0,
1698            ..disc
1699        };
1700        assert_eq!(disc_zero.capacity_gb(), 0.0);
1701    }
1702
1703    #[test]
1704    fn disc_title_duration_display_edge_cases() {
1705        let mut t = DiscTitle::empty();
1706
1707        // 0 seconds
1708        t.duration_secs = 0.0;
1709        assert_eq!(t.duration_display(), "0h 00m");
1710
1711        // 1 second
1712        t.duration_secs = 1.0;
1713        assert_eq!(t.duration_display(), "0h 00m");
1714
1715        // 59 minutes
1716        t.duration_secs = 59.0 * 60.0;
1717        assert_eq!(t.duration_display(), "0h 59m");
1718
1719        // 24 hours
1720        t.duration_secs = 24.0 * 3600.0;
1721        assert_eq!(t.duration_display(), "24h 00m");
1722    }
1723}