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}
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 pub rating: u32,
60 pub location: String,
62 pub remixer: String,
63 pub tonality: String,
64 pub label: String,
65 pub mix: String,
66 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 pub path: String,
86 pub is_folder: bool,
87 pub key_type: String,
89 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 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 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); }
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 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="&" 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}