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/// Playlists to include, as indices into `library.playlists` (leaf playlists only).
109pub fn build(
110    library: &Library,
111    tracks: &[DeviceTrack],
112    selected: &[usize],
113    device_name: &str,
114    export_date: &str,
115) -> Export {
116    let mut artists = Ids::default();
117    let mut genres = Ids::default();
118    let mut labels = Ids::default();
119    let mut albums: HashMap<String, (u32, Option<u32>)> = HashMap::new(); // name -> (id, single artist or None once mixed)
120    let mut album_rows: Vec<(u32, String)> = Vec::new();
121    let mut track_ids: HashMap<u64, u32> = HashMap::new();
122    let mut rows_out = Vec::with_capacity(tracks.len());
123    let keys = key_rows(tracks);
124
125    for (i, dt) in tracks.iter().enumerate() {
126        let t = &dt.track;
127        let id = i as u32 + 1;
128        track_ids.insert(t.id, id);
129        let artist_id = artists.get(&t.artist);
130        let album_id = if t.album.trim().is_empty() {
131            0
132        } else {
133            let entry = albums.entry(t.album.trim().to_string()).or_insert_with(|| {
134                album_rows.push((album_rows.len() as u32 + 1, t.album.trim().to_string()));
135                (album_rows.len() as u32, Some(artist_id))
136            });
137            if entry.1 != Some(artist_id) {
138                entry.1 = None;
139            }
140            entry.0
141        };
142        let key_id = keys
143            .iter()
144            .find(|(_, n)| n == t.tonality.trim())
145            .map_or(0, |(id, _)| *id);
146        let color_id = t
147            .colour
148            .and_then(|rgb| COLORS.iter().find(|(_, _, c)| *c == rgb))
149            .map(|(id, _, _)| *id)
150            .unwrap_or(0);
151        let file_name = dt.usb_path.rsplit('/').next().unwrap_or("").to_string();
152        rows_out.push(TrackRow {
153            id,
154            sample_rate: dt.sample_rate,
155            composer_id: artists.get(&t.composer),
156            file_size: dt.file_size.min(u32::MAX as u64) as u32,
157            artwork_id: 0,
158            key_id,
159            original_artist_id: 0,
160            label_id: labels.get(&t.label),
161            remixer_id: artists.get(&t.remixer),
162            bitrate: dt.bitrate,
163            track_number: t.track_number,
164            tempo: (t.average_bpm * 100.0).round() as u32,
165            genre_id: genres.get(&t.genre),
166            album_id,
167            artist_id,
168            disc_number: t.disc_number as u16,
169            play_count: t.play_count as u16,
170            year: t.year as u16,
171            sample_depth: dt.sample_depth,
172            duration_seconds: t.total_time as u16,
173            color_id,
174            rating: t.stars(),
175            file_type: dt.file_type,
176            date_added: t.date_added.clone(),
177            release_date: String::new(),
178            mix_name: t.mix.clone(),
179            analyze_path: dt.anlz_path("DAT"),
180            analyze_date: export_date.to_string(),
181            comment: t.comments.clone(),
182            title: t.name.clone(),
183            filename: file_name,
184            file_path: dt.usb_path.clone(),
185        });
186    }
187
188    let albums_out: Vec<(u32, u32, String)> = album_rows
189        .into_iter()
190        .map(|(id, name)| {
191            let artist = albums.get(&name).and_then(|(_, a)| *a).unwrap_or(0);
192            (id, artist, name)
193        })
194        .collect();
195
196    // Playlist tree: selected leaves plus every ancestor folder, ids in document order.
197    let mut include = vec![false; library.playlists.len()];
198    for &i in selected {
199        let mut cur = Some(i);
200        while let Some(c) = cur {
201            include[c] = true;
202            cur = library.playlists[c].parent;
203        }
204    }
205    let mut node_id = vec![0u32; library.playlists.len()];
206    let mut next = 1u32;
207    let mut sibling_count: HashMap<Option<usize>, u32> = HashMap::new();
208    let mut playlists = Vec::new();
209    for (i, p) in library.playlists.iter().enumerate() {
210        if !include[i] {
211            continue;
212        }
213        node_id[i] = next;
214        next += 1;
215        let sort = sibling_count.entry(p.parent).or_insert(0);
216        let sort_order = *sort;
217        *sort += 1;
218        let ids = if p.is_folder {
219            Vec::new()
220        } else {
221            p.track_ids
222                .iter()
223                .filter_map(|tid| track_ids.get(tid).copied())
224                .collect()
225        };
226        playlists.push(ExportPlaylist {
227            id: node_id[i],
228            parent: p.parent.map(|pi| node_id[pi]).unwrap_or(0),
229            sort_order,
230            is_folder: p.is_folder,
231            name: p.name.clone(),
232            track_ids: ids,
233        });
234    }
235
236    Export {
237        tracks: rows_out,
238        artists: artists.rows,
239        albums: albums_out,
240        genres: genres.rows,
241        labels: labels.rows,
242        keys,
243        playlists,
244        device_name: device_name.to_string(),
245        export_date: export_date.to_string(),
246    }
247}
248
249/// Today's date as `YYYY-MM-DD` (UTC), without pulling in a date crate.
250pub fn today() -> String {
251    let secs = std::time::SystemTime::now()
252        .duration_since(std::time::UNIX_EPOCH)
253        .map(|d| d.as_secs())
254        .unwrap_or(0);
255    let days = (secs / 86400) as i64;
256    // Howard Hinnant's civil-from-days
257    let z = days + 719468;
258    let era = z.div_euclid(146097);
259    let doe = z.rem_euclid(146097);
260    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
261    let y = yoe + era * 400;
262    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
263    let mp = (5 * doy + 2) / 153;
264    let d = doy - (153 * mp + 2) / 5 + 1;
265    let m = if mp < 10 { mp + 3 } else { mp - 9 };
266    let y = if m <= 2 { y + 1 } else { y };
267    format!("{y:04}-{m:02}-{d:02}")
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn extension_wins_over_a_wrong_kind() {
276        assert_eq!(
277            file_type_for("MP3 File", "Transfer.flac"),
278            rows::FILE_TYPE_FLAC
279        );
280        assert_eq!(file_type_for("ALAC File", "a.m4a"), rows::FILE_TYPE_ALAC);
281        assert_eq!(file_type_for("M4A File", "a.m4a"), rows::FILE_TYPE_M4A);
282        assert_eq!(
283            file_type_for("WAV File", "no-extension"),
284            rows::FILE_TYPE_WAV
285        );
286    }
287
288    fn keyed(tonality: &[&str]) -> Vec<DeviceTrack> {
289        tonality
290            .iter()
291            .map(|k| DeviceTrack {
292                track: Track {
293                    tonality: k.to_string(),
294                    ..Default::default()
295                },
296                usb_path: String::new(),
297                anlz_dir: String::new(),
298                anlz_index: 0,
299                file_size: 0,
300                sample_depth: 0,
301                file_type: 0,
302                bitrate: 0,
303                sample_rate: 0,
304            })
305            .collect()
306    }
307
308    fn names(rows: &[(u32, String)]) -> Vec<(u32, &str)> {
309        rows.iter().map(|(id, n)| (*id, n.as_str())).collect()
310    }
311
312    #[test]
313    fn keys_keep_the_xml_spelling_in_wheel_order() {
314        // #116: a Classic export has to show Cm on the player, like rekordbox's.
315        let rows = key_rows(&keyed(&["Cm", "Ab", "Cm", "", "A minor", " Abm "]));
316        assert_eq!(names(&rows), [(1, "Abm"), (2, "Ab"), (3, "Cm")]);
317        // Both spellings of one key get their own row, as on a rekordbox stick.
318        let rows = key_rows(&keyed(&["8A", "Am", "1A"]));
319        assert_eq!(names(&rows), [(1, "1A"), (2, "8A"), (3, "Am")]);
320        assert!(key_rows(&keyed(&["", "x"])).is_empty());
321    }
322
323    #[test]
324    fn today_is_a_date() {
325        let t = today();
326        assert_eq!(t.len(), 10);
327        assert!(t.starts_with("20"));
328    }
329}