Skip to main content

sift/
paths.rs

1//! Where a track goes, and getting it there.
2
3use 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
12/// A path component's byte ceiling, extension included. Common filesystems
13/// cap a name at 255 bytes; this leaves room for the extension and for a
14/// `.part` or temporary suffix during a move.
15const COMPONENT_BYTES: usize = 240;
16
17/// The year of a date, if it has one. Beets writes `0000` for an unknown
18/// original date, and means nothing by it.
19fn 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
25/// The library-relative path for one track, without extension.
26///
27/// Field values are cleaned before they are substituted, so a slash in a
28/// title ("AC/DC") cannot become a directory; the template's own slashes are
29/// the only separators. Components are then cleaned again for the rules that
30/// only make sense at the edges of a name, and NFC-normalised so the same
31/// title written on macOS and on Linux is the same bytes.
32pub 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    // An empty component means a field the template needed was missing.
92    // Dropping it would file tracks one level up, and a typo'd template
93    // could collapse a whole album onto one name, so it is refused.
94    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
123/// `rel` under `root`, refusing anything that would land outside it. The
124/// renderer already prevents this; tags are untrusted input, so it is checked
125/// again where it matters.
126pub 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
142/// `path` with each directory below `root` spelled as one already on disk
143/// when the two differ only in case.
144///
145/// Tags spell an act "The Squire Of Gothos" on one record and "of" on the
146/// next. On a case-sensitive disk that files them in two folders; on a
147/// case-insensitive one it makes the second import collide with the first.
148/// Reusing the folder that is already there keeps an artist in one place, in
149/// the spelling it was first filed under.
150fn 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    // Below a directory that does not exist yet, nothing does.
157    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        // On a case-sensitive disk a directory of exactly this name is the
165        // answer, for one stat. On a case-insensitive one it "exists" in any
166        // case, and the question is how it is spelled, so the listing is read.
167        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        // Names first: only an entry that matches is asked whether it is a
175        // directory, so a listing of thousands of artists costs one read.
176        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
197/// Whether the disk tells `Rain` from `RAIN`: the same split as
198/// [`collision_key`].
199const CASE_SENSITIVE: bool = !cfg!(any(target_os = "macos", windows));
200
201/// How two destination names compare on the filesystem they land on:
202/// case-insensitively on macOS, where `Rain.flac` and `RAIN.flac` are one
203/// file.
204pub 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
213/// A field value before it is substituted: normalised, and with any
214/// separator made safe, since a separator inside a value is never meant as
215/// one. The configured replacements wait for the whole component, as in
216/// beets: `^\.` and `\.$` describe the edges of a name, and "E.P." in the
217/// middle of an album folder's name is not at one.
218fn 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
226/// A rendered path component, with the configured replacements applied.
227fn 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
246/// Move (or copy) `from` to `to`, creating directories, never replacing an
247/// existing file.
248///
249/// The destination name is claimed with `create_new` first, so nothing can
250/// appear in the gap between checking and moving. Within a filesystem the
251/// file is then renamed over its own claim. Across filesystems it is copied
252/// to a temporary sibling, synced, length-checked and renamed into place, so
253/// a crash leaves either the old state or the whole file, never a truncated
254/// one under the real name.
255pub 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        // A copy gets a fresh mtime; a move of the same file should never
291        // look newer than the original just because it crossed filesystems.
292        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        // Release the claim, but only if it is still the empty placeholder.
311        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        // Nothing to match: filed as the tags spell it.
348        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    /// A copy takes the destination through a temporary file, which would
483    /// otherwise leave it with a fresh mtime; `transfer` restores the
484    /// source's. `move_file: false` always goes through the copy path, so a
485    /// same-filesystem temp dir exercises it without needing two devices.
486    #[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}