Skip to main content

baken_export/
build.rs

1//! From the planned tracks and playlists to the `export.pdb` model.
2
3use crate::collection::{Library, Track};
4use crate::pdb::fixed::COLORS;
5use crate::pdb::rows::{self, TrackRow};
6use crate::pdb::{Export, ExportPlaylist};
7use baken_core::rbsort::parse_key;
8use std::collections::HashMap;
9
10/// A track as it will exist on the stick.
11#[derive(Debug, Clone)]
12pub struct DeviceTrack {
13    pub track: Track,
14    /// `/Contents/...`
15    pub usb_path: String,
16    /// `/PIONEER/USBANLZ/Pxxx/xxxxxxxx`
17    pub anlz_dir: String,
18    /// `N` of `ANLZ000N.*`: tracks whose paths hash to the same `anlz_dir`
19    /// are numbered in export order, the way rekordbox does it.
20    pub anlz_index: u16,
21    pub file_size: u64,
22    pub sample_depth: u16,
23    pub file_type: u16,
24    pub bitrate: u32,
25    pub sample_rate: u32,
26}
27
28impl DeviceTrack {
29    /// `/PIONEER/USBANLZ/Pxxx/xxxxxxxx/ANLZ000N.<ext>`
30    pub fn anlz_path(&self, ext: &str) -> String {
31        format!("{}/ANLZ{:04}.{ext}", self.anlz_dir, self.anlz_index)
32    }
33}
34
35/// The extension decides, because rekordbox always writes a `Kind` that
36/// matches it and other tools do not (mixxx2rekordbox writes `MP3 File` for
37/// FLAC, and the player then fails to load the track). `Kind` only tells ALAC
38/// from AAC inside an `.m4a`, and covers files without a known extension.
39pub fn file_type_for(kind: &str, file_name: &str) -> u16 {
40    let ext = file_name
41        .rsplit('.')
42        .next()
43        .unwrap_or("")
44        .to_ascii_lowercase();
45    match ext.as_str() {
46        "mp3" => rows::FILE_TYPE_MP3,
47        "m4a" | "aac" | "mp4" if kind == "ALAC File" => rows::FILE_TYPE_ALAC,
48        "m4a" | "aac" | "mp4" => rows::FILE_TYPE_M4A,
49        "flac" => rows::FILE_TYPE_FLAC,
50        "wav" => rows::FILE_TYPE_WAV,
51        "aif" | "aiff" => rows::FILE_TYPE_AIFF,
52        _ => match kind {
53            "MP3 File" => rows::FILE_TYPE_MP3,
54            "M4A File" => rows::FILE_TYPE_M4A,
55            "FLAC File" => rows::FILE_TYPE_FLAC,
56            "ALAC File" => rows::FILE_TYPE_ALAC,
57            "WAV File" => rows::FILE_TYPE_WAV,
58            "AIFF File" => rows::FILE_TYPE_AIFF,
59            _ => 0,
60        },
61    }
62}
63
64#[derive(Default)]
65struct Ids {
66    map: HashMap<String, u32>,
67    rows: Vec<(u32, String)>,
68}
69
70impl Ids {
71    fn get(&mut self, name: &str) -> u32 {
72        let name = name.trim();
73        if name.is_empty() {
74            return 0;
75        }
76        if let Some(&id) = self.map.get(name) {
77            return id;
78        }
79        let id = self.rows.len() as u32 + 1;
80        self.map.insert(name.to_string(), id);
81        self.rows.push((id, name.to_string()));
82        id
83    }
84}
85
86/// The keys table holds each key the way the XML spells it, because rekordbox
87/// writes the names it displays and the player prints them as they are: a
88/// Classic export shows `Cm`, an Alphanumeric one `5A`. Only keys a track uses
89/// are listed, in wheel order (`1A` first), the first spelling seen first.
90fn key_rows(tracks: &[DeviceTrack]) -> Vec<(u32, String)> {
91    let mut names: Vec<(u8, String)> = Vec::new();
92    for dt in tracks {
93        let name = dt.track.tonality.trim();
94        if let Some(i) = parse_key(name) {
95            if !names.iter().any(|(_, n)| n == name) {
96                names.push((i, name.to_string()));
97            }
98        }
99    }
100    names.sort_by_key(|(i, _)| *i);
101    names
102        .into_iter()
103        .enumerate()
104        .map(|(id, (_, name))| (id as u32 + 1, name))
105        .collect()
106}
107
108/// The `export.pdb` model for `tracks` and the playlists `selected` (indices
109/// into `library.playlists`, leaf playlists only) with their parent folders.
110pub fn build(
111    library: &Library,
112    tracks: &[DeviceTrack],
113    selected: &[usize],
114    device_name: &str,
115    export_date: &str,
116) -> Export {
117    let mut artists = Ids::default();
118    let mut genres = Ids::default();
119    let mut labels = Ids::default();
120    let mut albums: HashMap<String, (u32, Option<u32>)> = HashMap::new(); // name -> (id, single artist or None once mixed)
121    let mut album_rows: Vec<(u32, String)> = Vec::new();
122    let mut track_ids: HashMap<u64, u32> = HashMap::new();
123    let mut rows_out = Vec::with_capacity(tracks.len());
124    let keys = key_rows(tracks);
125
126    for (i, dt) in tracks.iter().enumerate() {
127        let t = &dt.track;
128        let id = i as u32 + 1;
129        track_ids.insert(t.id, id);
130        let artist_id = artists.get(&t.artist);
131        let album_id = if t.album.trim().is_empty() {
132            0
133        } else {
134            let entry = albums.entry(t.album.trim().to_string()).or_insert_with(|| {
135                album_rows.push((album_rows.len() as u32 + 1, t.album.trim().to_string()));
136                (album_rows.len() as u32, Some(artist_id))
137            });
138            if entry.1 != Some(artist_id) {
139                entry.1 = None;
140            }
141            entry.0
142        };
143        let key_id = keys
144            .iter()
145            .find(|(_, n)| n == t.tonality.trim())
146            .map_or(0, |(id, _)| *id);
147        let color_id = t
148            .colour
149            .and_then(|rgb| COLORS.iter().find(|(_, _, c)| *c == rgb))
150            .map(|(id, _, _)| *id)
151            .unwrap_or(0);
152        let file_name = dt.usb_path.rsplit('/').next().unwrap_or("").to_string();
153        rows_out.push(TrackRow {
154            id,
155            sample_rate: dt.sample_rate,
156            composer_id: artists.get(&t.composer),
157            file_size: dt.file_size.min(u32::MAX as u64) as u32,
158            artwork_id: 0,
159            key_id,
160            original_artist_id: 0,
161            label_id: labels.get(&t.label),
162            remixer_id: artists.get(&t.remixer),
163            bitrate: dt.bitrate,
164            track_number: t.track_number,
165            tempo: (t.average_bpm * 100.0).round() as u32,
166            genre_id: genres.get(&t.genre),
167            album_id,
168            artist_id,
169            disc_number: t.disc_number as u16,
170            play_count: t.play_count as u16,
171            year: t.year as u16,
172            sample_depth: dt.sample_depth,
173            duration_seconds: t.total_time as u16,
174            color_id,
175            rating: t.stars(),
176            file_type: dt.file_type,
177            date_added: t.date_added.clone(),
178            release_date: String::new(),
179            mix_name: t.mix.clone(),
180            analyze_path: dt.anlz_path("DAT"),
181            analyze_date: export_date.to_string(),
182            comment: t.comments.clone(),
183            title: t.name.clone(),
184            filename: file_name,
185            file_path: dt.usb_path.clone(),
186        });
187    }
188
189    let albums_out: Vec<(u32, u32, String)> = album_rows
190        .into_iter()
191        .map(|(id, name)| {
192            let artist = albums.get(&name).and_then(|(_, a)| *a).unwrap_or(0);
193            (id, artist, name)
194        })
195        .collect();
196
197    // Playlist tree: selected leaves plus every ancestor folder, ids in document order.
198    let mut include = vec![false; library.playlists.len()];
199    for &i in selected {
200        let mut cur = Some(i);
201        while let Some(c) = cur {
202            include[c] = true;
203            cur = library.playlists[c].parent;
204        }
205    }
206    let mut node_id = vec![0u32; library.playlists.len()];
207    let mut next = 1u32;
208    let mut sibling_count: HashMap<Option<usize>, u32> = HashMap::new();
209    let mut playlists = Vec::new();
210    for (i, p) in library.playlists.iter().enumerate() {
211        if !include[i] {
212            continue;
213        }
214        node_id[i] = next;
215        next += 1;
216        let sort = sibling_count.entry(p.parent).or_insert(0);
217        let sort_order = *sort;
218        *sort += 1;
219        let ids = if p.is_folder {
220            Vec::new()
221        } else {
222            p.track_ids
223                .iter()
224                .filter_map(|tid| track_ids.get(tid).copied())
225                .collect()
226        };
227        playlists.push(ExportPlaylist {
228            id: node_id[i],
229            parent: p.parent.map(|pi| node_id[pi]).unwrap_or(0),
230            sort_order,
231            is_folder: p.is_folder,
232            name: p.name.clone(),
233            track_ids: ids,
234        });
235    }
236
237    Export {
238        tracks: rows_out,
239        artists: artists.rows,
240        albums: albums_out,
241        genres: genres.rows,
242        labels: labels.rows,
243        keys,
244        playlists,
245        device_name: device_name.to_string(),
246        export_date: export_date.to_string(),
247    }
248}
249
250/// Today's date as `YYYY-MM-DD` (UTC), without pulling in a date crate.
251pub fn today() -> String {
252    let secs = std::time::SystemTime::now()
253        .duration_since(std::time::UNIX_EPOCH)
254        .map(|d| d.as_secs())
255        .unwrap_or(0);
256    let days = (secs / 86400) as i64;
257    // Howard Hinnant's civil-from-days
258    let z = days + 719468;
259    let era = z.div_euclid(146097);
260    let doe = z.rem_euclid(146097);
261    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
262    let y = yoe + era * 400;
263    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
264    let mp = (5 * doy + 2) / 153;
265    let d = doy - (153 * mp + 2) / 5 + 1;
266    let m = if mp < 10 { mp + 3 } else { mp - 9 };
267    let y = if m <= 2 { y + 1 } else { y };
268    format!("{y:04}-{m:02}-{d:02}")
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn extension_wins_over_a_wrong_kind() {
277        assert_eq!(
278            file_type_for("MP3 File", "Transfer.flac"),
279            rows::FILE_TYPE_FLAC
280        );
281        assert_eq!(file_type_for("ALAC File", "a.m4a"), rows::FILE_TYPE_ALAC);
282        assert_eq!(file_type_for("M4A File", "a.m4a"), rows::FILE_TYPE_M4A);
283        assert_eq!(
284            file_type_for("WAV File", "no-extension"),
285            rows::FILE_TYPE_WAV
286        );
287    }
288
289    fn keyed(tonality: &[&str]) -> Vec<DeviceTrack> {
290        tonality
291            .iter()
292            .map(|k| DeviceTrack {
293                track: Track {
294                    tonality: k.to_string(),
295                    ..Default::default()
296                },
297                usb_path: String::new(),
298                anlz_dir: String::new(),
299                anlz_index: 0,
300                file_size: 0,
301                sample_depth: 0,
302                file_type: 0,
303                bitrate: 0,
304                sample_rate: 0,
305            })
306            .collect()
307    }
308
309    fn names(rows: &[(u32, String)]) -> Vec<(u32, &str)> {
310        rows.iter().map(|(id, n)| (*id, n.as_str())).collect()
311    }
312
313    #[test]
314    fn keys_keep_the_xml_spelling_in_wheel_order() {
315        // #116: a Classic export has to show Cm on the player, like rekordbox's.
316        let rows = key_rows(&keyed(&["Cm", "Ab", "Cm", "", "A minor", " Abm "]));
317        assert_eq!(names(&rows), [(1, "Abm"), (2, "Ab"), (3, "Cm")]);
318        // Both spellings of one key get their own row, as on a rekordbox stick.
319        let rows = key_rows(&keyed(&["8A", "Am", "1A"]));
320        assert_eq!(names(&rows), [(1, "1A"), (2, "8A"), (3, "Am")]);
321        assert!(key_rows(&keyed(&["", "x"])).is_empty());
322    }
323
324    #[test]
325    fn today_is_a_date() {
326        let t = today();
327        assert_eq!(t.len(), 10);
328        assert!(t.starts_with("20"));
329    }
330}