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, 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, pub table_style: String, 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 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 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 let p = PathBuf::from(final_path);
343 if p.is_relative() {
344 return std::env::current_dir().map(|cwd| cwd.join(&p)).unwrap_or(p);
345 }
346 p
347}
348
349fn get_default_config_toml() -> &'static str {
350 r#"# ~/.config/rust-lx/config.toml
351# Default configuration for rust-lx (rlx)
352
353[source]
354# Default search source: "all" searches all sources in parallel
355default_source = "all"
356
357# Default quality (fallback order: try each until success)
358# Valid: "128k", "192k", "320k", "flac", "flac24bit", "ape", "wav"
359default_quality = "320k"
360
361# Quality fallback chain (tried in order when default unavailable)
362quality_fallback = ["320k", "128k", "flac"]
363
364# Prefer JS sources over native parsers for same platform
365js_priority = true
366
367# Source priority list — controls search order and URL resolution fallback
368# Sources not listed here get appended at the end in alphabetical order
369priority = ["_4", "_9.393DeepSeek"]
370
371# Platform search and matching priority order
372platform_priority = ["wy", "kw", "tx", "mg", "kg"]
373
374[sources]
375# Source-specific overrides (optional)
376# [sources.sixyin_v1.2.1]
377# enabled = true
378# quality = "flac"
379
380[player]
381default_volume = 80
382repeat = "off" # "off", "one", "all"
383shuffle = false
384mpv_args = []
385enable_mpris = true
386auto_resume = true
387
388[download]
389output_dir = "~/Music/rust-lx"
390filename_template = "{singer} - {title}"
391embed_metadata = true
392embed_lyrics = true
393embed_lyrics_lx = true
394embed_lyrics_translated = false
395embed_lyrics_romanized = false
396embed_cover = true
397save_lyrics_file = false
398save_cover_file = false
399lrc_encoding = "utf8"
400max_concurrent = 3
401skip_existing = true
402use_other_source = true
403group_by_source = false
404timeout = 60
405beet_import = false
406use_beets_library = false
407
408[history]
409max_age_days = 90
410
411[display]
412color = "auto" # "auto", "always", "never"
413table_style = "rounded" # "rounded", "ascii", "compact"
414show_progress = true
415
416[network]
417# proxy = "socks5://127.0.0.1:1080"
418timeout = 15
419max_retries = 2
420"#
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use std::env;
427 use std::sync::Mutex;
428
429 static TEST_MUTEX: Mutex<()> = Mutex::new(());
430
431 #[test]
432 fn test_all_config_operations() {
433 let _guard = TEST_MUTEX.lock().unwrap();
434
435 let temp_dir_home = env::temp_dir().join("alx-test-home");
437 if temp_dir_home.exists() {
438 let _ = fs::remove_dir_all(&temp_dir_home);
439 }
440 unsafe {
441 env::set_var("ALX_HOME", temp_dir_home.to_str().unwrap());
442 }
443
444 let paths = resolve_paths();
445 assert_eq!(paths.config_file, temp_dir_home.join("config.toml"));
446 assert_eq!(paths.data_dir, temp_dir_home.join("data"));
447 assert_eq!(paths.cache_dir, temp_dir_home.join("cache"));
448 assert_eq!(
449 paths.sources_dir,
450 temp_dir_home.join("data").join("sources")
451 );
452
453 unsafe {
454 env::remove_var("ALX_HOME");
455 }
456
457 let temp_dir_load = env::temp_dir().join("alx-test-default-load-save");
459 if temp_dir_load.exists() {
460 let _ = fs::remove_dir_all(&temp_dir_load);
461 }
462 unsafe {
463 env::set_var("ALX_HOME", temp_dir_load.to_str().unwrap());
464 }
465
466 let config = Config::load();
468 assert!(config.is_ok());
469
470 let mut config = config.unwrap();
471 assert_eq!(config.source.default_source, "all");
472 assert_eq!(config.source.default_quality, Quality::Q320k);
473
474 config.player.default_volume = 90;
476 assert!(config.save().is_ok());
477
478 let reloaded = Config::load().unwrap();
480 assert_eq!(reloaded.player.default_volume, 90);
481
482 let _ = fs::remove_dir_all(&temp_dir_load);
483 let _ = fs::remove_dir_all(&temp_dir_home);
484 unsafe {
485 env::remove_var("ALX_HOME");
486 }
487 }
488
489 #[test]
490 fn test_expand_path() {
491 let _guard = TEST_MUTEX.lock().unwrap();
492
493 if let Some(home) = dirs::home_dir() {
495 let expanded = expand_path("~/Music/agent-lx-music");
496 assert_eq!(expanded, home.join("Music/agent-lx-music"));
497
498 let expanded_only_tilde = expand_path("~");
499 assert_eq!(expanded_only_tilde, home);
500 }
501
502 unsafe {
504 env::set_var("TEST_DIR", "foo");
505 env::set_var("TEST_SUB", "bar");
506 }
507
508 let expanded_simple = expand_path("/tmp/$TEST_DIR/baz");
509 assert_eq!(expanded_simple, PathBuf::from("/tmp/foo/baz"));
510
511 let expanded_braces = expand_path("/tmp/${TEST_DIR}_test/$TEST_SUB");
512 assert_eq!(expanded_braces, PathBuf::from("/tmp/foo_test/bar"));
513
514 if let Ok(cwd) = std::env::current_dir() {
516 let expanded_relative = expand_path("some/relative/path.mp3");
517 assert_eq!(expanded_relative, cwd.join("some/relative/path.mp3"));
518 }
519
520 unsafe {
521 env::remove_var("TEST_DIR");
522 env::remove_var("TEST_SUB");
523 }
524 }
525}