cdtoc 0.1.5

Parser and tools for CDTOC metadata tags.
Documentation
/*!
# CDTOC: Time
*/

use crate::TocError;
use dactyl::{
	NiceElapsed,
	traits::NiceInflection,
};
use std::{
	fmt,
	hash,
	iter::Sum,
	ops::{
		Add,
		AddAssign,
		Sub,
		SubAssign,
		Div,
		DivAssign,
		Mul,
		MulAssign,
	},
	time,
};



const SAMPLES_PER_SECTOR: u64 = 588;
const SECTORS_PER_SECOND: u64 = 75;



#[derive(Debug, Clone, Copy, Default, Ord, PartialOrd)]
/// # (CDDA Sector) Duration.
///
/// This struct holds a non-lossy — at least up to about 7.8 billion years —
/// CD sector duration (seconds + frames) for one or more tracks.
///
/// ## Examples
///
/// ```
/// use cdtoc::Toc;
///
/// let toc = Toc::from_cdtoc("9+96+5766+A284+E600+11FE5+15913+19A98+1E905+240CB+26280").unwrap();
/// let track = toc.audio_track(9).unwrap();
/// let duration = track.duration();
///
/// // The printable format is Dd HH:MM:SS+FF, though the day part is only
/// // present if non-zero.
/// assert_eq!(duration.to_string(), "00:01:55+04");
///
/// // The same as intelligible pieces:
/// assert_eq!(duration.dhmsf(), (0, 0, 1, 55, 4));
///
/// // If that's too many pieces, you can get just the seconds and frames:
/// assert_eq!(duration.seconds_frames(), (115, 4));
/// ```
///
/// The value can also be lossily converted to more familiar formats via
/// [`Duration::to_std_duration_lossy`] or [`Duration::to_f64_lossy`].
///
/// Durations can also be combined every which way, for example:
///
/// ```
/// use cdtoc::{Toc, Duration};
///
/// let toc = Toc::from_cdtoc("9+96+5766+A284+E600+11FE5+15913+19A98+1E905+240CB+26280").unwrap();
/// let duration: Duration = toc.audio_tracks()
///     .map(|t| t.duration())
///     .sum();
/// assert_eq!(duration.to_string(), "00:34:41+63");
/// ```
pub struct Duration(pub(crate) u64);

impl<T> Add<T> for Duration
where u64: From<T> {
	type Output = Self;
	fn add(self, other: T) -> Self { Self(self.0 + u64::from(other)) }
}

impl<T> AddAssign<T> for Duration
where u64: From<T> {
	fn add_assign(&mut self, other: T) { self.0 += u64::from(other); }
}

impl<T> Div<T> for Duration
where u64: From<T> {
	type Output = Self;
	fn div(self, other: T) -> Self {
		let other = u64::from(other);
		if other == 0 { Self(0) }
		else { Self(self.0.wrapping_div(other)) }
	}
}

impl<T> DivAssign<T> for Duration
where u64: From<T> {
	fn div_assign(&mut self, other: T) {
		let other = u64::from(other);
		if other == 0 { self.0 = 0; }
		else { self.0 = self.0.wrapping_div(other) };
	}
}

impl Eq for Duration {}

impl fmt::Display for Duration {
	#[allow(clippy::many_single_char_names)]
	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
		let (d, h, m, s, frames) = self.dhmsf();
		if d == 0 {
			write!(f, "{h:02}:{m:02}:{s:02}+{frames:02}")
		}
		else {
			write!(f, "{d}d {h:02}:{m:02}:{s:02}+{frames:02}")
		}
	}
}

impl From<u32> for Duration {
	fn from(src: u32) -> Self { Self(src.into()) }
}

impl From<u64> for Duration {
	fn from(src: u64) -> Self { Self(src) }
}

impl From<usize> for Duration {
	fn from(src: usize) -> Self { Self(src as u64) }
}

impl From<Duration> for u64 {
	fn from(src: Duration) -> Self { src.0 }
}

impl hash::Hash for Duration {
	fn hash<H: hash::Hasher>(&self, state: &mut H) { state.write_u64(self.0); }
}

impl PartialEq for Duration {
	fn eq(&self, other: &Self) -> bool { self.0 == other.0 }
}

impl<T> Mul<T> for Duration
where u64: From<T> {
	type Output = Self;
	fn mul(self, other: T) -> Self { Self(self.0 * u64::from(other)) }
}

impl<T> MulAssign<T> for Duration
where u64: From<T> {
	fn mul_assign(&mut self, other: T) { self.0 *= u64::from(other); }
}

impl<T> Sub<T> for Duration
where u64: From<T> {
	type Output = Self;
	fn sub(self, other: T) -> Self { Self(self.0.saturating_sub(u64::from(other))) }
}

impl<T> SubAssign<T> for Duration
where u64: From<T> {
	fn sub_assign(&mut self, other: T) { self.0 = self.0.saturating_sub(u64::from(other)); }
}

