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#[derive(Error, Debug)]
22pub enum CUEParseError {
23 #[error("invalid timestamp: {0}")]
25 InvalidTimeStamp(String),
26 #[error("missing {0} entry in cue track: {1}")]
29 MissingEntry(&'static str, String),
30 #[error("unsupported tag in line: {0}")]
32 InvalidTag(String),
33 #[error("missing value in line: {0}")]
35 MissingValue(String),
36 #[error("invalid line: {0}")]
38 Invalid(String),
39 #[error("not all files are used by tracks")]
41 FilesNotUsed,
42 #[error("the catalog code should be a 13-digit UPC/EAN code")]
44 InvalidCatalog(String),
45}
46
47pub type FileID = usize;
48
49#[derive(Debug, Clone, Eq, PartialEq, Default)]
51pub struct CUEFile {
52 pub files: Vec<String>,
54 pub title: String,
56 pub performer: String,
58 pub catalog: Option<String>,
60 pub text_file: Option<String>,
62 pub songwriter: Option<String>,
64 pub tracks: Vec<(FileID, CUETrack)>,
67 pub comments: Vec<String>,
69}
70
71impl CUEFile {
72 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 #[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 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#[derive(Debug, Clone, Eq, PartialEq, Default)]
368pub struct CUETrack {
369 pub title: String,
371 pub performer: Option<String>,
373 pub flags: Option<TrackFlags>,
375 pub isrc: Option<String>,
377 pub post_gap: Option<CUETimeStamp>,
379 pub pre_gap: Option<CUETimeStamp>,
381 pub songwriter: Option<String>,
383 pub indices: Vec<(u8, CUETimeStamp)>,
385 pub comments: Vec<String>,
387}
388
389impl CUETrack {
390 pub fn new() -> Self {
392 Self {
393 ..Default::default()
394 }
395 }
396}
397
398impl TryFrom<&str> for CUETrack {
399 type Error = CUEParseError;
400
401 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#[derive(Debug, Clone, Copy, Eq, PartialEq)]
567pub struct CUETimeStamp {
568 minutes: u8,
569 seconds: u8,
570 fractions: u8,
571}
572
573impl CUETimeStamp {
574 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
621impl 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}