use super::section::AnlzFile;
use crate::collection::Track;
use anyhow::Result;
use rayon::prelude::*;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct Entry {
pub dat: PathBuf,
pub beats: u32,
pub first_beat_ms: u32,
pub first_tempo_x100: u16,
}
impl Entry {
pub fn sibling(&self, ext: &str) -> PathBuf {
self.dat.with_extension(ext)
}
}
#[derive(Debug, Default)]
pub struct AnlzIndex {
by_name: HashMap<String, Vec<Entry>>,
pub files: usize,
}
pub fn default_roots() -> Vec<PathBuf> {
let mut roots = Vec::new();
#[cfg(target_os = "macos")]
if let Some(home) = std::env::var_os("HOME") {
roots.push(PathBuf::from(home).join("Library/Pioneer/rekordbox/share/PIONEER/USBANLZ"));
}
#[cfg(target_os = "windows")]
if let Some(appdata) = std::env::var_os("APPDATA") {
roots.push(PathBuf::from(appdata).join(r"Pioneer\rekordbox\share\PIONEER\USBANLZ"));
}
for mounts in mount_points() {
let Ok(entries) = std::fs::read_dir(mounts) else {
continue;
};
for v in entries.flatten() {
roots.push(v.path().join("PIONEER/Master/share/PIONEER/USBANLZ"));
}
}
roots.into_iter().filter(|p| p.is_dir()).collect()
}
fn mount_points() -> Vec<PathBuf> {
#[cfg(target_os = "macos")]
return vec![PathBuf::from("/Volumes")];
#[cfg(target_os = "linux")]
{
let mut dirs = vec![PathBuf::from("/media"), PathBuf::from("/mnt")];
if let Some(user) = std::env::var_os("USER") {
dirs.push(PathBuf::from("/media").join(&user));
dirs.push(PathBuf::from("/run/media").join(&user));
}
return dirs;
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
return Vec::new();
}
fn pqtz_summary(file: &AnlzFile) -> (u32, u32, u16) {
let Some(s) = file.find(b"PQTZ") else {
return (0, 0, 0);
};
let b = &s.bytes;
if b.len() < 0x20 {
return (0, 0, 0);
}
let beats = u32::from_be_bytes(b[0x14..0x18].try_into().unwrap());
if beats == 0 || b.len() < 0x20 {
return (0, 0, 0);
}
let tempo = u16::from_be_bytes(b[0x1a..0x1c].try_into().unwrap());
let time = u32::from_be_bytes(b[0x1c..0x20].try_into().unwrap());
(beats, time, tempo)
}
impl AnlzIndex {
pub fn build(roots: &[PathBuf]) -> Result<Self> {
let mut dats = Vec::new();
for root in roots {
for level1 in std::fs::read_dir(root)?.flatten() {
let Ok(level2) = std::fs::read_dir(level1.path()) else {
continue;
};
dats.extend(level2.flatten().map(|d| d.path().join("ANLZ0000.DAT")));
}
}
let found: Vec<(String, Entry)> = dats
.into_par_iter()
.filter_map(|dat| {
let file = AnlzFile::parse(&std::fs::read(&dat).ok()?).ok()?;
let path = file.path()?;
let name = path.rsplit(['/', '\\']).next().unwrap_or(&path).to_string();
let (beats, first_beat_ms, first_tempo_x100) = pqtz_summary(&file);
Some((
name,
Entry {
dat,
beats,
first_beat_ms,
first_tempo_x100,
},
))
})
.collect();
let mut idx = AnlzIndex::default();
for (name, entry) in found {
idx.files += 1;
idx.by_name.entry(name).or_default().push(entry);
}
Ok(idx)
}
pub fn find(&self, track: &Track) -> Option<&Entry> {
let cands = self.by_name.get(track.file_name())?;
let Some(t) = track.tempos.first() else {
return cands.iter().find(|e| e.beats == 0);
};
let grid_match = |e: &&Entry| {
((t.inizio * 1000.0).round() as i64 - e.first_beat_ms as i64).abs() <= 2
&& (t.bpm * 100.0).round() as u16 == e.first_tempo_x100
};
let duration_match = |e: &&Entry| {
e.first_tempo_x100 > 0
&& ((e.beats as f64 * 60.0 * 100.0 / e.first_tempo_x100 as f64)
- track.total_time as f64)
.abs()
< 3.0
};
cands
.iter()
.find(|e| grid_match(e) && duration_match(e))
.or_else(|| cands.iter().find(grid_match))
}
pub fn has_name(&self, track: &Track) -> bool {
self.by_name.contains_key(track.file_name())
}
}
pub fn read_optional(path: &Path) -> Result<Option<AnlzFile>> {
match std::fs::read(path) {
Ok(d) => Ok(Some(AnlzFile::parse(&d)?)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::collection::Tempo;
fn entry(dir: &str, first_beat_ms: u32, beats: u32) -> Entry {
Entry {
dat: PathBuf::from(dir).join("ANLZ0000.DAT"),
beats,
first_beat_ms,
first_tempo_x100: if beats == 0 { 0 } else { 12600 },
}
}
fn index(entries: Vec<Entry>) -> AnlzIndex {
let mut idx = AnlzIndex::default();
idx.by_name.insert("x.aif".into(), entries);
idx
}
fn track(first_beat_s: Option<f64>, total_time: u32) -> Track {
Track {
location: "/Music/x.aif".into(),
total_time,
tempos: first_beat_s
.map(|inizio| Tempo {
inizio,
bpm: 126.0,
metro: "4/4".into(),
battito: 1,
})
.into_iter()
.collect(),
..Default::default()
}
}
fn picked<'a>(idx: &'a AnlzIndex, t: &Track) -> Option<&'a str> {
idx.find(t).and_then(|e| e.dat.parent()?.to_str())
}
#[test]
fn a_later_first_beat_is_not_a_match() {
let idx = index(vec![entry("late", 475, 999), entry("early", 0, 1000)]);
assert_eq!(picked(&idx, &track(Some(0.0), 476)), Some("early"));
assert_eq!(picked(&idx, &track(Some(0.475), 475)), Some("late"));
assert_eq!(picked(&idx, &track(Some(0.002), 476)), Some("early"));
}
#[test]
fn a_candidate_that_disagrees_with_the_xml_is_never_taken() {
let one = index(vec![entry("stale", 475, 999)]);
assert_eq!(picked(&one, &track(Some(0.0), 476)), None);
let two = index(vec![entry("a", 475, 999), entry("b", 900, 999)]);
assert_eq!(picked(&two, &track(Some(0.0), 476)), None);
assert!(two.has_name(&track(Some(0.0), 476)));
}
#[test]
fn duration_breaks_a_tie_between_matching_grids() {
let idx = index(vec![entry("edit", 0, 400), entry("full", 0, 1000)]);
assert_eq!(picked(&idx, &track(Some(0.0), 476)), Some("full"));
assert_eq!(picked(&idx, &track(Some(0.0), 190)), Some("edit"));
}
#[test]
fn a_track_without_tempo_only_takes_an_analysis_without_a_grid() {
let gridded = index(vec![entry("grid", 0, 1000)]);
assert_eq!(picked(&gridded, &track(None, 476)), None);
let bare = index(vec![entry("grid", 0, 1000), entry("bare", 0, 0)]);
assert_eq!(picked(&bare, &track(None, 476)), Some("bare"));
}
}