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::{mpsc, Condvar, Mutex};
32
33#[derive(Debug, Clone, Default)]
34pub struct Options {
35 pub xml: PathBuf,
36 pub device: PathBuf,
37 pub playlists: Vec<String>,
39 pub anlz_roots: Vec<PathBuf>,
41 pub settings_dir: Option<PathBuf>,
42 pub no_settings: bool,
44 pub device_name: Option<String>,
46 pub cdjsafe: bool,
48 pub generate_analysis: bool,
51 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 pub anlz: Option<Entry>,
67}
68
69#[derive(Debug)]
70pub struct Plan {
71 pub library: Library,
72 pub tracks: Vec<PlanTrack>,
73 pub selected: Vec<usize>,
75 pub skipped: Vec<Skipped>,
76 pub settings_dir: Option<PathBuf>,
78 pub settings_files: Vec<&'static str>,
80 pub anlz_roots: Vec<PathBuf>,
81 pub anlz_files_indexed: usize,
82 pub device: PathBuf,
83 pub volume_root: bool,
86 pub filesystem: Option<volume::FileSystem>,
89 pub partition_table: Option<volume::PartitionTable>,
93 pub device_name: String,
94 pub cdjsafe: bool,
95 pub prune: bool,
96}
97
98impl Plan {
99 pub fn generated(&self) -> usize {
101 self.tracks.iter().filter(|t| t.anlz.is_none()).count()
102 }
103
104 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 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 pub anlz_unchanged: usize,
134 pub anlz_generated: usize,
136 pub pruned: usize,
137 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#[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 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 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 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
467struct Ahead {
474 workers: usize,
475 window: usize,
476 total: usize,
477 state: Mutex<(usize, usize, bool)>, 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
522struct StopOnDrop<'a>(&'a Ahead);
524
525impl Drop for StopOnDrop<'_> {
526 fn drop(&mut self) {
527 self.0.stop();
528 }
529}
530
531struct 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 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
595fn 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
648fn 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
662fn 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
705fn 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
750fn 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
775fn 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 fn audio() -> Measured {
809 Measured {
810 sample_rate: 44100,
811 channels: 2,
812 frames: 44100 * 401 / 2,
813 ..Default::default()
814 }
815 }
816
817 #[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 #[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 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}