mod bluray;
mod dvd;
mod encrypt;
pub mod mapfile;
use crate::drive::{Drive, extract_scsi_context};
use crate::error::{Error, Result};
use crate::sector::SectorReader;
use crate::udf;
use encrypt::HandshakeResult;
pub use crate::labels::{LabelPurpose, LabelQualifier};
#[derive(Debug)]
pub struct Disc {
pub volume_id: String,
pub meta_title: Option<String>,
pub format: DiscFormat,
pub capacity_sectors: u32,
pub capacity_bytes: u64,
pub layers: u8,
pub titles: Vec<DiscTitle>,
pub region: DiscRegion,
pub aacs: Option<AacsState>,
pub css: Option<crate::css::CssState>,
pub encrypted: bool,
pub content_format: ContentFormat,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ContentFormat {
BdTs,
MpegPs,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DiscFormat {
Uhd,
BluRay,
Dvd,
Unknown,
}
#[derive(Debug, Clone, PartialEq)]
pub enum DiscRegion {
Free,
BluRay(Vec<BdRegion>),
Dvd(Vec<u8>),
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BdRegion {
A,
B,
C,
}
#[derive(Debug, Clone)]
pub struct DiscTitle {
pub playlist: String,
pub playlist_id: u16,
pub duration_secs: f64,
pub size_bytes: u64,
pub clips: Vec<Clip>,
pub streams: Vec<Stream>,
pub chapters: Vec<Chapter>,
pub extents: Vec<Extent>,
pub content_format: ContentFormat,
pub codec_privates: Vec<Option<Vec<u8>>>,
}
#[derive(Debug, Clone)]
pub struct Clip {
pub clip_id: String,
pub in_time: u32,
pub out_time: u32,
pub duration_secs: f64,
pub source_packets: u32,
}
#[derive(Debug, Clone)]
pub enum Stream {
Video(VideoStream),
Audio(AudioStream),
Subtitle(SubtitleStream),
}
#[derive(Debug, Clone)]
pub struct VideoStream {
pub pid: u16,
pub codec: Codec,
pub resolution: Resolution,
pub frame_rate: FrameRate,
pub hdr: HdrFormat,
pub color_space: ColorSpace,
pub secondary: bool,
pub label: String,
}
#[derive(Debug, Clone)]
pub struct AudioStream {
pub pid: u16,
pub codec: Codec,
pub channels: AudioChannels,
pub language: String,
pub sample_rate: SampleRate,
pub secondary: bool,
pub purpose: LabelPurpose,
pub label: String,
}
#[derive(Debug, Clone)]
pub struct SubtitleStream {
pub pid: u16,
pub codec: Codec,
pub language: String,
pub forced: bool,
pub qualifier: LabelQualifier,
pub codec_data: Option<Vec<u8>>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Codec {
Hevc,
H264,
Vc1,
Mpeg2,
Mpeg1,
Av1,
TrueHd,
DtsHdMa,
DtsHdHr,
Dts,
Ac3,
Ac3Plus,
Lpcm,
Aac,
Mp2,
Mp3,
Flac,
Opus,
Pgs,
DvdSub,
Srt,
Ssa,
Unknown(u8),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Resolution {
R480i,
R480p,
R576i,
R576p,
R720p,
R1080i,
R1080p,
R2160p,
R4320p,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum FrameRate {
F23_976,
F24,
F25,
F29_97,
F30,
F50,
F59_94,
F60,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioChannels {
Mono,
Stereo,
Stereo21,
Quad,
Surround50,
Surround51,
Surround61,
Surround71,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SampleRate {
S44_1,
S48,
S96,
S192,
S48_96,
S48_192,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum HdrFormat {
Sdr,
Hdr10,
Hdr10Plus,
DolbyVision,
Hlg,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ColorSpace {
Bt709,
Bt2020,
Unknown,
}
#[derive(Debug, Clone)]
pub struct Chapter {
pub time_secs: f64,
pub name: String,
}
#[derive(Debug, Clone, Copy)]
pub struct Extent {
pub start_lba: u32,
pub sector_count: u32,
}
impl Codec {
pub fn name(&self) -> &'static str {
for (_, name, v) in Self::ALL_CODECS {
if v == self {
return name;
}
}
"Unknown"
}
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 {
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,
}
}
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),
}
}
pub fn is_uhd(&self) -> bool {
matches!(self, Resolution::R2160p | Resolution::R4320p)
}
pub fn is_hd(&self) -> bool {
!matches!(
self,
Resolution::R480i
| Resolution::R480p
| Resolution::R576i
| Resolution::R576p
| Resolution::Unknown
)
}
pub fn is_sd(&self) -> bool {
matches!(
self,
Resolution::R480i | Resolution::R480p | Resolution::R576i | Resolution::R576p
)
}
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,
}
}
}
impl FrameRate {
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,
}
}
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),
}
}
}
impl AudioChannels {
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,
}
}
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,
}
}
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,
}
}
}
impl SampleRate {
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,
}
}
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,
}
}
pub fn from_hz(hz: u32) -> Self {
match hz {
44100 => SampleRate::S44_1,
48000 => SampleRate::S48,
96000 => SampleRate::S96,
192000 => SampleRate::S192,
_ => SampleRate::Unknown,
}
}
}
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),
];
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())
}
}
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);
}
}
for (_id, v) in HdrFormat::ALL_HDR {
if HdrFormat::name(v) == s {
return Ok(*v);
}
}
Ok(HdrFormat::Sdr)
}
}
impl DiscTitle {
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(),
}
}
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")
}
pub fn size_gb(&self) -> f64 {
self.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0)
}
pub fn total_sectors(&self) -> u64 {
self.extents.iter().map(|e| e.sector_count as u64).sum()
}
}
#[derive(Debug)]
pub struct AacsState {
pub version: u8,
pub bus_encryption: bool,
pub mkb_version: Option<u32>,
pub disc_hash: String,
pub key_source: KeySource,
pub vuk: [u8; 16],
pub unit_keys: Vec<(u32, [u8; 16])>,
pub read_data_key: Option<[u8; 16]>,
pub volume_id: [u8; 16],
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum KeySource {
KeyDb,
KeyDbDerived,
ProcessingKey,
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",
}
}
}
const KEYDB_SEARCH_PATHS: &[&str] = &[
".config/aacs/KEYDB.cfg", ".config/freemkv/keydb.cfg", ];
const KEYDB_SYSTEM_PATH: &str = "/etc/aacs/KEYDB.cfg";
#[derive(Default)]
pub struct ScanOptions {
pub keydb_path: Option<std::path::PathBuf>,
}
impl ScanOptions {
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
}
}
#[derive(Debug)]
pub struct DiscId {
pub volume_id: String,
pub meta_title: Option<String>,
pub format: DiscFormat,
pub capacity_sectors: u32,
pub encrypted: bool,
pub layers: u8,
}
impl DiscId {
pub fn name(&self) -> &str {
self.meta_title.as_deref().unwrap_or(&self.volume_id)
}
}
impl Disc {
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 } 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,
})
}
pub fn capacity_gb(&self) -> f64 {
self.capacity_sectors as f64 * 2048.0 / (1024.0 * 1024.0 * 1024.0)
}
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))
}
pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result<Self> {
let handshake = Self::do_handshake(session, opts);
session.set_speed(0xFFFF);
let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
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)?;
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)
}
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)
}
fn scan_with(
reader: &mut dyn SectorReader,
capacity: u32,
handshake: Option<HandshakeResult>,
opts: &ScanOptions,
udf_fs: udf::UdfFs,
) -> Result<Self> {
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
};
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)
});
let meta_title = Self::read_meta_title(reader, &udf_fs);
crate::labels::apply(reader, &udf_fs, &mut titles);
crate::labels::fill_defaults(&mut titles);
let format = Self::detect_format(&titles);
let layers = if capacity > 24_000_000 { 2 } else { 1 };
let region = DiscRegion::Free;
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,
})
}
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 {
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
}
}
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
};
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, concat!("libfreemkv v", env!("CARGO_PKG_VERSION")))
.map_err(|e| Error::IoError { source: e })?;
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_on_error => 32,
None => DEFAULT_BATCH_SECTORS,
};
let mut buf = vec![0u8; batch as usize * 2048];
let mut bytes_done = 0u64;
let mut halt_requested = false;
let copy_t0 = std::time::Instant::now();
let mut iter_count: u64 = 0;
let mut read_ok_count: u64 = 0;
let mut read_err_count: u64 = 0;
let mut last_log_iter: u64 = 0;
let mut not_ready_retries: u32 = 0;
const NOT_READY_MAX_RETRIES: u32 = 3;
let mut bridge_degradation_count: u32 = 0;
const BRIDGE_DEGRADATION_MAX: u32 = 5;
const BRIDGE_DEGRADATION_COOLDOWN_SECS: u64 = 10;
const DAMAGE_WINDOW: usize = 50;
const DAMAGE_THRESHOLD_PCT: usize = 25;
const JUMP_SECTORS_FACTOR: u64 = 256;
let mut damage_window: Vec<bool> = Vec::with_capacity(DAMAGE_WINDOW);
let mut jump_multiplier: u64 = 1;
let mut consecutive_good: u64 = 0;
tracing::trace!(
target: "freemkv::disc",
phase = "copy_start",
total_bytes,
batch,
skip_on_error = opts.skip_on_error,
"Disc::copy entered"
);
'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;
}
let Some((region_pos, region_size)) = map.next_with(0, mapfile::SectorStatus::NonTried)
else {
break;
};
let region_end = region_pos + region_size;
let mut pos = region_pos;
tracing::trace!(
target: "freemkv::disc",
phase = "region_enter",
region_pos,
region_size,
region_end,
"entering NonTried region"
);
while pos < region_end {
if let Some(ref h) = opts.halt {
if h.load(std::sync::atomic::Ordering::Relaxed) {
halt_requested = true;
break 'outer;
}
}
let block_bytes = (region_end - pos).min(batch as u64 * 2048);
let block_lba = (pos / 2048) as u32;
let block_count = (block_bytes / 2048) as u16;
let recovery = !opts.skip_on_error;
let read_result = reader.read_sectors(
block_lba,
block_count,
&mut buf[..block_bytes as usize],
recovery,
);
let mut did_skip_ahead = false;
if read_result.is_ok() {
read_ok_count += 1;
damage_window.push(true);
if damage_window.len() > DAMAGE_WINDOW {
damage_window.remove(0);
}
consecutive_good += 1;
if consecutive_good >= DAMAGE_WINDOW as u64 {
jump_multiplier = 1;
}
bridge_degradation_count = 0;
if opts.decrypt {
crate::decrypt::decrypt_sectors(
&mut buf[..block_bytes as usize],
&keys,
0,
)?;
}
file.seek(SeekFrom::Start(pos))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&buf[..block_bytes as usize])
.map_err(|e| Error::IoError { source: e })?;
map.record(pos, block_bytes, mapfile::SectorStatus::Finished)
.map_err(|e| Error::IoError { source: e })?;
bytes_done = bytes_done.saturating_add(block_bytes);
} else if !opts.skip_on_error {
let (status, sense) = read_result
.as_ref()
.err()
.map(extract_scsi_context)
.unwrap_or((0, None));
return Err(Error::DiscRead {
sector: block_lba as u64,
status: Some(status),
sense,
});
} else {
let err = read_result.err().unwrap();
read_err_count += 1;
consecutive_good = 0;
if err.is_scsi_transport_failure() {
tracing::warn!(
target: "freemkv::disc",
phase = "transport_failure",
lba = block_lba,
error = %err,
"transport failure (bridge crash); aborting copy — caller should USB reset + resume"
);
return Err(err);
}
if err.is_bridge_degradation() {
if bridge_degradation_count < BRIDGE_DEGRADATION_MAX {
bridge_degradation_count += 1;
tracing::warn!(
target: "freemkv::disc",
phase = "bridge_degradation",
lba = block_lba,
degradation_count = bridge_degradation_count,
error = %err,
"bridge degradation; cooling down 10s"
);
std::thread::sleep(std::time::Duration::from_secs(BRIDGE_DEGRADATION_COOLDOWN_SECS));
continue;
}
tracing::warn!(
target: "freemkv::disc",
phase = "bridge_degradation_exhausted",
lba = block_lba,
"bridge degradation retries exhausted; treating as bad sector"
);
}
let sense = err.scsi_sense();
let sense_key = sense.map(|s| s.sense_key).unwrap_or(0);
let asc = sense.map(|s| s.asc).unwrap_or(0);
let ascq = sense.map(|s| s.ascq).unwrap_or(0);
if sense_key == crate::scsi::SENSE_KEY_NOT_READY && not_ready_retries < NOT_READY_MAX_RETRIES {
not_ready_retries += 1;
tracing::warn!(
target: "freemkv::disc",
phase = "not_ready_pause",
lba = block_lba,
sense_key,
asc,
ascq,
retry = not_ready_retries,
"NOT READY; pausing 3s then retrying"
);
std::thread::sleep(std::time::Duration::from_secs(3));
continue;
}
not_ready_retries = 0;
tracing::warn!(
target: "freemkv::disc",
phase = "skip_ecc_block",
lba = block_lba,
sectors = block_count,
sense_key,
asc,
ascq,
error = %err,
"ECC block failed; marking NonTrimmed"
);
let zero = vec![0u8; block_bytes as usize];
file.seek(SeekFrom::Start(pos))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&zero)
.map_err(|e| Error::IoError { source: e })?;
map.record(pos, block_bytes, mapfile::SectorStatus::NonTrimmed)
.map_err(|e| Error::IoError { source: e })?;
bytes_done = bytes_done.saturating_add(block_bytes);
damage_window.push(false);
if damage_window.len() > DAMAGE_WINDOW {
damage_window.remove(0);
}
let bad_count = damage_window.iter().filter(|&&b| !b).count();
if damage_window.len() >= DAMAGE_WINDOW
&& bad_count * 100 / damage_window.len() >= DAMAGE_THRESHOLD_PCT
{
let jump_sectors = JUMP_SECTORS_FACTOR * batch as u64 * jump_multiplier;
let jump_lba = ((pos / 2048) + jump_sectors) as u32;
let region_end_lba = (region_end / 2048) as u32;
if jump_lba < region_end_lba {
let jump_pos = jump_lba as u64 * 2048;
let gap_start = pos + block_bytes;
let gap_bytes = jump_pos.saturating_sub(gap_start);
let jump_mb = gap_bytes / 1_048_576;
tracing::warn!(
target: "freemkv::disc",
phase = "damage_jump",
from_lba = block_lba,
to_lba = jump_lba,
jump_mb,
bad_pct = bad_count * 100 / damage_window.len(),
multiplier = jump_multiplier,
"25%+ failures in last 50 blocks; jumping ahead"
);
if gap_bytes > 0 {
let zero = vec![0u8; 65536];
let mut filled: u64 = 0;
while filled < gap_bytes {
let chunk = (gap_bytes - filled).min(zero.len() as u64);
file.seek(SeekFrom::Start(gap_start + filled))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&zero[..chunk as usize])
.map_err(|e| Error::IoError { source: e })?;
filled += chunk;
}
map.record(gap_start, gap_bytes, mapfile::SectorStatus::NonTrimmed)
.map_err(|e| Error::IoError { source: e })?;
bytes_done = bytes_done.saturating_add(gap_bytes);
}
pos = jump_pos;
jump_multiplier *= 2;
did_skip_ahead = true;
}
}
}
if !did_skip_ahead {
pos += block_bytes;
}
iter_count += 1;
if iter_count - last_log_iter >= 100 {
last_log_iter = iter_count;
let stats = map.stats();
tracing::trace!(
target: "freemkv::disc",
phase = "iter_progress",
iter_count,
read_ok_count,
read_err_count,
pos,
region_end,
bytes_good = stats.bytes_good,
bytes_pending = stats.bytes_pending,
copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64,
"Disc::copy inner iter"
);
}
if let Some(reporter) = opts.progress {
let stats = map.stats();
reporter.report(&crate::progress::PassProgress {
kind: crate::progress::PassKind::Sweep,
work_done: pos,
work_total: total_bytes,
bytes_good_total: stats.bytes_good,
bytes_bad_total: stats.bytes_unreadable + stats.bytes_retryable,
bytes_total_disc: total_bytes,
});
}
}
}
file.sync_all().map_err(|e| Error::IoError { source: e })?;
let stats = map.stats();
tracing::trace!(
target: "freemkv::disc",
phase = "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,
})
}
}
#[derive(Default)]
pub struct CopyOptions<'a> {
pub decrypt: bool,
pub resume: bool,
pub batch_sectors: Option<u16>,
pub skip_on_error: bool,
pub progress: Option<&'a dyn crate::progress::Progress>,
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
}
#[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,
}
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)
}
#[derive(Default)]
pub struct PatchOptions<'a> {
pub decrypt: bool,
pub block_sectors: Option<u16>,
pub full_recovery: bool,
pub reverse: bool,
pub wedged_threshold: u64,
pub progress: Option<&'a dyn crate::progress::Progress>,
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
}
#[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,
pub blocks_attempted: u64,
pub blocks_read_ok: u64,
pub blocks_read_failed: u64,
pub wedged_exit: bool,
}
impl Disc {
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 unreadable_count: u64 = 0;
let mut buf = vec![0u8; block_sectors as usize * 2048];
let mut bad_ranges = map.ranges_with(&[
mapfile::SectorStatus::NonTried,
mapfile::SectorStatus::NonTrimmed,
mapfile::SectorStatus::NonScraped,
mapfile::SectorStatus::Unreadable,
]);
if opts.reverse {
bad_ranges.reverse();
}
let work_total: u64 = bad_ranges.iter().map(|(_, sz)| *sz).sum();
let mut work_done: u64 = 0;
tracing::trace!(
target: "freemkv::disc",
phase = "patch_start",
block_sectors,
recovery,
reverse = opts.reverse,
wedged_threshold = opts.wedged_threshold,
num_ranges = bad_ranges.len(),
work_total,
"Disc::patch entered"
);
'outer: for (range_pos, range_size) in bad_ranges {
let end = range_pos + range_size;
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;
}
}
let (pos, block_bytes) = if opts.reverse {
if block_end <= range_pos {
break;
}
let span = (block_end - range_pos).min(block_sectors as u64 * 2048);
(block_end - span, span)
} else {
if block_end >= end {
break;
}
let span = (end - block_end).min(block_sectors as u64 * 2048);
(block_end, span)
};
let lba = (pos / 2048) as u32;
let count = (block_bytes / 2048) as u16;
let bytes = count as usize * 2048;
blocks_attempted += 1;
let read_result = reader.read_sectors(lba, count, &mut buf[..bytes], recovery);
if let Err(ref e) = read_result {
if !e.is_marginal_read() {
let err = read_result.err().unwrap();
tracing::trace!(
target: "freemkv::disc",
phase = "patch_bail",
lba,
error = %err,
"patch read failed with non-marginal sense; bailing"
);
return Err(err);
}
}
let read_ok = read_result.is_ok();
if read_ok {
blocks_read_ok += 1;
consecutive_failures = 0;
if opts.decrypt {
crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?;
}
file.seek(SeekFrom::Start(pos))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&buf[..bytes])
.map_err(|e| Error::IoError { source: e })?;
map.record(pos, block_bytes, mapfile::SectorStatus::Finished)
.map_err(|e| Error::IoError { source: e })?;
} else {
blocks_read_failed += 1;
consecutive_failures += 1;
unreadable_count += 1;
map.record(pos, block_bytes, mapfile::SectorStatus::Unreadable)
.map_err(|e| Error::IoError { source: e })?;
}
if opts.reverse {
block_end = block_end.saturating_sub(block_bytes);
} else {
block_end += block_bytes;
}
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;
}
work_done = work_done.saturating_add(block_bytes);
if let Some(reporter) = opts.progress {
let s = map.stats();
let kind = if block_sectors == 1 {
crate::progress::PassKind::Scrape {
reverse: opts.reverse,
}
} else {
crate::progress::PassKind::Trim {
reverse: opts.reverse,
}
};
reporter.report(&crate::progress::PassProgress {
kind,
work_done,
work_total,
bytes_good_total: s.bytes_good,
bytes_bad_total: s.bytes_unreadable + s.bytes_retryable,
bytes_total_disc: total_bytes,
});
}
}
}
file.sync_all().map_err(|e| Error::IoError { source: e })?;
let stats = map.stats();
tracing::info!(
target: "freemkv::disc",
phase = "patch_done",
blocks_attempted,
blocks_read_ok,
blocks_read_failed,
unreadable_count,
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;
pub fn ecc_sectors(format: DiscFormat) -> u16 {
match format {
DiscFormat::Uhd | DiscFormat::BluRay => 32,
DiscFormat::Dvd => 16,
DiscFormat::Unknown => 32,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DamageSeverity {
Clean,
Cosmetic,
Moderate,
Serious,
}
pub fn classify_damage(bad_sectors: u64, lost_ms: f64) -> DamageSeverity {
if bad_sectors == 0 {
return DamageSeverity::Clean;
}
if bad_sectors >= 500 || lost_ms >= 30_000.0 {
return DamageSeverity::Serious;
}
if bad_sectors >= 51 || lost_ms >= 1_000.0 {
return DamageSeverity::Moderate;
}
DamageSeverity::Cosmetic
}
#[cfg(test)]
mod severity_tests {
use super::*;
#[test]
fn clean_when_no_damage() {
assert_eq!(classify_damage(0, 0.0), DamageSeverity::Clean);
}
#[test]
fn cosmetic_for_a_handful() {
assert_eq!(classify_damage(1, 5.0), DamageSeverity::Cosmetic);
assert_eq!(classify_damage(50, 999.0), DamageSeverity::Cosmetic);
}
#[test]
fn moderate_threshold_by_sectors() {
assert_eq!(classify_damage(51, 0.0), DamageSeverity::Moderate);
}
#[test]
fn moderate_threshold_by_time() {
assert_eq!(classify_damage(10, 1_000.0), DamageSeverity::Moderate);
}
#[test]
fn serious_threshold_by_sectors() {
assert_eq!(classify_damage(500, 0.0), DamageSeverity::Serious);
}
#[test]
fn serious_threshold_by_time() {
assert_eq!(classify_damage(10, 30_000.0), DamageSeverity::Serious);
}
}
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;
}
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>() {
let sectors = (kb / 2) as u16;
let aligned = (sectors / 3) * 3;
if aligned >= MIN_BATCH_SECTORS {
return aligned.min(MAX_BATCH_SECTORS);
}
}
}
}
DEFAULT_BATCH_SECTORS
}
#[cfg(test)]
mod tests {
use super::*;
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() {
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();
assert!((gb - 23.3).abs() < 0.1, "expected ~23.3 GB, got {}", gb);
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();
t.duration_secs = 0.0;
assert_eq!(t.duration_display(), "0h 00m");
t.duration_secs = 1.0;
assert_eq!(t.duration_display(), "0h 00m");
t.duration_secs = 59.0 * 60.0;
assert_eq!(t.duration_display(), "0h 59m");
t.duration_secs = 24.0 * 3600.0;
assert_eq!(t.duration_display(), "24h 00m");
}
}