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}
27
28impl Cue {
29    pub fn is_hot(&self) -> bool {
30        self.num >= 0
31    }
32    pub fn is_loop(&self) -> bool {
33        self.kind == 4 || self.end.is_some()
34    }
35}
36
37#[derive(Debug, Clone, Default)]
38pub struct Track {
39    pub id: u64,
40    pub name: String,
41    pub artist: String,
42    pub composer: String,
43    pub album: String,
44    pub grouping: String,
45    pub genre: String,
46    pub kind: String,
47    pub size: u64,
48    pub total_time: u32,
49    pub disc_number: u32,
50    pub track_number: u32,
51    pub year: u32,
52    pub average_bpm: f64,
53    pub date_added: String,
54    pub bit_rate: u32,
55    pub sample_rate: u32,
56    pub comments: String,
57    pub play_count: u32,
58    /// 0, 51, 102, 153, 204, 255 for 0..=5 stars.
59    pub rating: u32,
60    /// Decoded filesystem path.
61    pub location: String,
62    pub remixer: String,
63    pub tonality: String,
64    pub label: String,
65    pub mix: String,
66    /// `0xRRGGBB`.
67    pub colour: Option<u32>,
68    pub tempos: Vec<Tempo>,
69    pub cues: Vec<Cue>,
70}
71
72impl Track {
73    pub fn file_name(&self) -> &str {
74        self.location.rsplit('/').next().unwrap_or(&self.location)
75    }
76    pub fn stars(&self) -> u8 {
77        (self.rating / 51).min(5) as u8
78    }
79}
80
81#[derive(Debug, Clone, Default)]
82pub struct Playlist {
83    pub name: String,
84    /// `Folder/Sub/Name` under ROOT.
85    pub path: String,
86    pub is_folder: bool,
87    /// `KeyType` for playlists; only "0" (TrackID references) is exportable.
88    pub key_type: String,
89    /// Index into `Library::playlists` of the containing folder.
90    pub parent: Option<usize>,
91    pub track_ids: Vec<u64>,
92}
93
94#[derive(Debug, Clone, Default)]
95pub struct Library {
96    pub tracks: Vec<Track>,
97    /// Document order, folders before their children.
98    pub playlists: Vec<Playlist>,
99}
100
101impl Library {
102    pub fn load(path: &Path) -> Result<Self> {
103        let data = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
104        Self::parse(&data)
105    }
106
107    pub fn parse(data: &[u8]) -> Result<Self> {
108        let mut reader = Reader::from_reader(data);
109        reader.config_mut().trim_text(true);
110        let mut lib = Library::default();
111        let mut in_collection = false;
112        let mut in_playlists = false;
113        let mut current: Option<Track> = None;
114        // stack of playlist indices for the NODE nesting (ROOT excluded)
115        let mut node_stack: Vec<Option<usize>> = Vec::new();
116        let mut buf = Vec::new();
117        loop {
118            match reader.read_event_into(&mut buf)? {
119                Event::Eof => break,
120                Event::Start(e) => match e.name().as_ref() {
121                    "COLLECTION" => in_collection = true,
122                    "PLAYLISTS" => in_playlists = true,
123                    "TRACK" if in_collection => current = Some(track_from(&e)?),
124                    "NODE" if in_playlists => {
125                        let idx = lib.push_node(&e, &node_stack)?;
126                        node_stack.push(idx);
127                    }
128                    _ => {}
129                },
130                Event::Empty(e) => match e.name().as_ref() {
131                    "TRACK" if in_collection => lib.tracks.push(track_from(&e)?),
132                    "TRACK" if in_playlists => {
133                        if let (Some(Some(idx)), Some(key)) = (node_stack.last(), attr(&e, "Key")?)
134                        {
135                            lib.playlists[*idx].track_ids.push(key.trim().parse()?);
136                        }
137                    }
138                    "TEMPO" => {
139                        if let Some(t) = current.as_mut() {
140                            t.tempos.push(Tempo {
141                                inizio: num(&e, "Inizio")?,
142                                bpm: num(&e, "Bpm")?,
143                                metro: attr(&e, "Metro")?.unwrap_or_default(),
144                                battito: num(&e, "Battito")? as u32,
145                            });
146                        }
147                    }
148                    "POSITION_MARK" => {
149                        if let Some(t) = current.as_mut() {
150                            let rgb =
151                                match (attr(&e, "Red")?, attr(&e, "Green")?, attr(&e, "Blue")?) {
152                                    (Some(r), Some(g), Some(b)) => {
153                                        Some((r.parse()?, g.parse()?, b.parse()?))
154                                    }
155                                    _ => None,
156                                };
157                            t.cues.push(Cue {
158                                name: attr(&e, "Name")?.unwrap_or_default(),
159                                kind: num(&e, "Type")? as u32,
160                                start: num(&e, "Start")?,
161                                end: attr(&e, "End")?.map(|v| v.trim().parse()).transpose()?,
162                                num: attr(&e, "Num")?
163                                    .map(|v| v.trim().parse())
164                                    .transpose()?
165                                    .unwrap_or(-1),
166                                rgb,
167                            });
168                        }
169                    }
170                    "NODE" if in_playlists => {
171                        lib.push_node(&e, &node_stack)?;
172                    }
173                    _ => {}
174                },
175                Event::End(e) => match e.name().as_ref() {
176                    "COLLECTION" => in_collection = false,
177                    "PLAYLISTS" => in_playlists = false,
178                    "TRACK" if in_collection => {
179                        if let Some(t) = current.take() {
180                            lib.tracks.push(t);
181                        }
182                    }
183                    "NODE" if in_playlists => {
184                        node_stack.pop();
185                    }
186                    _ => {}
187                },
188                _ => {}
189            }
190            buf.clear();
191        }
192        Ok(lib)
193    }
194
195    fn push_node(&mut self, e: &BytesStart, stack: &[Option<usize>]) -> Result<Option<usize>> {
196        let name = attr(e, "Name")?.unwrap_or_default();
197        let ty = attr(e, "Type")?.unwrap_or_default();
198        if stack.is_empty() && ty == "0" {
199            return Ok(None); // ROOT
200        }
201        let parent = stack.last().copied().flatten();
202        let path = match parent {
203            Some(p) => format!("{}/{}", self.playlists[p].path, name),
204            None => name.clone(),
205        };
206        self.playlists.push(Playlist {
207            name,
208            path,
209            is_folder: ty == "0",
210            key_type: attr(e, "KeyType")?.unwrap_or_default(),
211            parent,
212            track_ids: Vec::new(),
213        });
214        Ok(Some(self.playlists.len() - 1))
215    }
216
217    pub fn track(&self, id: u64) -> Option<&Track> {
218        self.tracks.iter().find(|t| t.id == id)
219    }
220
221    /// Playlist by `Folder/Name` path.
222    pub fn playlist(&self, path: &str) -> Option<&Playlist> {
223        self.playlists
224            .iter()
225            .find(|p| p.path == path && !p.is_folder)
226    }
227}
228
229fn attr(e: &BytesStart, name: &str) -> Result<Option<String>> {
230    for a in e.attributes() {
231        let a = a?;
232        if a.key.as_ref() == name {
233            #[allow(deprecated)]
234            return Ok(Some(a.unescape_value()?.into_owned()));
235        }
236    }
237    Ok(None)
238}
239
240fn num(e: &BytesStart, name: &str) -> Result<f64> {
241    Ok(attr(e, name)?
242        .map(|v| v.trim().parse::<f64>())
243        .transpose()?
244        .unwrap_or(0.0))
245}
246
247fn track_from(e: &BytesStart) -> Result<Track> {
248    let mut t = Track::default();
249    for a in e.attributes() {
250        let a = a?;
251        #[allow(deprecated)]
252        let v = a.unescape_value()?.into_owned();
253        let n = || v.trim().parse::<u64>().unwrap_or(0);
254        match a.key.as_ref() {
255            "TrackID" => t.id = n(),
256            "Name" => t.name = v,
257            "Artist" => t.artist = v,
258            "Composer" => t.composer = v,
259            "Album" => t.album = v,
260            "Grouping" => t.grouping = v,
261            "Genre" => t.genre = v,
262            "Kind" => t.kind = v,
263            "Size" => t.size = n(),
264            "TotalTime" => t.total_time = n() as u32,
265            "DiscNumber" => t.disc_number = n() as u32,
266            "TrackNumber" => t.track_number = n() as u32,
267            "Year" => t.year = n() as u32,
268            "AverageBpm" => t.average_bpm = v.trim().parse().unwrap_or(0.0),
269            "DateAdded" => t.date_added = v,
270            "BitRate" => t.bit_rate = n() as u32,
271            "SampleRate" => t.sample_rate = n() as u32,
272            "Comments" => t.comments = v,
273            "PlayCount" => t.play_count = n() as u32,
274            "Rating" => t.rating = n() as u32,
275            "Location" => t.location = baken_core::cdjsafe::decode_location(&v)?,
276            "Remixer" => t.remixer = v,
277            "Tonality" => t.tonality = v,
278            "Label" => t.label = v,
279            "Mix" => t.mix = v,
280            "Colour" => {
281                t.colour = u32::from_str_radix(v.trim().trim_start_matches("0x"), 16).ok();
282            }
283            _ => {}
284        }
285    }
286    Ok(t)
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    const SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
294<DJ_PLAYLISTS Version="1.0.0">
295  <PRODUCT Name="rekordbox" Version="7.2.18" Company="AlphaTheta"/>
296  <COLLECTION Entries="2">
297    <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">
298      <TEMPO Inizio="0.281" Bpm="128.00" Metro="4/4" Battito="1"/>
299      <POSITION_MARK Name="" Type="0" Start="0.281" Num="-1"/>
300      <POSITION_MARK Name="" Type="0" Start="15.280" Num="1" Red="40" Green="226" Blue="20"/>
301      <POSITION_MARK Name="lp" Type="4" Start="30.000" End="33.750" Num="-1"/>
302    </TRACK>
303    <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=""/>
304  </COLLECTION>
305  <PLAYLISTS>
306    <NODE Type="0" Name="ROOT" Count="2">
307      <NODE Name="Sets" Type="0" Count="1">
308        <NODE Name="Friday" Type="1" KeyType="0" Entries="2">
309          <TRACK Key="352"/>
310          <TRACK Key="2"/>
311        </NODE>
312      </NODE>
313      <NODE Name="Empty" Type="1" KeyType="0" Entries="0"/>
314    </NODE>
315  </PLAYLISTS>
316</DJ_PLAYLISTS>"#;
317
318    #[test]
319    fn parses_tracks_children_and_tree() {
320        let lib = Library::parse(SAMPLE.as_bytes()).unwrap();
321        assert_eq!(lib.tracks.len(), 2);
322        let t = &lib.tracks[0];
323        assert_eq!((t.id, t.stars(), t.colour), (352, 3, Some(0xFFFF00)));
324        assert_eq!(t.location, "/Volumes/X/Music/Unreal.mp3");
325        assert_eq!(t.tempos.len(), 1);
326        assert_eq!(t.cues.len(), 3);
327        assert!(t.cues[1].is_hot() && t.cues[1].rgb == Some((40, 226, 20)));
328        assert!(t.cues[2].is_loop() && t.cues[2].end == Some(33.75));
329        assert_eq!(lib.tracks[1].name, "&");
330        assert_eq!(lib.tracks[1].file_name(), "22. 盾.flac");
331        let paths: Vec<_> = lib
332            .playlists
333            .iter()
334            .map(|p| (p.path.as_str(), p.is_folder))
335            .collect();
336        assert_eq!(
337            paths,
338            vec![("Sets", true), ("Sets/Friday", false), ("Empty", false)]
339        );
340        assert_eq!(lib.playlist("Sets/Friday").unwrap().track_ids, vec![352, 2]);
341        assert_eq!(lib.playlists[1].parent, Some(0));
342    }
343}