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
70pub const ONELIBRARY_FILES: [&str; 4] = [
77 "exportLibrary.db",
78 "exportLibrary.db-wal",
79 "exportLibrary.db-shm",
80 "exportExt.pdb",
81];
82
83#[derive(Debug)]
84pub struct Plan {
85 pub library: Library,
86 pub tracks: Vec<PlanTrack>,
87 pub selected: Vec<usize>,
89 pub skipped: Vec<Skipped>,
90 pub settings_dir: Option<PathBuf>,
92 pub settings_files: Vec<&'static str>,
94 pub anlz_roots: Vec<PathBuf>,
95 pub anlz_files_indexed: usize,
96 pub device: PathBuf,
97 pub volume_root: bool,
100 pub filesystem: Option<volume::FileSystem>,
103 pub partition_table: Option<volume::PartitionTable>,
107 pub onelibrary_files: Vec<&'static str>,
111 pub device_name: String,
112 pub cdjsafe: bool,
113 pub prune: bool,
114}
115
116impl Plan {
117 pub fn generated(&self) -> usize {
119 self.tracks.iter().filter(|t| t.anlz.is_none()).count()
120 }
121
122 pub fn without_grid(&self) -> usize {
125 self.tracks
126 .iter()
127 .filter(|t| t.anlz.is_none() && t.device.track.tempos.is_empty())
128 .count()
129 }
130
131 pub fn active_loops(&self) -> usize {
133 self.tracks
134 .iter()
135 .filter(|t| collection::active_loop(&t.device.track.cues).is_some())
136 .count()
137 }
138
139 pub fn active_loop_warnings(&self) -> Vec<collection::ActiveLoopWarning> {
141 self.tracks
142 .iter()
143 .filter_map(|t| collection::ActiveLoopWarning::check(&t.device.track))
144 .collect()
145 }
146
147 pub fn format_warnings(&self) -> Vec<volume::FormatWarning> {
149 volume::warnings(self.filesystem.as_ref(), self.partition_table)
150 }
151
152 pub fn playlist_names(&self) -> Vec<&str> {
153 self.selected
154 .iter()
155 .map(|&i| self.library.playlists[i].path.as_str())
156 .collect()
157 }
158}
159
160#[derive(Debug, Default)]
161pub struct Report {
162 pub copied: usize,
163 pub kept: usize,
164 pub transcoded: usize,
165 pub anlz_files: usize,
166 pub anlz_unchanged: usize,
168 pub anlz_generated: usize,
170 pub pruned: usize,
171 pub apple_double_kept: usize,
175 pub onelibrary_removed: usize,
177 pub onelibrary_kept: usize,
181 pub cancelled: bool,
182 pub failures: Vec<(String, String)>,
183 pub tracks_in_database: usize,
184}
185
186pub fn plan(opts: &Options) -> Result<Plan> {
187 if !opts.device.is_dir() {
188 return Err(Error::DeviceNotFound(opts.device.clone()));
189 }
190 let (settings_dir, settings_files) = if opts.no_settings {
191 (None, Vec::new())
192 } else {
193 let dir = settings::locate(opts.settings_dir.as_deref())
194 .map_err(|searched| Error::SettingsNotFound { searched })?;
195 let files = settings::files(&dir)?;
196 (Some(dir), files)
197 };
198
199 let library = Library::load(&opts.xml)?;
200 let selected = select_playlists(&library, &opts.playlists)?;
201
202 let anlz_roots = if opts.anlz_roots.is_empty() {
203 anlz::locate::default_roots()
204 } else {
205 opts.anlz_roots.clone()
206 };
207 if anlz_roots.is_empty() && !opts.generate_analysis {
208 return Err(Error::NoAnlzRoot {
209 searched: anlz_roots,
210 });
211 }
212 let index = AnlzIndex::build(&anlz_roots)?;
213
214 let mut seen = HashSet::new();
215 let mut skipped = Vec::new();
216 let mut tracks = Vec::new();
217 let mut layout = layout::Layout::default();
218 let mut anlz_slots = AnlzSlots::default();
219 for &pi in &selected {
220 for &tid in &library.playlists[pi].track_ids {
221 if !seen.insert(tid) {
222 continue;
223 }
224 let Some(track) = library.track(tid) else {
225 skipped.push(Skipped {
226 name: format!("TrackID {tid}"),
227 reason: "not in the collection".into(),
228 });
229 continue;
230 };
231 let source = PathBuf::from(&track.location);
232 let Ok(meta) = std::fs::metadata(&source) else {
233 skipped.push(Skipped {
234 name: track.name.clone(),
235 reason: format!("source file missing: {}", source.display()),
236 });
237 continue;
238 };
239 let entry = index.find(track);
240 if entry.is_none() && !opts.generate_analysis {
241 let reason = if index.has_name(track) {
242 "the rekordbox analysis found for this file name does not match the XML's beat grid (export the XML again after changing the grid, or pass --generate-analysis)"
243 } else {
244 "no rekordbox analysis found (analyse it in rekordbox first, or pass --generate-analysis)"
245 };
246 skipped.push(Skipped {
247 name: track.name.clone(),
248 reason: reason.into(),
249 });
250 continue;
251 }
252 let usb_path = if opts.cdjsafe {
253 let mp3 = Path::new(&track.location).with_extension("mp3");
254 layout.assign(&collection::Track {
255 location: mp3.to_string_lossy().into_owned(),
256 ..track.clone()
257 })
258 } else {
259 layout.assign(track)
260 };
261 let (file_type, bitrate, sample_rate, sample_depth) = if opts.cdjsafe {
262 (pdb::rows::FILE_TYPE_MP3, 320, 44100, 16)
263 } else {
264 (
265 build::file_type_for(&track.kind, track.file_name()),
266 track.bit_rate,
267 track.sample_rate,
268 layout::sample_depth(&source),
269 )
270 };
271 let (anlz_dir, anlz_index) = anlz_slots.assign(&usb_path);
272 tracks.push(PlanTrack {
273 device: DeviceTrack {
274 anlz_dir,
275 anlz_index,
276 usb_path,
277 track: track.clone(),
278 file_size: meta.len(),
279 sample_depth,
280 file_type,
281 bitrate,
282 sample_rate,
283 },
284 source,
285 anlz: entry.cloned(),
286 });
287 }
288 }
289 if tracks.is_empty() {
290 return Err(Error::NothingToExport);
291 }
292 let device_name = opts
293 .device_name
294 .clone()
295 .or_else(|| {
296 opts.device
297 .file_name()
298 .map(|n| n.to_string_lossy().into_owned())
299 })
300 .unwrap_or_else(|| "USB".into());
301 let volume_root = is_volume_root(&opts.device);
302 let (filesystem, partition_table) = if volume_root {
303 volume::probe(&opts.device)
304 } else {
305 (None, None)
306 };
307 let rb_dir = opts.device.join("PIONEER/rekordbox");
308 let onelibrary_files = ONELIBRARY_FILES
309 .into_iter()
310 .filter(|f| rb_dir.join(f).symlink_metadata().is_ok())
311 .collect();
312 Ok(Plan {
313 library,
314 tracks,
315 selected,
316 skipped,
317 settings_dir,
318 settings_files,
319 anlz_roots,
320 anlz_files_indexed: index.files,
321 device: opts.device.clone(),
322 volume_root,
323 filesystem,
324 partition_table,
325 onelibrary_files,
326 device_name,
327 cdjsafe: opts.cdjsafe,
328 prune: opts.prune,
329 })
330}
331
332fn select_playlists(library: &Library, names: &[String]) -> Result<Vec<usize>> {
333 let mut out = Vec::new();
334 if names.is_empty() {
335 for (i, p) in library.playlists.iter().enumerate() {
336 if !p.is_folder && p.key_type == "0" {
337 out.push(i);
338 }
339 }
340 } else {
341 for name in names {
342 let name = name.trim().trim_matches('/');
343 let (i, p) = library
344 .playlists
345 .iter()
346 .enumerate()
347 .find(|(_, p)| p.path == name && !p.is_folder)
348 .ok_or_else(|| Error::PlaylistNotFound(name.to_string()))?;
349 if p.key_type != "0" {
350 return Err(Error::UnsupportedPlaylistType {
351 path: p.path.clone(),
352 key_type: p.key_type.clone(),
353 });
354 }
355 if !out.contains(&i) {
356 out.push(i);
357 }
358 }
359 }
360 if out.is_empty() {
361 return Err(Error::NoPlaylists);
362 }
363 Ok(out)
364}
365
366#[cfg(unix)]
367fn is_volume_root(dir: &Path) -> bool {
368 use std::os::unix::fs::MetadataExt;
369 let Ok(dir) = std::fs::canonicalize(dir) else {
370 return true;
371 };
372 let Some(parent) = dir.parent() else {
373 return true;
374 };
375 match (std::fs::metadata(&dir), std::fs::metadata(parent)) {
376 (Ok(d), Ok(p)) => d.dev() != p.dev(),
377 _ => true,
378 }
379}
380
381#[cfg(not(unix))]
383fn is_volume_root(_: &Path) -> bool {
384 true
385}
386
387fn device_path(device: &Path, usb_path: &str) -> PathBuf {
388 device.join(usb_path.trim_start_matches('/'))
389}
390
391pub fn export(plan: &Plan, progress: &dyn Progress, cancel: &CancelToken) -> Result<Report> {
392 let mut report = Report::default();
393 let total = plan.tracks.len();
394 let mut exported: Vec<DeviceTrack> = Vec::with_capacity(total);
395 let mut wanted: HashSet<PathBuf> = HashSet::new();
396
397 let rb_dir = plan.device.join("PIONEER/rekordbox");
400 let probe = rb_dir.join(".baken-write-test");
401 std::fs::create_dir_all(&rb_dir)
402 .and_then(|()| std::fs::write(&probe, b""))
403 .and_then(|()| std::fs::remove_file(&probe))
404 .map_err(|err| Error::DeviceWrite {
405 path: rb_dir.clone(),
406 err,
407 })?;
408
409 let ahead = Ahead::new(total);
410 let (tx, rx) = mpsc::channel::<(usize, anyhow::Result<Prepared>)>();
411 std::thread::scope(|s| {
412 for _ in 0..ahead.workers {
413 let tx = tx.clone();
414 let ahead = &ahead;
415 s.spawn(move || {
416 while let Some(i) = ahead.take() {
417 let prepared = std::panic::catch_unwind(AssertUnwindSafe(|| {
420 prepare(plan, &plan.tracks[i])
421 }))
422 .unwrap_or_else(|_| Err(anyhow::anyhow!("preparing the track panicked")));
423 if tx.send((i, prepared)).is_err() {
424 break;
425 }
426 }
427 });
428 }
429 drop(tx);
430 let _stop = StopOnDrop(&ahead);
431 let mut ready = BTreeMap::new();
432 for (i, pt) in plan.tracks.iter().enumerate() {
433 if cancel.is_cancelled() {
434 report.cancelled = true;
435 break;
436 }
437 let prepared = loop {
438 if let Some(p) = ready.remove(&i) {
439 break p;
440 }
441 let (j, p) = rx.recv().expect("every track is prepared once");
442 ready.insert(j, p);
443 };
444 match prepared.and_then(|p| write_track(plan, pt, p, &mut report)) {
445 Ok(dt) => {
446 wanted.insert(device_path(&plan.device, &dt.usb_path));
447 for kind in FileKind::ALL {
448 wanted.insert(device_path(&plan.device, &dt.anlz_path(kind.extension())));
449 }
450 exported.push(dt);
451 }
452 Err(e) => report
453 .failures
454 .push((pt.device.track.name.clone(), e.to_string())),
455 }
456 progress.on_file_done(i + 1, total, &pt.source);
457 ahead.written(i + 1);
458 }
459 });
460 if report.cancelled {
461 return Ok(report);
462 }
463
464 let date = build::today();
465 let model = build::build(
466 &plan.library,
467 &exported,
468 &plan.selected,
469 &plan.device_name,
470 &date,
471 );
472 report.tracks_in_database = exported.len();
473 let pdb_path = rb_dir.join("export.pdb");
474 std::fs::write(&pdb_path, pdb::write(&model)).map_err(|err| Error::DeviceWrite {
475 path: pdb_path,
476 err,
477 })?;
478 remove_onelibrary(&rb_dir, &mut report);
479
480 if let Some(dir) = &plan.settings_dir {
481 settings::copy_all(dir, &plan.settings_files, &plan.device)?;
482 }
483
484 if plan.prune {
485 report.pruned += prune_tree(&plan.device.join("Contents"), &wanted)?;
486 report.pruned += prune_tree(&plan.device.join("PIONEER/USBANLZ"), &wanted)?;
487 }
488 if report.copied + report.transcoded > 0 || plan.prune {
495 remove_apple_double(&plan.device.join("Contents"), true, &mut report)?;
496 }
497 if report.anlz_files > 0 || plan.prune {
498 remove_apple_double(&plan.device.join("PIONEER/USBANLZ"), true, &mut report)?;
499 }
500 remove_apple_double(&plan.device.join("PIONEER"), false, &mut report)?;
501 remove_apple_double(&rb_dir, false, &mut report)?;
502 for dir in ["Contents", "PIONEER"] {
503 if remove_sidecar(&plan.device.join(format!("._{dir}")))? {
504 report.apple_double_kept += 1;
505 }
506 }
507 Ok(report)
508}
509
510fn remove_onelibrary(rb_dir: &Path, report: &mut Report) {
518 for name in ONELIBRARY_FILES {
519 match fsname::remove_file(&rb_dir.join(name)) {
520 Ok(()) => report.onelibrary_removed += 1,
521 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
522 Err(_) => report.onelibrary_kept += 1,
523 }
524 }
525}
526
527struct Ahead {
534 workers: usize,
535 window: usize,
536 total: usize,
537 state: Mutex<(usize, usize, bool)>, moved: Condvar,
539}
540
541impl Ahead {
542 fn new(total: usize) -> Self {
543 let workers = std::thread::available_parallelism()
544 .map_or(1, |n| n.get())
545 .clamp(1, 2)
546 .min(total.max(1));
547 Ahead {
548 workers,
549 window: workers * 2,
550 total,
551 state: Mutex::new((0, 0, false)),
552 moved: Condvar::new(),
553 }
554 }
555
556 fn take(&self) -> Option<usize> {
557 let mut st = self.state.lock().unwrap();
558 loop {
559 let (next, written, stopped) = *st;
560 if stopped || next >= self.total {
561 return None;
562 }
563 if next < written + self.window {
564 st.0 += 1;
565 return Some(next);
566 }
567 st = self.moved.wait(st).unwrap();
568 }
569 }
570
571 fn written(&self, n: usize) {
572 self.state.lock().unwrap().1 = n;
573 self.moved.notify_all();
574 }
575
576 fn stop(&self) {
577 self.state.lock().unwrap().2 = true;
578 self.moved.notify_all();
579 }
580}
581
582struct StopOnDrop<'a>(&'a Ahead);
584
585impl Drop for StopOnDrop<'_> {
586 fn drop(&mut self) {
587 self.0.stop();
588 }
589}
590
591struct Prepared {
595 files: Vec<(FileKind, AnlzFile)>,
596 device: DeviceTrack,
597 generated: bool,
598 audio: Audio,
599}
600
601enum Audio {
603 Source,
605 Transcoded(TempFile),
607 OnStick,
609}
610
611struct TempFile(PathBuf);
614
615impl TempFile {
616 fn new(ext: &str) -> Self {
617 static NEXT: AtomicU64 = AtomicU64::new(0);
618 TempFile(std::env::temp_dir().join(format!(
619 "baken-expressport-{}-{}.{ext}",
620 std::process::id(),
621 NEXT.fetch_add(1, Ordering::Relaxed)
622 )))
623 }
624}
625
626impl Drop for TempFile {
627 fn drop(&mut self) {
628 let _ = std::fs::remove_file(&self.0);
629 }
630}
631
632fn prepare(plan: &Plan, pt: &PlanTrack) -> anyhow::Result<Prepared> {
633 let audio = if plan.cdjsafe {
634 cdjsafe_audio(plan, pt)?
635 } else {
636 Audio::Source
637 };
638 let mut prepared = prepare_analysis(plan, pt)?;
639 if plan.cdjsafe {
640 let mp3 = match &audio {
642 Audio::Source => pt.source.clone(),
643 Audio::Transcoded(tmp) => tmp.0.clone(),
644 Audio::OnStick => device_path(&plan.device, &pt.device.usb_path),
645 };
646 let frames = rewrite::mp3_audio(&mp3)?.frames;
647 for (kind, file) in &mut prepared.files {
648 match kind {
649 FileKind::Dat => rewrite::set_cbr_pvbr(file, frames),
650 FileKind::Ext => file.remove(b"PVB2"),
652 FileKind::TwoEx => {}
653 }
654 }
655 }
656 prepared.audio = audio;
657 Ok(prepared)
658}
659
660fn cdjsafe_audio(plan: &Plan, pt: &PlanTrack) -> anyhow::Result<Audio> {
665 if device_path(&plan.device, &pt.device.usb_path).is_file() {
666 return Ok(Audio::OnStick);
667 }
668 if baken_core::cdjsafe::probe(&pt.source)?.is_compatible_mp3() {
669 return Ok(Audio::Source);
670 }
671 let tmp = TempFile::new("mp3");
672 baken_core::cdjsafe::transcode(&pt.source, &tmp.0)?;
673 Ok(Audio::Transcoded(tmp))
674}
675
676fn prepare_analysis(plan: &Plan, pt: &PlanTrack) -> anyhow::Result<Prepared> {
679 let Some(entry) = &pt.anlz else {
680 let mp3 = if pt.device.file_type == pdb::rows::FILE_TYPE_MP3 && !plan.cdjsafe {
681 Some(rewrite::mp3_audio(&pt.source)?)
682 } else {
683 None
684 };
685 let audio = generate::measure(&pt.source)?;
686 let files = generate::build_files(
687 &pt.device.track,
688 &pt.device.usb_path,
689 &audio,
690 mp3.map(|m| m.frames),
691 );
692 return Ok(Prepared {
693 files: FileKind::ALL.into_iter().zip(files).collect(),
694 device: with_measured(&pt.device, &audio, mp3),
695 generated: true,
696 audio: Audio::Source,
697 });
698 };
699 let mut files = Vec::new();
700 for kind in FileKind::ALL {
701 let Some(mut file) = read_optional(&entry.sibling(kind.extension()))? else {
702 if kind != FileKind::TwoEx {
703 anyhow::bail!(
704 "analysis file .{} missing next to {}",
705 kind.extension(),
706 entry.dat.display()
707 );
708 }
709 continue;
710 };
711 rewrite::prepare(
712 &mut file,
713 kind,
714 &pt.device.usb_path,
715 &pt.device.track.cues,
716 pt.device.track.grid_bpm(),
717 );
718 files.push((kind, file));
719 }
720 Ok(Prepared {
721 files,
722 device: pt.device.clone(),
723 generated: false,
724 audio: Audio::Source,
725 })
726}
727
728fn write_track(
731 plan: &Plan,
732 pt: &PlanTrack,
733 mut prepared: Prepared,
734 report: &mut Report,
735) -> anyhow::Result<DeviceTrack> {
736 let dest = device_path(&plan.device, &pt.device.usb_path);
737 if let Some(parent) = dest.parent() {
738 std::fs::create_dir_all(parent)?;
739 }
740 let existing = std::fs::metadata(&dest).ok().map(|m| m.len());
741 prepared.device.file_size = match (&prepared.audio, existing) {
742 (Audio::OnStick, Some(size)) => {
743 report.kept += 1;
744 size
745 }
746 (Audio::OnStick, None) => {
747 anyhow::bail!("{} disappeared from the stick", dest.display())
748 }
749 (Audio::Transcoded(mp3), _) => {
750 let size = copy_audio(&mp3.0, &dest)?;
751 report.transcoded += 1;
752 size
753 }
754 (Audio::Source, Some(size)) if !plan.cdjsafe && size == pt.device.file_size => {
755 report.kept += 1;
756 size
757 }
758 (Audio::Source, _) => {
759 let size = copy_audio(&pt.source, &dest)?;
760 report.copied += 1;
761 size
762 }
763 };
764 std::fs::create_dir_all(device_path(&plan.device, &pt.device.anlz_dir))?;
765 for (kind, file) in &prepared.files {
766 write_anlz(
767 &device_path(&plan.device, &pt.device.anlz_path(kind.extension())),
768 &file.to_bytes(),
769 report,
770 )?;
771 }
772 if prepared.generated {
773 report.anlz_generated += 1;
774 }
775 Ok(prepared.device)
776}
777
778fn write_anlz(path: &Path, bytes: &[u8], report: &mut Report) -> std::io::Result<()> {
782 match std::fs::read(path) {
783 Ok(old) if old == bytes => report.anlz_unchanged += 1,
784 _ => {
785 std::fs::write(path, bytes)?;
786 report.anlz_files += 1;
787 }
788 }
789 Ok(())
790}
791
792fn copy_audio(src: &Path, dst: &Path) -> std::io::Result<u64> {
799 use std::io::{Read, Write};
800 const PIECE: usize = 4 << 20;
801 let mut reader = std::fs::File::open(src)?;
802 let mut writer = std::fs::File::create(dst)?;
803 let (tx, rx) = mpsc::sync_channel::<std::io::Result<Vec<u8>>>(3);
804 std::thread::scope(|s| {
805 s.spawn(move || loop {
806 let mut piece = vec![0u8; PIECE];
807 let sent = match reader.read(&mut piece) {
808 Ok(0) => break,
809 Ok(n) => {
810 piece.truncate(n);
811 tx.send(Ok(piece))
812 }
813 Err(e) => {
814 let _ = tx.send(Err(e));
815 break;
816 }
817 };
818 if sent.is_err() {
819 break;
820 }
821 });
822 let mut written = 0u64;
823 for piece in rx {
824 let piece = piece?;
825 writer.write_all(&piece)?;
826 written += piece.len() as u64;
827 }
828 Ok(written)
829 })
830}
831
832fn with_measured(dt: &DeviceTrack, audio: &Measured, mp3: Option<Mp3Audio>) -> DeviceTrack {
837 use pdb::rows::{FILE_TYPE_AIFF, FILE_TYPE_ALAC, FILE_TYPE_FLAC, FILE_TYPE_WAV};
838 let mut dt = dt.clone();
839 let secs = audio.duration_ms() / 1000.0;
840 if audio.sample_rate == 0 || secs <= 0.0 {
841 return dt;
842 }
843 if dt.sample_rate == 0 {
844 dt.sample_rate = audio.sample_rate;
845 }
846 if dt.bitrate == 0 {
847 let lossless = [
848 FILE_TYPE_FLAC,
849 FILE_TYPE_WAV,
850 FILE_TYPE_AIFF,
851 FILE_TYPE_ALAC,
852 ]
853 .contains(&dt.file_type);
854 dt.bitrate = match mp3.and_then(|m| m.kbps()) {
855 Some(kbps) => kbps,
856 None if lossless => {
857 (audio.sample_rate as u64 * dt.sample_depth as u64 * audio.channels as u64 / 1000)
858 as u32
859 }
860 None => (dt.file_size as f64 * 8.0 / secs / 1000.0).round() as u32,
861 };
862 }
863 if dt.track.total_time == 0 {
864 dt.track.total_time = secs as u32;
865 }
866 dt
867}
868
869fn prune_tree(root: &Path, keep: &HashSet<PathBuf>) -> Result<usize> {
878 fn walk(dir: &Path, keep: &HashSet<PathBuf>, removed: &mut usize) -> std::io::Result<bool> {
879 let mut empty = true;
880 let mut sidecars = Vec::new();
881 for entry in std::fs::read_dir(dir)? {
882 let entry = entry?;
883 let path = entry.path();
884 if entry.file_type()?.is_dir() {
885 if walk(&path, keep, removed)? {
886 fsname::remove_dir(&path)?;
887 } else {
888 empty = false;
889 }
890 } else if let Some(name) = entry.file_name().to_string_lossy().strip_prefix("._") {
891 sidecars.push((path.clone(), dir.join(name)));
892 } else if keep.contains(&fsname::nfc(&path)) {
893 empty = false;
894 } else {
895 fsname::remove_file(&path)?;
896 *removed += 1;
897 }
898 }
899 for (sidecar, of) in sidecars {
900 if of.symlink_metadata().is_ok() || remove_sidecar(&sidecar)? {
901 empty = false;
902 }
903 }
904 Ok(empty)
905 }
906 let keep: HashSet<PathBuf> = keep.iter().map(|p| fsname::nfc(p)).collect();
907 let mut removed = 0;
908 if root.is_dir() {
909 walk(root, &keep, &mut removed)?;
910 }
911 Ok(removed)
912}
913
914fn remove_apple_double(root: &Path, recursive: bool, report: &mut Report) -> Result<()> {
917 fn walk(dir: &Path, recursive: bool, kept: &mut usize) -> std::io::Result<()> {
918 for entry in std::fs::read_dir(dir)? {
919 let entry = entry?;
920 let path = entry.path();
921 if entry.file_type()?.is_dir() {
922 if recursive {
923 walk(&path, recursive, kept)?;
924 }
925 } else if entry.file_name().to_string_lossy().starts_with("._")
926 && remove_sidecar(&path)?
927 {
928 *kept += 1;
929 }
930 }
931 Ok(())
932 }
933 if root.is_dir() {
934 walk(root, recursive, &mut report.apple_double_kept)?;
935 }
936 Ok(())
937}
938
939fn remove_sidecar(path: &Path) -> std::io::Result<bool> {
944 match fsname::remove_file(path) {
945 Ok(()) => Ok(false),
946 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
947 Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => Ok(true),
948 Err(e) => Err(e),
949 }
950}
951
952#[cfg(test)]
953mod tests {
954 use super::*;
955 use pdb::rows::{FILE_TYPE_FLAC, FILE_TYPE_M4A, FILE_TYPE_MP3, FILE_TYPE_WAV};
956
957 fn track(file_type: u16, sample_depth: u16) -> DeviceTrack {
958 DeviceTrack {
959 track: collection::Track::default(),
960 usb_path: String::new(),
961 anlz_dir: String::new(),
962 anlz_index: 0,
963 file_size: 8_000_000,
964 sample_depth,
965 file_type,
966 bitrate: 0,
967 sample_rate: 0,
968 }
969 }
970
971 fn audio() -> Measured {
973 Measured {
974 sample_rate: 44100,
975 channels: 2,
976 frames: 44100 * 401 / 2,
977 ..Default::default()
978 }
979 }
980
981 #[test]
984 fn prune_decides_a_sidecar_by_its_file() {
985 let root = std::env::temp_dir().join(format!("baken-prune-{}", std::process::id()));
986 let _ = std::fs::remove_dir_all(&root);
987 let files = [
988 "Artist/Album/kept.wav",
989 "Artist/Album/._kept.wav",
990 "Artist/Album/gone.wav",
991 "Artist/Album/._gone.wav",
992 "Artist/Old/gone.flac",
993 "Artist/Old/._gone.flac",
994 "Artist/._Old",
995 "Orphan/._nothing.wav",
996 ];
997 for f in files {
998 let p = root.join(f);
999 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1000 std::fs::write(&p, b"x").unwrap();
1001 }
1002 let keep = HashSet::from([root.join("Artist/Album/kept.wav")]);
1003 assert_eq!(prune_tree(&root, &keep).unwrap(), 2);
1004 let mut left: Vec<_> = files
1005 .iter()
1006 .filter(|f| root.join(f).exists())
1007 .copied()
1008 .collect();
1009 left.sort();
1010 assert_eq!(left, ["Artist/Album/._kept.wav", "Artist/Album/kept.wav"]);
1011 assert!(!root.join("Artist/Old").exists() && !root.join("Orphan").exists());
1012 std::fs::remove_dir_all(&root).unwrap();
1013 }
1014
1015 #[cfg(target_os = "macos")]
1018 fn has_xattr(path: &Path, name: &str) -> bool {
1019 let c = std::ffi::CString::new(path.to_str().unwrap()).unwrap();
1020 let name = std::ffi::CString::new(name).unwrap();
1021 unsafe { libc::getxattr(c.as_ptr(), name.as_ptr(), std::ptr::null_mut(), 0, 0, 0) >= 0 }
1022 }
1023
1024 #[test]
1027 fn copy_audio_copies_the_bytes_and_nothing_else() {
1028 let dir = std::env::temp_dir().join(format!("baken-copy-{}", std::process::id()));
1029 std::fs::create_dir_all(&dir).unwrap();
1030 let data: Vec<u8> = (0..(9usize << 20) + 12345)
1031 .map(|i| (i % 251) as u8)
1032 .collect();
1033 let src = dir.join("src.wav");
1034 std::fs::write(&src, &data).unwrap();
1035 #[cfg(target_os = "macos")]
1036 {
1037 let c = std::ffi::CString::new(src.to_str().unwrap()).unwrap();
1038 let name = std::ffi::CString::new("ninja.tyna.test").unwrap();
1039 let r = unsafe {
1040 libc::setxattr(
1041 c.as_ptr(),
1042 name.as_ptr(),
1043 b"1".as_ptr() as *const _,
1044 1,
1045 0,
1046 0,
1047 )
1048 };
1049 assert_eq!(r, 0);
1050 assert!(has_xattr(&src, "ninja.tyna.test"));
1051 }
1052 let dst = dir.join("dst.wav");
1053 assert_eq!(copy_audio(&src, &dst).unwrap(), data.len() as u64);
1054 assert!(std::fs::read(&dst).unwrap() == data);
1055 #[cfg(target_os = "macos")]
1056 assert!(!has_xattr(&dst, "ninja.tyna.test"));
1057 std::fs::write(&src, b"").unwrap();
1058 assert_eq!(copy_audio(&src, &dst).unwrap(), 0);
1059 assert_eq!(std::fs::metadata(&dst).unwrap().len(), 0);
1060 assert!(copy_audio(&dir.join("missing.wav"), &dst).is_err());
1061 std::fs::remove_dir_all(&dir).unwrap();
1062 }
1063
1064 #[test]
1065 fn a_temp_file_goes_with_its_handle() {
1066 let tmp = TempFile::new("mp3");
1067 std::fs::write(&tmp.0, b"x").unwrap();
1068 let path = tmp.0.clone();
1069 assert!(path.is_file());
1070 drop(tmp);
1071 assert!(!path.exists());
1072 assert_ne!(TempFile::new("mp3").0, TempFile::new("mp3").0);
1073 }
1074
1075 #[test]
1078 fn cdjsafe_pvbr_set_late_equals_pvbr_built_with_frames() {
1079 let mut late = AnlzFile {
1080 header_tail: [0; 16],
1081 sections: vec![generate::assemble::pvbr(None)],
1082 };
1083 rewrite::set_cbr_pvbr(&mut late, 19698);
1084 assert_eq!(
1085 late.sections[0].bytes,
1086 generate::assemble::pvbr(Some(19698)).bytes
1087 );
1088 }
1089
1090 #[test]
1091 fn ahead_hands_out_every_index_once_within_the_window() {
1092 let ahead = Ahead::new(20);
1093 let mut got = Vec::new();
1094 while got.len() < ahead.window {
1095 got.push(ahead.take().unwrap());
1096 }
1097 ahead.written(3);
1098 for _ in 0..3 {
1099 got.push(ahead.take().unwrap());
1100 }
1101 ahead.written(20);
1102 while let Some(i) = ahead.take() {
1103 got.push(i);
1104 }
1105 assert_eq!(got, (0..20).collect::<Vec<_>>());
1106 let stopped = Ahead::new(5);
1107 stopped.stop();
1108 assert_eq!(stopped.take(), None);
1109 }
1110
1111 #[test]
1112 fn measured_values_fill_only_what_the_xml_left_at_zero() {
1113 let wav = with_measured(&track(FILE_TYPE_WAV, 24), &audio(), None);
1114 assert_eq!(
1115 (wav.sample_rate, wav.bitrate, wav.track.total_time),
1116 (44100, 2116, 200)
1117 );
1118 let flac = with_measured(&track(FILE_TYPE_FLAC, 16), &audio(), None);
1119 assert_eq!(flac.bitrate, 1411);
1120
1121 let mp3 = Mp3Audio {
1122 frames: 7656,
1123 bytes: 7656 * 1045,
1124 sample_rate: 44100,
1125 };
1126 assert_eq!(
1127 with_measured(&track(FILE_TYPE_MP3, 16), &audio(), Some(mp3)).bitrate,
1128 320
1129 );
1130 assert_eq!(
1132 with_measured(&track(FILE_TYPE_M4A, 16), &audio(), None).bitrate,
1133 319
1134 );
1135
1136 let mut from_xml = track(FILE_TYPE_MP3, 16);
1137 from_xml.sample_rate = 48000;
1138 from_xml.bitrate = 256;
1139 from_xml.track.total_time = 199;
1140 let kept = with_measured(&from_xml, &audio(), Some(mp3));
1141 assert_eq!(
1142 (kept.sample_rate, kept.bitrate, kept.track.total_time),
1143 (48000, 256, 199)
1144 );
1145
1146 let silent = with_measured(&track(FILE_TYPE_FLAC, 16), &Measured::default(), None);
1147 assert_eq!((silent.sample_rate, silent.bitrate), (0, 0));
1148 }
1149}