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