cdtoc/lib.rs
1/*!
2# CDTOC
3
4[](https://docs.rs/cdtoc/)
5[](https://github.com/Blobfolio/cdtoc/blob/master/CHANGELOG.md)<br>
6[](https://crates.io/crates/cdtoc)
7[](https://github.com/Blobfolio/cdtoc/actions)
8[](https://deps.rs/crate/cdtoc/)<br>
9[](https://en.wikipedia.org/wiki/WTFPL)
10[](https://github.com/Blobfolio/cdtoc/issues)
11
12
13
14CDTOC is a simple Rust library for parsing and working with audio CD tables of contents, namely in the form of [CDTOC-style](https://forum.dbpoweramp.com/showthread.php?16705-FLAC-amp-Ogg-Vorbis-Storage-of-CDTOC&s=3ca0c65ee58fc45489103bb1c39bfac0&p=76686&viewfull=1#post76686) metadata values.
15
16By default it can also generate disc IDs for services like [AccurateRip](http://accuraterip.com/), [CDDB](https://en.wikipedia.org/wiki/CDDB), [CUETools Database](http://cue.tools/wiki/CUETools_Database), and [MusicBrainz](https://musicbrainz.org/), but you can disable the corresponding crate feature(s) — `accuraterip`, `cddb`, `ctdb`, and `musicbrainz` respectively — to shrink the dependency tree if you don't need that functionality.
17
18
19
20## Examples
21
22```
23use cdtoc::Toc;
24
25// From a CDTOC string.
26let toc1 = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
27
28// From the raw parts.
29let toc2 = Toc::from_parts(
30 vec![150, 11563, 25174, 45863],
31 None,
32 55370,
33).unwrap();
34
35// Either way gets you to the same place.
36assert_eq!(toc1, toc2);
37
38// You can also get a CDTOC-style string back at any time:
39assert_eq!(toc1.to_string(), "4+96+2D2B+6256+B327+D84A");
40```
41
42
43
44## De/Serialization
45
46The optional `serde` crate feature can be enabled to expose de/serialization implementations for this library's types:
47
48| Type | Format | Notes |
49| ---- | ------ | ----- |
50| [`AccurateRip`] | `String` | |
51| [`Cddb`] | `String` | |
52| [`Duration`] | `u64` | |
53| [`ShaB64`] | `String` | MusicBrainz and CTDB IDs. |
54| [`Toc`] | `String` | |
55| [`Track`] | `Map` | |
56| [`TrackPosition`] | `String` | |
57*/
58
59#![deny(
60 clippy::allow_attributes_without_reason,
61 clippy::correctness,
62 unreachable_pub,
63 unsafe_code,
64)]
65
66#![warn(
67 clippy::complexity,
68 clippy::nursery,
69 clippy::pedantic,
70 clippy::perf,
71 clippy::style,
72
73 clippy::allow_attributes,
74 clippy::clone_on_ref_ptr,
75 clippy::create_dir,
76 clippy::filetype_is_file,
77 clippy::format_push_string,
78 clippy::get_unwrap,
79 clippy::impl_trait_in_params,
80 clippy::lossy_float_literal,
81 clippy::missing_assert_message,
82 clippy::missing_docs_in_private_items,
83 clippy::needless_raw_strings,
84 clippy::panic_in_result_fn,
85 clippy::pub_without_shorthand,
86 clippy::rest_pat_in_fully_bound_structs,
87 clippy::semicolon_inside_block,
88 clippy::str_to_string,
89 clippy::string_to_string,
90 clippy::todo,
91 clippy::undocumented_unsafe_blocks,
92 clippy::unneeded_field_pattern,
93 clippy::unseparated_literal_suffix,
94 clippy::unwrap_in_result,
95
96 macro_use_extern_crate,
97 missing_copy_implementations,
98 missing_docs,
99 non_ascii_idents,
100 trivial_casts,
101 trivial_numeric_casts,
102 unused_crate_dependencies,
103 unused_extern_crates,
104 unused_import_braces,
105)]
106
107#![expect(clippy::doc_markdown, reason = "This gets annoying with names like MusicBrainz.")]
108
109#![cfg_attr(docsrs, feature(doc_cfg))]
110
111
112
113mod error;
114mod hex;
115mod time;
116mod track;
117#[cfg(feature = "accuraterip")] mod accuraterip;
118#[cfg(feature = "cddb")] mod cddb;
119#[cfg(feature = "ctdb")] mod ctdb;
120#[cfg(feature = "musicbrainz")] mod musicbrainz;
121#[cfg(feature = "serde")] mod serde;
122#[cfg(feature = "sha1")] mod shab64;
123
124pub use error::TocError;
125pub use time::Duration;
126pub use track::{
127 Track,
128 Tracks,
129 TrackPosition,
130};
131#[cfg(feature = "accuraterip")] pub use accuraterip::AccurateRip;
132#[cfg(feature = "cddb")] pub use cddb::Cddb;
133#[cfg(feature = "sha1")] pub use shab64::ShaB64;
134
135use dactyl::traits::HexToUnsigned;
136use std::fmt;
137
138
139
140#[cfg(any(feature = "ctdb", feature = "musicbrainz"))]
141/// # Track Zeroes.
142///
143/// The CTDB and Musicbrainz IDs hash one hundred tracks' worth of hexified
144/// sector values, eight bytes each. This serves as the default.
145const TRACK_ZEROES: [[u8; 8]; 100] = [[b'0'; 8]; 100];
146
147
148
149#[derive(Debug, Clone, Eq, Hash, PartialEq)]
150/// # CDTOC.
151///
152/// This struct holds a CD's parsed table of contents.
153///
154/// You can initialize it using a [CDTOC-style](https://forum.dbpoweramp.com/showthread.php?16705-FLAC-amp-Ogg-Vorbis-Storage-of-CDTOC&s=3ca0c65ee58fc45489103bb1c39bfac0&p=76686&viewfull=1#post76686) metadata value
155/// via [`Toc::from_cdtoc`] or manually with [`Toc::from_parts`].
156///
157/// Once parsed, you can obtain things like the [number of audio tracks](Toc::audio_len),
158/// their [sector positions](Toc::audio_sectors), information about the [session(s)](Toc::kind)
159/// and so on.
160///
161/// Many online databases derive their unique disc IDs using tables of content
162/// too. [`Toc`] can give you the following, provided the corresponding crate
163/// feature(s) are enabled:
164///
165/// | Service | Feature | Method |
166/// | ------- | ------- | ------ |
167/// | [AccurateRip](http://accuraterip.com/) | `accuraterip` | [`Toc::accuraterip_id`] |
168/// | [CDDB](https://en.wikipedia.org/wiki/CDDB) | `cddb` | [`Toc::cddb_id`] |
169/// | [CUETools Database](http://cue.tools/wiki/CUETools_Database) | `ctdb` | [`Toc::ctdb_id`] |
170/// | [MusicBrainz](https://musicbrainz.org/) | `musicbrainz` | [`Toc::musicbrainz_id`] |
171///
172/// If you don't care about any of those, import this crate with
173/// `default-features = false` to skip the overhead.
174///
175/// ## Examples
176///
177/// ```
178/// use cdtoc::Toc;
179///
180/// // From a CDTOC string.
181/// let toc1 = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
182///
183/// // From the raw parts.
184/// let toc2 = Toc::from_parts(
185/// vec![150, 11563, 25174, 45863],
186/// None,
187/// 55370,
188/// ).unwrap();
189///
190/// // Either way gets you to the same place.
191/// assert_eq!(toc1, toc2);
192///
193/// // You can also get a CDTOC-style string back at any time:
194/// assert_eq!(toc1.to_string(), "4+96+2D2B+6256+B327+D84A");
195/// ```
196pub struct Toc {
197 /// # Disc Type.
198 kind: TocKind,
199
200 /// # Start Sectors for Each Audio Track.
201 audio: Vec<u32>,
202
203 /// # Start Sector for Data Track (if any).
204 data: u32,
205
206 /// # Leadout Sector.
207 leadout: u32,
208}
209
210impl fmt::Display for Toc {
211 #[expect(clippy::cast_possible_truncation, reason = "False positive.")]
212 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213 /// # Trim Leading Zeroes.
214 const fn trim_leading_zeroes(mut src: &[u8]) -> &[u8] {
215 while let [b'0', rest @ ..] = src { src = rest; }
216 src
217 }
218
219 let mut out = Vec::with_capacity(128);
220
221 // Audio track count.
222 let audio_len = self.audio.len() as u8;
223 let buf = hex::upper_encode_u8(audio_len);
224 if 16 <= audio_len { out.push(buf[0]); }
225 out.push(buf[1]);
226
227 /// # Helper: Add Track to Buffer.
228 macro_rules! push {
229 ($v:expr) => (
230 out.push(b'+');
231 out.extend_from_slice(trim_leading_zeroes(&hex::upper_encode_u32($v)));
232 );
233 }
234
235 // The sectors.
236 for v in &self.audio { push!(*v); }
237
238 // And finally some combination of data and leadout.
239 match self.kind {
240 TocKind::Audio => { push!(self.leadout); },
241 TocKind::CDExtra => {
242 push!(self.data);
243 push!(self.leadout);
244 },
245 TocKind::DataFirst => {
246 push!(self.leadout);
247
248 // Handle this manually since there's the weird X marker.
249 out.push(b'+');
250 out.push(b'X');
251 out.extend_from_slice(trim_leading_zeroes(&hex::upper_encode_u32(self.data)));
252 },
253 }
254
255 std::str::from_utf8(&out)
256 .map_err(|_| fmt::Error)
257 .and_then(|s| <str as fmt::Display>::fmt(s, f))
258 }
259}
260
261impl Toc {
262 /// # From CDTOC Metadata Tag.
263 ///
264 /// Instantiate a new [`Toc`] from a CDTOC metadata tag value, of the
265 /// format described [here](https://forum.dbpoweramp.com/showthread.php?16705-FLAC-amp-Ogg-Vorbis-Storage-of-CDTOC&s=3ca0c65ee58fc45489103bb1c39bfac0&p=76686&viewfull=1#post76686).
266 ///
267 /// ## Examples
268 ///
269 /// ```
270 /// use cdtoc::Toc;
271 ///
272 /// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
273 /// ```
274 ///
275 /// ## Errors
276 ///
277 /// This will return an error if the tag value is improperly formatted, the
278 /// audio track count is outside `1..=99`, there are too many or too few
279 /// sectors, the leadin is less than `150`, or the sectors are ordered
280 /// incorrectly.
281 pub fn from_cdtoc<S>(src: S) -> Result<Self, TocError>
282 where S: AsRef<str> {
283 let (audio, data, leadout) = parse_cdtoc_metadata(src.as_ref().as_bytes())?;
284 Self::from_parts(audio, data, leadout)
285 }
286
287 /// # From Durations.
288 ///
289 /// This will attempt to create an audio-only [`Toc`] from the track
290 /// durations. (Needless to say, this will only work if all tracks are
291 /// present and in the right order!)
292 ///
293 /// If you happen to know the disc's true leadin offset you can specify it,
294 /// otherwise the "industry default" value of `150` will be assumed.
295 ///
296 /// To create a mixed-mode [`Toc`] from scratch, use [`Toc::from_parts`]
297 /// instead so you can specify the location of the data session.
298 ///
299 /// ## Examples
300 ///
301 /// ```
302 /// use cdtoc::{Toc, Duration};
303 ///
304 /// let toc = Toc::from_durations(
305 /// [
306 /// Duration::from(46650_u64),
307 /// Duration::from(41702_u64),
308 /// Duration::from(30295_u64),
309 /// Duration::from(37700_u64),
310 /// Duration::from(40050_u64),
311 /// Duration::from(53985_u64),
312 /// Duration::from(37163_u64),
313 /// Duration::from(59902_u64),
314 /// ],
315 /// None,
316 /// ).unwrap();
317 /// assert_eq!(
318 /// toc.to_string(),
319 /// "8+96+B6D0+159B6+1D00D+26351+2FFC3+3D2A4+463CF+54DCD",
320 /// );
321 /// ```
322 ///
323 /// ## Errors
324 ///
325 /// This will return an error if the track count is outside `1..=99`, the
326 /// leadin is less than 150, or the sectors overflow `u32`.
327 pub fn from_durations<I>(src: I, leadin: Option<u32>) -> Result<Self, TocError>
328 where I: IntoIterator<Item=Duration> {
329 let mut last: u32 = leadin.unwrap_or(150);
330 let mut audio: Vec<u32> = vec![last];
331 for d in src {
332 let next = u32::try_from(d.sectors())
333 .ok()
334 .and_then(|n| last.checked_add(n))
335 .ok_or(TocError::SectorSize)?;
336 audio.push(next);
337 last = next;
338 }
339
340 let leadout = audio.remove(audio.len() - 1);
341 Self::from_parts(audio, None, leadout)
342 }
343
344 /// # From Parts.
345 ///
346 /// Instantiate a new [`Toc`] by manually specifying the (starting) sectors
347 /// for each audio track, data track (if any), and the leadout.
348 ///
349 /// If a data track is supplied, it must fall between the last audio track
350 /// and leadout, or come before either.
351 ///
352 /// ## Examples
353 ///
354 /// ```
355 /// use cdtoc::Toc;
356 ///
357 /// let toc = Toc::from_parts(
358 /// vec![150, 11563, 25174, 45863],
359 /// None,
360 /// 55370,
361 /// ).unwrap();
362 ///
363 /// assert_eq!(toc.to_string(), "4+96+2D2B+6256+B327+D84A");
364 ///
365 /// // Sanity matters; the leadin, for example, can't be less than 150.
366 /// assert!(Toc::from_parts(
367 /// vec![0, 10525],
368 /// None,
369 /// 15000,
370 /// ).is_err());
371 /// ```
372 ///
373 /// ## Errors
374 ///
375 /// This will return an error if the audio track count is outside `1..=99`,
376 /// the leadin is less than `150`, or the sectors are in the wrong order.
377 pub fn from_parts(audio: Vec<u32>, data: Option<u32>, leadout: u32)
378 -> Result<Self, TocError> {
379 // Check length.
380 let audio_len = audio.len();
381 if 0 == audio_len { return Err(TocError::NoAudio); }
382 if 99 < audio_len { return Err(TocError::TrackCount); }
383
384 // Audio leadin must be at least 150.
385 if audio[0] < 150 { return Err(TocError::LeadinSize); }
386
387 // Audio is out of order?
388 if
389 (1 < audio_len && audio.windows(2).any(|pair| pair[1] <= pair[0])) ||
390 leadout <= audio[audio_len - 1]
391 {
392 return Err(TocError::SectorOrder);
393 }
394
395 // Figure out the kind and validate the data sector.
396 let kind =
397 if let Some(d) = data {
398 if d < audio[0] { TocKind::DataFirst }
399 else if audio[audio_len - 1] < d && d < leadout {
400 TocKind::CDExtra
401 }
402 else { return Err(TocError::SectorOrder); }
403 }
404 else { TocKind::Audio };
405
406 Ok(Self { kind, audio, data: data.unwrap_or_default(), leadout })
407 }
408
409 /// # Set Audio Leadin.
410 ///
411 /// Set the audio leadin, nudging all entries up or down accordingly (
412 /// including data and leadout).
413 ///
414 /// Note: this method cannot be used for data-first mixed-mode CDs.
415 ///
416 /// ## Examples
417 ///
418 /// ```
419 /// use cdtoc::{Toc, TocKind};
420 ///
421 /// let mut toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
422 /// assert_eq!(toc.audio_leadin(), 150);
423 ///
424 /// // Bump it up to 182.
425 /// assert!(toc.set_audio_leadin(182).is_ok());
426 /// assert_eq!(toc.audio_leadin(), 182);
427 /// assert_eq!(
428 /// toc.to_string(),
429 /// "4+B6+2D4B+6276+B347+D86A",
430 /// );
431 ///
432 /// // Back down to 150.
433 /// assert!(toc.set_audio_leadin(150).is_ok());
434 /// assert_eq!(toc.audio_leadin(), 150);
435 /// assert_eq!(
436 /// toc.to_string(),
437 /// "4+96+2D2B+6256+B327+D84A",
438 /// );
439 ///
440 /// // For CD-Extra, the data track will get nudged too.
441 /// toc = Toc::from_cdtoc("3+96+2D2B+6256+B327+D84A").unwrap();
442 /// assert_eq!(toc.kind(), TocKind::CDExtra);
443 /// assert_eq!(toc.audio_leadin(), 150);
444 /// assert_eq!(toc.data_sector(), Some(45863));
445 ///
446 /// assert!(toc.set_audio_leadin(182).is_ok());
447 /// assert_eq!(toc.audio_leadin(), 182);
448 /// assert_eq!(toc.data_sector(), Some(45895));
449 ///
450 /// // And back again.
451 /// assert!(toc.set_audio_leadin(150).is_ok());
452 /// assert_eq!(toc.audio_leadin(), 150);
453 /// assert_eq!(toc.data_sector(), Some(45863));
454 /// ```
455 ///
456 /// ## Errors
457 ///
458 /// This will return an error if the leadin is less than `150`, the CD
459 /// format is data-first, or the nudging causes the sectors to overflow
460 /// `u32`.
461 pub fn set_audio_leadin(&mut self, leadin: u32) -> Result<(), TocError> {
462 use std::cmp::Ordering;
463
464 if leadin < 150 { Err(TocError::LeadinSize) }
465 else if matches!(self.kind, TocKind::DataFirst) {
466 Err(TocError::Format(TocKind::DataFirst))
467 }
468 else {
469 let current = self.audio_leadin();
470 match leadin.cmp(¤t) {
471 // Nudge downward.
472 Ordering::Less => {
473 let diff = current - leadin;
474 for v in &mut self.audio { *v -= diff; }
475 if self.has_data() { self.data -= diff; }
476 self.leadout -= diff;
477 },
478 // Nudge upward.
479 Ordering::Greater => {
480 let diff = leadin - current;
481 for v in &mut self.audio {
482 *v = v.checked_add(diff).ok_or(TocError::SectorSize)?;
483 }
484 if self.has_data() {
485 self.data = self.data.checked_add(diff)
486 .ok_or(TocError::SectorSize)?;
487 }
488 self.leadout = self.leadout.checked_add(diff)
489 .ok_or(TocError::SectorSize)?;
490 },
491 // Noop.
492 Ordering::Equal => {},
493 }
494
495 Ok(())
496 }
497 }
498
499 /// # Set Media Kind.
500 ///
501 /// This method can be used to override the table of content's derived
502 /// media format.
503 ///
504 /// This is weird, but might come in handy if you need to correct a not-
505 /// quite-right CDTOC metadata tag value, such as one that accidentally
506 /// included the data session in its leading track count or ordered the
507 /// sectors of a data-audio CD sequentially.
508 ///
509 /// ## Examples
510 ///
511 /// ```
512 /// use cdtoc::{Toc, TocKind};
513 ///
514 /// // This will be interpreted as audio-only.
515 /// let mut toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
516 ///
517 /// // If the track count was wrong and it is really a mixed-mode CD-Extra
518 /// // disc, this will fix it right up:
519 /// assert!(toc.set_kind(TocKind::CDExtra).is_ok());
520 /// assert_eq!(
521 /// toc.to_string(),
522 /// "3+96+2D2B+6256+B327+D84A",
523 /// );
524 /// ```
525 ///
526 /// ## Errors
527 ///
528 /// This will return an error if there aren't enough sectors or tracks for
529 /// the new kind.
530 pub fn set_kind(&mut self, kind: TocKind) -> Result<(), TocError> {
531 match (self.kind, kind) {
532 // The last "audio" track is really data.
533 (TocKind::Audio, TocKind::CDExtra) => {
534 let len = self.audio.len();
535 if len == 1 { return Err(TocError::NoAudio); }
536 self.data = self.audio.remove(len - 1);
537 },
538 // The first "audio" track is really data.
539 (TocKind::Audio, TocKind::DataFirst) => {
540 if self.audio.len() == 1 { return Err(TocError::NoAudio); }
541 self.data = self.audio.remove(0);
542 },
543 // The "data" track is the really the last audio track.
544 (TocKind::CDExtra, TocKind::Audio) => {
545 self.audio.push(self.data);
546 self.data = 0;
547 },
548 // The "data" track is the really the last audio track.
549 (TocKind::DataFirst, TocKind::Audio) => {
550 self.audio.insert(0, self.data);
551 self.data = 0;
552 },
553 // Data should come first, not last.
554 (TocKind::CDExtra, TocKind::DataFirst) => {
555 // Move the old track to the end of the audio list and replace
556 // with the first.
557 self.audio.push(self.data);
558 self.data = self.audio.remove(0);
559 },
560 // Data should come last, not first.
561 (TocKind::DataFirst, TocKind::CDExtra) => {
562 // Move the old track to the front of the audio list and
563 // replace with the last.
564 self.audio.insert(0, self.data);
565 self.data = self.audio.remove(self.audio.len() - 1);
566 },
567 // Noop.
568 _ => return Ok(()),
569 }
570
571 self.kind = kind;
572 Ok(())
573 }
574}
575
576impl Toc {
577 #[must_use]
578 /// # Audio Leadin.
579 ///
580 /// Return the leadin of the audio session, sometimes called the "offset".
581 /// In practice, this is just where the first audio track begins.
582 ///
583 /// ## Examples
584 ///
585 /// ```
586 /// use cdtoc::Toc;
587 ///
588 /// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
589 /// assert_eq!(toc.audio_leadin(), 150);
590 /// ```
591 pub const fn audio_leadin(&self) -> u32 {
592 if let [ out, .. ] = self.audio.as_slice() { *out }
593 // This isn't actually reachable.
594 else { 150 }
595 }
596
597 #[must_use]
598 /// # Normalized Audio Leadin.
599 ///
600 /// This is the same as [`Toc::audio_leadin`], but _without_ the mandatory
601 /// 150-sector CD lead-in.
602 ///
603 /// ## Examples
604 ///
605 /// ```
606 /// use cdtoc::Toc;
607 ///
608 /// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
609 /// assert_eq!(toc.audio_leadin(), 150);
610 /// assert_eq!(toc.audio_leadin_normalized(), 0);
611 /// ```
612 pub const fn audio_leadin_normalized(&self) -> u32 { self.audio_leadin() - 150 }
613
614 #[must_use]
615 /// # Audio Leadout.
616 ///
617 /// Return the leadout for the audio session. This is usually the same as
618 /// [`Toc::leadout`], but for CD-Extra discs, the audio leadout is actually
619 /// the start of the data, minus a gap of `11_400`.
620 ///
621 /// ## Examples
622 ///
623 /// ```
624 /// use cdtoc::Toc;
625 ///
626 /// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
627 /// assert_eq!(toc.audio_leadout(), 55370);
628 /// ```
629 pub const fn audio_leadout(&self) -> u32 {
630 if matches!(self.kind, TocKind::CDExtra) {
631 self.data.saturating_sub(11_400)
632 }
633 else { self.leadout }
634 }
635
636 #[must_use]
637 /// # Normalized Audio Leadout.
638 ///
639 /// This is the same as [`Toc::audio_leadout`], but _without_ the mandatory
640 /// 150-sector CD lead-in.
641 ///
642 /// ## Examples
643 ///
644 /// ```
645 /// use cdtoc::Toc;
646 ///
647 /// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
648 /// assert_eq!(toc.audio_leadout(), 55370);
649 /// assert_eq!(toc.audio_leadout_normalized(), 55220);
650 /// ```
651 pub const fn audio_leadout_normalized(&self) -> u32 {
652 self.audio_leadout() - 150
653 }
654
655 #[must_use]
656 /// # Number of Audio Tracks.
657 ///
658 /// ## Examples
659 ///
660 /// ```
661 /// use cdtoc::Toc;
662 ///
663 /// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
664 /// assert_eq!(toc.audio_len(), 4);
665 /// ```
666 pub const fn audio_len(&self) -> usize { self.audio.len() }
667
668 #[must_use]
669 /// # Audio Sectors.
670 ///
671 /// Return the starting positions of each audio track.
672 ///
673 /// ## Examples
674 ///
675 /// ```
676 /// use cdtoc::Toc;
677 ///
678 /// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
679 /// assert_eq!(toc.audio_sectors(), &[150, 11563, 25174, 45863]);
680 /// ```
681 pub const fn audio_sectors(&self) -> &[u32] { self.audio.as_slice() }
682
683 #[expect(clippy::cast_possible_truncation, reason = "False positive.")]
684 #[must_use]
685 /// # Audio Track.
686 ///
687 /// Return the details of a given audio track on the disc, or `None` if the
688 /// track number is out of range.
689 pub fn audio_track(&self, num: usize) -> Option<Track> {
690 let len = self.audio_len();
691 if num == 0 || len < num { None }
692 else {
693 let from = self.audio[num - 1];
694 let to =
695 if num < len { self.audio[num] }
696 else { self.audio_leadout() };
697
698 Some(Track {
699 num: num as u8,
700 pos: TrackPosition::from((num, len)),
701 from,
702 to,
703 })
704 }
705 }
706
707 #[must_use]
708 /// # Audio Tracks.
709 ///
710 /// Return an iterator of [`Track`] details covering the whole album.
711 pub const fn audio_tracks(&self) -> Tracks<'_> {
712 Tracks::new(self.audio.as_slice(), self.audio_leadout())
713 }
714
715 #[must_use]
716 /// # Data Sector.
717 ///
718 /// Return the starting position of the data track, if any.
719 ///
720 /// ## Examples
721 ///
722 /// ```
723 /// use cdtoc::Toc;
724 ///
725 /// // No data here.
726 /// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
727 /// assert_eq!(toc.data_sector(), None);
728 ///
729 /// // This CD-Extra has data, though!
730 /// let toc = Toc::from_cdtoc("3+96+2D2B+6256+B327+D84A").unwrap();
731 /// assert_eq!(toc.data_sector(), Some(45_863));
732 /// ```
733 pub const fn data_sector(&self) -> Option<u32> {
734 if self.kind.has_data() { Some(self.data) }
735 else { None }
736 }
737
738 #[must_use]
739 /// # Normalized Data Sector.
740 ///
741 /// This is the same as [`Toc::data_sector`], but _without_ the mandatory
742 /// 150-sector CD lead-in.
743 ///
744 /// ## Examples
745 ///
746 /// ```
747 /// use cdtoc::Toc;
748 ///
749 /// // No data here.
750 /// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
751 /// assert_eq!(toc.data_sector(), None);
752 ///
753 /// // This CD-Extra has data, though!
754 /// let toc = Toc::from_cdtoc("3+96+2D2B+6256+B327+D84A").unwrap();
755 /// assert_eq!(toc.data_sector(), Some(45_863));
756 /// assert_eq!(toc.data_sector_normalized(), Some(45_713));
757 /// ```
758 pub const fn data_sector_normalized(&self) -> Option<u32> {
759 if self.kind.has_data() { Some(self.data.saturating_sub(150)) }
760 else { None }
761 }
762
763 #[must_use]
764 /// # Has Data?
765 ///
766 /// This returns `true` for mixed-mode CDs and `false` for audio-only ones.
767 ///
768 /// ## Examples
769 ///
770 /// ```
771 /// use cdtoc::Toc;
772 ///
773 /// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
774 /// assert_eq!(toc.has_data(), false);
775 ///
776 /// let toc = Toc::from_cdtoc("3+96+2D2B+6256+B327+D84A").unwrap();
777 /// assert_eq!(toc.has_data(), true);
778 /// ```
779 pub const fn has_data(&self) -> bool { self.kind.has_data() }
780
781 #[must_use]
782 /// # HTOA Pre-gap "Track".
783 ///
784 /// Return a `Track` object representing the space between the mandatory
785 /// disc leadin (`150`) and the start of the first audio track, if any.
786 ///
787 /// Such regions usually only contain a small amount of silence — extra
788 /// padding, basically — but every once in a while might be a secret bonus
789 /// song.
790 ///
791 /// ## Examples
792 ///
793 /// ```
794 /// use cdtoc::Toc;
795 ///
796 /// // This disc has no HTOA.
797 /// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
798 /// assert!(toc.htoa().is_none());
799 ///
800 /// // But this one does!
801 /// let toc = Toc::from_cdtoc("15+247E+2BEC+4AF4+7368+9704+B794+E271+110D0+12B7A+145C1+16CAF+195CF+1B40F+1F04A+21380+2362D+2589D+2793D+2A760+2DA32+300E1+32B46").unwrap();
802 /// let htoa = toc.htoa().unwrap();
803 /// assert!(htoa.is_htoa()); // Should always be true.
804 ///
805 /// // HTOAs have no track number.
806 /// assert_eq!(htoa.number(), 0);
807 ///
808 /// // Their position is also technically invalid.
809 /// assert!(! htoa.position().is_valid());
810 ///
811 /// // Their ranges are normal, though.
812 /// assert_eq!(htoa.sector_range(), 150..9342);
813 /// ```
814 pub const fn htoa(&self) -> Option<Track> {
815 let leadin = self.audio_leadin();
816 if leadin == 150 || matches!(self.kind, TocKind::DataFirst) { None }
817 else {
818 Some(Track {
819 num: 0,
820 pos: TrackPosition::Invalid,
821 from: 150,
822 to: leadin,
823 })
824 }
825 }
826
827 #[must_use]
828 /// # CD Format.
829 ///
830 /// This returns the [`TocKind`] corresponding to the table of contents,
831 /// useful if you want to know whether or not the disc has a data session,
832 /// and where it is in relation to the audio session.
833 ///
834 /// ## Examples
835 ///
836 /// ```
837 /// use cdtoc::{Toc, TocKind};
838 ///
839 /// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
840 /// assert_eq!(toc.kind(), TocKind::Audio);
841 ///
842 /// let toc = Toc::from_cdtoc("3+96+2D2B+6256+B327+D84A").unwrap();
843 /// assert_eq!(toc.kind(), TocKind::CDExtra);
844 ///
845 /// let toc = Toc::from_cdtoc("3+2D2B+6256+B327+D84A+X96").unwrap();
846 /// assert_eq!(toc.kind(), TocKind::DataFirst);
847 /// ```
848 pub const fn kind(&self) -> TocKind { self.kind }
849
850 #[must_use]
851 /// # Absolute Leadin.
852 ///
853 /// Return the offset of the first track (no matter the session type).
854 ///
855 /// ## Examples
856 ///
857 /// ```
858 /// use cdtoc::Toc;
859 ///
860 /// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
861 /// assert_eq!(toc.leadin(), 150);
862 /// ```
863 pub const fn leadin(&self) -> u32 {
864 if matches!(self.kind, TocKind::DataFirst) { self.data }
865 else { self.audio_leadin() }
866 }
867
868 #[must_use]
869 /// # Normalized Absolute Leadin.
870 ///
871 /// This is the same as [`Toc::leadin`], but _without_ the mandatory
872 /// 150-sector CD lead-in.
873 ///
874 /// ## Examples
875 ///
876 /// ```
877 /// use cdtoc::Toc;
878 ///
879 /// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
880 /// assert_eq!(toc.leadin(), 150);
881 /// assert_eq!(toc.leadin_normalized(), 0);
882 /// ```
883 pub const fn leadin_normalized(&self) -> u32 {
884 self.leadin().saturating_sub(150)
885 }
886
887 #[must_use]
888 /// # Absolute Leadout.
889 ///
890 /// Return the disc leadout, regardless of whether it marks the end of the
891 /// audio or data session.
892 ///
893 /// ## Examples
894 ///
895 /// ```
896 /// use cdtoc::Toc;
897 ///
898 /// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
899 /// assert_eq!(toc.leadout(), 55_370);
900 /// ```
901 pub const fn leadout(&self) -> u32 { self.leadout }
902
903 #[must_use]
904 /// # Normalized Absolute Leadout.
905 ///
906 /// This is the same as [`Toc::leadout`], but _without_ the mandatory
907 /// 150-sector CD lead-in.
908 ///
909 /// ## Examples
910 ///
911 /// ```
912 /// use cdtoc::Toc;
913 ///
914 /// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
915 /// assert_eq!(toc.leadout(), 55_370);
916 /// assert_eq!(toc.leadout_normalized(), 55_220);
917 /// ```
918 pub const fn leadout_normalized(&self) -> u32 { self.leadout - 150 }
919
920 #[must_use]
921 /// # Duration.
922 ///
923 /// Return the total duration of all audio tracks.
924 ///
925 /// ## Examples
926 ///
927 /// ```
928 /// use cdtoc::{Duration, Toc};
929 ///
930 /// let toc = Toc::from_cdtoc("4+96+2D2B+6256+B327+D84A").unwrap();
931 /// assert_eq!(
932 /// toc.duration(),
933 /// toc.audio_tracks().map(|t| t.duration()).sum(),
934 /// );
935 /// ```
936 pub const fn duration(&self) -> Duration {
937 Duration((self.audio_leadout() - self.audio_leadin()) as u64)
938 }
939}
940
941
942
943#[derive(Debug, Clone, Copy, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
944/// # CD Format.
945///
946/// This enum is used to differentiate between audio-only and mixed-mode discs
947/// because that ultimately determines the formatting of CDTOC metadata values
948/// and various derived third-party IDs.
949pub enum TocKind {
950 #[default]
951 /// # Audio-Only.
952 Audio,
953
954 /// # Mixed w/ Trailing Data Session.
955 CDExtra,
956
957 /// # Mixed w/ Leading Data Session.
958 ///
959 /// This would only be possible with a weird homebrew CD-R; retail CDs
960 /// place their data sessions at the end.
961 DataFirst,
962}
963
964impl fmt::Display for TocKind {
965 #[inline]
966 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
967 <str as fmt::Display>::fmt(self.as_str(), f)
968 }
969}
970
971impl TocKind {
972 #[must_use]
973 /// # As Str.
974 ///
975 /// Return the value as a string slice.
976 pub const fn as_str(self) -> &'static str {
977 match self {
978 Self::Audio => "audio-only",
979 Self::CDExtra => "CD-Extra",
980 Self::DataFirst => "data+audio",
981 }
982 }
983
984 #[must_use]
985 /// # Has Data?
986 ///
987 /// Returns `true` if the format is mixed-mode.
988 ///
989 /// ## Examples
990 ///
991 /// ```
992 /// use cdtoc::TocKind;
993 ///
994 /// // Yep!
995 /// assert!(TocKind::CDExtra.has_data());
996 /// assert!(TocKind::DataFirst.has_data());
997 ///
998 /// // Nope!
999 /// assert!(! TocKind::Audio.has_data());
1000 /// ```
1001 pub const fn has_data(self) -> bool {
1002 matches!(self, Self::CDExtra | Self::DataFirst)
1003 }
1004}
1005
1006
1007
1008/// # Parse CDTOC Metadata.
1009///
1010/// This parses the audio track count and sector positions from a CDTOC-style
1011/// metadata tag value. It will return a parsing error if the formatting is
1012/// grossly wrong, but will not validate the sanity of the count/parts.
1013fn parse_cdtoc_metadata(src: &[u8]) -> Result<(Vec<u32>, Option<u32>, u32), TocError> {
1014 let src = src.trim_ascii();
1015 let mut split = src.split(|b| b'+'.eq(b));
1016
1017 // The number of audio tracks comes first.
1018 let audio_len = split.next()
1019 .and_then(u8::htou)
1020 .ok_or(TocError::TrackCount)?;
1021
1022 // We should have starting positions for just as many tracks.
1023 let sectors: Vec<u32> = split
1024 .by_ref()
1025 .take(usize::from(audio_len))
1026 .map(u32::htou)
1027 .collect::<Option<Vec<u32>>>()
1028 .ok_or(TocError::SectorSize)?;
1029
1030 // Make sure we actually do.
1031 let sectors_len = sectors.len();
1032 if 0 == sectors_len { return Err(TocError::NoAudio); }
1033 if sectors_len != usize::from(audio_len) {
1034 return Err(TocError::SectorCount(audio_len, sectors_len));
1035 }
1036
1037 // There should be at least one more entry to mark the audio leadout.
1038 let last1 = split.next()
1039 .ok_or(TocError::SectorCount(audio_len, sectors_len - 1))?;
1040 let last1 = u32::htou(last1).ok_or(TocError::SectorSize)?;
1041
1042 // If there is yet another entry, we've got a mixed-mode disc.
1043 if let Some(last2) = split.next() {
1044 // Unlike the other values, this entry might have an x-prefix to denote
1045 // a non-standard data-first position.
1046 let last2 = u32::htou(last2)
1047 .or_else(||
1048 last2.strip_prefix(b"X").or_else(|| last2.strip_prefix(b"x"))
1049 .and_then(u32::htou)
1050 )
1051 .ok_or(TocError::SectorSize)?;
1052
1053 // That should be that!
1054 let remaining = split.count();
1055 if remaining == 0 {
1056 // "last1" is data, "last2" is leadout.
1057 if last1 < last2 {
1058 Ok((sectors, Some(last1), last2))
1059 }
1060 // "last2" is data, "last1" is leadout.
1061 else {
1062 Ok((sectors, Some(last2), last1))
1063 }
1064 }
1065 // Too many sectors!
1066 else {
1067 Err(TocError::SectorCount(audio_len, sectors_len + remaining))
1068 }
1069 }
1070 // A typical audio-only CD.
1071 else { Ok((sectors, None, last1)) }
1072}
1073
1074
1075
1076#[cfg(test)]
1077mod tests {
1078 use super::*;
1079 use brunch as _;
1080 use serde_json as _;
1081
1082 const CDTOC_AUDIO: &str = "B+96+5DEF+A0F2+F809+1529F+1ACB3+20CBC+24E14+2AF17+2F4EA+35BDD+3B96D";
1083 const CDTOC_EXTRA: &str = "A+96+3757+696D+C64F+10A13+14DA2+19E88+1DBAA+213A4+2784E+2D7AF+36F11";
1084 const CDTOC_DATA_AUDIO: &str = "A+3757+696D+C64F+10A13+14DA2+19E88+1DBAA+213A4+2784E+2D7AF+36F11+X96";
1085
1086 #[test]
1087 /// # Test Audio-Only Parsing.
1088 fn t_audio() {
1089 let toc = Toc::from_cdtoc(CDTOC_AUDIO).expect("Unable to parse CDTOC_AUDIO.");
1090 let sectors = vec![
1091 150,
1092 24047,
1093 41202,
1094 63497,
1095 86687,
1096 109_747,
1097 134_332,
1098 151_060,
1099 175_895,
1100 193_770,
1101 220_125,
1102 ];
1103 assert_eq!(toc.audio_len(), 11);
1104 assert_eq!(toc.audio_sectors(), §ors);
1105 assert_eq!(toc.data_sector(), None);
1106 assert!(!toc.has_data());
1107 assert_eq!(toc.kind(), TocKind::Audio);
1108 assert_eq!(toc.audio_leadin(), 150);
1109 assert_eq!(toc.audio_leadout(), 244_077);
1110 assert_eq!(toc.leadin(), 150);
1111 assert_eq!(toc.leadout(), 244_077);
1112 assert_eq!(toc.to_string(), CDTOC_AUDIO);
1113
1114 // This should match when built with the equivalent parts.
1115 assert_eq!(
1116 Toc::from_parts(sectors, None, 244_077),
1117 Ok(toc),
1118 );
1119
1120 // Let's also quickly test that a long TOC works gets the audio track
1121 // count right.
1122 let toc = Toc::from_cdtoc("20+96+33BA+5B5E+6C74+7C96+91EE+A9A3+B1AC+BEFC+D2E6+E944+103AC+11426+14B58+174E2+1A9F7+1C794+1F675+21AB9+24090+277DD+2A783+2D508+2DEAA+2F348+31F20+37419+3A463+3DC2F+4064B+43337+4675B+4A7C0")
1123 .expect("Long TOC failed.");
1124 assert_eq!(toc.audio_len(), 32);
1125 assert_eq!(
1126 toc.to_string(),
1127 "20+96+33BA+5B5E+6C74+7C96+91EE+A9A3+B1AC+BEFC+D2E6+E944+103AC+11426+14B58+174E2+1A9F7+1C794+1F675+21AB9+24090+277DD+2A783+2D508+2DEAA+2F348+31F20+37419+3A463+3DC2F+4064B+43337+4675B+4A7C0"
1128 );
1129
1130 // And one more with a hexish track count.
1131 let toc = Toc::from_cdtoc("10+96+2B4E+4C51+6B3C+9E08+CD43+FC99+13A55+164B8+191C9+1C0FF+1F613+21B5A+23F70+27A4A+2C20D+2FC65").unwrap();
1132 assert_eq!(toc.audio_len(), 16);
1133 assert_eq!(
1134 toc.to_string(),
1135 "10+96+2B4E+4C51+6B3C+9E08+CD43+FC99+13A55+164B8+191C9+1C0FF+1F613+21B5A+23F70+27A4A+2C20D+2FC65"
1136 );
1137 }
1138
1139 #[test]
1140 /// # Test CD-Extra Parsing.
1141 fn t_extra() {
1142 let toc = Toc::from_cdtoc(CDTOC_EXTRA).expect("Unable to parse CDTOC_EXTRA.");
1143 let sectors = vec![
1144 150,
1145 14167,
1146 26989,
1147 50767,
1148 68115,
1149 85410,
1150 106_120,
1151 121_770,
1152 136_100,
1153 161_870,
1154 ];
1155 assert_eq!(toc.audio_len(), 10);
1156 assert_eq!(toc.audio_sectors(), §ors);
1157 assert_eq!(toc.data_sector(), Some(186_287));
1158 assert!(toc.has_data());
1159 assert_eq!(toc.kind(), TocKind::CDExtra);
1160 assert_eq!(toc.audio_leadin(), 150);
1161 assert_eq!(toc.audio_leadout(), 174_887);
1162 assert_eq!(toc.leadin(), 150);
1163 assert_eq!(toc.leadout(), 225_041);
1164 assert_eq!(toc.to_string(), CDTOC_EXTRA);
1165
1166 // This should match when built with the equivalent parts.
1167 assert_eq!(
1168 Toc::from_parts(sectors, Some(186_287), 225_041),
1169 Ok(toc),
1170 );
1171 }
1172
1173 #[test]
1174 /// # Test Data-First Parsing.
1175 fn t_data_first() {
1176 let toc = Toc::from_cdtoc(CDTOC_DATA_AUDIO)
1177 .expect("Unable to parse CDTOC_DATA_AUDIO.");
1178 let sectors = vec![
1179 14167,
1180 26989,
1181 50767,
1182 68115,
1183 85410,
1184 106_120,
1185 121_770,
1186 136_100,
1187 161_870,
1188 186_287,
1189 ];
1190 assert_eq!(toc.audio_len(), 10);
1191 assert_eq!(toc.audio_sectors(), §ors);
1192 assert_eq!(toc.data_sector(), Some(150));
1193 assert!(toc.has_data());
1194 assert_eq!(toc.kind(), TocKind::DataFirst);
1195 assert_eq!(toc.audio_leadin(), 14167);
1196 assert_eq!(toc.audio_leadout(), 225_041);
1197 assert_eq!(toc.leadin(), 150);
1198 assert_eq!(toc.leadout(), 225_041);
1199 assert_eq!(toc.to_string(), CDTOC_DATA_AUDIO);
1200
1201 // This should match when built with the equivalent parts.
1202 assert_eq!(
1203 Toc::from_parts(sectors, Some(150), 225_041),
1204 Ok(toc),
1205 );
1206 }
1207
1208 #[test]
1209 /// # Test Metadata Failures.
1210 fn t_bad() {
1211 for i in [
1212 "A+96+3757+696D+C64F+10A13+14DA2+19E88+1DBAA+213A4+2784E+2D7AF+36F11+36F12",
1213 "A+96+3757+696D+C64F+10A13+14DA2+19E88+1DBAA+213A4+2784E",
1214 "0+96",
1215 "A+96+3757+696D+C64F+10A13+14DA2+19E88+2784E+1DBAA+213A4+2D7AF+36F11",
1216 ] {
1217 assert!(Toc::from_cdtoc(i).is_err());
1218 }
1219 }
1220
1221 #[test]
1222 #[expect(clippy::cognitive_complexity, reason = "It is what it is.")]
1223 /// # Test Kind Conversions.
1224 fn t_rekind() {
1225 // Start with audio.
1226 let mut toc = Toc::from_cdtoc(CDTOC_AUDIO)
1227 .expect("Unable to parse CDTOC_AUDIO.");
1228
1229 // To CD-Extra.
1230 assert!(toc.set_kind(TocKind::CDExtra).is_ok());
1231 assert_eq!(toc.audio_len(), 10);
1232 assert_eq!(
1233 toc.audio_sectors(),
1234 &[
1235 150,
1236 24047,
1237 41202,
1238 63497,
1239 86687,
1240 109_747,
1241 134_332,
1242 151_060,
1243 175_895,
1244 193_770,
1245 ]
1246 );
1247 assert_eq!(toc.data_sector(), Some(220_125));
1248 assert!(toc.has_data());
1249 assert_eq!(toc.kind(), TocKind::CDExtra);
1250 assert_eq!(toc.audio_leadin(), 150);
1251 assert_eq!(toc.audio_leadout(), 208_725);
1252 assert_eq!(toc.leadin(), 150);
1253 assert_eq!(toc.leadout(), 244_077);
1254
1255 // Back again.
1256 assert!(toc.set_kind(TocKind::Audio).is_ok());
1257 assert_eq!(Toc::from_cdtoc(CDTOC_AUDIO).unwrap(), toc);
1258
1259 // To data-audio.
1260 assert!(toc.set_kind(TocKind::DataFirst).is_ok());
1261 assert_eq!(toc.audio_len(), 10);
1262 assert_eq!(
1263 toc.audio_sectors(),
1264 &[
1265 24047,
1266 41202,
1267 63497,
1268 86687,
1269 109_747,
1270 134_332,
1271 151_060,
1272 175_895,
1273 193_770,
1274 220_125,
1275 ]
1276 );
1277 assert_eq!(toc.data_sector(), Some(150));
1278 assert!(toc.has_data());
1279 assert_eq!(toc.kind(), TocKind::DataFirst);
1280 assert_eq!(toc.audio_leadin(), 24047);
1281 assert_eq!(toc.audio_leadout(), 244_077);
1282 assert_eq!(toc.leadin(), 150);
1283 assert_eq!(toc.leadout(), 244_077);
1284
1285 // Back again.
1286 assert!(toc.set_kind(TocKind::Audio).is_ok());
1287 assert_eq!(Toc::from_cdtoc(CDTOC_AUDIO).unwrap(), toc);
1288
1289 // Now test data-to-other-data conversions.
1290 toc = Toc::from_cdtoc(CDTOC_EXTRA)
1291 .expect("Unable to parse CDTOC_EXTRA.");
1292 let extra = toc.clone();
1293 let data_audio = Toc::from_cdtoc(CDTOC_DATA_AUDIO)
1294 .expect("Unable to parse CDTOC_DATA_AUDIO.");
1295
1296 // To data-audio.
1297 assert!(toc.set_kind(TocKind::DataFirst).is_ok());
1298 assert_eq!(toc, data_audio);
1299
1300 // And back again.
1301 assert!(toc.set_kind(TocKind::CDExtra).is_ok());
1302 assert_eq!(toc, extra);
1303 }
1304}