pub mod anlz;
pub mod build;
pub mod collection;
mod error;
pub mod layout;
pub mod pdb;
pub mod settings;
pub use error::{Error, Result};
use anlz::hash::anlz_dir;
use anlz::locate::{read_optional, AnlzIndex, Entry};
use anlz::rewrite::{self, FileKind};
use baken_core::{CancelToken, Progress};
use build::DeviceTrack;
use collection::Library;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default)]
pub struct Options {
pub xml: PathBuf,
pub device: PathBuf,
pub playlists: Vec<String>,
pub anlz_roots: Vec<PathBuf>,
pub settings_dir: Option<PathBuf>,
pub device_name: Option<String>,
pub cdjsafe: bool,
pub prune: bool,
}
#[derive(Debug, Clone)]
pub struct Skipped {
pub name: String,
pub reason: String,
}
#[derive(Debug, Clone)]
pub struct PlanTrack {
pub device: DeviceTrack,
pub source: PathBuf,
pub anlz: Entry,
}
#[derive(Debug)]
pub struct Plan {
pub library: Library,
pub tracks: Vec<PlanTrack>,
pub selected: Vec<usize>,
pub skipped: Vec<Skipped>,
pub settings_dir: PathBuf,
pub anlz_roots: Vec<PathBuf>,
pub anlz_files_indexed: usize,
pub device: PathBuf,
pub device_name: String,
pub cdjsafe: bool,
pub prune: bool,
}
impl Plan {
pub fn playlist_names(&self) -> Vec<&str> {
self.selected
.iter()
.map(|&i| self.library.playlists[i].path.as_str())
.collect()
}
}
#[derive(Debug, Default)]
pub struct Report {
pub copied: usize,
pub kept: usize,
pub transcoded: usize,
pub anlz_files: usize,
pub pruned: usize,
pub cancelled: bool,
pub failures: Vec<(String, String)>,
pub tracks_in_database: usize,
}
pub fn plan(opts: &Options) -> Result<Plan> {
if !opts.device.is_dir() {
return Err(Error::DeviceNotFound(opts.device.clone()));
}
let settings_dir = settings::locate(opts.settings_dir.as_deref())
.map_err(|searched| Error::SettingsNotFound { searched })?;
let library = Library::load(&opts.xml)?;
let selected = select_playlists(&library, &opts.playlists)?;
let anlz_roots = if opts.anlz_roots.is_empty() {
anlz::locate::default_roots()
} else {
opts.anlz_roots.clone()
};
if anlz_roots.is_empty() {
return Err(Error::NoAnlzRoot {
searched: anlz_roots,
});
}
let index = AnlzIndex::build(&anlz_roots)?;
let mut seen = HashSet::new();
let mut skipped = Vec::new();
let mut tracks = Vec::new();
let mut layout = layout::Layout::default();
for &pi in &selected {
for &tid in &library.playlists[pi].track_ids {
if !seen.insert(tid) {
continue;
}
let Some(track) = library.track(tid) else {
skipped.push(Skipped {
name: format!("TrackID {tid}"),
reason: "not in the collection".into(),
});
continue;
};
let source = PathBuf::from(&track.location);
let Ok(meta) = std::fs::metadata(&source) else {
skipped.push(Skipped {
name: track.name.clone(),
reason: format!("source file missing: {}", source.display()),
});
continue;
};
let Some(entry) = index.find(track) else {
skipped.push(Skipped {
name: track.name.clone(),
reason: "no rekordbox analysis found (analyse it in rekordbox first)".into(),
});
continue;
};
let mut t = track.clone();
if opts.cdjsafe {
let stem = t
.file_name()
.rsplit_once('.')
.map(|(s, _)| s.to_string())
.unwrap_or_else(|| t.file_name().to_string());
t.location = format!(
"{}/{stem}.mp3",
t.location.rsplit_once('/').map(|(d, _)| d).unwrap_or("")
);
}
let usb_path = layout.assign(&t);
let (file_type, bitrate, sample_rate, sample_depth) = if opts.cdjsafe {
(pdb::rows::FILE_TYPE_MP3, 320, 44100, 16)
} else {
(
build::file_type_for(&track.kind, track.file_name()),
track.bit_rate,
track.sample_rate,
layout::sample_depth(&source),
)
};
tracks.push(PlanTrack {
device: DeviceTrack {
anlz_dir: anlz_dir(&usb_path),
usb_path,
track: track.clone(),
file_size: meta.len(),
sample_depth,
file_type,
bitrate,
sample_rate,
},
source,
anlz: entry.clone(),
});
}
}
if tracks.is_empty() {
return Err(Error::NothingToExport);
}
let device_name = opts
.device_name
.clone()
.or_else(|| {
opts.device
.file_name()
.map(|n| n.to_string_lossy().into_owned())
})
.unwrap_or_else(|| "USB".into());
Ok(Plan {
library,
tracks,
selected,
skipped,
settings_dir,
anlz_roots,
anlz_files_indexed: index.files,
device: opts.device.clone(),
device_name,
cdjsafe: opts.cdjsafe,
prune: opts.prune,
})
}
fn select_playlists(library: &Library, names: &[String]) -> Result<Vec<usize>> {
let mut out = Vec::new();
if names.is_empty() {
for (i, p) in library.playlists.iter().enumerate() {
if !p.is_folder && p.key_type == "0" {
out.push(i);
}
}
} else {
for name in names {
let name = name.trim().trim_matches('/');
let (i, p) = library
.playlists
.iter()
.enumerate()
.find(|(_, p)| p.path == name && !p.is_folder)
.ok_or_else(|| Error::PlaylistNotFound(name.to_string()))?;
if p.key_type != "0" {
return Err(Error::UnsupportedPlaylistType {
path: p.path.clone(),
key_type: p.key_type.clone(),
});
}
if !out.contains(&i) {
out.push(i);
}
}
}
if out.is_empty() {
return Err(Error::NoPlaylists);
}
Ok(out)
}
fn device_path(device: &Path, usb_path: &str) -> PathBuf {
device.join(usb_path.trim_start_matches('/'))
}
pub fn export(plan: &Plan, progress: &dyn Progress, cancel: &CancelToken) -> Result<Report> {
let mut report = Report::default();
let total = plan.tracks.len();
let mut exported: Vec<DeviceTrack> = Vec::with_capacity(total);
let mut wanted: HashSet<PathBuf> = HashSet::new();
for (i, pt) in plan.tracks.iter().enumerate() {
if cancel.is_cancelled() {
report.cancelled = true;
return Ok(report);
}
match export_track(plan, pt, &mut report) {
Ok(mut dt) => {
let dest = device_path(&plan.device, &dt.usb_path);
dt.file_size = std::fs::metadata(&dest)
.map(|m| m.len())
.unwrap_or(dt.file_size);
wanted.insert(dest);
for kind in FileKind::ALL {
wanted.insert(device_path(
&plan.device,
&format!("{}/ANLZ0000.{}", dt.anlz_dir, kind.extension()),
));
}
exported.push(dt);
}
Err(e) => report
.failures
.push((pt.device.track.name.clone(), e.to_string())),
}
progress.on_file_done(i + 1, total, &pt.source);
}
let date = build::today();
let model = build::build(
&plan.library,
&exported,
&plan.selected,
&plan.device_name,
&date,
);
report.tracks_in_database = exported.len();
let rb_dir = plan.device.join("PIONEER/rekordbox");
std::fs::create_dir_all(&rb_dir)?;
std::fs::write(rb_dir.join("export.pdb"), pdb::write(&model))?;
settings::copy_all(&plan.settings_dir, &plan.device)?;
if plan.prune {
report.pruned += prune_tree(&plan.device.join("Contents"), &wanted)?;
report.pruned += prune_tree(&plan.device.join("PIONEER/USBANLZ"), &wanted)?;
}
remove_apple_double(&plan.device.join("Contents"))?;
remove_apple_double(&plan.device.join("PIONEER"))?;
Ok(report)
}
fn export_track(plan: &Plan, pt: &PlanTrack, report: &mut Report) -> anyhow::Result<DeviceTrack> {
let dest = device_path(&plan.device, &pt.device.usb_path);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
let existing = std::fs::metadata(&dest).ok().map(|m| m.len());
if plan.cdjsafe {
if existing.is_some() {
report.kept += 1;
} else if baken_core::cdjsafe::probe(&pt.source)?.is_compatible_mp3() {
std::fs::copy(&pt.source, &dest)?;
report.copied += 1;
} else {
baken_core::cdjsafe::transcode(&pt.source, &dest)?;
report.transcoded += 1;
}
} else if existing == Some(pt.device.file_size) {
report.kept += 1;
} else {
std::fs::copy(&pt.source, &dest)?;
report.copied += 1;
}
let frames = if plan.cdjsafe {
Some(rewrite::mp3_audio_frames(&dest)?)
} else {
None
};
let anlz_dest = device_path(&plan.device, &pt.device.anlz_dir);
std::fs::create_dir_all(&anlz_dest)?;
let bpm = pt
.device
.track
.tempos
.first()
.map(|t| t.bpm)
.unwrap_or(pt.device.track.average_bpm);
for kind in FileKind::ALL {
let Some(mut file) = read_optional(&pt.anlz.sibling(kind.extension()))? else {
if kind != FileKind::TwoEx {
anyhow::bail!(
"analysis file .{} missing next to {}",
kind.extension(),
pt.anlz.dat.display()
);
}
continue;
};
rewrite::prepare(
&mut file,
kind,
&pt.device.usb_path,
&pt.device.track.cues,
bpm,
);
if let Some(frames) = frames {
match kind {
FileKind::Dat => rewrite::set_cbr_pvbr(&mut file, frames),
FileKind::Ext => rewrite::strip_pvb2(&mut file),
FileKind::TwoEx => {}
}
}
std::fs::write(
anlz_dest.join(format!("ANLZ0000.{}", kind.extension())),
file.to_bytes(),
)?;
report.anlz_files += 1;
}
Ok(pt.device.clone())
}
fn prune_tree(root: &Path, keep: &HashSet<PathBuf>) -> Result<usize> {
fn walk(dir: &Path, keep: &HashSet<PathBuf>, removed: &mut usize) -> std::io::Result<bool> {
let mut empty = true;
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_dir() {
if walk(&path, keep, removed)? {
std::fs::remove_dir(&path)?;
} else {
empty = false;
}
} else if keep.contains(&path) {
empty = false;
} else {
std::fs::remove_file(&path)?;
*removed += 1;
}
}
Ok(empty)
}
let mut removed = 0;
if root.is_dir() {
walk(root, keep, &mut removed)?;
}
Ok(removed)
}
fn remove_apple_double(root: &Path) -> Result<()> {
fn walk(dir: &Path) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_dir() {
walk(&path)?;
} else if entry.file_name().to_string_lossy().starts_with("._") {
std::fs::remove_file(&path)?;
}
}
Ok(())
}
if root.is_dir() {
walk(root)?;
}
Ok(())
}