use base64::Engine as _;
use sha1::{Digest, Sha1};
use crate::error::{OpticaldiscsError, Result};
pub const FRAMES_PER_SECOND: u32 = 75;
pub const PREGAP_FRAMES: u32 = 150;
#[derive(Debug, Clone)]
pub struct TrackInfo {
pub number: u8,
pub offset: u32,
pub track_type: String,
}
#[derive(Debug, Clone)]
pub struct DiscTOC {
pub first_track: u8,
pub last_track: u8,
pub lead_out: u32,
pub track_offsets: Vec<u32>,
}
impl DiscTOC {
pub fn from_tracks(tracks: &[TrackInfo], lead_out_frames: u32) -> Option<Self> {
let first = tracks.first()?;
let last = tracks.last()?;
Some(Self {
first_track: first.number,
last_track: last.number,
lead_out: lead_out_frames + PREGAP_FRAMES,
track_offsets: tracks.iter().map(|t| t.offset + PREGAP_FRAMES).collect(),
})
}
pub fn total_seconds(&self) -> u32 {
self.lead_out / FRAMES_PER_SECOND
}
pub fn total_time_string(&self) -> String {
let s = self.total_seconds();
format!("{:02}:{:02}", s / 60, s % 60)
}
pub fn track_count(&self) -> u8 {
self.last_track.saturating_sub(self.first_track) + 1
}
pub fn musicbrainz_id(&self) -> String {
let mut data = Vec::with_capacity(2 + 4 + 99 * 4);
data.push(self.first_track);
data.push(self.last_track);
data.extend_from_slice(&self.lead_out.to_be_bytes());
for i in 0..99usize {
let offset = self.track_offsets.get(i).copied().unwrap_or(0);
data.extend_from_slice(&offset.to_be_bytes());
}
let hash = Sha1::digest(&data);
base64::engine::general_purpose::STANDARD
.encode(hash.as_slice())
.chars()
.map(|c| match c {
'+' => '.',
'/' => '_',
'=' => '-',
other => other,
})
.collect()
}
pub fn freedb_id(&self) -> String {
let checksum: u32 = self
.track_offsets
.iter()
.map(|&off| digit_sum(off / FRAMES_PER_SECOND))
.sum();
let total_seconds = self.lead_out / FRAMES_PER_SECOND;
let first_seconds = self.track_offsets.first().copied().unwrap_or(0) / FRAMES_PER_SECOND;
let length = total_seconds.saturating_sub(first_seconds);
let disc_id = ((checksum % 0xFF) << 24) | (length << 8) | self.track_offsets.len() as u32;
format!("{disc_id:08x}")
}
pub fn to_toc_string(&self) -> String {
let mut parts = vec![
self.first_track.to_string(),
self.track_count().to_string(),
self.lead_out.to_string(),
];
for offset in &self.track_offsets {
parts.push(offset.to_string());
}
parts.join("+")
}
}
pub fn parse_msf(s: &str) -> Result<u32> {
let err = || OpticaldiscsError::Parse(format!("invalid MSF timestamp: {s:?}"));
let mut parts = s.splitn(3, ':');
let mm: u32 = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?;
let ss: u32 = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?;
let ff: u32 = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?;
Ok((mm * 60 + ss) * FRAMES_PER_SECOND + ff)
}
pub fn frames_to_msf(frames: u32) -> (u8, u8, u8) {
let ff = (frames % FRAMES_PER_SECOND) as u8;
let total_seconds = frames / FRAMES_PER_SECOND;
let ss = (total_seconds % 60) as u8;
let mm = (total_seconds / 60) as u8;
(mm, ss, ff)
}
fn digit_sum(n: u32) -> u32 {
let mut sum = 0;
let mut remaining = n;
loop {
sum += remaining % 10;
remaining /= 10;
if remaining == 0 {
break;
}
}
sum
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_msf_zero() {
assert_eq!(parse_msf("00:00:00").unwrap(), 0);
}
#[test]
fn parse_msf_pregap() {
assert_eq!(parse_msf("00:02:00").unwrap(), 150);
}
#[test]
fn parse_msf_one_minute() {
assert_eq!(parse_msf("01:00:00").unwrap(), 4500);
}
#[test]
fn parse_msf_maximum() {
assert_eq!(parse_msf("74:59:74").unwrap(), 337_499);
}
#[test]
fn parse_msf_invalid_format() {
assert!(parse_msf("00:00").is_err());
assert!(parse_msf("notmsf").is_err());
assert!(parse_msf("00:xx:00").is_err());
assert!(parse_msf("").is_err());
}
#[test]
fn frames_to_msf_zero() {
assert_eq!(frames_to_msf(0), (0, 0, 0));
}
#[test]
fn frames_to_msf_pregap() {
assert_eq!(frames_to_msf(150), (0, 2, 0));
}
#[test]
fn frames_to_msf_one_minute() {
assert_eq!(frames_to_msf(4500), (1, 0, 0));
}
#[test]
fn frames_to_msf_roundtrip() {
for &msf in &["00:00:00", "00:02:00", "01:00:00", "74:59:74", "32:17:42"] {
let frames = parse_msf(msf).unwrap();
let (mm, ss, ff) = frames_to_msf(frames);
let back = format!("{mm:02}:{ss:02}:{ff:02}");
assert_eq!(back, msf, "roundtrip failed for {msf}");
}
}
#[test]
fn from_tracks_basic() {
let tracks = vec![
TrackInfo {
number: 1,
offset: 0,
track_type: "AUDIO".into(),
},
TrackInfo {
number: 2,
offset: 18_901,
track_type: "AUDIO".into(),
},
];
let toc = DiscTOC::from_tracks(&tracks, 41_000).unwrap();
assert_eq!(toc.first_track, 1);
assert_eq!(toc.last_track, 2);
assert_eq!(toc.track_count(), 2);
assert_eq!(toc.track_offsets, vec![150, 19_051]);
assert_eq!(toc.lead_out, 41_150);
}
#[test]
fn from_tracks_empty_returns_none() {
assert!(DiscTOC::from_tracks(&[], 0).is_none());
}
#[test]
fn from_tracks_single_track() {
let tracks = vec![TrackInfo {
number: 1,
offset: 0,
track_type: "MODE1".into(),
}];
let toc = DiscTOC::from_tracks(&tracks, 9_000).unwrap();
assert_eq!(toc.track_count(), 1);
assert_eq!(toc.track_offsets, vec![150]);
assert_eq!(toc.lead_out, 9_150);
}
#[test]
fn total_seconds_and_time_string() {
let tracks = vec![TrackInfo {
number: 1,
offset: 0,
track_type: "AUDIO".into(),
}];
let toc = DiscTOC::from_tracks(&tracks, 4_350).unwrap(); assert_eq!(toc.total_seconds(), 60);
assert_eq!(toc.total_time_string(), "01:00");
}
#[test]
fn freedb_id_two_track_disc() {
let tracks = vec![
TrackInfo {
number: 1,
offset: 0,
track_type: "AUDIO".into(),
},
TrackInfo {
number: 2,
offset: 18_901,
track_type: "AUDIO".into(),
},
];
let toc = DiscTOC::from_tracks(&tracks, 41_000).unwrap();
assert_eq!(toc.freedb_id(), "0d022202");
}
#[test]
fn freedb_id_single_data_track() {
let tracks = vec![TrackInfo {
number: 1,
offset: 0,
track_type: "MODE1".into(),
}];
let toc = DiscTOC::from_tracks(&tracks, 18_000).unwrap();
let id = toc.freedb_id();
assert_eq!(id.len(), 8);
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn musicbrainz_id_two_track_disc() {
let tracks = vec![
TrackInfo {
number: 1,
offset: 0,
track_type: "AUDIO".into(),
},
TrackInfo {
number: 2,
offset: 18_901,
track_type: "AUDIO".into(),
},
];
let toc = DiscTOC::from_tracks(&tracks, 41_000).unwrap();
let id = toc.musicbrainz_id();
assert_eq!(id, "88mmRzPF.QgexLjFmYaEiNMUN44-");
}
#[test]
fn musicbrainz_id_is_28_chars() {
let tracks = vec![TrackInfo {
number: 1,
offset: 0,
track_type: "AUDIO".into(),
}];
let toc = DiscTOC::from_tracks(&tracks, 9_000).unwrap();
let id = toc.musicbrainz_id();
assert_eq!(id.len(), 28, "MB DiscID must be exactly 28 characters");
}
#[test]
fn musicbrainz_id_valid_char_set() {
let tracks = vec![
TrackInfo {
number: 1,
offset: 0,
track_type: "AUDIO".into(),
},
TrackInfo {
number: 2,
offset: 18_901,
track_type: "AUDIO".into(),
},
];
let toc = DiscTOC::from_tracks(&tracks, 41_000).unwrap();
let id = toc.musicbrainz_id();
assert!(
id.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-'),
"invalid char in MB DiscID: {id}"
);
}
#[test]
fn musicbrainz_id_deterministic() {
let tracks = vec![TrackInfo {
number: 1,
offset: 0,
track_type: "AUDIO".into(),
}];
let toc = DiscTOC::from_tracks(&tracks, 9_000).unwrap();
assert_eq!(toc.musicbrainz_id(), toc.musicbrainz_id());
}
#[test]
fn digit_sum_values() {
assert_eq!(digit_sum(0), 0);
assert_eq!(digit_sum(1), 1);
assert_eq!(digit_sum(9), 9);
assert_eq!(digit_sum(10), 1);
assert_eq!(digit_sum(123), 6);
assert_eq!(digit_sum(999), 27);
assert_eq!(digit_sum(254), 11); }
}