Skip to main content

baken_export/
collection.rs

1//! `collection.xml` (rekordbox "Export Collection in xml format") to a typed model.
2
3use 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    /// rekordbox `Type`: 0 cue, 1 fade-in, 2 fade-out, 3 load, 4 loop.
20    pub kind: u32,
21    pub start: f64,
22    pub end: Option<f64>,
23    /// -1 memory cue, 0..=7 hot cue A..H.
24    pub num: i32,
25    pub rgb: Option<(u8, u8, u8)>,
26    /// `Name` started with [`ACTIVE_LOOP_MARKER`], which is not kept in `name`.
27    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
42/// The XML has no field for an active loop (a memory loop the player engages
43/// when playback reaches it), so a memory loop whose `Name` starts with this,
44/// in any case, is one (issue #210).
45pub 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
58/// The track's active loop: players take one, so the earliest marked memory loop.
59pub 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/// A track whose [`ACTIVE_LOOP_MARKER`]s do not name exactly one memory loop.
66#[derive(Debug, Clone, PartialEq)]
67pub enum ActiveLoopWarning {
68    /// Several memory loops are marked; only the one at `used` seconds is active.
69    Several {
70        track: String,
71        count: usize,
72        used: f64,
73    },
74    /// Only cues that are not memory loops are marked, so none is active.
75    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    /// 0, 51, 102, 153, 204, 255 for 0..=5 stars.
136    pub rating: u32,
137    /// Decoded filesystem path.
138    pub location: String,
139    pub remixer: String,
140    pub tonality: String,
141    pub label: String,
142    pub mix: String,
143    /// `0xRRGGBB`.
144    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    /// BPM of the first grid segment, or `AverageBpm` without a grid; loop
157    /// lengths in the cue sections are counted in these beats.
158    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    /// `Folder/Sub/Name` under ROOT.
167    pub path: String,
168    pub is_folder: bool,
169    /// `KeyType` for playlists; only "0" (TrackID references) is exportable.
170    pub key_type: String,
171    /// Index into `Library::playlists` of the containing folder.
172    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    /// Document order, folders before their children.
180    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        // stack of playlist indices for the NODE nesting (ROOT excluded)
197        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); // ROOT
285        }
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    /// Playlist by `Folder/Name` path.
307    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="&amp;" 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        // a marked memory loop and its marked hot copy: one active loop
461        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}