use std::fs; use std::path::{Path, PathBuf}; use std::time::Duration; #[derive(Debug, Clone)] pub struct CueTrack { pub number: u32, pub title: String, pub performer: Option, /// Начало трека (INDEX 01), абсолютное время от начала физического файла. pub start: Duration, } #[derive(Debug, Clone)] pub struct CueSheet { pub album_title: Option, pub album_performer: Option, /// Абсолютный путь к аудиофайлу, на который ссылается cue (разрешён относительно /// расположения самого .cue файла). pub audio_path: PathBuf, pub tracks: Vec, } /// Парсит .cue файл. Поддерживается только один `FILE` на лист (типичный случай — /// весь альбом одним треком с cue-разметкой); мульти-файловые cue-листы — редкость, /// для них будет использован только первый встреченный `FILE`. pub fn parse_cue_file(cue_path: &Path) -> anyhow::Result { let content = fs::read_to_string(cue_path)?; let dir = cue_path.parent().unwrap_or_else(|| Path::new(".")); let mut album_title = None; let mut album_performer = None; let mut file_name: Option = None; let mut tracks = Vec::new(); let mut current_number: Option = None; let mut current_title: Option = None; let mut current_performer: Option = None; for raw in content.lines() { let line = raw.trim(); if line.is_empty() { continue; } let upper = line.to_ascii_uppercase(); if upper.starts_with("FILE ") { if file_name.is_none() { file_name = extract_quoted(line); } } else if let Some(rest) = upper.strip_prefix("TRACK ") { current_number = rest.split_whitespace().next().and_then(|s| s.parse().ok()); current_title = None; current_performer = None; } else if upper.starts_with("TITLE ") { if current_number.is_some() { current_title = extract_quoted(line); } else { album_title = extract_quoted(line); } } else if upper.starts_with("PERFORMER ") { if current_number.is_some() { current_performer = extract_quoted(line); } else { album_performer = extract_quoted(line); } } else if let Some(rest) = upper.strip_prefix("INDEX 01 ") { if let (Some(num), Some(start)) = (current_number, parse_timecode(rest.trim())) { tracks.push(CueTrack { number: num, title: current_title .clone() .unwrap_or_else(|| format!("Track {num}")), performer: current_performer.clone(), start, }); } } // INDEX 00 (пре-гэп), REM, CATALOG, FLAGS и т.п. — игнорируем, для базового // проигрывания не нужны. } let file_name = file_name.ok_or_else(|| anyhow::anyhow!("cue sheet has no FILE entry: {}", cue_path.display()))?; if tracks.is_empty() { anyhow::bail!("cue sheet has no tracks with INDEX 01: {}", cue_path.display()); } Ok(CueSheet { album_title, album_performer, audio_path: dir.join(file_name), tracks, }) } fn extract_quoted(line: &str) -> Option { let start = line.find('"')?; let rest = &line[start + 1..]; let end = rest.find('"')?; Some(rest[..end].to_string()) } /// Формат таймкода cue — `MM:SS:FF`, где FF — кадры, 75 кадров в секунде (стандарт CD). fn parse_timecode(s: &str) -> Option { let parts: Vec<&str> = s.split(':').collect(); if parts.len() != 3 { return None; } let mm: u64 = parts[0].parse().ok()?; let ss: u64 = parts[1].parse().ok()?; let ff: u64 = parts[2].parse().ok()?; Some(Duration::from_secs_f64( (mm * 60 + ss) as f64 + ff as f64 / 75.0, )) } #[cfg(test)] mod tests { use super::*; #[test] fn parses_basic_cue_sheet() { let cue = r#" PERFORMER "Test Artist" TITLE "Test Album" FILE "album.flac" WAVE TRACK 01 AUDIO TITLE "First Song" PERFORMER "Test Artist" INDEX 00 00:00:00 INDEX 01 00:00:00 TRACK 02 AUDIO TITLE "Second Song" INDEX 00 03:58:50 INDEX 01 04:00:50 "#; let dir = tempfile::tempdir().unwrap(); let cue_path = dir.path().join("album.cue"); fs::write(&cue_path, cue).unwrap(); let sheet = parse_cue_file(&cue_path).unwrap(); assert_eq!(sheet.album_title.as_deref(), Some("Test Album")); assert_eq!(sheet.album_performer.as_deref(), Some("Test Artist")); assert_eq!(sheet.audio_path, dir.path().join("album.flac")); assert_eq!(sheet.tracks.len(), 2); assert_eq!(sheet.tracks[0].title, "First Song"); assert_eq!(sheet.tracks[0].start, Duration::ZERO); assert_eq!(sheet.tracks[1].title, "Second Song"); // 4:00 + 50/75 s let expected = Duration::from_secs_f64(4.0 * 60.0 + 50.0 / 75.0); assert!((sheet.tracks[1].start.as_secs_f64() - expected.as_secs_f64()).abs() < 0.001); } #[test] fn track_without_title_gets_fallback_name() { let cue = r#" FILE "x.wav" WAVE TRACK 01 AUDIO INDEX 01 00:00:00 "#; let dir = tempfile::tempdir().unwrap(); let cue_path = dir.path().join("x.cue"); fs::write(&cue_path, cue).unwrap(); let sheet = parse_cue_file(&cue_path).unwrap(); assert_eq!(sheet.tracks[0].title, "Track 1"); } }