use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::{Result, anyhow, bail};
use crate::acquire::types::AudioFormat;
use crate::proc;
const PROBE_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, PartialEq)]
pub struct AudioInfo {
pub duration_secs: f64,
pub sample_rate: Option<i64>,
pub bit_depth: Option<i64>,
pub channels: Option<i64>,
pub bit_rate: Option<i64>,
pub codec: Option<String>,
pub file_size: u64,
pub tags: Tags,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Tags {
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub album_artist: Option<String>,
pub genre: Option<String>,
pub comment: Option<String>,
pub year: Option<i64>,
pub track_no: Option<i64>,
pub disc_no: Option<i64>,
}
impl AudioInfo {
pub fn length_secs(&self) -> i64 {
self.duration_secs.round() as i64
}
pub fn rekordbox_file_type(&self, path: &Path) -> Option<i64> {
let from_codec = match self.codec.as_deref() {
Some("flac") => Some(5),
Some("pcm_s16be" | "pcm_s24be" | "pcm_s16le" | "pcm_s24le") => {
None
}
Some("mp3") => Some(0),
Some("aac" | "alac") => Some(1),
_ => None,
};
if from_codec.is_some() {
return from_codec;
}
path.extension()
.and_then(|e| e.to_str())
.and_then(|e| e.parse::<AudioFormat>().ok())
.and_then(|f| f.rekordbox_file_type())
}
}
pub fn probe(path: &Path) -> Result<AudioInfo> {
if !path.exists() {
bail!("no such audio file: {}", path.display());
}
let file_size = std::fs::metadata(path)?.len();
let mut cmd = proc::capture("ffprobe");
cmd.args([
"-v",
"error",
"-select_streams",
"a:0",
"-show_entries",
"stream=codec_name,sample_rate,channels,bits_per_raw_sample,bit_rate",
"-show_entries",
"format=duration,bit_rate",
"-show_entries",
"format_tags",
"-of",
"default=noprint_wrappers=1",
])
.arg(path);
let out = proc::run_with_deadline(cmd, Instant::now() + PROBE_TIMEOUT)?;
if !out.status.success() {
bail!(
"ffprobe failed on {}: {}",
path.display(),
proc::stderr_tail(&out.stderr)
);
}
let text = String::from_utf8_lossy(&out.stdout);
parse_probe(&text, file_size)
.ok_or_else(|| anyhow!("ffprobe gave no usable stream info for {}", path.display()))
}
fn parse_probe(text: &str, file_size: u64) -> Option<AudioInfo> {
let mut duration = None;
let mut sample_rate = None;
let mut bit_depth = None;
let mut channels = None;
let mut stream_bit_rate = None;
let mut format_bit_rate = None;
let mut codec = None;
let mut raw_tags: Vec<(String, String)> = Vec::new();
for line in text.lines() {
let Some((k, v)) = line.split_once('=') else {
continue;
};
let v = v.trim();
if v.is_empty() || v == "N/A" {
continue;
}
if let Some(name) = k.trim().strip_prefix("TAG:") {
raw_tags.push((name.to_ascii_lowercase(), v.to_string()));
continue;
}
match k.trim() {
"duration" => duration = v.parse::<f64>().ok(),
"sample_rate" => sample_rate = v.parse::<i64>().ok(),
"bits_per_raw_sample" => bit_depth = v.parse::<i64>().ok(),
"channels" => channels = v.parse::<i64>().ok(),
"codec_name" => codec = Some(v.to_string()),
"bit_rate" => {
if stream_bit_rate.is_none() {
stream_bit_rate = v.parse::<i64>().ok();
} else {
format_bit_rate = v.parse::<i64>().ok();
}
}
_ => {}
}
}
Some(AudioInfo {
duration_secs: duration?,
sample_rate,
bit_depth,
channels,
bit_rate: stream_bit_rate.or(format_bit_rate),
codec,
file_size,
tags: build_tags(&raw_tags),
})
}
fn build_tags(raw: &[(String, String)]) -> Tags {
let get = |names: &[&str]| -> Option<String> {
names.iter().find_map(|want| {
raw.iter()
.find(|(k, _)| k == want)
.map(|(_, v)| v.trim().to_string())
.filter(|v| !v.is_empty())
})
};
let number = |names: &[&str]| -> Option<i64> {
get(names).and_then(|v| {
v.split(['/', '-'])
.next()
.and_then(|n| n.trim().parse::<i64>().ok())
})
};
Tags {
title: get(&["title"]),
artist: get(&["artist"]),
album: get(&["album"]),
album_artist: get(&["album_artist", "albumartist"]),
genre: get(&["genre"]),
comment: get(&["comment", "description"]),
year: number(&["date", "year", "originaldate"]),
track_no: number(&["track", "tracknumber"]),
disc_no: number(&["disc", "discnumber"]),
}
}
#[cfg(test)]
mod tests {
use super::*;
const FLAC: &str = "\
codec_name=flac
sample_rate=44100
channels=2
bits_per_raw_sample=16
bit_rate=N/A
duration=310.588231
bit_rate=1064321
";
const MP3: &str = "\
codec_name=mp3
sample_rate=44100
channels=2
bits_per_raw_sample=N/A
bit_rate=128000
duration=265.102
bit_rate=128000
";
#[test]
fn parses_a_lossless_stream() {
let i = parse_probe(FLAC, 41_330_706).unwrap();
assert_eq!(i.codec.as_deref(), Some("flac"));
assert_eq!(i.sample_rate, Some(44100));
assert_eq!(i.bit_depth, Some(16));
assert_eq!(i.channels, Some(2));
assert_eq!(i.length_secs(), 311);
assert_eq!(i.file_size, 41_330_706);
assert_eq!(i.bit_rate, Some(1_064_321));
}
#[test]
fn a_lossy_stream_has_no_bit_depth() {
let i = parse_probe(MP3, 4_242_000).unwrap();
assert_eq!(i.bit_depth, None);
assert_eq!(i.bit_rate, Some(128_000));
assert_eq!(i.length_secs(), 265);
}
#[test]
fn na_and_blank_values_are_treated_as_absent() {
let i = parse_probe("duration=100\nsample_rate=N/A\nchannels=\n", 1).unwrap();
assert_eq!(i.sample_rate, None);
assert_eq!(i.channels, None);
}
#[test]
fn no_duration_means_not_usable_audio() {
assert!(parse_probe("codec_name=flac\nsample_rate=0\n", 1).is_none());
assert!(parse_probe("", 1).is_none());
}
#[test]
fn file_type_comes_from_the_codec_not_the_extension() {
let i = parse_probe(FLAC, 1).unwrap();
assert_eq!(
i.rekordbox_file_type(Path::new("/x/mislabelled.mp3")),
Some(5)
);
}
#[test]
fn pcm_falls_back_to_the_extension_because_the_container_decides() {
let pcm = parse_probe("codec_name=pcm_s16be\nduration=10\n", 1).unwrap();
assert_eq!(pcm.rekordbox_file_type(Path::new("/x/a.aiff")), Some(11));
assert_eq!(pcm.rekordbox_file_type(Path::new("/x/a.wav")), Some(4));
}
#[test]
fn an_unreadable_codec_and_extension_yield_no_file_type() {
let i = parse_probe("codec_name=vorbis\nduration=10\n", 1).unwrap();
assert_eq!(i.rekordbox_file_type(Path::new("/x/a.ogg")), None);
}
#[test]
fn probing_a_missing_file_errors_rather_than_panicking() {
assert!(probe(Path::new("/nonexistent/x.flac")).is_err());
}
}