1use std::path::Path;
4
5use unicode_normalization::UnicodeNormalization;
6
7use crate::config::Config;
8use crate::format::{self, FormatError};
9use crate::meta::{Tags, Track};
10use crate::musicbrainz::Release;
11
12const COMPONENT_BYTES: usize = 240;
16
17fn year(date: Option<&str>) -> Option<String> {
20 date.and_then(|d| d.get(..4))
21 .filter(|y| y.chars().all(|c| c.is_ascii_digit()) && *y != "0000")
22 .map(str::to_string)
23}
24
25pub fn render(
33 cfg: &Config,
34 tags: &Tags,
35 local: &Track,
36 release: Option<&Release>,
37) -> Result<String, PathError> {
38 let date_year = year(tags.date.as_deref());
39 let original_year = year(tags.original_date.as_deref());
40 let shown_year = if cfg.original_date {
41 original_year.clone().or(date_year.clone())
42 } else {
43 date_year.clone()
44 };
45 let album_type = release
46 .and_then(|r| r.release_group.as_ref())
47 .and_then(|g| g.primary_type.clone());
48 let fields = |name: &str| -> Option<String> {
49 let raw = match name {
50 "album artist" | "albumartist" => Some(tags.album_artist.clone()),
51 "artist" => Some(tags.artist.clone()),
52 "album" => Some(tags.album.clone()),
53 "title" => Some(tags.title.clone()),
54 "tracknumber" | "track" => Some(tags.track.to_string()),
55 "totaltracks" => Some(tags.track_total.to_string()),
56 "discnumber" | "disc" => Some(tags.disc.to_string()),
57 "totaldiscs" => Some(tags.disc_total.to_string()),
58 "year" => shown_year.clone(),
59 "original year" => original_year.clone(),
60 "date" => {
61 if cfg.original_date {
62 tags.original_date.clone().or(tags.date.clone())
63 } else {
64 tags.date.clone()
65 }
66 }
67 "codec" | "format" => Some(local.format.clone()),
68 "label" => tags.label.clone(),
69 "catalog number" => tags.catalog_number.clone(),
70 "country" => tags.country.clone(),
71 "media" => tags.media.clone(),
72 "album type" => album_type.clone(),
73 "musicbrainz album id" => tags.mb_album_id.clone(),
74 "bitdepth" => local.bit_depth.map(|b| b.to_string()),
75 "samplerate" => local.sample_rate.map(|r| r.to_string()),
76 _ => None,
77 }?;
78 let v = clean_value(cfg, &raw);
79 (!v.is_empty()).then_some(v)
80 };
81 let template = if tags.compilation {
82 &cfg.path_comp
83 } else {
84 &cfg.path_default
85 };
86 let rendered = format::format(template, &fields)?;
87 let parts: Vec<String> = rendered
88 .split('/')
89 .map(|c| truncate(&clean(cfg, c), COMPONENT_BYTES))
90 .collect();
91 if let Some(bad) = parts
95 .iter()
96 .find(|c| c.is_empty() || *c == "." || *c == "..")
97 {
98 return Err(PathError::Component {
99 template: template.clone(),
100 rendered: rendered.clone(),
101 part: bad.clone(),
102 });
103 }
104 Ok(parts.join("/"))
105}
106
107#[derive(Debug, thiserror::Error)]
108pub enum PathError {
109 #[error("template: {0}")]
110 Template(#[from] FormatError),
111 #[error(
112 "template {template:?} rendered {rendered:?}, which has an empty or relative component {part:?}"
113 )]
114 Component {
115 template: String,
116 rendered: String,
117 part: String,
118 },
119 #[error("{0} is outside the library")]
120 Escapes(std::path::PathBuf),
121}
122
123pub fn under(root: &Path, rel: &str) -> Result<std::path::PathBuf, PathError> {
127 let path = root.join(rel);
128 let clean = path.components().all(|c| {
129 matches!(
130 c,
131 std::path::Component::Normal(_)
132 | std::path::Component::RootDir
133 | std::path::Component::Prefix(_)
134 )
135 });
136 if !clean || !path.starts_with(root) {
137 return Err(PathError::Escapes(path));
138 }
139 Ok(existing_case(root, &path))
140}
141
142fn existing_case(root: &Path, path: &Path) -> std::path::PathBuf {
151 let Ok(rel) = path.strip_prefix(root) else {
152 return path.to_path_buf();
153 };
154 let parts: Vec<_> = rel.components().collect();
155 let mut out = root.to_path_buf();
156 let mut absent = false;
158 for (i, part) in parts.iter().enumerate() {
159 let name = part.as_os_str();
160 if absent || i + 1 == parts.len() {
161 out.push(name);
162 continue;
163 }
164 if CASE_SENSITIVE && out.join(name).is_dir() {
168 out.push(name);
169 continue;
170 }
171 let fold =
172 |n: &std::ffi::OsStr| n.to_string_lossy().nfc().collect::<String>().to_lowercase();
173 let want = fold(name);
174 let dirs: Vec<std::ffi::OsString> = std::fs::read_dir(&out)
177 .map(|entries| {
178 entries
179 .flatten()
180 .filter(|e| fold(&e.file_name()) == want && e.path().is_dir())
181 .map(|e| e.file_name())
182 .collect()
183 })
184 .unwrap_or_default();
185 absent = dirs.is_empty();
186 let spelled = dirs
187 .iter()
188 .find(|d| d.as_os_str() == name)
189 .or_else(|| dirs.first())
190 .cloned()
191 .unwrap_or_else(|| name.to_os_string());
192 out.push(spelled);
193 }
194 out
195}
196
197const CASE_SENSITIVE: bool = !cfg!(any(target_os = "macos", windows));
200
201pub fn collision_key(path: &Path) -> String {
205 let s: String = path.to_string_lossy().nfc().collect();
206 if cfg!(target_os = "macos") || cfg!(windows) {
207 s.to_lowercase()
208 } else {
209 s
210 }
211}
212
213fn clean_value(cfg: &Config, s: &str) -> String {
219 let mut s: String = s.nfc().collect();
220 if cfg.asciify_paths {
221 s = deunicode::deunicode(&s);
222 }
223 s.replace(['/', '\0'], "-")
224}
225
226fn clean(cfg: &Config, s: &str) -> String {
228 let mut s = clean_value(cfg, s);
229 for (re, with) in &cfg.replace {
230 s = re.replace_all(&s, with.as_str()).into_owned();
231 }
232 s
233}
234
235fn truncate(s: &str, max: usize) -> String {
236 if s.len() <= max {
237 return s.to_string();
238 }
239 let mut end = max;
240 while !s.is_char_boundary(end) {
241 end -= 1;
242 }
243 s[..end].trim_end().to_string()
244}
245
246pub async fn transfer(from: &Path, to: &Path, move_file: bool) -> std::io::Result<()> {
256 if let Some(dir) = to.parent() {
257 tokio::fs::create_dir_all(dir).await?;
258 }
259 tokio::fs::OpenOptions::new()
260 .write(true)
261 .create_new(true)
262 .open(to)
263 .await?;
264 let result = async {
265 if move_file {
266 match tokio::fs::rename(from, to).await {
267 Ok(()) => return Ok(()),
268 Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => {}
269 Err(e) => return Err(e),
270 }
271 }
272 let tmp = to.with_file_name(format!(
273 ".{}.sift-{}.tmp",
274 to.file_name().unwrap_or_default().to_string_lossy(),
275 std::process::id()
276 ));
277 let from_meta = tokio::fs::metadata(from).await?;
278 let copied = tokio::fs::copy(from, &tmp).await?;
279 let file = tokio::fs::File::open(&tmp).await?;
280 file.sync_all().await?;
281 let expected = from_meta.len();
282 if copied != expected || file.metadata().await?.len() != expected {
283 let _ = tokio::fs::remove_file(&tmp).await;
284 return Err(std::io::Error::other(format!(
285 "short copy of {}",
286 from.display()
287 )));
288 }
289 tokio::fs::rename(&tmp, to).await?;
290 if let Ok(modified) = from_meta.modified() {
293 let to = to.to_path_buf();
294 tokio::task::spawn_blocking(move || {
295 std::fs::File::options()
296 .write(true)
297 .open(&to)?
298 .set_modified(modified)
299 })
300 .await
301 .expect("mtime setter panicked")?;
302 }
303 if move_file {
304 tokio::fs::remove_file(from).await?;
305 }
306 Ok(())
307 }
308 .await;
309 if result.is_err() {
310 if tokio::fs::metadata(to).await.is_ok_and(|m| m.len() == 0) {
312 let _ = tokio::fs::remove_file(to).await;
313 }
314 }
315 result
316}
317
318#[cfg(test)]
319mod tests {
320
321 #[test]
322 fn a_path_under_a_folder_not_yet_made_is_kept_as_written() {
323 let dir = tempfile::tempdir().unwrap();
324 let root = dir.path();
325 std::fs::create_dir_all(root.join("Other")).unwrap();
326 let got = under(root, "New Artist/(2020) Album [FLAC]/01. A.flac").unwrap();
327 assert_eq!(got, root.join("New Artist/(2020) Album [FLAC]/01. A.flac"));
328 }
329
330 #[test]
331 fn a_folder_already_filed_in_another_case_is_reused() {
332 let dir = tempfile::tempdir().unwrap();
333 let root = dir.path();
334 std::fs::create_dir_all(root.join("The Squire of Gothos").join("(2010) Old [MP3]"))
335 .unwrap();
336 let got = under(
337 root,
338 "The Squire Of Gothos/(2010) New [FLAC]/0101. Track.flac",
339 )
340 .unwrap();
341 assert_eq!(
342 got,
343 root.join("The Squire of Gothos")
344 .join("(2010) New [FLAC]")
345 .join("0101. Track.flac")
346 );
347 let fresh = under(root, "Someone Else/Album/01. T.flac").unwrap();
349 assert_eq!(
350 fresh,
351 root.join("Someone Else").join("Album").join("01. T.flac")
352 );
353 }
354
355 use super::*;
356
357 fn release() -> Release {
358 serde_json::from_value(serde_json::json!({"id": "r", "title": "x", "media": []})).unwrap()
359 }
360
361 #[test]
362 fn slashes_in_values_never_become_directories() {
363 let cfg = Config {
364 directory: "/m".into(),
365 ..Config::default()
366 };
367 let tags = Tags {
368 album_artist: "AC/DC".into(),
369 album: "Live: 1991".into(),
370 title: "T.N.T.".into(),
371 track: 3,
372 ..Default::default()
373 };
374 let local = Track {
375 format: "FLAC".into(),
376 ..Default::default()
377 };
378 assert_eq!(
379 render(&cfg, &tags, &local, Some(&release())).unwrap(),
380 "AC-DC/Live_ 1991/03 T.N.T_"
381 );
382 }
383
384 #[test]
385 fn edge_replacements_apply_to_the_edges_of_a_component_only() {
386 let cfg = Config {
387 directory: "/m".into(),
388 path_default: "%album artist%/%album% x/%title%".into(),
389 replace: vec![
390 (regex::Regex::new(r"\.$").unwrap(), "-".into()),
391 (regex::Regex::new(r"^\.").unwrap(), "-".into()),
392 ],
393 ..Config::default()
394 };
395 let tags = Tags {
396 album_artist: "R.E.M.".into(),
397 album: "The Vertigo E.P.".into(),
398 title: "...Kill All Your Friends...".into(),
399 ..Default::default()
400 };
401 assert_eq!(
402 render(&cfg, &tags, &Track::default(), None).unwrap(),
403 "R.E.M-/The Vertigo E.P. x/-..Kill All Your Friends..-"
404 );
405 }
406
407 #[test]
408 fn a_zero_original_year_falls_back_to_the_release_year() {
409 let cfg = Config {
410 directory: "/m".into(),
411 path_default: "[(%year%) ]%album%".into(),
412 original_date: true,
413 ..Config::default()
414 };
415 let tags = Tags {
416 album: "Swine Flu".into(),
417 date: Some("2009-03-01".into()),
418 original_date: Some("0000".into()),
419 ..Default::default()
420 };
421 assert_eq!(
422 render(&cfg, &tags, &Track::default(), None).unwrap(),
423 "(2009) Swine Flu"
424 );
425 }
426
427 #[test]
428 fn long_names_are_cut_on_a_character_boundary() {
429 let s = "é".repeat(200);
430 let t = truncate(&s, 241);
431 assert_eq!(t.len(), 240);
432 assert!(t.is_char_boundary(t.len()));
433 }
434
435 #[test]
436 fn empty_components_are_refused_not_dropped() {
437 let cfg = Config {
438 directory: "/m".into(),
439 path_default: "%album artist%/%genre%/%title%".into(),
440 ..Config::default()
441 };
442 let tags = Tags {
443 album_artist: "A".into(),
444 title: "T".into(),
445 ..Default::default()
446 };
447 assert!(matches!(
448 render(&cfg, &tags, &Track::default(), Some(&release())),
449 Err(PathError::Component { .. })
450 ));
451 }
452
453 #[test]
454 fn nothing_escapes_the_library() {
455 assert!(under(Path::new("/m"), "a/b.flac").is_ok());
456 assert!(under(Path::new("/m"), "../etc/passwd").is_err());
457 }
458
459 #[tokio::test]
460 async fn transfer_never_replaces_an_existing_file() {
461 let dir = tempfile::tempdir().unwrap();
462 let from = dir.path().join("a.flac");
463 let to = dir.path().join("b.flac");
464 std::fs::write(&from, b"new").unwrap();
465 std::fs::write(&to, b"precious").unwrap();
466 assert!(transfer(&from, &to, true).await.is_err());
467 assert_eq!(std::fs::read(&to).unwrap(), b"precious");
468 assert!(from.exists());
469 }
470
471 #[tokio::test]
472 async fn transfer_moves_and_creates_directories() {
473 let dir = tempfile::tempdir().unwrap();
474 let from = dir.path().join("a.flac");
475 std::fs::write(&from, b"x").unwrap();
476 let to = dir.path().join("lib/Artist/Album/01 a.flac");
477 transfer(&from, &to, true).await.unwrap();
478 assert!(!from.exists());
479 assert_eq!(std::fs::read(&to).unwrap(), b"x");
480 }
481
482 #[tokio::test]
487 async fn a_copy_keeps_the_source_mtime() {
488 let dir = tempfile::tempdir().unwrap();
489 let from = dir.path().join("a.flac");
490 std::fs::write(&from, b"x").unwrap();
491 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(3600);
492 std::fs::File::options()
493 .write(true)
494 .open(&from)
495 .unwrap()
496 .set_modified(past)
497 .unwrap();
498 let to = dir.path().join("lib/Artist/Album/01 a.flac");
499 transfer(&from, &to, false).await.unwrap();
500 let got = std::fs::metadata(&to).unwrap().modified().unwrap();
501 assert_eq!(
502 got.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(),
503 past.duration_since(std::time::UNIX_EPOCH)
504 .unwrap()
505 .as_secs()
506 );
507 }
508}