Skip to main content

baken_export/
layout.rs

1//! Where audio goes on the stick: `/Contents/<Artist>/<Album>/<file>`, the
2//! way rekordbox lays it out. Names are FAT32-safe, stems are cut at 43
3//! characters like rekordbox does, and collisions get a `-1`, `-2` suffix.
4
5use crate::collection::Track;
6use baken_core::cdjsafe::stick_path;
7use std::collections::HashSet;
8use std::path::Path;
9
10#[derive(Debug, Default)]
11pub struct Layout {
12    used: HashSet<String>,
13}
14
15impl Layout {
16    /// USB-relative path for `track`, unique within this layout.
17    pub fn assign(&mut self, track: &Track) -> String {
18        let path =
19            |suffix: &str| stick_path(&track.artist, &track.album, track.file_name(), suffix);
20        let mut candidate = path("");
21        let mut n = 1;
22        while !self.used.insert(candidate.to_lowercase()) {
23            candidate = path(&format!("-{n}"));
24            n += 1;
25        }
26        candidate
27    }
28}
29
30/// Bit depth of a lossless file from its header; lossy formats and anything
31/// unreadable report 16, which is what rekordbox writes for them.
32pub fn sample_depth(path: &Path) -> u16 {
33    fn read(path: &Path) -> Option<u16> {
34        use std::io::Read;
35        let mut f = std::fs::File::open(path).ok()?;
36        let mut head = vec![0u8; 64 * 1024];
37        let n = f.read(&mut head).ok()?;
38        head.truncate(n);
39        if head.starts_with(b"fLaC") {
40            // STREAMINFO is the first metadata block: bits-per-sample is 5 bits at byte 12 of the block body
41            let body = &head[8..];
42            let bps = (((body[12] & 1) as u16) << 4) | ((body[13] >> 4) as u16);
43            return Some(bps + 1);
44        }
45        if head.starts_with(b"FORM") && &head[8..12] == b"AIFF"
46            || head.starts_with(b"FORM") && &head[8..12] == b"AIFC"
47        {
48            let mut off = 12;
49            while off + 8 <= head.len() {
50                let len = u32::from_be_bytes(head[off + 4..off + 8].try_into().ok()?) as usize;
51                if &head[off..off + 4] == b"COMM" {
52                    return Some(u16::from_be_bytes(
53                        head[off + 14..off + 16].try_into().ok()?,
54                    ));
55                }
56                off += 8 + len + (len & 1);
57            }
58        }
59        if head.starts_with(b"RIFF") && &head[8..12] == b"WAVE" {
60            let mut off = 12;
61            while off + 8 <= head.len() {
62                let len = u32::from_le_bytes(head[off + 4..off + 8].try_into().ok()?) as usize;
63                if &head[off..off + 4] == b"fmt " {
64                    return Some(u16::from_le_bytes(
65                        head[off + 22..off + 24].try_into().ok()?,
66                    ));
67                }
68                off += 8 + len + (len & 1);
69            }
70        }
71        // MP4/M4A: look for an `alac` sample entry; AAC has no meaningful depth.
72        if let Some(i) = head
73            .windows(4)
74            .position(|w| w == b"alac")
75            .filter(|_| &head[4..8] == b"ftyp")
76        {
77            // the alac box repeats its tag; the ALACSpecificConfig has bitDepth at +9 after the inner tag
78            if let Some(j) = head[i + 4..].windows(4).position(|w| w == b"alac") {
79                let cfg = i + 4 + j + 4 + 4;
80                return head.get(cfg + 5).map(|&b| b as u16);
81            }
82        }
83        None
84    }
85    read(path).filter(|d| (8..=32).contains(d)).unwrap_or(16)
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    fn track(artist: &str, album: &str, file: &str) -> Track {
93        Track {
94            artist: artist.into(),
95            album: album.into(),
96            location: format!("/Volumes/X/{file}"),
97            ..Default::default()
98        }
99    }
100
101    #[test]
102    fn rekordbox_naming_rules() {
103        let mut l = Layout::default();
104        assert_eq!(
105            l.assign(&track(
106                "Lidvall",
107                "The Strange Guy EP",
108                "Lidvall - The Strange Guy EP - 05 Where have you been-.flac"
109            )),
110            "/Contents/Lidvall/The Strange Guy EP/Lidvall - The Strange Guy EP - 05 Where hav.flac"
111        );
112        assert_eq!(
113            l.assign(&track("6EJOU ", "", "6EJOU - Psycho Dreams.aif")),
114            "/Contents/6EJOU/UnknownAlbum/6EJOU - Psycho Dreams.aif"
115        );
116        assert_eq!(
117            l.assign(&track("Oktobr", "CRVA002 | ANNIVERSARY", "x.flac")),
118            "/Contents/Oktobr/CRVA002 _ ANNIVERSARY/x.flac"
119        );
120        // second file truncating to the same stem gets a suffix
121        let a = l.assign(&track(
122            "T.A.M",
123            "",
124            "T.A.M - Raw-Hypnotic 2023 - Year Compilation - 11 Speedy Hypno.flac",
125        ));
126        let b = l.assign(&track(
127            "T.A.M",
128            "",
129            "T.A.M - Raw-Hypnotic 2023 - Year Compilation - 12 Other.flac",
130        ));
131        assert_eq!(
132            a,
133            "/Contents/T.A.M/UnknownAlbum/T.A.M - Raw-Hypnotic 2023 - Year Compilatio.flac"
134        );
135        assert_eq!(
136            b,
137            "/Contents/T.A.M/UnknownAlbum/T.A.M - Raw-Hypnotic 2023 - Year Compilat-1.flac"
138        );
139        assert_eq!(
140            l.assign(&track("", "", "盾.mp3")),
141            "/Contents/UnknownArtist/UnknownAlbum/盾.mp3"
142        );
143    }
144}