1use 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 pub path_default: String,
38 pub path_comp: String,
39 pub replace: Vec<(regex::Regex, String)>,
40 pub asciify_paths: bool,
41 pub original_date: bool,
44 pub per_disc_numbering: bool,
46 pub move_files: bool,
47 pub fetch_art: bool,
48 pub art_max_width: u32,
51 pub art_min_width: u32,
53 pub art_quality: u8,
55 pub art_ratio: Option<Ratio>,
57 pub art_high_resolution: bool,
60 pub strong_threshold: f64,
62 pub musicbrainz_contact: String,
63 pub cache_dir: Option<PathBuf>,
65 pub import_added: bool,
73 pub ft_in_title: Option<FtInTitle>,
76 pub discogs: Option<DiscogsConf>,
79}
80
81#[derive(Debug, Clone)]
82pub struct FtInTitle {
83 pub drop: bool,
85 pub format: String,
87}
88
89#[derive(Debug, Clone)]
90pub struct DiscogsConf {
91 pub token: String,
92 pub index_tracks: bool,
94}
95
96pub 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#[derive(Debug, Clone, Copy, PartialEq)]
145pub enum Ratio {
146 Percent(f32),
147 Pixels(u32),
148}
149
150impl Ratio {
151 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 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 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 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 Ok(cfg)
387 }
388
389 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
430fn 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
447pub 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
462fn 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 "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}