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