/* * This file is a part of RSPlayer * Licensed under the GPL-3.0 License * Copyright (C) 2026 Salmonik @ salmonik@tuta.io * https://codeberg.org/salmonik/RSPlayer */ use directories::ProjectDirs; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; use std::sync::{LazyLock, RwLock}; use std::time::SystemTime; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Config { #[serde(skip)] pub config_path: PathBuf, pub music_paths: Vec, pub scan_interval: Option, pub lastfm_api_key: Option, pub lastfm_api_secret: Option, pub lastfm_session_key: Option, pub listenbrainz_token: Option, pub volume: Option, pub shuffle: Option, pub display_format: Option, } /// Identity of the config file on disk, used to tell whether a cached copy is /// still current. `modified` alone is not enough: several filesystems only /// store whole seconds, so a quick edit could keep the same timestamp. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct FileStamp { modified: Option, len: u64, } struct CachedConfig { config: Config, stamp: FileStamp, } static CACHE: LazyLock>> = LazyLock::new(|| RwLock::new(None)); impl Config { pub fn load() -> Result> { let config_path = Self::config_path()?; let config_dir = config_path .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| PathBuf::from(".")); if !config_path.exists() { fs::create_dir_all(&config_dir)?; let default = Self { config_path: config_path.clone(), music_paths: vec![dirs::audio_dir().unwrap_or_else(|| PathBuf::from("~/Music"))], scan_interval: Some(3600), lastfm_api_key: None, lastfm_api_secret: None, lastfm_session_key: None, listenbrainz_token: None, volume: Some(50), shuffle: None, display_format: Some(String::from("$ARTIST - $TRACK")), }; default.save()?; return Ok(default); } let content = fs::read_to_string(&config_path)?; let mut config: Self = toml::from_str(&content)?; config.config_path = config_path; if config.music_paths.is_empty() { config.music_paths = vec![dirs::audio_dir().unwrap_or_else(|| PathBuf::from("~/Music"))]; } if config.scan_interval.is_none() { config.scan_interval = Some(3600); } config.save()?; Ok(config) } /// Where the config lives: next to the binary for debug builds (so a /// checkout does not touch the installed config), XDG config otherwise. pub fn config_path() -> Result> { if cfg!(debug_assertions) { return Ok(std::env::current_exe()? .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| PathBuf::from(".")) .join("config.toml")); } let proj_dirs = ProjectDirs::from("com", "rsplayer", "rsplayer") .ok_or("Failed to get project directories")?; let config_dir = if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { PathBuf::from(xdg).join("rsplayer") } else { proj_dirs.config_dir().to_path_buf() }; Ok(config_dir.join("config.toml")) } fn file_stamp() -> FileStamp { let stamp = Self::config_path() .ok() .and_then(|path| fs::metadata(path).ok()); FileStamp { modified: stamp.as_ref().and_then(|m| m.modified().ok()), len: stamp.as_ref().map(|m| m.len()).unwrap_or(0), } } /// The config, parsed once and reused until the file changes on disk. /// /// The daemon reads the config for every command, status line and /// notification, and [`Config::load`] parses the TOML *and* rewrites the /// file. Checking a stat instead keeps that off the hot paths while /// `rsp settings set` still takes effect immediately. pub fn cached() -> Result> { let stamp = Self::file_stamp(); if let Ok(guard) = CACHE.read() && let Some(cached) = guard.as_ref() && cached.stamp == stamp { return Ok(cached.config.clone()); } let config = Self::load()?; // `load` normalizes and rewrites the file, so take the stamp afterwards: // reading it before would make our own write look like an outside edit. let stamp = Self::file_stamp(); if let Ok(mut guard) = CACHE.write() { *guard = Some(CachedConfig { config: config.clone(), stamp, }); } Ok(config) } pub fn save(&self) -> Result<(), Box> { let mut output = String::new(); let paths_str = self .music_paths .iter() .map(|p| format!("\"{}\"", p.display())) .collect::>() .join(", "); output.push_str(&format!("music_paths = [{}]\n", paths_str)); output.push_str(&format!( "scan_interval = {}\n", self.scan_interval.unwrap_or(3600) )); let api_key = self .lastfm_api_key .as_ref() .filter(|s| !s.is_empty()) .map(|s| s.as_str()) .unwrap_or(""); output.push_str(&format!("lastfm_api_key = \"{}\"\n", api_key)); let api_secret = self .lastfm_api_secret .as_ref() .filter(|s| !s.is_empty()) .map(|s| s.as_str()) .unwrap_or(""); output.push_str(&format!("lastfm_api_secret = \"{}\"\n", api_secret)); let session_key = self .lastfm_session_key .as_ref() .filter(|s| !s.is_empty()) .map(|s| s.as_str()) .unwrap_or(""); output.push_str(&format!("lastfm_session_key = \"{}\"\n", session_key)); let lb_token = self .listenbrainz_token .as_ref() .filter(|s| !s.is_empty()) .map(|s| s.as_str()) .unwrap_or(""); output.push_str(&format!("listenbrainz_token = \"{}\"\n", lb_token)); output.push_str(&format!("volume = {}\n", self.volume.unwrap_or(0))); let shuffle = self .shuffle .as_ref() .filter(|s| !s.is_empty()) .map(|s| s.as_str()) .unwrap_or(""); output.push_str(&format!("shuffle = \"{}\"\n", shuffle)); let fmt = self.display_format.clone().unwrap_or_default(); output.push_str(&format!("display_format = \"{}\"\n", fmt)); if let Some(parent) = self.config_path.parent() { fs::create_dir_all(parent)?; } fs::write(&self.config_path, output)?; // Keep the parsed cache in step with what was just written, so this // write does not make the next `cached()` re-read the file. if let Ok(mut guard) = CACHE.write() && let Some(cached) = guard.as_mut() { cached.stamp = Self::file_stamp(); cached.config = self.clone(); } Ok(()) } pub fn get(&self, key: &str) -> Result> { match key { "music_paths" => Ok(self .music_paths .iter() .map(|p| p.display().to_string()) .collect::>() .join(", ")), "scan_interval" => Ok(self .scan_interval .map(|i| i.to_string()) .unwrap_or_else(|| "not set".to_string())), "lastfm_api_key" => Ok(self .lastfm_api_key .as_ref() .filter(|s| !s.is_empty()) .map(|_| "".to_string()) .unwrap_or_else(|| "not set".to_string())), "lastfm_api_secret" => Ok(self .lastfm_api_secret .as_ref() .filter(|s| !s.is_empty()) .map(|_| "".to_string()) .unwrap_or_else(|| "not set".to_string())), "lastfm_session_key" => Ok(self .lastfm_session_key .as_ref() .filter(|s| !s.is_empty()) .map(|_| "".to_string()) .unwrap_or_else(|| "not set".to_string())), "listenbrainz_token" => Ok(self .listenbrainz_token .as_ref() .filter(|s| !s.is_empty()) .map(|_| "".to_string()) .unwrap_or_else(|| "not set".to_string())), "volume" => Ok(self .volume .map(|v| v.to_string()) .unwrap_or_else(|| "not set".to_string())), "shuffle" => Ok(self.shuffle.clone().unwrap_or_else(|| "off".to_string())), "display_format" => Ok(self .display_format .clone() .unwrap_or_else(|| "not set".to_string())), _ => Err(format!("Unknown setting: {}", key).into()), } } pub fn set(&mut self, key: &str, value: &str) -> Result<(), Box> { match key { "music_paths" => { self.music_paths = value.split(',').map(|p| PathBuf::from(p.trim())).collect(); } "scan_interval" => { self.scan_interval = Some(value.parse()?); } "lastfm_api_key" => { self.lastfm_api_key = Some(value.to_string()); } "lastfm_api_secret" => { self.lastfm_api_secret = Some(value.to_string()); } "lastfm_session_key" => { self.lastfm_session_key = Some(value.to_string()); } "listenbrainz_token" => { self.listenbrainz_token = Some(value.to_string()); } "volume" => { self.volume = Some(value.parse()?); } "shuffle" => { if value == "on" || value == "all" || value == "album" || value == "albums" { self.shuffle = Some(value.to_string()); } else if value == "off" || value == "none" { self.shuffle = None; } else { return Err(format!("Invalid shuffle value: {}", value).into()); } } "display_format" => { self.display_format = Some(value.to_string()); } _ => return Err(format!("Unknown setting: {}", key).into()), } Ok(()) } pub fn list(&self) { println!( "music_paths = {}", self.music_paths .iter() .map(|p| p.display().to_string()) .collect::>() .join(", ") ); println!( "scan_interval = {}", self.scan_interval .map_or("not set".to_string(), |v| v.to_string()) ); println!( "lastfm_api_key = {}", self.lastfm_api_key .as_ref() .filter(|s| !s.is_empty()) .map_or("not set".to_string(), |_| "".to_string()) ); println!( "lastfm_api_secret = {}", self.lastfm_api_secret .as_ref() .filter(|s| !s.is_empty()) .map_or("not set".to_string(), |_| "".to_string()) ); println!( "lastfm_session_key = {}", self.lastfm_session_key .as_ref() .filter(|s| !s.is_empty()) .map_or("not set".to_string(), |_| "".to_string()) ); println!( "listenbrainz_token = {}", self.listenbrainz_token .as_ref() .filter(|s| !s.is_empty()) .map_or("not set".to_string(), |_| "".to_string()) ); println!( "volume = {}", self.volume .filter(|&v| v != 0) .map_or("not set".to_string(), |v| v.to_string()) ); println!( "shuffle = {}", self.shuffle .clone() .filter(|s| !s.is_empty()) .unwrap_or_else(|| "off".to_string()) ); println!( "display_format = {}", self.display_format .clone() .unwrap_or_else(|| "not set".to_string()) ); } pub fn unset(&mut self, key: &str) -> Result<(), Box> { match key { "music_paths" => self.music_paths.clear(), "scan_interval" => self.scan_interval = None, "lastfm_api_key" => self.lastfm_api_key = None, "lastfm_api_secret" => self.lastfm_api_secret = None, "lastfm_session_key" => self.lastfm_session_key = None, "listenbrainz_token" => self.listenbrainz_token = None, "volume" => self.volume = None, "shuffle" => self.shuffle = None, "display_format" => self.display_format = None, _ => return Err(format!("Unknown setting: {}", key).into()), } Ok(()) } pub fn get_socket_path() -> PathBuf { runtime_dir().join("daemon.sock") } } /// Directory holding the daemon socket and PID file. /// /// Uses the user's XDG state directory (~/.local/state/rsplayer) so the path /// is identical no matter how the daemon was started (rc.d service, login /// shell, ...). The previous XDG_RUNTIME_DIR-based path depended on the /// environment and broke the service/CLI handshake on FreeBSD, where /// XDG_RUNTIME_DIR is often unset (falling back to the world-writable /// /tmp/rsplayer). pub fn runtime_dir() -> PathBuf { if let Some(base) = directories::BaseDirs::new() && let Some(state) = base.state_dir() { return state.join("rsplayer"); } // Fallback when no home/state dir is resolvable std::env::var("XDG_RUNTIME_DIR") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from("/tmp")) .join("rsplayer") } /// Create the runtime directory (private, 0700) and return its path. pub fn ensure_runtime_dir() -> std::io::Result { let dir = runtime_dir(); std::fs::create_dir_all(&dir)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?; } Ok(dir) }