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        let bytes = |at: usize| -> Option<[u8; 2]> { head.get(at..at + 2)?.try_into().ok() };
40        if head.starts_with(b"fLaC") {
41            // STREAMINFO is the first metadata block: bits-per-sample is 5 bits at byte 12 of the block body
42            let [a, b] = bytes(20)?;
43            return Some(((((a & 1) as u16) << 4) | (b >> 4) as u16) + 1);
44        }
45        if head.starts_with(b"FORM") && matches!(head.get(8..12), Some(b"AIFF" | b"AIFC")) {
46            let comm = chunk(&head, b"COMM", true)?;
47            return Some(u16::from_be_bytes(comm.get(6..8)?.try_into().ok()?));
48        }
49        if head.starts_with(b"RIFF") && head.get(8..12) == Some(b"WAVE") {
50            let fmt = chunk(&head, b"fmt ", false)?;
51            return Some(u16::from_le_bytes(fmt.get(14..16)?.try_into().ok()?));
52        }
53        // MP4/M4A: look for an `alac` sample entry; AAC has no meaningful depth.
54        if let Some(i) = head
55            .windows(4)
56            .position(|w| w == b"alac")
57            .filter(|_| head.get(4..8) == Some(b"ftyp"))
58        {
59            // the alac box repeats its tag; the ALACSpecificConfig has bitDepth at +9 after the inner tag
60            if let Some(j) = head[i + 4..].windows(4).position(|w| w == b"alac") {
61                let cfg = i + 4 + j + 4 + 4;
62                return head.get(cfg + 5).map(|&b| b as u16);
63            }
64        }
65        None
66    }
67    read(path).filter(|d| (8..=32).contains(d)).unwrap_or(16)
68}
69
70/// Payload of the first RIFF/FORM chunk `id` within `buf`, if all of it is there.
71fn chunk<'a>(buf: &'a [u8], id: &[u8; 4], big_endian: bool) -> Option<&'a [u8]> {
72    let mut off = 12;
73    while off + 8 <= buf.len() {
74        let size: [u8; 4] = buf[off + 4..off + 8].try_into().ok()?;
75        let len = if big_endian {
76            u32::from_be_bytes(size)
77        } else {
78            u32::from_le_bytes(size)
79        } as usize;
80        if &buf[off..off + 4] == id {
81            return buf.get(off + 8..off + 8 + len);
82        }
83        off += 8 + len + (len & 1);
84    }
85    None
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 a_truncated_header_reports_16_instead_of_panicking() {
103        let path = std::env::temp_dir().join(format!("baken-depth-{}", std::process::id()));
104        for head in [
105            &b"fLaC\0\0"[..],
106            b"FORM\0\0\0\0AI",
107            b"RIFF\0\0\0\0WAVEfmt \x10\0\0\0",
108        ] {
109            std::fs::write(&path, head).unwrap();
110            assert_eq!(sample_depth(&path), 16);
111        }
112        let _ = std::fs::remove_file(&path);
113    }
114
115    #[test]
116    fn rekordbox_naming_rules() {
117        let mut l = Layout::default();
118        assert_eq!(
119            l.assign(&track(
120                "Lidvall",
121                "The Strange Guy EP",
122                "Lidvall - The Strange Guy EP - 05 Where have you been-.flac"
123            )),
124            "/Contents/Lidvall/The Strange Guy EP/Lidvall - The Strange Guy EP - 05 Where hav.flac"
125        );
126        assert_eq!(
127            l.assign(&track("6EJOU ", "", "6EJOU - Psycho Dreams.aif")),
128            "/Contents/6EJOU/UnknownAlbum/6EJOU - Psycho Dreams.aif"
129        );
130        assert_eq!(
131            l.assign(&track("Oktobr", "CRVA002 | ANNIVERSARY", "x.flac")),
132            "/Contents/Oktobr/CRVA002 _ ANNIVERSARY/x.flac"
133        );
134        // second file truncating to the same stem gets a suffix
135        let a = l.assign(&track(
136            "T.A.M",
137            "",
138            "T.A.M - Raw-Hypnotic 2023 - Year Compilation - 11 Speedy Hypno.flac",
139        ));
140        let b = l.assign(&track(
141            "T.A.M",
142            "",
143            "T.A.M - Raw-Hypnotic 2023 - Year Compilation - 12 Other.flac",
144        ));
145        assert_eq!(
146            a,
147            "/Contents/T.A.M/UnknownAlbum/T.A.M - Raw-Hypnotic 2023 - Year Compilatio.flac"
148        );
149        assert_eq!(
150            b,
151            "/Contents/T.A.M/UnknownAlbum/T.A.M - Raw-Hypnotic 2023 - Year Compilat-1.flac"
152        );
153        assert_eq!(
154            l.assign(&track("", "", "盾.mp3")),
155            "/Contents/UnknownArtist/UnknownAlbum/盾.mp3"
156        );
157    }
158}