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::hash::anlz_dir;
20use anlz::locate::{read_optional, AnlzIndex, Entry};
21use anlz::rewrite::{self, FileKind};
22use baken_core::{CancelToken, Progress};
23use build::DeviceTrack;
24use collection::Library;
25use std::collections::HashSet;
26use std::path::{Path, PathBuf};
27
28#[derive(Debug, Clone, Default)]
29pub struct Options {
30    pub xml: PathBuf,
31    pub device: PathBuf,
32    /// `Folder/Name` paths; empty means every TrackID playlist.
33    pub playlists: Vec<String>,
34    /// Empty means rekordbox's default locations on this machine.
35    pub anlz_roots: Vec<PathBuf>,
36    pub settings_dir: Option<PathBuf>,
37    /// Defaults to the device directory name.
38    pub device_name: Option<String>,
39    /// Transcode every track to 320 kbps CBR MP3 and reuse the source analysis.
40    pub cdjsafe: bool,
41    /// Compute the analysis files from the audio for tracks rekordbox never
42    /// analysed, instead of leaving them out (issue #147).
43    pub generate_analysis: bool,
44    /// Delete audio and analysis on the stick that this export does not reference.
45    pub prune: bool,
46}
47
48#[derive(Debug, Clone)]
49pub struct Skipped {
50    pub name: String,
51    pub reason: String,
52}
53
54#[derive(Debug, Clone)]
55pub struct PlanTrack {
56    pub device: DeviceTrack,
57    pub source: PathBuf,
58    /// rekordbox's own analysis to copy; `None` means generate it from the audio.
59    pub anlz: Option<Entry>,
60}
61
62#[derive(Debug)]
63pub struct Plan {
64    pub library: Library,
65    pub tracks: Vec<PlanTrack>,
66    /// Indices into `library.playlists`.
67    pub selected: Vec<usize>,
68    pub skipped: Vec<Skipped>,
69    pub settings_dir: PathBuf,
70    pub anlz_roots: Vec<PathBuf>,
71    pub anlz_files_indexed: usize,
72    pub device: PathBuf,
73    pub device_name: String,
74    pub cdjsafe: bool,
75    pub prune: bool,
76}
77
78impl Plan {
79    /// Tracks whose analysis files will be generated rather than copied.
80    pub fn generated(&self) -> usize {
81        self.tracks.iter().filter(|t| t.anlz.is_none()).count()
82    }
83
84    pub fn playlist_names(&self) -> Vec<&str> {
85        self.selected
86            .iter()
87            .map(|&i| self.library.playlists[i].path.as_str())
88            .collect()
89    }
90}
91
92#[derive(Debug, Default)]
93pub struct Report {
94    pub copied: usize,
95    pub kept: usize,
96    pub transcoded: usize,
97    pub anlz_files: usize,
98    /// Tracks whose analysis files were generated from the audio.
99    pub anlz_generated: usize,
100    pub pruned: usize,
101    pub cancelled: bool,
102    pub failures: Vec<(String, String)>,
103    pub tracks_in_database: usize,
104}
105
106pub fn plan(opts: &Options) -> Result<Plan> {
107    if !opts.device.is_dir() {
108        return Err(Error::DeviceNotFound(opts.device.clone()));
109    }
110    let settings_dir = settings::locate(opts.settings_dir.as_deref())
111        .map_err(|searched| Error::SettingsNotFound { searched })?;
112
113    let library = Library::load(&opts.xml)?;
114    let selected = select_playlists(&library, &opts.playlists)?;
115
116    let anlz_roots = if opts.anlz_roots.is_empty() {
117        anlz::locate::default_roots()
118    } else {
119        opts.anlz_roots.clone()
120    };
121    if anlz_roots.is_empty() && !opts.generate_analysis {
122        return Err(Error::NoAnlzRoot {
123            searched: anlz_roots,
124        });
125    }
126    let index = AnlzIndex::build(&anlz_roots)?;
127
128    let mut seen = HashSet::new();
129    let mut skipped = Vec::new();
130    let mut tracks = Vec::new();
131    let mut layout = layout::Layout::default();
132    for &pi in &selected {
133        for &tid in &library.playlists[pi].track_ids {
134            if !seen.insert(tid) {
135                continue;
136            }
137            let Some(track) = library.track(tid) else {
138                skipped.push(Skipped {
139                    name: format!("TrackID {tid}"),
140                    reason: "not in the collection".into(),
141                });
142                continue;
143            };
144            let source = PathBuf::from(&track.location);
145            let Ok(meta) = std::fs::metadata(&source) else {
146                skipped.push(Skipped {
147                    name: track.name.clone(),
148                    reason: format!("source file missing: {}", source.display()),
149                });
150                continue;
151            };
152            let entry = index.find(track);
153            if entry.is_none() && !opts.generate_analysis {
154                skipped.push(Skipped {
155                    name: track.name.clone(),
156                    reason: "no rekordbox analysis found (analyse it in rekordbox first, or pass --generate-analysis)".into(),
157                });
158                continue;
159            }
160            let mut t = track.clone();
161            if opts.cdjsafe {
162                let stem = t
163                    .file_name()
164                    .rsplit_once('.')
165                    .map(|(s, _)| s.to_string())
166                    .unwrap_or_else(|| t.file_name().to_string());
167                t.location = format!(
168                    "{}/{stem}.mp3",
169                    t.location.rsplit_once('/').map(|(d, _)| d).unwrap_or("")
170                );
171            }
172            let usb_path = layout.assign(&t);
173            let (file_type, bitrate, sample_rate, sample_depth) = if opts.cdjsafe {
174                (pdb::rows::FILE_TYPE_MP3, 320, 44100, 16)
175            } else {
176                (
177                    build::file_type_for(&track.kind, track.file_name()),
178                    track.bit_rate,
179                    track.sample_rate,
180                    layout::sample_depth(&source),
181                )
182            };
183            tracks.push(PlanTrack {
184                device: DeviceTrack {
185                    anlz_dir: anlz_dir(&usb_path),
186                    usb_path,
187                    track: track.clone(),
188                    file_size: meta.len(),
189                    sample_depth,
190                    file_type,
191                    bitrate,
192                    sample_rate,
193                },
194                source,
195                anlz: entry.cloned(),
196            });
197        }
198    }
199    if tracks.is_empty() {
200        return Err(Error::NothingToExport);
201    }
202    let device_name = opts
203        .device_name
204        .clone()
205        .or_else(|| {
206            opts.device
207                .file_name()
208                .map(|n| n.to_string_lossy().into_owned())
209        })
210        .unwrap_or_else(|| "USB".into());
211    Ok(Plan {
212        library,
213        tracks,
214        selected,
215        skipped,
216        settings_dir,
217        anlz_roots,
218        anlz_files_indexed: index.files,
219        device: opts.device.clone(),
220        device_name,
221        cdjsafe: opts.cdjsafe,
222        prune: opts.prune,
223    })
224}
225
226fn select_playlists(library: &Library, names: &[String]) -> Result<Vec<usize>> {
227    let mut out = Vec::new();
228    if names.is_empty() {
229        for (i, p) in library.playlists.iter().enumerate() {
230            if !p.is_folder && p.key_type == "0" {
231                out.push(i);
232            }
233        }
234    } else {
235        for name in names {
236            let name = name.trim().trim_matches('/');
237            let (i, p) = library
238                .playlists
239                .iter()
240                .enumerate()
241                .find(|(_, p)| p.path == name && !p.is_folder)
242                .ok_or_else(|| Error::PlaylistNotFound(name.to_string()))?;
243            if p.key_type != "0" {
244                return Err(Error::UnsupportedPlaylistType {
245                    path: p.path.clone(),
246                    key_type: p.key_type.clone(),
247                });
248            }
249            if !out.contains(&i) {
250                out.push(i);
251            }
252        }
253    }
254    if out.is_empty() {
255        return Err(Error::NoPlaylists);
256    }
257    Ok(out)
258}
259
260fn device_path(device: &Path, usb_path: &str) -> PathBuf {
261    device.join(usb_path.trim_start_matches('/'))
262}
263
264pub fn export(plan: &Plan, progress: &dyn Progress, cancel: &CancelToken) -> Result<Report> {
265    let mut report = Report::default();
266    let total = plan.tracks.len();
267    let mut exported: Vec<DeviceTrack> = Vec::with_capacity(total);
268    let mut wanted: HashSet<PathBuf> = HashSet::new();
269
270    for (i, pt) in plan.tracks.iter().enumerate() {
271        if cancel.is_cancelled() {
272            report.cancelled = true;
273            return Ok(report);
274        }
275        match export_track(plan, pt, &mut report) {
276            Ok(mut dt) => {
277                let dest = device_path(&plan.device, &dt.usb_path);
278                dt.file_size = std::fs::metadata(&dest)
279                    .map(|m| m.len())
280                    .unwrap_or(dt.file_size);
281                wanted.insert(dest);
282                for kind in FileKind::ALL {
283                    wanted.insert(device_path(
284                        &plan.device,
285                        &format!("{}/ANLZ0000.{}", dt.anlz_dir, kind.extension()),
286                    ));
287                }
288                exported.push(dt);
289            }
290            Err(e) => report
291                .failures
292                .push((pt.device.track.name.clone(), e.to_string())),
293        }
294        progress.on_file_done(i + 1, total, &pt.source);
295    }
296
297    let date = build::today();
298    let model = build::build(
299        &plan.library,
300        &exported,
301        &plan.selected,
302        &plan.device_name,
303        &date,
304    );
305    report.tracks_in_database = exported.len();
306    let rb_dir = plan.device.join("PIONEER/rekordbox");
307    std::fs::create_dir_all(&rb_dir)?;
308    std::fs::write(rb_dir.join("export.pdb"), pdb::write(&model))?;
309
310    settings::copy_all(&plan.settings_dir, &plan.device)?;
311
312    if plan.prune {
313        report.pruned += prune_tree(&plan.device.join("Contents"), &wanted)?;
314        report.pruned += prune_tree(&plan.device.join("PIONEER/USBANLZ"), &wanted)?;
315    }
316    remove_apple_double(&plan.device.join("Contents"))?;
317    remove_apple_double(&plan.device.join("PIONEER"))?;
318    Ok(report)
319}
320
321fn export_track(plan: &Plan, pt: &PlanTrack, report: &mut Report) -> anyhow::Result<DeviceTrack> {
322    let dest = device_path(&plan.device, &pt.device.usb_path);
323    if let Some(parent) = dest.parent() {
324        std::fs::create_dir_all(parent)?;
325    }
326    let existing = std::fs::metadata(&dest).ok().map(|m| m.len());
327    if plan.cdjsafe {
328        if existing.is_some() {
329            report.kept += 1;
330        } else if baken_core::cdjsafe::probe(&pt.source)?.is_compatible_mp3() {
331            std::fs::copy(&pt.source, &dest)?;
332            report.copied += 1;
333        } else {
334            baken_core::cdjsafe::transcode(&pt.source, &dest)?;
335            report.transcoded += 1;
336        }
337    } else if existing == Some(pt.device.file_size) {
338        report.kept += 1;
339    } else {
340        std::fs::copy(&pt.source, &dest)?;
341        report.copied += 1;
342    }
343
344    let frames = if plan.cdjsafe {
345        Some(rewrite::mp3_audio_frames(&dest)?)
346    } else {
347        None
348    };
349    let anlz_dest = device_path(&plan.device, &pt.device.anlz_dir);
350    std::fs::create_dir_all(&anlz_dest)?;
351    let bpm = pt
352        .device
353        .track
354        .tempos
355        .first()
356        .map(|t| t.bpm)
357        .unwrap_or(pt.device.track.average_bpm);
358    let Some(entry) = &pt.anlz else {
359        let frames = match frames {
360            Some(f) => Some(f),
361            None if is_mp3(&dest) => Some(rewrite::mp3_audio_frames(&dest)?),
362            None => None,
363        };
364        let pcm = generate::decode::decode(&pt.source)?;
365        let files = generate::build_files(&pt.device.track, &pt.device.usb_path, &pcm, frames);
366        for (kind, file) in FileKind::ALL.iter().zip(files.iter()) {
367            std::fs::write(
368                anlz_dest.join(format!("ANLZ0000.{}", kind.extension())),
369                file.to_bytes(),
370            )?;
371            report.anlz_files += 1;
372        }
373        report.anlz_generated += 1;
374        return Ok(pt.device.clone());
375    };
376    for kind in FileKind::ALL {
377        let Some(mut file) = read_optional(&entry.sibling(kind.extension()))? else {
378            if kind != FileKind::TwoEx {
379                anyhow::bail!(
380                    "analysis file .{} missing next to {}",
381                    kind.extension(),
382                    entry.dat.display()
383                );
384            }
385            continue;
386        };
387        rewrite::prepare(
388            &mut file,
389            kind,
390            &pt.device.usb_path,
391            &pt.device.track.cues,
392            bpm,
393        );
394        if let Some(frames) = frames {
395            match kind {
396                FileKind::Dat => rewrite::set_cbr_pvbr(&mut file, frames),
397                FileKind::Ext => rewrite::strip_pvb2(&mut file),
398                FileKind::TwoEx => {}
399            }
400        }
401        std::fs::write(
402            anlz_dest.join(format!("ANLZ0000.{}", kind.extension())),
403            file.to_bytes(),
404        )?;
405        report.anlz_files += 1;
406    }
407    Ok(pt.device.clone())
408}
409
410fn is_mp3(path: &Path) -> bool {
411    path.extension()
412        .and_then(|e| e.to_str())
413        .is_some_and(|e| e.eq_ignore_ascii_case("mp3"))
414}
415
416/// Delete files under `root` not in `keep`, then empty directories. Returns the file count.
417fn prune_tree(root: &Path, keep: &HashSet<PathBuf>) -> Result<usize> {
418    fn walk(dir: &Path, keep: &HashSet<PathBuf>, removed: &mut usize) -> std::io::Result<bool> {
419        let mut empty = true;
420        for entry in std::fs::read_dir(dir)? {
421            let entry = entry?;
422            let path = entry.path();
423            if entry.file_type()?.is_dir() {
424                if walk(&path, keep, removed)? {
425                    std::fs::remove_dir(&path)?;
426                } else {
427                    empty = false;
428                }
429            } else if keep.contains(&path) {
430                empty = false;
431            } else {
432                std::fs::remove_file(&path)?;
433                *removed += 1;
434            }
435        }
436        Ok(empty)
437    }
438    let mut removed = 0;
439    if root.is_dir() {
440        walk(root, keep, &mut removed)?;
441    }
442    Ok(removed)
443}
444
445/// macOS leaves `._*` AppleDouble files on FAT volumes; Linux-based players trip on them.
446fn remove_apple_double(root: &Path) -> Result<()> {
447    fn walk(dir: &Path) -> std::io::Result<()> {
448        for entry in std::fs::read_dir(dir)? {
449            let entry = entry?;
450            let path = entry.path();
451            if entry.file_type()?.is_dir() {
452                walk(&path)?;
453            } else if entry.file_name().to_string_lossy().starts_with("._") {
454                std::fs::remove_file(&path)?;
455            }
456        }
457        Ok(())
458    }
459    if root.is_dir() {
460        walk(root)?;
461    }
462    Ok(())
463}