Skip to main content

baken_export/pdb/
rows.rs

1//! Row encoders for every table `expressport` writes. Layouts were read off
2//! a real rekordbox 7 export; see the fixture notes for the byte dumps.
3
4use super::string::encode;
5
6fn u32s(out: &mut Vec<u8>, vals: &[u32]) {
7    for v in vals {
8        out.extend_from_slice(&v.to_le_bytes());
9    }
10}
11
12fn align4(n: usize) -> usize {
13    (n + 3) & !3
14}
15
16/// Where an encoded string goes when the row so far is `at` bytes long.
17/// rekordbox packs ASCII strings but starts every UTF-16 string on a 4-byte
18/// boundary of the row (every track, artist and album row of the reference
19/// export). The stick a CDJ-2000NXS2 froze on had them unaligned (#116).
20fn string_at(at: usize, encoded: &[u8]) -> usize {
21    if encoded[0] == 0x90 {
22        align4(at)
23    } else {
24        at
25    }
26}
27
28/// genres (0x01), labels (0x04): `id, name`.
29pub fn named(id: u32, name: &str) -> Vec<u8> {
30    let mut r = Vec::new();
31    u32s(&mut r, &[id]);
32    r.extend(encode(name));
33    r
34}
35
36/// artists (0x02): `0x60, index_shift, id, 0x03, name_offset, name`.
37pub fn artist(position_in_page: usize, id: u32, name: &str) -> Vec<u8> {
38    let mut r = Vec::new();
39    r.extend_from_slice(&0x60u16.to_le_bytes());
40    r.extend_from_slice(&((position_in_page as u16) * 0x20).to_le_bytes());
41    u32s(&mut r, &[id]);
42    with_name(r, name)
43}
44
45/// albums (0x03): `0x80, index_shift, 0, artist_id, id, 0, 0x03, name_offset, name`.
46pub fn album(position_in_page: usize, id: u32, artist_id: u32, name: &str) -> Vec<u8> {
47    let mut r = Vec::new();
48    r.extend_from_slice(&0x80u16.to_le_bytes());
49    r.extend_from_slice(&((position_in_page as u16) * 0x20).to_le_bytes());
50    u32s(&mut r, &[0, artist_id, id, 0]);
51    with_name(r, name)
52}
53
54/// The `0x03, name_offset, name` tail of artists and albums, allocated
55/// `align4(header) + align4(name) + 4` bytes like rekordbox does.
56fn with_name(mut r: Vec<u8>, name: &str) -> Vec<u8> {
57    let name = encode(name);
58    let header = r.len() + 2;
59    let at = string_at(header, &name);
60    r.extend_from_slice(&[0x03, at as u8]);
61    r.resize(at, 0);
62    r.extend_from_slice(&name);
63    r.resize(align4(header) + align4(name.len()) + 4, 0);
64    r
65}
66
67/// keys (0x05): `id, id, name`.
68pub fn key(id: u32, name: &str) -> Vec<u8> {
69    let mut r = Vec::new();
70    u32s(&mut r, &[id, id]);
71    r.extend(encode(name));
72    r
73}
74
75/// colors (0x06): `0u32, id u8, id u16, 0u8, name`.
76pub fn color(id: u8, name: &str) -> Vec<u8> {
77    let mut r = vec![0, 0, 0, 0, id, id, 0, 0];
78    r.extend(encode(name));
79    r
80}
81
82/// playlist_tree (0x07): `parent, 0, sort_order, id, is_folder, name`.
83pub fn playlist_node(
84    parent: u32,
85    sort_order: u32,
86    id: u32,
87    is_folder: bool,
88    name: &str,
89) -> Vec<u8> {
90    let mut r = Vec::new();
91    u32s(&mut r, &[parent, 0, sort_order, id, is_folder as u32]);
92    r.extend(encode(name));
93    r
94}
95
96/// playlist_entries (0x08): `entry_index, track_id, playlist_id`.
97pub fn playlist_entry(entry_index: u32, track_id: u32, playlist_id: u32) -> Vec<u8> {
98    let mut r = Vec::new();
99    u32s(&mut r, &[entry_index, track_id, playlist_id]);
100    r
101}
102
103/// columns (0x10): `id u16, code u16, name`.
104pub fn column(id: u16, code: u16, name: &str) -> Vec<u8> {
105    let mut r = Vec::new();
106    r.extend_from_slice(&id.to_le_bytes());
107    r.extend_from_slice(&code.to_le_bytes());
108    r.extend(encode(name));
109    r
110}
111
112/// history (0x13): the single "property" row rekordbox writes into an
113/// export: `0x0280, track_count, 0, export_date, 0x19, 0x1e, "1000",
114/// device_name`, zero-padded to 40 bytes. The count is what a CDJ-3000 shows
115/// as Songs (587 on the reference export, 0 on an empty one).
116pub fn history_property(track_count: u32, export_date: &str, device_name: &str) -> Vec<u8> {
117    let mut r = Vec::new();
118    u32s(&mut r, &[0x0280, track_count, 0]);
119    r.extend(encode(export_date));
120    r.extend_from_slice(&[0x19, 0x1e]);
121    r.extend(encode("1000"));
122    let name = encode(device_name);
123    r.resize(string_at(r.len(), &name), 0);
124    r.extend(name);
125    if r.len() < 40 {
126        r.resize(40, 0);
127    }
128    r
129}
130
131/// Everything a `tracks` (0x00) row needs.
132#[derive(Debug, Clone, Default)]
133pub struct TrackRow {
134    pub id: u32,
135    pub sample_rate: u32,
136    pub composer_id: u32,
137    pub file_size: u32,
138    pub artwork_id: u32,
139    pub key_id: u32,
140    pub original_artist_id: u32,
141    pub label_id: u32,
142    pub remixer_id: u32,
143    pub bitrate: u32,
144    pub track_number: u32,
145    /// BPM x 100.
146    pub tempo: u32,
147    pub genre_id: u32,
148    pub album_id: u32,
149    pub artist_id: u32,
150    pub disc_number: u16,
151    pub play_count: u16,
152    pub year: u16,
153    pub sample_depth: u16,
154    pub duration_seconds: u16,
155    pub color_id: u8,
156    /// 0..=5 stars.
157    pub rating: u8,
158    pub file_type: u16,
159    pub date_added: String,
160    pub release_date: String,
161    pub mix_name: String,
162    pub analyze_path: String,
163    pub analyze_date: String,
164    pub comment: String,
165    pub title: String,
166    pub filename: String,
167    pub file_path: String,
168}
169
170pub const FILE_TYPE_MP3: u16 = 1;
171pub const FILE_TYPE_M4A: u16 = 4;
172pub const FILE_TYPE_FLAC: u16 = 5;
173pub const FILE_TYPE_ALAC: u16 = 6;
174pub const FILE_TYPE_WAV: u16 = 11;
175pub const FILE_TYPE_AIFF: u16 = 12;
176
177impl TrackRow {
178    pub fn encode(&self, position_in_page: usize) -> Vec<u8> {
179        let mut r = Vec::with_capacity(0x88 + 256);
180        r.extend_from_slice(&0x24u16.to_le_bytes());
181        r.extend_from_slice(&((position_in_page as u16) * 0x20).to_le_bytes());
182        u32s(
183            &mut r,
184            &[
185                0xC0700,
186                self.sample_rate,
187                self.composer_id,
188                self.file_size,
189                self.id + 20,
190                3933607398,
191                self.artwork_id,
192                self.key_id,
193                self.original_artist_id,
194                self.label_id,
195                self.remixer_id,
196                self.bitrate,
197                self.track_number,
198                self.tempo,
199                self.genre_id,
200                self.album_id,
201                self.artist_id,
202                self.id,
203            ],
204        );
205        for v in [
206            self.disc_number,
207            self.play_count,
208            self.year,
209            self.sample_depth,
210            self.duration_seconds,
211            41,
212        ] {
213            r.extend_from_slice(&v.to_le_bytes());
214        }
215        r.push(self.color_id);
216        r.push(self.rating);
217        r.extend_from_slice(&self.file_type.to_le_bytes());
218        r.extend_from_slice(&3u16.to_le_bytes());
219        debug_assert_eq!(r.len(), 0x5e);
220
221        // The three small decimal strings rekordbox writes vary per track and
222        // look like update counters; "1" is a plausible fresh value.
223        let strings: [&str; 21] = [
224            "",
225            "",
226            "1",
227            "1",
228            "1",
229            "",
230            "ON",
231            "ON",
232            "",
233            "",
234            &self.date_added,
235            &self.release_date,
236            &self.mix_name,
237            "",
238            &self.analyze_path,
239            &self.analyze_date,
240            &self.comment,
241            &self.title,
242            "",
243            &self.filename,
244            &self.file_path,
245        ];
246        let encoded: Vec<Vec<u8>> = strings.iter().map(|s| encode(s)).collect();
247        let mut offsets = Vec::with_capacity(encoded.len());
248        let mut off = 0x88;
249        for e in &encoded {
250            off = string_at(off, e);
251            r.extend_from_slice(&(off as u16).to_le_bytes());
252            offsets.push(off);
253            off += e.len();
254        }
255        debug_assert_eq!(r.len(), 0x88);
256        // rekordbox allocates 0x88 + sum(align4(string)) + 4 per track row,
257        // which always covers the padding before UTF-16 strings.
258        let alloc = 0x88 + encoded.iter().map(|e| align4(e.len())).sum::<usize>() + 4;
259        for (e, at) in encoded.iter().zip(offsets) {
260            r.resize(at, 0);
261            r.extend_from_slice(e);
262        }
263        r.resize(alloc, 0);
264        r
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn fixed_rows_match_fixture_bytes() {
274        assert_eq!(key(1, "1A"), hex("010000000100000007314100")[..11]);
275        assert_eq!(color(1, "Pink"), hex("00000000010100000b50696e6b"));
276        assert_eq!(named(1, "Techno"), hex("010000000f546563686e6f"));
277        let a = artist(1, 2, "tk_elektron");
278        assert_eq!(&a[..10], &hex("6000200002000000030a")[..]);
279        assert_eq!(a.len(), 28); // the next row of the reference export starts 28 bytes later
280        assert_eq!(
281            &album(1, 2, 4, "x")[..22],
282            &hex("80002000000000000400000002000000000000000316")[..]
283        );
284        // UTF-16 names start on a 4-byte boundary: whole rows from the reference export
285        assert_eq!(
286            artist(10, 11, "Rødhåd"),
287            hex("600040010b000000030c0000901000005200f80064006800e500640000000000")
288        );
289        assert_eq!(
290            album(2, 153, 0, "atöm.04"),
291            hex(concat!(
292                "800040000000000000000000990000000000000003180000",
293                "9012000061007400f6006d002e0030003400000000000000"
294            ))
295        );
296        assert_eq!(
297            &playlist_node(0, 1, 3, false, "HT-70min")[..20],
298            &hex("0000000000000000010000000300000000000000")[..]
299        );
300        assert_eq!(
301            &column(1, 0x80, "\u{fffa}GENRE\u{fffb}")[..8],
302            &hex("0100800090120000")[..]
303        );
304        let h = history_property(0, "2024-11-16", "");
305        assert_eq!(h.len(), 40);
306        assert_eq!(
307            &h[..31],
308            &hex("80020000000000000000000017323032342d31312d3136191e0b3130303003")[..]
309        );
310        // the reference export's row, 587 tracks
311        assert_eq!(
312            &history_property(587, "2025-04-11", "")[..8],
313            &hex("800200004b020000")[..]
314        );
315    }
316
317    #[test]
318    fn track_row_layout() {
319        let t = TrackRow {
320            id: 352,
321            title: "Unreal".into(),
322            file_type: FILE_TYPE_MP3,
323            ..Default::default()
324        };
325        let r = t.encode(0);
326        assert_eq!(&r[..4], &[0x24, 0, 0, 0]);
327        assert_eq!(u32::from_le_bytes(r[0x48..0x4c].try_into().unwrap()), 352);
328        assert_eq!(u16::from_le_bytes(r[0x5a..0x5c].try_into().unwrap()), 1);
329        let title_off =
330            u16::from_le_bytes(r[0x5e + 2 * 17..0x60 + 2 * 17].try_into().unwrap()) as usize;
331        assert_eq!(
332            super::super::string::decode(&r, title_off).unwrap().0,
333            "Unreal"
334        );
335    }
336
337    #[test]
338    fn track_row_aligns_utf16_strings() {
339        let t = TrackRow {
340            title: "American Boy 🅴".into(),
341            filename: "01. Crazy In Love.mp3".into(),
342            file_path: "/Contents/Beyoncé; JAŸ-Z/Crazy In Love/01. Crazy In Love.mp3".into(),
343            comment: "2008".into(),
344            ..Default::default()
345        };
346        let r = t.encode(0);
347        let offsets: Vec<usize> = (0..21)
348            .map(|k| u16::from_le_bytes(r[0x5e + 2 * k..0x60 + 2 * k].try_into().unwrap()) as usize)
349            .collect();
350        let mut end = 0x88;
351        for &off in &offsets {
352            let (_, len) = super::super::string::decode(&r, off).unwrap();
353            if r[off] == 0x90 {
354                assert_eq!(off % 4, 0);
355                assert!(r[end..off].iter().all(|&b| b == 0));
356            } else {
357                assert_eq!(off, end, "ASCII strings stay packed");
358            }
359            end = off + len;
360        }
361        assert!(end <= r.len());
362        let text = |k: usize| super::super::string::decode(&r, offsets[k]).unwrap().0;
363        assert_eq!(text(17), "American Boy 🅴");
364        assert_eq!(text(20), t.file_path);
365    }
366
367    fn hex(s: &str) -> Vec<u8> {
368        (0..s.len())
369            .step_by(2)
370            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
371            .collect()
372    }
373}