1pub mod anlz;
9pub mod build;
10pub mod collection;
11mod error;
12pub mod layout;
13pub mod pdb;
14pub mod settings;
15
16pub use error::{Error, Result};
17
18use anlz::generate;
19use anlz::generate::Measured;
20use anlz::hash::AnlzSlots;
21use anlz::locate::{read_optional, AnlzIndex, Entry};
22use anlz::rewrite::{self, FileKind, Mp3Audio};
23use baken_core::{fsname, CancelToken, Progress};
24use build::DeviceTrack;
25use collection::Library;
26use std::collections::HashSet;
27use std::path::{Path, PathBuf};
28
29#[derive(Debug, Clone, Default)]
30pub struct Options {
31 pub xml: PathBuf,
32 pub device: PathBuf,
33 pub playlists: Vec<String>,
35 pub anlz_roots: Vec<PathBuf>,
37 pub settings_dir: Option<PathBuf>,
38 pub no_settings: bool,
40 pub device_name: Option<String>,
42 pub cdjsafe: bool,
44 pub generate_analysis: bool,
47 pub prune: bool,
49}
50
51#[derive(Debug, Clone)]
52pub struct Skipped {
53 pub name: String,
54 pub reason: String,
55}
56
57#[derive(Debug, Clone)]
58pub struct PlanTrack {
59 pub device: DeviceTrack,
60 pub source: PathBuf,
61 pub anlz: Option<Entry>,
63}
64
65#[derive(Debug)]
66pub struct Plan {
67 pub library: Library,
68 pub tracks: Vec<PlanTrack>,
69 pub selected: Vec<usize>,
71 pub skipped: Vec<Skipped>,
72 pub settings_dir: Option<PathBuf>,
74 pub settings_files: Vec<&'static str>,
76 pub anlz_roots: Vec<PathBuf>,
77 pub anlz_files_indexed: usize,
78 pub device: PathBuf,
79 pub volume_root: bool,
82 pub device_name: String,
83 pub cdjsafe: bool,
84 pub prune: bool,
85}
86
87impl Plan {
88 pub fn generated(&self) -> usize {
90 self.tracks.iter().filter(|t| t.anlz.is_none()).count()
91 }
92
93 pub fn without_grid(&self) -> usize {
96 self.tracks
97 .iter()
98 .filter(|t| t.anlz.is_none() && t.device.track.tempos.is_empty())
99 .count()
100 }
101
102 pub fn playlist_names(&self) -> Vec<&str> {
103 self.selected
104 .iter()
105 .map(|&i| self.library.playlists[i].path.as_str())
106 .collect()
107 }
108}
109
110#[derive(Debug, Default)]
111pub struct Report {
112 pub copied: usize,
113 pub kept: usize,
114 pub transcoded: usize,
115 pub anlz_files: usize,
116 pub anlz_unchanged: usize,
118 pub anlz_generated: usize,
120 pub pruned: usize,
121 pub cancelled: bool,
122 pub failures: Vec<(String, String)>,
123 pub tracks_in_database: usize,
124}
125
126pub fn plan(opts: &Options) -> Result<Plan> {
127 if !opts.device.is_dir() {
128 return Err(Error::DeviceNotFound(opts.device.clone()));
129 }
130 let (settings_dir, settings_files) = if opts.no_settings {
131 (None, Vec::new())
132 } else {
133 let dir = settings::locate(opts.settings_dir.as_deref())
134 .map_err(|searched| Error::SettingsNotFound { searched })?;
135 let files = settings::files(&dir)?;
136 (Some(dir), files)
137 };
138
139 let library = Library::load(&opts.xml)?;
140 let selected = select_playlists(&library, &opts.playlists)?;
141
142 let anlz_roots = if opts.anlz_roots.is_empty() {
143 anlz::locate::default_roots()
144 } else {
145 opts.anlz_roots.clone()
146 };
147 if anlz_roots.is_empty() && !opts.generate_analysis {
148 return Err(Error::NoAnlzRoot {
149 searched: anlz_roots,
150 });
151 }
152 let index = AnlzIndex::build(&anlz_roots)?;
153
154 let mut seen = HashSet::new();
155 let mut skipped = Vec::new();
156 let mut tracks = Vec::new();
157 let mut layout = layout::Layout::default();
158 let mut anlz_slots = AnlzSlots::default();
159 for &pi in &selected {
160 for &tid in &library.playlists[pi].track_ids {
161 if !seen.insert(tid) {
162 continue;
163 }
164 let Some(track) = library.track(tid) else {
165 skipped.push(Skipped {
166 name: format!("TrackID {tid}"),
167 reason: "not in the collection".into(),
168 });
169 continue;
170 };
171 let source = PathBuf::from(&track.location);
172 let Ok(meta) = std::fs::metadata(&source) else {
173 skipped.push(Skipped {
174 name: track.name.clone(),
175 reason: format!("source file missing: {}", source.display()),
176 });
177 continue;
178 };
179 let entry = index.find(track);
180 if entry.is_none() && !opts.generate_analysis {
181 skipped.push(Skipped {
182 name: track.name.clone(),
183 reason: "no rekordbox analysis found (analyse it in rekordbox first, or pass --generate-analysis)".into(),
184 });
185 continue;
186 }
187 let mut t = track.clone();
188 if opts.cdjsafe {
189 let stem = t
190 .file_name()
191 .rsplit_once('.')
192 .map(|(s, _)| s.to_string())
193 .unwrap_or_else(|| t.file_name().to_string());
194 t.location = format!(
195 "{}/{stem}.mp3",
196 t.location.rsplit_once('/').map(|(d, _)| d).unwrap_or("")
197 );
198 }
199 let usb_path = layout.assign(&t);
200 let (file_type, bitrate, sample_rate, sample_depth) = if opts.cdjsafe {
201 (pdb::rows::FILE_TYPE_MP3, 320, 44100, 16)
202 } else {
203 (
204 build::file_type_for(&track.kind, track.file_name()),
205 track.bit_rate,
206 track.sample_rate,
207 layout::sample_depth(&source),
208 )
209 };
210 let (anlz_dir, anlz_index) = anlz_slots.assign(&usb_path);
211 tracks.push(PlanTrack {
212 device: DeviceTrack {
213 anlz_dir,
214 anlz_index,
215 usb_path,
216 track: track.clone(),
217 file_size: meta.len(),
218 sample_depth,
219 file_type,
220 bitrate,
221 sample_rate,
222 },
223 source,
224 anlz: entry.cloned(),
225 });
226 }
227 }
228 if tracks.is_empty() {
229 return Err(Error::NothingToExport);
230 }
231 let device_name = opts
232 .device_name
233 .clone()
234 .or_else(|| {
235 opts.device
236 .file_name()
237 .map(|n| n.to_string_lossy().into_owned())
238 })
239 .unwrap_or_else(|| "USB".into());
240 Ok(Plan {
241 library,
242 tracks,
243 selected,
244 skipped,
245 settings_dir,
246 settings_files,
247 anlz_roots,
248 anlz_files_indexed: index.files,
249 device: opts.device.clone(),
250 volume_root: is_volume_root(&opts.device),
251 device_name,
252 cdjsafe: opts.cdjsafe,
253 prune: opts.prune,
254 })
255}
256
257fn select_playlists(library: &Library, names: &[String]) -> Result<Vec<usize>> {
258 let mut out = Vec::new();
259 if names.is_empty() {
260 for (i, p) in library.playlists.iter().enumerate() {
261 if !p.is_folder && p.key_type == "0" {
262 out.push(i);
263 }
264 }
265 } else {
266 for name in names {
267 let name = name.trim().trim_matches('/');
268 let (i, p) = library
269 .playlists
270 .iter()
271 .enumerate()
272 .find(|(_, p)| p.path == name && !p.is_folder)
273 .ok_or_else(|| Error::PlaylistNotFound(name.to_string()))?;
274 if p.key_type != "0" {
275 return Err(Error::UnsupportedPlaylistType {
276 path: p.path.clone(),
277 key_type: p.key_type.clone(),
278 });
279 }
280 if !out.contains(&i) {
281 out.push(i);
282 }
283 }
284 }
285 if out.is_empty() {
286 return Err(Error::NoPlaylists);
287 }
288 Ok(out)
289}
290
291#[cfg(unix)]
292fn is_volume_root(dir: &Path) -> bool {
293 use std::os::unix::fs::MetadataExt;
294 let Ok(dir) = std::fs::canonicalize(dir) else {
295 return true;
296 };
297 let Some(parent) = dir.parent() else {
298 return true;
299 };
300 match (std::fs::metadata(&dir), std::fs::metadata(parent)) {
301 (Ok(d), Ok(p)) => d.dev() != p.dev(),
302 _ => true,
303 }
304}
305
306#[cfg(not(unix))]
308fn is_volume_root(_: &Path) -> bool {
309 true
310}
311
312fn device_path(device: &Path, usb_path: &str) -> PathBuf {
313 device.join(usb_path.trim_start_matches('/'))
314}
315
316pub fn export(plan: &Plan, progress: &dyn Progress, cancel: &CancelToken) -> Result<Report> {
317 let mut report = Report::default();
318 let total = plan.tracks.len();
319 let mut exported: Vec<DeviceTrack> = Vec::with_capacity(total);
320 let mut wanted: HashSet<PathBuf> = HashSet::new();
321
322 let rb_dir = plan.device.join("PIONEER/rekordbox");
325 let probe = rb_dir.join(".baken-write-test");
326 std::fs::create_dir_all(&rb_dir)
327 .and_then(|()| std::fs::write(&probe, b""))
328 .and_then(|()| std::fs::remove_file(&probe))
329 .map_err(|err| Error::DeviceWrite {
330 path: rb_dir.clone(),
331 err,
332 })?;
333
334 for (i, pt) in plan.tracks.iter().enumerate() {
335 if cancel.is_cancelled() {
336 report.cancelled = true;
337 return Ok(report);
338 }
339 match export_track(plan, pt, &mut report) {
340 Ok(mut dt) => {
341 let dest = device_path(&plan.device, &dt.usb_path);
342 dt.file_size = std::fs::metadata(&dest)
343 .map(|m| m.len())
344 .unwrap_or(dt.file_size);
345 wanted.insert(dest);
346 for kind in FileKind::ALL {
347 wanted.insert(device_path(&plan.device, &dt.anlz_path(kind.extension())));
348 }
349 exported.push(dt);
350 }
351 Err(e) => report
352 .failures
353 .push((pt.device.track.name.clone(), e.to_string())),
354 }
355 progress.on_file_done(i + 1, total, &pt.source);
356 }
357
358 let date = build::today();
359 let model = build::build(
360 &plan.library,
361 &exported,
362 &plan.selected,
363 &plan.device_name,
364 &date,
365 );
366 report.tracks_in_database = exported.len();
367 let pdb_path = rb_dir.join("export.pdb");
368 std::fs::write(&pdb_path, pdb::write(&model)).map_err(|err| Error::DeviceWrite {
369 path: pdb_path,
370 err,
371 })?;
372
373 if let Some(dir) = &plan.settings_dir {
374 settings::copy_all(dir, &plan.settings_files, &plan.device)?;
375 }
376
377 if plan.prune {
378 report.pruned += prune_tree(&plan.device.join("Contents"), &wanted)?;
379 report.pruned += prune_tree(&plan.device.join("PIONEER/USBANLZ"), &wanted)?;
380 }
381 if report.copied + report.transcoded > 0 || plan.prune {
384 remove_apple_double(&plan.device.join("Contents"), true)?;
385 }
386 if report.anlz_files > 0 || plan.prune {
387 remove_apple_double(&plan.device.join("PIONEER/USBANLZ"), true)?;
388 }
389 remove_apple_double(&plan.device.join("PIONEER"), false)?;
390 remove_apple_double(&rb_dir, false)?;
391 for dir in ["Contents", "PIONEER"] {
392 match std::fs::remove_file(plan.device.join(format!("._{dir}"))) {
393 Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
394 _ => {}
395 }
396 }
397 Ok(report)
398}
399
400fn export_track(plan: &Plan, pt: &PlanTrack, report: &mut Report) -> anyhow::Result<DeviceTrack> {
401 let dest = device_path(&plan.device, &pt.device.usb_path);
402 if let Some(parent) = dest.parent() {
403 std::fs::create_dir_all(parent)?;
404 }
405 let existing = std::fs::metadata(&dest).ok().map(|m| m.len());
406 if plan.cdjsafe {
407 if existing.is_some() {
408 report.kept += 1;
409 } else if baken_core::cdjsafe::probe(&pt.source)?.is_compatible_mp3() {
410 std::fs::copy(&pt.source, &dest)?;
411 report.copied += 1;
412 } else {
413 baken_core::cdjsafe::transcode(&pt.source, &dest)?;
414 report.transcoded += 1;
415 }
416 } else if existing == Some(pt.device.file_size) {
417 report.kept += 1;
418 } else {
419 std::fs::copy(&pt.source, &dest)?;
420 report.copied += 1;
421 }
422
423 let mp3 = if plan.cdjsafe {
424 Some(rewrite::mp3_audio(&dest)?)
425 } else {
426 None
427 };
428 let anlz_dest = device_path(&plan.device, &pt.device.anlz_dir);
429 std::fs::create_dir_all(&anlz_dest)?;
430 let bpm = pt
431 .device
432 .track
433 .tempos
434 .first()
435 .map(|t| t.bpm)
436 .unwrap_or(pt.device.track.average_bpm);
437 let Some(entry) = &pt.anlz else {
438 let mp3 = match mp3 {
439 Some(m) => Some(m),
440 None if is_mp3(&dest) => Some(rewrite::mp3_audio(&dest)?),
441 None => None,
442 };
443 let audio = generate::measure(&pt.source)?;
444 let files = generate::build_files(
445 &pt.device.track,
446 &pt.device.usb_path,
447 &audio,
448 mp3.map(|m| m.frames),
449 );
450 for (kind, file) in FileKind::ALL.iter().zip(files.iter()) {
451 write_anlz(
452 &device_path(&plan.device, &pt.device.anlz_path(kind.extension())),
453 &file.to_bytes(),
454 report,
455 )?;
456 }
457 report.anlz_generated += 1;
458 return Ok(with_measured(&pt.device, &audio, mp3));
459 };
460 for kind in FileKind::ALL {
461 let Some(mut file) = read_optional(&entry.sibling(kind.extension()))? else {
462 if kind != FileKind::TwoEx {
463 anyhow::bail!(
464 "analysis file .{} missing next to {}",
465 kind.extension(),
466 entry.dat.display()
467 );
468 }
469 continue;
470 };
471 rewrite::prepare(
472 &mut file,
473 kind,
474 &pt.device.usb_path,
475 &pt.device.track.cues,
476 bpm,
477 );
478 if let Some(mp3) = mp3 {
479 match kind {
480 FileKind::Dat => rewrite::set_cbr_pvbr(&mut file, mp3.frames),
481 FileKind::Ext => rewrite::strip_pvb2(&mut file),
482 FileKind::TwoEx => {}
483 }
484 }
485 write_anlz(
486 &device_path(&plan.device, &pt.device.anlz_path(kind.extension())),
487 &file.to_bytes(),
488 report,
489 )?;
490 }
491 Ok(pt.device.clone())
492}
493
494fn write_anlz(path: &Path, bytes: &[u8], report: &mut Report) -> std::io::Result<()> {
498 match std::fs::read(path) {
499 Ok(old) if old == bytes => report.anlz_unchanged += 1,
500 _ => {
501 std::fs::write(path, bytes)?;
502 report.anlz_files += 1;
503 }
504 }
505 Ok(())
506}
507
508fn with_measured(dt: &DeviceTrack, audio: &Measured, mp3: Option<Mp3Audio>) -> DeviceTrack {
513 use pdb::rows::{FILE_TYPE_AIFF, FILE_TYPE_ALAC, FILE_TYPE_FLAC, FILE_TYPE_WAV};
514 let mut dt = dt.clone();
515 let secs = audio.duration_ms() / 1000.0;
516 if audio.sample_rate == 0 || secs <= 0.0 {
517 return dt;
518 }
519 if dt.sample_rate == 0 {
520 dt.sample_rate = audio.sample_rate;
521 }
522 if dt.bitrate == 0 {
523 let lossless = [
524 FILE_TYPE_FLAC,
525 FILE_TYPE_WAV,
526 FILE_TYPE_AIFF,
527 FILE_TYPE_ALAC,
528 ]
529 .contains(&dt.file_type);
530 dt.bitrate = match mp3.and_then(|m| m.kbps()) {
531 Some(kbps) => kbps,
532 None if lossless => {
533 (audio.sample_rate as u64 * dt.sample_depth as u64 * audio.channels as u64 / 1000)
534 as u32
535 }
536 None => (dt.file_size as f64 * 8.0 / secs / 1000.0).round() as u32,
537 };
538 }
539 if dt.track.total_time == 0 {
540 dt.track.total_time = secs as u32;
541 }
542 dt
543}
544
545fn is_mp3(path: &Path) -> bool {
546 path.extension()
547 .and_then(|e| e.to_str())
548 .is_some_and(|e| e.eq_ignore_ascii_case("mp3"))
549}
550
551fn prune_tree(root: &Path, keep: &HashSet<PathBuf>) -> Result<usize> {
556 fn walk(dir: &Path, keep: &HashSet<PathBuf>, removed: &mut usize) -> std::io::Result<bool> {
557 let mut empty = true;
558 for entry in std::fs::read_dir(dir)? {
559 let entry = entry?;
560 let path = entry.path();
561 if entry.file_type()?.is_dir() {
562 if walk(&path, keep, removed)? {
563 fsname::remove_dir(&path)?;
564 } else {
565 empty = false;
566 }
567 } else if keep.contains(&fsname::nfc(&path)) {
568 empty = false;
569 } else {
570 fsname::remove_file(&path)?;
571 *removed += 1;
572 }
573 }
574 Ok(empty)
575 }
576 let keep: HashSet<PathBuf> = keep.iter().map(|p| fsname::nfc(p)).collect();
577 let mut removed = 0;
578 if root.is_dir() {
579 walk(root, &keep, &mut removed)?;
580 }
581 Ok(removed)
582}
583
584fn remove_apple_double(root: &Path, recursive: bool) -> Result<()> {
586 fn walk(dir: &Path, recursive: bool) -> std::io::Result<()> {
587 for entry in std::fs::read_dir(dir)? {
588 let entry = entry?;
589 let path = entry.path();
590 if entry.file_type()?.is_dir() {
591 if recursive {
592 walk(&path, recursive)?;
593 }
594 } else if entry.file_name().to_string_lossy().starts_with("._") {
595 fsname::remove_file(&path)?;
596 }
597 }
598 Ok(())
599 }
600 if root.is_dir() {
601 walk(root, recursive)?;
602 }
603 Ok(())
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609 use pdb::rows::{FILE_TYPE_FLAC, FILE_TYPE_M4A, FILE_TYPE_MP3, FILE_TYPE_WAV};
610
611 fn track(file_type: u16, sample_depth: u16) -> DeviceTrack {
612 DeviceTrack {
613 track: collection::Track::default(),
614 usb_path: String::new(),
615 anlz_dir: String::new(),
616 anlz_index: 0,
617 file_size: 8_000_000,
618 sample_depth,
619 file_type,
620 bitrate: 0,
621 sample_rate: 0,
622 }
623 }
624
625 fn audio() -> Measured {
627 Measured {
628 sample_rate: 44100,
629 channels: 2,
630 frames: 44100 * 401 / 2,
631 ..Default::default()
632 }
633 }
634
635 #[test]
636 fn measured_values_fill_only_what_the_xml_left_at_zero() {
637 let wav = with_measured(&track(FILE_TYPE_WAV, 24), &audio(), None);
638 assert_eq!(
639 (wav.sample_rate, wav.bitrate, wav.track.total_time),
640 (44100, 2116, 200)
641 );
642 let flac = with_measured(&track(FILE_TYPE_FLAC, 16), &audio(), None);
643 assert_eq!(flac.bitrate, 1411);
644
645 let mp3 = Mp3Audio {
646 frames: 7656,
647 bytes: 7656 * 1045,
648 sample_rate: 44100,
649 };
650 assert_eq!(
651 with_measured(&track(FILE_TYPE_MP3, 16), &audio(), Some(mp3)).bitrate,
652 320
653 );
654 assert_eq!(
656 with_measured(&track(FILE_TYPE_M4A, 16), &audio(), None).bitrate,
657 319
658 );
659
660 let mut from_xml = track(FILE_TYPE_MP3, 16);
661 from_xml.sample_rate = 48000;
662 from_xml.bitrate = 256;
663 from_xml.track.total_time = 199;
664 let kept = with_measured(&from_xml, &audio(), Some(mp3));
665 assert_eq!(
666 (kept.sample_rate, kept.bitrate, kept.track.total_time),
667 (48000, 256, 199)
668 );
669
670 let silent = with_measured(&track(FILE_TYPE_FLAC, 16), &Measured::default(), None);
671 assert_eq!((silent.sample_rate, silent.bitrate), (0, 0));
672 }
673}