impl Sum for Duration {
	fn sum<I>(iter: I) -> Self
	where I: Iterator<Item = Self> {
		iter.fold(Self::default(), |a, b| a + b)
	}
}

impl Duration {
	/// # From CDDA Samples.
	///
	/// Derive the duration from the total number of a track's _CDDA-quality_
	/// samples.
	///
	/// This method assumes the count was captured at a rate of 44.1 kHz, and
	/// requires it divide evenly into the samples-per-sector size used by
	/// standard audio CDs (`588`).
	///
	/// For more flexible (and/or approximate) sample/duration conversions, use
	/// [`Duration::from_samples`] instead.
	///
	/// ## Examples
	///
	/// ```
	/// use cdtoc::Duration;
	///
	/// let duration = Duration::from_cdda_samples(5_073_852).unwrap();
	/// assert_eq!(
	///     duration.to_string(),
	///     "00:01:55+04",
	/// );
	/// ```
	///
	/// ## Errors
	///
	/// This will return an error if the sample count is not evenly divisible
	/// by `588`, the number of samples-per-sector for a standard audio CD.
	pub const fn from_cdda_samples(total_samples: u64) -> Result<Self, TocError> {
		let out = total_samples.wrapping_div(SAMPLES_PER_SECTOR);
		if total_samples % SAMPLES_PER_SECTOR == 0 { Ok(Self(out)) }
		else { Err(TocError::CDDASampleCount) }
	}

	#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
	#[must_use]
	/// # From Samples (Rescaled).
	///
	/// Derive the equivalent CDDA duration for a track with an arbitrary
	/// sample rate (i.e. not 44.1 kHz) or sample count.
	///
	/// This operation is potentially lossy and may result in a duration that
	/// is off by ±1 frame.
	///
	/// For standard CDDA tracks, use [`Duration::from_cdda_samples`] instead.
	///
	/// ## Examples
	///
	/// ```
	/// use cdtoc::Duration;
	///
	/// let duration = Duration::from_samples(96_000, 17_271_098);
	/// assert_eq!(
	///     duration.to_string(),
	///     "00:02:59+68",
	/// );
	/// ```
	pub fn from_samples(sample_rate: u32,  total_samples: u64) -> Self {
		if sample_rate == 0 || total_samples == 0 { Self::default() }
		else {
			let sample_rate = u64::from(sample_rate);
			let (s, rem) = dactyl::div_mod(total_samples, sample_rate);
			if rem == 0 { Self(s * SECTORS_PER_SECOND) }
			else {
				let f = dactyl::int_div_float(rem * 75, sample_rate)
					.map_or(0, |f| f.trunc() as u64);
				Self(s * SECTORS_PER_SECOND + f)
			}
		}
	}
}

impl Duration {
	#[allow(clippy::many_single_char_names, clippy::cast_possible_truncation)]
	#[must_use]
	/// # Days, Hours, Minutes, Seconds, Frames.
	///
	/// Carve up the duration into a quintuple of days, hours, minutes,
	/// seconds, and frames.
	///
	/// ## Examples
	///
	/// ```
	/// use cdtoc::Toc;
	///
	/// let toc = Toc::from_cdtoc("9+96+5766+A284+E600+11FE5+15913+19A98+1E905+240CB+26280").unwrap();
	/// let track = toc.audio_track(9).unwrap();
	/// assert_eq!(
	///     track.duration().dhmsf(),
	///     (0, 0, 1, 55, 4),
	/// );
	/// ```
	pub const fn dhmsf(self) -> (u64, u8, u8, u8, u8) {
		let (s, f) = self.seconds_frames();
		if s <= 4_294_967_295 {
			let (d, h, m, s) = NiceElapsed::dhms(s as u32);
			(d as u64, h, m, s, f)
		}
		else {
			let d = s.wrapping_div(86_400);
			let [h, m, s] = NiceElapsed::hms((s - d * 86_400) as u32);
			(d, h, m, s, f)
		}
	}

	#[must_use]
	/// # Total Samples.
	///
	/// Return the total number of samples.
	///
	/// ## Examples
	///
	/// ```
	/// use cdtoc::Toc;
	///
	/// let toc = Toc::from_cdtoc("9+96+5766+A284+E600+11FE5+15913+19A98+1E905+240CB+26280").unwrap();
	/// let track = toc.audio_track(9).unwrap();
	/// assert_eq!(
	///     track.duration().samples(),
	///     5_073_852,
	/// );
	/// ```
	pub const fn samples(self) -> u64 { self.0 * SAMPLES_PER_SECTOR }

	#[must_use]
	/// # Seconds + Frames.
	///
	/// Return the duration as a tuple containing the total number of seconds
	/// and remaining frames (some fraction of a second).
	///
	/// Audio CDs have 75 frames per second, so the frame portion will always
	/// be in the range of `0..75`.
	///
	/// ## Examples
	///
	/// ```
	/// use cdtoc::Toc;
	///
	/// let toc = Toc::from_cdtoc("9+96+5766+A284+E600+11FE5+15913+19A98+1E905+240CB+26280").unwrap();
	/// let track = toc.audio_track(9).unwrap();
	/// assert_eq!(
	///     track.duration().seconds_frames(),
	///     (115, 4),
	/// );
	/// ```
	pub const fn seconds_frames(self) -> (u64, u8) {
		(self.0.wrapping_div(SECTORS_PER_SECOND), (self.0 % SECTORS_PER_SECOND) as u8)
	}

