Skip to main content

lux_core/
config.rs

1use crate::error::{LuxError, Result};
2use crate::types::Quality;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::path::PathBuf;
7
8fn default_platform_priority() -> Vec<String> {
9    vec![
10        "wy".to_string(),
11        "kw".to_string(),
12        "tx".to_string(),
13        "mg".to_string(),
14        "kg".to_string(),
15    ]
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct SourceSettings {
20    pub default_source: String,
21    pub default_quality: Quality,
22    pub quality_fallback: Vec<Quality>,
23    pub js_priority: bool,
24    pub priority: Vec<String>,
25    #[serde(default = "default_platform_priority")]
26    pub platform_priority: Vec<String>,
27}
28
29impl Default for SourceSettings {
30    fn default() -> Self {
31        Self {
32            default_source: "all".to_string(),
33            default_quality: Quality::Q320k,
34            quality_fallback: vec![Quality::Q320k, Quality::Q128k, Quality::Flac],
35            js_priority: true,
36            priority: vec!["_4".to_string(), "_9.393DeepSeek".to_string()],
37            platform_priority: default_platform_priority(),
38        }
39    }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct SourceOverride {
44    pub enabled: bool,
45    pub quality: Option<Quality>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct PlayerSettings {
50    pub default_volume: u8,
51    pub repeat: String, // "off", "one", "all"
52    pub shuffle: bool,
53    pub mpv_args: Vec<String>,
54    pub mpv_socket: Option<String>,
55    pub enable_mpris: bool,
56    pub auto_resume: bool,
57}
58
59impl Default for PlayerSettings {
60    fn default() -> Self {
61        Self {
62            default_volume: 80,
63            repeat: "off".to_string(),
64            shuffle: false,
65            mpv_args: vec![],
66            mpv_socket: None,
67            enable_mpris: true,
68            auto_resume: true,
69        }
70    }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct DownloadSettings {
75    pub output_dir: String,
76    pub filename_template: String,
77    pub embed_metadata: bool,
78    pub embed_lyrics: bool,
79    pub embed_lyrics_lx: bool,
80    pub embed_lyrics_translated: bool,
81    pub embed_lyrics_romanized: bool,
82    pub embed_cover: bool,
83    pub save_lyrics_file: bool,
84    pub save_cover_file: bool,
85    pub lrc_encoding: String,
86    pub max_concurrent: usize,
87    pub skip_existing: bool,
88    pub use_other_source: bool,
89    pub group_by_source: bool,
90    pub timeout: u64,
91    pub beet_import: bool,
92    pub use_beets_library: bool,
93}
94
95impl Default for DownloadSettings {
96    fn default() -> Self {
97        Self {
98            output_dir: "~/Music/agent-lx-music".to_string(),
99            filename_template: "{singer} - {title}".to_string(),
100            embed_metadata: true,
101            embed_lyrics: true,
102            embed_lyrics_lx: true,
103            embed_lyrics_translated: false,
104            embed_lyrics_romanized: false,
105            embed_cover: true,
106            save_lyrics_file: false,
107            save_cover_file: false,
108            lrc_encoding: "utf8".to_string(),
109            max_concurrent: 3,
110            skip_existing: true,
111            use_other_source: true,
112            group_by_source: false,
113            timeout: 60,
114            beet_import: false,
115            use_beets_library: false,
116        }
117    }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct HistorySettings {
122    pub max_age_days: u64,
123}
124
125impl Default for HistorySettings {
126    fn default() -> Self {
127        Self { max_age_days: 90 }
128    }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct DisplaySettings {
133    pub color: String,       // "auto", "always", "never"
134    pub table_style: String, // "rounded", "ascii", "compact"
135    pub show_progress: bool,
136}
137
138impl Default for DisplaySettings {
139    fn default() -> Self {
140        Self {
141            color: "auto".to_string(),
142            table_style: "rounded".to_string(),
143            show_progress: true,
144        }
145    }
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct NetworkSettings {
150    pub proxy: Option<String>,
151    pub timeout: u64,
152    pub max_retries: usize,
153}
154
155impl Default for NetworkSettings {
156    fn default() -> Self {
157        Self {
158            proxy: None,
159            timeout: 15,
160            max_retries: 2,
161        }
162    }
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, Default)]
166pub struct Config {
167    pub source: SourceSettings,
168    #[serde(default)]
169    pub sources: HashMap<String, SourceOverride>,
170    #[serde(default)]
171    pub player: PlayerSettings,
172    #[serde(default)]
173    pub download: DownloadSettings,
174    pub history: HistorySettings,
175    pub display: DisplaySettings,
176    pub network: NetworkSettings,
177}
178
179#[derive(Debug, Clone)]
180pub struct XdgPaths {
181    pub config_file: PathBuf,
182    pub data_dir: PathBuf,
183    pub cache_dir: PathBuf,
184    pub sources_dir: PathBuf,
185    pub db_file: PathBuf,
186}
187
188pub fn resolve_paths() -> XdgPaths {
189    let home = std::env::var("ALX_HOME").ok().map(PathBuf::from);
190
191    let config_file = if let Some(ref h) = home {
192        h.join("config.toml")
193    } else if let Ok(c) = std::env::var("ALX_CONFIG") {
194        PathBuf::from(c)
195    } else {
196        dirs::config_dir()
197            .unwrap_or_else(|| PathBuf::from("/home/fuyu/.config"))
198            .join("agent-lx-music/config.toml")
199    };
200
201    let data_dir = if let Some(ref h) = home {
202        h.join("data")
203    } else if let Ok(d) = std::env::var("ALX_DATA") {
204        PathBuf::from(d)
205    } else {
206        dirs::data_dir()
207            .unwrap_or_else(|| PathBuf::from("/home/fuyu/.local/share"))
208            .join("agent-lx-music")
209    };
210
211    let cache_dir = if let Some(ref h) = home {
212        h.join("cache")
213    } else if let Ok(c) = std::env::var("ALX_CACHE") {
214        PathBuf::from(c)
215    } else {
216        dirs::cache_dir()
217            .unwrap_or_else(|| PathBuf::from("/home/fuyu/.cache"))
218            .join("agent-lx-music")
219    };
220
221    XdgPaths {
222        config_file,
223        sources_dir: data_dir.join("sources"),
224        db_file: data_dir.join("agent-lx-music.db"),
225        data_dir,
226        cache_dir,
227    }
228}
229
230impl Config {
231    pub fn load() -> Result<Self> {
232        let paths = resolve_paths();
233        if !paths.config_file.exists() {
234            Self::init_default(&paths)?;
235        }
236
237        let content = fs::read_to_string(&paths.config_file)
238            .map_err(|e| LuxError::Config(format!("Failed to read config file: {}", e)))?;
239
240        let config: Config = toml::from_str(&content)
241            .map_err(|e| LuxError::Config(format!("Failed to parse TOML config: {}", e)))?;
242
243        Ok(config)
244    }
245
246    pub fn save(&self) -> Result<()> {
247        let paths = resolve_paths();
248        if let Some(parent) = paths.config_file.parent() {
249            fs::create_dir_all(parent)
250                .map_err(|e| LuxError::Io(format!("Failed to create config dir: {}", e)))?;
251        }
252
253        let content = toml::to_string_pretty(self)
254            .map_err(|e| LuxError::Config(format!("Failed to serialize TOML config: {}", e)))?;
255
256        fs::write(&paths.config_file, content)
257            .map_err(|e| LuxError::Io(format!("Failed to write config file: {}", e)))?;
258
259        Ok(())
260    }
261
262    fn init_default(paths: &XdgPaths) -> Result<()> {
263        if let Some(parent) = paths.config_file.parent() {
264            fs::create_dir_all(parent)
265                .map_err(|e| LuxError::Io(format!("Failed to create config dir: {}", e)))?;
266        }
267        fs::create_dir_all(&paths.sources_dir)
268            .map_err(|e| LuxError::Io(format!("Failed to create sources dir: {}", e)))?;
269        fs::create_dir_all(&paths.cache_dir)
270            .map_err(|e| LuxError::Io(format!("Failed to create cache dir: {}", e)))?;
271
272        let default_toml = get_default_config_toml();
273        fs::write(&paths.config_file, default_toml)
274            .map_err(|e| LuxError::Io(format!("Failed to write default config: {}", e)))?;
275
276        Ok(())
277    }
278
279    pub fn get_resolved_download_dir(&self) -> PathBuf {
280        expand_path(&self.download.output_dir)
281    }
282}
283
284pub fn expand_path(path_str: &str) -> PathBuf {
285    let mut resolved = path_str.to_string();
286
287    // 1. Handle tilde (~) expansion
288    if resolved.starts_with("~/")
289        && let Some(home) = dirs::home_dir()
290    {
291        resolved = resolved.replacen(
292            "~/",
293            &format!("{}/", home.to_string_lossy().trim_end_matches('/')),
294            1,
295        );
296    } else if resolved == "~"
297        && let Some(home) = dirs::home_dir()
298    {
299        resolved = home.to_string_lossy().to_string();
300    }
301
302    // 2. Expand environment variables ($VAR and ${VAR})
303    let mut final_path = String::new();
304    let chars: Vec<char> = resolved.chars().collect();
305    let mut i = 0;
306    while i < chars.len() {
307        if chars[i] == '$' && i + 1 < chars.len() {
308            if chars[i + 1] == '{' {
309                let mut j = i + 2;
310                let mut var_name = String::new();
311                while j < chars.len() && chars[j] != '}' {
312                    var_name.push(chars[j]);
313                    j += 1;
314                }
315                if j < chars.len() && chars[j] == '}' {
316                    if let Ok(val) = std::env::var(&var_name) {
317                        final_path.push_str(&val);
318                    }
319                    i = j + 1;
320                    continue;
321                }
322            } else {
323                let mut j = i + 1;
324                let mut var_name = String::new();
325                while j < chars.len() && (chars[j].is_alphanumeric() || chars[j] == '_') {
326                    var_name.push(chars[j]);
327                    j += 1;
328                }
329                if !var_name.is_empty() {
330                    if let Ok(val) = std::env::var(&var_name) {
331                        final_path.push_str(&val);
332                    }
333                    i = j;
334                    continue;
335                }
336            }
337        }
338        final_path.push(chars[i]);
339        i += 1;
340    }
341
342    PathBuf::from(final_path)
343}
344
345fn get_default_config_toml() -> &'static str {
346    r#"# ~/.config/rust-lx/config.toml
347# Default configuration for rust-lx (rlx)
348
349[source]
350# Default search source: "all" searches all sources in parallel
351default_source = "all"
352
353# Default quality (fallback order: try each until success)
354# Valid: "128k", "192k", "320k", "flac", "flac24bit", "ape", "wav"
355default_quality = "320k"
356
357# Quality fallback chain (tried in order when default unavailable)
358quality_fallback = ["320k", "128k", "flac"]
359
360# Prefer JS sources over native parsers for same platform
361js_priority = true
362
363# Source priority list — controls search order and URL resolution fallback
364# Sources not listed here get appended at the end in alphabetical order
365priority = ["_4", "_9.393DeepSeek"]
366
367# Platform search and matching priority order
368platform_priority = ["wy", "kw", "tx", "mg", "kg"]
369
370[sources]
371# Source-specific overrides (optional)
372# [sources.sixyin_v1.2.1]
373# enabled = true
374# quality = "flac"
375
376[player]
377default_volume = 80
378repeat = "off" # "off", "one", "all"
379shuffle = false
380mpv_args = []
381enable_mpris = true
382auto_resume = true
383
384[download]
385output_dir = "~/Music/rust-lx"
386filename_template = "{singer} - {title}"
387embed_metadata = true
388embed_lyrics = true
389embed_lyrics_lx = true
390embed_lyrics_translated = false
391embed_lyrics_romanized = false
392embed_cover = true
393save_lyrics_file = false
394save_cover_file = false
395lrc_encoding = "utf8"
396max_concurrent = 3
397skip_existing = true
398use_other_source = true
399group_by_source = false
400timeout = 60
401beet_import = false
402use_beets_library = false
403
404[history]
405max_age_days = 90
406
407[display]
408color = "auto" # "auto", "always", "never"
409table_style = "rounded" # "rounded", "ascii", "compact"
410show_progress = true
411
412[network]
413# proxy = "socks5://127.0.0.1:1080"
414timeout = 15
415max_retries = 2
416"#
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use std::env;
423    use std::sync::Mutex;
424
425    static TEST_MUTEX: Mutex<()> = Mutex::new(());
426
427    #[test]
428    fn test_all_config_operations() {
429        let _guard = TEST_MUTEX.lock().unwrap();
430
431        // 1. Test resolve paths with home override
432        let temp_dir_home = env::temp_dir().join("alx-test-home");
433        if temp_dir_home.exists() {
434            let _ = fs::remove_dir_all(&temp_dir_home);
435        }
436        unsafe {
437            env::set_var("ALX_HOME", temp_dir_home.to_str().unwrap());
438        }
439
440        let paths = resolve_paths();
441        assert_eq!(paths.config_file, temp_dir_home.join("config.toml"));
442        assert_eq!(paths.data_dir, temp_dir_home.join("data"));
443        assert_eq!(paths.cache_dir, temp_dir_home.join("cache"));
444        assert_eq!(
445            paths.sources_dir,
446            temp_dir_home.join("data").join("sources")
447        );
448
449        unsafe {
450            env::remove_var("ALX_HOME");
451        }
452
453        // 2. Test config default load and save
454        let temp_dir_load = env::temp_dir().join("alx-test-default-load-save");
455        if temp_dir_load.exists() {
456            let _ = fs::remove_dir_all(&temp_dir_load);
457        }
458        unsafe {
459            env::set_var("ALX_HOME", temp_dir_load.to_str().unwrap());
460        }
461
462        // Loading should initialize the default config
463        let config = Config::load();
464        assert!(config.is_ok());
465
466        let mut config = config.unwrap();
467        assert_eq!(config.source.default_source, "all");
468        assert_eq!(config.source.default_quality, Quality::Q320k);
469
470        // Modify a setting and save
471        config.player.default_volume = 90;
472        assert!(config.save().is_ok());
473
474        // Reload to verify it preserved the change
475        let reloaded = Config::load().unwrap();
476        assert_eq!(reloaded.player.default_volume, 90);
477
478        let _ = fs::remove_dir_all(&temp_dir_load);
479        let _ = fs::remove_dir_all(&temp_dir_home);
480        unsafe {
481            env::remove_var("ALX_HOME");
482        }
483    }
484
485    #[test]
486    fn test_expand_path() {
487        let _guard = TEST_MUTEX.lock().unwrap();
488
489        // Test tilde expansion
490        if let Some(home) = dirs::home_dir() {
491            let expanded = expand_path("~/Music/agent-lx-music");
492            assert_eq!(expanded, home.join("Music/agent-lx-music"));
493
494            let expanded_only_tilde = expand_path("~");
495            assert_eq!(expanded_only_tilde, home);
496        }
497
498        // Test environment variable expansion
499        unsafe {
500            env::set_var("TEST_DIR", "foo");
501            env::set_var("TEST_SUB", "bar");
502        }
503
504        let expanded_simple = expand_path("/tmp/$TEST_DIR/baz");
505        assert_eq!(expanded_simple, PathBuf::from("/tmp/foo/baz"));
506
507        let expanded_braces = expand_path("/tmp/${TEST_DIR}_test/$TEST_SUB");
508        assert_eq!(expanded_braces, PathBuf::from("/tmp/foo_test/bar"));
509
510        unsafe {
511            env::remove_var("TEST_DIR");
512            env::remove_var("TEST_SUB");
513        }
514    }
515}