Skip to main content

sift/
config.rs

1//! Configuration, read from a beets `config.yaml` so an existing setup keeps
2//! working unchanged.
3//!
4//! Only what sift acts on is read; everything else in the file is ignored
5//! rather than rejected, so a config full of plugin settings still loads.
6//! Path templates may be beets' `$field`/`%func{}` syntax or sift's own
7//! fb2k-style syntax; beets templates are translated on load.
8
9use std::collections::BTreeMap;
10use std::path::{Path, PathBuf};
11
12use serde::Deserialize;
13
14#[derive(Debug, thiserror::Error)]
15pub enum ConfigError {
16    #[error("{path}: {source}")]
17    Read {
18        path: PathBuf,
19        source: std::io::Error,
20    },
21    #[error("{path}: {source}")]
22    Parse {
23        path: PathBuf,
24        source: serde_yaml_ng::Error,
25    },
26    #[error("replace pattern {pattern:?}: {source}")]
27    Regex {
28        pattern: String,
29        source: regex::Error,
30    },
31}
32
33#[derive(Debug, Clone)]
34pub struct Config {
35    pub directory: PathBuf,
36    /// fb2k-style templates, without extension.
37    pub path_default: String,
38    pub path_comp: String,
39    pub replace: Vec<(regex::Regex, String)>,
40    pub asciify_paths: bool,
41    /// Take the year from the first release of the release group, so a
42    /// 2011 remaster of a 1977 album files under 1977.
43    pub original_date: bool,
44    /// Number tracks within each disc rather than across the release.
45    pub per_disc_numbering: bool,
46    pub move_files: bool,
47    pub fetch_art: bool,
48    /// Largest cover width to embed; Cover Art Archive serves 250, 500 and
49    /// 1200 thumbnails, and the nearest one at or below this is used.
50    pub art_max_width: u32,
51    /// Smallest cover width accepted; a smaller candidate is skipped.
52    pub art_min_width: u32,
53    /// JPEG quality used when a cover is resized and re-encoded.
54    pub art_quality: u8,
55    /// How far from square a cover may be before it is rejected.
56    pub art_ratio: Option<Ratio>,
57    /// Prefer the Cover Art Archive's full-size original over its
58    /// thumbnails.
59    pub art_high_resolution: bool,
60    /// Below this distance a match is applied without asking.
61    pub strong_threshold: f64,
62    pub musicbrainz_contact: String,
63    /// Where MusicBrainz responses are kept between imports.
64    pub cache_dir: Option<PathBuf>,
65    /// Keep a file's own modification time as its library `added` time, and
66    /// preserve mtimes across moves, copies and tag writes, so a re-import
67    /// or re-file never makes an old album look newly added. True when
68    /// beets' `importadded` plugin is enabled. beets also has
69    /// `importadded.preserve_mtimes` and `importadded.preserve_write_mtimes`
70    /// options, both true by default; sift does not read them and always
71    /// behaves as if both are on once the plugin is listed.
72    pub import_added: bool,
73    /// beets' `ftintitle` plugin: fold a featured artist out of the track
74    /// artist and into the title. `None` when the plugin isn't enabled.
75    pub ft_in_title: Option<FtInTitle>,
76    /// Set only when the `discogs` plugin is listed and a token is
77    /// available, from `discogs.user_token` or `DISCOGS_TOKEN`.
78    pub discogs: Option<DiscogsConf>,
79}
80
81#[derive(Debug, Clone)]
82pub struct FtInTitle {
83    /// Drop the featured artist instead of adding it to the title.
84    pub drop: bool,
85    /// Where `{0}` is the featured artist, e.g. `"feat. {0}"`.
86    pub format: String,
87}
88
89#[derive(Debug, Clone)]
90pub struct DiscogsConf {
91    pub token: String,
92    /// Prefix a medley's sub-tracks with the enclosing index track's title.
93    pub index_tracks: bool,
94}
95
96/// The template beets ships with, translated.
97pub const DEFAULT_PATH: &str = "%album artist%/%album%/$num(%tracknumber%,2) %title%";
98
99impl Default for Config {
100    fn default() -> Self {
101        Self {
102            directory: PathBuf::new(),
103            path_default: DEFAULT_PATH.into(),
104            path_comp: "Compilations/%album%/$num(%tracknumber%,2) %title%".into(),
105            replace: beets_default_replace(),
106            asciify_paths: false,
107            original_date: false,
108            per_disc_numbering: false,
109            move_files: false,
110            fetch_art: true,
111            art_max_width: 1200,
112            art_min_width: 0,
113            art_quality: 90,
114            art_ratio: None,
115            art_high_resolution: false,
116            strong_threshold: 0.04,
117            musicbrainz_contact: "https://github.com/radiosilence/sift".into(),
118            cache_dir: dirs::cache_dir().map(|d| d.join("sift")),
119            import_added: false,
120            ft_in_title: None,
121            discogs: None,
122        }
123    }
124}
125
126fn beets_default_replace() -> Vec<(regex::Regex, String)> {
127    [
128        (r"[\\/]", "_"),
129        (r"^\.", "_"),
130        (r"[\x00-\x1f]", "_"),
131        (r#"[<>:"\?\*\|]"#, "_"),
132        (r"\.$", "_"),
133        (r"\s+$", ""),
134        (r"^\s+", ""),
135        (r"^-", "_"),
136    ]
137    .into_iter()
138    .map(|(p, r)| (regex::Regex::new(p).expect("static pattern"), r.to_string()))
139    .collect()
140}
141
142/// How far from square a cover may be, as beets' fetchart `enforce_ratio`
143/// reads it: a percentage of the longer side, or a fixed number of pixels.
144#[derive(Debug, Clone, Copy, PartialEq)]
145pub enum Ratio {
146    Percent(f32),
147    Pixels(u32),
148}
149
150impl Ratio {
151    /// Parses "10%", "10px" or a bare "10" (pixels), as beets does.
152    fn parse(s: &str) -> Option<Self> {
153        let s = s.trim();
154        if let Some(pct) = s.strip_suffix('%') {
155            return pct.trim().parse().ok().map(Ratio::Percent);
156        }
157        s.strip_suffix("px")
158            .unwrap_or(s)
159            .trim()
160            .parse()
161            .ok()
162            .map(Ratio::Pixels)
163    }
164
165    /// Whether `width`x`height` is close enough to square.
166    pub fn allows(self, width: u32, height: u32) -> bool {
167        let tolerance = match self {
168            Ratio::Percent(p) => p / 100.0 * width.max(height) as f32,
169            Ratio::Pixels(px) => px as f32,
170        };
171        (width as i64 - height as i64).unsigned_abs() as f32 <= tolerance
172    }
173}
174
175#[derive(Debug, Default, Deserialize)]
176struct Raw {
177    #[serde(default)]
178    include: Vec<String>,
179    directory: Option<String>,
180    #[serde(default)]
181    import: RawImport,
182    #[serde(default)]
183    paths: BTreeMap<String, String>,
184    /// Ordered: beets applies these in file order, and so must we.
185    replace: Option<serde_yaml_ng::Mapping>,
186    asciify_paths: Option<bool>,
187    original_date: Option<bool>,
188    per_disc_numbering: Option<bool>,
189    #[serde(default)]
190    plugins: PluginList,
191    #[serde(default)]
192    fetchart: RawArt,
193    #[serde(default)]
194    embedart: RawArt,
195    #[serde(rename = "match", default)]
196    matching: RawMatch,
197    #[serde(default)]
198    discogs: RawDiscogs,
199    ftintitle: Option<RawFtInTitle>,
200}
201
202#[derive(Debug, Default, Deserialize)]
203struct RawFtInTitle {
204    auto: Option<bool>,
205    drop: Option<bool>,
206    format: Option<String>,
207}
208
209#[derive(Debug, Default, Deserialize)]
210struct RawImport {
211    #[serde(rename = "move")]
212    move_files: Option<bool>,
213    copy: Option<bool>,
214}
215
216#[derive(Debug, Default, Deserialize)]
217struct RawArt {
218    maxwidth: Option<u32>,
219    minwidth: Option<u32>,
220    quality: Option<u8>,
221    enforce_ratio: Option<String>,
222    high_resolution: Option<bool>,
223}
224
225#[derive(Debug, Default, Deserialize)]
226struct RawMatch {
227    strong_rec_thresh: Option<f64>,
228}
229
230#[derive(Debug, Default, Deserialize)]
231struct RawDiscogs {
232    user_token: Option<String>,
233    index_tracks: Option<bool>,
234}
235
236#[derive(Debug, Default, Deserialize)]
237#[serde(untagged)]
238enum PluginList {
239    #[default]
240    None,
241    List(Vec<String>),
242    Line(String),
243}
244
245impl PluginList {
246    fn contains(&self, name: &str) -> bool {
247        match self {
248            Self::None => false,
249            Self::List(l) => l.iter().any(|p| p == name),
250            Self::Line(s) => s.split_whitespace().any(|p| p == name),
251        }
252    }
253}
254
255impl Config {
256    /// Load a beets config, following `include:` relative to its directory.
257    /// Later files override earlier ones, as in beets: includes first, then
258    /// the file itself.
259    pub fn load(path: &Path) -> Result<Self, ConfigError> {
260        let mut layers = Vec::new();
261        collect(path, &mut layers, 0)?;
262        let mut cfg = Self::default();
263        let mut art_width = None;
264        let mut art_min_width = None;
265        let mut art_quality = None;
266        let mut art_ratio = None;
267        let mut art_high_resolution = None;
268        let mut fetchart = false;
269        let mut ftintitle_enabled = false;
270        let mut ft_auto = None;
271        let mut ft_drop = None;
272        let mut ft_format = None;
273        let mut discogs_enabled = false;
274        let mut discogs_token = None;
275        let mut discogs_index_tracks = false;
276        for raw in layers {
277            if let Some(d) = raw.directory {
278                cfg.directory = expand(&d);
279            }
280            if let Some(m) = raw.import.move_files {
281                cfg.move_files = m;
282            }
283            if raw.import.copy == Some(false) && raw.import.move_files.is_none() {
284                cfg.move_files = true;
285            }
286            if let Some(t) = raw.paths.get("default") {
287                cfg.path_default = translate(t);
288            }
289            if let Some(t) = raw.paths.get("comp") {
290                cfg.path_comp = translate(t);
291            }
292            if let Some(map) = raw.replace {
293                cfg.replace = map
294                    .into_iter()
295                    .filter_map(|(k, v)| {
296                        Some((
297                            k.as_str()?.to_string(),
298                            v.as_str().unwrap_or_default().to_string(),
299                        ))
300                    })
301                    .map(|(p, r)| {
302                        regex::Regex::new(&p)
303                            .map(|re| (re, r))
304                            .map_err(|source| ConfigError::Regex { pattern: p, source })
305                    })
306                    .collect::<Result<_, _>>()?;
307            }
308            cfg.asciify_paths = raw.asciify_paths.unwrap_or(cfg.asciify_paths);
309            cfg.original_date = raw.original_date.unwrap_or(cfg.original_date);
310            cfg.per_disc_numbering = raw.per_disc_numbering.unwrap_or(cfg.per_disc_numbering);
311            cfg.import_added |= raw.plugins.contains("importadded");
312            fetchart |= raw.plugins.contains("fetchart");
313            art_width = raw
314                .embedart
315                .maxwidth
316                .or(raw.fetchart.maxwidth)
317                .or(art_width);
318            art_min_width = raw
319                .embedart
320                .minwidth
321                .or(raw.fetchart.minwidth)
322                .or(art_min_width);
323            art_quality = raw
324                .embedart
325                .quality
326                .or(raw.fetchart.quality)
327                .or(art_quality);
328            art_high_resolution = raw
329                .embedart
330                .high_resolution
331                .or(raw.fetchart.high_resolution)
332                .or(art_high_resolution);
333            art_ratio = raw
334                .embedart
335                .enforce_ratio
336                .as_deref()
337                .or(raw.fetchart.enforce_ratio.as_deref())
338                .and_then(Ratio::parse)
339                .or(art_ratio);
340            if let Some(t) = raw.matching.strong_rec_thresh {
341                cfg.strong_threshold = t;
342            }
343            ftintitle_enabled |= raw.plugins.contains("ftintitle");
344            if let Some(ft) = raw.ftintitle {
345                ft_auto = ft.auto.or(ft_auto);
346                ft_drop = ft.drop.or(ft_drop);
347                ft_format = ft.format.or(ft_format);
348            }
349            discogs_enabled |= raw.plugins.contains("discogs");
350            discogs_token = raw.discogs.user_token.or(discogs_token);
351            discogs_index_tracks = raw.discogs.index_tracks.unwrap_or(discogs_index_tracks);
352        }
353        cfg.fetch_art = fetchart;
354        if let Some(w) = art_width {
355            cfg.art_max_width = w;
356        }
357        if ftintitle_enabled && ft_auto != Some(false) {
358            cfg.ft_in_title = Some(FtInTitle {
359                drop: ft_drop.unwrap_or(false),
360                format: ft_format.unwrap_or_else(|| "feat. {0}".into()),
361            });
362        }
363        if let Some(w) = art_min_width {
364            cfg.art_min_width = w;
365        }
366        if let Some(q) = art_quality {
367            cfg.art_quality = q;
368        }
369        if let Some(r) = art_ratio {
370            cfg.art_ratio = Some(r);
371        }
372        if let Some(h) = art_high_resolution {
373            cfg.art_high_resolution = h;
374        }
375        let token = std::env::var("DISCOGS_TOKEN").ok().or(discogs_token);
376        cfg.discogs = discogs_enabled
377            .then_some(token)
378            .flatten()
379            .map(|token| DiscogsConf {
380                token,
381                index_tracks: discogs_index_tracks,
382            });
383        // No `directory` is allowed: a shared base config often leaves it to
384        // a per-machine file, and a caller may set it after loading. Whoever
385        // imports checks it is set.
386        Ok(cfg)
387    }
388
389    /// `$BEETSDIR/config.yaml`, then `~/.config/beets/config.yaml`.
390    pub fn default_path() -> Option<PathBuf> {
391        std::env::var_os("BEETSDIR")
392            .map(|d| PathBuf::from(d).join("config.yaml"))
393            .or_else(|| dirs::home_dir().map(|h| h.join(".config/beets/config.yaml")))
394            .filter(|p| p.exists())
395    }
396}
397
398fn collect(path: &Path, out: &mut Vec<Raw>, depth: usize) -> Result<(), ConfigError> {
399    let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
400        path: path.into(),
401        source,
402    })?;
403    let raw: Raw = if text.trim().is_empty() {
404        Raw::default()
405    } else {
406        serde_yaml_ng::from_str(&text).map_err(|source| ConfigError::Parse {
407            path: path.into(),
408            source,
409        })?
410    };
411    if depth < 8 {
412        let dir = path.parent().unwrap_or(Path::new("."));
413        for inc in &raw.include {
414            collect(&dir.join(expand(inc)), out, depth + 1)?;
415        }
416    }
417    out.push(raw);
418    Ok(())
419}
420
421fn expand(p: &str) -> PathBuf {
422    match p.strip_prefix("~/") {
423        Some(rest) => dirs::home_dir()
424            .map(|h| h.join(rest))
425            .unwrap_or_else(|| PathBuf::from(p)),
426        None => PathBuf::from(p),
427    }
428}
429
430/// beets field names to sift's.
431fn field(name: &str) -> String {
432    match name {
433        "albumartist" => "%album artist%".into(),
434        "track" => "$num(%tracknumber%,2)".into(),
435        "disc" => "$num(%discnumber%,2)".into(),
436        "tracktotal" => "%totaltracks%".into(),
437        "disctotal" => "%totaldiscs%".into(),
438        "format" => "%codec%".into(),
439        "original_year" => "%original year%".into(),
440        "albumtype" => "%album type%".into(),
441        "catalognum" => "%catalog number%".into(),
442        "mb_albumid" => "%musicbrainz album id%".into(),
443        other => format!("%{other}%"),
444    }
445}
446
447/// Translate a beets path template into fb2k syntax. A template that uses
448/// no beets syntax is returned as it is, so sift's own templates pass
449/// through.
450pub fn translate(template: &str) -> String {
451    if !template.contains('$') && !template.contains("%if{") && !template.contains('{') {
452        return template.to_string();
453    }
454    if template.contains("$num(") || template.contains("$if(") {
455        return template.to_string();
456    }
457    let chars: Vec<char> = template.chars().collect();
458    let (out, _) = translate_until(&chars, 0, &[]);
459    out
460}
461
462/// Translate from `i` until one of `stops` at nesting depth zero.
463fn translate_until(c: &[char], mut i: usize, stops: &[char]) -> (String, usize) {
464    let mut out = String::new();
465    let mut literal = String::new();
466    let flush = |literal: &mut String, out: &mut String| {
467        if literal.is_empty() {
468            return;
469        }
470        if literal.chars().any(|ch| "[]'%$(),".contains(ch)) {
471            out.push('\'');
472            out.push_str(&literal.replace('\'', "''"));
473            out.push('\'');
474        } else {
475            out.push_str(literal);
476        }
477        literal.clear();
478    };
479    while i < c.len() {
480        let ch = c[i];
481        if stops.contains(&ch) {
482            break;
483        }
484        if ch == '$' && c.get(i + 1) == Some(&'$') {
485            literal.push('$');
486            i += 2;
487        } else if ch == '$' && c.get(i + 1) == Some(&'{') {
488            let end = c[i..]
489                .iter()
490                .position(|&x| x == '}')
491                .map_or(c.len(), |p| i + p);
492            flush(&mut literal, &mut out);
493            out.push_str(&field(&c[i + 2..end].iter().collect::<String>()));
494            i = end + 1;
495        } else if ch == '$'
496            && c.get(i + 1)
497                .is_some_and(|x| x.is_alphanumeric() || *x == '_')
498        {
499            let start = i + 1;
500            let mut end = start;
501            while end < c.len() && (c[end].is_alphanumeric() || c[end] == '_') {
502                end += 1;
503            }
504            flush(&mut literal, &mut out);
505            out.push_str(&field(&c[start..end].iter().collect::<String>()));
506            i = end;
507        } else if ch == '%'
508            && c[i + 1..].iter().position(|&x| x == '{').is_some_and(|p| {
509                c[i + 1..i + 1 + p]
510                    .iter()
511                    .all(|x| x.is_alphanumeric() || *x == '_')
512            })
513        {
514            let name_end = i + 1 + c[i + 1..].iter().position(|&x| x == '{').unwrap();
515            let name: String = c[i + 1..name_end].iter().collect();
516            let mut args = Vec::new();
517            let mut j = name_end + 1;
518            loop {
519                let (arg, next) = translate_until(c, j, &[',', '}']);
520                args.push(arg);
521                j = next;
522                match c.get(j) {
523                    Some(',') => j += 1,
524                    _ => break,
525                }
526            }
527            flush(&mut literal, &mut out);
528            out.push_str(&function(&name, &args));
529            i = j + 1;
530        } else {
531            literal.push(ch);
532            i += 1;
533        }
534    }
535    flush(&mut literal, &mut out);
536    (out, i)
537}
538
539fn function(name: &str, args: &[String]) -> String {
540    let arg = |n: usize| args.get(n).cloned().unwrap_or_default();
541    match name {
542        // Disambiguates albums with identical paths; sift refuses the
543        // collision instead, which is visible rather than silent.
544        "aunique" => String::new(),
545        "if" => format!("$if({},{},{})", arg(0), arg(1), arg(2)),
546        "left" => format!("$left({},{})", arg(0), arg(1)),
547        "right" => format!("$right({},{})", arg(0), arg(1)),
548        "upper" => format!("$upper({})", arg(0)),
549        "lower" => format!("$lower({})", arg(0)),
550        "title" => format!("$caps({})", arg(0)),
551        "ifdef" => format!(
552            "$if({},{},{})",
553            field(args.first().map_or("", |s| s.trim_matches('%'))),
554            arg(1),
555            arg(2)
556        ),
557        other => format!("${other}({})", args.join(",")),
558    }
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564
565    #[test]
566    fn translates_the_users_beets_template() {
567        let t = translate(
568            "$albumartist/%if{$year,($year) }$album%aunique{} [$format]/$disc$track. $artist - $title",
569        );
570        let render = |year: Option<&str>| {
571            let year = year.map(str::to_string);
572            let fields = move |name: &str| match name {
573                "album artist" | "artist" => Some("Daisy the Great".to_string()),
574                "year" => year.clone(),
575                "album" => Some("All You Need Is Time".into()),
576                "codec" => Some("FLAC".into()),
577                "discnumber" => Some("1".into()),
578                "tracknumber" => Some("2".into()),
579                "title" => Some("Glitter".into()),
580                _ => None,
581            };
582            crate::format::format(&t, &fields).unwrap()
583        };
584        assert_eq!(
585            render(Some("2022")),
586            "Daisy the Great/(2022) All You Need Is Time [FLAC]/0102. Daisy the Great - Glitter"
587        );
588        assert_eq!(
589            render(None),
590            "Daisy the Great/All You Need Is Time [FLAC]/0102. Daisy the Great - Glitter"
591        );
592    }
593
594    #[test]
595    fn fb2k_templates_pass_through() {
596        let t = "%album artist%/['('%year%') ']%album%";
597        assert_eq!(translate(t), t);
598    }
599
600    #[test]
601    fn loads_includes_in_order_and_ignores_unknown_keys() {
602        let dir = tempfile::tempdir().unwrap();
603        std::fs::write(
604            dir.path().join("base.yaml"),
605            "original_date: true\nper_disc_numbering: true\nimport:\n  move: true\npaths:\n  default: $albumartist/$album/$track $title\nreplace:\n  '[\\\\/]': '-'\n  '[<>:\"\\?\\*\\|]': '-'\nplugins:\n  - fetchart\n  - embedart\nembedart:\n  maxwidth: 1200\nfetchart:\n  minwidth: 500\n",
606        )
607        .unwrap();
608        std::fs::write(
609            dir.path().join("config.yaml"),
610            "include: [./base.yaml]\ndirectory: /music\nlastgenre:\n  whatever: 1\n",
611        )
612        .unwrap();
613        let cfg = Config::load(&dir.path().join("config.yaml")).unwrap();
614        assert_eq!(cfg.directory, PathBuf::from("/music"));
615        assert!(cfg.original_date && cfg.per_disc_numbering && cfg.move_files && cfg.fetch_art);
616        assert_eq!(cfg.art_max_width, 1200);
617        assert_eq!(cfg.art_min_width, 500);
618        assert_eq!(
619            cfg.path_default,
620            "%album artist%/%album%/$num(%tracknumber%,2) %title%"
621        );
622        assert_eq!(cfg.replace.len(), 2);
623        assert_eq!(
624            cfg.replace[1]
625                .0
626                .replace_all("a:b", cfg.replace[1].1.as_str()),
627            "a-b"
628        );
629    }
630
631    #[test]
632    fn ftintitle_plugin_enables_it_with_options() {
633        let dir = tempfile::tempdir().unwrap();
634        std::fs::write(
635            dir.path().join("config.yaml"),
636            "directory: /music\nplugins: [ftintitle]\nftintitle:\n  drop: true\n",
637        )
638        .unwrap();
639        let cfg = Config::load(&dir.path().join("config.yaml")).unwrap();
640        let ft = cfg.ft_in_title.expect("ftintitle should be enabled");
641        assert!(ft.drop);
642        assert_eq!(ft.format, "feat. {0}");
643    }
644
645    #[test]
646    fn loads_fetchart_quality_and_ratio() {
647        let dir = tempfile::tempdir().unwrap();
648        std::fs::write(
649            dir.path().join("config.yaml"),
650            "directory: /music\nfetchart:\n  quality: 95\n  enforce_ratio: 10%\n  high_resolution: true\n",
651        )
652        .unwrap();
653        let cfg = Config::load(&dir.path().join("config.yaml")).unwrap();
654        assert_eq!(cfg.art_quality, 95);
655        assert_eq!(cfg.art_ratio, Some(Ratio::Percent(10.0)));
656        assert!(cfg.art_high_resolution);
657    }
658
659    #[test]
660    fn ratio_tolerance() {
661        assert_eq!(Ratio::parse("10%"), Some(Ratio::Percent(10.0)));
662        assert_eq!(Ratio::parse("10px"), Some(Ratio::Pixels(10)));
663        assert_eq!(Ratio::parse("10"), Some(Ratio::Pixels(10)));
664
665        assert!(Ratio::Percent(10.0).allows(1000, 950));
666        assert!(!Ratio::Percent(10.0).allows(1000, 800));
667        assert!(!Ratio::Pixels(10).allows(1000, 980));
668    }
669}