1pub 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 pub playlists: Vec<String>,
40 pub anlz_roots: Vec<PathBuf>,
42 pub settings_dir: Option<PathBuf>,
43 pub no_settings: bool,
45 pub device_name: Option<String>,
47 pub cdjsafe: bool,
49 pub generate_analysis: bool,
52 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 pub anlz: Option<Entry>,
68}
69
70#[derive(Debug)]
71pub struct Plan {
72 pub library: Library,
73 pub tracks: Vec<PlanTrack>,
74 pub selected: Vec<usize>,
76 pub skipped: Vec<Skipped>,
77 pub settings_dir: Option<PathBuf>,
79 pub settings_files: Vec<&'static str>,
81 pub anlz_roots: Vec<PathBuf>,
82 pub anlz_files_indexed: usize,
83 pub device: PathBuf,
84 pub volume_root: bool,
87 pub filesystem: Option<volume::FileSystem>,
90 pub partition_table: Option<volume::PartitionTable>,
94 pub device_name: String,
95 pub cdjsafe: bool,
96 pub prune: bool,
97}
98
99impl Plan {
100 pub fn generated(&self) -> usize {
102 self.tracks.iter().filter(|t| t.anlz.is_none()).count()
103 }
104
105 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 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 pub anlz_unchanged: usize,
135 pub anlz_generated: usize,
137 pub pruned: usize,
138 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#[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 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 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 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
472struct Ahead {
479 workers: usize,
480 window: usize,
481 total: usize,
482 state: Mutex<(usize, usize, bool)>, 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
527struct StopOnDrop<'a>(&'a Ahead);
529
530impl Drop for StopOnDrop<'_> {
531 fn drop(&mut self) {
532 self.0.stop();
533 }
534}
535
536struct Prepared {
540 files: Vec<(FileKind, AnlzFile)>,
541 device: DeviceTrack,
542 generated: bool,
543 audio: Audio,
544}
545
546enum Audio {
548 Source,
550 Transcoded(TempFile),
552 OnStick,
554}
555
556struct 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 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
604fn 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
620fn 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
679fn 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
720fn 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
734fn 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
774fn 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
817fn 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
862fn 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
887fn 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 fn audio() -> Measured {
921 Measured {
922 sample_rate: 44100,
923 channels: 2,
924 frames: 44100 * 401 / 2,
925 ..Default::default()
926 }
927 }
928
929 #[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 #[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 #[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 #[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 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}