cdtoc/
accuraterip.rs

1/*!
2# CDTOC: AccurateRip
3*/
4
5use crate::{
6	Cddb,
7	Toc,
8	TocError,
9};
10use dactyl::traits::{
11	BytesToUnsigned,
12	HexToUnsigned,
13};
14use std::{
15	collections::BTreeMap,
16	fmt,
17	ops::Range,
18	str::FromStr,
19};
20
21
22
23/// # Drive Offset: Max Vendor Length.
24///
25/// Vendors are not required, but cannot exceed 8 bytes.
26const DRIVE_OFFSET_VENDOR_MAX: usize = 8;
27
28/// # Drive Offset: Max Model Length.
29///
30/// Models are required, and cannot exceed 16 bytes.
31const DRIVE_OFFSET_MODEL_MAX: usize = 16;
32
33/// # Drive Offset: Offset Range.
34///
35/// Offsets won't work if they exceed the ignorable range baked into
36/// AccurateRip's checksum algorithm.
37const DRIVE_OFFSET_OFFSET_RNG: Range<i16> = -2940..2941;
38
39
40
41#[cfg_attr(docsrs, doc(cfg(feature = "accuraterip")))]
42#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)]
43/// # AccurateRip ID.
44///
45/// This struct holds an [AccurateRip](http://accuraterip.com/) ID.
46///
47/// Values of this type are returned by [`Toc::accuraterip_id`].
48///
49/// ## Examples
50///
51/// ```
52/// use cdtoc::Toc;
53///
54/// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
55/// let ar_id = toc.accuraterip_id();
56///
57/// // Usually you'll want this value as a string:
58/// assert_eq!(
59///     ar_id.to_string(),
60///     "004-0002189a-00087f33-1f02e004",
61/// );
62///
63/// // But you can also get a binary version matching the format of the
64/// // checksum bin files:
65/// assert_eq!(
66///     <[u8; 13]>::from(ar_id),
67///     [4, 154, 24, 2, 0, 51, 127, 8, 0, 4, 224, 2, 31],
68/// );
69/// ```
70pub struct AccurateRip([u8; 13]);
71
72impl AsRef<[u8]> for AccurateRip {
73	#[inline]
74	fn as_ref(&self) -> &[u8] { self.0.as_slice() }
75}
76
77impl From<AccurateRip> for [u8; 13] {
78	#[inline]
79	fn from(src: AccurateRip) -> Self { src.0 }
80}
81
82impl fmt::Display for AccurateRip {
83	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84		let disc_id = self.encode();
85		std::str::from_utf8(disc_id.as_slice())
86			.map_err(|_| fmt::Error)
87			.and_then(|s| <str as fmt::Display>::fmt(s, f))
88	}
89}
90
91impl From<&Toc> for AccurateRip {
92	#[expect(clippy::cast_possible_truncation, reason = "False positive.")]
93	fn from(src: &Toc) -> Self {
94		let mut b: u32 = 0;
95		let mut c: u32 = 0;
96
97		let mut idx = 1;
98		for v in src.audio_sectors() {
99			let off = v.saturating_sub(150);
100			b += off;
101			c += off.max(1) * idx;
102			idx += 1;
103		}
104
105		// Add in the last part.
106		let leadout = src.leadout().saturating_sub(150);
107
108		let b = (b + leadout).to_le_bytes();
109		let c = (c + leadout.max(1) * idx).to_le_bytes();
110		let d = u32::from(src.cddb_id()).to_le_bytes();
111
112		Self([
113			src.audio_len() as u8,
114			b[0], b[1], b[2], b[3],
115			c[0], c[1], c[2], c[3],
116			d[0], d[1], d[2], d[3],
117		])
118	}
119}
120
121impl FromStr for AccurateRip {
122	type Err = TocError;
123	#[inline]
124	fn from_str(src: &str) -> Result<Self, Self::Err> { Self::decode(src) }
125}
126
127impl TryFrom<&str> for AccurateRip {
128	type Error = TocError;
129	#[inline]
130	fn try_from(src: &str) -> Result<Self, Self::Error> { Self::decode(src) }
131}
132
133impl AccurateRip {
134	/// # Drive Offset Data URL.
135	///
136	/// The binary-encoded list of known AccurateRip drive offsets can be
137	/// downloaded from this fixed URL.
138	///
139	/// The method [`AccurateRip::parse_drive_offsets`] can be used to parse
140	/// the raw data into a Rustful structure.
141	pub const DRIVE_OFFSET_URL: &'static str = "http://www.accuraterip.com/accuraterip/DriveOffsets.bin";
142}
143
144impl AccurateRip {
145	#[must_use]
146	/// # Number of Audio Tracks.
147	///
148	/// ## Examples
149	///
150	/// ```
151	/// use cdtoc::Toc;
152	///
153	/// // From Toc.
154	/// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
155	/// assert_eq!(toc.audio_len(), 4_usize);
156	///
157	/// // From AccurateRip.
158	/// let disc_id = toc.accuraterip_id();
159	/// assert_eq!(disc_id.audio_len(), 4_u8);
160	/// ```
161	pub const fn audio_len(&self) -> u8 { self.0[0] }
162
163	#[expect(unsafe_code, reason = "For performance.")]
164	#[must_use]
165	/// # AccurateRip Checksum URL.
166	///
167	/// This returns the URL where you can download the v1 and v2 checksums for
168	/// the disc, provided it is actually _in_ the AccurateRip database. (If it
169	/// isn't, their server will return a `404`.)
170	///
171	/// You can also get this directly via [`Toc::accuraterip_checksum_url`].
172	///
173	/// ## Examples
174	///
175	/// ```
176	/// use cdtoc::Toc;
177	///
178	/// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
179	/// let ar_id = toc.accuraterip_id();
180	/// assert_eq!(
181	///     ar_id.checksum_url(),
182	///     "http://www.accuraterip.com/accuraterip/a/9/8/dBAR-004-0002189a-00087f33-1f02e004.bin",
183	/// );
184	/// ```
185	pub fn checksum_url(&self) -> String {
186		// First things first, build the disc ID.
187		let disc_id = self.encode();
188		debug_assert!(disc_id.is_ascii(), "Bug: AccurateRip ID is not ASCII?!");
189
190		let mut out = String::with_capacity(84);
191		out.push_str("http://www.accuraterip.com/accuraterip/");
192		out.push(char::from(disc_id[11]));
193		out.push('/');
194		out.push(char::from(disc_id[10]));
195		out.push('/');
196		out.push(char::from(disc_id[9]));
197		out.push_str("/dBAR-");
198		// Safety: all bytes are ASCII.
199		out.push_str(unsafe { std::str::from_utf8_unchecked(disc_id.as_slice()) });
200		out.push_str(".bin");
201		out
202	}
203
204	#[must_use]
205	/// # CDDB ID.
206	///
207	/// In cases where your application requires both AccurateRip and CDDB IDs,
208	/// using this method to obtain the latter is cheaper than calling
209	/// [`Toc::cddb_id`].
210	///
211	/// ## Examples
212	///
213	/// ```
214	/// use cdtoc::Toc;
215	///
216	/// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
217	/// let ar_id = toc.accuraterip_id();
218	/// assert_eq!(
219	///     ar_id.cddb_id(),
220	///     toc.cddb_id(),
221	/// );
222	/// ```
223	pub const fn cddb_id(&self) -> Cddb {
224		Cddb(u32::from_le_bytes([
225			self.0[9],
226			self.0[10],
227			self.0[11],
228			self.0[12],
229		]))
230	}
231
232	/// # Decode.
233	///
234	/// Convert an AccurateRip ID string back into an [`AccurateRip`] instance.
235	///
236	/// ## Examples
237	///
238	/// ```
239	/// use cdtoc::{AccurateRip, Toc};
240	///
241	/// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
242	/// let ar_id = toc.accuraterip_id();
243	/// let ar_str = ar_id.to_string();
244	/// assert_eq!(ar_str, "004-0002189a-00087f33-1f02e004");
245	/// assert_eq!(AccurateRip::decode(ar_str), Ok(ar_id));
246	/// ```
247	///
248	/// Alternatively, you can use its `FromStr` and `TryFrom<&str>` impls:
249	///
250	/// ```
251	/// use cdtoc::{AccurateRip, Toc};
252	///
253	/// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
254	/// let ar_id = toc.accuraterip_id();
255	/// let ar_str = ar_id.to_string();
256	/// assert_eq!(AccurateRip::try_from(ar_str.as_str()), Ok(ar_id));
257	/// assert_eq!(ar_str.parse::<AccurateRip>(), Ok(ar_id));
258	/// ```
259	///
260	/// ## Errors
261	///
262	/// This will return an error if decoding fails.
263	pub fn decode<S>(src: S) -> Result<Self, TocError>
264	where S: AsRef<str> {
265		let src = src.as_ref().as_bytes();
266		if src.len() == 30 && src[3] == b'-' && src[12] == b'-' && src[21] == b'-' {
267			let a = u8::btou(&src[..3]).ok_or(TocError::AccurateRipDecode)?;
268			let b = u32::htou(&src[4..12])
269				.map(u32::to_le_bytes)
270				.ok_or(TocError::AccurateRipDecode)?;
271			let c = u32::htou(&src[13..21])
272				.map(u32::to_le_bytes)
273				.ok_or(TocError::AccurateRipDecode)?;
274			let d = u32::htou(&src[22..])
275				.map(u32::to_le_bytes)
276				.ok_or(TocError::AccurateRipDecode)?;
277
278			Ok(Self([
279				a,
280				b[0], b[1], b[2], b[3],
281				c[0], c[1], c[2], c[3],
282				d[0], d[1], d[2], d[3],
283			]))
284		}
285		else { Err(TocError::AccurateRipDecode) }
286	}
287
288	/// # Parse Checksums.
289	///
290	/// This will parse the v1 and v2 track checksums from a raw AccurateRip
291	/// checksum [bin file](AccurateRip::checksum_url).
292	///
293	/// The return result is a vector — indexed by track number (`n-1`) — of
294	/// `checksum => confidence` pairs.
295	///
296	/// Note: AccurateRip does not differentiate between v1 and v2 checksums;
297	/// the only way to know which is which is to find a match for a checksum
298	/// you calculated yourself.
299	///
300	/// ## Errors
301	///
302	/// This will return an error if parsing is unsuccessful, or the result is
303	/// empty.
304	pub fn parse_checksums(&self, bin: &[u8]) -> Result<Vec<BTreeMap<u32, u8>>, TocError> {
305		// We're expecting 0+ sections containing a 13-byte disc ID and a
306		// 9-byte checksum for each track.
307		let audio_len = self.audio_len() as usize;
308		let chunk_size = 13 + 9 * audio_len;
309		let mut out: Vec<BTreeMap<u32, u8>> = vec![BTreeMap::default(); audio_len];
310
311		for chunk in bin.chunks_exact(chunk_size) {
312			// Verify the chunk begins with the disc ID, and get to the meat.
313			let chunk = chunk.strip_prefix(&self.0).ok_or(TocError::Checksums)?;
314			// Update the list for each track, combining them if for some
315			// reason the same value appears twice.
316			for (k, v) in chunk.chunks_exact(9).enumerate() {
317				let crc = u32::from_le_bytes([v[1], v[2], v[3], v[4]]);
318				if crc != 0 {
319					let e = out[k].entry(crc).or_insert(0);
320					*e = e.saturating_add(v[0]);
321				}
322			}
323		}
324
325		// Consider it okay if we found at least one checksum.
326		if out.iter().any(|v| ! v.is_empty()) { Ok(out) }
327		else { Err(TocError::NoChecksums) }
328	}
329
330	/// # Parse Drive Offsets.
331	///
332	/// This will parse the vendor, model, and sample read offset information
333	/// from the raw AccurateRip offset list ([bin file](AccurateRip::DRIVE_OFFSET_URL)).
334	///
335	/// The parsed offsets will be grouped by `(vendor, model)`. Some entries
336	/// will not have a vendor, but entries without models are silently
337	/// ignored.
338	///
339	/// ## Errors
340	///
341	/// This will return an error if parsing is unsuccessful, or the result is
342	/// empty.
343	pub fn parse_drive_offsets(raw: &[u8])
344	-> Result<BTreeMap<(&str, &str), i16>, TocError> {
345		/// # Block Size.
346		///
347		/// The size of each raw entry, in bytes.
348		const BLOCK_SIZE: usize = 69;
349
350		/// # Trim Callback.
351		///
352		/// This is used to trim both ASCII whitespace and control characters,
353		/// as the raw data isn't afraid to null-pad its entries.
354		const fn trim_vm(c: char) -> bool { c.is_ascii_whitespace() || c.is_ascii_control() }
355
356		// There should be thousands of blocks, but we _need_ at least one!
357		if raw.len() < BLOCK_SIZE { return Err(TocError::NoDriveOffsets); }
358
359		// Entries come in blocks of 69 bytes. The first two bytes hold the
360		// little-endian offset; the next 32 hold the vendor/model; the rest
361		// we can ignore!
362		let mut out = BTreeMap::default();
363		for chunk in raw.chunks_exact(BLOCK_SIZE) {
364			// The offset is easy!
365			let offset = i16::from_le_bytes([chunk[0], chunk[1]]);
366
367			// The vendor/model come glued together with an inconsistent
368			// delimiter, so we have to work a bit to pull them apart.
369			let vm = std::str::from_utf8(&chunk[2..34])
370				.ok()
371				.filter(|vm| vm.is_ascii())
372				.ok_or(TocError::DriveOffsetDecode)?;
373
374			let (vendor, model) =
375				// If the vendor is missing, the string should begin "- ".
376				if let Some(model) = vm.strip_prefix("- ") {
377					("", model.trim_matches(trim_vm))
378				}
379				// Otherwise there should be a " - " separating the two, even
380				// in cases where the model is missing.
381				else {
382					let mut split = vm.splitn(2, " - ");
383					let vendor = split.next().ok_or(TocError::DriveOffsetDecode)?;
384					let model = split.next().unwrap_or("");
385					(vendor.trim_matches(trim_vm), model.trim_matches(trim_vm))
386				};
387
388			// Skip empty models.
389			if model.is_empty() {}
390			// Add the entry so long as the fields fit.
391			else if
392				DRIVE_OFFSET_OFFSET_RNG.contains(&offset) &&
393				vendor.len() <= DRIVE_OFFSET_VENDOR_MAX &&
394				model.len() <= DRIVE_OFFSET_MODEL_MAX &&
395				vendor.is_ascii() && model.is_ascii()
396			{
397				out.insert((vendor, model), offset);
398			}
399			// Otherwise the data's bad.
400			else { return Err(TocError::DriveOffsetDecode); }
401		}
402
403		// Return the results, unless they're empty.
404		if out.is_empty() { Err(TocError::NoDriveOffsets) }
405		else { Ok(out) }
406	}
407}
408
409impl AccurateRip {
410	#[inline]
411	/// # Encode to Buffer.
412	///
413	/// Format the AccurateRip ID for display, returning the bytes as a
414	/// fixed-length array.
415	fn encode(&self) -> [u8; 30] {
416		let mut disc_id: [u8; 30] = [
417			b'0', b'0', b'0',
418			b'-', b'0', b'0', b'0', b'0', b'0', b'0', b'0', b'0',
419			b'-', b'0', b'0', b'0', b'0', b'0', b'0', b'0', b'0',
420			b'-', b'0', b'0', b'0', b'0', b'0', b'0', b'0', b'0',
421		];
422
423		// Length.
424		disc_id[..3].copy_from_slice(dactyl::NiceU8::from(self.0[0]).as_bytes3());
425
426		// ID Parts.
427		faster_hex::hex_encode_fallback(&[self.0[4], self.0[3], self.0[2], self.0[1]], &mut disc_id[4..12]);
428		faster_hex::hex_encode_fallback(&[self.0[8], self.0[7], self.0[6], self.0[5]], &mut disc_id[13..21]);
429		faster_hex::hex_encode_fallback(&[self.0[12], self.0[11], self.0[10], self.0[9]], &mut disc_id[22..]);
430
431		disc_id
432	}
433}
434
435
436
437impl Toc {
438	#[cfg_attr(docsrs, doc(cfg(feature = "accuraterip")))]
439	#[must_use]
440	/// # AccurateRip ID.
441	///
442	/// This returns the [AccurateRip](http://accuraterip.com/) ID
443	/// corresponding to the table of contents.
444	///
445	/// ## Examples
446	///
447	/// ```
448	/// use cdtoc::Toc;
449	///
450	/// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
451	/// let ar_id = toc.accuraterip_id();
452	///
453	/// // Usually you'll want this value as a string:
454	/// assert_eq!(
455	///     ar_id.to_string(),
456	///     "004-0002189a-00087f33-1f02e004",
457	/// );
458	///
459	/// // But you can also get a binary version matching the format of the
460	/// // checksum bin files:
461	/// assert_eq!(
462	///     <[u8; 13]>::from(ar_id),
463	///     [4, 154, 24, 2, 0, 51, 127, 8, 0, 4, 224, 2, 31],
464	/// );
465	/// ```
466	pub fn accuraterip_id(&self) -> AccurateRip { AccurateRip::from(self) }
467
468	#[cfg_attr(docsrs, doc(cfg(feature = "accuraterip")))]
469	#[must_use]
470	/// # AccurateRip Checksum URL.
471	///
472	/// This returns the URL where you can download the v1 and v2 checksums for
473	/// the disc, provided it is actually _in_ the AccurateRip database. (If it
474	/// isn't, their server will return a `404`.)
475	///
476	/// ## Examples
477	///
478	/// ```
479	/// use cdtoc::Toc;
480	///
481	/// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
482	/// assert_eq!(
483	///     toc.accuraterip_checksum_url(),
484	///     "http://www.accuraterip.com/accuraterip/a/9/8/dBAR-004-0002189a-00087f33-1f02e004.bin",
485	/// );
486	/// ```
487	pub fn accuraterip_checksum_url(&self) -> String {
488		self.accuraterip_id().checksum_url()
489	}
490
491	#[cfg_attr(docsrs, doc(cfg(feature = "accuraterip")))]
492	/// # Parse Checksums.
493	///
494	/// This will parse the v1 and v2 track checksums from a raw AccurateRip
495	/// checksum [bin file](AccurateRip::checksum_url).
496	///
497	/// See [`AccurateRip::parse_checksums`] for more information.
498	///
499	/// ## Errors
500	///
501	/// This will return an error if parsing is unsuccessful, or the result is
502	/// empty.
503	pub fn accuraterip_parse_checksums(&self, bin: &[u8]) -> Result<Vec<BTreeMap<u32, u8>>, TocError> {
504		self.accuraterip_id().parse_checksums(bin)
505	}
506}
507
508
509
510#[cfg(test)]
511mod tests {
512	use super::*;
513
514	/// # Test Drive Offset Bin.
515	const OFFSET_BIN: &[u8] = &[155, 2, 80, 73, 79, 78, 69, 69, 82, 32, 32, 45, 32, 66, 68, 45, 82, 87, 32, 32, 32, 66, 68, 82, 45, 88, 49, 50, 0, 0, 0, 0, 0, 0, 0, 75, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 155, 2, 80, 73, 79, 78, 69, 69, 82, 32, 32, 45, 32, 66, 68, 45, 82, 87, 32, 32, 32, 66, 68, 82, 45, 88, 49, 50, 85, 0, 0, 0, 0, 0, 0, 201, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 155, 2, 80, 73, 79, 78, 69, 69, 82, 32, 32, 45, 32, 66, 68, 45, 82, 87, 32, 32, 32, 66, 68, 82, 45, 88, 49, 51, 0, 0, 0, 0, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 155, 2, 80, 73, 79, 78, 69, 69, 82, 32, 32, 45, 32, 66, 68, 45, 82, 87, 32, 32, 32, 66, 68, 82, 45, 88, 49, 51, 85, 0, 0, 0, 0, 0, 0, 60, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
516
517	#[test]
518	fn t_accuraterip() {
519		for (t, id) in [
520			(
521				"D+96+3B5D+78E3+B441+EC83+134F4+17225+1A801+1EA5C+23B5B+27CEF+2B58B+2F974+35D56+514C8",
522				"013-001802ed-00f8ee31-b611560e",
523			),
524			(
525				"4+96+2D2B+6256+B327+D84A",
526				"004-0002189a-00087f33-1f02e004",
527			),
528			(
529				"10+B6+5352+62AC+99D6+E218+12AC0+135E7+142E9+178B0+19D22+1B0D0+1E7FA+22882+247DB+27074+2A1BD+2C0FB",
530				"016-0018be61-012232a8-d6096410",
531			),
532			(
533				"15+247E+2BEC+4AF4+7368+9704+B794+E271+110D0+12B7A+145C1+16CAF+195CF+1B40F+1F04A+21380+2362D+2589D+2793D+2A760+2DA32+300E1+32B46",
534				"021-0022250d-020afc1b-100a5515",
535			),
536			(
537				"63+96+12D9+5546+A8A2+CAAA+128BF+17194+171DF+1722A+17275+172C0+1730B+17356+173A1+173EC+17437+17482+174CD+17518+17563+175AE+175F9+17644+1768F+176DA+17725+17770+177BB+17806+17851+1789C+178E7+17932+1797D+179C8+17A13+17A5E+17AA9+17AF4+17B3F+17B8A+17BD5+17C20+17C6B+17CB6+17D01+17D4C+17D97+17DE2+17E2D+17E78+17EC3+17F0E+17F59+17FA4+17FEF+1803A+18085+180D0+1811B+18166+181B1+181FC+18247+18292+182DD+18328+18373+183BE+18409+18454+1849F+184EA+18535+18580+185CB+18616+18661+186AC+186F7+18742+1878D+187D8+18823+1886E+188B9+18904+1894F+1899A+189E5+18A30+18A7B+18AC6+18B11+18B5C+18BA7+18BF2+18C38+1ECDC+246E9",
538				"099-00909976-1e2814f1-cc07c363",
539			),
540		] {
541			let toc = Toc::from_cdtoc(t).expect("Invalid TOC");
542			let ar_id = toc.accuraterip_id();
543			assert_eq!(ar_id.to_string(), id);
544
545			// Test decoding three ways.
546			assert_eq!(AccurateRip::decode(id), Ok(ar_id));
547			assert_eq!(AccurateRip::try_from(id), Ok(ar_id));
548			assert_eq!(id.parse::<AccurateRip>(), Ok(ar_id));
549		}
550	}
551
552	#[test]
553	fn t_drive_offsets() {
554		let parsed = AccurateRip::parse_drive_offsets(OFFSET_BIN)
555			.expect("Drive offset parsing failed.");
556
557		// Should never be empty.
558		assert!(! parsed.is_empty());
559
560		// Search for a known offset in the list to make sure it parsed.
561		let offset = parsed.get(&("PIONEER", "BD-RW   BDR-X13U"))
562			.expect("Unable to find BDR-X13U offset.");
563		assert_eq!(*offset, 667);
564	}
565}