nsm/crates/library/src/cue.rs
2026-07-13 09:24:20 +03:00

169 lines
5.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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");
}
}