From 455ec54613230f4004a24f8d2798fb0084e00cb4 Mon Sep 17 00:00:00 2001 From: salmonik Date: Mon, 11 May 2026 16:25:49 +0200 Subject: Add display_format config, fix restart command, clean up config code - Add display_format option with get/set/list/unset for customizing track display - Add Track::format() supporting $ARTIST, $ALBUM, $TRACK variables - Update queue, status, notifications to use display_format - Fix restart command using wrong subcommand 'start' -> 'start-daemon' - Remove socket_path from config (now computed at runtime from XDG_RUNTIME_DIR) - Consolidate duplicate save() and save_all() into single save() - Fix redundant .clone().clone() in config.rs - Add todo item for caching config in daemon hot paths --- AGENTS.md | 4 +- Cargo.lock | 107 ++++-------------------------- Cargo.toml | 2 +- TODO.md | 6 +- rsplayer.service | 4 +- src/cli.rs | 12 ++-- src/config.rs | 186 ++++++++++++++++++++++++++++++++++++++++++---------- src/daemon.rs | 66 ++++++++++++++----- src/notification.rs | 23 ++++--- src/queue.rs | 16 +++++ tests/config.rs | 16 +++-- tests/queue.rs | 26 ++++++++ 12 files changed, 294 insertions(+), 174 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4ec9a5a..b7ce695 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,4 @@ Run these before completing any task: - [x] Desktop notifications for track changes, queue end, and errors ## Remaining Work -- Queue state persistence to disk -- Album shuffle mode with proper album/folder grouping -- Scrobbling (Last.fm/ListenBrainz) +Check @TODO.md diff --git a/Cargo.lock b/Cargo.lock index 52ab8c8..4d6bee2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -146,7 +146,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.4", + "rustix", "slab", "windows-sys 0.61.2", ] @@ -177,7 +177,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.4", + "rustix", ] [[package]] @@ -203,7 +203,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.4", + "rustix", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -1327,12 +1327,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1713,7 +1707,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -2040,19 +2034,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.11.1", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", -] - [[package]] name = "rustix" version = "1.1.4" @@ -2062,7 +2043,7 @@ dependencies = [ "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys 0.12.1", + "linux-raw-sys", "windows-sys 0.61.2", ] @@ -2615,18 +2596,18 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] [[package]] name = "terminal_size" -version = "0.3.0" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "rustix 0.38.44", - "windows-sys 0.48.0", + "rustix", + "windows-sys 0.61.2", ] [[package]] @@ -3338,15 +3319,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -3389,21 +3361,6 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -3470,12 +3427,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3494,12 +3445,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3518,12 +3463,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3554,12 +3493,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3578,12 +3511,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3602,12 +3529,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3626,12 +3547,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3798,7 +3713,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.4", + "rustix", "serde", "serde_repr", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 0fe9f43..33b6b24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ libc = "0.2.186" ctrlc = "3.5.2" rand = "0.10.1" notify-rust = "4.17.0" -terminal_size = "0.3" +terminal_size = "0.4.4" [dev-dependencies] tempfile = "3.17" diff --git a/TODO.md b/TODO.md index bdca569..c66ae50 100644 --- a/TODO.md +++ b/TODO.md @@ -60,6 +60,8 @@ - [x] **Shuffle first song** - `update_shuffle_indices` now shuffles all indices when position is 0; directory playback uses `q.current()` instead of `files.first()`. +- [x] **Customize status/notification display** - Added `display_format` config with `$ARTIST`, `$ALBUM`, `$TRACK` variables. Unified format for queue display, status, and notifications. + ## Remaining Work ### Phase 1-3: Completed ✓ @@ -75,6 +77,6 @@ - [ ] **Scrobbling (Last.fm/ListenBrainz)** - Add scrobbling support via configured credentials. -- [ ] **Customize status display** - Variables like $ARTIST, $ALBUM, $TRACK available in config so `queue`, `status` and notifs can be customized - - [ ] **Dynamic autocompletion of artists, albums and tracks** - Let shell communicate with the daemon so it returns autocompletions for shell + +- [ ] **Cache config in daemon** - Avoid repeated Config::load() calls in daemon hot paths; pass display_format through command chain instead diff --git a/rsplayer.service b/rsplayer.service index 4f6b36d..7a64671 100644 --- a/rsplayer.service +++ b/rsplayer.service @@ -4,9 +4,9 @@ After=network.target sound.target [Service] Type=simple -ExecStart=/usr/bin/rsp start +ExecStart=/usr/bin/rsp start-daemon Restart=on-failure RestartSec=5 [Install] -WantedBy=default.target \ No newline at end of file +WantedBy=default.target diff --git a/src/cli.rs b/src/cli.rs index bd81983..e0cea63 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -14,9 +14,9 @@ pub struct Cli { #[derive(Subcommand)] pub enum Commands { /// Start the daemon - Start, + StartDaemon, /// Stop the daemon - Stop, + StopDaemon, /// Restart the daemon (kill existing and start new) Restart, /// Play a track, album, or artist @@ -37,7 +37,7 @@ pub enum Commands { /// Toggle play/pause Toggle, /// Stop playback - StopPlayback, + Stop, /// Skip to next track Next, /// Skip to previous track @@ -101,10 +101,10 @@ pub enum SettingsCommands { impl Cli { pub fn run(&self) -> Result<(), Box> { match &self.command { - Commands::Start => { + Commands::StartDaemon => { crate::daemon::start_daemon()?; } - Commands::Stop => { + Commands::StopDaemon => { crate::daemon::stop_daemon()?; } Commands::Restart => { @@ -135,7 +135,7 @@ impl Cli { Commands::Toggle => { crate::daemon::send_command("toggle".to_string())?; } - Commands::StopPlayback => { + Commands::Stop => { crate::daemon::send_command("stop".to_string())?; } Commands::Next => { diff --git a/src/config.rs b/src/config.rs index 42573ed..5fd2f1b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,13 +9,13 @@ pub struct Config { pub config_path: PathBuf, pub music_paths: Vec, pub scan_interval: Option, - pub socket_path: 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, } impl Config { @@ -43,8 +43,13 @@ impl Config { config_path: config_path.clone(), music_paths: vec![dirs::audio_dir().unwrap_or_else(|| PathBuf::from("~/Music"))], scan_interval: Some(3600), - socket_path: None, - ..Default::default() + 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); @@ -53,15 +58,72 @@ impl Config { 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) } pub fn save(&self) -> Result<(), Box> { - let content = toml::to_string_pretty(self)?; + 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, content)?; + fs::write(&self.config_path, output)?; Ok(()) } @@ -77,32 +139,39 @@ impl Config { .scan_interval .map(|i| i.to_string()) .unwrap_or_else(|| "not set".to_string())), - "socket_path" => Ok(self - .socket_path - .as_ref() - .map(|p| p.display().to_string()) - .unwrap_or_else(|| "default".to_string())), "lastfm_api_key" => Ok(self .lastfm_api_key - .clone() + .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 - .clone() + .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 - .clone() + .as_ref() + .filter(|s| !s.is_empty()) + .map(|_| "".to_string()) .unwrap_or_else(|| "not set".to_string())), "listenbrainz_token" => Ok(self .listenbrainz_token - .clone() + .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()), } } @@ -115,9 +184,6 @@ impl Config { "scan_interval" => { self.scan_interval = Some(value.parse()?); } - "socket_path" => { - self.socket_path = Some(PathBuf::from(value)); - } "lastfm_api_key" => { self.lastfm_api_key = Some(value.to_string()); } @@ -142,45 +208,97 @@ impl Config { 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); - println!("scan_interval = {:?}", self.scan_interval); - println!("socket_path = {:?}", self.socket_path); - println!("lastfm_api_key = {:?}", self.lastfm_api_key); - println!("lastfm_api_secret = {:?}", self.lastfm_api_secret); - println!("lastfm_session_key = {:?}", self.lastfm_session_key); - println!("listenbrainz_token = {:?}", self.listenbrainz_token); - println!("volume = {:?}", self.volume); - println!("shuffle = {:?}", self.shuffle); + 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, - "socket_path" => self.socket_path = 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(&self) -> PathBuf { - self.socket_path.clone().unwrap_or_else(|| { - let runtime_dir = std::env::var("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from("/tmp")); - runtime_dir.join("rsplayer").join("daemon.sock") - }) + pub fn get_socket_path() -> PathBuf { + let runtime_dir = std::env::var("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/tmp")); + runtime_dir.join("rsplayer").join("daemon.sock") } } diff --git a/src/daemon.rs b/src/daemon.rs index b79e67a..acc7729 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -12,7 +12,7 @@ use std::time::Duration; pub fn start_daemon() -> Result<(), Box> { let config = Config::load()?; - let socket_path = config.get_socket_path(); + let socket_path = Config::get_socket_path(); let pid_path = get_pid_path(&config); // Check if daemon is already running @@ -55,7 +55,6 @@ pub fn start_daemon() -> Result<(), Box> { let queue = Arc::new(Mutex::new(Queue::new())); // Load and apply saved settings - let config = Config::load().unwrap_or_default(); if let Some(vol) = config.volume { audio_engine.set_volume(vol as f32 / 100.0); } @@ -69,6 +68,9 @@ pub fn start_daemon() -> Result<(), Box> { } } + // Save full config + config.save()?; + // Accept connections until shutdown listener.set_nonblocking(true)?; // Helper: poll until a specific track actually starts playing or timeout @@ -106,7 +108,8 @@ pub fn start_daemon() -> Result<(), Box> { audio_engine.play(next_track.clone()); if wait_for_start(&audio_engine, &next_track, 500) { started = true; - notification::notify_track(&Track::from_path(next_track)); + let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); + notification::notify_track(&Track::from_path(next_track), fmt.as_deref()); break; } } else { @@ -182,7 +185,7 @@ pub fn stop_daemon() -> Result<(), Box> { } // Clean up files - let socket_path = config.get_socket_path(); + let socket_path = Config::get_socket_path(); std::fs::remove_file(&socket_path).ok(); std::fs::remove_file(&pid_path).ok(); @@ -203,7 +206,7 @@ pub fn restart_daemon() -> Result<(), Box> { // Start new daemon in background let current_exe = std::env::current_exe()?; std::process::Command::new(current_exe) - .arg("start") + .arg("start-daemon") .spawn()?; println!("Daemon restarted"); @@ -216,8 +219,7 @@ pub fn send_command(cmd: String) -> Result<(), Box> { } fn send_command_raw(cmd: String) -> Result<(), Box> { - let config = Config::load()?; - let socket_path = config.get_socket_path(); + let socket_path = Config::get_socket_path(); let mut stream = UnixStream::connect(socket_path)?; stream.write_all(cmd.as_bytes())?; @@ -254,7 +256,11 @@ fn process_command( if audio.is_playing() { if let Some(track_path) = audio.current_track() { let track = Track::from_path(track_path); - let track_str = track.display_string(0, 1); + let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); + let track_str = match fmt.as_deref() { + Some(f) => track.format(f), + None => track.display_string(0, 1), + }; format!("Playing\nTrack: {}\n", track_str) } else { "Playing\n".to_string() @@ -296,7 +302,11 @@ fn process_command( if let Ok(mut q) = queue.lock() { if let Some(next_track) = q.next() { audio.play(next_track.clone()); - notification::notify_track(&Track::from_path(next_track)); + let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); + notification::notify_track( + &Track::from_path(next_track.clone()), + fmt.as_deref(), + ); "Playing next track\n".to_string() } else { audio.stop(); @@ -311,7 +321,11 @@ fn process_command( if let Ok(mut q) = queue.lock() { if let Some(prev_track) = q.previous() { audio.play(prev_track.clone()); - notification::notify_track(&Track::from_path(prev_track)); + let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); + notification::notify_track( + &Track::from_path(prev_track.clone()), + fmt.as_deref(), + ); "Playing previous track\n".to_string() } else { "No previous track\n".to_string() @@ -371,7 +385,13 @@ fn process_command( } else { ("last", rest) }; - let path_str = path_str.to_string(); + let path_str = if path_str.starts_with("~/") { + std::env::var("HOME") + .map(|h| path_str.replacen("~", &h, 1)) + .unwrap_or_else(|_| path_str.to_string()) + } else { + path_str.to_string() + }; if path_str.is_empty() { return "Usage: play [now|next|last] \n".to_string(); @@ -390,7 +410,9 @@ fn process_command( } if let Some(track) = q.track_at(q.position()) { audio.play(track.path.clone()); - notification::notify_track(track); + let fmt = + Config::load().ok().and_then(|c| c.display_format.clone()); + notification::notify_track(track, fmt.as_deref()); } format!("Playing now: {}\n", path_str) } @@ -405,7 +427,9 @@ fn process_command( if was_empty && !was_playing { let track = Track::from_path(path.clone()); audio.play(path); - notification::notify_track(&track); + let fmt = + Config::load().ok().and_then(|c| c.display_format.clone()); + notification::notify_track(&track, fmt.as_deref()); format!("Playing: {}\n", path_str) } else { format!("Queued: {}\n", path_str) @@ -431,7 +455,10 @@ fn process_command( } if let Some(track) = q.track_at(q.position()) { audio.play(track.path.clone()); - notification::notify_track(track); + let fmt = Config::load() + .ok() + .and_then(|c| c.display_format.clone()); + notification::notify_track(track, fmt.as_deref()); } format!( "Playing now: {} tracks from {}\n", @@ -459,7 +486,10 @@ fn process_command( && let Some(track) = q.current() { audio.play(track.path.clone()); - notification::notify_track(track); + let fmt = Config::load() + .ok() + .and_then(|c| c.display_format.clone()); + notification::notify_track(track, fmt.as_deref()); } format!("Queued {} tracks from: {}\n", files.len(), path_str) } @@ -512,9 +542,13 @@ fn process_command( q.shuffled_indices().to_vec() }; let is_playing = audio.is_playing(); + let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); for (display_idx, &track_idx) in indices.iter().enumerate() { if let Some(track) = q.track_at(track_idx) { - let track_str = track.display_string(display_idx, q.len()); + let track_str = match fmt.as_deref() { + Some(f) => track.format(f), + None => track.display_string(display_idx, q.len()), + }; if track_idx == playing_track_idx && is_playing { output.push_str(&format!("> {} (playing)\n", track_str)); } else { diff --git a/src/notification.rs b/src/notification.rs index 2f7baa2..4947e6f 100644 --- a/src/notification.rs +++ b/src/notification.rs @@ -1,15 +1,18 @@ use crate::queue::Track; -pub fn notify_track(track: &Track) { - let body = match (track.artist.as_deref(), track.title.as_deref()) { - (Some(a), Some(t)) => format!("{} - {}", a, t), - (_, Some(t)) => t.to_string(), - _ => track - .path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("Unknown") - .to_string(), +pub fn notify_track(track: &Track, format: Option<&str>) { + let body = match format { + Some(fmt) => track.format(fmt), + None => match (track.artist.as_deref(), track.title.as_deref()) { + (Some(a), Some(t)) => format!("{} - {}", a, t), + (_, Some(t)) => t.to_string(), + _ => track + .path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Unknown") + .to_string(), + }, }; let _ = notify_rust::Notification::new() diff --git a/src/queue.rs b/src/queue.rs index 2c20356..d42203f 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -117,6 +117,22 @@ impl Track { format!("{:0width$}. {} - {}", index + 1, album_artist, title) } } + + pub fn format(&self, format_str: &str) -> String { + let artist = self.artist.clone().unwrap_or_default(); + let album = self.album.clone().unwrap_or_default(); + let title = self.title.clone().unwrap_or_else(|| { + self.path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Unknown") + .to_string() + }); + format_str + .replace("$ARTIST", &artist) + .replace("$ALBUM", &album) + .replace("$TRACK", &title) + } } pub struct Queue { diff --git a/tests/config.rs b/tests/config.rs index 70c5034..1702db9 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -7,13 +7,13 @@ fn test_config_get_scan_interval() { config_path: PathBuf::from("/test/config.toml"), music_paths: vec![], scan_interval: Some(3600), - socket_path: None, lastfm_api_key: None, lastfm_api_secret: None, lastfm_session_key: None, listenbrainz_token: None, volume: None, shuffle: None, + display_format: Some(String::from("$ARTIST - $TRACK")), }; assert_eq!(config.get("scan_interval").unwrap(), "3600"); } @@ -22,7 +22,15 @@ fn test_config_get_scan_interval() { fn test_config_get_not_set() { let config = Config { config_path: PathBuf::from("/test/config.toml"), - ..Default::default() + music_paths: vec![], + scan_interval: None, + lastfm_api_key: None, + lastfm_api_secret: None, + lastfm_session_key: None, + listenbrainz_token: None, + volume: None, + shuffle: None, + display_format: None, }; assert_eq!(config.get("scan_interval").unwrap(), "not set"); } @@ -33,13 +41,13 @@ fn test_config_get_volume() { config_path: PathBuf::from("/test/config.toml"), music_paths: vec![], scan_interval: None, - socket_path: None, lastfm_api_key: None, lastfm_api_secret: None, lastfm_session_key: None, listenbrainz_token: None, volume: Some(75), shuffle: None, + display_format: None, }; assert_eq!(config.get("volume").unwrap(), "75"); } @@ -113,13 +121,13 @@ fn test_config_unset_volume() { config_path: PathBuf::from("/test/config.toml"), music_paths: vec![], scan_interval: None, - socket_path: None, lastfm_api_key: None, lastfm_api_secret: None, lastfm_session_key: None, listenbrainz_token: None, volume: Some(50), shuffle: None, + display_format: None, }; config.unset("volume").unwrap(); assert_eq!(config.volume, None); diff --git a/tests/queue.rs b/tests/queue.rs index 438fa23..a73c312 100644 --- a/tests/queue.rs +++ b/tests/queue.rs @@ -248,6 +248,32 @@ fn test_track_display_index_offset() { assert_eq!(t.display_string(4, 10), "05. Artist - Title"); } +#[test] +fn test_track_format_variables() { + let t = track( + "/music/test.mp3", + Some("My Song"), + Some("John Doe"), + Some("Great Album"), + ); + assert_eq!(t.format("$ARTIST - $TRACK"), "John Doe - My Song"); + assert_eq!(t.format("$TRACK ($ALBUM)"), "My Song (Great Album)"); + assert_eq!(t.format("Now playing: $ARTIST"), "Now playing: John Doe"); +} + +#[test] +fn test_track_format_empty_variables() { + let t = track_no_meta("/music/test.mp3"); + assert_eq!(t.format("$ARTIST - $TRACK"), " - test.mp3"); +} + +#[test] +fn test_track_format_missing_variables() { + let t = track("/music/test.mp3", Some("My Song"), Some("John Doe"), None); + assert_eq!(t.format("Track: $TRACK"), "Track: My Song"); + assert_eq!(t.format("Album: $ALBUM"), "Album: "); +} + // ---------- Shuffle ---------- #[test] -- cgit v1.2.3