	#[must_use]
	/// # Number of Sectors.
	///
	/// Return the total number of sectors.
	///
	/// ## Examples
	///
	/// ```
	/// use cdtoc::Toc;
	///
	/// let toc = Toc::from_cdtoc("9+96+5766+A284+E600+11FE5+15913+19A98+1E905+240CB+26280").unwrap();
	/// let track = toc.audio_track(9).unwrap();
	/// assert_eq!(
	///     track.duration().sectors(),
	///     8629,
	/// );
	/// ```
	pub const fn sectors(self) -> u64 { self.0 }

	#[allow(clippy::cast_precision_loss)]
	#[must_use]
	/// # To `f64` (Lossy).
	///
	/// Return the duration as a float (seconds.subseconds).
	///
	/// Given that 75ths don't always make the cleanest of fractions, there
	/// will likely be some loss in precision.
	///
	/// ## Examples
	///
	/// ```
	/// use cdtoc::Toc;
	///
	/// let toc = Toc::from_cdtoc("9+96+5766+A284+E600+11FE5+15913+19A98+1E905+240CB+26280").unwrap();
	/// let track = toc.audio_track(9).unwrap();
	/// assert_eq!(
	///     track.duration().to_f64_lossy(),
	///     115.05333333333333,
	/// );
	/// ```
	pub fn to_f64_lossy(self) -> f64 {
		// Most durations will probably fit within `u32`, which converts
		// cleanly.
		if self.0 <= 4_294_967_295 { self.0 as f64 / 75.0 }
		// Otherwise let's try to do it in parts and hope for the best.
		else {
			let (s, f) = self.seconds_frames();
			s as f64 + f64::from(f) / 75.0
		}
	}

	#[must_use]
	/// # To [`std::time::Duration`] (Lossy).
	///
	/// Return the value as a "normal" [`std::time::Duration`].
	///
	/// Note that the `std` struct only counts time down to the nanosecond, so
	/// this value might be off by a few frames.
	///
	/// ## Examples
	///
	/// ```
	/// use cdtoc::Toc;
	///
	/// let toc = Toc::from_cdtoc("9+96+5766+A284+E600+11FE5+15913+19A98+1E905+240CB+26280").unwrap();
	/// let track = toc.audio_track(9).unwrap();
	/// assert_eq!(
	///     track.duration().to_std_duration_lossy().as_nanos(),
	///     115_053_333_333,
	/// );
	/// ```
	pub fn to_std_duration_lossy(self) -> time::Duration {
		// There are 1_000_000_000 nanoseconds per 75 sectors. Reducing this to
		// 40_000_000:3 leaves less chance of temporary overflow.
		self.0.checked_mul(40_000_000)
			.map_or_else(
				|| {
					let (s, f) = self.seconds_frames();
					time::Duration::from_secs(s) +
					time::Duration::from_nanos((u64::from(f) * 40_000_000).wrapping_div(3))
				},
				|n| time::Duration::from_nanos(n.wrapping_div(3)),
			)
	}

	#[allow(clippy::many_single_char_names)]
	#[must_use]
	/// # To String Pretty.
	///
	/// Return a string reprsentation of the non-zero parts with English
	/// labels, separated Oxford-comma-style.
	///
	/// ## Examples
	///
	/// ```
	/// use cdtoc::{Toc, Duration};
	///
	/// let toc = Toc::from_cdtoc("9+96+5766+A284+E600+11FE5+15913+19A98+1E905+240CB+26280").unwrap();
	/// let track = toc.audio_track(9).unwrap();
	/// assert_eq!(
	///     track.duration().to_string_pretty(),
	///     "1 minute, 55 seconds, and 4 frames",
	/// );
	///
	/// // Empty durations look like this:
	/// assert_eq!(
	///     Duration::default().to_string_pretty(),
	///     "0 seconds",
	/// );
	/// ```
	pub fn to_string_pretty(self) -> String {
		let (d, h, m, s, f) = self.dhmsf();
		let mut parts: Vec<String> = Vec::new();
		if d != 0 { parts.push(d.nice_inflect("day", "days")); }
		for (num, single, plural) in [
			(h, "hour", "hours"),
			(m, "minute", "minutes"),
			(s, "second", "seconds"),
			(f, "frame", "frames"),
		] {
			if num != 0 { parts.push(num.nice_inflect(single, plural)); }
		}

		match parts.len() {
			0 => "0 seconds".to_owned(),
			1 => parts.remove(0),
			2 => parts.join(" and "),
			n => {
				let last = parts.remove(n - 1);
				let mut out = parts.join(", ");
				out.push_str(", and ");
				out.push_str(&last);
				out
			},
		}
	}
}