Skip to main content

max / audiofiles

7.5 KB · 255 lines History Blame Raw
1 //! Shared path and file utilities used across audiofiles crates.
2
3 use std::path::Path;
4
5 /// Audio file extensions recognised throughout audiofiles.
6 ///
7 /// `bwf` is WAV with a `bext` chunk; symphonia decodes it as WAV.
8 /// `m4a`/`alac` route through symphonia's `isomp4` format with `aac`/`alac`
9 /// codecs. `caf` uses the `caf` format with `pcm`/`alac`.
10 ///
11 /// Missing on purpose: `opus` (no symphonia codec), `w64` (Wave64; no
12 /// symphonia format). Add when upstream support lands or we swap decoders.
13 pub const AUDIO_EXTENSIONS: &[&str] = &[
14 "wav", "flac", "mp3", "ogg", "aiff", "aif", "m4a", "alac", "caf", "bwf",
15 ];
16
17 /// Get the lowercase file extension, or empty string if none.
18 pub fn get_extension(path: &Path) -> String {
19 path.extension()
20 .and_then(|e| e.to_str())
21 .unwrap_or("")
22 .to_lowercase()
23 }
24
25 /// Extract the filename as a string, with a fallback default.
26 pub fn get_filename(path: &Path, default: &str) -> String {
27 path.file_name()
28 .and_then(|n| n.to_str())
29 .unwrap_or(default)
30 .to_string()
31 }
32
33 /// Split a filename into its stem and extension at the last dot.
34 ///
35 /// Returns `("name", "ext")` for `"name.ext"`, and `("name", "")` for
36 /// extensionless names. Dotfiles like `".hidden"` are treated as having no
37 /// extension (the leading dot is part of the stem, not a separator) because
38 /// `rfind('.')` at position 0 is excluded by the `pos > 0` guard.
39 pub fn split_name_ext(filename: &str) -> (String, String) {
40 match filename.rfind('.') {
41 Some(pos) if pos > 0 => (filename[..pos].to_string(), filename[pos + 1..].to_string()),
42 _ => (filename.to_string(), String::new()),
43 }
44 }
45
46 /// Check whether a path has an audio file extension.
47 ///
48 /// Rejects macOS resource fork sidecar files (`._*.wav` etc.) which carry an
49 /// audio extension but contain binary metadata, not audio data. These files
50 /// are invisible on macOS but appear as regular files on Linux.
51 pub fn is_audio_file(path: &Path) -> bool {
52 if is_macos_resource_fork(path) {
53 return false;
54 }
55 let ext = get_extension(path);
56 AUDIO_EXTENSIONS.contains(&ext.as_str())
57 }
58
59 /// Returns `true` for macOS resource fork sidecar files (`._*`) and `.DS_Store`.
60 ///
61 /// These are metadata files that macOS creates alongside real files. They often
62 /// survive in zip archives and extracted folders transferred to Linux, where
63 /// they are visible as regular files.
64 pub fn is_macos_resource_fork(path: &Path) -> bool {
65 let name = path
66 .file_name()
67 .and_then(|n| n.to_str())
68 .unwrap_or("");
69 name.starts_with("._") || name == ".DS_Store"
70 }
71
72 /// Returns `true` for macOS system directories that should be skipped during
73 /// directory traversal (e.g. `__MACOSX` from zip extraction, Spotlight indexes).
74 pub fn is_macos_metadata_dir(path: &Path) -> bool {
75 let name = path
76 .file_name()
77 .and_then(|n| n.to_str())
78 .unwrap_or("");
79 matches!(name, "__MACOSX" | ".Spotlight-V100" | ".fseventsd" | ".Trashes")
80 }
81
82 #[cfg(test)]
83 mod tests {
84 use super::*;
85 use std::path::Path;
86
87 #[test]
88 fn get_extension_wav() {
89 assert_eq!(get_extension(Path::new("kick.wav")), "wav");
90 }
91
92 #[test]
93 fn get_extension_uppercase() {
94 assert_eq!(get_extension(Path::new("kick.WAV")), "wav");
95 }
96
97 #[test]
98 fn get_extension_no_ext() {
99 assert_eq!(get_extension(Path::new("noext")), "");
100 }
101
102 #[test]
103 fn get_extension_dotfile() {
104 assert_eq!(get_extension(Path::new(".hidden")), "");
105 }
106
107 #[test]
108 fn get_extension_double_dot() {
109 assert_eq!(get_extension(Path::new("file.tar.gz")), "gz");
110 }
111
112 #[test]
113 fn get_filename_normal() {
114 assert_eq!(get_filename(Path::new("/home/user/kick.wav"), "unknown"), "kick.wav");
115 }
116
117 #[test]
118 fn get_filename_root_path() {
119 assert_eq!(get_filename(Path::new("/"), "fallback"), "fallback");
120 }
121
122 #[test]
123 fn get_filename_empty_path() {
124 assert_eq!(get_filename(Path::new(""), "default"), "default");
125 }
126
127 #[test]
128 fn is_audio_file_wav() {
129 assert!(is_audio_file(Path::new("kick.wav")));
130 }
131
132 #[test]
133 fn is_audio_file_flac_uppercase() {
134 assert!(is_audio_file(Path::new("pad.FLAC")));
135 }
136
137 #[test]
138 fn is_audio_file_mp3() {
139 assert!(is_audio_file(Path::new("song.mp3")));
140 }
141
142 #[test]
143 fn is_audio_file_ogg() {
144 assert!(is_audio_file(Path::new("loop.ogg")));
145 }
146
147 #[test]
148 fn is_audio_file_aiff() {
149 assert!(is_audio_file(Path::new("strings.aiff")));
150 }
151
152 #[test]
153 fn is_audio_file_aif() {
154 assert!(is_audio_file(Path::new("brass.AIF")));
155 }
156
157 #[test]
158 fn is_audio_file_extended_formats() {
159 assert!(is_audio_file(Path::new("vocal.m4a")));
160 assert!(is_audio_file(Path::new("loop.alac")));
161 assert!(is_audio_file(Path::new("snare.caf")));
162 assert!(is_audio_file(Path::new("field.bwf")));
163 assert!(is_audio_file(Path::new("FIELD.BWF")));
164 }
165
166 #[test]
167 fn is_audio_file_txt_rejected() {
168 assert!(!is_audio_file(Path::new("readme.txt")));
169 }
170
171 #[test]
172 fn is_audio_file_png_rejected() {
173 assert!(!is_audio_file(Path::new("photo.png")));
174 }
175
176 #[test]
177 fn is_audio_file_no_ext_rejected() {
178 assert!(!is_audio_file(Path::new("noext")));
179 }
180
181 #[test]
182 fn split_name_ext_normal() {
183 let (stem, ext) = split_name_ext("file.wav");
184 assert_eq!(stem, "file");
185 assert_eq!(ext, "wav");
186 }
187
188 #[test]
189 fn split_name_ext_no_extension() {
190 let (stem, ext) = split_name_ext("file");
191 assert_eq!(stem, "file");
192 assert_eq!(ext, "");
193 }
194
195 #[test]
196 fn split_name_ext_multiple_dots() {
197 let (stem, ext) = split_name_ext("my.file.wav");
198 assert_eq!(stem, "my.file");
199 assert_eq!(ext, "wav");
200 }
201
202 // --- macOS resource fork / metadata filtering ---
203
204 #[test]
205 fn resource_fork_wav_rejected() {
206 assert!(!is_audio_file(Path::new("._kick.wav")));
207 }
208
209 #[test]
210 fn resource_fork_aiff_rejected() {
211 assert!(!is_audio_file(Path::new("._strings.aiff")));
212 }
213
214 #[test]
215 fn resource_fork_flac_rejected() {
216 assert!(!is_audio_file(Path::new("._pad.flac")));
217 }
218
219 #[test]
220 fn resource_fork_in_subdir_rejected() {
221 assert!(!is_audio_file(Path::new("/samples/drums/._snare.wav")));
222 }
223
224 #[test]
225 fn resource_fork_detected() {
226 assert!(is_macos_resource_fork(Path::new("._kick.wav")));
227 assert!(is_macos_resource_fork(Path::new("/path/to/._file.aiff")));
228 assert!(is_macos_resource_fork(Path::new(".DS_Store")));
229 }
230
231 #[test]
232 fn normal_files_not_resource_fork() {
233 assert!(!is_macos_resource_fork(Path::new("kick.wav")));
234 assert!(!is_macos_resource_fork(Path::new(".hidden.wav")));
235 assert!(!is_macos_resource_fork(Path::new("my_file.flac")));
236 }
237
238 #[test]
239 fn macos_metadata_dirs_detected() {
240 assert!(is_macos_metadata_dir(Path::new("__MACOSX")));
241 assert!(is_macos_metadata_dir(Path::new("/path/to/__MACOSX")));
242 assert!(is_macos_metadata_dir(Path::new(".Spotlight-V100")));
243 assert!(is_macos_metadata_dir(Path::new(".fseventsd")));
244 assert!(is_macos_metadata_dir(Path::new(".Trashes")));
245 }
246
247 #[test]
248 fn normal_dirs_not_metadata() {
249 assert!(!is_macos_metadata_dir(Path::new("samples")));
250 assert!(!is_macos_metadata_dir(Path::new("drums")));
251 assert!(!is_macos_metadata_dir(Path::new(".hidden_dir")));
252 }
253
254 }
255