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