1use anyhow::{Context, Result};
4use quick_xml::events::{BytesStart, Event};
5use quick_xml::reader::Reader;
6use std::path::Path;
7
8#[derive(Debug, Clone, Default)]
9pub struct Tempo {
10 pub inizio: f64,
11 pub bpm: f64,
12 pub metro: String,
13 pub battito: u32,
14}
15
16#[derive(Debug, Clone, Default)]
17pub struct Cue {
18 pub name: String,
19 pub kind: u32,
21 pub start: f64,
22 pub end: Option<f64>,
23 pub num: i32,
25 pub rgb: Option<(u8, u8, u8)>,
26 pub marked: bool,
28}
29
30impl Cue {
31 pub fn is_hot(&self) -> bool {
32 self.num >= 0
33 }
34 pub fn is_loop(&self) -> bool {
35 self.kind == 4 || self.end.is_some()
36 }
37 pub fn is_memory_loop(&self) -> bool {
38 !self.is_hot() && self.is_loop()
39 }
40}
41
42pub const ACTIVE_LOOP_MARKER: &str = "[active]";
46
47fn split_marker(name: String) -> (String, bool) {
48 let trimmed = name.trim_start();
49 match trimmed.get(..ACTIVE_LOOP_MARKER.len()) {
50 Some(head) if head.eq_ignore_ascii_case(ACTIVE_LOOP_MARKER) => (
51 trimmed[ACTIVE_LOOP_MARKER.len()..].trim_start().to_string(),
52 true,
53 ),
54 _ => (name, false),
55 }
56}
57
58pub fn active_loop(cues: &[Cue]) -> Option<&Cue> {
60 cues.iter()
61 .filter(|c| c.marked && c.is_memory_loop())
62 .min_by(|a, b| a.start.total_cmp(&b.start))
63}
64
65#[derive(Debug, Clone, PartialEq)]
67pub enum ActiveLoopWarning {
68 Several {
70 track: String,
71 count: usize,
72 used: f64,
73 },
74 NotAMemoryLoop { track: String },
76}
77
78impl ActiveLoopWarning {
79 pub fn check(track: &Track) -> Option<Self> {
80 let count = track
81 .cues
82 .iter()
83 .filter(|c| c.marked && c.is_memory_loop())
84 .count();
85 match active_loop(&track.cues) {
86 Some(used) if count > 1 => Some(Self::Several {
87 track: track.name.clone(),
88 count,
89 used: used.start,
90 }),
91 None if track.cues.iter().any(|c| c.marked) => Some(Self::NotAMemoryLoop {
92 track: track.name.clone(),
93 }),
94 _ => None,
95 }
96 }
97}
98
99impl std::fmt::Display for ActiveLoopWarning {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 match self {
102 Self::Several { track, count, used } => write!(
103 f,
104 "{track}: {count} memory loops are named {ACTIVE_LOOP_MARKER}, but a player takes one active loop per track; only the one at {used:.3} s is written as active"
105 ),
106 Self::NotAMemoryLoop { track } => write!(
107 f,
108 "{track}: {ACTIVE_LOOP_MARKER} is on a cue that is not a memory loop, so the track gets no active loop"
109 ),
110 }
111 }
112}
113
114#[derive(Debug, Clone, Default)]
115pub struct Track {
116 pub id: u64,
117 pub name: String,
118 pub artist: String,
119 pub composer: String,
120 pub album: String,
121 pub grouping: String,
122 pub genre: String,
123 pub kind: String,
124 pub size: u64,
125 pub total_time: u32,
126 pub disc_number: u32,
127 pub track_number: u32,
128 pub year: u32,
129 pub average_bpm: f64,
130 pub date_added: String,
131 pub bit_rate: u32,
132 pub sample_rate: u32,
133 pub comments: String,
134 pub play_count: u32,
135 pub rating: u32,
137 pub location: String,
139 pub remixer: String,
140 pub tonality: String,
141 pub label: String,
142 pub mix: String,
143 pub colour: Option<u32>,
145 pub tempos: Vec<Tempo>,
146 pub cues: Vec<Cue>,
147}
148
149impl Track {
150 pub fn file_name(&self) -> &str {
151 self.location.rsplit('/').next().unwrap_or(&self.location)
152 }
153 pub fn stars(&self) -> u8 {
154 (self.rating / 51).min(5) as u8
155 }
156 pub fn grid_bpm(&self) -> f64 {
159 self.tempos.first().map_or(self.average_bpm, |t| t.bpm)
160 }
161}
162
163#[derive(Debug, Clone, Default)]
164pub struct Playlist {
165 pub name: String,
166 pub path: String,
168 pub is_folder: bool,
169 pub key_type: String,
171 pub parent: Option<usize>,
173 pub track_ids: Vec<u64>,
174}
175
176#[derive(Debug, Clone, Default)]
177pub struct Library {
178 pub tracks: Vec<Track>,
179 pub playlists: Vec<Playlist>,
181}
182
183impl Library {
184 pub fn load(path: &Path) -> Result<Self> {
185 let data = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
186 Self::parse(&data)
187 }
188
189 pub fn parse(data: &[u8]) -> Result<Self> {
190 let mut reader = Reader::from_reader(data);
191 reader.config_mut().trim_text(true);
192 let mut lib = Library::default();
193 let mut in_collection = false;
194 let mut in_playlists = false;
195 let mut current: Option<Track> = None;
196 let mut node_stack: Vec<Option<usize>> = Vec::new();
198 let mut buf = Vec::new();
199 loop {
200 match reader.read_event_into(&mut buf)? {
201 Event::Eof => break,
202 Event::Start(e) => match e.name().as_ref() {
203 "COLLECTION" => in_collection = true,
204 "PLAYLISTS" => in_playlists = true,
205 "TRACK" if in_collection => current = Some(track_from(&e)?),
206 "NODE" if in_playlists => {
207 let idx = lib.push_node(&e, &node_stack)?;
208 node_stack.push(idx);
209 }
210 _ => {}
211 },
212 Event::Empty(e) => match e.name().as_ref() {
213 "TRACK" if in_collection => lib.tracks.push(track_from(&e)?),
214 "TRACK" if in_playlists => {
215 if let (Some(Some(idx)), Some(key)) = (node_stack.last(), attr(&e, "Key")?)
216 {
217 lib.playlists[*idx].track_ids.push(key.trim().parse()?);
218 }
219 }
220 "TEMPO" => {
221 if let Some(t) = current.as_mut() {
222 t.tempos.push(Tempo {
223 inizio: num(&e, "Inizio")?,
224 bpm: num(&e, "Bpm")?,
225 metro: attr(&e, "Metro")?.unwrap_or_default(),
226 battito: num(&e, "Battito")? as u32,
227 });
228 }
229 }
230 "POSITION_MARK" => {
231 if let Some(t) = current.as_mut() {
232 let rgb =
233 match (attr(&e, "Red")?, attr(&e, "Green")?, attr(&e, "Blue")?) {
234 (Some(r), Some(g), Some(b)) => {
235 Some((r.parse()?, g.parse()?, b.parse()?))
236 }
237 _ => None,
238 };
239 let (name, marked) =
240 split_marker(attr(&e, "Name")?.unwrap_or_default());
241 t.cues.push(Cue {
242 name,
243 marked,
244 kind: num(&e, "Type")? as u32,
245 start: num(&e, "Start")?,
246 end: attr(&e, "End")?.map(|v| v.trim().parse()).transpose()?,
247 num: attr(&e, "Num")?
248 .map(|v| v.trim().parse())
249 .transpose()?
250 .unwrap_or(-1),
251 rgb,
252 });
253 }
254 }
255 "NODE" if in_playlists => {
256 lib.push_node(&e, &node_stack)?;
257 }
258 _ => {}
259 },
260 Event::End(e) => match e.name().as_ref() {
261 "COLLECTION" => in_collection = false,
262 "PLAYLISTS" => in_playlists = false,
263 "TRACK" if in_collection => {
264 if let Some(t) = current.take() {
265 lib.tracks.push(t);
266 }
267 }
268 "NODE" if in_playlists => {
269 node_stack.pop();
270 }
271 _ => {}
272 },
273 _ => {}
274 }
275 buf.clear();
276 }
277 Ok(lib)
278 }
279
280 fn push_node(&mut self, e: &BytesStart, stack: &[Option<usize>]) -> Result<Option<usize>> {
281 let name = attr(e, "Name")?.unwrap_or_default();
282 let ty = attr(e, "Type")?.unwrap_or_default();
283 if stack.is_empty() && ty == "0" {
284 return Ok(None); }
286 let parent = stack.last().copied().flatten();
287 let path = match parent {
288 Some(p) => format!("{}/{}", self.playlists[p].path, name),
289 None => name.clone(),
290 };
291 self.playlists.push(Playlist {
292 name,
293 path,
294 is_folder: ty == "0",
295 key_type: attr(e, "KeyType")?.unwrap_or_default(),
296 parent,
297 track_ids: Vec::new(),
298 });
299 Ok(Some(self.playlists.len() - 1))
300 }
301
302 pub fn track(&self, id: u64) -> Option<&Track> {
303 self.tracks.iter().find(|t| t.id == id)
304 }
305
306 pub fn playlist(&self, path: &str) -> Option<&Playlist> {
308 self.playlists
309 .iter()
310 .find(|p| p.path == path && !p.is_folder)
311 }
312}
313
314fn attr(e: &BytesStart, name: &str) -> Result<Option<String>> {
315 for a in e.attributes() {
316 let a = a?;
317 if a.key.as_ref() == name {
318 #[allow(deprecated)]
319 return Ok(Some(a.unescape_value()?.into_owned()));
320 }
321 }
322 Ok(None)
323}
324
325fn num(e: &BytesStart, name: &str) -> Result<f64> {
326 Ok(attr(e, name)?
327 .map(|v| v.trim().parse::<f64>())
328 .transpose()?
329 .unwrap_or(0.0))
330}
331
332fn track_from(e: &BytesStart) -> Result<Track> {
333 let mut t = Track::default();
334 for a in e.attributes() {
335 let a = a?;
336 #[allow(deprecated)]
337 let v = a.unescape_value()?.into_owned();
338 let n = || v.trim().parse::<u64>().unwrap_or(0);
339 match a.key.as_ref() {
340 "TrackID" => t.id = n(),
341 "Name" => t.name = v,
342 "Artist" => t.artist = v,
343 "Composer" => t.composer = v,
344 "Album" => t.album = v,
345 "Grouping" => t.grouping = v,
346 "Genre" => t.genre = v,
347 "Kind" => t.kind = v,
348 "Size" => t.size = n(),
349 "TotalTime" => t.total_time = n() as u32,
350 "DiscNumber" => t.disc_number = n() as u32,
351 "TrackNumber" => t.track_number = n() as u32,
352 "Year" => t.year = n() as u32,
353 "AverageBpm" => t.average_bpm = v.trim().parse().unwrap_or(0.0),
354 "DateAdded" => t.date_added = v,
355 "BitRate" => t.bit_rate = n() as u32,
356 "SampleRate" => t.sample_rate = n() as u32,
357 "Comments" => t.comments = v,
358 "PlayCount" => t.play_count = n() as u32,
359 "Rating" => t.rating = n() as u32,
360 "Location" => t.location = baken_core::cdjsafe::decode_location(&v)?,
361 "Remixer" => t.remixer = v,
362 "Tonality" => t.tonality = v,
363 "Label" => t.label = v,
364 "Mix" => t.mix = v,
365 "Colour" => {
366 t.colour = u32::from_str_radix(v.trim().trim_start_matches("0x"), 16).ok();
367 }
368 _ => {}
369 }
370 }
371 Ok(t)
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 const SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
379<DJ_PLAYLISTS Version="1.0.0">
380 <PRODUCT Name="rekordbox" Version="7.2.18" Company="AlphaTheta"/>
381 <COLLECTION Entries="2">
382 <TRACK TrackID="352" Name="Unreal" Artist="XamarA" Album="" Genre="Techno" Kind="MP3 File" Size="18920358" TotalTime="472" DiscNumber="0" TrackNumber="0" Year="0" AverageBpm="128.00" DateAdded="2023-09-06" BitRate="320" SampleRate="48000" Comments="" PlayCount="1" Rating="153" Location="file://localhost/Volumes/X/Music/Unreal.mp3" Remixer="" Tonality="1A" Label="" Mix="" Colour="0xFFFF00">
383 <TEMPO Inizio="0.281" Bpm="128.00" Metro="4/4" Battito="1"/>
384 <POSITION_MARK Name="" Type="0" Start="0.281" Num="-1"/>
385 <POSITION_MARK Name="" Type="0" Start="15.280" Num="1" Red="40" Green="226" Blue="20"/>
386 <POSITION_MARK Name="[active] lp" Type="4" Start="30.000" End="33.750" Num="-1"/>
387 </TRACK>
388 <TRACK TrackID="2" Name="&" Artist="A" Kind="FLAC File" Size="1" TotalTime="1" AverageBpm="0" Location="file://localhost/V/22.%20%E7%9B%BE.flac" Tonality=""/>
389 </COLLECTION>
390 <PLAYLISTS>
391 <NODE Type="0" Name="ROOT" Count="2">
392 <NODE Name="Sets" Type="0" Count="1">
393 <NODE Name="Friday" Type="1" KeyType="0" Entries="2">
394 <TRACK Key="352"/>
395 <TRACK Key="2"/>
396 </NODE>
397 </NODE>
398 <NODE Name="Empty" Type="1" KeyType="0" Entries="0"/>
399 </NODE>
400 </PLAYLISTS>
401</DJ_PLAYLISTS>"#;
402
403 #[test]
404 fn parses_tracks_children_and_tree() {
405 let lib = Library::parse(SAMPLE.as_bytes()).unwrap();
406 assert_eq!(lib.tracks.len(), 2);
407 let t = &lib.tracks[0];
408 assert_eq!((t.id, t.stars(), t.colour), (352, 3, Some(0xFFFF00)));
409 assert_eq!(t.location, "/Volumes/X/Music/Unreal.mp3");
410 assert_eq!(t.tempos.len(), 1);
411 assert_eq!(t.cues.len(), 3);
412 assert!(t.cues[1].is_hot() && t.cues[1].rgb == Some((40, 226, 20)));
413 assert!(t.cues[2].is_loop() && t.cues[2].end == Some(33.75));
414 assert_eq!((t.cues[2].name.as_str(), t.cues[2].marked), ("lp", true));
415 assert!(!t.cues[0].marked);
416 assert_eq!(lib.tracks[1].name, "&");
417 assert_eq!(lib.tracks[1].file_name(), "22. 盾.flac");
418 let paths: Vec<_> = lib
419 .playlists
420 .iter()
421 .map(|p| (p.path.as_str(), p.is_folder))
422 .collect();
423 assert_eq!(
424 paths,
425 vec![("Sets", true), ("Sets/Friday", false), ("Empty", false)]
426 );
427 assert_eq!(lib.playlist("Sets/Friday").unwrap().track_ids, vec![352, 2]);
428 assert_eq!(lib.playlists[1].parent, Some(0));
429 }
430
431 #[test]
432 fn the_active_marker_is_read_and_left_out_of_the_name() {
433 let split = |n: &str| split_marker(n.to_string());
434 assert_eq!(split("[active] Build"), ("Build".to_string(), true));
435 assert_eq!(split(" [Active]Drop"), ("Drop".to_string(), true));
436 assert_eq!(split("[ACTIVE]"), (String::new(), true));
437 assert_eq!(
438 split("Build [active]"),
439 ("Build [active]".to_string(), false)
440 );
441 assert_eq!(split("[act"), ("[act".to_string(), false));
442 assert_eq!(split("ワーズワース"), ("ワーズワース".to_string(), false));
443 }
444
445 #[test]
446 fn active_loop_warnings() {
447 let cue = |start: f64, kind: u32, num: i32| Cue {
448 start,
449 kind,
450 end: (kind == 4).then_some(start + 4.0),
451 num,
452 marked: true,
453 ..Default::default()
454 };
455 let track = |cues: Vec<Cue>| Track {
456 name: "T".into(),
457 cues,
458 ..Default::default()
459 };
460 let one = track(vec![cue(8.0, 4, -1), cue(8.0, 4, 0)]);
462 assert_eq!(active_loop(&one.cues).map(|c| c.start), Some(8.0));
463 assert_eq!(ActiveLoopWarning::check(&one), None);
464 let two = track(vec![cue(40.0, 4, -1), cue(20.0, 4, -1)]);
465 assert_eq!(
466 ActiveLoopWarning::check(&two),
467 Some(ActiveLoopWarning::Several {
468 track: "T".into(),
469 count: 2,
470 used: 20.0
471 })
472 );
473 let cue_only = track(vec![cue(5.0, 0, -1), cue(5.0, 0, 1)]);
474 assert_eq!(active_loop(&cue_only.cues).map(|c| c.start), None);
475 assert!(matches!(
476 ActiveLoopWarning::check(&cue_only),
477 Some(ActiveLoopWarning::NotAMemoryLoop { .. })
478 ));
479 assert_eq!(ActiveLoopWarning::check(&track(Vec::new())), None);
480 }
481}