cue_rw/
lib.rs

1use std::fmt::Display;
2
3use bitflags::bitflags;
4use itertools::Itertools;
5use num_rational::Rational32;
6use thiserror::Error;
7
8trait StringExt {
9    fn trim_surrounding(&self, c: char) -> &Self;
10}
11
12impl StringExt for str {
13    fn trim_surrounding(&self, c: char) -> &str {
14        self.strip_prefix(c)
15            .and_then(|s| s.strip_suffix(c))
16            .unwrap_or(self)
17    }
18}
19
20/// Error type for CUE parsing
21#[derive(Error, Debug)]
22pub enum CUEParseError {
23    /// The timestamp is invalid
24    #[error("invalid timestamp: {0}")]
25    InvalidTimeStamp(String),
26    /// The required entries (title and performer) are not present in the CUE
27    /// track
28    #[error("missing {0} entry in cue track: {1}")]
29    MissingEntry(&'static str, String),
30    /// Invalid tag (first word) occurred in line
31    #[error("unsupported tag in line: {0}")]
32    InvalidTag(String),
33    /// No value (word after tag) are present in line
34    #[error("missing value in line: {0}")]
35    MissingValue(String),
36    /// The line cannot be parsed
37    #[error("invalid line: {0}")]
38    Invalid(String),
39    /// Some file lines are not associated with track definitions
40    #[error("not all files are used by tracks")]
41    FilesNotUsed,
42    /// The catalog value is not a 13-digit UPC/EAN code
43    #[error("the catalog code should be a 13-digit UPC/EAN code")]
44    InvalidCatalog(String),
45}
46
47pub type FileID = usize;
48
49/// Main struct for representing a CUE file
50#[derive(Debug, Clone, Eq, PartialEq, Default)]
51pub struct CUEFile {
52    /// The file names referenced by the CUE file
53    pub files:      Vec<String>,
54    /// The title of the album
55    pub title:      String,
56    /// The performer of the album
57    pub performer:  String,
58    /// The catalog ID of the album
59    pub catalog:    Option<String>,
60    /// The name of the CD-TEXT file
61    pub text_file:  Option<String>,
62    /// The songwriter of the album
63    pub songwriter: Option<String>,
64    /// Represents which file this track resides in, it is guaranteed that
65    /// [`FileID`]s for all [`CUETrack`]s uses up 0..`CUEFile.files.len()`
66    pub tracks:     Vec<(FileID, CUETrack)>,
67    /// REM comment lines
68    pub comments:   Vec<String>,
69}
70
71impl CUEFile {
72    /// Construct a new empty `CUEFile`
73    pub fn new() -> Self {
74        Self {
75            ..Default::default()
76        }
77    }
78}
79
80impl TryFrom<&str> for CUEFile {
81    type Error = CUEParseError;
82
83    fn try_from(value: &str) -> Result<Self, Self::Error> {
84        let value = value.strip_prefix('\u{feff}').unwrap_or(value);
85
86        let mut files = vec![];
87        let mut cur_file_id = None;
88        let mut title = None;
89        let mut performer = None;
90        let mut catalog = None;
91        let mut text_file = None;
92        let mut songwriter = None;
93        let mut comments = vec![];
94        let mut tracks = vec![];
95
96        let mut lines = value.lines().peekable();
97        loop {
98            let Some(line) = lines.next() else {
99                break;
100            };
101
102            if !line.starts_with("  ") {
103                let mut line_split = line.splitn(2, ' ');
104                let tag = line_split.next().unwrap();
105
106                match tag {
107                    "FILE" => {
108                        let mut split = line_split
109                            .next()
110                            .ok_or_else(|| CUEParseError::MissingValue(line.to_owned()))?
111                            .rsplitn(2, ' ');
112                        let file_name = split.nth(1).unwrap().trim_surrounding('"');
113                        files.push(file_name.to_owned());
114                        cur_file_id = Some(files.len() - 1);
115                    }
116                    "TITLE" => {
117                        title = Some(
118                            line_split
119                                .next()
120                                .ok_or_else(|| CUEParseError::MissingValue(line.to_owned()))?
121                                .trim_surrounding('"'),
122                        )
123                    }
124                    "PERFORMER" => {
125                        performer = Some(
126                            line_split
127                                .next()
128                                .ok_or_else(|| CUEParseError::MissingValue(line.to_owned()))?
129                                .trim_surrounding('"'),
130                        )
131                    }
132                    "CATALOG" => {
133                        let catalog_val = line_split
134                            .next()
135                            .ok_or_else(|| CUEParseError::MissingValue(line.to_owned()))?;
136                        if catalog_val.len() != 13
137                            || catalog_val.chars().any(|c| !c.is_ascii_digit())
138                        {
139                            Err(CUEParseError::InvalidCatalog(catalog_val.to_owned()))?
140                        }
141
142                        catalog = Some(catalog_val.to_owned())
143                    }
144                    "CDTEXTFILE" => {
145                        text_file = Some(
146                            line_split
147                                .next()
148                                .ok_or_else(|| CUEParseError::MissingValue(line.to_owned()))?
149                                .trim_surrounding('"')
150                                .to_owned(),
151                        )
152                    }
153                    "SONGWRITER" => {
154                        songwriter = Some(
155                            line_split
156                                .next()
157                                .ok_or_else(|| CUEParseError::MissingValue(line.to_owned()))?
158                                .trim_surrounding('"')
159                                .to_owned(),
160                        )
161                    }
162                    "REM" => comments.push(
163                        line_split
164                            .next()
165                            .ok_or_else(|| CUEParseError::MissingValue(line.to_owned()))?
166                            .to_owned(),
167                    ),
168                    _ => Err(CUEParseError::InvalidTag(line.to_owned()))?,
169                }
170            } else {
171                if cur_file_id.is_none() {
172                    Err(CUEParseError::Invalid(line.to_owned()))?
173                }
174
175                let line = line.trim();
176                if line.starts_with("TRACK") {
177                    let mut track_str = vec![];
178                    while let Some(line) = lines.peek() {
179                        if !line.starts_with("  ") || line.trim().starts_with("TRACK") {
180                            break;
181                        } else {
182                            track_str.push(lines.next().unwrap().trim());
183                        }
184                    }
185                    if !track_str.is_empty() {
186                        let track_str = track_str.join("\n");
187                        let track = CUETrack::try_from(track_str.as_str())?;
188                        tracks.push((cur_file_id.unwrap(), track));
189                    }
190                } else {
191                    Err(CUEParseError::InvalidTag(line.to_owned()))?
192                }
193            }
194        }
195
196        if files.is_empty() {
197            Err(CUEParseError::MissingEntry("file", value.to_owned()))?
198        }
199        let title = title
200            .map(|s| s.to_owned())
201            .ok_or_else(|| CUEParseError::MissingEntry("title", value.to_owned()))?;
202        let performer = performer
203            .map(|s| s.to_owned())
204            .ok_or_else(|| CUEParseError::MissingEntry("performer", value.to_owned()))?;
205
206        let mut file_ids = tracks
207            .iter()
208            .map(|(track_file_id, _)| *track_file_id)
209            .collect::<Vec<_>>();
210        file_ids.dedup();
211
212        if !file_ids
213            .into_iter()
214            .zip(0..files.len())
215            .all(|(track_file_id, file_id)| track_file_id == file_id)
216        {
217            Err(CUEParseError::FilesNotUsed)?
218        }
219
220        Ok(Self {
221            files,
222            title,
223            performer,
224            catalog,
225            text_file,
226            songwriter,
227            tracks,
228            comments,
229        })
230    }
231}
232
233impl Display for CUEFile {
234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235        let comments_str = self
236            .comments
237            .iter()
238            .map(|comment| format!("REM {comment}"))
239            .join("\n");
240        let comments_str = if comments_str.is_empty() {
241            None
242        } else {
243            Some(comments_str)
244        };
245
246        let title_str = format!(r#"TITLE "{}""#, self.title);
247        let performer_str = format!(r#"PERFORMER "{}""#, self.performer);
248        let catalog_str = self
249            .catalog
250            .as_ref()
251            .map(|catalog| format!("CATALOG {catalog}"));
252        let text_file_str = self
253            .text_file
254            .as_ref()
255            .map(|text_file| format!(r#"CDTEXTFILE "{text_file}""#));
256        let songwriter_str = self
257            .songwriter
258            .as_ref()
259            .map(|songwriter| format!(r#"SONGWRITER "{songwriter}""#));
260
261        let file_strs = self
262            .files
263            .iter()
264            .map(|file| format!(r#"FILE "{}" WAVE"#, file))
265            .collect::<Vec<_>>();
266        let track_strs = self
267            .tracks
268            .iter()
269            .enumerate()
270            .map(|(i, (file_id, t))| {
271                let track_header = format!("  TRACK {:02} AUDIO", i + 1);
272                let track_content = t.to_string();
273
274                (file_id, format!("{track_header}\n{track_content}"))
275            })
276            .collect::<Vec<_>>();
277
278        let mut file_track_strs = vec![];
279        for (file_id, file_str) in file_strs.into_iter().enumerate() {
280            let track_part = track_strs
281                .iter()
282                .filter(|(id, _)| **id == file_id)
283                .map(|(_, s)| s)
284                .join("\n");
285
286            file_track_strs.push(format!("{file_str}\n{track_part}"));
287        }
288        let file_track_strs = file_track_strs.into_iter().join("\n");
289
290        let str = [
291            comments_str,
292            Some(title_str),
293            Some(performer_str),
294            catalog_str,
295            text_file_str,
296            songwriter_str,
297            Some(file_track_strs),
298        ]
299        .into_iter()
300        .flatten()
301        .join("\n");
302        write!(f, "{}", str)
303    }
304}
305
306bitflags! {
307    /// Sub code flags for tracks used in CUE
308    #[repr(transparent)]
309    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
310    pub struct TrackFlags: u8 {
311        const DCP = 0x0;
312        const CH4 = 0x1;
313        const PRE = 0x2;
314        const SCMS = 0x4;
315    }
316}
317
318impl From<&str> for TrackFlags {
319    /// Non-known flags are ignored
320    fn from(s: &str) -> Self {
321        let mut flags = TrackFlags::empty();
322        for s in s.split(' ') {
323            flags |= match s {
324                "DCH" => TrackFlags::DCP,
325                "4CH" => TrackFlags::CH4,
326                "PRE" => TrackFlags::PRE,
327                "SCMS" => TrackFlags::SCMS,
328                _ => TrackFlags::empty(),
329            }
330        }
331        flags
332    }
333}
334
335impl Display for TrackFlags {
336    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337        let dcp_str = if self.contains(TrackFlags::DCP) {
338            Some("DCP")
339        } else {
340            None
341        };
342        let ch4_str = if self.contains(TrackFlags::CH4) {
343            Some("4CH")
344        } else {
345            None
346        };
347        let pre_str = if self.contains(TrackFlags::PRE) {
348            Some("PRE")
349        } else {
350            None
351        };
352        let scms_str = if self.contains(TrackFlags::SCMS) {
353            Some("SCMS")
354        } else {
355            None
356        };
357        let str = [dcp_str, ch4_str, pre_str, scms_str]
358            .into_iter()
359            .flatten()
360            .join(" ");
361
362        write!(f, "{str}")
363    }
364}
365
366/// struct describing a CUE track
367#[derive(Debug, Clone, Eq, PartialEq, Default)]
368pub struct CUETrack {
369    /// The title of the track
370    pub title:      String,
371    /// The performer of the track
372    pub performer:  Option<String>,
373    /// The sub code flags of the track
374    pub flags:      Option<TrackFlags>,
375    /// The ISRC of the track
376    pub isrc:       Option<String>,
377    /// The post gap of the track
378    pub post_gap:   Option<CUETimeStamp>,
379    /// The pre gap of the track
380    pub pre_gap:    Option<CUETimeStamp>,
381    /// The songwriter of the track
382    pub songwriter: Option<String>,
383    /// The index values of the track
384    pub indices:    Vec<(u8, CUETimeStamp)>,
385    /// REM comments of the track
386    pub comments:   Vec<String>,
387}
388
389impl CUETrack {
390    /// Construct a new empty `CUETrack`
391    pub fn new() -> Self {
392        Self {
393            ..Default::default()
394        }
395    }
396}
397
398impl TryFrom<&str> for CUETrack {
399    type Error = CUEParseError;
400
401    /// Try from lines describing CUE track **without** the "TRACK XX AUDIO"
402    /// line
403    fn try_from(value: &str) -> Result<Self, Self::Error> {
404        let mut title = None;
405        let mut performer = None;
406        let mut flags = None;
407        let mut isrc = None;
408        let mut post_gap = None;
409        let mut pre_gap = None;
410        let mut songwriter = None;
411        let mut indices = vec![];
412        let mut comments = vec![];
413
414        for line in value.lines() {
415            let mut line_split = line.splitn(2, ' ');
416
417            let tag = line_split.next().unwrap();
418            match tag {
419                "TITLE" => {
420                    title = Some(
421                        line_split
422                            .next()
423                            .ok_or_else(|| CUEParseError::MissingValue(line.to_owned()))?
424                            .trim_surrounding('"'),
425                    )
426                }
427                "PERFORMER" => {
428                    performer = Some(
429                        line_split
430                            .next()
431                            .ok_or_else(|| CUEParseError::MissingValue(line.to_owned()))?
432                            .trim_surrounding('"')
433                            .to_owned(),
434                    )
435                }
436                "FLAGS" => {
437                    let flags_val = line_split
438                        .next()
439                        .ok_or_else(|| CUEParseError::MissingValue(line.to_owned()))?;
440                    flags = Some(TrackFlags::from(flags_val));
441                }
442                "ISRC" => {
443                    isrc = Some(
444                        line_split
445                            .next()
446                            .ok_or_else(|| CUEParseError::MissingValue(line.to_owned()))?
447                            .trim_surrounding('"')
448                            .to_owned(),
449                    )
450                }
451                "POSTGAP" => {
452                    let post_gap_str = line_split
453                        .next()
454                        .ok_or_else(|| CUEParseError::MissingValue(line.to_owned()))?;
455                    post_gap = Some(CUETimeStamp::try_from(post_gap_str)?)
456                }
457                "PREGAP" => {
458                    let pre_gap_str = line_split
459                        .next()
460                        .ok_or_else(|| CUEParseError::MissingValue(line.to_owned()))?;
461                    pre_gap = Some(CUETimeStamp::try_from(pre_gap_str)?)
462                }
463                "SONGWRITER" => {
464                    songwriter = Some(
465                        line_split
466                            .next()
467                            .ok_or_else(|| CUEParseError::MissingValue(line.to_owned()))?
468                            .trim_surrounding('"')
469                            .to_owned(),
470                    )
471                }
472                "INDEX" => {
473                    let mut split = line_split
474                        .next()
475                        .ok_or_else(|| CUEParseError::MissingValue(line.to_owned()))?
476                        .split(' ');
477                    let index_i = split
478                        .next()
479                        .unwrap()
480                        .parse::<u8>()
481                        .map_err(|_| CUEParseError::Invalid(line.to_owned()))?;
482                    let index_ts = split
483                        .next()
484                        .ok_or_else(|| CUEParseError::MissingValue(line.to_owned()))?
485                        .try_into()?;
486                    indices.push((index_i, index_ts))
487                }
488                "REM" => comments.push(
489                    line_split
490                        .next()
491                        .ok_or_else(|| CUEParseError::MissingValue(line.to_owned()))?
492                        .to_owned(),
493                ),
494                _ => Err(CUEParseError::InvalidTag(line.to_owned()))?,
495            }
496        }
497
498        let title = title
499            .map(|s| s.to_owned())
500            .ok_or_else(|| CUEParseError::MissingEntry("title", value.to_owned()))?;
501
502        Ok(Self {
503            title,
504            performer,
505            flags,
506            isrc,
507            post_gap,
508            pre_gap,
509            songwriter,
510            indices,
511            comments,
512        })
513    }
514}
515
516impl Display for CUETrack {
517    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
518        let flags_str = self
519            .flags
520            .as_ref()
521            .map(|flags| format!("    FLAGS {}", flags));
522        let title_str = format!(r#"    TITLE "{}""#, self.title);
523        let performer_str = self
524            .performer
525            .as_ref()
526            .map(|performer| format!(r#"    PERFORMER "{}""#, performer));
527        let isrc_str = self.isrc.as_ref().map(|isrc| format!("    ISRC {}", isrc));
528        let pregap_str = self
529            .pre_gap
530            .as_ref()
531            .map(|pregap| format!("    PREGAP {}", pregap));
532        let indices_str = self
533            .indices
534            .iter()
535            .map(|(i, ts)| format!("    INDEX {:02} {}", i, ts))
536            .join("\n");
537        let comments_str = self
538            .comments
539            .iter()
540            .map(|comment| format!("    REM {}", comment))
541            .join("\n");
542        let comments_str = if comments_str.is_empty() {
543            None
544        } else {
545            Some(comments_str)
546        };
547
548        let str = [
549            flags_str,
550            Some(title_str),
551            performer_str,
552            pregap_str,
553            isrc_str,
554            Some(indices_str),
555            comments_str,
556        ]
557        .into_iter()
558        .flatten()
559        .join("\n");
560        write!(f, "{}", str)
561    }
562}
563
564/// Represents a CUE timestamp, the value in seconds can be obtained by
565/// converting it into [`Rational32`]
566#[derive(Debug, Clone, Copy, Eq, PartialEq)]
567pub struct CUETimeStamp {
568    minutes:   u8,
569    seconds:   u8,
570    fractions: u8,
571}
572
573impl CUETimeStamp {
574    /// Construct a new `CUETimeStamp`
575    pub fn new(minutes: u8, seconds: u8, fractions: u8) -> Self {
576        Self {
577            minutes,
578            seconds,
579            fractions,
580        }
581    }
582}
583
584impl TryFrom<&str> for CUETimeStamp {
585    type Error = CUEParseError;
586
587    fn try_from(value: &str) -> Result<Self, Self::Error> {
588        let err = || CUEParseError::InvalidTimeStamp(value.to_owned());
589
590        let split = value.split(':').collect::<Vec<_>>();
591        if split.len() != 3 {
592            Err(err())?
593        }
594
595        let numbers = split
596            .into_iter()
597            .map(|s| s.parse::<u8>().map_err(|_| err()))
598            .collect::<Result<Vec<_>, _>>()?;
599        if numbers.iter().any(|&n| n >= 100) {
600            Err(err())?
601        }
602
603        Ok(Self {
604            minutes:   numbers[0],
605            seconds:   numbers[1],
606            fractions: numbers[2],
607        })
608    }
609}
610
611impl Display for CUETimeStamp {
612    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
613        write!(
614            f,
615            "{:02}:{:02}:{:02}",
616            self.minutes, self.seconds, self.fractions
617        )
618    }
619}
620
621/// Converts a [`CUETimeStamp`] into a [`Rational32`], in seconds.
622impl From<CUETimeStamp> for Rational32 {
623    fn from(ts: CUETimeStamp) -> Self {
624        Rational32::from_integer(ts.minutes as i32 * 60)
625            + Rational32::from_integer(ts.seconds as _)
626            + Rational32::new(ts.fractions as i32, 75)
627    }
628}