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::hash::anlz_dir;
20use anlz::locate::{read_optional, AnlzIndex, Entry};
21use anlz::rewrite::{self, FileKind};
22use baken_core::{CancelToken, Progress};
23use build::DeviceTrack;
24use collection::Library;
25use std::collections::HashSet;
26use std::path::{Path, PathBuf};
27
28#[derive(Debug, Clone, Default)]
29pub struct Options {
30 pub xml: PathBuf,
31 pub device: PathBuf,
32 pub playlists: Vec<String>,
34 pub anlz_roots: Vec<PathBuf>,
36 pub settings_dir: Option<PathBuf>,
37 pub device_name: Option<String>,
39 pub cdjsafe: bool,
41 pub generate_analysis: bool,
44 pub prune: bool,
46}
47
48#[derive(Debug, Clone)]
49pub struct Skipped {
50 pub name: String,
51 pub reason: String,
52}
53
54#[derive(Debug, Clone)]
55pub struct PlanTrack {
56 pub device: DeviceTrack,
57 pub source: PathBuf,
58 pub anlz: Option<Entry>,
60}
61
62#[derive(Debug)]
63pub struct Plan {
64 pub library: Library,
65 pub tracks: Vec<PlanTrack>,
66 pub selected: Vec<usize>,
68 pub skipped: Vec<Skipped>,
69 pub settings_dir: PathBuf,
70 pub anlz_roots: Vec<PathBuf>,
71 pub anlz_files_indexed: usize,
72 pub device: PathBuf,
73 pub device_name: String,
74 pub cdjsafe: bool,
75 pub prune: bool,
76}
77
78impl Plan {
79 pub fn generated(&self) -> usize {
81 self.tracks.iter().filter(|t| t.anlz.is_none()).count()
82 }
83
84 pub fn playlist_names(&self) -> Vec<&str> {
85 self.selected
86 .iter()
87 .map(|&i| self.library.playlists[i].path.as_str())
88 .collect()
89 }
90}
91
92#[derive(Debug, Default)]
93pub struct Report {
94 pub copied: usize,
95 pub kept: usize,
96 pub transcoded: usize,
97 pub anlz_files: usize,
98 pub anlz_generated: usize,
100 pub pruned: usize,
101 pub cancelled: bool,
102 pub failures: Vec<(String, String)>,
103 pub tracks_in_database: usize,
104}
105
106pub fn plan(opts: &Options) -> Result<Plan> {
107 if !opts.device.is_dir() {
108 return Err(Error::DeviceNotFound(opts.device.clone()));
109 }
110 let settings_dir = settings::locate(opts.settings_dir.as_deref())
111 .map_err(|searched| Error::SettingsNotFound { searched })?;
112
113 let library = Library::load(&opts.xml)?;
114 let selected = select_playlists(&library, &opts.playlists)?;
115
116 let anlz_roots = if opts.anlz_roots.is_empty() {
117 anlz::locate::default_roots()
118 } else {
119 opts.anlz_roots.clone()
120 };
121 if anlz_roots.is_empty() && !opts.generate_analysis {
122 return Err(Error::NoAnlzRoot {
123 searched: anlz_roots,
124 });
125 }
126 let index = AnlzIndex::build(&anlz_roots)?;
127
128 let mut seen = HashSet::new();
129 let mut skipped = Vec::new();
130 let mut tracks = Vec::new();
131 let mut layout = layout::Layout::default();
132 for &pi in &selected {
133 for &tid in &library.playlists[pi].track_ids {
134 if !seen.insert(tid) {
135 continue;
136 }
137 let Some(track) = library.track(tid) else {
138 skipped.push(Skipped {
139 name: format!("TrackID {tid}"),
140 reason: "not in the collection".into(),
141 });
142 continue;
143 };
144 let source = PathBuf::from(&track.location);
145 let Ok(meta) = std::fs::metadata(&source) else {
146 skipped.push(Skipped {
147 name: track.name.clone(),
148 reason: format!("source file missing: {}", source.display()),
149 });
150 continue;
151 };
152 let entry = index.find(track);
153 if entry.is_none() && !opts.generate_analysis {
154 skipped.push(Skipped {
155 name: track.name.clone(),
156 reason: "no rekordbox analysis found (analyse it in rekordbox first, or pass --generate-analysis)".into(),
157 });
158 continue;
159 }
160 let mut t = track.clone();
161 if opts.cdjsafe {
162 let stem = t
163 .file_name()
164 .rsplit_once('.')
165 .map(|(s, _)| s.to_string())
166 .unwrap_or_else(|| t.file_name().to_string());
167 t.location = format!(
168 "{}/{stem}.mp3",
169 t.location.rsplit_once('/').map(|(d, _)| d).unwrap_or("")
170 );
171 }
172 let usb_path = layout.assign(&t);
173 let (file_type, bitrate, sample_rate, sample_depth) = if opts.cdjsafe {
174 (pdb::rows::FILE_TYPE_MP3, 320, 44100, 16)
175 } else {
176 (
177 build::file_type_for(&track.kind, track.file_name()),
178 track.bit_rate,
179 track.sample_rate,
180 layout::sample_depth(&source),
181 )
182 };
183 tracks.push(PlanTrack {
184 device: DeviceTrack {
185 anlz_dir: anlz_dir(&usb_path),
186 usb_path,
187 track: track.clone(),
188 file_size: meta.len(),
189 sample_depth,
190 file_type,
191 bitrate,
192 sample_rate,
193 },
194 source,
195 anlz: entry.cloned(),
196 });
197 }
198 }
199 if tracks.is_empty() {
200 return Err(Error::NothingToExport);
201 }
202 let device_name = opts
203 .device_name
204 .clone()
205 .or_else(|| {
206 opts.device
207 .file_name()
208 .map(|n| n.to_string_lossy().into_owned())
209 })
210 .unwrap_or_else(|| "USB".into());
211 Ok(Plan {
212 library,
213 tracks,
214 selected,
215 skipped,
216 settings_dir,
217 anlz_roots,
218 anlz_files_indexed: index.files,
219 device: opts.device.clone(),
220 device_name,
221 cdjsafe: opts.cdjsafe,
222 prune: opts.prune,
223 })
224}
225
226fn select_playlists(library: &Library, names: &[String]) -> Result<Vec<usize>> {
227 let mut out = Vec::new();
228 if names.is_empty() {
229 for (i, p) in library.playlists.iter().enumerate() {
230 if !p.is_folder && p.key_type == "0" {
231 out.push(i);
232 }
233 }
234 } else {
235 for name in names {
236 let name = name.trim().trim_matches('/');
237 let (i, p) = library
238 .playlists
239 .iter()
240 .enumerate()
241 .find(|(_, p)| p.path == name && !p.is_folder)
242 .ok_or_else(|| Error::PlaylistNotFound(name.to_string()))?;
243 if p.key_type != "0" {
244 return Err(Error::UnsupportedPlaylistType {
245 path: p.path.clone(),
246 key_type: p.key_type.clone(),
247 });
248 }
249 if !out.contains(&i) {
250 out.push(i);
251 }
252 }
253 }
254 if out.is_empty() {
255 return Err(Error::NoPlaylists);
256 }
257 Ok(out)
258}
259
260fn device_path(device: &Path, usb_path: &str) -> PathBuf {
261 device.join(usb_path.trim_start_matches('/'))
262}
263
264pub fn export(plan: &Plan, progress: &dyn Progress, cancel: &CancelToken) -> Result<Report> {
265 let mut report = Report::default();
266 let total = plan.tracks.len();
267 let mut exported: Vec<DeviceTrack> = Vec::with_capacity(total);
268 let mut wanted: HashSet<PathBuf> = HashSet::new();
269
270 for (i, pt) in plan.tracks.iter().enumerate() {
271 if cancel.is_cancelled() {
272 report.cancelled = true;
273 return Ok(report);
274 }
275 match export_track(plan, pt, &mut report) {
276 Ok(mut dt) => {
277 let dest = device_path(&plan.device, &dt.usb_path);
278 dt.file_size = std::fs::metadata(&dest)
279 .map(|m| m.len())
280 .unwrap_or(dt.file_size);
281 wanted.insert(dest);
282 for kind in FileKind::ALL {
283 wanted.insert(device_path(
284 &plan.device,
285 &format!("{}/ANLZ0000.{}", dt.anlz_dir, kind.extension()),
286 ));
287 }
288 exported.push(dt);
289 }
290 Err(e) => report
291 .failures
292 .push((pt.device.track.name.clone(), e.to_string())),
293 }
294 progress.on_file_done(i + 1, total, &pt.source);
295 }
296
297 let date = build::today();
298 let model = build::build(
299 &plan.library,
300 &exported,
301 &plan.selected,
302 &plan.device_name,
303 &date,
304 );
305 report.tracks_in_database = exported.len();
306 let rb_dir = plan.device.join("PIONEER/rekordbox");
307 std::fs::create_dir_all(&rb_dir)?;
308 std::fs::write(rb_dir.join("export.pdb"), pdb::write(&model))?;
309
310 settings::copy_all(&plan.settings_dir, &plan.device)?;
311
312 if plan.prune {
313 report.pruned += prune_tree(&plan.device.join("Contents"), &wanted)?;
314 report.pruned += prune_tree(&plan.device.join("PIONEER/USBANLZ"), &wanted)?;
315 }
316 remove_apple_double(&plan.device.join("Contents"))?;
317 remove_apple_double(&plan.device.join("PIONEER"))?;
318 Ok(report)
319}
320
321fn export_track(plan: &Plan, pt: &PlanTrack, report: &mut Report) -> anyhow::Result<DeviceTrack> {
322 let dest = device_path(&plan.device, &pt.device.usb_path);
323 if let Some(parent) = dest.parent() {
324 std::fs::create_dir_all(parent)?;
325 }
326 let existing = std::fs::metadata(&dest).ok().map(|m| m.len());
327 if plan.cdjsafe {
328 if existing.is_some() {
329 report.kept += 1;
330 } else if baken_core::cdjsafe::probe(&pt.source)?.is_compatible_mp3() {
331 std::fs::copy(&pt.source, &dest)?;
332 report.copied += 1;
333 } else {
334 baken_core::cdjsafe::transcode(&pt.source, &dest)?;
335 report.transcoded += 1;
336 }
337 } else if existing == Some(pt.device.file_size) {
338 report.kept += 1;
339 } else {
340 std::fs::copy(&pt.source, &dest)?;
341 report.copied += 1;
342 }
343
344 let frames = if plan.cdjsafe {
345 Some(rewrite::mp3_audio_frames(&dest)?)
346 } else {
347 None
348 };
349 let anlz_dest = device_path(&plan.device, &pt.device.anlz_dir);
350 std::fs::create_dir_all(&anlz_dest)?;
351 let bpm = pt
352 .device
353 .track
354 .tempos
355 .first()
356 .map(|t| t.bpm)
357 .unwrap_or(pt.device.track.average_bpm);
358 let Some(entry) = &pt.anlz else {
359 let frames = match frames {
360 Some(f) => Some(f),
361 None if is_mp3(&dest) => Some(rewrite::mp3_audio_frames(&dest)?),
362 None => None,
363 };
364 let pcm = generate::decode::decode(&pt.source)?;
365 let files = generate::build_files(&pt.device.track, &pt.device.usb_path, &pcm, frames);
366 for (kind, file) in FileKind::ALL.iter().zip(files.iter()) {
367 std::fs::write(
368 anlz_dest.join(format!("ANLZ0000.{}", kind.extension())),
369 file.to_bytes(),
370 )?;
371 report.anlz_files += 1;
372 }
373 report.anlz_generated += 1;
374 return Ok(pt.device.clone());
375 };
376 for kind in FileKind::ALL {
377 let Some(mut file) = read_optional(&entry.sibling(kind.extension()))? else {
378 if kind != FileKind::TwoEx {
379 anyhow::bail!(
380 "analysis file .{} missing next to {}",
381 kind.extension(),
382 entry.dat.display()
383 );
384 }
385 continue;
386 };
387 rewrite::prepare(
388 &mut file,
389 kind,
390 &pt.device.usb_path,
391 &pt.device.track.cues,
392 bpm,
393 );
394 if let Some(frames) = frames {
395 match kind {
396 FileKind::Dat => rewrite::set_cbr_pvbr(&mut file, frames),
397 FileKind::Ext => rewrite::strip_pvb2(&mut file),
398 FileKind::TwoEx => {}
399 }
400 }
401 std::fs::write(
402 anlz_dest.join(format!("ANLZ0000.{}", kind.extension())),
403 file.to_bytes(),
404 )?;
405 report.anlz_files += 1;
406 }
407 Ok(pt.device.clone())
408}
409
410fn is_mp3(path: &Path) -> bool {
411 path.extension()
412 .and_then(|e| e.to_str())
413 .is_some_and(|e| e.eq_ignore_ascii_case("mp3"))
414}
415
416fn prune_tree(root: &Path, keep: &HashSet<PathBuf>) -> Result<usize> {
418 fn walk(dir: &Path, keep: &HashSet<PathBuf>, removed: &mut usize) -> std::io::Result<bool> {
419 let mut empty = true;
420 for entry in std::fs::read_dir(dir)? {
421 let entry = entry?;
422 let path = entry.path();
423 if entry.file_type()?.is_dir() {
424 if walk(&path, keep, removed)? {
425 std::fs::remove_dir(&path)?;
426 } else {
427 empty = false;
428 }
429 } else if keep.contains(&path) {
430 empty = false;
431 } else {
432 std::fs::remove_file(&path)?;
433 *removed += 1;
434 }
435 }
436 Ok(empty)
437 }
438 let mut removed = 0;
439 if root.is_dir() {
440 walk(root, keep, &mut removed)?;
441 }
442 Ok(removed)
443}
444
445fn remove_apple_double(root: &Path) -> Result<()> {
447 fn walk(dir: &Path) -> std::io::Result<()> {
448 for entry in std::fs::read_dir(dir)? {
449 let entry = entry?;
450 let path = entry.path();
451 if entry.file_type()?.is_dir() {
452 walk(&path)?;
453 } else if entry.file_name().to_string_lossy().starts_with("._") {
454 std::fs::remove_file(&path)?;
455 }
456 }
457 Ok(())
458 }
459 if root.is_dir() {
460 walk(root)?;
461 }
462 Ok(())
463}