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;
15pub mod volume;
16
17pub use error::{Error, Result};
18
19use anlz::generate;
20use anlz::generate::Measured;
21use anlz::hash::AnlzSlots;
22use anlz::locate::{read_optional, AnlzIndex, Entry};
23use anlz::rewrite::{self, FileKind, Mp3Audio};
24use anlz::section::AnlzFile;
25use baken_core::{fsname, CancelToken, Progress};
26use build::DeviceTrack;
27use collection::Library;
28use std::collections::{BTreeMap, HashSet};
29use std::panic::AssertUnwindSafe;
30use std::path::{Path, PathBuf};
31use std::sync::atomic::{AtomicU64, Ordering};
32use std::sync::{mpsc, Condvar, Mutex};
33
34#[derive(Debug, Clone, Default)]
35pub struct Options {
36    pub xml: PathBuf,
37    pub device: PathBuf,
38    /// `Folder/Name` paths; empty means every TrackID playlist.
39    pub playlists: Vec<String>,
40    /// Empty means rekordbox's default locations on this machine.
41    pub anlz_roots: Vec<PathBuf>,
42    pub settings_dir: Option<PathBuf>,
43    /// Write no My Settings, so the player keeps its own.
44    pub no_settings: bool,
45    /// Defaults to the device directory name.
46    pub device_name: Option<String>,
47    /// Transcode every track to 320 kbps CBR MP3 and reuse the source analysis.
48    pub cdjsafe: bool,
49    /// Compute the analysis files from the audio for tracks rekordbox never
50    /// analysed, instead of leaving them out (issue #147).
51    pub generate_analysis: bool,
52    /// Delete audio and analysis on the stick that this export does not reference.
53    pub prune: bool,
54}
55
56#[derive(Debug, Clone)]
57pub struct Skipped {
58    pub name: String,
59    pub reason: String,
60}
61
62#[derive(Debug, Clone)]
63pub struct PlanTrack {
64    pub device: DeviceTrack,
65    pub source: PathBuf,
66    /// rekordbox's own analysis to copy; `None` means generate it from the audio.
67    pub anlz: Option<Entry>,
68}
69
70/// What rekordbox 7 writes into `PIONEER/rekordbox/` beside `export.pdb` and
71/// expressport does not: the OneLibrary database with its `-wal` and `-shm`,
72/// and `exportExt.pdb`, the Device Library's extension tables. Left next to a
73/// new `export.pdb` they describe rekordbox's old library, which rekordbox
74/// reports as "a library inconsistency on the device" and OneLibrary players
75/// show instead of ours (issue #208).
76pub const ONELIBRARY_FILES: [&str; 4] = [
77    "exportLibrary.db",
78    "exportLibrary.db-wal",
79    "exportLibrary.db-shm",
80    "exportExt.pdb",
81];
82
83#[derive(Debug)]
84pub struct Plan {
85    pub library: Library,
86    pub tracks: Vec<PlanTrack>,
87    /// Indices into `library.playlists`.
88    pub selected: Vec<usize>,
89    pub skipped: Vec<Skipped>,
90    /// `None` with `no_settings`.
91    pub settings_dir: Option<PathBuf>,
92    /// Settings files to copy, already validated so a bad one fails before anything is written.
93    pub settings_files: Vec<&'static str>,
94    pub anlz_roots: Vec<PathBuf>,
95    pub anlz_files_indexed: usize,
96    pub device: PathBuf,
97    /// `false` when the device is a directory on the disk of its parent, such
98    /// as an empty mount point with no stick mounted on it.
99    pub volume_root: bool,
100    /// Filesystem of the stick; `None` where it cannot be read, and when the
101    /// device is not a volume root.
102    pub filesystem: Option<volume::FileSystem>,
103    /// Partition table of the disk the stick's volume is on, best effort. A
104    /// caller that cannot run `diskutil` or `lsblk` (a sandboxed app) can set
105    /// it itself before showing [`Plan::format_warnings`].
106    pub partition_table: Option<volume::PartitionTable>,
107    /// Those of [`ONELIBRARY_FILES`] on the stick, in that order. [`export`]
108    /// removes them once it has written `export.pdb`, so a cancelled or
109    /// failed run leaves both of the stick's libraries as they were.
110    pub onelibrary_files: Vec<&'static str>,
111    pub device_name: String,
112    pub cdjsafe: bool,
113    pub prune: bool,
114}
115
116impl Plan {
117    /// Tracks whose analysis files will be generated rather than copied.
118    pub fn generated(&self) -> usize {
119        self.tracks.iter().filter(|t| t.anlz.is_none()).count()
120    }
121
122    /// Generated tracks whose XML carries no beat grid (`TEMPO`), so they get
123    /// none on the stick either.
124    pub fn without_grid(&self) -> usize {
125        self.tracks
126            .iter()
127            .filter(|t| t.anlz.is_none() && t.device.track.tempos.is_empty())
128            .count()
129    }
130
131    /// Tracks that get an active loop from a memory loop named `[active]` (issue #210).
132    pub fn active_loops(&self) -> usize {
133        self.tracks
134            .iter()
135            .filter(|t| collection::active_loop(&t.device.track.cues).is_some())
136            .count()
137    }
138
139    /// Tracks whose `[active]` marks do not name exactly one memory loop.
140    pub fn active_loop_warnings(&self) -> Vec<collection::ActiveLoopWarning> {
141        self.tracks
142            .iter()
143            .filter_map(|t| collection::ActiveLoopWarning::check(&t.device.track))
144            .collect()
145    }
146
147    /// What the stick's filesystem or partition table rules out (issue #184).
148    pub fn format_warnings(&self) -> Vec<volume::FormatWarning> {
149        volume::warnings(self.filesystem.as_ref(), self.partition_table)
150    }
151
152    pub fn playlist_names(&self) -> Vec<&str> {
153        self.selected
154            .iter()
155            .map(|&i| self.library.playlists[i].path.as_str())
156            .collect()
157    }
158}
159
160#[derive(Debug, Default)]
161pub struct Report {
162    pub copied: usize,
163    pub kept: usize,
164    pub transcoded: usize,
165    pub anlz_files: usize,
166    /// Analysis files already on the stick byte for byte, so not written again.
167    pub anlz_unchanged: usize,
168    /// Tracks whose analysis files were generated from the audio.
169    pub anlz_generated: usize,
170    pub pruned: usize,
171    /// AppleDouble `._` files left on the stick because the system refused to
172    /// remove them: inside the App Sandbox the `._X` of a file the app wrote
173    /// cannot be unlinked while `X` exists (issue #192).
174    pub apple_double_kept: usize,
175    /// [`ONELIBRARY_FILES`] removed after `export.pdb` was written (issue #208).
176    pub onelibrary_removed: usize,
177    /// [`ONELIBRARY_FILES`] the system did not let the export remove, so they
178    /// are still on the stick next to the new `export.pdb`. Counted rather
179    /// than failing the run, which has written `export.pdb` by then.
180    pub onelibrary_kept: usize,
181    pub cancelled: bool,
182    pub failures: Vec<(String, String)>,
183    pub tracks_in_database: usize,
184}
185
186pub fn plan(opts: &Options) -> Result<Plan> {
187    if !opts.device.is_dir() {
188        return Err(Error::DeviceNotFound(opts.device.clone()));
189    }
190    let (settings_dir, settings_files) = if opts.no_settings {
191        (None, Vec::new())
192    } else {
193        let dir = settings::locate(opts.settings_dir.as_deref())
194            .map_err(|searched| Error::SettingsNotFound { searched })?;
195        let files = settings::files(&dir)?;
196        (Some(dir), files)
197    };
198
199    let library = Library::load(&opts.xml)?;
200    let selected = select_playlists(&library, &opts.playlists)?;
201
202    let anlz_roots = if opts.anlz_roots.is_empty() {
203        anlz::locate::default_roots()
204    } else {
205        opts.anlz_roots.clone()
206    };
207    if anlz_roots.is_empty() && !opts.generate_analysis {
208        return Err(Error::NoAnlzRoot {
209            searched: anlz_roots,
210        });
211    }
212    let index = AnlzIndex::build(&anlz_roots)?;
213
214    let mut seen = HashSet::new();
215    let mut skipped = Vec::new();
216    let mut tracks = Vec::new();
217    let mut layout = layout::Layout::default();
218    let mut anlz_slots = AnlzSlots::default();
219    for &pi in &selected {
220        for &tid in &library.playlists[pi].track_ids {
221            if !seen.insert(tid) {
222                continue;
223            }
224            let Some(track) = library.track(tid) else {
225                skipped.push(Skipped {
226                    name: format!("TrackID {tid}"),
227                    reason: "not in the collection".into(),
228                });
229                continue;
230            };
231            let source = PathBuf::from(&track.location);
232            let Ok(meta) = std::fs::metadata(&source) else {
233                skipped.push(Skipped {
234                    name: track.name.clone(),
235                    reason: format!("source file missing: {}", source.display()),
236                });
237                continue;
238            };
239            let entry = index.find(track);
240            if entry.is_none() && !opts.generate_analysis {
241                let reason = if index.has_name(track) {
242                    "the rekordbox analysis found for this file name does not match the XML's beat grid (export the XML again after changing the grid, or pass --generate-analysis)"
243                } else {
244                    "no rekordbox analysis found (analyse it in rekordbox first, or pass --generate-analysis)"
245                };
246                skipped.push(Skipped {
247                    name: track.name.clone(),
248                    reason: reason.into(),
249                });
250                continue;
251            }
252            let usb_path = if opts.cdjsafe {
253                let mp3 = Path::new(&track.location).with_extension("mp3");
254                layout.assign(&collection::Track {
255                    location: mp3.to_string_lossy().into_owned(),
256                    ..track.clone()
257                })
258            } else {
259                layout.assign(track)
260            };
261            let (file_type, bitrate, sample_rate, sample_depth) = if opts.cdjsafe {
262                (pdb::rows::FILE_TYPE_MP3, 320, 44100, 16)
263            } else {
264                (
265                    build::file_type_for(&track.kind, track.file_name()),
266                    track.bit_rate,
267                    track.sample_rate,
268                    layout::sample_depth(&source),
269                )
270            };
271            let (anlz_dir, anlz_index) = anlz_slots.assign(&usb_path);
272            tracks.push(PlanTrack {
273                device: DeviceTrack {
274                    anlz_dir,
275                    anlz_index,
276                    usb_path,
277                    track: track.clone(),
278                    file_size: meta.len(),
279                    sample_depth,
280                    file_type,
281                    bitrate,
282                    sample_rate,
283                },
284                source,
285                anlz: entry.cloned(),
286            });
287        }
288    }
289    if tracks.is_empty() {
290        return Err(Error::NothingToExport);
291    }
292    let device_name = opts
293        .device_name
294        .clone()
295        .or_else(|| {
296            opts.device
297                .file_name()
298                .map(|n| n.to_string_lossy().into_owned())
299        })
300        .unwrap_or_else(|| "USB".into());
301    let volume_root = is_volume_root(&opts.device);
302    let (filesystem, partition_table) = if volume_root {
303        volume::probe(&opts.device)
304    } else {
305        (None, None)
306    };
307    let rb_dir = opts.device.join("PIONEER/rekordbox");
308    let onelibrary_files = ONELIBRARY_FILES
309        .into_iter()
310        .filter(|f| rb_dir.join(f).symlink_metadata().is_ok())
311        .collect();
312    Ok(Plan {
313        library,
314        tracks,
315        selected,
316        skipped,
317        settings_dir,
318        settings_files,
319        anlz_roots,
320        anlz_files_indexed: index.files,
321        device: opts.device.clone(),
322        volume_root,
323        filesystem,
324        partition_table,
325        onelibrary_files,
326        device_name,
327        cdjsafe: opts.cdjsafe,
328        prune: opts.prune,
329    })
330}
331
332fn select_playlists(library: &Library, names: &[String]) -> Result<Vec<usize>> {
333    let mut out = Vec::new();
334    if names.is_empty() {
335        for (i, p) in library.playlists.iter().enumerate() {
336            if !p.is_folder && p.key_type == "0" {
337                out.push(i);
338            }
339        }
340    } else {
341        for name in names {
342            let name = name.trim().trim_matches('/');
343            let (i, p) = library
344                .playlists
345                .iter()
346                .enumerate()
347                .find(|(_, p)| p.path == name && !p.is_folder)
348                .ok_or_else(|| Error::PlaylistNotFound(name.to_string()))?;
349            if p.key_type != "0" {
350                return Err(Error::UnsupportedPlaylistType {
351                    path: p.path.clone(),
352                    key_type: p.key_type.clone(),
353                });
354            }
355            if !out.contains(&i) {
356                out.push(i);
357            }
358        }
359    }
360    if out.is_empty() {
361        return Err(Error::NoPlaylists);
362    }
363    Ok(out)
364}
365
366#[cfg(unix)]
367fn is_volume_root(dir: &Path) -> bool {
368    use std::os::unix::fs::MetadataExt;
369    let Ok(dir) = std::fs::canonicalize(dir) else {
370        return true;
371    };
372    let Some(parent) = dir.parent() else {
373        return true;
374    };
375    match (std::fs::metadata(&dir), std::fs::metadata(parent)) {
376        (Ok(d), Ok(p)) => d.dev() != p.dev(),
377        _ => true,
378    }
379}
380
381/// Not checked on Windows, where a stick is a drive letter rather than a mount point.
382#[cfg(not(unix))]
383fn is_volume_root(_: &Path) -> bool {
384    true
385}
386
387fn device_path(device: &Path, usb_path: &str) -> PathBuf {
388    device.join(usb_path.trim_start_matches('/'))
389}
390
391pub fn export(plan: &Plan, progress: &dyn Progress, cancel: &CancelToken) -> Result<Report> {
392    let mut report = Report::default();
393    let total = plan.tracks.len();
394    let mut exported: Vec<DeviceTrack> = Vec::with_capacity(total);
395    let mut wanted: HashSet<PathBuf> = HashSet::new();
396
397    // Before the first track, so a stick that is not mounted, read-only or gone
398    // stops the run with a reason instead of failing every track (issue #165).
399    let rb_dir = plan.device.join("PIONEER/rekordbox");
400    let probe = rb_dir.join(".baken-write-test");
401    std::fs::create_dir_all(&rb_dir)
402        .and_then(|()| std::fs::write(&probe, b""))
403        .and_then(|()| std::fs::remove_file(&probe))
404        .map_err(|err| Error::DeviceWrite {
405            path: rb_dir.clone(),
406            err,
407        })?;
408
409    let ahead = Ahead::new(total);
410    let (tx, rx) = mpsc::channel::<(usize, anyhow::Result<Prepared>)>();
411    std::thread::scope(|s| {
412        for _ in 0..ahead.workers {
413            let tx = tx.clone();
414            let ahead = &ahead;
415            s.spawn(move || {
416                while let Some(i) = ahead.take() {
417                    // a panic (a decoder on a broken file) fails that track
418                    // instead of leaving the writer waiting for it forever
419                    let prepared = std::panic::catch_unwind(AssertUnwindSafe(|| {
420                        prepare(plan, &plan.tracks[i])
421                    }))
422                    .unwrap_or_else(|_| Err(anyhow::anyhow!("preparing the track panicked")));
423                    if tx.send((i, prepared)).is_err() {
424                        break;
425                    }
426                }
427            });
428        }
429        drop(tx);
430        let _stop = StopOnDrop(&ahead);
431        let mut ready = BTreeMap::new();
432        for (i, pt) in plan.tracks.iter().enumerate() {
433            if cancel.is_cancelled() {
434                report.cancelled = true;
435                break;
436            }
437            let prepared = loop {
438                if let Some(p) = ready.remove(&i) {
439                    break p;
440                }
441                let (j, p) = rx.recv().expect("every track is prepared once");
442                ready.insert(j, p);
443            };
444            match prepared.and_then(|p| write_track(plan, pt, p, &mut report)) {
445                Ok(dt) => {
446                    wanted.insert(device_path(&plan.device, &dt.usb_path));
447                    for kind in FileKind::ALL {
448                        wanted.insert(device_path(&plan.device, &dt.anlz_path(kind.extension())));
449                    }
450                    exported.push(dt);
451                }
452                Err(e) => report
453                    .failures
454                    .push((pt.device.track.name.clone(), e.to_string())),
455            }
456            progress.on_file_done(i + 1, total, &pt.source);
457            ahead.written(i + 1);
458        }
459    });
460    if report.cancelled {
461        return Ok(report);
462    }
463
464    let date = build::today();
465    let model = build::build(
466        &plan.library,
467        &exported,
468        &plan.selected,
469        &plan.device_name,
470        &date,
471    );
472    report.tracks_in_database = exported.len();
473    let pdb_path = rb_dir.join("export.pdb");
474    std::fs::write(&pdb_path, pdb::write(&model)).map_err(|err| Error::DeviceWrite {
475        path: pdb_path,
476        err,
477    })?;
478    remove_onelibrary(&rb_dir, &mut report);
479
480    if let Some(dir) = &plan.settings_dir {
481        settings::copy_all(dir, &plan.settings_files, &plan.device)?;
482    }
483
484    if plan.prune {
485        report.pruned += prune_tree(&plan.device.join("Contents"), &wanted)?;
486        report.pruned += prune_tree(&plan.device.join("PIONEER/USBANLZ"), &wanted)?;
487    }
488    // Only files written in this run can have gained an AppleDouble file, so
489    // the two big trees are walked only when something was written into them.
490    // Copying the audio without xattrs (`copy_audio`) does not make the walk
491    // unnecessary: macOS adds `com.apple.provenance` to every file a process
492    // under a third-party app (a terminal, Zed) creates, and FAT keeps that
493    // in a `._` file too (issue #196).
494    if report.copied + report.transcoded > 0 || plan.prune {
495        remove_apple_double(&plan.device.join("Contents"), true, &mut report)?;
496    }
497    if report.anlz_files > 0 || plan.prune {
498        remove_apple_double(&plan.device.join("PIONEER/USBANLZ"), true, &mut report)?;
499    }
500    remove_apple_double(&plan.device.join("PIONEER"), false, &mut report)?;
501    remove_apple_double(&rb_dir, false, &mut report)?;
502    for dir in ["Contents", "PIONEER"] {
503        if remove_sidecar(&plan.device.join(format!("._{dir}")))? {
504            report.apple_double_kept += 1;
505        }
506    }
507    Ok(report)
508}
509
510/// Remove rekordbox's OneLibrary and `exportExt.pdb` once our `export.pdb` is
511/// on the stick, and not before: until then they match the old one (issue
512/// #208). Whichever of [`ONELIBRARY_FILES`] is there goes, also one rekordbox
513/// wrote after the plan. A file the system does not let us remove is counted,
514/// since an error would lose the report of a run that has written
515/// `export.pdb`. Their `._` files go with the `PIONEER/rekordbox` walk at the
516/// end of [`export`], under the rules of #192.
517fn remove_onelibrary(rb_dir: &Path, report: &mut Report) {
518    for name in ONELIBRARY_FILES {
519        match fsname::remove_file(&rb_dir.join(name)) {
520            Ok(()) => report.onelibrary_removed += 1,
521            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
522            Err(_) => report.onelibrary_kept += 1,
523        }
524    }
525}
526
527/// Hands out track indices to the workers that prepare tracks ahead of the
528/// writer, at most `window` beyond the last track written, so a slow stick
529/// does not pile up prepared tracks. Two workers already hide the decoding
530/// behind the copy (352 generated tracks on an SSD image: 128 s to 54 s);
531/// four gained another 5 to 10 s there, which a stick writing slower than
532/// that image would not show.
533struct Ahead {
534    workers: usize,
535    window: usize,
536    total: usize,
537    state: Mutex<(usize, usize, bool)>, // next, written, stopped
538    moved: Condvar,
539}
540
541impl Ahead {
542    fn new(total: usize) -> Self {
543        let workers = std::thread::available_parallelism()
544            .map_or(1, |n| n.get())
545            .clamp(1, 2)
546            .min(total.max(1));
547        Ahead {
548            workers,
549            window: workers * 2,
550            total,
551            state: Mutex::new((0, 0, false)),
552            moved: Condvar::new(),
553        }
554    }
555
556    fn take(&self) -> Option<usize> {
557        let mut st = self.state.lock().unwrap();
558        loop {
559            let (next, written, stopped) = *st;
560            if stopped || next >= self.total {
561                return None;
562            }
563            if next < written + self.window {
564                st.0 += 1;
565                return Some(next);
566            }
567            st = self.moved.wait(st).unwrap();
568        }
569    }
570
571    fn written(&self, n: usize) {
572        self.state.lock().unwrap().1 = n;
573        self.moved.notify_all();
574    }
575
576    fn stop(&self) {
577        self.state.lock().unwrap().2 = true;
578        self.moved.notify_all();
579    }
580}
581
582/// Releases the workers however the writer leaves, so the scope can end.
583struct StopOnDrop<'a>(&'a Ahead);
584
585impl Drop for StopOnDrop<'_> {
586    fn drop(&mut self) {
587        self.0.stop();
588    }
589}
590
591/// A track's analysis files, audio and final `DeviceTrack`, computed ahead
592/// of the stick writes (issue #160) from local files, except that `--cdjsafe`
593/// looks at the stick once to see whether the MP3 is already there.
594struct Prepared {
595    files: Vec<(FileKind, AnlzFile)>,
596    device: DeviceTrack,
597    generated: bool,
598    audio: Audio,
599}
600
601/// Where the audio written to the stick comes from.
602enum Audio {
603    /// The source file, byte for byte.
604    Source,
605    /// `--cdjsafe`: the MP3 encoded on the local disk ahead of the write.
606    Transcoded(TempFile),
607    /// `--cdjsafe`: already on the stick, nothing to write.
608    OnStick,
609}
610
611/// A file on the local disk, removed when dropped: a transcode that never
612/// reaches the stick (a failure, a cancel) leaves nothing behind.
613struct TempFile(PathBuf);
614
615impl TempFile {
616    fn new(ext: &str) -> Self {
617        static NEXT: AtomicU64 = AtomicU64::new(0);
618        TempFile(std::env::temp_dir().join(format!(
619            "baken-expressport-{}-{}.{ext}",
620            std::process::id(),
621            NEXT.fetch_add(1, Ordering::Relaxed)
622        )))
623    }
624}
625
626impl Drop for TempFile {
627    fn drop(&mut self) {
628        let _ = std::fs::remove_file(&self.0);
629    }
630}
631
632fn prepare(plan: &Plan, pt: &PlanTrack) -> anyhow::Result<Prepared> {
633    let audio = if plan.cdjsafe {
634        cdjsafe_audio(plan, pt)?
635    } else {
636        Audio::Source
637    };
638    let mut prepared = prepare_analysis(plan, pt)?;
639    if plan.cdjsafe {
640        // `PVBR` describes the MP3 that ends up on the stick, whichever that is
641        let mp3 = match &audio {
642            Audio::Source => pt.source.clone(),
643            Audio::Transcoded(tmp) => tmp.0.clone(),
644            Audio::OnStick => device_path(&plan.device, &pt.device.usb_path),
645        };
646        let frames = rewrite::mp3_audio(&mp3)?.frames;
647        for (kind, file) in &mut prepared.files {
648            match kind {
649                FileKind::Dat => rewrite::set_cbr_pvbr(file, frames),
650                // `PVB2` describes FLAC seeking; it means nothing for an MP3
651                FileKind::Ext => file.remove(b"PVB2"),
652                FileKind::TwoEx => {}
653            }
654        }
655    }
656    prepared.audio = audio;
657    Ok(prepared)
658}
659
660/// `--cdjsafe`: the MP3 for the stick, encoded here on the worker so that the
661/// encoder never waits for the stick and the stick sees one plain copy
662/// (issue #197). A track already on the stick is kept; one that is already
663/// 320 kbps CBR MP3 goes as it is.
664fn cdjsafe_audio(plan: &Plan, pt: &PlanTrack) -> anyhow::Result<Audio> {
665    if device_path(&plan.device, &pt.device.usb_path).is_file() {
666        return Ok(Audio::OnStick);
667    }
668    if baken_core::cdjsafe::probe(&pt.source)?.is_compatible_mp3() {
669        return Ok(Audio::Source);
670    }
671    let tmp = TempFile::new("mp3");
672    baken_core::cdjsafe::transcode(&pt.source, &tmp.0)?;
673    Ok(Audio::Transcoded(tmp))
674}
675
676/// The analysis files: rekordbox's own rewritten for the stick, or generated
677/// from the audio. `--cdjsafe` sets `PVBR` afterwards, in [`prepare`].
678fn prepare_analysis(plan: &Plan, pt: &PlanTrack) -> anyhow::Result<Prepared> {
679    let Some(entry) = &pt.anlz else {
680        let mp3 = if pt.device.file_type == pdb::rows::FILE_TYPE_MP3 && !plan.cdjsafe {
681            Some(rewrite::mp3_audio(&pt.source)?)
682        } else {
683            None
684        };
685        let audio = generate::measure(&pt.source)?;
686        let files = generate::build_files(
687            &pt.device.track,
688            &pt.device.usb_path,
689            &audio,
690            mp3.map(|m| m.frames),
691        );
692        return Ok(Prepared {
693            files: FileKind::ALL.into_iter().zip(files).collect(),
694            device: with_measured(&pt.device, &audio, mp3),
695            generated: true,
696            audio: Audio::Source,
697        });
698    };
699    let mut files = Vec::new();
700    for kind in FileKind::ALL {
701        let Some(mut file) = read_optional(&entry.sibling(kind.extension()))? else {
702            if kind != FileKind::TwoEx {
703                anyhow::bail!(
704                    "analysis file .{} missing next to {}",
705                    kind.extension(),
706                    entry.dat.display()
707                );
708            }
709            continue;
710        };
711        rewrite::prepare(
712            &mut file,
713            kind,
714            &pt.device.usb_path,
715            &pt.device.track.cues,
716            pt.device.track.grid_bpm(),
717        );
718        files.push((kind, file));
719    }
720    Ok(Prepared {
721        files,
722        device: pt.device.clone(),
723        generated: false,
724        audio: Audio::Source,
725    })
726}
727
728/// Everything that touches the stick, one track at a time in plan order. The
729/// returned track carries the size of the audio file as it is on the stick.
730fn write_track(
731    plan: &Plan,
732    pt: &PlanTrack,
733    mut prepared: Prepared,
734    report: &mut Report,
735) -> anyhow::Result<DeviceTrack> {
736    let dest = device_path(&plan.device, &pt.device.usb_path);
737    if let Some(parent) = dest.parent() {
738        std::fs::create_dir_all(parent)?;
739    }
740    let existing = std::fs::metadata(&dest).ok().map(|m| m.len());
741    prepared.device.file_size = match (&prepared.audio, existing) {
742        (Audio::OnStick, Some(size)) => {
743            report.kept += 1;
744            size
745        }
746        (Audio::OnStick, None) => {
747            anyhow::bail!("{} disappeared from the stick", dest.display())
748        }
749        (Audio::Transcoded(mp3), _) => {
750            let size = copy_audio(&mp3.0, &dest)?;
751            report.transcoded += 1;
752            size
753        }
754        (Audio::Source, Some(size)) if !plan.cdjsafe && size == pt.device.file_size => {
755            report.kept += 1;
756            size
757        }
758        (Audio::Source, _) => {
759            let size = copy_audio(&pt.source, &dest)?;
760            report.copied += 1;
761            size
762        }
763    };
764    std::fs::create_dir_all(device_path(&plan.device, &pt.device.anlz_dir))?;
765    for (kind, file) in &prepared.files {
766        write_anlz(
767            &device_path(&plan.device, &pt.device.anlz_path(kind.extension())),
768            &file.to_bytes(),
769            report,
770        )?;
771    }
772    if prepared.generated {
773        report.anlz_generated += 1;
774    }
775    Ok(prepared.device)
776}
777
778/// Write an analysis file unless the stick already holds exactly these bytes.
779/// A re-run after a playlist change then writes only what changed, and on a
780/// USB stick writing is what takes the time.
781fn write_anlz(path: &Path, bytes: &[u8], report: &mut Report) -> std::io::Result<()> {
782    match std::fs::read(path) {
783        Ok(old) if old == bytes => report.anlz_unchanged += 1,
784        _ => {
785            std::fs::write(path, bytes)?;
786            report.anlz_files += 1;
787        }
788    }
789    Ok(())
790}
791
792/// Copy the audio of `src` to `dst`: the bytes only, no extended attributes,
793/// ACL, mode or times. `std::fs::copy` carries those over, and on a FAT stick
794/// every source xattr then becomes a `._` file next to the track. A reader
795/// thread keeps up to three 4 MiB pieces ahead of the writes, so a slow
796/// source (a NAS, an HDD) overlaps a slow stick instead of adding to it
797/// (issue #196). Returns the bytes written.
798fn copy_audio(src: &Path, dst: &Path) -> std::io::Result<u64> {
799    use std::io::{Read, Write};
800    const PIECE: usize = 4 << 20;
801    let mut reader = std::fs::File::open(src)?;
802    let mut writer = std::fs::File::create(dst)?;
803    let (tx, rx) = mpsc::sync_channel::<std::io::Result<Vec<u8>>>(3);
804    std::thread::scope(|s| {
805        s.spawn(move || loop {
806            let mut piece = vec![0u8; PIECE];
807            let sent = match reader.read(&mut piece) {
808                Ok(0) => break,
809                Ok(n) => {
810                    piece.truncate(n);
811                    tx.send(Ok(piece))
812                }
813                Err(e) => {
814                    let _ = tx.send(Err(e));
815                    break;
816                }
817            };
818            if sent.is_err() {
819                break;
820            }
821        });
822        let mut written = 0u64;
823        for piece in rx {
824            let piece = piece?;
825            writer.write_all(&piece)?;
826            written += piece.len() as u64;
827        }
828        Ok(written)
829    })
830}
831
832/// Fill in what the XML left at 0 from the audio a generated-analysis track
833/// was just decoded from (#167); a value rekordbox wrote always stays. The
834/// rules follow what rekordbox writes: MP3 the audio-frame rate, lossless the
835/// PCM rate (`1411`, `2116`, `1536`), length truncated to whole seconds.
836fn with_measured(dt: &DeviceTrack, audio: &Measured, mp3: Option<Mp3Audio>) -> DeviceTrack {
837    use pdb::rows::{FILE_TYPE_AIFF, FILE_TYPE_ALAC, FILE_TYPE_FLAC, FILE_TYPE_WAV};
838    let mut dt = dt.clone();
839    let secs = audio.duration_ms() / 1000.0;
840    if audio.sample_rate == 0 || secs <= 0.0 {
841        return dt;
842    }
843    if dt.sample_rate == 0 {
844        dt.sample_rate = audio.sample_rate;
845    }
846    if dt.bitrate == 0 {
847        let lossless = [
848            FILE_TYPE_FLAC,
849            FILE_TYPE_WAV,
850            FILE_TYPE_AIFF,
851            FILE_TYPE_ALAC,
852        ]
853        .contains(&dt.file_type);
854        dt.bitrate = match mp3.and_then(|m| m.kbps()) {
855            Some(kbps) => kbps,
856            None if lossless => {
857                (audio.sample_rate as u64 * dt.sample_depth as u64 * audio.channels as u64 / 1000)
858                    as u32
859            }
860            None => (dt.file_size as f64 * 8.0 / secs / 1000.0).round() as u32,
861        };
862    }
863    if dt.track.total_time == 0 {
864        dt.track.total_time = secs as u32;
865    }
866    dt
867}
868
869/// Delete files under `root` not in `keep`, then empty directories, and return
870/// the number of files deleted. Paths are compared in NFC: on macOS 26
871/// `read_dir` lists an ExFAT or FAT stick's names in NFD whatever form they
872/// were written in, and the stick is written in the XML's NFC (issue #154).
873///
874/// A `._X` AppleDouble file is decided by its `X`: left alone while `X` stays,
875/// since a sandboxed caller may not remove it then (issue #192), and removed
876/// once `X` is gone, unless the volume already dropped it together with `X`.
877fn prune_tree(root: &Path, keep: &HashSet<PathBuf>) -> Result<usize> {
878    fn walk(dir: &Path, keep: &HashSet<PathBuf>, removed: &mut usize) -> std::io::Result<bool> {
879        let mut empty = true;
880        let mut sidecars = Vec::new();
881        for entry in std::fs::read_dir(dir)? {
882            let entry = entry?;
883            let path = entry.path();
884            if entry.file_type()?.is_dir() {
885                if walk(&path, keep, removed)? {
886                    fsname::remove_dir(&path)?;
887                } else {
888                    empty = false;
889                }
890            } else if let Some(name) = entry.file_name().to_string_lossy().strip_prefix("._") {
891                sidecars.push((path.clone(), dir.join(name)));
892            } else if keep.contains(&fsname::nfc(&path)) {
893                empty = false;
894            } else {
895                fsname::remove_file(&path)?;
896                *removed += 1;
897            }
898        }
899        for (sidecar, of) in sidecars {
900            if of.symlink_metadata().is_ok() || remove_sidecar(&sidecar)? {
901                empty = false;
902            }
903        }
904        Ok(empty)
905    }
906    let keep: HashSet<PathBuf> = keep.iter().map(|p| fsname::nfc(p)).collect();
907    let mut removed = 0;
908    if root.is_dir() {
909        walk(root, &keep, &mut removed)?;
910    }
911    Ok(removed)
912}
913
914/// macOS leaves `._*` AppleDouble files on FAT volumes; Linux-based players trip on them.
915/// Those the system refuses to remove are counted in `report.apple_double_kept`.
916fn remove_apple_double(root: &Path, recursive: bool, report: &mut Report) -> Result<()> {
917    fn walk(dir: &Path, recursive: bool, kept: &mut usize) -> std::io::Result<()> {
918        for entry in std::fs::read_dir(dir)? {
919            let entry = entry?;
920            let path = entry.path();
921            if entry.file_type()?.is_dir() {
922                if recursive {
923                    walk(&path, recursive, kept)?;
924                }
925            } else if entry.file_name().to_string_lossy().starts_with("._")
926                && remove_sidecar(&path)?
927            {
928                *kept += 1;
929            }
930        }
931        Ok(())
932    }
933    if root.is_dir() {
934        walk(root, recursive, &mut report.apple_double_kept)?;
935    }
936    Ok(())
937}
938
939/// Remove an AppleDouble file; `Ok(true)` when the system refused. Inside the
940/// App Sandbox every file the app writes carries `com.apple.quarantine`, which
941/// FAT stores in `._X`, and unlinking `._X` while `X` exists is refused as an
942/// attribute change on `X` (issue #192). Already gone is fine: it goes with `X`.
943fn remove_sidecar(path: &Path) -> std::io::Result<bool> {
944    match fsname::remove_file(path) {
945        Ok(()) => Ok(false),
946        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
947        Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => Ok(true),
948        Err(e) => Err(e),
949    }
950}
951
952#[cfg(test)]
953mod tests {
954    use super::*;
955    use pdb::rows::{FILE_TYPE_FLAC, FILE_TYPE_M4A, FILE_TYPE_MP3, FILE_TYPE_WAV};
956
957    fn track(file_type: u16, sample_depth: u16) -> DeviceTrack {
958        DeviceTrack {
959            track: collection::Track::default(),
960            usb_path: String::new(),
961            anlz_dir: String::new(),
962            anlz_index: 0,
963            file_size: 8_000_000,
964            sample_depth,
965            file_type,
966            bitrate: 0,
967            sample_rate: 0,
968        }
969    }
970
971    /// 200.5 seconds of stereo at 44.1 kHz.
972    fn audio() -> Measured {
973        Measured {
974            sample_rate: 44100,
975            channels: 2,
976            frames: 44100 * 401 / 2,
977            ..Default::default()
978        }
979    }
980
981    /// On a plain filesystem `._X` stays when `X` is removed, so prune has to
982    /// remove it itself, and must leave the `._X` of a kept `X` alone (#192).
983    #[test]
984    fn prune_decides_a_sidecar_by_its_file() {
985        let root = std::env::temp_dir().join(format!("baken-prune-{}", std::process::id()));
986        let _ = std::fs::remove_dir_all(&root);
987        let files = [
988            "Artist/Album/kept.wav",
989            "Artist/Album/._kept.wav",
990            "Artist/Album/gone.wav",
991            "Artist/Album/._gone.wav",
992            "Artist/Old/gone.flac",
993            "Artist/Old/._gone.flac",
994            "Artist/._Old",
995            "Orphan/._nothing.wav",
996        ];
997        for f in files {
998            let p = root.join(f);
999            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1000            std::fs::write(&p, b"x").unwrap();
1001        }
1002        let keep = HashSet::from([root.join("Artist/Album/kept.wav")]);
1003        assert_eq!(prune_tree(&root, &keep).unwrap(), 2);
1004        let mut left: Vec<_> = files
1005            .iter()
1006            .filter(|f| root.join(f).exists())
1007            .copied()
1008            .collect();
1009        left.sort();
1010        assert_eq!(left, ["Artist/Album/._kept.wav", "Artist/Album/kept.wav"]);
1011        assert!(!root.join("Artist/Old").exists() && !root.join("Orphan").exists());
1012        std::fs::remove_dir_all(&root).unwrap();
1013    }
1014
1015    /// The system may add `com.apple.provenance` to any file a process
1016    /// creates, so only the named attribute tells whether the copy carried one.
1017    #[cfg(target_os = "macos")]
1018    fn has_xattr(path: &Path, name: &str) -> bool {
1019        let c = std::ffi::CString::new(path.to_str().unwrap()).unwrap();
1020        let name = std::ffi::CString::new(name).unwrap();
1021        unsafe { libc::getxattr(c.as_ptr(), name.as_ptr(), std::ptr::null_mut(), 0, 0, 0) >= 0 }
1022    }
1023
1024    /// Longer than one piece and not a multiple of it; on macOS the source
1025    /// carries an xattr, which must not reach the copy (a `._` file on FAT).
1026    #[test]
1027    fn copy_audio_copies_the_bytes_and_nothing_else() {
1028        let dir = std::env::temp_dir().join(format!("baken-copy-{}", std::process::id()));
1029        std::fs::create_dir_all(&dir).unwrap();
1030        let data: Vec<u8> = (0..(9usize << 20) + 12345)
1031            .map(|i| (i % 251) as u8)
1032            .collect();
1033        let src = dir.join("src.wav");
1034        std::fs::write(&src, &data).unwrap();
1035        #[cfg(target_os = "macos")]
1036        {
1037            let c = std::ffi::CString::new(src.to_str().unwrap()).unwrap();
1038            let name = std::ffi::CString::new("ninja.tyna.test").unwrap();
1039            let r = unsafe {
1040                libc::setxattr(
1041                    c.as_ptr(),
1042                    name.as_ptr(),
1043                    b"1".as_ptr() as *const _,
1044                    1,
1045                    0,
1046                    0,
1047                )
1048            };
1049            assert_eq!(r, 0);
1050            assert!(has_xattr(&src, "ninja.tyna.test"));
1051        }
1052        let dst = dir.join("dst.wav");
1053        assert_eq!(copy_audio(&src, &dst).unwrap(), data.len() as u64);
1054        assert!(std::fs::read(&dst).unwrap() == data);
1055        #[cfg(target_os = "macos")]
1056        assert!(!has_xattr(&dst, "ninja.tyna.test"));
1057        std::fs::write(&src, b"").unwrap();
1058        assert_eq!(copy_audio(&src, &dst).unwrap(), 0);
1059        assert_eq!(std::fs::metadata(&dst).unwrap().len(), 0);
1060        assert!(copy_audio(&dir.join("missing.wav"), &dst).is_err());
1061        std::fs::remove_dir_all(&dir).unwrap();
1062    }
1063
1064    #[test]
1065    fn a_temp_file_goes_with_its_handle() {
1066        let tmp = TempFile::new("mp3");
1067        std::fs::write(&tmp.0, b"x").unwrap();
1068        let path = tmp.0.clone();
1069        assert!(path.is_file());
1070        drop(tmp);
1071        assert!(!path.exists());
1072        assert_ne!(TempFile::new("mp3").0, TempFile::new("mp3").0);
1073    }
1074
1075    /// `--cdjsafe` builds a generated track without `PVBR` frames and sets them
1076    /// once the transcoded file is on the stick; that must equal building with them.
1077    #[test]
1078    fn cdjsafe_pvbr_set_late_equals_pvbr_built_with_frames() {
1079        let mut late = AnlzFile {
1080            header_tail: [0; 16],
1081            sections: vec![generate::assemble::pvbr(None)],
1082        };
1083        rewrite::set_cbr_pvbr(&mut late, 19698);
1084        assert_eq!(
1085            late.sections[0].bytes,
1086            generate::assemble::pvbr(Some(19698)).bytes
1087        );
1088    }
1089
1090    #[test]
1091    fn ahead_hands_out_every_index_once_within_the_window() {
1092        let ahead = Ahead::new(20);
1093        let mut got = Vec::new();
1094        while got.len() < ahead.window {
1095            got.push(ahead.take().unwrap());
1096        }
1097        ahead.written(3);
1098        for _ in 0..3 {
1099            got.push(ahead.take().unwrap());
1100        }
1101        ahead.written(20);
1102        while let Some(i) = ahead.take() {
1103            got.push(i);
1104        }
1105        assert_eq!(got, (0..20).collect::<Vec<_>>());
1106        let stopped = Ahead::new(5);
1107        stopped.stop();
1108        assert_eq!(stopped.take(), None);
1109    }
1110
1111    #[test]
1112    fn measured_values_fill_only_what_the_xml_left_at_zero() {
1113        let wav = with_measured(&track(FILE_TYPE_WAV, 24), &audio(), None);
1114        assert_eq!(
1115            (wav.sample_rate, wav.bitrate, wav.track.total_time),
1116            (44100, 2116, 200)
1117        );
1118        let flac = with_measured(&track(FILE_TYPE_FLAC, 16), &audio(), None);
1119        assert_eq!(flac.bitrate, 1411);
1120
1121        let mp3 = Mp3Audio {
1122            frames: 7656,
1123            bytes: 7656 * 1045,
1124            sample_rate: 44100,
1125        };
1126        assert_eq!(
1127            with_measured(&track(FILE_TYPE_MP3, 16), &audio(), Some(mp3)).bitrate,
1128            320
1129        );
1130        // 8 MB over 200.5 s
1131        assert_eq!(
1132            with_measured(&track(FILE_TYPE_M4A, 16), &audio(), None).bitrate,
1133            319
1134        );
1135
1136        let mut from_xml = track(FILE_TYPE_MP3, 16);
1137        from_xml.sample_rate = 48000;
1138        from_xml.bitrate = 256;
1139        from_xml.track.total_time = 199;
1140        let kept = with_measured(&from_xml, &audio(), Some(mp3));
1141        assert_eq!(
1142            (kept.sample_rate, kept.bitrate, kept.track.total_time),
1143            (48000, 256, 199)
1144        );
1145
1146        let silent = with_measured(&track(FILE_TYPE_FLAC, 16), &Measured::default(), None);
1147        assert_eq!((silent.sample_rate, silent.bitrate), (0, 0));
1148    }
1149}