169 lines
5.9 KiB
Rust
169 lines
5.9 KiB
Rust
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<String>,
|
||
/// Начало трека (INDEX 01), абсолютное время от начала физического файла.
|
||
pub start: Duration,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct CueSheet {
|
||
pub album_title: Option<String>,
|
||
pub album_performer: Option<String>,
|
||
/// Абсолютный путь к аудиофайлу, на который ссылается cue (разрешён относительно
|
||
/// расположения самого .cue файла).
|
||
pub audio_path: PathBuf,
|
||
pub tracks: Vec<CueTrack>,
|
||
}
|
||
|
||
/// Парсит .cue файл. Поддерживается только один `FILE` на лист (типичный случай —
|
||
/// весь альбом одним треком с cue-разметкой); мульти-файловые cue-листы — редкость,
|
||
/// для них будет использован только первый встреченный `FILE`.
|
||
pub fn parse_cue_file(cue_path: &Path) -> anyhow::Result<CueSheet> {
|
||
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<String> = None;
|
||
|
||
let mut tracks = Vec::new();
|
||
let mut current_number: Option<u32> = None;
|
||
let mut current_title: Option<String> = None;
|
||
let mut current_performer: Option<String> = 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<String> {
|
||
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<Duration> {
|
||
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");
|
||
}
|
||
}
|