mecomp_core/
config.rs

1//! Handles the configuration of the daemon.
2//!
3//! this module is responsible for parsing the Config.toml file, parsing cli arguments, and
4//! setting up the logger.
5
6use config::{Config, ConfigError, Environment, File};
7use one_or_many::OneOrMany;
8use serde::Deserialize;
9
10use std::{path::PathBuf, str::FromStr};
11
12use mecomp_storage::util::MetadataConflictResolution;
13
14pub static DEFAULT_CONFIG: &str = include_str!("../Mecomp.toml");
15
16#[derive(Clone, Debug, Deserialize, Default, PartialEq, Eq)]
17pub struct Settings {
18    /// General Daemon Settings
19    #[serde(default)]
20    pub daemon: DaemonSettings,
21    /// Parameters for the reclustering algorithm.
22    #[serde(default)]
23    pub reclustering: ReclusterSettings,
24    /// Settings for the TUI
25    #[serde(default)]
26    pub tui: TuiSettings,
27}
28
29impl Settings {
30    /// Load settings from the config file, environment variables, and CLI arguments.
31    ///
32    /// The config file is located at the path specified by the `--config` flag.
33    ///
34    /// The environment variables are prefixed with `MECOMP_`.
35    ///
36    /// # Arguments
37    ///
38    /// * `flags` - The parsed CLI arguments.
39    ///
40    /// # Errors
41    ///
42    /// This function will return an error if the config file is not found or if the config file is
43    /// invalid.
44    #[inline]
45    pub fn init(
46        config: PathBuf,
47        port: Option<u16>,
48        log_level: Option<log::LevelFilter>,
49    ) -> Result<Self, ConfigError> {
50        let s = Config::builder()
51            .add_source(File::from(config))
52            .add_source(Environment::with_prefix("MECOMP"))
53            .build()?;
54
55        let mut settings: Self = s.try_deserialize()?;
56
57        for path in &mut settings.daemon.library_paths {
58            *path = shellexpand::tilde(&path.to_string_lossy())
59                .into_owned()
60                .into();
61        }
62
63        if let Some(port) = port {
64            settings.daemon.rpc_port = port;
65        }
66
67        if let Some(log_level) = log_level {
68            settings.daemon.log_level = log_level;
69        }
70
71        Ok(settings)
72    }
73
74    /// Get the (default) path to the config file.
75    /// If the config file does not exist at this path, it will be created with the default config.
76    ///
77    /// See [`crate::get_config_dir`] for more information about where this default path is located.
78    ///
79    /// # Errors
80    ///
81    /// This function will return an error if the system config directory (e.g., `~/.config` on linux) could not be found, or if the config file was missing and could not be created.
82    #[inline]
83    pub fn get_config_path() -> Result<PathBuf, std::io::Error> {
84        match crate::get_config_dir() {
85            Ok(config_dir) => {
86                // if the config directory does not exist, create it
87                if !config_dir.exists() {
88                    std::fs::create_dir_all(&config_dir)?;
89                }
90                let config_file = config_dir.join("Mecomp.toml");
91
92                if !config_file.exists() {
93                    std::fs::write(&config_file, DEFAULT_CONFIG)?;
94                }
95
96                Ok(config_file)
97            }
98            Err(e) => {
99                eprintln!("Error: {e}");
100                Err(std::io::Error::new(
101                    std::io::ErrorKind::NotFound,
102                    "Unable to find the config directory for mecomp.",
103                ))
104            }
105        }
106    }
107}
108
109#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
110pub struct DaemonSettings {
111    /// The port to listen on for RPC requests.
112    /// Default is 6600.
113    #[serde(default = "default_port")]
114    pub rpc_port: u16,
115    /// The root paths of the music library.
116    #[serde(default = "default_library_paths")]
117    pub library_paths: Box<[PathBuf]>,
118    /// Separators for artist names in song metadata.
119    /// For example, "Foo, Bar, Baz" would be split into \["Foo", "Bar", "Baz"\]. if the separator is ", ".
120    /// If the separator is not found, the entire string is considered as a single artist.
121    /// If unset, will not split artists.
122    ///
123    /// Users can provide one or many separators, and must provide them as either a single string or an array of strings.
124    ///
125    /// ```toml
126    /// [daemon]
127    /// artist_separator = " & "
128    /// artist_separator = [" & ", "; "]
129    /// ...
130    /// ```
131    #[serde(default, deserialize_with = "de_artist_separator")]
132    pub artist_separator: OneOrMany<String>,
133    /// Exceptions for artist name separation, for example:
134    /// "Foo & Bar; Baz" would be split into \["Foo", "Bar", "Baz"\] if the separators are set to "&" and "; ".
135    ///
136    /// However, if the following exception is set:
137    /// ```toml
138    /// [daemon]
139    /// protected_artist_names = ["Foo & Bar"]
140    /// ```
141    /// Then the artist "Foo & Bar; Baz" would be split into \["Foo & Bar", "Baz"\].
142    ///
143    /// Note that the exception applies to the entire "name", so:
144    /// ```toml
145    /// [daemon]
146    /// protected_artist_names = ["Foo & Bar"]
147    /// ```
148    /// would split "Foo & Bar" into \["Foo & Bar"\],
149    /// but "Foo & Bar Baz" would still be split into \["Foo", "Bar Baz"\].
150    #[serde(default)]
151    pub protected_artist_names: OneOrMany<String>,
152    #[serde(default)]
153    pub genre_separator: Option<String>,
154    /// how conflicting metadata should be resolved
155    /// "overwrite" - overwrite the metadata with new metadata
156    /// "skip" - skip the file (keep old metadata)
157    #[serde(default)]
158    pub conflict_resolution: MetadataConflictResolution,
159    /// What level of logging to use.
160    /// Default is "info".
161    #[serde(default = "default_log_level")]
162    #[serde(deserialize_with = "de_log_level")]
163    pub log_level: log::LevelFilter,
164}
165
166fn de_artist_separator<'de, D>(deserializer: D) -> Result<OneOrMany<String>, D::Error>
167where
168    D: serde::Deserializer<'de>,
169{
170    let v = OneOrMany::<String>::deserialize(deserializer)?
171        .into_iter()
172        .filter(|s| !s.is_empty())
173        .collect::<OneOrMany<String>>();
174    if v.is_empty() {
175        Ok(OneOrMany::None)
176    } else {
177        Ok(v)
178    }
179}
180
181fn de_log_level<'de, D>(deserializer: D) -> Result<log::LevelFilter, D::Error>
182where
183    D: serde::Deserializer<'de>,
184{
185    let s = String::deserialize(deserializer)?;
186    Ok(log::LevelFilter::from_str(&s).unwrap_or_else(|_| default_log_level()))
187}
188
189const fn default_port() -> u16 {
190    6600
191}
192
193fn default_library_paths() -> Box<[PathBuf]> {
194    vec![shellexpand::tilde("~/Music/").into_owned().into()].into_boxed_slice()
195}
196
197const fn default_log_level() -> log::LevelFilter {
198    log::LevelFilter::Info
199}
200
201impl Default for DaemonSettings {
202    #[inline]
203    fn default() -> Self {
204        Self {
205            rpc_port: default_port(),
206            library_paths: default_library_paths(),
207            artist_separator: OneOrMany::None,
208            protected_artist_names: OneOrMany::None,
209            genre_separator: None,
210            conflict_resolution: MetadataConflictResolution::Overwrite,
211            log_level: default_log_level(),
212        }
213    }
214}
215
216#[derive(Clone, Copy, Debug, Deserialize, Default, PartialEq, Eq)]
217#[serde(rename_all = "lowercase")]
218pub enum ClusterAlgorithm {
219    KMeans,
220    #[default]
221    GMM,
222}
223
224#[cfg(feature = "analysis")]
225impl From<ClusterAlgorithm> for mecomp_analysis::clustering::ClusteringMethod {
226    #[inline]
227    fn from(algo: ClusterAlgorithm) -> Self {
228        match algo {
229            ClusterAlgorithm::KMeans => Self::KMeans,
230            ClusterAlgorithm::GMM => Self::GaussianMixtureModel,
231        }
232    }
233}
234
235#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
236#[serde(rename_all = "lowercase")]
237pub enum ProjectionMethod {
238    #[default]
239    None,
240    TSne,
241    Pca,
242}
243
244#[cfg(feature = "analysis")]
245impl From<ProjectionMethod> for mecomp_analysis::clustering::ProjectionMethod {
246    #[inline]
247    fn from(proj: ProjectionMethod) -> Self {
248        match proj {
249            ProjectionMethod::None => Self::None,
250            ProjectionMethod::TSne => Self::TSne,
251            ProjectionMethod::Pca => Self::Pca,
252        }
253    }
254}
255
256#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
257pub struct ReclusterSettings {
258    /// The number of reference datasets to use for the gap statistic.
259    /// (which is used to determine the optimal number of clusters)
260    /// 50 will give a decent estimate but for the best results use more,
261    /// 500 will give a very good estimate but be very slow.
262    /// We default to 250 in release mode.
263    #[serde(default = "default_gap_statistic_reference_datasets")]
264    pub gap_statistic_reference_datasets: u32,
265    /// The maximum number of clusters to create.
266    /// This is the upper bound on the number of clusters that can be created.
267    /// Increase if you're getting a "could not find optimal k" error.
268    /// Default is 24.
269    #[serde(default = "default_max_clusters")]
270    pub max_clusters: usize,
271    /// The clustering algorithm to use.
272    /// Either "kmeans" or "gmm".
273    #[serde(default)]
274    pub algorithm: ClusterAlgorithm,
275    /// The projection method to preprocess the data with before clustering.
276    /// Either "tsne", "pca", or "none".
277    /// Default is "none".
278    #[serde(default)]
279    pub projection_method: ProjectionMethod,
280}
281
282const fn default_gap_statistic_reference_datasets() -> u32 {
283    50
284}
285
286const fn default_max_clusters() -> usize {
287    #[cfg(debug_assertions)]
288    return 16;
289    #[cfg(not(debug_assertions))]
290    return 24;
291}
292
293impl Default for ReclusterSettings {
294    #[inline]
295    fn default() -> Self {
296        Self {
297            gap_statistic_reference_datasets: default_gap_statistic_reference_datasets(),
298            max_clusters: default_max_clusters(),
299            algorithm: ClusterAlgorithm::default(),
300            projection_method: ProjectionMethod::default(),
301        }
302    }
303}
304
305#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
306pub struct TuiSettings {
307    /// How many songs should be queried for when starting a radio.
308    /// Default is 20.
309    #[serde(default = "default_radio_count")]
310    pub radio_count: u32,
311    /// The color scheme to use for the TUI.
312    /// Each color is either:
313    /// - a hex string in the format `#RRGGBB`.
314    ///   example: `#FFFFFF` for white.
315    /// - a material design color name in format "<COLOR>_<SHADE>".
316    ///   so "pink", `red-900`,  `light-blue_500`, `red900`, etc. are all invalid.
317    ///   but `PINK_900`, `RED_900`, `LIGHT_BLUE_500` are valid.
318    ///   - Exceptions are `WHITE` and `BLACK`, which are always valid.
319    #[serde(default)]
320    pub colors: TuiColorScheme,
321}
322
323const fn default_radio_count() -> u32 {
324    20
325}
326
327impl Default for TuiSettings {
328    #[inline]
329    fn default() -> Self {
330        Self {
331            radio_count: default_radio_count(),
332            colors: TuiColorScheme::default(),
333        }
334    }
335}
336
337#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Default)]
338pub struct TuiColorScheme {
339    /// app border colors
340    pub app_border: Option<String>,
341    pub app_border_text: Option<String>,
342    /// border colors
343    pub border_unfocused: Option<String>,
344    pub border_focused: Option<String>,
345    /// popup border color
346    pub popup_border: Option<String>,
347    /// text colors
348    pub text_normal: Option<String>,
349    pub text_highlight: Option<String>,
350    pub text_highlight_alt: Option<String>,
351    /// gauge colors, such as song progress bar
352    pub gauge_filled: Option<String>,
353    pub gauge_unfilled: Option<String>,
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    use pretty_assertions::assert_eq;
361    use rstest::rstest;
362
363    #[derive(Debug, PartialEq, Eq, Deserialize)]
364    #[allow(dead_code)]
365    #[serde(transparent)]
366    struct ArtistSeparatorTest {
367        #[serde(deserialize_with = "de_artist_separator")]
368        artist_separator: OneOrMany<String>,
369    }
370
371    #[rstest]
372    #[case(Vec::<String>::new())]
373    #[case("")]
374    fn test_de_artist_separator_empty<'de, D>(#[case] input: D)
375    where
376        D: serde::de::IntoDeserializer<'de>,
377    {
378        let deserializer = input.into_deserializer();
379        let result: Result<OneOrMany<String>, _> = de_artist_separator(deserializer);
380        assert!(result.is_ok());
381        assert!(result.unwrap().is_empty());
382    }
383
384    #[rstest]
385    #[case(vec![" & "], String::from(" & ").into())]
386    #[case(" & ", String::from(" & ").into())]
387    #[case(vec![" & ", "; "], vec![String::from(" & "), String::from("; ")].into())]
388    #[case(vec!["", " & ", "", "; "], vec![String::from(" & "), String::from("; ")].into())]
389    fn test_de_artist_separator<'de, D>(#[case] input: D, #[case] expected: OneOrMany<String>)
390    where
391        D: serde::de::IntoDeserializer<'de>,
392    {
393        let deserializer = input.into_deserializer();
394        let result: Result<OneOrMany<String>, _> = de_artist_separator(deserializer);
395        assert!(result.is_ok());
396        assert_eq!(result.unwrap(), expected);
397    }
398
399    #[test]
400    fn test_init_config() {
401        let temp_dir = tempfile::tempdir().unwrap();
402        let config_path = temp_dir.path().join("config.toml");
403        std::fs::write(
404            &config_path,
405            r#"            
406[daemon]
407rpc_port = 6600
408library_paths = ["/Music"]
409artist_separator = ["; "]
410genre_separator = ", "
411conflict_resolution = "overwrite"
412log_level = "debug"
413
414[reclustering]
415gap_statistic_reference_datasets = 50
416max_clusters = 24
417algorithm = "gmm"
418
419[tui]
420radio_count = 21
421[tui.colors]
422app_border = "PINK_900"
423app_border_text = "PINK_300"
424border_unfocused = "RED_900"
425border_focused = "RED_200"
426popup_border = "LIGHT_BLUE_500"
427text_normal = "WHITE"
428text_highlight = "RED_600"
429text_highlight_alt = "RED_200"
430gauge_filled = "WHITE"
431gauge_unfilled = "BLACK"
432            "#,
433        )
434        .unwrap();
435
436        let expected = Settings {
437            daemon: DaemonSettings {
438                rpc_port: 6600,
439                library_paths: ["/Music".into()].into(),
440                artist_separator: vec!["; ".into()].into(),
441                protected_artist_names: OneOrMany::None,
442                genre_separator: Some(", ".into()),
443                conflict_resolution: MetadataConflictResolution::Overwrite,
444                log_level: log::LevelFilter::Debug,
445            },
446            reclustering: ReclusterSettings {
447                gap_statistic_reference_datasets: 50,
448                max_clusters: 24,
449                algorithm: ClusterAlgorithm::GMM,
450                projection_method: ProjectionMethod::None,
451            },
452            tui: TuiSettings {
453                radio_count: 21,
454                colors: TuiColorScheme {
455                    app_border: Some("PINK_900".into()),
456                    app_border_text: Some("PINK_300".into()),
457                    border_unfocused: Some("RED_900".into()),
458                    border_focused: Some("RED_200".into()),
459                    popup_border: Some("LIGHT_BLUE_500".into()),
460                    text_normal: Some("WHITE".into()),
461                    text_highlight: Some("RED_600".into()),
462                    text_highlight_alt: Some("RED_200".into()),
463                    gauge_filled: Some("WHITE".into()),
464                    gauge_unfilled: Some("BLACK".into()),
465                },
466            },
467        };
468
469        let settings = Settings::init(config_path, None, None).unwrap();
470
471        assert_eq!(settings, expected);
472    }
473
474    #[test]
475    fn test_tui_colors_unset() {
476        let temp_dir = tempfile::tempdir().unwrap();
477        let config_path = temp_dir.path().join("config.toml");
478        std::fs::write(
479            &config_path,
480            r#"            
481[daemon]
482rpc_port = 6600
483library_paths = ["/Music"]
484artist_separator = ["; "]
485protected_artist_names = ["Foo & Bar"]
486genre_separator = ", "
487conflict_resolution = "overwrite"
488log_level = "debug"
489
490[reclustering]
491gap_statistic_reference_datasets = 50
492max_clusters = 24
493algorithm = "gmm"
494
495[tui]
496radio_count = 21
497            "#,
498        )
499        .unwrap();
500
501        let expected = Settings {
502            daemon: DaemonSettings {
503                rpc_port: 6600,
504                library_paths: ["/Music".into()].into(),
505                artist_separator: vec!["; ".into()].into(),
506                protected_artist_names: "Foo & Bar".to_string().into(),
507                genre_separator: Some(", ".into()),
508                conflict_resolution: MetadataConflictResolution::Overwrite,
509                log_level: log::LevelFilter::Debug,
510            },
511            reclustering: ReclusterSettings {
512                gap_statistic_reference_datasets: 50,
513                max_clusters: 24,
514                algorithm: ClusterAlgorithm::GMM,
515                projection_method: ProjectionMethod::None,
516            },
517            tui: TuiSettings {
518                radio_count: 21,
519                colors: TuiColorScheme::default(),
520            },
521        };
522
523        let settings = Settings::init(config_path, None, None).unwrap();
524
525        assert_eq!(settings, expected);
526    }
527
528    #[test]
529    fn test_artist_names_to_not_split() {
530        let temp_dir = tempfile::tempdir().unwrap();
531        let config_path = temp_dir.path().join("config.toml");
532        std::fs::write(
533            &config_path,
534            r#"            
535[daemon]
536rpc_port = 6600
537library_paths = ["/Music"]
538artist_separator = ["; "]
539protected_artist_names = ["Foo & Bar"]
540genre_separator = ", "
541conflict_resolution = "overwrite"
542log_level = "debug"
543
544[reclustering]
545gap_statistic_reference_datasets = 50
546max_clusters = 24
547algorithm = "gmm"
548
549[tui]
550radio_count = 21
551            "#,
552        )
553        .unwrap();
554
555        let expected = Settings {
556            daemon: DaemonSettings {
557                rpc_port: 6600,
558                library_paths: ["/Music".into()].into(),
559                artist_separator: vec!["; ".into()].into(),
560                protected_artist_names: "Foo & Bar".to_string().into(),
561                genre_separator: Some(", ".into()),
562                conflict_resolution: MetadataConflictResolution::Overwrite,
563                log_level: log::LevelFilter::Debug,
564            },
565            reclustering: ReclusterSettings {
566                gap_statistic_reference_datasets: 50,
567                max_clusters: 24,
568                algorithm: ClusterAlgorithm::GMM,
569                projection_method: ProjectionMethod::None,
570            },
571            tui: TuiSettings {
572                radio_count: 21,
573                colors: TuiColorScheme::default(),
574            },
575        };
576
577        let settings = Settings::init(config_path, None, None).unwrap();
578
579        assert_eq!(settings, expected);
580    }
581
582    #[test]
583    fn test_default_config_works() {
584        let temp_dir = tempfile::tempdir().unwrap();
585        let config_path = temp_dir.path().join("config.toml");
586        std::fs::write(&config_path, DEFAULT_CONFIG).unwrap();
587
588        let settings = Settings::init(config_path, None, None);
589
590        assert!(settings.is_ok(), "Error: {:?}", settings.err());
591    }
592}