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