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