Skip to main content

baken_export/
lib.rs

1//! Device export for Bake'n Deck (`baken expressport`).
2//!
3//! Writes a rekordbox-compatible USB export from `collection.xml` and the
4//! analysis files rekordbox keeps locally, without touching rekordbox's
5//! database. Two phases like `cdjsafe`: [`plan`] resolves everything without
6//! writing, [`export`] writes.
7
8pub mod anlz;
9pub mod build;
10pub mod collection;
11mod error;
12pub mod layout;
13pub mod pdb;
14pub mod settings;
15
16pub use error::{Error, Result};
17
18use anlz::generate;
19use anlz::generate::Measured;
20use anlz::hash::AnlzSlots;
21use anlz::locate::{read_optional, AnlzIndex, Entry};
22use anlz::rewrite::{self, FileKind, Mp3Audio};
23use baken_core::{fsname, CancelToken, Progress};
24use build::DeviceTrack;
25use collection::Library;
26use std::collections::HashSet;
27use std::path::{Path, PathBuf};
28
29#[derive(Debug, Clone, Default)]
30pub struct Options {
31    pub xml: PathBuf,
32    pub device: PathBuf,
33    /// `Folder/Name` paths; empty means every TrackID playlist.
34    pub playlists: Vec<String>,
35    /// Empty means rekordbox's default locations on this machine.
36    pub anlz_roots: Vec<PathBuf>,
37    pub settings_dir: Option<PathBuf>,
38    /// Write no My Settings, so the player keeps its own.
39    pub no_settings: bool,
40    /// Defaults to the device directory name.
41    pub device_name: Option<String>,
42    /// Transcode every track to 320 kbps CBR MP3 and reuse the source analysis.
43    pub cdjsafe: bool,
44    /// Compute the analysis files from the audio for tracks rekordbox never
45    /// analysed, instead of leaving them out (issue #147).
46    pub generate_analysis: bool,
47    /// Delete audio and analysis on the stick that this export does not reference.
48    pub prune: bool,
49}
50
51#[derive(Debug, Clone)]
52pub struct Skipped {
53    pub name: String,
54    pub reason: String,
55}
56
57#[derive(Debug, Clone)]
58pub struct PlanTrack {
59    pub device: DeviceTrack,
60    pub source: PathBuf,
61    /// rekordbox's own analysis to copy; `None` means generate it from the audio.
62    pub anlz: Option<Entry>,
63}
64
65#[derive(Debug)]
66pub struct Plan {
67    pub library: Library,
68    pub tracks: Vec<PlanTrack>,
69    /// Indices into `library.playlists`.
70    pub selected: Vec<usize>,
71    pub skipped: Vec<Skipped>,
72    /// `None` with `no_settings`.
73    pub settings_dir: Option<PathBuf>,
74    /// Settings files to copy, already validated so a bad one fails before anything is written.
75    pub settings_files: Vec<&'static str>,
76    pub anlz_roots: Vec<PathBuf>,
77    pub anlz_files_indexed: usize,
78    pub device: PathBuf,
79    /// `false` when the device is a directory on the disk of its parent, such
80    /// as an empty mount point with no stick mounted on it.
81    pub volume_root: bool,
82    pub device_name: String,
83    pub cdjsafe: bool,
84    pub prune: bool,
85}
86
87impl Plan {
88    /// Tracks whose analysis files will be generated rather than copied.
89    pub fn generated(&self) -> usize {
90        self.tracks.iter().filter(|t| t.anlz.is_none()).count()
91    }
92
93    /// Generated tracks whose XML carries no beat grid (`TEMPO`), so they get
94    /// none on the stick either.
95    pub fn without_grid(&self) -> usize {
96        self.tracks
97            .iter()
98            .filter(|t| t.anlz.is_none() && t.device.track.tempos.is_empty())
99            .count()
100    }
101
102    pub fn playlist_names(&self) -> Vec<&str> {
103        self.selected
104            .iter()
105            .map(|&i| self.library.playlists[i].path.as_str())
106            .collect()
107    }
108}
109
110#[derive(Debug, Default)]
111pub struct Report {
112    pub copied: usize,
113    pub kept: usize,
114    pub transcoded: usize,
115    pub anlz_files: usize,
116    /// Analysis files already on the stick byte for byte, so not written again.
117    pub anlz_unchanged: usize,
118    /// Tracks whose analysis files were generated from the audio.
119    pub anlz_generated: usize,
120    pub pruned: usize,
121    pub cancelled: bool,
122    pub failures: Vec<(String, String)>,
123    pub tracks_in_database: usize,
124}
125
126pub fn plan(opts: &Options) -> Result<Plan> {
127    if !opts.device.is_dir() {
128        return Err(Error::DeviceNotFound(opts.device.clone()));
129    }
130    let (settings_dir, settings_files) = if opts.no_settings {
131        (None, Vec::new())
132    } else {
133        let dir = settings::locate(opts.settings_dir.as_deref())
134            .map_err(|searched| Error::SettingsNotFound { searched })?;
135        let files = settings::files(&dir)?;
136        (Some(dir), files)
137    };
138
139    let library = Library::load(&opts.xml)?;
140    let selected = select_playlists(&library, &opts.playlists)?;
141
142    let anlz_roots = if opts.anlz_roots.is_empty() {
143        anlz::locate::default_roots()
144    } else {
145        opts.anlz_roots.clone()
146    };
147    if anlz_roots.is_empty() && !opts.generate_analysis {
148        return Err(Error::NoAnlzRoot {
149            searched: anlz_roots,
150        });
151    }
152    let index = AnlzIndex::build(&anlz_roots)?;
153
154    let mut seen = HashSet::new();
155    let mut skipped = Vec::new();
156    let mut tracks = Vec::new();
157    let mut layout = layout::Layout::default();
158    let mut anlz_slots = AnlzSlots::default();
159    for &pi in &selected {
160        for &tid in &library.playlists[pi].track_ids {
161            if !seen.insert(tid) {
162                continue;
163            }
164            let Some(track) = library.track(tid) else {
165                skipped.push(Skipped {
166                    name: format!("TrackID {tid}"),
167                    reason: "not in the collection".into(),
168                });
169                continue;
170            };
171            let source = PathBuf::from(&track.location);
172            let Ok(meta) = std::fs::metadata(&source) else {
173                skipped.push(Skipped {
174                    name: track.name.clone(),
175                    reason: format!("source file missing: {}", source.display()),
176                });
177                continue;
178            };
179            let entry = index.find(track);
180            if entry.is_none() && !opts.generate_analysis {
181                skipped.push(Skipped {
182                    name: track.name.clone(),
183                    reason: "no rekordbox analysis found (analyse it in rekordbox first, or pass --generate-analysis)".into(),
184                });
185                continue;
186            }
187            let mut t = track.clone();
188            if opts.cdjsafe {
189                let stem = t
190                    .file_name()
191                    .rsplit_once('.')
192                    .map(|(s, _)| s.to_string())
193                    .unwrap_or_else(|| t.file_name().to_string());
194                t.location = format!(
195                    "{}/{stem}.mp3",
196                    t.location.rsplit_once('/').map(|(d, _)| d).unwrap_or("")
197                );
198            }
199            let usb_path = layout.assign(&t);
200            let (file_type, bitrate, sample_rate, sample_depth) = if opts.cdjsafe {
201                (pdb::rows::FILE_TYPE_MP3, 320, 44100, 16)
202            } else {
203                (
204                    build::file_type_for(&track.kind, track.file_name()),
205                    track.bit_rate,
206                    track.sample_rate,
207                    layout::sample_depth(&source),
208                )
209            };
210            let (anlz_dir, anlz_index) = anlz_slots.assign(&usb_path);
211            tracks.push(PlanTrack {
212                device: DeviceTrack {
213                    anlz_dir,
214                    anlz_index,
215                    usb_path,
216                    track: track.clone(),
217                    file_size: meta.len(),
218                    sample_depth,
219                    file_type,
220                    bitrate,
221                    sample_rate,
222                },
223                source,
224                anlz: entry.cloned(),
225            });
226        }
227    }
228    if tracks.is_empty() {
229        return Err(Error::NothingToExport);
230    }
231    let device_name = opts
232        .device_name
233        .clone()
234        .or_else(|| {
235            opts.device
236                .file_name()
237                .map(|n| n.to_string_lossy().into_owned())
238        })
239        .unwrap_or_else(|| "USB".into());
240    Ok(Plan {
241        library,
242        tracks,
243        selected,
244        skipped,
245        settings_dir,
246        settings_files,
247        anlz_roots,
248        anlz_files_indexed: index.files,
249        device: opts.device.clone(),
250        volume_root: is_volume_root(&opts.device),
251        device_name,
252        cdjsafe: opts.cdjsafe,
253        prune: opts.prune,
254    })
255}
256
257fn select_playlists(library: &Library, names: &[String]) -> Result<Vec<usize>> {
258    let mut out = Vec::new();
259    if names.is_empty() {
260        for (i, p) in library.playlists.iter().enumerate() {
261            if !p.is_folder && p.key_type == "0" {
262                out.push(i);
263            }
264        }
265    } else {
266        for name in names {
267            let name = name.trim().trim_matches('/');
268            let (i, p) = library
269                .playlists
270                .iter()
271                .enumerate()
272                .find(|(_, p)| p.path == name && !p.is_folder)
273                .ok_or_else(|| Error::PlaylistNotFound(name.to_string()))?;
274            if p.key_type != "0" {
275                return Err(Error::UnsupportedPlaylistType {
276                    path: p.path.clone(),
277                    key_type: p.key_type.clone(),
278                });
279            }
280            if !out.contains(&i) {
281                out.push(i);
282            }
283        }
284    }
285    if out.is_empty() {
286        return Err(Error::NoPlaylists);
287    }
288    Ok(out)
289}
290
291#[cfg(unix)]
292fn is_volume_root(dir: &Path) -> bool {
293    use std::os::unix::fs::MetadataExt;
294    let Ok(dir) = std::fs::canonicalize(dir) else {
295        return true;
296    };
297    let Some(parent) = dir.parent() else {
298        return true;
299    };
300    match (std::fs::metadata(&dir), std::fs::metadata(parent)) {
301        (Ok(d), Ok(p)) => d.dev() != p.dev(),
302        _ => true,
303    }
304}
305
306/// Not checked on Windows, where a stick is a drive letter rather than a mount point.
307#[cfg(not(unix))]
308fn is_volume_root(_: &Path) -> bool {
309    true
310}
311
312fn device_path(device: &Path, usb_path: &str) -> PathBuf {
313    device.join(usb_path.trim_start_matches('/'))
314}
315
316pub fn export(plan: &Plan, progress: &dyn Progress, cancel: &CancelToken) -> Result<Report> {
317    let mut report = Report::default();
318    let total = plan.tracks.len();
319    let mut exported: Vec<DeviceTrack> = Vec::with_capacity(total);
320    let mut wanted: HashSet<PathBuf> = HashSet::new();
321
322    // Before the first track, so a stick that is not mounted, read-only or gone
323    // stops the run with a reason instead of failing every track (issue #165).
324    let rb_dir = plan.device.join("PIONEER/rekordbox");
325    let probe = rb_dir.join(".baken-write-test");
326    std::fs::create_dir_all(&rb_dir)
327        .and_then(|()| std::fs::write(&probe, b""))
328        .and_then(|()| std::fs::remove_file(&probe))
329        .map_err(|err| Error::DeviceWrite {
330            path: rb_dir.clone(),
331            err,
332        })?;
333
334    for (i, pt) in plan.tracks.iter().enumerate() {
335        if cancel.is_cancelled() {
336            report.cancelled = true;
337            return Ok(report);
338        }
339        match export_track(plan, pt, &mut report) {
340            Ok(mut dt) => {
341                let dest = device_path(&plan.device, &dt.usb_path);
342                dt.file_size = std::fs::metadata(&dest)
343                    .map(|m| m.len())
344                    .unwrap_or(dt.file_size);
345                wanted.insert(dest);
346                for kind in FileKind::ALL {
347                    wanted.insert(device_path(&plan.device, &dt.anlz_path(kind.extension())));
348                }
349                exported.push(dt);
350            }
351            Err(e) => report
352                .failures
353                .push((pt.device.track.name.clone(), e.to_string())),
354        }
355        progress.on_file_done(i + 1, total, &pt.source);
356    }
357
358    let date = build::today();
359    let model = build::build(
360        &plan.library,
361        &exported,
362        &plan.selected,
363        &plan.device_name,
364        &date,
365    );
366    report.tracks_in_database = exported.len();
367    let pdb_path = rb_dir.join("export.pdb");
368    std::fs::write(&pdb_path, pdb::write(&model)).map_err(|err| Error::DeviceWrite {
369        path: pdb_path,
370        err,
371    })?;
372
373    if let Some(dir) = &plan.settings_dir {
374        settings::copy_all(dir, &plan.settings_files, &plan.device)?;
375    }
376
377    if plan.prune {
378        report.pruned += prune_tree(&plan.device.join("Contents"), &wanted)?;
379        report.pruned += prune_tree(&plan.device.join("PIONEER/USBANLZ"), &wanted)?;
380    }
381    // Only files written in this run can have gained an AppleDouble file, so
382    // the two big trees are walked only when something was written into them.
383    if report.copied + report.transcoded > 0 || plan.prune {
384        remove_apple_double(&plan.device.join("Contents"), true)?;
385    }
386    if report.anlz_files > 0 || plan.prune {
387        remove_apple_double(&plan.device.join("PIONEER/USBANLZ"), true)?;
388    }
389    remove_apple_double(&plan.device.join("PIONEER"), false)?;
390    remove_apple_double(&rb_dir, false)?;
391    for dir in ["Contents", "PIONEER"] {
392        match std::fs::remove_file(plan.device.join(format!("._{dir}"))) {
393            Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
394            _ => {}
395        }
396    }
397    Ok(report)
398}
399
400fn export_track(plan: &Plan, pt: &PlanTrack, report: &mut Report) -> anyhow::Result<DeviceTrack> {
401    let dest = device_path(&plan.device, &pt.device.usb_path);
402    if let Some(parent) = dest.parent() {
403        std::fs::create_dir_all(parent)?;
404    }
405    let existing = std::fs::metadata(&dest).ok().map(|m| m.len());
406    if plan.cdjsafe {
407        if existing.is_some() {
408            report.kept += 1;
409        } else if baken_core::cdjsafe::probe(&pt.source)?.is_compatible_mp3() {
410            std::fs::copy(&pt.source, &dest)?;
411            report.copied += 1;
412        } else {
413            baken_core::cdjsafe::transcode(&pt.source, &dest)?;
414            report.transcoded += 1;
415        }
416    } else if existing == Some(pt.device.file_size) {
417        report.kept += 1;
418    } else {
419        std::fs::copy(&pt.source, &dest)?;
420        report.copied += 1;
421    }
422
423    let mp3 = if plan.cdjsafe {
424        Some(rewrite::mp3_audio(&dest)?)
425    } else {
426        None
427    };
428    let anlz_dest = device_path(&plan.device, &pt.device.anlz_dir);
429    std::fs::create_dir_all(&anlz_dest)?;
430    let bpm = pt
431        .device
432        .track
433        .tempos
434        .first()
435        .map(|t| t.bpm)
436        .unwrap_or(pt.device.track.average_bpm);
437    let Some(entry) = &pt.anlz else {
438        let mp3 = match mp3 {
439            Some(m) => Some(m),
440            None if is_mp3(&dest) => Some(rewrite::mp3_audio(&dest)?),
441            None => None,
442        };
443        let audio = generate::measure(&pt.source)?;
444        let files = generate::build_files(
445            &pt.device.track,
446            &pt.device.usb_path,
447            &audio,
448            mp3.map(|m| m.frames),
449        );
450        for (kind, file) in FileKind::ALL.iter().zip(files.iter()) {
451            write_anlz(
452                &device_path(&plan.device, &pt.device.anlz_path(kind.extension())),
453                &file.to_bytes(),
454                report,
455            )?;
456        }
457        report.anlz_generated += 1;
458        return Ok(with_measured(&pt.device, &audio, mp3));
459    };
460    for kind in FileKind::ALL {
461        let Some(mut file) = read_optional(&entry.sibling(kind.extension()))? else {
462            if kind != FileKind::TwoEx {
463                anyhow::bail!(
464                    "analysis file .{} missing next to {}",
465                    kind.extension(),
466                    entry.dat.display()
467                );
468            }
469            continue;
470        };
471        rewrite::prepare(
472            &mut file,
473            kind,
474            &pt.device.usb_path,
475            &pt.device.track.cues,
476            bpm,
477        );
478        if let Some(mp3) = mp3 {
479            match kind {
480                FileKind::Dat => rewrite::set_cbr_pvbr(&mut file, mp3.frames),
481                FileKind::Ext => rewrite::strip_pvb2(&mut file),
482                FileKind::TwoEx => {}
483            }
484        }
485        write_anlz(
486            &device_path(&plan.device, &pt.device.anlz_path(kind.extension())),
487            &file.to_bytes(),
488            report,
489        )?;
490    }
491    Ok(pt.device.clone())
492}
493
494/// Write an analysis file unless the stick already holds exactly these bytes.
495/// A re-run after a playlist change then writes only what changed, and on a
496/// USB stick writing is what takes the time.
497fn write_anlz(path: &Path, bytes: &[u8], report: &mut Report) -> std::io::Result<()> {
498    match std::fs::read(path) {
499        Ok(old) if old == bytes => report.anlz_unchanged += 1,
500        _ => {
501            std::fs::write(path, bytes)?;
502            report.anlz_files += 1;
503        }
504    }
505    Ok(())
506}
507
508/// Fill in what the XML left at 0 from the audio a generated-analysis track
509/// was just decoded from (#167); a value rekordbox wrote always stays. The
510/// rules follow what rekordbox writes: MP3 the audio-frame rate, lossless the
511/// PCM rate (`1411`, `2116`, `1536`), length truncated to whole seconds.
512fn with_measured(dt: &DeviceTrack, audio: &Measured, mp3: Option<Mp3Audio>) -> DeviceTrack {
513    use pdb::rows::{FILE_TYPE_AIFF, FILE_TYPE_ALAC, FILE_TYPE_FLAC, FILE_TYPE_WAV};
514    let mut dt = dt.clone();
515    let secs = audio.duration_ms() / 1000.0;
516    if audio.sample_rate == 0 || secs <= 0.0 {
517        return dt;
518    }
519    if dt.sample_rate == 0 {
520        dt.sample_rate = audio.sample_rate;
521    }
522    if dt.bitrate == 0 {
523        let lossless = [
524            FILE_TYPE_FLAC,
525            FILE_TYPE_WAV,
526            FILE_TYPE_AIFF,
527            FILE_TYPE_ALAC,
528        ]
529        .contains(&dt.file_type);
530        dt.bitrate = match mp3.and_then(|m| m.kbps()) {
531            Some(kbps) => kbps,
532            None if lossless => {
533                (audio.sample_rate as u64 * dt.sample_depth as u64 * audio.channels as u64 / 1000)
534                    as u32
535            }
536            None => (dt.file_size as f64 * 8.0 / secs / 1000.0).round() as u32,
537        };
538    }
539    if dt.track.total_time == 0 {
540        dt.track.total_time = secs as u32;
541    }
542    dt
543}
544
545fn is_mp3(path: &Path) -> bool {
546    path.extension()
547        .and_then(|e| e.to_str())
548        .is_some_and(|e| e.eq_ignore_ascii_case("mp3"))
549}
550
551/// Delete files under `root` not in `keep`, then empty directories. Returns the file count.
552/// Remove what `keep` does not name. Paths are compared in NFC: on macOS 26
553/// `read_dir` lists an ExFAT or FAT stick's names in NFD whatever form they
554/// were written in, and the stick is written in the XML's NFC (issue #154).
555fn prune_tree(root: &Path, keep: &HashSet<PathBuf>) -> Result<usize> {
556    fn walk(dir: &Path, keep: &HashSet<PathBuf>, removed: &mut usize) -> std::io::Result<bool> {
557        let mut empty = true;
558        for entry in std::fs::read_dir(dir)? {
559            let entry = entry?;
560            let path = entry.path();
561            if entry.file_type()?.is_dir() {
562                if walk(&path, keep, removed)? {
563                    fsname::remove_dir(&path)?;
564                } else {
565                    empty = false;
566                }
567            } else if keep.contains(&fsname::nfc(&path)) {
568                empty = false;
569            } else {
570                fsname::remove_file(&path)?;
571                *removed += 1;
572            }
573        }
574        Ok(empty)
575    }
576    let keep: HashSet<PathBuf> = keep.iter().map(|p| fsname::nfc(p)).collect();
577    let mut removed = 0;
578    if root.is_dir() {
579        walk(root, &keep, &mut removed)?;
580    }
581    Ok(removed)
582}
583
584/// macOS leaves `._*` AppleDouble files on FAT volumes; Linux-based players trip on them.
585fn remove_apple_double(root: &Path, recursive: bool) -> Result<()> {
586    fn walk(dir: &Path, recursive: bool) -> std::io::Result<()> {
587        for entry in std::fs::read_dir(dir)? {
588            let entry = entry?;
589            let path = entry.path();
590            if entry.file_type()?.is_dir() {
591                if recursive {
592                    walk(&path, recursive)?;
593                }
594            } else if entry.file_name().to_string_lossy().starts_with("._") {
595                fsname::remove_file(&path)?;
596            }
597        }
598        Ok(())
599    }
600    if root.is_dir() {
601        walk(root, recursive)?;
602    }
603    Ok(())
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609    use pdb::rows::{FILE_TYPE_FLAC, FILE_TYPE_M4A, FILE_TYPE_MP3, FILE_TYPE_WAV};
610
611    fn track(file_type: u16, sample_depth: u16) -> DeviceTrack {
612        DeviceTrack {
613            track: collection::Track::default(),
614            usb_path: String::new(),
615            anlz_dir: String::new(),
616            anlz_index: 0,
617            file_size: 8_000_000,
618            sample_depth,
619            file_type,
620            bitrate: 0,
621            sample_rate: 0,
622        }
623    }
624
625    /// 200.5 seconds of stereo at 44.1 kHz.
626    fn audio() -> Measured {
627        Measured {
628            sample_rate: 44100,
629            channels: 2,
630            frames: 44100 * 401 / 2,
631            ..Default::default()
632        }
633    }
634
635    #[test]
636    fn measured_values_fill_only_what_the_xml_left_at_zero() {
637        let wav = with_measured(&track(FILE_TYPE_WAV, 24), &audio(), None);
638        assert_eq!(
639            (wav.sample_rate, wav.bitrate, wav.track.total_time),
640            (44100, 2116, 200)
641        );
642        let flac = with_measured(&track(FILE_TYPE_FLAC, 16), &audio(), None);
643        assert_eq!(flac.bitrate, 1411);
644
645        let mp3 = Mp3Audio {
646            frames: 7656,
647            bytes: 7656 * 1045,
648            sample_rate: 44100,
649        };
650        assert_eq!(
651            with_measured(&track(FILE_TYPE_MP3, 16), &audio(), Some(mp3)).bitrate,
652            320
653        );
654        // 8 MB over 200.5 s
655        assert_eq!(
656            with_measured(&track(FILE_TYPE_M4A, 16), &audio(), None).bitrate,
657            319
658        );
659
660        let mut from_xml = track(FILE_TYPE_MP3, 16);
661        from_xml.sample_rate = 48000;
662        from_xml.bitrate = 256;
663        from_xml.track.total_time = 199;
664        let kept = with_measured(&from_xml, &audio(), Some(mp3));
665        assert_eq!(
666            (kept.sample_rate, kept.bitrate, kept.track.total_time),
667            (48000, 256, 199)
668        );
669
670        let silent = with_measured(&track(FILE_TYPE_FLAC, 16), &Measured::default(), None);
671        assert_eq!((silent.sample_rate, silent.bitrate), (0, 0));
672    }
673}