From e0ca6d16b18e5c094204e7e58312939dd216f598 Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Fri, 24 Jul 2026 20:44:09 +0930 Subject: [PATCH 01/23] feat: add centralised path helper --- src/core/mod.rs | 1 + src/core/paths.rs | 73 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 src/core/paths.rs diff --git a/src/core/mod.rs b/src/core/mod.rs index 1bbf21e1..5f678503 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -5,6 +5,7 @@ pub mod first_run; pub mod format; pub mod layout; pub mod pagination; +pub mod paths; pub mod persisted_playback; pub mod plugin_api; pub mod queue; diff --git a/src/core/paths.rs b/src/core/paths.rs new file mode 100644 index 00000000..59730953 --- /dev/null +++ b/src/core/paths.rs @@ -0,0 +1,73 @@ +use std::{ + ffi::OsString, + path::PathBuf, +}; + +const APP_CONFIG_DIR: &str = "spotatui"; +const FALLBACK_CONFIG_DIR: &str = ".config"; +const XDG_CONFIG_HOME_ENV: &str = "XDG_CONFIG_HOME"; + +/// Resolve spotatui's app config directory. +/// +/// Uses `$XDG_CONFIG_HOME/spotatui` when XDG_CONFIG_HOME is set to an absolute +/// path, otherwise preserves the historical `$HOME/.config/spotatui` fallback. +pub(crate) fn app_config_dir() -> Option { + app_config_dir_from(std::env::var_os(XDG_CONFIG_HOME_ENV), dirs::home_dir()) +} + +fn app_config_dir_from(xdg_config_home: Option, home: Option) -> Option { + xdg_config_home + .and_then(valid_xdg_config_home) + .or_else(|| home.map(|home| home.join(FALLBACK_CONFIG_DIR))) + .map(|config_dir| config_dir.join(APP_CONFIG_DIR)) +} + +fn valid_xdg_config_home(value: OsString) -> Option { + if value.is_empty() { + return None; + } + + let path = PathBuf::from(value); + if path.is_absolute() { + Some(path) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn app_config_dir_uses_absolute_xdg_config_home() { + let path = app_config_dir_from( + Some(OsString::from("/tmp/xdg-config")), + Some(Path::new("/home/alice").to_path_buf()), + ); + + assert_eq!(path, Some(Path::new("/tmp/xdg-config/spotatui").to_path_buf())); + } + + #[test] + fn app_config_dir_falls_back_to_home_when_xdg_is_unset() { + let path = app_config_dir_from(None, Some(Path::new("/home/alice").to_path_buf())); + + assert_eq!(path, Some(Path::new("/home/alice/.config/spotatui").to_path_buf())); + } + + #[test] + fn app_config_dir_ignores_empty_or_relative_xdg_config_home() { + let home = Some(Path::new("/home/alice").to_path_buf()); + + assert_eq!( + app_config_dir_from(Some(OsString::new()), home.clone()), + Some(Path::new("/home/alice/.config/spotatui").to_path_buf()) + ); + assert_eq!( + app_config_dir_from(Some(OsString::from("relative")), home), + Some(Path::new("/home/alice/.config/spotatui").to_path_buf()) + ); + } +} From 6260779d817f93e1dbe1df1b5641ca30b23c14e3 Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Fri, 24 Jul 2026 22:39:24 +0930 Subject: [PATCH 02/23] chore: cargo fmt path helper --- src/core/paths.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/core/paths.rs b/src/core/paths.rs index 59730953..cc6bb946 100644 --- a/src/core/paths.rs +++ b/src/core/paths.rs @@ -1,7 +1,4 @@ -use std::{ - ffi::OsString, - path::PathBuf, -}; +use std::{ffi::OsString, path::PathBuf}; const APP_CONFIG_DIR: &str = "spotatui"; const FALLBACK_CONFIG_DIR: &str = ".config"; @@ -15,7 +12,10 @@ pub(crate) fn app_config_dir() -> Option { app_config_dir_from(std::env::var_os(XDG_CONFIG_HOME_ENV), dirs::home_dir()) } -fn app_config_dir_from(xdg_config_home: Option, home: Option) -> Option { +fn app_config_dir_from( + xdg_config_home: Option, + home: Option, +) -> Option { xdg_config_home .and_then(valid_xdg_config_home) .or_else(|| home.map(|home| home.join(FALLBACK_CONFIG_DIR))) @@ -47,14 +47,20 @@ mod tests { Some(Path::new("/home/alice").to_path_buf()), ); - assert_eq!(path, Some(Path::new("/tmp/xdg-config/spotatui").to_path_buf())); + assert_eq!( + path, + Some(Path::new("/tmp/xdg-config/spotatui").to_path_buf()) + ); } #[test] fn app_config_dir_falls_back_to_home_when_xdg_is_unset() { let path = app_config_dir_from(None, Some(Path::new("/home/alice").to_path_buf())); - assert_eq!(path, Some(Path::new("/home/alice/.config/spotatui").to_path_buf())); + assert_eq!( + path, + Some(Path::new("/home/alice/.config/spotatui").to_path_buf()) + ); } #[test] From f8843ec3954d93e15817f386ab16314dafb9c51f Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Fri, 24 Jul 2026 22:44:40 +0930 Subject: [PATCH 03/23] chore: use xdg config path for client config --- src/core/config.rs | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/core/config.rs b/src/core/config.rs index 1372840c..b40dfe09 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -4,13 +4,11 @@ use serde::{Deserialize, Serialize}; use std::{ fs, io::{stdin, Write}, - path::{Path, PathBuf}, + path::PathBuf, }; const DEFAULT_PORT: u16 = 8888; const FILE_NAME: &str = "client.yml"; -const CONFIG_DIR: &str = ".config"; -const APP_CONFIG_DIR: &str = "spotatui"; const TOKEN_CACHE_FILE: &str = ".spotify_token_cache.json"; const GITIGNORE_FILE: &str = ".gitignore"; pub const NCSPOT_CLIENT_ID: &str = "d420a117a32841c2b3474932e49fb54b"; @@ -81,19 +79,9 @@ impl ClientConfig { } pub fn get_or_build_paths(&self) -> Result { - match dirs::home_dir() { - Some(home) => { - let path = Path::new(&home); - let home_config_dir = path.join(CONFIG_DIR); - let app_config_dir = home_config_dir.join(APP_CONFIG_DIR); - - if !home_config_dir.exists() { - fs::create_dir(&home_config_dir)?; - } - - if !app_config_dir.exists() { - fs::create_dir(&app_config_dir)?; - } + match crate::core::paths::app_config_dir() { + Some(app_config_dir) => { + fs::create_dir_all(&app_config_dir)?; // Create .gitignore to protect sensitive files from being committed let gitignore_path = app_config_dir.join(GITIGNORE_FILE); From 1085069b45c26e69449404d2d5335779ecd0fae0 Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Sat, 25 Jul 2026 11:22:05 +0930 Subject: [PATCH 04/23] feat: add cache and state paths to path helper --- src/core/paths.rs | 107 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 94 insertions(+), 13 deletions(-) diff --git a/src/core/paths.rs b/src/core/paths.rs index cc6bb946..9977922d 100644 --- a/src/core/paths.rs +++ b/src/core/paths.rs @@ -1,28 +1,64 @@ use std::{ffi::OsString, path::PathBuf}; -const APP_CONFIG_DIR: &str = "spotatui"; +const APP_DIR: &str = "spotatui"; const FALLBACK_CONFIG_DIR: &str = ".config"; +#[allow(dead_code)] +const FALLBACK_CACHE_DIR: &str = ".cache"; +const FALLBACK_STATE_DIR: &str = ".local/state"; +#[allow(dead_code)] +const XDG_CACHE_HOME_ENV: &str = "XDG_CACHE_HOME"; const XDG_CONFIG_HOME_ENV: &str = "XDG_CONFIG_HOME"; +const XDG_STATE_HOME_ENV: &str = "XDG_STATE_HOME"; /// Resolve spotatui's app config directory. /// /// Uses `$XDG_CONFIG_HOME/spotatui` when XDG_CONFIG_HOME is set to an absolute /// path, otherwise preserves the historical `$HOME/.config/spotatui` fallback. pub(crate) fn app_config_dir() -> Option { - app_config_dir_from(std::env::var_os(XDG_CONFIG_HOME_ENV), dirs::home_dir()) + app_dir_from( + std::env::var_os(XDG_CONFIG_HOME_ENV), + dirs::home_dir(), + FALLBACK_CONFIG_DIR, + ) } -fn app_config_dir_from( - xdg_config_home: Option, +/// Resolve spotatui's app cache directory. +/// +/// Uses `$XDG_CACHE_HOME/spotatui` when XDG_CACHE_HOME is set to an absolute +/// path, otherwise falls back to `$HOME/.cache/spotatui`. +#[allow(dead_code)] +pub(crate) fn app_cache_dir() -> Option { + app_dir_from( + std::env::var_os(XDG_CACHE_HOME_ENV), + dirs::home_dir(), + FALLBACK_CACHE_DIR, + ) +} + +/// Resolve spotatui's app state directory. +/// +/// Uses `$XDG_STATE_HOME/spotatui` when XDG_STATE_HOME is set to an absolute +/// path, otherwise falls back to `$HOME/.local/state/spotatui`. +pub(crate) fn app_state_dir() -> Option { + app_dir_from( + std::env::var_os(XDG_STATE_HOME_ENV), + dirs::home_dir(), + FALLBACK_STATE_DIR, + ) +} + +fn app_dir_from( + xdg_home: Option, home: Option, + fallback_dir: &str, ) -> Option { - xdg_config_home - .and_then(valid_xdg_config_home) - .or_else(|| home.map(|home| home.join(FALLBACK_CONFIG_DIR))) - .map(|config_dir| config_dir.join(APP_CONFIG_DIR)) + xdg_home + .and_then(valid_xdg_home) + .or_else(|| home.map(|home| home.join(fallback_dir))) + .map(|dir| dir.join(APP_DIR)) } -fn valid_xdg_config_home(value: OsString) -> Option { +fn valid_xdg_home(value: OsString) -> Option { if value.is_empty() { return None; } @@ -42,9 +78,10 @@ mod tests { #[test] fn app_config_dir_uses_absolute_xdg_config_home() { - let path = app_config_dir_from( + let path = app_dir_from( Some(OsString::from("/tmp/xdg-config")), Some(Path::new("/home/alice").to_path_buf()), + FALLBACK_CONFIG_DIR, ); assert_eq!( @@ -55,7 +92,11 @@ mod tests { #[test] fn app_config_dir_falls_back_to_home_when_xdg_is_unset() { - let path = app_config_dir_from(None, Some(Path::new("/home/alice").to_path_buf())); + let path = app_dir_from( + None, + Some(Path::new("/home/alice").to_path_buf()), + FALLBACK_CONFIG_DIR, + ); assert_eq!( path, @@ -68,12 +109,52 @@ mod tests { let home = Some(Path::new("/home/alice").to_path_buf()); assert_eq!( - app_config_dir_from(Some(OsString::new()), home.clone()), + app_dir_from(Some(OsString::new()), home.clone(), FALLBACK_CONFIG_DIR), Some(Path::new("/home/alice/.config/spotatui").to_path_buf()) ); assert_eq!( - app_config_dir_from(Some(OsString::from("relative")), home), + app_dir_from(Some(OsString::from("relative")), home, FALLBACK_CONFIG_DIR), Some(Path::new("/home/alice/.config/spotatui").to_path_buf()) ); } + + #[test] + fn app_cache_dir_uses_xdg_cache_home_or_home_fallback() { + assert_eq!( + app_dir_from( + Some(OsString::from("/tmp/xdg-cache")), + Some(Path::new("/home/alice").to_path_buf()), + FALLBACK_CACHE_DIR, + ), + Some(Path::new("/tmp/xdg-cache/spotatui").to_path_buf()) + ); + assert_eq!( + app_dir_from( + None, + Some(Path::new("/home/alice").to_path_buf()), + FALLBACK_CACHE_DIR, + ), + Some(Path::new("/home/alice/.cache/spotatui").to_path_buf()) + ); + } + + #[test] + fn app_state_dir_uses_xdg_state_home_or_home_fallback() { + assert_eq!( + app_dir_from( + Some(OsString::from("/tmp/xdg-state")), + Some(Path::new("/home/alice").to_path_buf()), + FALLBACK_STATE_DIR, + ), + Some(Path::new("/tmp/xdg-state/spotatui").to_path_buf()) + ); + assert_eq!( + app_dir_from( + None, + Some(Path::new("/home/alice").to_path_buf()), + FALLBACK_STATE_DIR, + ), + Some(Path::new("/home/alice/.local/state/spotatui").to_path_buf()) + ); + } } From 46e98b0a71e4119c363a4b527295ac1713bf5910 Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Sat, 25 Jul 2026 11:24:04 +0930 Subject: [PATCH 05/23] chore: refactor to read and wirte from xdg paths chore: use xdg config path for user config chore: use xdg state path for last session chore: use xdg state path for histories chore: use xdg cache path for streaming cache chore: use xdg config path for runtime client auth config chore: update comments to not explicitly reference `~/.config/spotatui/` chore: show actual config file path in first run prompt --- src/cli/plugin.rs | 9 +++---- src/core/first_run.rs | 27 +++++++++++++++++---- src/core/persisted_playback.rs | 6 ++--- src/core/user_config.rs | 8 +++---- src/infra/history.rs | 43 ++++++++++------------------------ src/infra/player/streaming.rs | 7 +----- src/infra/youtube/playlists.rs | 2 +- src/runtime.rs | 10 +++----- 8 files changed, 51 insertions(+), 61 deletions(-) diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs index 9e5c756d..f19954e9 100644 --- a/src/cli/plugin.rs +++ b/src/cli/plugin.rs @@ -1,6 +1,7 @@ //! `spotatui plugin` subcommand: a thin git-based installer for Lua plugins. //! -//! Plugins are git repositories cloned into `~/.config/spotatui/plugins//`, where the +//! Plugins are git repositories cloned into the app config directory's +//! `plugins//`, where the //! engine loads `main.lua` (or `init.lua`) at startup. Installed plugins are tracked in a //! `plugins.lock` file next to the `plugins/` directory so they can be listed and updated. @@ -19,7 +20,7 @@ pub fn plugin_subcommand() -> Command { .about("Install and manage Lua plugins") .long_about( "Manage Lua plugins installed from git repositories. Plugins are cloned into \ -~/.config/spotatui/plugins// and loaded at startup (main.lua, or init.lua). \ +the spotatui config directory's plugins// and loaded at startup (main.lua, or init.lua). \ Requires `git` on your PATH.", ) .subcommand_required(true) @@ -74,7 +75,7 @@ Requires `git` on your PATH.", Command::new("new") .about("Scaffold a new plugin to start from") .long_about( - "Create a new directory plugin in ~/.config/spotatui/plugins// with a working \ + "Create a new directory plugin in the spotatui config directory's plugins// with a working \ main.lua and a README.md to edit and publish.", ) .arg( @@ -366,7 +367,7 @@ Registers a `{name}_hello` command that shows a notification. Edit `main.lua` to spotatui plugin add owner/{name} ``` -Or copy this directory into `~/.config/spotatui/plugins/`. +Or copy this directory into the spotatui config directory's `plugins/`. ## Key binding diff --git a/src/core/first_run.rs b/src/core/first_run.rs index abbd1b11..fcbdf3b7 100644 --- a/src/core/first_run.rs +++ b/src/core/first_run.rs @@ -25,6 +25,17 @@ use crossterm::{ }; use std::io::{stdin, stdout, Write}; +#[cfg(any(feature = "subsonic", feature = "youtube", feature = "local-files"))] +fn config_file_path_display(user_config: &UserConfig) -> String { + user_config + .path_to_config + .as_ref() + .map(|paths| paths.config_file_path.clone()) + .or_else(|| crate::core::paths::app_config_dir().map(|dir| dir.join("config.yml"))) + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "config.yml".to_string()) +} + /// Run the interactive first-run source picker. A no-op after the first launch /// (detected by the presence of `client.yml`) and when only Spotify is compiled /// in. Must be called before [`ClientConfig::load_config`], which would otherwise @@ -305,7 +316,8 @@ async fn configure_subsonic(user_config: &mut UserConfig) -> Result<()> { Err(e) => { println!("failed: {e}"); println!( - "Saved anyway. Fix the details in ~/.config/spotatui/config.yml and relaunch if needed." + "Saved anyway. Fix the details in {} and relaunch if needed.", + config_file_path_display(user_config) ); } } @@ -333,7 +345,8 @@ fn configure_youtube(user_config: &UserConfig) { println!("YouTube playback needs the `yt-dlp` binary on your PATH."); println!("Install it (e.g. `pipx install yt-dlp` or your distro package) and relaunch."); println!( - "If it lives at a custom path, set behavior.ytdlp_path in ~/.config/spotatui/config.yml." + "If it lives at a custom path, set behavior.ytdlp_path in {}.", + config_file_path_display(user_config) ); } } @@ -344,11 +357,17 @@ fn configure_local(user_config: &UserConfig) { match &user_config.behavior.local_music_path { Some(path) => { println!("\nLocal files will be read from: {path}"); - println!("(Change behavior.local_music_path in config.yml to use another folder.)"); + println!( + "(Change behavior.local_music_path in {} to use another folder.)", + config_file_path_display(user_config) + ); } None => { println!("\nNo music folder was detected automatically."); - println!("Set behavior.local_music_path in ~/.config/spotatui/config.yml."); + println!( + "Set behavior.local_music_path in {}.", + config_file_path_display(user_config) + ); } } } diff --git a/src/core/persisted_playback.rs b/src/core/persisted_playback.rs index f0b47403..1d51e838 100644 --- a/src/core/persisted_playback.rs +++ b/src/core/persisted_playback.rs @@ -113,14 +113,14 @@ pub enum PersistedPlayback { } /// Location of the session file: `$SPOTATUI_LAST_SESSION_PATH` when set, else -/// `/last_session.yml` next to the app config. +/// `/last_session.yml`. pub fn default_session_path() -> Result { if let Ok(path) = std::env::var(PATH_ENV) { return Ok(PathBuf::from(path)); } - crate::core::user_config::default_app_config_dir() + crate::core::paths::app_state_dir() .map(|dir| dir.join(FILE_NAME)) - .ok_or_else(|| anyhow!("cannot resolve the spotatui config directory")) + .ok_or_else(|| anyhow!("cannot resolve the spotatui state directory")) } /// Load the persisted session. A missing file means "no session to resume" diff --git a/src/core/user_config.rs b/src/core/user_config.rs index b9d11b84..8b97eb7a 100644 --- a/src/core/user_config.rs +++ b/src/core/user_config.rs @@ -8,8 +8,6 @@ use std::collections::HashMap; use std::{fs, path::PathBuf}; const FILE_NAME: &str = "config.yml"; -const CONFIG_DIR: &str = ".config"; -const APP_CONFIG_DIR: &str = "spotatui"; pub const DEFAULT_TICK_RATE_MILLISECONDS: u64 = 250; pub const DEFAULT_ANIMATION_TICK_RATE_MILLISECONDS: u64 = 16; pub const MAX_TICK_RATE_MILLISECONDS: u64 = 999; @@ -88,7 +86,7 @@ pub fn format_update_delay_secs(secs: u64) -> String { } pub(crate) fn default_app_config_dir() -> Option { - dirs::home_dir().map(|home| home.join(CONFIG_DIR).join(APP_CONFIG_DIR)) + crate::core::paths::app_config_dir() } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] @@ -1105,8 +1103,8 @@ pub struct UserConfig { } impl UserConfig { - /// Get the spotatui app config directory (~/.config/spotatui). - /// Returns None if $HOME is not set. + /// Get the spotatui app config directory. + /// Returns None if neither an absolute XDG config directory nor $HOME is set. #[cfg(feature = "self-update")] pub fn get_app_config_dir() -> Option { default_app_config_dir() diff --git a/src/infra/history.rs b/src/infra/history.rs index ea24aa42..e0a2e616 100644 --- a/src/infra/history.rs +++ b/src/infra/history.rs @@ -457,24 +457,15 @@ pub fn spawn_history_collector(app: Arc>) { /// Default output path for the shareable recap page, used by the automatic /// monthly check and the in-app `generate_recap` key. pub fn recap_output_path() -> Result { - let home = dirs::home_dir().ok_or_else(|| anyhow!("No $HOME directory found for history"))?; - Ok( - home - .join(".config") - .join("spotatui") - .join("spotatui-recap.html"), - ) + crate::core::paths::app_state_dir() + .map(|dir| dir.join("spotatui-recap.html")) + .ok_or_else(|| anyhow!("cannot resolve the spotatui state directory")) } fn last_recap_file_path() -> Result { - let home = dirs::home_dir().ok_or_else(|| anyhow!("No $HOME directory found for history"))?; - Ok( - home - .join(".config") - .join("spotatui") - .join(HISTORY_SUBDIR) - .join("last_recap_at.txt"), - ) + crate::core::paths::app_state_dir() + .map(|dir| dir.join(HISTORY_SUBDIR).join("last_recap_at.txt")) + .ok_or_else(|| anyhow!("cannot resolve the spotatui state directory")) } fn auto_recap_is_due(last_recap_contents: Option<&str>, now_ts: i64) -> bool { @@ -759,14 +750,9 @@ impl ListenRecord { } fn listens_file_path() -> Result { - let home = dirs::home_dir().ok_or_else(|| anyhow!("No $HOME directory found for history"))?; - Ok( - home - .join(".config") - .join("spotatui") - .join(HISTORY_SUBDIR) - .join(LISTENS_FILE_NAME), - ) + crate::core::paths::app_state_dir() + .map(|dir| dir.join(HISTORY_SUBDIR).join(LISTENS_FILE_NAME)) + .ok_or_else(|| anyhow!("cannot resolve the spotatui state directory")) } fn append_listen_record(record: &ListenRecord) -> Result<()> { @@ -2152,14 +2138,9 @@ fn js_string(input: &str) -> String { } fn last_synced_file_path() -> Result { - let home = dirs::home_dir().ok_or_else(|| anyhow!("No $HOME directory found for history"))?; - Ok( - home - .join(".config") - .join("spotatui") - .join(HISTORY_SUBDIR) - .join("last_synced_at.txt"), - ) + crate::core::paths::app_state_dir() + .map(|dir| dir.join(HISTORY_SUBDIR).join("last_synced_at.txt")) + .ok_or_else(|| anyhow!("cannot resolve the spotatui state directory")) } /// Resolve a sync endpoint, honoring the `SPOTATUI_SYNC_BASE_URL` override diff --git a/src/infra/player/streaming.rs b/src/infra/player/streaming.rs index cdb3784b..a48c0bb9 100644 --- a/src/infra/player/streaming.rs +++ b/src/infra/player/streaming.rs @@ -1490,10 +1490,5 @@ mod tests { /// Helper to get the default cache path for streaming pub fn get_default_cache_path() -> Option { - dirs::home_dir().map(|home| { - home - .join(".config") - .join("spotatui") - .join("streaming_cache") - }) + crate::core::paths::app_cache_dir().map(|dir| dir.join("streaming_cache")) } diff --git a/src/infra/youtube/playlists.rs b/src/infra/youtube/playlists.rs index 01617667..c5b4c8f5 100644 --- a/src/infra/youtube/playlists.rs +++ b/src/infra/youtube/playlists.rs @@ -4,7 +4,7 @@ //! There is no clean login API for YouTube Music (Google shut down the OAuth //! path; cookies are fragile and risky), so instead of syncing a remote //! library the YouTube source keeps playlists **locally**: -//! `~/.config/spotatui/youtube_playlists.yml`. Together with anonymous +//! `youtube_playlists.yml` in the spotatui config directory. Together with anonymous //! search + playback this makes spotatui fully usable without any account. //! //! The file is human-editable and shareable: diff --git a/src/runtime.rs b/src/runtime.rs index d84062b5..eb1787e6 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -478,12 +478,8 @@ fn install_panic_hook() { } ratatui::restore(); - let panic_log_path = dirs::home_dir().map(|home| { - home - .join(".config") - .join("spotatui") - .join("spotatui_panic.log") - }); + let panic_log_path = + crate::core::paths::app_state_dir().map(|dir| dir.join("spotatui_panic.log")); if let Some(path) = panic_log_path.as_ref() { if let Some(parent) = path.parent() { @@ -997,7 +993,7 @@ pub async fn run() -> Result<()> { .override_usage("Press `?` while running the app to see keybindings") .before_help(BANNER) .after_help( - "Client authentication settings are stored in $HOME/.config/spotatui/client.yml (use --reconfigure-auth to update them)", + "Client authentication settings are stored in the spotatui config directory (use --reconfigure-auth to update them)", ) .arg( Arg::new("tick-rate") From dcd211307f3911bcf69e673ff1b4133dba0a8e1d Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Sun, 26 Jul 2026 15:10:23 +0930 Subject: [PATCH 06/23] chore: document XDG-aware config paths --- README.md | 15 ++++++++------- docs/configuration.md | 6 ++++-- docs/keybindings.md | 3 ++- docs/native-streaming.md | 9 +++++---- docs/scripting.md | 23 +++++++++++++---------- docs/themes.md | 4 +++- 6 files changed, 35 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index eb60aa75..dce2b856 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ Search the radio-browser.info directory in-app (Enter plays a station directly), Requires the [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) binary (`ffmpeg` recommended). No Google account, API key, or cookies — search and playback are anonymous. If playback breaks after a YouTube change, updating yt-dlp (`yt-dlp -U`) is the fix; no spotatui update needed. -**Local YouTube playlists** live in `~/.config/spotatui/youtube_playlists.yml`, a plain human-editable file you can back up or share. Create one from the sidebar, add tracks with `w`, and play a playlist as a queue with `Enter`. +**Local YouTube playlists** live in the spotatui config directory as `youtube_playlists.yml`, a plain human-editable file you can back up or share. Create one from the sidebar, add tracks with `w`, and play a playlist as a queue with `Enter`. ## Native Streaming @@ -219,7 +219,7 @@ See the [Native Streaming Wiki](https://github.com/LargeModGames/spotatui/wiki/N ## Configuration -The config file is at `${HOME}/.config/spotatui/config.yml`. You can also configure spotatui in-app by pressing `Alt-,` to open Settings. +The config file is at `$XDG_CONFIG_HOME/spotatui/config.yml`, falling back to `${HOME}/.config/spotatui/config.yml` when `XDG_CONFIG_HOME` is not set. You can also configure spotatui in-app by pressing `Alt-,` to open Settings. Nearly everything is customizable: keybindings, themes, icons, playbar button labels, status-line and window-title format templates, table columns (reorder/rename/resize), default sorting per screen, startup screen, and layout (sidebar/playbar position). Invalid values fall back to defaults with a logged warning — a config typo never blocks startup. @@ -227,7 +227,7 @@ Nearly everything is customizable: keybindings, themes, icons, playbar button la - Full config reference: [Configuration Wiki](https://github.com/LargeModGames/spotatui/wiki/Configuration) - Built-in themes (Spotify, Dracula, Nord, …): [Themes Wiki](https://github.com/LargeModGames/spotatui/wiki/Themes) -spotatui also stores local listening history at `${HOME}/.config/spotatui/history/listens.jsonl`, which powers `spotatui history recap`. Short or skipped plays are stored but excluded from recap totals. +spotatui also stores local listening history at `$XDG_STATE_HOME/spotatui/history/listens.jsonl`, falling back to `${HOME}/.local/state/spotatui/history/listens.jsonl` when `XDG_STATE_HOME` is not set. This powers `spotatui history recap`. Short or skipped plays are stored but excluded from recap totals. ### Discord Rich Presence @@ -243,7 +243,7 @@ You can also override the app ID via `SPOTATUI_DISCORD_APP_ID`, or disable it in ### Anonymous Song Counter -spotatui includes an opt-in global counter showing how many songs have been played by all users worldwide (the badge and chart at the top of this README). It is **completely anonymous** — no personal information, song names, artists, or listening history is collected; it only sends a simple increment when a new song starts. It is enabled by default and can be disabled with `enable_global_song_count: false` in `~/.config/spotatui/config.yml`. This is purely a fun community metric with zero tracking of individual users. +spotatui includes an opt-in global counter showing how many songs have been played by all users worldwide (the badge and chart at the top of this README). It is **completely anonymous** — no personal information, song names, artists, or listening history is collected; it only sends a simple increment when a new song starts. It is enabled by default and can be disabled with `enable_global_song_count: false` in `config.yml`. This is purely a fun community metric with zero tracking of individual users. ### GitHub Profile Widget @@ -326,13 +326,14 @@ Follow the spotifyd documentation to get set up. After that: If you used the original `spotify-tui` before: - The binary name changed from `spt` to `spotatui`. -- Config paths changed: `~/.config/spotify-tui/` → `~/.config/spotatui/`. +- Config paths changed: `~/.config/spotify-tui/` → `$XDG_CONFIG_HOME/spotatui/` or `~/.config/spotatui/` when `XDG_CONFIG_HOME` is not set. You can copy your existing config: ```bash -mkdir -p ~/.config/spotatui -cp -r ~/.config/spotify-tui/* ~/.config/spotatui/ +config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +mkdir -p "$config_home/spotatui" +cp -r ~/.config/spotify-tui/* "$config_home/spotatui/" ``` You may be asked to re-authenticate with Spotify the first time. diff --git a/docs/configuration.md b/docs/configuration.md index 75ed5775..619c24ab 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,8 +2,10 @@ spotatui reads `config.yml` from the app config directory: -- Linux / macOS: `~/.config/spotatui/config.yml` -- Windows: `C:\Users\\.config\spotatui\config.yml` +- `$XDG_CONFIG_HOME/spotatui/config.yml` when `XDG_CONFIG_HOME` is set to an absolute path. +- `${HOME}/.config/spotatui/config.yml` otherwise. + +You can also point spotatui at a specific config file with `--config `. All fields are optional; omitted values use the built-in defaults. A complete, commented example lives in [`examples/config.example.yml`](../examples/config.example.yml). diff --git a/docs/keybindings.md b/docs/keybindings.md index 7fae1201..043e87b0 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -37,7 +37,8 @@ Press `?` in spotatui to see the help menu with all keybindings. ## Customizing Keybindings -Edit `~/.config/spotatui/config.yml`: +Edit `config.yml` in the spotatui app config directory (`$XDG_CONFIG_HOME/spotatui`, +or `~/.config/spotatui` when `XDG_CONFIG_HOME` is not set): ```yaml keybindings: diff --git a/docs/native-streaming.md b/docs/native-streaming.md index 53d354f1..5af4006d 100644 --- a/docs/native-streaming.md +++ b/docs/native-streaming.md @@ -26,10 +26,11 @@ Native streaming uses 320 kbps by default. To select a different quality, set streaming_bitrate: 320 # 96, 160, or 320 kbps ``` -`client.yml` is in the same app config directory as `config.yml` (for example, -`~/.config/spotatui/client.yml` on Linux and macOS). This setting controls the -librespot native player directly; the Spotify app may still describe a Connect -device's quality as "Automatic". +`client.yml` is in the spotatui app config directory (for example, +`$XDG_CONFIG_HOME/spotatui/client.yml`, or `~/.config/spotatui/client.yml` when +`XDG_CONFIG_HOME` is not set). This setting controls the librespot native player +directly; the Spotify app may still describe a Connect device's quality as +"Automatic". ## Notes diff --git a/docs/scripting.md b/docs/scripting.md index a9c73747..c86893a6 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -6,7 +6,8 @@ feature, which is enabled in the default build. ## File locations -Plugins are loaded from your config directory (`~/.config/spotatui/`) at startup, in this order: +Plugins are loaded from your app config directory (`$XDG_CONFIG_HOME/spotatui`, or +`~/.config/spotatui` when `XDG_CONFIG_HOME` is not set) at startup, in this order: 1. `init.lua`, if present. 2. Single-file plugins: every `plugins/*.lua` file, sorted by filename. @@ -54,10 +55,11 @@ spotatui plugin remove # uninstall spotatui plugin new # scaffold a new plugin to start from ``` -`add` clones the repository into `~/.config/spotatui/plugins//` (a shallow clone) and -records it in `~/.config/spotatui/plugins.lock`. `update` fast-forwards each clone to the remote's -latest commit. Restart spotatui after installing or updating for changes to take effect, and bind -any commands the plugin registers under `plugin_commands` in `config.yml`. +`add` clones the repository into `plugins//` under the spotatui app config +directory (a shallow clone) and records it in `plugins.lock` in that same +directory. `update` fast-forwards each clone to the remote's latest commit. +Restart spotatui after installing or updating for changes to take effect, and +bind any commands the plugin registers under `plugin_commands` in `config.yml`. Single-file plugins you drop into `plugins/` by hand are not tracked in the lockfile; `plugin list` shows them under "untracked". @@ -71,9 +73,9 @@ your config directory: spotatui plugin new my-plugin ``` -This writes `~/.config/spotatui/plugins/my-plugin/main.lua` (with a `require_api` guard, a sample -command, and a suggested key binding) plus a `README.md`. Edit it, then `git init` and push to -share it. +This writes `plugins/my-plugin/main.lua` under the spotatui app config directory +(with a `require_api` guard, a sample command, and a suggested key binding) plus +a `README.md`. Edit it, then `git init` and push to share it. A shareable plugin is a git repository with a `main.lua` (or `init.lua`) entry point at its root: @@ -288,8 +290,9 @@ tick. If the app stalls past several interval periods, the interval fires once a ### Persistent storage Each plugin gets a private key-value store persisted as plain JSON at -`~/.config/spotatui/plugin-data/.json`. Values must be JSON-serializable (tables, -strings, numbers, booleans); functions and userdata raise. +`plugin-data/.json` under the spotatui app config directory. Values must +be JSON-serializable (tables, strings, numbers, booleans); functions and userdata +raise. - `spotatui.storage_get(key)` - the stored value, or `nil`. - `spotatui.storage_set(key, value)` - store a value. `nil` removes the key. diff --git a/docs/themes.md b/docs/themes.md index 898a02e2..540cee02 100644 --- a/docs/themes.md +++ b/docs/themes.md @@ -21,7 +21,9 @@ spotatui comes with several built-in theme presets. Access them via `Alt-,` > Th ## Custom Themes -You can create custom themes in `~/.config/spotatui/config.yml`: +You can create custom themes in `config.yml` in the spotatui app config directory +(`$XDG_CONFIG_HOME/spotatui`, or `~/.config/spotatui` when `XDG_CONFIG_HOME` is +not set): ```yaml theme: From 3f4aa8a5abfdbd59071d290465ece7727df9b7fb Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Sun, 26 Jul 2026 15:16:16 +0930 Subject: [PATCH 07/23] chore: document XDG-aware config paths in plugins --- PLUGINS.md | 10 ++++++---- examples/plugins/README.md | 12 ++++++++---- examples/plugins/accent-cycler.lua | 6 ++++-- examples/plugins/now-playing-webhook.lua | 4 +++- examples/plugins/queue-browser.lua | 6 ++++-- examples/plugins/session-stats/main.lua | 6 ++++-- examples/plugins/track-info-popup.lua | 6 ++++-- examples/plugins/track-notifier.lua | 4 +++- 8 files changed, 36 insertions(+), 18 deletions(-) diff --git a/PLUGINS.md b/PLUGINS.md index 4dd19d20..63196f3e 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -17,14 +17,16 @@ spotatui plugin remove # uninstall spotatui plugin new # scaffold a new plugin to start from ``` -Plugins are cloned into `~/.config/spotatui/plugins//` and loaded at startup. Restart -spotatui after installing, and bind any commands the plugin registers under `plugin_commands` in -`config.yml`. +Plugins are cloned into `plugins//` under the spotatui app config directory +(`$XDG_CONFIG_HOME/spotatui`, or `~/.config/spotatui` when `XDG_CONFIG_HOME` is +not set) and loaded at startup. Restart spotatui after installing, and bind any +commands the plugin registers under `plugin_commands` in `config.yml`. Plugins are not sandboxed and run with full app privileges and network access, so only install ones you trust. See [Trust and safety](docs/scripting.md#trust-and-safety). -You can also drop a single `.lua` file into `~/.config/spotatui/plugins/` by hand. +You can also drop a single `.lua` file into the app config directory's `plugins/` +folder by hand. ## First-party examples diff --git a/examples/plugins/README.md b/examples/plugins/README.md index 3765c34d..32e8211b 100644 --- a/examples/plugins/README.md +++ b/examples/plugins/README.md @@ -20,18 +20,22 @@ privileges, so read anything you install from elsewhere (see Single-file plugins go straight into `plugins/`: ```bash -cp track-notifier.lua ~/.config/spotatui/plugins/ +config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +mkdir -p "$config_home/spotatui/plugins" +cp track-notifier.lua "$config_home/spotatui/plugins/" ``` Directory plugins (a folder with a `main.lua` entry point) are copied as a whole: ```bash -cp -r session-stats ~/.config/spotatui/plugins/ +config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +mkdir -p "$config_home/spotatui/plugins" +cp -r session-stats "$config_home/spotatui/plugins/" ``` Restart spotatui after installing. Plugins that register commands need a key binding; add one to -`~/.config/spotatui/config.yml` under `plugin_commands` (each plugin documents a suggested key in -its header comment). +`config.yml` in the spotatui app config directory under `plugin_commands` (each +plugin documents a suggested key in its header comment). To install a plugin published as a git repository, use the built-in installer instead: diff --git a/examples/plugins/accent-cycler.lua b/examples/plugins/accent-cycler.lua index 0451e184..411c3cde 100644 --- a/examples/plugins/accent-cycler.lua +++ b/examples/plugins/accent-cycler.lua @@ -3,9 +3,11 @@ -- Theme overrides from set_theme are runtime-only; they reset when spotatui restarts. -- -- Install (single file): --- cp accent-cycler.lua ~/.config/spotatui/plugins/ +-- config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +-- mkdir -p "$config_home/spotatui/plugins" +-- cp accent-cycler.lua "$config_home/spotatui/plugins/" -- --- Suggested binding, in ~/.config/spotatui/config.yml: +-- Suggested binding, in config.yml in the spotatui app config directory: -- plugin_commands: -- cycle_accent: "ctrl-y" diff --git a/examples/plugins/now-playing-webhook.lua b/examples/plugins/now-playing-webhook.lua index 79bc7cdb..e0160ffe 100644 --- a/examples/plugins/now-playing-webhook.lua +++ b/examples/plugins/now-playing-webhook.lua @@ -4,7 +4,9 @@ -- webhooks, home-automation triggers, or a "what am I listening to" endpoint. -- -- Install (single file): --- cp now-playing-webhook.lua ~/.config/spotatui/plugins/ +-- config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +-- mkdir -p "$config_home/spotatui/plugins" +-- cp now-playing-webhook.lua "$config_home/spotatui/plugins/" -- Edit WEBHOOK_URL below first. local WEBHOOK_URL = "https://example.com/webhook" diff --git a/examples/plugins/queue-browser.lua b/examples/plugins/queue-browser.lua index 63b09611..7e6c7912 100644 --- a/examples/plugins/queue-browser.lua +++ b/examples/plugins/queue-browser.lua @@ -3,9 +3,11 @@ -- async data reads, timers, storage, navigation). -- -- Install (single file): --- cp queue-browser.lua ~/.config/spotatui/plugins/ +-- config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +-- mkdir -p "$config_home/spotatui/plugins" +-- cp queue-browser.lua "$config_home/spotatui/plugins/" -- --- Suggested binding, in ~/.config/spotatui/config.yml: +-- Suggested binding, in config.yml in the spotatui app config directory: -- plugin_commands: -- queue_browser: "ctrl-b" -- diff --git a/examples/plugins/session-stats/main.lua b/examples/plugins/session-stats/main.lua index 2938f687..1b7911d2 100644 --- a/examples/plugins/session-stats/main.lua +++ b/examples/plugins/session-stats/main.lua @@ -4,9 +4,11 @@ -- helper module (stats.lua). This is the layout `spotatui plugin add owner/repo` produces. -- -- Install (directory): --- cp -r session-stats ~/.config/spotatui/plugins/ +-- config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +-- mkdir -p "$config_home/spotatui/plugins" +-- cp -r session-stats "$config_home/spotatui/plugins/" -- --- Suggested binding, in ~/.config/spotatui/config.yml: +-- Suggested binding, in config.yml in the spotatui app config directory: -- plugin_commands: -- session_stats: "ctrl-s" diff --git a/examples/plugins/track-info-popup.lua b/examples/plugins/track-info-popup.lua index 5e0c3e8f..56c24fa0 100644 --- a/examples/plugins/track-info-popup.lua +++ b/examples/plugins/track-info-popup.lua @@ -1,9 +1,11 @@ -- track-info-popup: a `track_info` command that opens a popup with the current track details. -- -- Install (single file): --- cp track-info-popup.lua ~/.config/spotatui/plugins/ +-- config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +-- mkdir -p "$config_home/spotatui/plugins" +-- cp track-info-popup.lua "$config_home/spotatui/plugins/" -- --- Suggested binding, in ~/.config/spotatui/config.yml: +-- Suggested binding, in config.yml in the spotatui app config directory: -- plugin_commands: -- track_info: "ctrl-i" diff --git a/examples/plugins/track-notifier.lua b/examples/plugins/track-notifier.lua index f18f590c..49870def 100644 --- a/examples/plugins/track-notifier.lua +++ b/examples/plugins/track-notifier.lua @@ -1,7 +1,9 @@ -- track-notifier: show a "Now playing" toast and a playbar segment on every track change. -- -- Install (single file): --- cp track-notifier.lua ~/.config/spotatui/plugins/ +-- config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +-- mkdir -p "$config_home/spotatui/plugins" +-- cp track-notifier.lua "$config_home/spotatui/plugins/" -- Then restart spotatui. spotatui.on("track_change", function(pb) From 04b94605a42ef663b0647133a8b3f94c215aeb70 Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Mon, 27 Jul 2026 17:48:24 +0930 Subject: [PATCH 08/23] feat: add runtime state persistence model introduce a core state module for machine-managed app state that will move out of config.yml, including volume, shuffle, active source, announcement state, layout sizes, radio stations, and sync token. resolve the state file through the XDG state directory, save it atomically with private file permissions, and add focused round-trip and sanitization coverage. --- src/core/mod.rs | 1 + src/core/state.rs | 303 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 src/core/state.rs diff --git a/src/core/mod.rs b/src/core/mod.rs index 5f678503..ba17555b 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -11,6 +11,7 @@ pub mod plugin_api; pub mod queue; pub mod sort; pub mod source; +pub mod state; #[cfg(test)] pub mod test_helpers; pub mod user_config; diff --git a/src/core/state.rs b/src/core/state.rs new file mode 100644 index 00000000..918f08d1 --- /dev/null +++ b/src/core/state.rs @@ -0,0 +1,303 @@ +//! Persisted runtime state and app-managed mutable data. +//! +//! This is the machine-owned counterpart to `config.yml`. It holds values that +//! change as the app runs, or data the app manages directly, so ordinary config +//! edits do not churn every time volume, source, layout, announcements, or radio +//! stations change. + +use crate::core::{ + source::Source, + user_config::{RadioStationConfig, UserConfig}, +}; +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +const FILE_NAME: &str = "state.yml"; + +/// Optional state fields exactly as stored in `state.yml`. +/// +/// Fields are optional so legacy migration can overlay values from `config.yml` +/// only when `state.yml` has not already claimed ownership of that value. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct PersistedRuntimeState { + pub volume_percent: Option, + pub shuffle_enabled: Option, + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "source_config::serialize_option", + deserialize_with = "source_config::deserialize_option" + )] + pub active_source: Option, + pub seen_announcement_ids: Option>, + pub dismissed_announcements: Option>, + pub sidebar_width_percent: Option, + pub playbar_height_rows: Option, + pub library_height_percent: Option, + pub radio_stations: Option>, + pub sync_token: Option, +} + +impl PersistedRuntimeState { + /// Build a complete runtime-state snapshot from the in-memory config fields + /// that are moving out of `config.yml`. + pub fn from_user_config(config: &UserConfig) -> Self { + let behavior = &config.behavior; + Self { + volume_percent: Some(behavior.volume_percent.min(100)), + shuffle_enabled: Some(behavior.shuffle_enabled), + active_source: Some(behavior.active_source), + seen_announcement_ids: Some(sanitized_ids(&behavior.seen_announcement_ids)), + dismissed_announcements: Some(sanitized_ids(&behavior.dismissed_announcements)), + sidebar_width_percent: Some(behavior.sidebar_width_percent.min(100)), + playbar_height_rows: Some(behavior.playbar_height_rows), + library_height_percent: Some(behavior.library_height_percent.min(100)), + radio_stations: Some(sanitized_radio_stations(&behavior.radio_stations)), + sync_token: trim_to_optional_string(behavior.sync_token.clone()), + } + } + + /// Overlay this state onto config defaults after `config.yml` has loaded. + /// + /// This method intentionally updates only the fields owned by `state.yml`. + pub fn apply_to_user_config(&self, config: &mut UserConfig) { + if let Some(volume_percent) = self.volume_percent { + config.behavior.volume_percent = volume_percent.min(100); + } + if let Some(shuffle_enabled) = self.shuffle_enabled { + config.behavior.shuffle_enabled = shuffle_enabled; + } + if let Some(active_source) = self.active_source { + config.behavior.active_source = active_source; + } + if let Some(seen_announcement_ids) = &self.seen_announcement_ids { + config.behavior.seen_announcement_ids = sanitized_ids(seen_announcement_ids); + } + if let Some(dismissed_announcements) = &self.dismissed_announcements { + config.behavior.dismissed_announcements = sanitized_ids(dismissed_announcements); + } + if let Some(sidebar_width_percent) = self.sidebar_width_percent { + config.behavior.sidebar_width_percent = sidebar_width_percent.min(100); + } + if let Some(playbar_height_rows) = self.playbar_height_rows { + config.behavior.playbar_height_rows = playbar_height_rows; + } + if let Some(library_height_percent) = self.library_height_percent { + config.behavior.library_height_percent = library_height_percent.min(100); + } + if let Some(radio_stations) = &self.radio_stations { + config.behavior.radio_stations = sanitized_radio_stations(radio_stations); + } + if self.sync_token.is_some() { + config.behavior.sync_token = trim_to_optional_string(self.sync_token.clone()); + } + } +} + +/// Location of the app runtime-state file: `/state.yml`. +pub fn default_state_path() -> Result { + crate::core::paths::app_state_dir() + .map(|dir| dir.join(FILE_NAME)) + .ok_or_else(|| anyhow!("cannot resolve the spotatui state directory")) +} + +/// Load state. A missing file means empty state; malformed state is returned as +/// an error so callers can log and continue with config/default values. +pub fn load(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(contents) => { + if contents.trim().is_empty() { + Ok(PersistedRuntimeState::default()) + } else { + serde_yaml::from_str(&contents) + .with_context(|| format!("malformed runtime state file: {}", path.display())) + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(PersistedRuntimeState::default()), + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), + } +} + +/// Save state atomically and privately. `sync_token` can be present, so this +/// file uses the same private-file helper as the token/config paths. +pub fn save(path: &Path, state: &PersistedRuntimeState) -> Result<()> { + let yaml = serde_yaml::to_string(state).context("serializing runtime state")?; + if let Some(dir) = path.parent() { + ensure_private_state_dir(dir)?; + } + let tmp = path.with_extension("yml.tmp"); + crate::core::auth::write_private_file(&tmp, yaml.as_bytes()) + .with_context(|| format!("writing {}", tmp.display()))?; + std::fs::rename(&tmp, path).with_context(|| format!("replacing {}", path.display()))?; + Ok(()) +} + +fn ensure_private_state_dir(dir: &Path) -> Result<()> { + std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) + .with_context(|| format!("setting private permissions on {}", dir.display()))?; + } + + Ok(()) +} + +fn sanitized_ids(ids: &[String]) -> Vec { + ids + .iter() + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + .collect() +} + +fn sanitized_radio_stations(stations: &[RadioStationConfig]) -> Vec { + stations + .iter() + .filter_map(|station| { + let name = station.name.trim(); + let url = station.url.trim(); + if name.is_empty() || url.is_empty() { + None + } else { + Some(RadioStationConfig { + name: name.to_string(), + url: url.to_string(), + }) + } + }) + .collect() +} + +fn trim_to_optional_string(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +mod source_config { + use super::Source; + use serde::{Deserialize, Deserializer, Serializer}; + + pub(super) fn serialize_option( + source: &Option, + serializer: S, + ) -> Result + where + S: Serializer, + { + match source { + Some(source) => serializer.serialize_some(source.to_config_str()), + None => serializer.serialize_none(), + } + } + + pub(super) fn deserialize_option<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + Option::::deserialize(deserializer) + .map(|source| source.map(|source| Source::from_config_str(&source))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_file_loads_as_empty_state() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state.yml"); + + assert_eq!(load(&path).unwrap(), PersistedRuntimeState::default()); + } + + #[test] + fn state_round_trips_all_runtime_attributes() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("spotatui").join("state.yml"); + let state = PersistedRuntimeState { + volume_percent: Some(42), + shuffle_enabled: Some(true), + active_source: Some(Source::Radio), + seen_announcement_ids: Some(vec!["a".to_string(), "b".to_string()]), + dismissed_announcements: Some(vec!["old".to_string()]), + sidebar_width_percent: Some(25), + playbar_height_rows: Some(7), + library_height_percent: Some(35), + radio_stations: Some(vec![RadioStationConfig { + name: "Station".to_string(), + url: "https://example.test/stream".to_string(), + }]), + sync_token: Some("secret".to_string()), + }; + + save(&path, &state).unwrap(); + + let yaml = std::fs::read_to_string(&path).unwrap(); + assert!(yaml.contains("active_source: Radio")); + assert_eq!(load(&path).unwrap(), state); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let dir_mode = std::fs::metadata(path.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777; + let file_mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(dir_mode, 0o700); + assert_eq!(file_mode, 0o600); + } + } + + #[test] + fn applying_state_sanitizes_values() { + let mut config = UserConfig::new(); + let state = PersistedRuntimeState { + volume_percent: Some(150), + active_source: Some(Source::Subsonic), + seen_announcement_ids: Some(vec![" seen ".to_string(), " ".to_string()]), + sidebar_width_percent: Some(120), + library_height_percent: Some(101), + radio_stations: Some(vec![ + RadioStationConfig { + name: " Good ".to_string(), + url: " https://example.test ".to_string(), + }, + RadioStationConfig { + name: "Broken".to_string(), + url: " ".to_string(), + }, + ]), + sync_token: Some(" token ".to_string()), + ..Default::default() + }; + + state.apply_to_user_config(&mut config); + + assert_eq!(config.behavior.volume_percent, 100); + assert_eq!(config.behavior.active_source, Source::Subsonic); + assert_eq!(config.behavior.seen_announcement_ids, vec!["seen"]); + assert_eq!(config.behavior.sidebar_width_percent, 100); + assert_eq!(config.behavior.library_height_percent, 100); + assert_eq!( + config.behavior.radio_stations, + vec![RadioStationConfig { + name: "Good".to_string(), + url: "https://example.test".to_string(), + }] + ); + assert_eq!(config.behavior.sync_token, Some("token".to_string())); + } +} From 9de4fc7b3ec4c5deed65369f473f06d46f3cf6c5 Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Tue, 28 Jul 2026 14:41:34 +0930 Subject: [PATCH 09/23] feat: move runtime state out of config Separate app-managed runtime state into state.yml while keeping config.yml for user-authored preferences and startup overrides. --- README.md | 2 +- docs/configuration.md | 24 +- src/core/app.rs | 171 ++++++--- src/core/first_run.rs | 10 +- src/core/layout.rs | 83 ++--- src/core/persisted_playback.rs | 3 +- src/core/plugin_api.rs | 20 +- src/core/state.rs | 293 +++++++++++----- src/core/user_config.rs | 445 ++++++------------------ src/infra/local/dispatch.rs | 4 +- src/infra/media_metadata.rs | 2 +- src/infra/network/native_shuffle.rs | 3 +- src/infra/network/playback.rs | 22 +- src/infra/network/utils.rs | 2 +- src/infra/player/events.rs | 6 +- src/infra/queue/dispatch.rs | 6 +- src/infra/radio/dispatch.rs | 74 ++-- src/infra/radio/mod.rs | 2 +- src/infra/scripting/engine.rs | 3 +- src/infra/subsonic/dispatch.rs | 4 +- src/infra/youtube/dispatch.rs | 4 +- src/runtime.rs | 69 +++- src/tui/handlers/announcement_prompt.rs | 4 +- src/tui/handlers/library.rs | 4 +- src/tui/handlers/mod.rs | 5 +- src/tui/handlers/mouse.rs | 14 +- src/tui/handlers/playlist.rs | 78 ++++- src/tui/handlers/resize.rs | 164 +++++---- src/tui/handlers/search_results.rs | 9 +- src/tui/handlers/select_device.rs | 4 +- src/tui/runner.rs | 8 +- src/tui/ui/library.rs | 9 +- src/tui/ui/lyrics.rs | 4 +- src/tui/ui/player.rs | 14 +- 34 files changed, 869 insertions(+), 700 deletions(-) diff --git a/README.md b/README.md index dce2b856..0d4c004d 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ Prefer setting the password via the `SPOTATUI_SUBSONIC_PASSWORD` environment var ### Internet Radio -Search the radio-browser.info directory in-app (Enter plays a station directly), and press the save key (`F` by default) to keep a station in your sidebar. Saved stations live under `behavior.radio_stations`; the playbar shows a `LIVE` badge with the stream's now-playing title. +Search the radio-browser.info directory in-app (Enter plays a station directly), and press the save key (`F` by default) to keep a station in your sidebar. Stations can also be preconfigured in `config.yml`; stations saved in-app live in `state.yml`. The playbar shows a `LIVE` badge with the stream's now-playing title. ### YouTube diff --git a/docs/configuration.md b/docs/configuration.md index 619c24ab..b5bcb220 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -11,6 +11,8 @@ All fields are optional; omitted values use the built-in defaults. A complete, c Simple values (numbers, toggles, icons, positions) can also be changed live in the in-app **Settings** screen (see the hint in the top-right of the UI). Structured config — `format:` templates, `tables:` columns, and `playbar_control_labels` — is file-only. Edit the file while the app is closed: saving from the Settings screen rewrites the `behavior`, `theme`, and `keybindings` sections, but your `format:`, `tables:`, and `plugin_commands:` sections survive in-app saves untouched. +Machine-managed runtime state lives separately in `$XDG_STATE_HOME/spotatui/state.yml` (or `${HOME}/.local/state/spotatui/state.yml`). This includes volume, shuffle, active source, seen announcements, resized pane dimensions, and saved radio stations. `sync_token` remains user config and is stored in `config.yml`. + ## Safe by default A typo in `config.yml` never prevents the app from starting. Structural mistakes — an unknown sort field, a bad template placeholder, an invalid column id, an icon that is too wide — are logged as warnings and the affected value falls back to its built-in default. Warnings go to the log file whose path is printed at startup (`/tmp/spotatui_logs/spotatuilog`). @@ -37,7 +39,7 @@ behavior: # Volume volume_increment: 10 # step for + / - (0..=100, fatal if outside) - volume_percent: 100 # startup volume + volume_percent: 100 # optional startup volume; omit to restore last runtime volume # Scrolling table_scroll_padding: 5 # rows kept visible below the selection before @@ -84,20 +86,34 @@ Valid fields per screen: `default` keeps the order the API returns (playlist order, date saved, play order). A field that is not valid for that screen falls back to `default` with a warning. +## Internet Radio + +Preconfigured stations can be declared in `config.yml`: + +```yaml +behavior: + radio_stations: + - name: SomaFM Groove Salad + url: https://ice1.somafm.com/groovesalad-128-mp3 +``` + +Stations saved from inside the app are stored in `state.yml`. The sidebar merges configured stations first and app-saved stations second, deduped by stream URL. To remove a configured station, edit `config.yml`; the in-app remove action only removes app-saved stations from `state.yml`. + ## Layout ```yaml behavior: sidebar_position: left # left | right | hidden playbar_position: bottom # bottom | top - sidebar_width_percent: 20 # 0 hides the sidebar entirely - library_height_percent: 30 - playbar_height_rows: 6 # 0 hides the playbar + sidebar_width_percent: 20 # optional startup sidebar width; omit to restore last runtime size + library_height_percent: 30 # optional startup library height; omit to restore last runtime size + playbar_height_rows: 6 # optional startup playbar height; 0 hides it; omit to restore last runtime size small_terminal_width: 150 small_terminal_height: 45 ``` - `sidebar_position: hidden` gives the content the full width, but the sidebar auto-reveals while the Library or Playlists panel has keyboard focus or is hovered, so it never becomes unreachable. +- `sidebar_width_percent`, `library_height_percent`, and `playbar_height_rows` are optional startup overrides. Runtime resize changes use `{` / `}` for sidebar width, `(` / `)` for playbar height, `(` / `)` while hovering Library or Playlists for the library/sidebar split, and `|` to reset sizes; those changes persist in `state.yml`. - `small_terminal_width` / `small_terminal_height` are the responsive-layout breakpoints. At or above `small_terminal_width` columns the app uses the wide layout (search box inside the sidebar); below it the search box gets its own full-width top row. `enforce_wide_search_bar: true` forces the full-width search row regardless of width. - Unknown position strings fall back to the default with a warning. - Mouse hit-testing follows every arrangement automatically. diff --git a/src/core/app.rs b/src/core/app.rs index 1a88cecf..7b42930c 100644 --- a/src/core/app.rs +++ b/src/core/app.rs @@ -4,6 +4,9 @@ use crate::core::plugin_api::{ }; use crate::core::sort::{SortContext, SortField, SortOrder, SortState}; use crate::core::source::Source; +use crate::core::state::{ + PersistedRuntimeState, RadioStationAddOutcome, RadioStationConfig, RuntimeState, +}; use crate::core::user_config::{color_to_string, normalize_tick_rate_milliseconds, UserConfig}; use crate::infra::history::{RecapPeriod, StatsData, StreakSummary}; use crate::infra::network::sync::{PartySession, PartyStatus}; @@ -1372,6 +1375,8 @@ pub struct App { pub audio_capture_active: bool, pub home_scroll: u16, pub user_config: UserConfig, + pub runtime_state: RuntimeState, + pub state_path: Option, pub artists: Vec, pub artist: Option, pub album_table_context: AlbumTableContext, @@ -1712,13 +1717,9 @@ pub struct App { /// When true, we hold off on sending another one — rapid key presses /// just update `pending_volume` and the latest value wins. pub is_volume_change_in_flight: bool, - /// Deadline for a debounced config save scheduled by an auto-repeating key - /// (volume, panel resize, shuffle). Those keys used to call `save_config()` - /// synchronously per repeat, paying a full read, YAML parse, rebuild, - /// serialize, and write on the UI thread, dozens of times per second while - /// held. The tick handler flushes this once the debounce window passes; - /// shutdown flushes it unconditionally. - pub config_save_due: Option, + /// Deadline for a debounced state save scheduled by auto-repeating runtime + /// state changes such as volume and shuffle. + pub state_save_due: Option, /// Reference to the native streaming player for direct control (bypasses event channel) #[cfg(feature = "streaming")] pub streaming_player: Option>, @@ -1895,6 +1896,8 @@ impl Default for App { artists: vec![], artist: None, user_config: UserConfig::new(), + runtime_state: RuntimeState::default(), + state_path: None, saved_album_tracks_index: 0, recently_played: Default::default(), size: Size::default(), @@ -2072,7 +2075,7 @@ impl Default for App { playlist_tracks_prefetch_in_flight: HashSet::new(), playlist_sort_fetch_in_flight: HashSet::new(), is_volume_change_in_flight: false, - config_save_due: None, + state_save_due: None, pending_volume: None, last_dispatched_volume: None, #[cfg(feature = "streaming")] @@ -2146,14 +2149,31 @@ impl Default for App { } impl App { + #[cfg(test)] pub fn new( io_tx: Sender, user_config: UserConfig, spotify_token_expiry: Option, ) -> App { - // Read the persisted active source before moving user_config into the struct, + Self::new_with_state( + io_tx, + user_config, + RuntimeState::default(), + None, + spotify_token_expiry, + ) + } + + pub fn new_with_state( + io_tx: Sender, + user_config: UserConfig, + runtime_state: RuntimeState, + state_path: Option, + spotify_token_expiry: Option, + ) -> App { + // Read the persisted active source before moving runtime_state into the struct, // so the restored value overrides the Source::default() set by App::default(). - let active_source = user_config.behavior.active_source; + let active_source = runtime_state.active_source; // Resolve configurable per-context default sort states. Config validation // already rejected invalid specs at load time, so parse failure here is a // defensive fallback to the built-in default sort. @@ -2201,6 +2221,8 @@ impl App { App { io_tx: Some(io_tx), user_config, + runtime_state, + state_path, // A token expiry means a Spotify session loaded at startup; a free-source // launch with no cached token passes `None`. In-TUI login flips both fields. spotify_connected: spotify_token_expiry.is_some(), @@ -2818,7 +2840,7 @@ impl App { track_duration_ms: self.native_track_info.as_ref().map(|info| info.duration_ms), position_ms, desired_playing: is_playing, - shuffle: self.user_config.behavior.shuffle_enabled, + shuffle: self.runtime_state.shuffle_enabled, repeat: self .current_playback_context .as_ref() @@ -3918,32 +3940,89 @@ impl App { } } - /// Picks up pending volume changes from the tick loop and sends them to Spotify. - /// - /// Skips dispatching if the previous request is still in flight, or if we - /// already sent this exact value and are just waiting for the API to confirm. - /// - /// We intentionally don't clear `pending_volume` here — it sticks around until - /// `get_current_playback` sees the matching value come back from the API. - /// Schedule a debounced config save. Hot paths (volume, panel resize, - /// shuffle) call this instead of `save_config()` so a held key doesn't pay + /// Schedule a debounced state save. Hot paths (volume, shuffle) call this + /// instead of `save_runtime_state()` so a held key doesn't pay /// disk + YAML work on every auto-repeat; the save lands once, shortly /// after the last change. - pub fn schedule_config_save(&mut self) { - const CONFIG_SAVE_DEBOUNCE_MS: u64 = 500; - self.config_save_due = Some(Instant::now() + Duration::from_millis(CONFIG_SAVE_DEBOUNCE_MS)); + pub fn schedule_state_save(&mut self) { + const STATE_SAVE_DEBOUNCE_MS: u64 = 500; + self.state_save_due = Some(Instant::now() + Duration::from_millis(STATE_SAVE_DEBOUNCE_MS)); + } + + pub fn current_runtime_state(&self) -> PersistedRuntimeState { + self.runtime_state.to_persisted() + } + + pub fn save_runtime_state(&self) -> anyhow::Result<()> { + let path = match &self.state_path { + Some(path) => path.clone(), + None => crate::core::state::default_state_path()?, + }; + crate::core::state::save(&path, &self.current_runtime_state()) + } + + pub fn add_radio_station( + &mut self, + name: impl AsRef, + url: impl AsRef, + ) -> anyhow::Result { + if self.is_configured_radio_station_url(url.as_ref()) { + return Ok(RadioStationAddOutcome::AlreadyExists); + } + let before_len = self.runtime_state.radio_stations.len(); + let outcome = self.runtime_state.add_radio_station(name, url)?; + if outcome == RadioStationAddOutcome::Added { + if let Err(error) = self.save_runtime_state() { + self.runtime_state.radio_stations.truncate(before_len); + return Err(error); + } + } + Ok(outcome) + } + + pub fn is_configured_radio_station_url(&self, url: &str) -> bool { + let url = url.trim(); + !url.is_empty() + && self + .user_config + .behavior + .radio_stations + .iter() + .any(|station| station.url.trim() == url) + } + + pub fn remove_radio_station_by_url( + &mut self, + url: impl AsRef, + ) -> anyhow::Result> { + let Some(index) = self + .runtime_state + .radio_stations + .iter() + .position(|station| station.url.trim() == url.as_ref().trim()) + else { + return Ok(None); + }; + let removed = self.runtime_state.remove_radio_station_by_url(url)?; + if let Err(error) = self.save_runtime_state() { + if let Some(removed) = removed { + self.runtime_state.radio_stations.insert(index, removed); + } + return Err(error); + } + Ok(removed) } - /// Flush a scheduled config save once its debounce window has passed, or + /// Flush a scheduled state save once its debounce window has passed, or /// immediately when `force` is set (shutdown). - pub fn flush_config_save(&mut self, force: bool) { - let Some(due) = self.config_save_due else { + pub fn flush_state_save(&mut self, force: bool) { + let Some(due) = self.state_save_due else { return; }; if force || Instant::now() >= due { - self.config_save_due = None; - if let Err(e) = self.user_config.save_config() { - self.handle_error(anyhow!("Failed to save config: {}", e)); + self.state_save_due = None; + if let Err(e) = self.save_runtime_state() { + self.handle_error(anyhow!("Failed to save state: {}", e)); } } } @@ -3999,7 +4078,7 @@ impl App { // No Spotify device volume (e.g. a decoded source is playing, or the slim // build has no context): fall back to the configured volume, not 0, so the // playbar and volume-up/down base math stay correct for every source. - .unwrap_or(self.user_config.behavior.volume_percent as u32) + .unwrap_or(self.runtime_state.volume_percent as u32) } /// Set volume to an absolute percentage (0-100). Routes through the same @@ -4017,8 +4096,8 @@ impl App { // librespot. The dispatcher converts the u8 percentage to a float. if self.active_decoded_source() { self.dispatch(IoEvent::ChangeVolume(next_volume)); - self.user_config.behavior.volume_percent = next_volume; - self.schedule_config_save(); + self.runtime_state.volume_percent = next_volume; + self.schedule_state_save(); self.pending_volume = Some(next_volume); return; } @@ -4032,8 +4111,8 @@ impl App { if let Some(ctx) = &mut self.current_playback_context { ctx.device.volume_percent = Some(next_volume.into()); } - self.user_config.behavior.volume_percent = next_volume; - self.schedule_config_save(); + self.runtime_state.volume_percent = next_volume; + self.schedule_state_save(); self.pending_volume = Some(next_volume); // Notify MPRIS clients of the change (VolumeChanged is never emitted by @@ -4073,8 +4152,8 @@ impl App { // librespot. The dispatcher converts the u8 percentage to a float. if self.active_decoded_source() { self.dispatch(IoEvent::ChangeVolume(next_volume)); - self.user_config.behavior.volume_percent = next_volume; - self.schedule_config_save(); + self.runtime_state.volume_percent = next_volume; + self.schedule_state_save(); self.pending_volume = Some(next_volume); return; } @@ -4088,8 +4167,8 @@ impl App { if let Some(ctx) = &mut self.current_playback_context { ctx.device.volume_percent = Some(next_volume.into()); } - self.user_config.behavior.volume_percent = next_volume; - self.schedule_config_save(); + self.runtime_state.volume_percent = next_volume; + self.schedule_state_save(); self.pending_volume = Some(next_volume); // Notify MPRIS clients of the change (VolumeChanged is never emitted by @@ -4134,8 +4213,8 @@ impl App { // librespot. The dispatcher converts the u8 percentage to a float. if self.active_decoded_source() { self.dispatch(IoEvent::ChangeVolume(next_volume_u8)); - self.user_config.behavior.volume_percent = next_volume_u8; - self.schedule_config_save(); + self.runtime_state.volume_percent = next_volume_u8; + self.schedule_state_save(); self.pending_volume = Some(next_volume_u8); return; } @@ -4149,8 +4228,8 @@ impl App { if let Some(ctx) = &mut self.current_playback_context { ctx.device.volume_percent = Some(next_volume_u8.into()); } - self.user_config.behavior.volume_percent = next_volume_u8; - self.schedule_config_save(); + self.runtime_state.volume_percent = next_volume_u8; + self.schedule_state_save(); self.pending_volume = Some(next_volume_u8); // Notify MPRIS clients of the change (VolumeChanged is never emitted by @@ -4212,7 +4291,7 @@ impl App { volume_percent: Some(u32::from(volume_percent)), }, repeat_state: RepeatState::Off, - shuffle_state: self.user_config.behavior.shuffle_enabled, + shuffle_state: self.runtime_state.shuffle_enabled, context: None, timestamp: Utc::now(), progress: None, @@ -6229,8 +6308,8 @@ impl App { if let Some(ctx) = &mut self.current_playback_context { ctx.shuffle_state = new_shuffle_state; } - self.user_config.behavior.shuffle_enabled = new_shuffle_state; - self.schedule_config_save(); + self.runtime_state.shuffle_enabled = new_shuffle_state; + self.schedule_state_save(); // Notify MPRIS clients of the change #[cfg(all(feature = "mpris", target_os = "linux"))] @@ -9897,7 +9976,7 @@ mod tests { let mut app = make_app_simple(); app.current_playback_context = None; app.pending_volume = None; - app.user_config.behavior.volume_percent = 42; + app.runtime_state.volume_percent = 42; assert_eq!( app.desired_volume(), diff --git a/src/core/first_run.rs b/src/core/first_run.rs index fcbdf3b7..d8733bf2 100644 --- a/src/core/first_run.rs +++ b/src/core/first_run.rs @@ -13,6 +13,7 @@ use crate::core::config::ClientConfig; use crate::core::source::Source; +use crate::core::state::RuntimeState; use crate::core::user_config::UserConfig; use anyhow::{anyhow, Result}; use crossterm::{ @@ -42,6 +43,7 @@ fn config_file_path_display(user_config: &UserConfig) -> String { /// trigger the Spotify-only auth wizard on a fresh install. pub async fn run_first_run_picker( user_config: &mut UserConfig, + runtime_state: &mut RuntimeState, client_config: &mut ClientConfig, ) -> Result<()> { // First run is detected by the absence of the Spotify client config file. @@ -72,7 +74,7 @@ pub async fn run_first_run_picker( numbered_fallback(&options)? }; - apply_selections(selections, user_config, client_config).await + apply_selections(selections, user_config, runtime_state, client_config).await } /// Act on the sources the user chose. `active_source` is set to the first checked @@ -80,6 +82,7 @@ pub async fn run_first_run_picker( async fn apply_selections( selections: Vec, user_config: &mut UserConfig, + runtime_state: &mut RuntimeState, client_config: &mut ClientConfig, ) -> Result<()> { // Spotify only: keep today's behavior and let `load_config` run the wizard. @@ -96,10 +99,13 @@ async fn apply_selections( if !spotify_selected { client_config.init_default_spotify_config()?; } - user_config.behavior.active_source = active; + runtime_state.active_source = active; // The global song counter opt-in is asked before this picker runs, so the // user's choice already sits on `user_config`; save_config persists it here. + // The active source is runtime state, so save it separately. user_config.save_config()?; + let state_path = crate::core::state::default_state_path()?; + crate::core::state::save(&state_path, &runtime_state.to_persisted())?; // Collect credentials / check prerequisites for each chosen free source. for source in &selections { diff --git a/src/core/layout.rs b/src/core/layout.rs index 9f1bc3f9..b748d216 100644 --- a/src/core/layout.rs +++ b/src/core/layout.rs @@ -1,4 +1,5 @@ use crate::core::app::{ActiveBlock, App}; +use crate::core::state::RuntimeState; use crate::core::user_config::BehaviorConfig; use ratatui::layout::{Constraint, Layout, Rect}; @@ -49,23 +50,24 @@ pub fn compute_main_layout(app: &App) -> Option { let margin = main_layout_margin(app); let wide_layout = is_wide_layout(app); let behavior = &app.user_config.behavior; + let runtime_state = &app.runtime_state; let (input_area, routes_area, playbar_area) = if wide_layout { if behavior.playbar_position == "top" { let [playbar_area, routes_area] = root.layout( - &Layout::vertical([playbar_constraint(behavior), Constraint::Min(1)]).margin(margin), + &Layout::vertical([playbar_constraint(runtime_state), Constraint::Min(1)]).margin(margin), ); (None, routes_area, playbar_area) } else { let [routes_area, playbar_area] = root.layout( - &Layout::vertical([Constraint::Min(1), playbar_constraint(behavior)]).margin(margin), + &Layout::vertical([Constraint::Min(1), playbar_constraint(runtime_state)]).margin(margin), ); (None, routes_area, playbar_area) } } else if behavior.playbar_position == "top" { let [playbar_area, input_area, routes_area] = root.layout( &Layout::vertical([ - playbar_constraint(behavior), + playbar_constraint(runtime_state), Constraint::Length(3), Constraint::Min(1), ]) @@ -77,7 +79,7 @@ pub fn compute_main_layout(app: &App) -> Option { &Layout::vertical([ Constraint::Length(3), Constraint::Min(1), - playbar_constraint(behavior), + playbar_constraint(runtime_state), ]) .margin(margin), ); @@ -85,7 +87,7 @@ pub fn compute_main_layout(app: &App) -> Option { }; let (user_area, content_area) = split_routes_area(app, routes_area); - let library = library_constraints(behavior); + let library = library_constraints(runtime_state); let (input, help, settings, library_area, playlist_area) = if wide_layout { let [input_area, library_area, playlist_area] = user_area.layout(&Layout::vertical([ Constraint::Length(3), @@ -131,11 +133,11 @@ pub fn compute_main_layout(app: &App) -> Option { }) } -/// Returns horizontal constraints for the [sidebar, content] split based on config. +/// Returns horizontal constraints for the [sidebar, content] split based on runtime state. /// When sidebar_width_percent is 0, the sidebar is hidden (zero length). /// When sidebar_width_percent is 100, content is hidden. -pub fn sidebar_constraints(behavior: &BehaviorConfig) -> [Constraint; 2] { - let sidebar = behavior.sidebar_width_percent.min(100) as u16; +pub fn sidebar_constraints(state: &RuntimeState) -> [Constraint; 2] { + let sidebar = state.sidebar_width_percent.min(100) as u16; let content = 100u16.saturating_sub(sidebar); [ Constraint::Percentage(sidebar), @@ -148,7 +150,7 @@ fn split_routes_area(app: &App, routes_area: Rect) -> (Rect, Rect) { let sidebar = if sidebar_is_hidden(app) { [Constraint::Length(0), Constraint::Min(1)] } else { - sidebar_constraints(behavior) + sidebar_constraints(&app.runtime_state) }; match behavior.sidebar_position.as_str() { @@ -206,15 +208,15 @@ pub fn split_input_help_and_settings(app: &App, input_row_area: Rect) -> [Rect; input_row_area.layout(&Layout::horizontal(constraints)) } -/// Returns the playbar height constraint based on config. +/// Returns the playbar height constraint based on runtime state. /// When playbar_height_rows is 0, the playbar is hidden. -pub fn playbar_constraint(behavior: &BehaviorConfig) -> Constraint { - Constraint::Length(behavior.playbar_height_rows) +pub fn playbar_constraint(state: &RuntimeState) -> Constraint { + Constraint::Length(state.playbar_height_rows) } /// Returns vertical constraints for the [library, playlists] split within the sidebar. -pub fn library_constraints(behavior: &BehaviorConfig) -> [Constraint; 2] { - let library = behavior.library_height_percent.min(100) as u16; +pub fn library_constraints(state: &RuntimeState) -> [Constraint; 2] { + let library = state.library_height_percent.min(100) as u16; let playlists = 100u16.saturating_sub(library); [ Constraint::Percentage(library), @@ -225,14 +227,14 @@ pub fn library_constraints(behavior: &BehaviorConfig) -> [Constraint; 2] { /// Returns the fullscreen content/playbar split used by lyrics and cover-art views. /// /// When `playbar_height_rows` is 0, the playbar is hidden and the content area fills the screen. -pub fn fullscreen_view_layout(behavior: &BehaviorConfig, area: Rect) -> (Rect, Option) { - if behavior.playbar_height_rows == 0 { +pub fn fullscreen_view_layout(state: &RuntimeState, area: Rect) -> (Rect, Option) { + if state.playbar_height_rows == 0 { return (area, None); } let chunks = Layout::vertical([ Constraint::Min(0), - Constraint::Length(behavior.playbar_height_rows), + Constraint::Length(state.playbar_height_rows), ]) .split(area); let content_area = chunks[0]; @@ -268,28 +270,29 @@ pub fn miniplayer_playbar_area(area: Rect) -> Rect { #[cfg(test)] mod tests { use super::*; - use crate::core::user_config::UserConfig; - fn make_behavior_with(sidebar_pct: u8, playbar_rows: u16) -> BehaviorConfig { - let mut cfg = UserConfig::new(); - cfg.behavior.sidebar_width_percent = sidebar_pct; - cfg.behavior.playbar_height_rows = playbar_rows; - cfg.behavior + fn make_state_with(sidebar_pct: u8, playbar_rows: u16) -> RuntimeState { + RuntimeState { + sidebar_width_percent: sidebar_pct, + playbar_height_rows: playbar_rows, + ..RuntimeState::default() + } } - fn make_behavior_with_library(library_pct: u8) -> BehaviorConfig { - let mut cfg = UserConfig::new(); - cfg.behavior.library_height_percent = library_pct; - cfg.behavior + fn make_state_with_library(library_pct: u8) -> RuntimeState { + RuntimeState { + library_height_percent: library_pct, + ..RuntimeState::default() + } } - fn make_behavior(sidebar_pct: u8) -> BehaviorConfig { - make_behavior_with(sidebar_pct, 6) + fn make_state(sidebar_pct: u8) -> RuntimeState { + make_state_with(sidebar_pct, 6) } #[test] fn default_sidebar_is_20_percent() { - let b = make_behavior(20); + let b = make_state(20); let [sidebar, content] = sidebar_constraints(&b); assert_eq!(sidebar, Constraint::Percentage(20)); assert_eq!(content, Constraint::Percentage(80)); @@ -297,7 +300,7 @@ mod tests { #[test] fn hidden_sidebar_gives_zero_percent() { - let b = make_behavior(0); + let b = make_state(0); let [sidebar, content] = sidebar_constraints(&b); assert_eq!(sidebar, Constraint::Percentage(0)); assert_eq!(content, Constraint::Percentage(100)); @@ -305,7 +308,7 @@ mod tests { #[test] fn full_sidebar_hides_content() { - let b = make_behavior(100); + let b = make_state(100); let [sidebar, content] = sidebar_constraints(&b); assert_eq!(sidebar, Constraint::Percentage(100)); assert_eq!(content, Constraint::Percentage(0)); @@ -313,7 +316,7 @@ mod tests { #[test] fn over_100_percent_is_clamped() { - let mut b = make_behavior(20); + let mut b = make_state(20); b.sidebar_width_percent = 255; let [sidebar, content] = sidebar_constraints(&b); assert_eq!(sidebar, Constraint::Percentage(100)); @@ -322,19 +325,19 @@ mod tests { #[test] fn default_playbar_is_6_rows() { - let b = make_behavior_with(20, 6); + let b = make_state_with(20, 6); assert_eq!(playbar_constraint(&b), Constraint::Length(6)); } #[test] fn hidden_playbar_is_zero_rows() { - let b = make_behavior_with(20, 0); + let b = make_state_with(20, 0); assert_eq!(playbar_constraint(&b), Constraint::Length(0)); } #[test] fn default_library_is_30_percent() { - let b = make_behavior_with_library(30); + let b = make_state_with_library(30); let [lib, playlists] = library_constraints(&b); assert_eq!(lib, Constraint::Percentage(30)); assert_eq!(playlists, Constraint::Percentage(70)); @@ -342,7 +345,7 @@ mod tests { #[test] fn hidden_library_gives_zero_percent() { - let b = make_behavior_with_library(0); + let b = make_state_with_library(0); let [lib, playlists] = library_constraints(&b); assert_eq!(lib, Constraint::Percentage(0)); assert_eq!(playlists, Constraint::Percentage(100)); @@ -350,7 +353,7 @@ mod tests { #[test] fn library_over_100_percent_is_clamped() { - let mut b = make_behavior_with_library(30); + let mut b = make_state_with_library(30); b.library_height_percent = 255; let [lib, playlists] = library_constraints(&b); assert_eq!(lib, Constraint::Percentage(100)); @@ -359,7 +362,7 @@ mod tests { #[test] fn fullscreen_layout_hides_playbar_when_height_is_zero() { - let b = make_behavior_with(20, 0); + let b = make_state_with(20, 0); let area = Rect::new(2, 4, 80, 24); let (content, playbar) = fullscreen_view_layout(&b, area); @@ -370,7 +373,7 @@ mod tests { #[test] fn fullscreen_layout_splits_content_and_playbar_when_height_is_set() { - let b = make_behavior_with(20, 6); + let b = make_state_with(20, 6); let area = Rect::new(2, 4, 80, 24); let (content, playbar) = fullscreen_view_layout(&b, area); diff --git a/src/core/persisted_playback.rs b/src/core/persisted_playback.rs index 1d51e838..1323c7d4 100644 --- a/src/core/persisted_playback.rs +++ b/src/core/persisted_playback.rs @@ -1,7 +1,8 @@ //! Persist the last non-Spotify playback session so it survives restarts. //! //! The browse-scope [`Source`](crate::core::source::Source) is already persisted -//! in `config.yml` (see `BehaviorConfig::active_source`). What this module adds +//! in `state.yml` (see [`RuntimeState::active_source`](crate::core::state::RuntimeState::active_source)). +//! What this module adds //! is the *playback* side: which track/queue was playing, where in it, and //! whether it was paused — so that after a restart the app can resume the exact //! song on the same source. diff --git a/src/core/plugin_api.rs b/src/core/plugin_api.rs index 6ce28c33..0022e6a2 100644 --- a/src/core/plugin_api.rs +++ b/src/core/plugin_api.rs @@ -364,9 +364,13 @@ pub struct ConfigSnapshot { pub behavior: BehaviorSnapshot, } -/// Build a [`ConfigSnapshot`] from the live user config. Theme colors use the -/// same string forms `parse_theme_item` accepts (named or `"r, g, b"`). -pub fn config_snapshot(config: &crate::core::user_config::UserConfig) -> ConfigSnapshot { +/// Build a [`ConfigSnapshot`] from the live user config and runtime state. +/// Theme colors use the same string forms `parse_theme_item` accepts (named or +/// `"r, g, b"`). +pub fn config_snapshot( + config: &crate::core::user_config::UserConfig, + runtime_state: &crate::core::state::RuntimeState, +) -> ConfigSnapshot { use crate::core::user_config::color_to_string; let t = &config.theme; let theme: std::collections::BTreeMap = [ @@ -411,12 +415,12 @@ pub fn config_snapshot(config: &crate::core::user_config::UserConfig) -> ConfigS playing_icon: b.playing_icon.clone(), paused_icon: b.paused_icon.clone(), set_window_title: b.set_window_title, - shuffle_enabled: b.shuffle_enabled, + shuffle_enabled: runtime_state.shuffle_enabled, stop_after_current_track: b.stop_after_current_track, - sidebar_width_percent: b.sidebar_width_percent, - playbar_height_rows: b.playbar_height_rows, - library_height_percent: b.library_height_percent, - active_source: format!("{:?}", b.active_source).to_lowercase(), + sidebar_width_percent: runtime_state.sidebar_width_percent, + playbar_height_rows: runtime_state.playbar_height_rows, + library_height_percent: runtime_state.library_height_percent, + active_source: format!("{:?}", runtime_state.active_source).to_lowercase(), }, } } diff --git a/src/core/state.rs b/src/core/state.rs index 918f08d1..9fec6d16 100644 --- a/src/core/state.rs +++ b/src/core/state.rs @@ -2,27 +2,177 @@ //! //! This is the machine-owned counterpart to `config.yml`. It holds values that //! change as the app runs, or data the app manages directly, so ordinary config -//! edits do not churn every time volume, source, layout, announcements, or radio -//! stations change. +//! edits do not churn every time volume, source, pane sizes, announcements, or +//! radio stations change. -use crate::core::{ - source::Source, - user_config::{RadioStationConfig, UserConfig}, -}; +use crate::core::source::Source; use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; const FILE_NAME: &str = "state.yml"; +/// One saved internet-radio station: a display name plus the direct stream URL. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RadioStationConfig { + pub name: String, + pub url: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadioStationAddOutcome { + Added, + AlreadyExists, +} + +/// Live runtime/app-managed state. +#[derive(Clone, Debug, PartialEq)] +pub struct RuntimeState { + pub volume_percent: u8, + pub shuffle_enabled: bool, + pub active_source: Source, + pub seen_announcement_ids: Vec, + pub dismissed_announcements: Vec, + pub sidebar_width_percent: u8, + pub playbar_height_rows: u16, + pub library_height_percent: u8, + pub radio_stations: Vec, +} + +impl Default for RuntimeState { + fn default() -> Self { + Self { + volume_percent: 100, + shuffle_enabled: false, + active_source: Source::default(), + seen_announcement_ids: Vec::new(), + dismissed_announcements: Vec::new(), + sidebar_width_percent: 20, + playbar_height_rows: 6, + library_height_percent: 30, + radio_stations: Vec::new(), + } + } +} + +impl RuntimeState { + pub fn apply_persisted(&mut self, state: &PersistedRuntimeState) { + if let Some(volume_percent) = state.volume_percent { + self.volume_percent = volume_percent.min(100); + } + if let Some(shuffle_enabled) = state.shuffle_enabled { + self.shuffle_enabled = shuffle_enabled; + } + if let Some(active_source) = state.active_source { + self.active_source = active_source; + } + if let Some(seen_announcement_ids) = &state.seen_announcement_ids { + self.seen_announcement_ids = sanitized_ids(seen_announcement_ids); + } + if let Some(dismissed_announcements) = &state.dismissed_announcements { + self.dismissed_announcements = sanitized_ids(dismissed_announcements); + } + if let Some(sidebar_width_percent) = state.sidebar_width_percent { + self.sidebar_width_percent = sidebar_width_percent.min(100); + } + if let Some(playbar_height_rows) = state.playbar_height_rows { + self.playbar_height_rows = playbar_height_rows; + } + if let Some(library_height_percent) = state.library_height_percent { + self.library_height_percent = library_height_percent.min(100); + } + if let Some(radio_stations) = &state.radio_stations { + self.radio_stations = sanitized_radio_stations(radio_stations); + } + } + + pub fn to_persisted(&self) -> PersistedRuntimeState { + PersistedRuntimeState { + volume_percent: Some(self.volume_percent.min(100)), + shuffle_enabled: Some(self.shuffle_enabled), + active_source: Some(self.active_source), + seen_announcement_ids: Some(sanitized_ids(&self.seen_announcement_ids)), + dismissed_announcements: Some(sanitized_ids(&self.dismissed_announcements)), + sidebar_width_percent: Some(self.sidebar_width_percent.min(100)), + playbar_height_rows: Some(self.playbar_height_rows), + library_height_percent: Some(self.library_height_percent.min(100)), + radio_stations: Some(sanitized_radio_stations(&self.radio_stations)), + } + } + + pub fn add_radio_station( + &mut self, + name: impl AsRef, + url: impl AsRef, + ) -> Result { + let name = name.as_ref().trim(); + let url = url.as_ref().trim(); + + if name.is_empty() { + return Err(anyhow!("Radio station name is empty")); + } + if url.is_empty() { + return Err(anyhow!("Radio station URL is empty")); + } + + if self + .radio_stations + .iter() + .any(|station| station.url.trim() == url) + { + return Ok(RadioStationAddOutcome::AlreadyExists); + } + + self.radio_stations.push(RadioStationConfig { + name: name.to_string(), + url: url.to_string(), + }); + + Ok(RadioStationAddOutcome::Added) + } + + pub fn remove_radio_station_by_url( + &mut self, + url: impl AsRef, + ) -> Result> { + let url = url.as_ref().trim(); + if url.is_empty() { + return Err(anyhow!("Radio station URL is empty")); + } + + let Some(index) = self + .radio_stations + .iter() + .position(|station| station.url.trim() == url) + else { + return Ok(None); + }; + + Ok(Some(self.radio_stations.remove(index))) + } + + pub fn mark_announcement_seen(&mut self, announcement_id: impl Into) { + let id = announcement_id.into(); + if id.is_empty() { + return; + } + + if !self.seen_announcement_ids.iter().any(|seen| seen == &id) { + self.seen_announcement_ids.push(id); + } + } +} + /// Optional state fields exactly as stored in `state.yml`. /// -/// Fields are optional so legacy migration can overlay values from `config.yml` -/// only when `state.yml` has not already claimed ownership of that value. +/// Fields are optional so sparse or older `state.yml` files can overlay only the +/// values they contain onto runtime defaults. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct PersistedRuntimeState { + #[serde(skip_serializing_if = "Option::is_none")] pub volume_percent: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub shuffle_enabled: Option, #[serde( skip_serializing_if = "Option::is_none", @@ -30,69 +180,18 @@ pub struct PersistedRuntimeState { deserialize_with = "source_config::deserialize_option" )] pub active_source: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub seen_announcement_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub dismissed_announcements: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub sidebar_width_percent: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub playbar_height_rows: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub library_height_percent: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub radio_stations: Option>, - pub sync_token: Option, -} - -impl PersistedRuntimeState { - /// Build a complete runtime-state snapshot from the in-memory config fields - /// that are moving out of `config.yml`. - pub fn from_user_config(config: &UserConfig) -> Self { - let behavior = &config.behavior; - Self { - volume_percent: Some(behavior.volume_percent.min(100)), - shuffle_enabled: Some(behavior.shuffle_enabled), - active_source: Some(behavior.active_source), - seen_announcement_ids: Some(sanitized_ids(&behavior.seen_announcement_ids)), - dismissed_announcements: Some(sanitized_ids(&behavior.dismissed_announcements)), - sidebar_width_percent: Some(behavior.sidebar_width_percent.min(100)), - playbar_height_rows: Some(behavior.playbar_height_rows), - library_height_percent: Some(behavior.library_height_percent.min(100)), - radio_stations: Some(sanitized_radio_stations(&behavior.radio_stations)), - sync_token: trim_to_optional_string(behavior.sync_token.clone()), - } - } - - /// Overlay this state onto config defaults after `config.yml` has loaded. - /// - /// This method intentionally updates only the fields owned by `state.yml`. - pub fn apply_to_user_config(&self, config: &mut UserConfig) { - if let Some(volume_percent) = self.volume_percent { - config.behavior.volume_percent = volume_percent.min(100); - } - if let Some(shuffle_enabled) = self.shuffle_enabled { - config.behavior.shuffle_enabled = shuffle_enabled; - } - if let Some(active_source) = self.active_source { - config.behavior.active_source = active_source; - } - if let Some(seen_announcement_ids) = &self.seen_announcement_ids { - config.behavior.seen_announcement_ids = sanitized_ids(seen_announcement_ids); - } - if let Some(dismissed_announcements) = &self.dismissed_announcements { - config.behavior.dismissed_announcements = sanitized_ids(dismissed_announcements); - } - if let Some(sidebar_width_percent) = self.sidebar_width_percent { - config.behavior.sidebar_width_percent = sidebar_width_percent.min(100); - } - if let Some(playbar_height_rows) = self.playbar_height_rows { - config.behavior.playbar_height_rows = playbar_height_rows; - } - if let Some(library_height_percent) = self.library_height_percent { - config.behavior.library_height_percent = library_height_percent.min(100); - } - if let Some(radio_stations) = &self.radio_stations { - config.behavior.radio_stations = sanitized_radio_stations(radio_stations); - } - if self.sync_token.is_some() { - config.behavior.sync_token = trim_to_optional_string(self.sync_token.clone()); - } - } } /// Location of the app runtime-state file: `/state.yml`. @@ -119,8 +218,7 @@ pub fn load(path: &Path) -> Result { } } -/// Save state atomically and privately. `sync_token` can be present, so this -/// file uses the same private-file helper as the token/config paths. +/// Save state atomically and privately. pub fn save(path: &Path, state: &PersistedRuntimeState) -> Result<()> { let yaml = serde_yaml::to_string(state).context("serializing runtime state")?; if let Some(dir) = path.parent() { @@ -172,17 +270,6 @@ fn sanitized_radio_stations(stations: &[RadioStationConfig]) -> Vec) -> Option { - value.and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) -} - mod source_config { use super::Source; use serde::{Deserialize, Deserializer, Serializer}; @@ -238,7 +325,6 @@ mod tests { name: "Station".to_string(), url: "https://example.test/stream".to_string(), }]), - sync_token: Some("secret".to_string()), }; save(&path, &state).unwrap(); @@ -262,8 +348,7 @@ mod tests { } #[test] - fn applying_state_sanitizes_values() { - let mut config = UserConfig::new(); + fn runtime_state_applies_and_sanitizes_persisted_values() { let state = PersistedRuntimeState { volume_percent: Some(150), active_source: Some(Source::Subsonic), @@ -280,24 +365,52 @@ mod tests { url: " ".to_string(), }, ]), - sync_token: Some(" token ".to_string()), ..Default::default() }; - state.apply_to_user_config(&mut config); + let mut runtime = RuntimeState::default(); + runtime.apply_persisted(&state); - assert_eq!(config.behavior.volume_percent, 100); - assert_eq!(config.behavior.active_source, Source::Subsonic); - assert_eq!(config.behavior.seen_announcement_ids, vec!["seen"]); - assert_eq!(config.behavior.sidebar_width_percent, 100); - assert_eq!(config.behavior.library_height_percent, 100); + assert_eq!(runtime.volume_percent, 100); + assert_eq!(runtime.active_source, Source::Subsonic); + assert_eq!(runtime.seen_announcement_ids, vec!["seen"]); + assert_eq!(runtime.sidebar_width_percent, 100); + assert_eq!(runtime.library_height_percent, 100); assert_eq!( - config.behavior.radio_stations, + runtime.radio_stations, vec![RadioStationConfig { name: "Good".to_string(), url: "https://example.test".to_string(), }] ); - assert_eq!(config.behavior.sync_token, Some("token".to_string())); + } + + #[test] + fn radio_station_helpers_trim_dedupe_and_remove() { + let mut runtime = RuntimeState::default(); + + assert_eq!( + runtime + .add_radio_station(" Groove ", " https://example.test/stream ") + .unwrap(), + RadioStationAddOutcome::Added + ); + assert_eq!(runtime.radio_stations[0].name, "Groove"); + assert_eq!(runtime.radio_stations[0].url, "https://example.test/stream"); + assert_eq!( + runtime + .add_radio_station("Duplicate", "https://example.test/stream") + .unwrap(), + RadioStationAddOutcome::AlreadyExists + ); + + let removed = runtime + .remove_radio_station_by_url(" https://example.test/stream ") + .unwrap(); + assert_eq!( + removed.map(|station| station.name), + Some("Groove".to_string()) + ); + assert!(runtime.radio_stations.is_empty()); } } diff --git a/src/core/user_config.rs b/src/core/user_config.rs index 8b97eb7a..f6a1c191 100644 --- a/src/core/user_config.rs +++ b/src/core/user_config.rs @@ -1,5 +1,5 @@ use crate::core::format::Template; -use crate::core::source::Source; +use crate::core::state::RadioStationConfig; use crate::tui::event::Key; use anyhow::{anyhow, Result}; use ratatui::style::{Color, Style}; @@ -44,6 +44,25 @@ pub fn normalize_tick_rate_milliseconds(value: i64) -> u64 { value.clamp(1, MAX_TICK_RATE_MILLISECONDS as i64) as u64 } +fn sanitized_radio_stations(stations: Vec) -> Vec { + let mut sanitized = Vec::new(); + for station in stations { + let name = station.name.trim(); + let url = station.url.trim(); + if name.is_empty() + || url.is_empty() + || sanitized.iter().any(|s: &RadioStationConfig| s.url == url) + { + continue; + } + sanitized.push(RadioStationConfig { + name: name.to_string(), + url: url.to_string(), + }); + } + sanitized +} + /// Parse a human-readable update delay into seconds. /// Accepts: "0", "30s", "10m", "2h", "7d", or a bare second count. pub fn parse_update_delay_secs(value: &str) -> Result { @@ -758,25 +777,11 @@ pub struct KeyBindings { pub generate_recap: Key, } -/// One internet-radio station in the config file: a display name plus the -/// direct stream URL. The same shape is used in `BehaviorConfigString` (file) -/// and `BehaviorConfig` (in-memory) — there is nothing to convert. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct RadioStationConfig { - pub name: String, - pub url: String, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RadioStationAddOutcome { - Added, - AlreadyExists, -} - #[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct BehaviorConfigString { pub seek_milliseconds: Option, pub volume_increment: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub volume_percent: Option, pub tick_rate_milliseconds: Option, pub animation_tick_rate_milliseconds: Option, @@ -791,10 +796,7 @@ pub struct BehaviorConfigString { pub discord_rpc_client_id: Option, pub enable_announcements: Option, pub announcement_feed_url: Option, - pub seen_announcement_ids: Option>, pub enable_monthly_recap_prompt: Option, - pub shuffle_enabled: Option, - pub active_source: Option, pub liked_icon: Option, pub shuffle_icon: Option, pub repeat_track_icon: Option, @@ -803,12 +805,8 @@ pub struct BehaviorConfigString { pub paused_icon: Option, pub set_window_title: Option, pub visualizer_style: Option, - pub dismissed_announcements: Option>, pub relay_server_url: Option, pub stop_after_current_track: Option, - pub sidebar_width_percent: Option, - pub playbar_height_rows: Option, - pub library_height_percent: Option, pub startup_behavior: Option, pub disable_auto_update: Option, pub auto_update_delay: Option, @@ -825,8 +823,9 @@ pub struct BehaviorConfigString { pub subsonic_url: Option, pub subsonic_username: Option, pub subsonic_password: Option, - pub radio_stations: Option>, pub ytdlp_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub radio_stations: Option>, // --- Phase 2: icons / glyphs / labels (defaults = today's glyphs) --- pub gauge_filled_icon: Option, pub gauge_unfilled_icon: Option, @@ -849,6 +848,12 @@ pub struct BehaviorConfigString { // --- Phase 6: layout arrangement --- pub sidebar_position: Option, pub playbar_position: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sidebar_width_percent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub playbar_height_rows: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub library_height_percent: Option, pub small_terminal_width: Option, pub small_terminal_height: Option, } @@ -857,7 +862,9 @@ pub struct BehaviorConfigString { pub struct BehaviorConfig { pub seek_milliseconds: u32, pub volume_increment: u8, - pub volume_percent: u8, + /// Optional startup volume override. Runtime volume changes are persisted in + /// `state.yml`; this config value wins only when explicitly present. + pub volume_percent: Option, pub tick_rate_milliseconds: u64, pub animation_tick_rate_milliseconds: u64, pub enable_text_emphasis: bool, @@ -873,11 +880,7 @@ pub struct BehaviorConfig { pub discord_rpc_client_id: Option, pub enable_announcements: bool, pub announcement_feed_url: Option, - pub seen_announcement_ids: Vec, pub enable_monthly_recap_prompt: bool, - pub shuffle_enabled: bool, - /// The last active source — persisted so it survives restarts. - pub active_source: Source, pub liked_icon: String, pub shuffle_icon: String, pub repeat_track_icon: String, @@ -886,12 +889,8 @@ pub struct BehaviorConfig { pub paused_icon: String, pub set_window_title: bool, pub visualizer_style: VisualizerStyle, - pub dismissed_announcements: Vec, pub relay_server_url: String, pub stop_after_current_track: bool, - pub sidebar_width_percent: u8, - pub playbar_height_rows: u16, - pub library_height_percent: u8, pub startup_behavior: StartupBehavior, pub disable_auto_update: bool, pub auto_update_delay: String, @@ -919,13 +918,12 @@ pub struct BehaviorConfig { /// prefer the `SPOTATUI_SUBSONIC_PASSWORD` environment variable, which /// overrides this field at connection time and is never written to disk. pub subsonic_password: Option, - /// The user's internet-radio station list, shown in the sidebar when the - /// Radio source is active. Stations found via the in-app directory search - /// are not persisted here (yet) — this list is hand-maintained in the config. - pub radio_stations: Vec, /// Path to the `yt-dlp` binary used by the YouTube source. `None` resolves /// plain `yt-dlp` through `$PATH`. pub ytdlp_path: Option, + /// User-authored stations shown alongside stations saved at runtime. + /// In-app favorite/remove actions mutate `state.yml`, not this list. + pub radio_stations: Vec, // --- Phase 2: icons / glyphs / labels --- pub gauge_filled_icon: String, pub gauge_unfilled_icon: String, @@ -950,6 +948,11 @@ pub struct BehaviorConfig { // --- Phase 6: layout arrangement --- pub sidebar_position: String, pub playbar_position: String, + /// Optional startup pane-size overrides. Runtime resize changes are persisted + /// in `state.yml`; these config values win only when explicitly present. + pub sidebar_width_percent: Option, + pub playbar_height_rows: Option, + pub library_height_percent: Option, pub small_terminal_width: u16, pub small_terminal_height: u16, } @@ -1173,7 +1176,7 @@ impl UserConfig { behavior: BehaviorConfig { seek_milliseconds: 5 * 1000, volume_increment: 10, - volume_percent: 100, + volume_percent: None, tick_rate_milliseconds: DEFAULT_TICK_RATE_MILLISECONDS, animation_tick_rate_milliseconds: DEFAULT_ANIMATION_TICK_RATE_MILLISECONDS, enable_text_emphasis: true, @@ -1187,10 +1190,7 @@ impl UserConfig { discord_rpc_client_id: None, enable_announcements: true, announcement_feed_url: None, - seen_announcement_ids: Vec::new(), enable_monthly_recap_prompt: true, - shuffle_enabled: false, - active_source: Source::default(), liked_icon: "♥".to_string(), shuffle_icon: "🔀".to_string(), repeat_track_icon: "🔂".to_string(), @@ -1199,12 +1199,8 @@ impl UserConfig { paused_icon: "⏸".to_string(), set_window_title: true, visualizer_style: VisualizerStyle::default(), - dismissed_announcements: Vec::new(), relay_server_url: "wss://spotatui-party.spotatui.workers.dev/ws".to_string(), stop_after_current_track: false, - sidebar_width_percent: 20, - playbar_height_rows: 6, - library_height_percent: 30, startup_behavior: StartupBehavior::Continue, disable_auto_update: false, auto_update_delay: "0".to_string(), @@ -1221,8 +1217,8 @@ impl UserConfig { subsonic_url: None, subsonic_username: None, subsonic_password: None, - radio_stations: Vec::new(), ytdlp_path: None, + radio_stations: Vec::new(), // --- Phase 2: icons / glyphs / labels (defaults = today's glyphs) --- gauge_filled_icon: "⣿".to_string(), gauge_unfilled_icon: "⣉".to_string(), @@ -1245,6 +1241,9 @@ impl UserConfig { // --- Phase 6: layout arrangement --- sidebar_position: "left".to_string(), playbar_position: "bottom".to_string(), + sidebar_width_percent: None, + playbar_height_rows: None, + library_height_percent: None, small_terminal_width: 150, small_terminal_height: 45, }, @@ -1436,7 +1435,7 @@ impl UserConfig { } if let Some(volume) = behavior_config.volume_percent { - self.behavior.volume_percent = volume.min(100); + self.behavior.volume_percent = Some(volume.min(100)); } let loaded_tick_rate = behavior_config.tick_rate_milliseconds; @@ -1540,45 +1539,20 @@ impl UserConfig { }; } - if let Some(seen_announcement_ids) = behavior_config.seen_announcement_ids { - self.behavior.seen_announcement_ids = seen_announcement_ids - .into_iter() - .map(|id| id.trim().to_string()) - .filter(|id| !id.is_empty()) - .collect(); - } - if let Some(discord_rpc_client_id) = behavior_config.discord_rpc_client_id { self.behavior.discord_rpc_client_id = Some(discord_rpc_client_id); } - if let Some(shuffle_enabled) = behavior_config.shuffle_enabled { - self.behavior.shuffle_enabled = shuffle_enabled; - } - - if let Some(active_source_str) = behavior_config.active_source { - self.behavior.active_source = Source::from_config_str(&active_source_str); - } - if let Some(visualizer_style) = behavior_config.visualizer_style { self.behavior.visualizer_style = visualizer_style; } - if let Some(dismissed_announcements) = behavior_config.dismissed_announcements { - self.behavior.dismissed_announcements = dismissed_announcements - .into_iter() - .map(|id| id.trim().to_string()) - .filter(|id| !id.is_empty()) - .collect(); - } - if let Some(relay_server_url) = behavior_config.relay_server_url { let trimmed = relay_server_url.trim(); if !trimmed.is_empty() { self.behavior.relay_server_url = trimmed.to_string(); } } - if let Some(sync_token) = behavior_config.sync_token { let trimmed = sync_token.trim(); if trimmed.is_empty() { @@ -1587,21 +1561,24 @@ impl UserConfig { self.behavior.sync_token = Some(trimmed.to_string()); } } - if let Some(stop_after_current_track) = behavior_config.stop_after_current_track { self.behavior.stop_after_current_track = stop_after_current_track; } + if let Some(radio_stations) = behavior_config.radio_stations { + self.behavior.radio_stations = sanitized_radio_stations(radio_stations); + } + if let Some(sidebar_width_percent) = behavior_config.sidebar_width_percent { - self.behavior.sidebar_width_percent = sidebar_width_percent.min(100); + self.behavior.sidebar_width_percent = Some(sidebar_width_percent.min(100)); } if let Some(playbar_height_rows) = behavior_config.playbar_height_rows { - self.behavior.playbar_height_rows = playbar_height_rows; + self.behavior.playbar_height_rows = Some(playbar_height_rows); } if let Some(library_height_percent) = behavior_config.library_height_percent { - self.behavior.library_height_percent = library_height_percent.min(100); + self.behavior.library_height_percent = Some(library_height_percent.min(100)); } if let Some(startup_behavior) = behavior_config.startup_behavior { @@ -1666,14 +1643,6 @@ impl UserConfig { if let Some(subsonic_password) = trim_to_none(behavior_config.subsonic_password) { self.behavior.subsonic_password = Some(subsonic_password); } - if let Some(radio_stations) = behavior_config.radio_stations { - // Drop entries missing a name or URL rather than failing the whole - // config; the dispatch filters again defensively at load time. - self.behavior.radio_stations = radio_stations - .into_iter() - .filter(|s| !s.name.trim().is_empty() && !s.url.trim().is_empty()) - .collect(); - } if let Some(ytdlp_path) = trim_to_none(behavior_config.ytdlp_path) { self.behavior.ytdlp_path = Some(ytdlp_path); } @@ -2068,7 +2037,7 @@ impl UserConfig { let build_behavior = || BehaviorConfigString { seek_milliseconds: Some(self.behavior.seek_milliseconds), volume_increment: Some(self.behavior.volume_increment), - volume_percent: Some(self.behavior.volume_percent), + volume_percent: self.behavior.volume_percent, tick_rate_milliseconds: Some(self.behavior.tick_rate_milliseconds), animation_tick_rate_milliseconds: Some(self.behavior.animation_tick_rate_milliseconds), enable_text_emphasis: Some(self.behavior.enable_text_emphasis), @@ -2082,10 +2051,7 @@ impl UserConfig { discord_rpc_client_id: self.behavior.discord_rpc_client_id.clone(), enable_announcements: Some(self.behavior.enable_announcements), announcement_feed_url: self.behavior.announcement_feed_url.clone(), - seen_announcement_ids: Some(self.behavior.seen_announcement_ids.clone()), enable_monthly_recap_prompt: Some(self.behavior.enable_monthly_recap_prompt), - shuffle_enabled: Some(self.behavior.shuffle_enabled), - active_source: Some(self.behavior.active_source.to_config_str().to_string()), liked_icon: Some(self.behavior.liked_icon.clone()), shuffle_icon: Some(self.behavior.shuffle_icon.clone()), repeat_track_icon: Some(self.behavior.repeat_track_icon.clone()), @@ -2094,23 +2060,19 @@ impl UserConfig { paused_icon: Some(self.behavior.paused_icon.clone()), set_window_title: Some(self.behavior.set_window_title), visualizer_style: Some(self.behavior.visualizer_style), - dismissed_announcements: Some(self.behavior.dismissed_announcements.clone()), relay_server_url: Some(self.behavior.relay_server_url.clone()), sync_token: self.behavior.sync_token.clone(), local_music_path: self.behavior.local_music_path.clone(), subsonic_url: self.behavior.subsonic_url.clone(), subsonic_username: self.behavior.subsonic_username.clone(), subsonic_password: self.behavior.subsonic_password.clone(), + ytdlp_path: self.behavior.ytdlp_path.clone(), radio_stations: if self.behavior.radio_stations.is_empty() { None } else { Some(self.behavior.radio_stations.clone()) }, - ytdlp_path: self.behavior.ytdlp_path.clone(), stop_after_current_track: Some(self.behavior.stop_after_current_track), - sidebar_width_percent: Some(self.behavior.sidebar_width_percent), - playbar_height_rows: Some(self.behavior.playbar_height_rows), - library_height_percent: Some(self.behavior.library_height_percent), startup_behavior: Some(self.behavior.startup_behavior), disable_auto_update: Some(self.behavior.disable_auto_update), auto_update_delay: Some(self.behavior.auto_update_delay.clone()), @@ -2146,6 +2108,9 @@ impl UserConfig { default_sort_recently_played: Some(self.behavior.default_sort_recently_played.clone()), sidebar_position: Some(self.behavior.sidebar_position.clone()), playbar_position: Some(self.behavior.playbar_position.clone()), + sidebar_width_percent: self.behavior.sidebar_width_percent, + playbar_height_rows: self.behavior.playbar_height_rows, + library_height_percent: self.behavior.library_height_percent, small_terminal_width: Some(self.behavior.small_terminal_width), small_terminal_height: Some(self.behavior.small_terminal_height), }; @@ -2285,11 +2250,11 @@ impl UserConfig { }; // Serialize to a String/bytes first, then write via a private-file helper - // (0o600 on Unix — this file carries the Subsonic password and party - // sync_token in cleartext, so it deserves the same protection as the - // Spotify token cache) using a temp-file + atomic rename, so a crash - // mid-write can't corrupt the config. Do not log `content_yml`: it may - // contain the plaintext password/sync_token. + // (0o600 on Unix — this file can carry the Subsonic password in + // cleartext, so it deserves the same protection as the Spotify token + // cache) using a temp-file + atomic rename, so a crash mid-write can't + // corrupt the config. Do not log `content_yml`: it may contain plaintext + // credentials. let content_yml = serde_yaml::to_string(&final_config)?; let tmp_path = paths.config_file_path.with_extension("yml.tmp"); crate::core::auth::write_private_file(&tmp_path, content_yml.as_bytes())?; @@ -2297,7 +2262,6 @@ impl UserConfig { Ok(()) } - pub fn padded_liked_icon(&self) -> String { format!("{} ", self.behavior.liked_icon) } @@ -2308,88 +2272,6 @@ impl UserConfig { pub fn padded_playing_icon(&self) -> String { format!("{} ", self.behavior.playing_icon) } - - pub fn add_radio_station( - &mut self, - name: impl AsRef, - url: impl AsRef, - ) -> Result { - let name = name.as_ref().trim(); - let url = url.as_ref().trim(); - - if name.is_empty() { - return Err(anyhow!("Radio station name is empty")); - } - if url.is_empty() { - return Err(anyhow!("Radio station URL is empty")); - } - - if self - .behavior - .radio_stations - .iter() - .any(|station| station.url.trim() == url) - { - return Ok(RadioStationAddOutcome::AlreadyExists); - } - - self.behavior.radio_stations.push(RadioStationConfig { - name: name.to_string(), - url: url.to_string(), - }); - - if let Err(error) = self.save_config() { - self.behavior.radio_stations.pop(); - return Err(error); - } - - Ok(RadioStationAddOutcome::Added) - } - - pub fn remove_radio_station_by_url( - &mut self, - url: impl AsRef, - ) -> Result> { - let url = url.as_ref().trim(); - if url.is_empty() { - return Err(anyhow!("Radio station URL is empty")); - } - - let Some(index) = self - .behavior - .radio_stations - .iter() - .position(|station| station.url.trim() == url) - else { - return Ok(None); - }; - - let removed = self.behavior.radio_stations.remove(index); - - if let Err(error) = self.save_config() { - self.behavior.radio_stations.insert(index, removed); - return Err(error); - } - - Ok(Some(removed)) - } - - pub fn mark_announcement_seen(&mut self, announcement_id: impl Into) { - let id = announcement_id.into(); - if id.is_empty() { - return; - } - - if !self - .behavior - .seen_announcement_ids - .iter() - .any(|seen| seen == &id) - { - self.behavior.seen_announcement_ids.push(id); - } - } - #[cfg(feature = "cover-art")] pub fn do_draw_cover_art(&self, full_image_support: bool) -> bool { self.behavior.draw_cover_art && (self.behavior.draw_cover_art_forced || full_image_support) @@ -2878,189 +2760,88 @@ mod tests { } #[test] - fn active_source_local_round_trips_through_config() { + fn sync_token_loads_as_user_config_and_trims_blank_to_none() { use super::{BehaviorConfigString, UserConfig}; - use crate::core::source::Source; - - // "Local" in YAML deserialized and resolved → Source::Local - let behavior: BehaviorConfigString = serde_yaml::from_str("active_source: Local").unwrap(); - assert_eq!(behavior.active_source, Some("Local".to_string())); + let behavior: BehaviorConfigString = serde_yaml::from_str("sync_token: ' token '\n").unwrap(); let mut config = UserConfig::new(); config.load_behaviorconfig(behavior).unwrap(); - assert_eq!(config.behavior.active_source, Source::Local); + assert_eq!(config.behavior.sync_token, Some("token".to_string())); + + let behavior: BehaviorConfigString = serde_yaml::from_str("sync_token: ' '\n").unwrap(); + config.load_behaviorconfig(behavior).unwrap(); + assert_eq!(config.behavior.sync_token, None); } #[test] - fn radio_stations_round_trip_through_config() { - use super::{BehaviorConfigString, RadioStationConfig, UserConfig}; + fn volume_percent_loads_as_optional_startup_override() { + use super::{BehaviorConfigString, UserConfig}; - let yaml = r#" -radio_stations: - - name: SomaFM Groove Salad - url: https://ice1.somafm.com/groovesalad-128-mp3 - - name: "" - url: https://blank-name.example/dropped -"#; - let behavior: BehaviorConfigString = serde_yaml::from_str(yaml).unwrap(); - assert_eq!(behavior.radio_stations.as_ref().map(Vec::len), Some(2)); + let behavior: BehaviorConfigString = serde_yaml::from_str("volume_percent: 150\n").unwrap(); + let mut config = UserConfig::new(); + config.load_behaviorconfig(behavior).unwrap(); + assert_eq!(config.behavior.volume_percent, Some(100)); + let behavior: BehaviorConfigString = serde_yaml::from_str("{}").unwrap(); let mut config = UserConfig::new(); config.load_behaviorconfig(behavior).unwrap(); - // The blank-name entry is dropped at load; the valid one survives intact. - assert_eq!( - config.behavior.radio_stations, - vec![RadioStationConfig { - name: "SomaFM Groove Salad".to_string(), - url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - }] - ); + assert_eq!(config.behavior.volume_percent, None); } #[test] - fn radio_stations_missing_field_defaults_to_empty() { + fn pane_sizes_load_as_optional_startup_overrides() { use super::{BehaviorConfigString, UserConfig}; - let behavior: BehaviorConfigString = serde_yaml::from_str("{}").unwrap(); - assert_eq!(behavior.radio_stations, None); + let behavior: BehaviorConfigString = serde_yaml::from_str( + r#" +sidebar_width_percent: 120 +playbar_height_rows: 9 +library_height_percent: 101 +"#, + ) + .unwrap(); + let mut config = UserConfig::new(); + config.load_behaviorconfig(behavior).unwrap(); + assert_eq!(config.behavior.sidebar_width_percent, Some(100)); + assert_eq!(config.behavior.playbar_height_rows, Some(9)); + assert_eq!(config.behavior.library_height_percent, Some(100)); + let behavior: BehaviorConfigString = serde_yaml::from_str("{}").unwrap(); let mut config = UserConfig::new(); config.load_behaviorconfig(behavior).unwrap(); - assert!(config.behavior.radio_stations.is_empty()); + assert_eq!(config.behavior.sidebar_width_percent, None); + assert_eq!(config.behavior.playbar_height_rows, None); + assert_eq!(config.behavior.library_height_percent, None); } #[test] - fn adding_radio_station_persists_trimmed_unique_entry() { - use super::{ - RadioStationAddOutcome, RadioStationConfig, UserConfig, UserConfigPaths, UserConfigString, - }; + fn radio_stations_load_as_user_config() { + use super::{BehaviorConfigString, UserConfig}; + use crate::core::state::RadioStationConfig; - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.yml"); + let behavior: BehaviorConfigString = serde_yaml::from_str( + r#" +radio_stations: + - name: " Groove Salad " + url: " https://ice1.somafm.com/groovesalad-128-mp3 " + - name: Duplicate + url: https://ice1.somafm.com/groovesalad-128-mp3 + - name: "" + url: https://blank-name.example/dropped +"#, + ) + .unwrap(); let mut config = UserConfig::new(); - config.path_to_config = Some(UserConfigPaths { - config_file_path: config_path.clone(), - }); - - let outcome = config - .add_radio_station( - " SomaFM Groove Salad ", - " https://ice1.somafm.com/groovesalad-128-mp3 ", - ) - .unwrap(); - assert_eq!(outcome, RadioStationAddOutcome::Added); + config.load_behaviorconfig(behavior).unwrap(); assert_eq!( config.behavior.radio_stations, vec![RadioStationConfig { - name: "SomaFM Groove Salad".to_string(), - url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - }] - ); - - let saved = std::fs::read_to_string(config_path).unwrap(); - let saved: UserConfigString = serde_yaml::from_str(&saved).unwrap(); - assert_eq!( - saved - .behavior - .unwrap() - .radio_stations - .unwrap() - .first() - .cloned(), - Some(RadioStationConfig { - name: "SomaFM Groove Salad".to_string(), - url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - }) - ); - } - - #[test] - fn adding_radio_station_dedupes_by_stream_url() { - use super::{RadioStationAddOutcome, RadioStationConfig, UserConfig, UserConfigPaths}; - - let dir = tempfile::tempdir().unwrap(); - let mut config = UserConfig::new(); - config.path_to_config = Some(UserConfigPaths { - config_file_path: dir.path().join("config.yml"), - }); - config.behavior.radio_stations = vec![RadioStationConfig { - name: "Existing".to_string(), - url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - }]; - - let outcome = config - .add_radio_station( - "Duplicate Name", - " https://ice1.somafm.com/groovesalad-128-mp3 ", - ) - .unwrap(); - - assert_eq!(outcome, RadioStationAddOutcome::AlreadyExists); - assert_eq!(config.behavior.radio_stations.len(), 1); - assert_eq!(config.behavior.radio_stations[0].name, "Existing"); - } - - #[test] - fn removing_radio_station_persists_by_stream_url() { - use super::{RadioStationConfig, UserConfig, UserConfigPaths, UserConfigString}; - - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.yml"); - let mut config = UserConfig::new(); - config.path_to_config = Some(UserConfigPaths { - config_file_path: config_path.clone(), - }); - config.behavior.radio_stations = vec![ - RadioStationConfig { name: "Groove Salad".to_string(), url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - }, - RadioStationConfig { - name: "Secret Agent".to_string(), - url: "https://ice1.somafm.com/secretagent-128-mp3".to_string(), - }, - ]; - - let removed = config - .remove_radio_station_by_url(" https://ice1.somafm.com/groovesalad-128-mp3 ") - .unwrap(); - - assert_eq!( - removed.map(|station| station.name), - Some("Groove Salad".to_string()) - ); - assert_eq!(config.behavior.radio_stations.len(), 1); - assert_eq!(config.behavior.radio_stations[0].name, "Secret Agent"); - - let saved = std::fs::read_to_string(config_path).unwrap(); - let saved: UserConfigString = serde_yaml::from_str(&saved).unwrap(); - assert_eq!( - saved - .behavior - .unwrap() - .radio_stations - .unwrap() - .iter() - .map(|station| station.name.as_str()) - .collect::>(), - vec!["Secret Agent"] + }] ); } - #[test] - fn active_source_missing_field_defaults_to_spotify() { - use super::{BehaviorConfigString, UserConfig}; - use crate::core::source::Source; - - // No active_source key in config → field is None → default Spotify preserved - let behavior: BehaviorConfigString = serde_yaml::from_str("{}").unwrap(); - assert_eq!(behavior.active_source, None); - - let mut config = UserConfig::new(); - config.load_behaviorconfig(behavior).unwrap(); - assert_eq!(config.behavior.active_source, Source::Spotify); - } - #[test] fn active_source_unknown_string_falls_back_to_spotify() { use crate::core::source::Source; diff --git a/src/infra/local/dispatch.rs b/src/infra/local/dispatch.rs index 7021e822..1d67cb54 100644 --- a/src/infra/local/dispatch.rs +++ b/src/infra/local/dispatch.rs @@ -109,7 +109,7 @@ pub async fn route_local_event(app: &Arc>, event: &IoEvent) -> bool { Some(player) => { player.set_volume(*volume as f32 / 100.0); // Keep the playbar's volume readout in sync. - app.lock().await.user_config.behavior.volume_percent = *volume; + app.lock().await.runtime_state.volume_percent = *volume; true } None => false, @@ -305,7 +305,7 @@ async fn start_local_queue(app: &Arc>, queue: Vec, start_idx: match result { Ok(Ok(info)) => { - let volume = app.lock().await.user_config.behavior.volume_percent; + let volume = app.lock().await.runtime_state.volume_percent; player.set_volume(volume as f32 / 100.0); // Publish the session exactly once, now that the source is decoding. diff --git a/src/infra/media_metadata.rs b/src/infra/media_metadata.rs index 8065f39e..960e1a07 100644 --- a/src/infra/media_metadata.rs +++ b/src/infra/media_metadata.rs @@ -122,7 +122,7 @@ pub fn current_playback_snapshot(app: &App) -> Option { let shuffle = !queue_owns_playback && context .map(|context| context.shuffle_state) - .unwrap_or(app.user_config.behavior.shuffle_enabled); + .unwrap_or(app.runtime_state.shuffle_enabled); let repeat = if queue_owns_playback { None } else { diff --git a/src/infra/network/native_shuffle.rs b/src/infra/network/native_shuffle.rs index b84a4afd..86191379 100644 --- a/src/infra/network/native_shuffle.rs +++ b/src/infra/network/native_shuffle.rs @@ -335,7 +335,8 @@ impl Network { ctx.is_playing = true; ctx.shuffle_state = true; } - app.user_config.behavior.shuffle_enabled = true; + app.runtime_state.shuffle_enabled = true; + app.schedule_state_save(); generation }; diff --git a/src/infra/network/playback.rs b/src/infra/network/playback.rs index 636c51b7..d8bfee85 100644 --- a/src/infra/network/playback.rs +++ b/src/infra/network/playback.rs @@ -749,7 +749,8 @@ async fn start_native_context_via_api( ctx.is_playing = true; ctx.shuffle_state = desired_shuffle_state; } - app.user_config.behavior.shuffle_enabled = desired_shuffle_state; + app.runtime_state.shuffle_enabled = desired_shuffle_state; + app.schedule_state_save(); // Keep the recovery chain alive: if the API accepted the start but the // native device never emits a player event, the watchdog fires again and // the bounded attempt counter eventually drops the request with a @@ -1025,10 +1026,10 @@ impl PlaybackNetwork for Network { // override API shuffle with saved preference. #[cfg(feature = "streaming")] if local_state.is_none() && is_native_device { - c.shuffle_state = app.user_config.behavior.shuffle_enabled; + c.shuffle_state = app.runtime_state.shuffle_enabled; // Proactively set native shuffle on first load to keep backend in sync if let Some(ref player) = streaming_player { - let _ = player.set_shuffle(app.user_config.behavior.shuffle_enabled); + let _ = player.set_shuffle(app.runtime_state.shuffle_enabled); } } @@ -1280,7 +1281,7 @@ impl PlaybackNetwork for Network { .current_playback_context .as_ref() .map(|ctx| ctx.shuffle_state) - .unwrap_or(app.user_config.behavior.shuffle_enabled) + .unwrap_or(app.runtime_state.shuffle_enabled) }; // Any explicit new playback target invalidates the app-owned shuffle @@ -1519,7 +1520,8 @@ impl PlaybackNetwork for Network { ctx.is_playing = true; ctx.shuffle_state = desired_shuffle_state; } - app.user_config.behavior.shuffle_enabled = desired_shuffle_state; + app.runtime_state.shuffle_enabled = desired_shuffle_state; + app.schedule_state_save(); } Err(load_err) => { let Some((device_id, context)) = api_fallback else { @@ -1569,7 +1571,8 @@ impl PlaybackNetwork for Network { ctx.is_playing = true; ctx.shuffle_state = desired_shuffle_state; } - app.user_config.behavior.shuffle_enabled = desired_shuffle_state; + app.runtime_state.shuffle_enabled = desired_shuffle_state; + app.schedule_state_save(); } Err(e) => { #[cfg(feature = "streaming")] @@ -1643,7 +1646,8 @@ impl PlaybackNetwork for Network { ctx.is_playing = true; ctx.shuffle_state = desired_shuffle_state; } - app.user_config.behavior.shuffle_enabled = desired_shuffle_state; + app.runtime_state.shuffle_enabled = desired_shuffle_state; + app.schedule_state_save(); } return; } @@ -1956,6 +1960,8 @@ impl PlaybackNetwork for Network { if let Some(ctx) = &mut app.current_playback_context { ctx.shuffle_state = shuffle_state; } + app.runtime_state.shuffle_enabled = shuffle_state; + app.schedule_state_save(); return; } @@ -1973,6 +1979,8 @@ impl PlaybackNetwork for Network { if let Some(ctx) = &mut app.current_playback_context { ctx.shuffle_state = shuffle_state; } + app.runtime_state.shuffle_enabled = shuffle_state; + app.schedule_state_save(); } Err(e) => { #[cfg(feature = "streaming")] diff --git a/src/infra/network/utils.rs b/src/infra/network/utils.rs index f17b0739..86dab1c7 100644 --- a/src/infra/network/utils.rs +++ b/src/infra/network/utils.rs @@ -213,7 +213,7 @@ impl UtilsNetwork for Network { ( app.user_config.behavior.enable_announcements, app.user_config.behavior.announcement_feed_url.clone(), - app.user_config.behavior.seen_announcement_ids.clone(), + app.runtime_state.seen_announcement_ids.clone(), ) }; diff --git a/src/infra/player/events.rs b/src/infra/player/events.rs index 0d578f50..3a784fcb 100644 --- a/src/infra/player/events.rs +++ b/src/infra/player/events.rs @@ -98,7 +98,7 @@ async fn handle_streaming_recovery(mut ctx: StreamingRecoveryContext) { let initial_volume = { let app = ctx.app.lock().await; - app.user_config.behavior.volume_percent + app.runtime_state.volume_percent }; let streaming_config = StreamingConfig { @@ -860,8 +860,8 @@ async fn handle_player_events( if let Some(ref mut ctx) = app.current_playback_context { ctx.device.volume_percent = Some(volume_percent as u32); } - app.user_config.behavior.volume_percent = volume_percent.min(100); - let _ = app.user_config.save_config(); + app.runtime_state.volume_percent = volume_percent.min(100); + let _ = app.save_runtime_state(); } } } diff --git a/src/infra/queue/dispatch.rs b/src/infra/queue/dispatch.rs index bf94264d..26374d3d 100644 --- a/src/infra/queue/dispatch.rs +++ b/src/infra/queue/dispatch.rs @@ -94,7 +94,7 @@ async fn route_queue_transport(app: &Arc>, event: &IoEvent) -> Option } IoEvent::ChangeVolume(volume) => { player.set_volume(*volume as f32 / 100.0); - app.lock().await.user_config.behavior.volume_percent = *volume; + app.lock().await.runtime_state.volume_percent = *volume; Some(true) } // Skip the queued track: advance to the next queued item (or resume). @@ -542,7 +542,7 @@ async fn finish_decoded_fetch( guard.dispatch(IoEvent::AdvanceNativeQueue); return; } - player.set_volume(guard.user_config.behavior.volume_percent as f32 / 100.0); + player.set_volume(guard.runtime_state.volume_percent as f32 / 100.0); if let Some(QueueNowPlaying::Decoded(d)) = guard.queue_now.as_mut() { d.tempfile = Some(tmp); d.advancing = false; @@ -659,7 +659,7 @@ async fn release_librespot(app: &Arc>) { #[cfg(feature = "local-files")] async fn apply_volume(app: &Arc>, player: &Arc) { - let volume = app.lock().await.user_config.behavior.volume_percent; + let volume = app.lock().await.runtime_state.volume_percent; player.set_volume(volume as f32 / 100.0); } diff --git a/src/infra/radio/dispatch.rs b/src/infra/radio/dispatch.rs index ad9047ce..f84f7e0c 100644 --- a/src/infra/radio/dispatch.rs +++ b/src/infra/radio/dispatch.rs @@ -34,6 +34,7 @@ use crate::core::app::{App, SearchResultBlock}; use crate::core::pagination::Paged; use crate::core::plugin_api::TrackInfo; use crate::core::source::Searcher; +use crate::core::state::RadioStationConfig; use crate::infra::audio::LocalPlayer; use crate::infra::network::IoEvent; @@ -88,7 +89,7 @@ pub async fn route_radio_event(app: &Arc>, event: &IoEvent) -> bool { IoEvent::ChangeVolume(volume) => match player(app).await { Some(p) => { p.set_volume(*volume as f32 / 100.0); - app.lock().await.user_config.behavior.volume_percent = *volume; + app.lock().await.runtime_state.volume_percent = *volume; true } None => false, @@ -110,29 +111,43 @@ pub async fn route_radio_event(app: &Arc>, event: &IoEvent) -> bool { // Browse + search // --------------------------------------------------------------------------- -/// Load the config-file station list into `app.radio_stations` (the sidebar's -/// Stations panel). No network — the list is user-configured. +/// Load configured and saved stations into `app.radio_stations` (the sidebar's +/// Stations panel). No network. async fn load_radio_stations(app: &Arc>) { let mut guard = app.lock().await; - let stations: Vec = guard - .user_config - .behavior - .radio_stations - .iter() - .filter(|s| !s.name.trim().is_empty() && !s.url.trim().is_empty()) - .map(|s| config_station_to_track_info(&s.name, &s.url)) - .collect(); + let stations = merged_radio_stations( + &guard.user_config.behavior.radio_stations, + &guard.runtime_state.radio_stations, + ); let empty = stations.is_empty(); guard.radio_stations = stations; guard.radio_stations_index = 0; if empty { guard.set_status_message( - "No radio stations configured (behavior.radio_stations); search to find some".to_string(), + "No radio stations configured or saved; search to add some".to_string(), 6, ); } } +fn merged_radio_stations( + configured: &[RadioStationConfig], + saved: &[RadioStationConfig], +) -> Vec { + let mut stations = Vec::new(); + let mut seen_urls: Vec = Vec::new(); + for station in configured.iter().chain(saved.iter()) { + let name = station.name.trim(); + let url = station.url.trim(); + if name.is_empty() || url.is_empty() || seen_urls.iter().any(|seen| seen == url) { + continue; + } + seen_urls.push(url.to_string()); + stations.push(config_station_to_track_info(name, url)); + } + stations +} + /// Search the radio-browser.info directory and populate `app.search_results`. /// /// Like the Subsonic search, only the songs block is populated — each row is a @@ -310,7 +325,7 @@ async fn start_radio(app: &Arc>, uri: &str) { match result { Ok(Ok(())) => { - let volume = app.lock().await.user_config.behavior.volume_percent; + let volume = app.lock().await.runtime_state.volume_percent; player.set_volume(volume as f32 / 100.0); let display = station.name.clone(); @@ -430,35 +445,39 @@ mod tests { ); } - /// Loading the (empty) config station list is consumed and surfaces a hint. + /// Loading the station list is consumed and merges config + saved stations. #[tokio::test] - async fn get_radio_stations_loads_config_list() { + async fn get_radio_stations_merges_configured_and_saved_lists() { let app = Arc::new(Mutex::new(test_app())); assert!(route_radio_event(&app, &IoEvent::GetRadioStations).await); assert!(app.lock().await.radio_stations.is_empty()); - // Now with two configured stations, one blank (filtered out). { let mut guard = app.lock().await; - guard.user_config.behavior.radio_stations = vec![ - crate::core::user_config::RadioStationConfig { + guard.user_config.behavior.radio_stations = vec![crate::core::state::RadioStationConfig { + name: "Configured Groove".to_string(), + url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), + }]; + guard.runtime_state.radio_stations = vec![ + crate::core::state::RadioStationConfig { name: "Groove Salad".to_string(), url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), }, - crate::core::user_config::RadioStationConfig { - name: " ".to_string(), - url: "https://x.example/s".to_string(), + crate::core::state::RadioStationConfig { + name: "Secret Agent".to_string(), + url: "https://ice1.somafm.com/secretagent-128-mp3".to_string(), }, ]; } assert!(route_radio_event(&app, &IoEvent::GetRadioStations).await); let guard = app.lock().await; - assert_eq!(guard.radio_stations.len(), 1); - assert_eq!(guard.radio_stations[0].name, "Groove Salad"); + assert_eq!(guard.radio_stations.len(), 2); + assert_eq!(guard.radio_stations[0].name, "Configured Groove"); assert_eq!( guard.radio_stations[0].uri.as_deref(), Some("radio:https://ice1.somafm.com/groovesalad-128-mp3") ); + assert_eq!(guard.radio_stations[1].name, "Secret Agent"); } /// End-to-end dispatch test: drive `route_radio_event` exactly as the runtime @@ -472,11 +491,10 @@ mod tests { let app = Arc::new(Mutex::new(test_app())); { let mut guard = app.lock().await; - guard.user_config.behavior.radio_stations = - vec![crate::core::user_config::RadioStationConfig { - name: "Groove Salad".to_string(), - url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - }]; + guard.runtime_state.radio_stations = vec![crate::core::state::RadioStationConfig { + name: "Groove Salad".to_string(), + url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), + }]; } assert!(route_radio_event(&app, &IoEvent::GetRadioStations).await); let uri = app.lock().await.radio_stations[0].uri.clone().unwrap(); diff --git a/src/infra/radio/mod.rs b/src/infra/radio/mod.rs index 39ed0af0..617d85c3 100644 --- a/src/infra/radio/mod.rs +++ b/src/infra/radio/mod.rs @@ -3,7 +3,7 @@ //! Plays direct HTTP(S) icecast/shoutcast-style streams (SomaFM, most stations //! in the radio-browser.info directory). Stations come from two places: //! -//! - the user's config list (`behavior.radio_stations`, name + URL pairs), +//! - the user's saved station list (`state.yml`, name + URL pairs), //! shown in the sidebar when the Radio source is active; //! - in-app search of the community [radio-browser.info](https://api.radio-browser.info) //! directory (30k+ stations), via [`RadioSource`]. diff --git a/src/infra/scripting/engine.rs b/src/infra/scripting/engine.rs index 4508a685..d4259ce2 100644 --- a/src/infra/scripting/engine.rs +++ b/src/infra/scripting/engine.rs @@ -921,7 +921,8 @@ impl ScriptEngine { } // Config has no generation counter (theme/settings can change from many // places); rebuilding the small snapshot each pass is cheap. - *self.shared.config_cache.borrow_mut() = plugin_api::config_snapshot(&app.user_config); + *self.shared.config_cache.borrow_mut() = + plugin_api::config_snapshot(&app.user_config, &app.runtime_state); let search_was = self.last_cache_gens[PluginDataKind::Search.index()]; if refresh( diff --git a/src/infra/subsonic/dispatch.rs b/src/infra/subsonic/dispatch.rs index e763fb7b..fa2967c9 100644 --- a/src/infra/subsonic/dispatch.rs +++ b/src/infra/subsonic/dispatch.rs @@ -120,7 +120,7 @@ pub async fn route_subsonic_event(app: &Arc>, event: &IoEvent) -> boo IoEvent::ChangeVolume(volume) => match player(app).await { Some(p) => { p.set_volume(*volume as f32 / 100.0); - app.lock().await.user_config.behavior.volume_percent = *volume; + app.lock().await.runtime_state.volume_percent = *volume; true } None => false, @@ -427,7 +427,7 @@ async fn start_subsonic_queue(app: &Arc>, uris: &[String], start_idx: match result { Ok(Ok(())) => { - let volume = app.lock().await.user_config.behavior.volume_percent; + let volume = app.lock().await.runtime_state.volume_percent; player.set_volume(volume as f32 / 100.0); let display = tracks[index].name.clone(); diff --git a/src/infra/youtube/dispatch.rs b/src/infra/youtube/dispatch.rs index ffd4621c..377f9e81 100644 --- a/src/infra/youtube/dispatch.rs +++ b/src/infra/youtube/dispatch.rs @@ -122,7 +122,7 @@ pub async fn route_youtube_event(app: &Arc>, event: &IoEvent) -> bool IoEvent::ChangeVolume(volume) => match player(app).await { Some(p) => { p.set_volume(*volume as f32 / 100.0); - app.lock().await.user_config.behavior.volume_percent = *volume; + app.lock().await.runtime_state.volume_percent = *volume; true } None => false, @@ -581,7 +581,7 @@ async fn start_youtube_queue(app: &Arc>, uris: &[String], start_idx: match result { Ok(Ok(())) => { - let volume = app.lock().await.user_config.behavior.volume_percent; + let volume = app.lock().await.runtime_state.volume_percent; player.set_volume(volume as f32 / 100.0); let display = tracks[index].name.clone(); diff --git a/src/runtime.rs b/src/runtime.rs index eb1787e6..d2d1c116 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1085,9 +1085,52 @@ screens more often and cost more CPU. Animation-heavy views keep their separate user_config.path_to_config.replace(path); } user_config.load_config()?; + let mut runtime_state = crate::core::state::RuntimeState::default(); + let mut state_path = None; + let mut should_save_initial_state = false; + match crate::core::state::default_state_path() { + Ok(path) => { + let state_file_exists = path.exists(); + match crate::core::state::load(&path) { + Ok(state) => { + runtime_state.apply_persisted(&state); + if !state_file_exists { + should_save_initial_state = true; + } + state_path = Some(path); + } + Err(e) => { + log::warn!("[state] ignoring unreadable runtime state: {e}"); + } + } + } + Err(e) => { + log::warn!("[state] runtime state path is unavailable: {e}"); + } + } + if let Some(startup_volume_percent) = user_config.behavior.volume_percent { + runtime_state.volume_percent = startup_volume_percent.min(100); + } + if let Some(sidebar_width_percent) = user_config.behavior.sidebar_width_percent { + runtime_state.sidebar_width_percent = sidebar_width_percent.min(100); + } + if let Some(playbar_height_rows) = user_config.behavior.playbar_height_rows { + runtime_state.playbar_height_rows = playbar_height_rows; + } + if let Some(library_height_percent) = user_config.behavior.library_height_percent { + runtime_state.library_height_percent = library_height_percent.min(100); + } + if should_save_initial_state { + if let Some(path) = &state_path { + let state = runtime_state.to_persisted(); + if let Err(e) = crate::core::state::save(path, &state) { + log::warn!("[state] failed to save initial runtime state: {e}"); + } + } + } info!("user config loaded successfully"); - let initial_shuffle_enabled = user_config.behavior.shuffle_enabled; + let initial_shuffle_enabled = runtime_state.shuffle_enabled; let initial_startup_behavior = user_config.behavior.startup_behavior; // Load the persisted non-Spotify playback session so the last song can resume @@ -1138,7 +1181,12 @@ screens more often and cost more CPU. Animation-heavy views keep their separate // otherwise launch the Spotify-only auth wizard on a fresh install. Skipped for // CLI subcommands (Spotify-only) and when `--reconfigure-auth` is requested. if matches.subcommand_name().is_none() && !matches.get_flag("reconfigure-auth") { - crate::core::first_run::run_first_run_picker(&mut user_config, &mut client_config).await?; + crate::core::first_run::run_first_run_picker( + &mut user_config, + &mut runtime_state, + &mut client_config, + ) + .await?; } client_config.load_config()?; info!("client authentication config loaded"); @@ -1179,7 +1227,7 @@ screens more often and cost more CPU. Animation-heavy views keep their separate // when unauthenticated). A free-source TUI launch tries a silent token load and // tolerates its absence; the user can add Spotify later via in-TUI login. let spotify_required = matches.subcommand_name().is_some() - || user_config.behavior.active_source == crate::core::source::Source::Spotify; + || runtime_state.active_source == crate::core::source::Source::Spotify; // The GitHub update check runs concurrently with authentication: both are // network round trips and neither depends on the other, so the check no @@ -1249,9 +1297,11 @@ screens more often and cost more CPU. Animation-heavy views keep their separate info!("app state initialized"); // Initialise app state - let app = Arc::new(Mutex::new(App::new( + let app = Arc::new(Mutex::new(App::new_with_state( sync_io_tx, user_config.clone(), + runtime_state.clone(), + state_path.clone(), token_expiry, ))); @@ -1599,7 +1649,7 @@ screens more often and cost more CPU. Animation-heavy views keep their separate token_cache_path: final_token_cache_path.clone(), client_config: client_config.clone(), redirect_uri: selected_redirect_uri.clone(), - volume_percent: user_config.behavior.volume_percent, + volume_percent: runtime_state.volume_percent, device_startup_behavior, // A restored non-Spotify session owns the startup play/pause decision; // otherwise the deferred task fires it once the device is selected. @@ -1719,7 +1769,8 @@ screens more often and cost more CPU. Animation-heavy views keep their separate ) .await; if ui_result.is_err() { - cloned_app.lock().await.flush_config_save(true); + let mut app = cloned_app.lock().await; + app.flush_state_save(true); } ui_result?; } @@ -2197,7 +2248,8 @@ async fn handle_mpris_events( if let Some(ref mut ctx) = app_lock.current_playback_context { ctx.shuffle_state = shuffle; } - app_lock.user_config.behavior.shuffle_enabled = shuffle; + app_lock.runtime_state.shuffle_enabled = shuffle; + app_lock.schedule_state_save(); } continue; } @@ -2207,7 +2259,8 @@ async fn handle_mpris_events( if let Some(ref mut ctx) = app_lock.current_playback_context { ctx.shuffle_state = shuffle; } - app_lock.user_config.behavior.shuffle_enabled = shuffle; + app_lock.runtime_state.shuffle_enabled = shuffle; + app_lock.schedule_state_save(); app_lock.dispatch(IoEvent::Shuffle(shuffle)); } MprisEvent::SetLoopStatus(loop_status) => { diff --git a/src/tui/handlers/announcement_prompt.rs b/src/tui/handlers/announcement_prompt.rs index 384b1cfd..086713d2 100644 --- a/src/tui/handlers/announcement_prompt.rs +++ b/src/tui/handlers/announcement_prompt.rs @@ -5,8 +5,8 @@ pub fn handler(key: Key, app: &mut App) { match key { Key::Enter | Key::Esc | Key::Char('q') | Key::Char(' ') => { if let Some(dismissed_id) = app.dismiss_active_announcement() { - app.user_config.mark_announcement_seen(dismissed_id); - if let Err(error) = app.user_config.save_config() { + app.runtime_state.mark_announcement_seen(dismissed_id); + if let Err(error) = app.save_runtime_state() { app.handle_error(anyhow::anyhow!( "Failed to persist dismissed announcement: {}", error diff --git a/src/tui/handlers/library.rs b/src/tui/handlers/library.rs index 5788b943..dc0284ef 100644 --- a/src/tui/handlers/library.rs +++ b/src/tui/handlers/library.rs @@ -90,8 +90,8 @@ pub fn handler(key: Key, app: &mut App) { 8 => { app.active_source = Source::Local; // Mirror the persisted value so the selection survives restarts. - app.user_config.behavior.active_source = Source::Local; - if let Err(e) = app.user_config.save_config() { + app.runtime_state.active_source = Source::Local; + if let Err(e) = app.save_runtime_state() { log::warn!("[source] failed to persist active_source: {e}"); } app.selected_playlist_index = Some(0); diff --git a/src/tui/handlers/mod.rs b/src/tui/handlers/mod.rs index 72465f69..d77079b6 100644 --- a/src/tui/handlers/mod.rs +++ b/src/tui/handlers/mod.rs @@ -1075,6 +1075,7 @@ mod tests { app.user_config.path_to_config = Some(UserConfigPaths { config_file_path: dir.path().join("config.yml"), }); + app.state_path = Some(dir.path().join("state.yml")); app.search_results.tracks = Some(Paged { items: vec![TrackInfo { uri: Some("radio:https://example.com/stream".to_string()), @@ -1100,9 +1101,9 @@ mod tests { let favorite_key = app.user_config.keys.like_track; handle_app(favorite_key, &mut app); - assert_eq!(app.user_config.behavior.radio_stations.len(), 1); + assert_eq!(app.runtime_state.radio_stations.len(), 1); assert_eq!( - app.user_config.behavior.radio_stations[0].url, + app.runtime_state.radio_stations[0].url, "https://example.com/stream" ); assert_eq!( diff --git a/src/tui/handlers/mouse.rs b/src/tui/handlers/mouse.rs index 26f6f117..5ec51cf6 100644 --- a/src/tui/handlers/mouse.rs +++ b/src/tui/handlers/mouse.rs @@ -866,7 +866,7 @@ fn fullscreen_view_playbar_area(app: &App) -> Option { } let root = Rect::new(0, 0, app.size.width, app.size.height); - let (_, playbar_area) = fullscreen_view_layout(&app.user_config.behavior, root); + let (_, playbar_area) = fullscreen_view_layout(&app.runtime_state, root); playbar_area } @@ -1386,13 +1386,13 @@ mod tests { } #[test] - fn fullscreen_view_playbar_area_uses_configured_height() { + fn fullscreen_view_playbar_area_uses_runtime_state_height() { let mut app = App::default(); app.size = Size { width: 160, height: 50, }; - app.user_config.behavior.playbar_height_rows = 8; + app.runtime_state.playbar_height_rows = 8; let playbar_area = fullscreen_view_playbar_area(&app).expect("fullscreen playbar area"); @@ -1406,7 +1406,7 @@ mod tests { width: 160, height: 50, }; - app.user_config.behavior.playbar_height_rows = 0; + app.runtime_state.playbar_height_rows = 0; assert!(fullscreen_view_playbar_area(&app).is_none()); } @@ -1418,7 +1418,7 @@ mod tests { width: 160, height: 50, }; - app.user_config.behavior.playbar_height_rows = 0; + app.runtime_state.playbar_height_rows = 0; app.push_navigation_stack(RouteId::LyricsView, ActiveBlock::LyricsView); with_playbar_context(&mut app); @@ -1446,7 +1446,7 @@ mod tests { width: 160, height: 50, }; - app.user_config.behavior.playbar_height_rows = 8; + app.runtime_state.playbar_height_rows = 8; app.push_navigation_stack(RouteId::Home, ActiveBlock::Home); with_playbar_context(&mut app); @@ -1470,7 +1470,7 @@ mod tests { width: 160, height: 50, }; - app.user_config.behavior.playbar_height_rows = 3; + app.runtime_state.playbar_height_rows = 3; app.push_navigation_stack(RouteId::Home, ActiveBlock::Home); with_playbar_context(&mut app); diff --git a/src/tui/handlers/playlist.rs b/src/tui/handlers/playlist.rs index d3b86722..f7ac205e 100644 --- a/src/tui/handlers/playlist.rs +++ b/src/tui/handlers/playlist.rs @@ -99,8 +99,18 @@ fn remove_radio_station(app: &mut App) { app.set_status_message("Radio station has no stream URL".to_string(), 4); return; }; + if app.is_configured_radio_station_url(url) { + app.set_status_message( + format!( + "Radio station is configured in config.yml: {}", + station.name + ), + 4, + ); + return; + } - match app.user_config.remove_radio_station_by_url(url) { + match app.remove_radio_station_by_url(url) { Ok(Some(removed)) => { app.radio_stations.remove(idx); app.selected_playlist_index = if app.radio_stations.is_empty() { @@ -248,11 +258,30 @@ pub fn handler(key: Key, app: &mut App) { mod tests { use super::*; use crate::core::plugin_api::TrackInfo; + use crate::core::state::RadioStationConfig; use crate::core::test_helpers::playlist_info; - use crate::core::user_config::{RadioStationConfig, UserConfig, UserConfigPaths}; + use crate::core::user_config::{UserConfig, UserConfigPaths}; use std::sync::mpsc::channel; use std::time::SystemTime; + fn radio_station_row(name: &str, url: &str) -> TrackInfo { + TrackInfo { + uri: Some(format!("radio:{url}")), + name: name.to_string(), + artists: vec![], + album: String::new(), + duration_ms: 0, + id: None, + album_id: None, + artist_refs: vec![], + is_playable: true, + is_local: false, + track_number: 0, + explicit: false, + image_url: None, + } + } + #[test] fn enter_playlist_dispatches_only_visible_page_load() { let (tx, rx) = channel(); @@ -348,7 +377,9 @@ mod tests { config.path_to_config = Some(UserConfigPaths { config_file_path: dir.path().join("config.yml"), }); - config.behavior.radio_stations = vec![ + let mut app = App::new(tx, config, Some(SystemTime::now())); + app.state_path = Some(dir.path().join("state.yml")); + app.runtime_state.radio_stations = vec![ RadioStationConfig { name: "Groove Salad".to_string(), url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), @@ -359,7 +390,6 @@ mod tests { }, ]; - let mut app = App::new(tx, config, Some(SystemTime::now())); app.active_source = Source::Radio; app.radio_stations = vec![ TrackInfo { @@ -397,9 +427,9 @@ mod tests { handler(Key::Char('D'), &mut app); - assert_eq!(app.user_config.behavior.radio_stations.len(), 1); + assert_eq!(app.runtime_state.radio_stations.len(), 1); assert_eq!( - app.user_config.behavior.radio_stations[0].url, + app.runtime_state.radio_stations[0].url, "https://ice1.somafm.com/secretagent-128-mp3" ); assert_eq!(app.radio_stations.len(), 1); @@ -410,4 +440,40 @@ mod tests { Some("Removed radio station: Groove Salad") ); } + + #[test] + fn shift_d_on_configured_radio_station_reports_config_ownership() { + let dir = tempfile::tempdir().unwrap(); + let (tx, _rx) = channel(); + let mut config = UserConfig::new(); + config.path_to_config = Some(UserConfigPaths { + config_file_path: dir.path().join("config.yml"), + }); + config.behavior.radio_stations = vec![RadioStationConfig { + name: "Configured Groove".to_string(), + url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), + }]; + let mut app = App::new(tx, config, Some(SystemTime::now())); + app.state_path = Some(dir.path().join("state.yml")); + app.runtime_state.radio_stations = vec![RadioStationConfig { + name: "Runtime Duplicate".to_string(), + url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), + }]; + app.active_source = Source::Radio; + app.radio_stations = vec![radio_station_row( + "Configured Groove", + "https://ice1.somafm.com/groovesalad-128-mp3", + )]; + app.selected_playlist_index = Some(0); + + handler(Key::Char('D'), &mut app); + + assert_eq!(app.runtime_state.radio_stations.len(), 1); + assert_eq!(app.radio_stations.len(), 1); + assert_eq!(app.selected_playlist_index, Some(0)); + assert_eq!( + app.status_message.as_deref(), + Some("Radio station is configured in config.yml: Configured Groove") + ); + } } diff --git a/src/tui/handlers/resize.rs b/src/tui/handlers/resize.rs index 0af87650..fc809d17 100644 --- a/src/tui/handlers/resize.rs +++ b/src/tui/handlers/resize.rs @@ -1,84 +1,89 @@ use crate::core::app::App; +use crate::core::state::RuntimeState; const SIDEBAR_STEP: u8 = 5; const MAX_PLAYBAR_ROWS: u16 = 50; const PLAYBAR_STEP: u16 = 1; const LIBRARY_STEP: u8 = 5; -// Default layout values (must match UserConfig::new() defaults) -const DEFAULT_SIDEBAR_WIDTH: u8 = 20; -const DEFAULT_PLAYBAR_HEIGHT: u16 = 6; -const DEFAULT_LIBRARY_HEIGHT: u8 = 30; - /// Decrease sidebar width by SIDEBAR_STEP percent (minimum 0%). pub fn decrease_sidebar_width(app: &mut App) { - app.user_config.behavior.sidebar_width_percent = app - .user_config - .behavior + app.runtime_state.sidebar_width_percent = app + .runtime_state .sidebar_width_percent .saturating_sub(SIDEBAR_STEP); - app.schedule_config_save(); + app.schedule_state_save(); } /// Increase sidebar width by SIDEBAR_STEP percent (maximum 100%). pub fn increase_sidebar_width(app: &mut App) { - app.user_config.behavior.sidebar_width_percent = app - .user_config - .behavior + app.runtime_state.sidebar_width_percent = app + .runtime_state .sidebar_width_percent .saturating_add(SIDEBAR_STEP) .min(100); - app.schedule_config_save(); + app.schedule_state_save(); } /// Decrease playbar height by PLAYBAR_STEP rows (minimum 0 = hidden). pub fn decrease_playbar_height(app: &mut App) { - app.user_config.behavior.playbar_height_rows = app - .user_config - .behavior + app.runtime_state.playbar_height_rows = app + .runtime_state .playbar_height_rows .saturating_sub(PLAYBAR_STEP); - app.schedule_config_save(); + app.schedule_state_save(); } /// Increase playbar height by PLAYBAR_STEP rows (capped at MAX_PLAYBAR_ROWS). pub fn increase_playbar_height(app: &mut App) { - app.user_config.behavior.playbar_height_rows = app - .user_config - .behavior + app.runtime_state.playbar_height_rows = app + .runtime_state .playbar_height_rows .saturating_add(PLAYBAR_STEP) .min(MAX_PLAYBAR_ROWS); - app.schedule_config_save(); + app.schedule_state_save(); } /// Decrease the library section height within the sidebar (minimum 0% = hidden). pub fn decrease_library_height(app: &mut App) { - app.user_config.behavior.library_height_percent = app - .user_config - .behavior + app.runtime_state.library_height_percent = app + .runtime_state .library_height_percent .saturating_sub(LIBRARY_STEP); - app.schedule_config_save(); + app.schedule_state_save(); } /// Increase the library section height within the sidebar (maximum 100%). pub fn increase_library_height(app: &mut App) { - app.user_config.behavior.library_height_percent = app - .user_config - .behavior + app.runtime_state.library_height_percent = app + .runtime_state .library_height_percent .saturating_add(LIBRARY_STEP) .min(100); - app.schedule_config_save(); + app.schedule_state_save(); } -/// Reset all pane sizes to their defaults. +/// Reset all pane sizes to explicit config startup overrides, or runtime defaults. pub fn reset_layout(app: &mut App) { - app.user_config.behavior.sidebar_width_percent = DEFAULT_SIDEBAR_WIDTH; - app.user_config.behavior.playbar_height_rows = DEFAULT_PLAYBAR_HEIGHT; - app.user_config.behavior.library_height_percent = DEFAULT_LIBRARY_HEIGHT; - app.schedule_config_save(); + let defaults = RuntimeState::default(); + app.runtime_state.sidebar_width_percent = app + .user_config + .behavior + .sidebar_width_percent + .unwrap_or(defaults.sidebar_width_percent) + .min(100); + app.runtime_state.playbar_height_rows = app + .user_config + .behavior + .playbar_height_rows + .unwrap_or(defaults.playbar_height_rows); + app.runtime_state.library_height_percent = app + .user_config + .behavior + .library_height_percent + .unwrap_or(defaults.library_height_percent) + .min(100); + app.schedule_state_save(); } #[cfg(test)] @@ -88,128 +93,141 @@ mod tests { #[test] fn decrease_sidebar_reduces_width_by_step() { let mut app = App::default(); - app.user_config.behavior.sidebar_width_percent = 20; + app.runtime_state.sidebar_width_percent = 20; decrease_sidebar_width(&mut app); - assert_eq!(app.user_config.behavior.sidebar_width_percent, 15); + assert_eq!(app.runtime_state.sidebar_width_percent, 15); } #[test] fn decrease_sidebar_clamps_at_zero() { let mut app = App::default(); - app.user_config.behavior.sidebar_width_percent = 3; + app.runtime_state.sidebar_width_percent = 3; decrease_sidebar_width(&mut app); - assert_eq!(app.user_config.behavior.sidebar_width_percent, 0); + assert_eq!(app.runtime_state.sidebar_width_percent, 0); } #[test] fn increase_sidebar_increases_width_by_step() { let mut app = App::default(); - app.user_config.behavior.sidebar_width_percent = 20; + app.runtime_state.sidebar_width_percent = 20; increase_sidebar_width(&mut app); - assert_eq!(app.user_config.behavior.sidebar_width_percent, 25); + assert_eq!(app.runtime_state.sidebar_width_percent, 25); } #[test] fn increase_sidebar_clamps_at_100() { let mut app = App::default(); - app.user_config.behavior.sidebar_width_percent = 98; + app.runtime_state.sidebar_width_percent = 98; increase_sidebar_width(&mut app); - assert_eq!(app.user_config.behavior.sidebar_width_percent, 100); + assert_eq!(app.runtime_state.sidebar_width_percent, 100); } #[test] fn sidebar_can_be_fully_hidden() { let mut app = App::default(); - app.user_config.behavior.sidebar_width_percent = 5; + app.runtime_state.sidebar_width_percent = 5; decrease_sidebar_width(&mut app); - assert_eq!(app.user_config.behavior.sidebar_width_percent, 0); + assert_eq!(app.runtime_state.sidebar_width_percent, 0); } #[test] fn decrease_playbar_reduces_height_by_step() { let mut app = App::default(); - app.user_config.behavior.playbar_height_rows = 6; + app.runtime_state.playbar_height_rows = 6; decrease_playbar_height(&mut app); - assert_eq!(app.user_config.behavior.playbar_height_rows, 5); + assert_eq!(app.runtime_state.playbar_height_rows, 5); } #[test] fn decrease_playbar_clamps_at_zero() { let mut app = App::default(); - app.user_config.behavior.playbar_height_rows = 0; + app.runtime_state.playbar_height_rows = 0; decrease_playbar_height(&mut app); - assert_eq!(app.user_config.behavior.playbar_height_rows, 0); + assert_eq!(app.runtime_state.playbar_height_rows, 0); } #[test] fn increase_playbar_increases_height_by_step() { let mut app = App::default(); - app.user_config.behavior.playbar_height_rows = 6; + app.runtime_state.playbar_height_rows = 6; increase_playbar_height(&mut app); - assert_eq!(app.user_config.behavior.playbar_height_rows, 7); + assert_eq!(app.runtime_state.playbar_height_rows, 7); } #[test] fn increase_playbar_clamps_at_max() { let mut app = App::default(); - app.user_config.behavior.playbar_height_rows = MAX_PLAYBAR_ROWS; + app.runtime_state.playbar_height_rows = MAX_PLAYBAR_ROWS; increase_playbar_height(&mut app); - assert_eq!( - app.user_config.behavior.playbar_height_rows, - MAX_PLAYBAR_ROWS - ); + assert_eq!(app.runtime_state.playbar_height_rows, MAX_PLAYBAR_ROWS); } #[test] fn playbar_can_be_hidden() { let mut app = App::default(); - app.user_config.behavior.playbar_height_rows = 1; + app.runtime_state.playbar_height_rows = 1; decrease_playbar_height(&mut app); - assert_eq!(app.user_config.behavior.playbar_height_rows, 0); + assert_eq!(app.runtime_state.playbar_height_rows, 0); } #[test] fn decrease_library_reduces_height_by_step() { let mut app = App::default(); - app.user_config.behavior.library_height_percent = 30; + app.runtime_state.library_height_percent = 30; decrease_library_height(&mut app); - assert_eq!(app.user_config.behavior.library_height_percent, 25); + assert_eq!(app.runtime_state.library_height_percent, 25); } #[test] fn increase_library_increases_height_by_step() { let mut app = App::default(); - app.user_config.behavior.library_height_percent = 30; + app.runtime_state.library_height_percent = 30; increase_library_height(&mut app); - assert_eq!(app.user_config.behavior.library_height_percent, 35); + assert_eq!(app.runtime_state.library_height_percent, 35); } #[test] fn library_can_be_fully_hidden() { let mut app = App::default(); - app.user_config.behavior.library_height_percent = 3; + app.runtime_state.library_height_percent = 3; decrease_library_height(&mut app); - assert_eq!(app.user_config.behavior.library_height_percent, 0); + assert_eq!(app.runtime_state.library_height_percent, 0); } #[test] - fn reset_layout_restores_all_defaults() { + fn reset_layout_restores_runtime_defaults_without_config_overrides() { let mut app = App::default(); - app.user_config.behavior.sidebar_width_percent = 50; - app.user_config.behavior.playbar_height_rows = 0; - app.user_config.behavior.library_height_percent = 80; + app.runtime_state.sidebar_width_percent = 50; + app.runtime_state.playbar_height_rows = 0; + app.runtime_state.library_height_percent = 80; reset_layout(&mut app); + let defaults = RuntimeState::default(); assert_eq!( - app.user_config.behavior.sidebar_width_percent, - DEFAULT_SIDEBAR_WIDTH + app.runtime_state.sidebar_width_percent, + defaults.sidebar_width_percent ); assert_eq!( - app.user_config.behavior.playbar_height_rows, - DEFAULT_PLAYBAR_HEIGHT + app.runtime_state.playbar_height_rows, + defaults.playbar_height_rows ); assert_eq!( - app.user_config.behavior.library_height_percent, - DEFAULT_LIBRARY_HEIGHT + app.runtime_state.library_height_percent, + defaults.library_height_percent ); } + + #[test] + fn reset_layout_prefers_config_startup_overrides() { + let mut app = App::default(); + app.runtime_state.sidebar_width_percent = 50; + app.runtime_state.playbar_height_rows = 0; + app.runtime_state.library_height_percent = 80; + app.user_config.behavior.sidebar_width_percent = Some(35); + app.user_config.behavior.playbar_height_rows = Some(9); + app.user_config.behavior.library_height_percent = Some(45); + reset_layout(&mut app); + assert_eq!(app.runtime_state.sidebar_width_percent, 35); + assert_eq!(app.runtime_state.playbar_height_rows, 9); + assert_eq!(app.runtime_state.library_height_percent, 45); + } } diff --git a/src/tui/handlers/search_results.rs b/src/tui/handlers/search_results.rs index 08e22747..13bfa02a 100644 --- a/src/tui/handlers/search_results.rs +++ b/src/tui/handlers/search_results.rs @@ -5,7 +5,7 @@ use crate::core::app::{ }; use crate::core::plugin_api::TrackInfo; use crate::core::source::Source; -use crate::core::user_config::RadioStationAddOutcome; +use crate::core::state::RadioStationAddOutcome; use crate::infra::network::IoEvent; use crate::tui::event::Key; use rspotify::model::idtypes::PlaylistId; @@ -468,7 +468,7 @@ fn favorite_radio_station(app: &mut App, station: TrackInfo) { let name = if trimmed.is_empty() { url } else { trimmed }.to_string(); let url = url.to_string(); - let message = match app.user_config.add_radio_station(&name, &url) { + let message = match app.add_radio_station(&name, &url) { Ok(RadioStationAddOutcome::Added) => format!("Favorited radio station: {name}"), Ok(RadioStationAddOutcome::AlreadyExists) => { format!("Radio station already favorited: {name}") @@ -805,6 +805,7 @@ mod tests { }); let (tx, _rx) = channel(); let mut app = App::new(tx, user_config, Some(SystemTime::now())); + app.state_path = Some(dir.path().join("state.yml")); app.active_source = Source::Radio; app.search_results.tracks = Some(Paged { items: vec![station( @@ -820,9 +821,9 @@ mod tests { let favorite_key = app.user_config.keys.like_track; handler(favorite_key, &mut app); - assert_eq!(app.user_config.behavior.radio_stations.len(), 1); + assert_eq!(app.runtime_state.radio_stations.len(), 1); assert_eq!( - app.user_config.behavior.radio_stations[0].url, + app.runtime_state.radio_stations[0].url, "https://ice1.somafm.com/groovesalad-128-mp3" ); assert_eq!(app.radio_stations.len(), 1); diff --git a/src/tui/handlers/select_device.rs b/src/tui/handlers/select_device.rs index b21f42b2..d46f77c0 100644 --- a/src/tui/handlers/select_device.rs +++ b/src/tui/handlers/select_device.rs @@ -92,8 +92,8 @@ fn select_source(app: &mut App) { if app.active_source != source { app.active_source = source; // Mirror the persisted value so it survives restarts. - app.user_config.behavior.active_source = source; - if let Err(e) = app.user_config.save_config() { + app.runtime_state.active_source = source; + if let Err(e) = app.save_runtime_state() { log::warn!("[source] failed to persist active_source: {e}"); } // Reset the sidebar playlist cursor to the top of the new source's list. diff --git a/src/tui/runner.rs b/src/tui/runner.rs index 6b134676..2dafd083 100644 --- a/src/tui/runner.rs +++ b/src/tui/runner.rs @@ -844,8 +844,8 @@ pub async fn start_ui( handlers::handle_app(key, &mut app); } else if app.get_current_route().active_block == ActiveBlock::AnnouncementPrompt { if let Some(dismissed_id) = app.dismiss_active_announcement() { - app.user_config.mark_announcement_seen(dismissed_id); - if let Err(error) = app.user_config.save_config() { + app.runtime_state.mark_announcement_seen(dismissed_id); + if let Err(error) = app.save_runtime_state() { app.handle_error(anyhow!( "Failed to persist dismissed announcement: {}", error @@ -896,7 +896,7 @@ pub async fn start_ui( app.flush_pending_api_seek(); app.flush_pending_source_seek(); app.flush_pending_volume(); - app.flush_config_save(false); + app.flush_state_save(false); #[cfg(feature = "scripting")] if let Some(engine) = script_engine.as_mut() { @@ -1352,7 +1352,7 @@ pub async fn start_ui( // persist it before the process exits. { let mut app = app.lock().await; - app.flush_config_save(true); + app.flush_state_save(true); } #[cfg(feature = "streaming")] diff --git a/src/tui/ui/library.rs b/src/tui/ui/library.rs index 347066c1..f1d54013 100644 --- a/src/tui/ui/library.rs +++ b/src/tui/ui/library.rs @@ -86,7 +86,7 @@ pub fn draw_playlist_block(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { // A station is a leaf — Enter plays it directly instead of drilling in. if app.active_source == Source::Radio { let items: Vec = if app.radio_stations.is_empty() { - vec!["(no stations \u{2014} add behavior.radio_stations or search)".to_string()] + vec!["(no saved stations \u{2014} search to add)".to_string()] } else { app .radio_stations @@ -203,7 +203,7 @@ pub fn draw_user_block(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { // Check for width to make a responsive layout if is_wide_layout(app) { - let lib_constraints = library_constraints(&app.user_config.behavior); + let lib_constraints = library_constraints(&app.runtime_state); let [input_area, library_area, playlist_area] = layout_chunk.layout(&Layout::vertical([ Constraint::Length(3), lib_constraints[0], @@ -217,9 +217,8 @@ pub fn draw_user_block(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { draw_library_block(f, app, library_area); draw_playlist_block(f, app, playlist_area); } else { - let [library_area, playlist_area] = layout_chunk.layout(&Layout::vertical( - library_constraints(&app.user_config.behavior), - )); + let [library_area, playlist_area] = + layout_chunk.layout(&Layout::vertical(library_constraints(&app.runtime_state))); // Search input and help draw_library_block(f, app, library_area); diff --git a/src/tui/ui/lyrics.rs b/src/tui/ui/lyrics.rs index 4f2683fa..8e25ebb2 100644 --- a/src/tui/ui/lyrics.rs +++ b/src/tui/ui/lyrics.rs @@ -10,7 +10,7 @@ use ratatui::{ use super::player::draw_playbar; pub fn draw_lyrics_view(f: &mut Frame<'_>, app: &App) { - let (content_area, playbar_area) = fullscreen_view_layout(&app.user_config.behavior, f.area()); + let (content_area, playbar_area) = fullscreen_view_layout(&app.runtime_state, f.area()); draw_lyrics(f, app, content_area); if let Some(playbar_area) = playbar_area { @@ -219,7 +219,7 @@ mod tests { // scroll_pos = 1.0 puts the active line at the vertical center of the // lyric area. let active_row = row_of(&buffer, "second line").expect("active line row"); - let expected = 1 + (24 - app.user_config.behavior.playbar_height_rows - 2) / 2; + let expected = 1 + (24 - app.runtime_state.playbar_height_rows - 2) / 2; assert_eq!(active_row, expected); } diff --git a/src/tui/ui/player.rs b/src/tui/ui/player.rs index 04f69927..441d725f 100644 --- a/src/tui/ui/player.rs +++ b/src/tui/ui/player.rs @@ -702,7 +702,7 @@ fn center_rect_within(bounds: Rect, size: Rect) -> Rect { #[cfg(feature = "cover-art")] pub fn draw_cover_art_view(f: &mut Frame<'_>, app: &App) { - let (content_area, playbar_area) = fullscreen_view_layout(&app.user_config.behavior, f.area()); + let (content_area, playbar_area) = fullscreen_view_layout(&app.runtime_state, f.area()); draw_cover_art_content(f, app, content_area); if let Some(playbar_area) = playbar_area { @@ -887,7 +887,7 @@ fn draw_local_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { is_playing: !local.player.is_paused(), position_ms: local.player.position().as_millis(), duration_ms: local.duration_ms, - volume_percent: app.user_config.behavior.volume_percent, + volume_percent: app.runtime_state.volume_percent, // Only show the indicator for multi-track queues; a single file is noise. queue_position: (local.queue.len() > 1).then(|| (local.index + 1, local.queue.len())), live: false, @@ -911,7 +911,7 @@ fn draw_subsonic_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { is_playing: !subsonic.player.is_paused(), position_ms: subsonic.player.position().as_millis(), duration_ms: track.map(|t| t.duration_ms).unwrap_or(0), - volume_percent: app.user_config.behavior.volume_percent, + volume_percent: app.runtime_state.volume_percent, queue_position: (subsonic.tracks.len() > 1) .then(|| (subsonic.index + 1, subsonic.tracks.len())), live: false, @@ -935,7 +935,7 @@ fn draw_youtube_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { is_playing: !youtube.player.is_paused(), position_ms: youtube.player.position().as_millis(), duration_ms: track.map(|t| t.duration_ms).unwrap_or(0), - volume_percent: app.user_config.behavior.volume_percent, + volume_percent: app.runtime_state.volume_percent, queue_position: (youtube.tracks.len() > 1).then(|| (youtube.index + 1, youtube.tracks.len())), live: false, show_modes: true, @@ -968,7 +968,7 @@ fn draw_radio_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { is_playing: !radio.player.is_paused(), position_ms: radio.player.position().as_millis(), duration_ms: 0, - volume_percent: app.user_config.behavior.volume_percent, + volume_percent: app.runtime_state.volume_percent, queue_position: None, live: true, show_modes: false, @@ -1159,7 +1159,7 @@ pub fn draw_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { is_playing: !d.player.is_paused(), position_ms: d.player.position().as_millis(), duration_ms: d.track.duration_ms, - volume_percent: app.user_config.behavior.volume_percent, + volume_percent: app.runtime_state.volume_percent, queue_position: None, live: false, // The native queue ignores the decoded shuffle/repeat modes (they belong @@ -1180,7 +1180,7 @@ pub fn draw_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { is_playing: app.native_is_playing.unwrap_or(true), position_ms: app.song_progress_ms, duration_ms: track.duration_ms, - volume_percent: app.user_config.behavior.volume_percent, + volume_percent: app.runtime_state.volume_percent, queue_position: None, live: false, show_modes: false, From bf31e4274157120aadc0e2393eb155f41a8150bb Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Tue, 28 Jul 2026 15:56:52 +0930 Subject: [PATCH 10/23] feat: move generated app state into XDG state/cache dirs Move Spotify OAuth token cache out of the config directory and into the XDG state directory while keeping client.yml as user configuration. Gate cache-dir path helpers behind the streaming feature and document the state/cache locations for history, token cache, and native streaming cache. Persist volume changes through runtime state and flush pending state saves before CLI command exit. --- docs/configuration.md | 2 +- docs/native-streaming.md | 4 ++++ src/core/config.rs | 20 +++++++++----------- src/core/paths.rs | 6 +++--- src/infra/network/playback.rs | 4 ++++ src/runtime.rs | 7 +++---- 6 files changed, 24 insertions(+), 19 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index b5bcb220..27dca868 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -11,7 +11,7 @@ All fields are optional; omitted values use the built-in defaults. A complete, c Simple values (numbers, toggles, icons, positions) can also be changed live in the in-app **Settings** screen (see the hint in the top-right of the UI). Structured config — `format:` templates, `tables:` columns, and `playbar_control_labels` — is file-only. Edit the file while the app is closed: saving from the Settings screen rewrites the `behavior`, `theme`, and `keybindings` sections, but your `format:`, `tables:`, and `plugin_commands:` sections survive in-app saves untouched. -Machine-managed runtime state lives separately in `$XDG_STATE_HOME/spotatui/state.yml` (or `${HOME}/.local/state/spotatui/state.yml`). This includes volume, shuffle, active source, seen announcements, resized pane dimensions, and saved radio stations. `sync_token` remains user config and is stored in `config.yml`. +Machine-managed runtime state lives separately in `$XDG_STATE_HOME/spotatui/state.yml` (or `${HOME}/.local/state/spotatui/state.yml`). This includes volume, shuffle, active source, seen announcements, resized pane dimensions, and saved radio stations. The Spotify OAuth token cache and local listening history also live under the app state directory. Native streaming credentials and audio cache live under `$XDG_CACHE_HOME/spotatui/streaming_cache` (or `${HOME}/.cache/spotatui/streaming_cache`). `sync_token` remains user config and is stored in `config.yml`. ## Safe by default diff --git a/docs/native-streaming.md b/docs/native-streaming.md index 5af4006d..3ae1ec93 100644 --- a/docs/native-streaming.md +++ b/docs/native-streaming.md @@ -32,6 +32,10 @@ streaming_bitrate: 320 # 96, 160, or 320 kbps directly; the Spotify app may still describe a Connect device's quality as "Automatic". +Native-streaming credentials and audio cache are stored in the app cache +directory, for example `$XDG_CACHE_HOME/spotatui/streaming_cache`, or +`~/.cache/spotatui/streaming_cache` when `XDG_CACHE_HOME` is not set. + ## Notes - Native streaming is **enabled by default** when built with the `streaming` feature diff --git a/src/core/config.rs b/src/core/config.rs index b40dfe09..6a16aeae 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -79,11 +79,15 @@ impl ClientConfig { } pub fn get_or_build_paths(&self) -> Result { - match crate::core::paths::app_config_dir() { - Some(app_config_dir) => { + match ( + crate::core::paths::app_config_dir(), + crate::core::paths::app_state_dir(), + ) { + (Some(app_config_dir), Some(app_state_dir)) => { fs::create_dir_all(&app_config_dir)?; + fs::create_dir_all(&app_state_dir)?; - // Create .gitignore to protect sensitive files from being committed + // Create .gitignore to protect sensitive config from being committed. let gitignore_path = app_config_dir.join(GITIGNORE_FILE); if !gitignore_path.exists() { let gitignore_content = "\ @@ -92,18 +96,12 @@ impl ClientConfig { # Client credentials (client_id, client_secret) client.yml - - # Panic logs - spotatui_panic.log - - # Streaming credentials - streaming_cache/credentials.json "; fs::write(&gitignore_path, gitignore_content)?; } let config_file_path = &app_config_dir.join(FILE_NAME); - let token_cache_path = &app_config_dir.join(TOKEN_CACHE_FILE); + let token_cache_path = &app_state_dir.join(TOKEN_CACHE_FILE); let paths = ConfigPaths { config_file_path: config_file_path.to_path_buf(), @@ -112,7 +110,7 @@ impl ClientConfig { Ok(paths) } - None => Err(anyhow!("No $HOME directory found for client config")), + _ => Err(anyhow!("No $HOME directory found for client config/state")), } } diff --git a/src/core/paths.rs b/src/core/paths.rs index 9977922d..e7f7c6b0 100644 --- a/src/core/paths.rs +++ b/src/core/paths.rs @@ -2,10 +2,10 @@ use std::{ffi::OsString, path::PathBuf}; const APP_DIR: &str = "spotatui"; const FALLBACK_CONFIG_DIR: &str = ".config"; -#[allow(dead_code)] +#[cfg(any(feature = "streaming", test))] const FALLBACK_CACHE_DIR: &str = ".cache"; const FALLBACK_STATE_DIR: &str = ".local/state"; -#[allow(dead_code)] +#[cfg(feature = "streaming")] const XDG_CACHE_HOME_ENV: &str = "XDG_CACHE_HOME"; const XDG_CONFIG_HOME_ENV: &str = "XDG_CONFIG_HOME"; const XDG_STATE_HOME_ENV: &str = "XDG_STATE_HOME"; @@ -26,7 +26,7 @@ pub(crate) fn app_config_dir() -> Option { /// /// Uses `$XDG_CACHE_HOME/spotatui` when XDG_CACHE_HOME is set to an absolute /// path, otherwise falls back to `$HOME/.cache/spotatui`. -#[allow(dead_code)] +#[cfg(feature = "streaming")] pub(crate) fn app_cache_dir() -> Option { app_dir_from( std::env::var_os(XDG_CACHE_HOME_ENV), diff --git a/src/infra/network/playback.rs b/src/infra/network/playback.rs index d8bfee85..62e4489f 100644 --- a/src/infra/network/playback.rs +++ b/src/infra/network/playback.rs @@ -2049,6 +2049,8 @@ impl PlaybackNetwork for Network { if let Some(ctx) = &mut app.current_playback_context { ctx.device.volume_percent = Some(volume.into()); } + app.runtime_state.volume_percent = volume.min(100); + app.schedule_state_save(); app.is_volume_change_in_flight = false; app.last_dispatched_volume = Some(volume); // Keep pending_volume set — cleared when API confirms the value matches @@ -2069,6 +2071,8 @@ impl PlaybackNetwork for Network { if let Some(ctx) = &mut app.current_playback_context { ctx.device.volume_percent = Some(volume.into()); } + app.runtime_state.volume_percent = volume.min(100); + app.schedule_state_save(); app.is_volume_change_in_flight = false; app.last_dispatched_volume = Some(volume); // Keep pending_volume set — cleared when get_current_playback confirms diff --git a/src/runtime.rs b/src/runtime.rs index 7b277621..3cdb9e76 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1333,10 +1333,9 @@ screens more often and cost more CPU. Animation-heavy views keep their separate let network = Network::new(spotify, client_config, &app, final_token_cache_path); // CLI doesn't use streaming #[cfg(not(feature = "streaming"))] let network = Network::new(spotify, client_config, &app, final_token_cache_path); - println!( - "{}", - cli::handle_matches(m, cmd.to_string(), network, user_config).await? - ); + let cli_result = cli::handle_matches(m, cmd.to_string(), network, user_config).await; + app.lock().await.flush_state_save(true); + println!("{}", cli_result?); // Launch the UI (async) } else { info!("launching interactive terminal ui"); From a3d44448ed40523879b27d0d186d5a6ab410dbea Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Tue, 28 Jul 2026 17:24:20 +0930 Subject: [PATCH 11/23] docs: clarify absolute-only XDG path handling Document that XDG directory variables are honored only when set to absolute paths, with HOME-based fallbacks for unset or relative values. Update plugin install and migration snippets to mirror runtime path resolution. --- PLUGINS.md | 7 ++++--- README.md | 11 +++++++---- docs/configuration.md | 2 +- docs/keybindings.md | 5 +++-- docs/native-streaming.md | 13 +++++++------ docs/scripting.md | 5 +++-- docs/themes.md | 4 ++-- examples/plugins/README.md | 10 ++++++++-- examples/plugins/accent-cycler.lua | 5 ++++- examples/plugins/now-playing-webhook.lua | 5 ++++- examples/plugins/queue-browser.lua | 5 ++++- examples/plugins/session-stats/main.lua | 5 ++++- examples/plugins/track-info-popup.lua | 5 ++++- examples/plugins/track-notifier.lua | 5 ++++- 14 files changed, 59 insertions(+), 28 deletions(-) diff --git a/PLUGINS.md b/PLUGINS.md index 63196f3e..b5fc5990 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -18,9 +18,10 @@ spotatui plugin new # scaffold a new plugin to start from ``` Plugins are cloned into `plugins//` under the spotatui app config directory -(`$XDG_CONFIG_HOME/spotatui`, or `~/.config/spotatui` when `XDG_CONFIG_HOME` is -not set) and loaded at startup. Restart spotatui after installing, and bind any -commands the plugin registers under `plugin_commands` in `config.yml`. +(`$XDG_CONFIG_HOME/spotatui` when `XDG_CONFIG_HOME` is set to an absolute path, +or `~/.config/spotatui` when it is unset or not absolute) and loaded at startup. +Restart spotatui after installing, and bind any commands the plugin registers +under `plugin_commands` in `config.yml`. Plugins are not sandboxed and run with full app privileges and network access, so only install ones you trust. See [Trust and safety](docs/scripting.md#trust-and-safety). diff --git a/README.md b/README.md index 4f50fae7..673c42e8 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ See the [Native Streaming Wiki](https://github.com/LargeModGames/spotatui/wiki/N ## Configuration -The config file is at `$XDG_CONFIG_HOME/spotatui/config.yml`, falling back to `${HOME}/.config/spotatui/config.yml` when `XDG_CONFIG_HOME` is not set. You can also configure spotatui in-app by pressing `Alt-,` to open Settings. +The config file is at `$XDG_CONFIG_HOME/spotatui/config.yml` when `XDG_CONFIG_HOME` is set to an absolute path, falling back to `${HOME}/.config/spotatui/config.yml` when it is unset or not absolute. You can also configure spotatui in-app by pressing `Alt-,` to open Settings. Nearly everything is customizable: keybindings, themes, icons, playbar button labels, status-line and window-title format templates, table columns (reorder/rename/resize), default sorting per screen, startup screen, and layout (sidebar/playbar position). Invalid values fall back to defaults with a logged warning — a config typo never blocks startup. @@ -227,7 +227,7 @@ Nearly everything is customizable: keybindings, themes, icons, playbar button la - Full config reference: [Configuration Wiki](https://github.com/LargeModGames/spotatui/wiki/Configuration) - Built-in themes (Spotify, Dracula, Nord, …): [Themes Wiki](https://github.com/LargeModGames/spotatui/wiki/Themes) -spotatui also stores local listening history at `$XDG_STATE_HOME/spotatui/history/listens.jsonl`, falling back to `${HOME}/.local/state/spotatui/history/listens.jsonl` when `XDG_STATE_HOME` is not set. This powers `spotatui history recap`. Short or skipped plays are stored but excluded from recap totals. +spotatui also stores local listening history at `$XDG_STATE_HOME/spotatui/history/listens.jsonl` when `XDG_STATE_HOME` is set to an absolute path, falling back to `${HOME}/.local/state/spotatui/history/listens.jsonl` when it is unset or not absolute. This powers `spotatui history recap`. Short or skipped plays are stored but excluded from recap totals. ### Discord Rich Presence @@ -326,12 +326,15 @@ Follow the spotifyd documentation to get set up. After that: If you used the original `spotify-tui` before: - The binary name changed from `spt` to `spotatui`. -- Config paths changed: `~/.config/spotify-tui/` → `$XDG_CONFIG_HOME/spotatui/` or `~/.config/spotatui/` when `XDG_CONFIG_HOME` is not set. +- Config paths changed: `~/.config/spotify-tui/` -> `$XDG_CONFIG_HOME/spotatui/` when `XDG_CONFIG_HOME` is set to an absolute path, or `~/.config/spotatui/` when it is unset or not absolute. You can copy your existing config: ```bash -config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +case "${XDG_CONFIG_HOME:-}" in + /*) config_home="$XDG_CONFIG_HOME" ;; + *) config_home="$HOME/.config" ;; +esac mkdir -p "$config_home/spotatui" cp -r ~/.config/spotify-tui/* "$config_home/spotatui/" ``` diff --git a/docs/configuration.md b/docs/configuration.md index 27dca868..2c1965c1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -11,7 +11,7 @@ All fields are optional; omitted values use the built-in defaults. A complete, c Simple values (numbers, toggles, icons, positions) can also be changed live in the in-app **Settings** screen (see the hint in the top-right of the UI). Structured config — `format:` templates, `tables:` columns, and `playbar_control_labels` — is file-only. Edit the file while the app is closed: saving from the Settings screen rewrites the `behavior`, `theme`, and `keybindings` sections, but your `format:`, `tables:`, and `plugin_commands:` sections survive in-app saves untouched. -Machine-managed runtime state lives separately in `$XDG_STATE_HOME/spotatui/state.yml` (or `${HOME}/.local/state/spotatui/state.yml`). This includes volume, shuffle, active source, seen announcements, resized pane dimensions, and saved radio stations. The Spotify OAuth token cache and local listening history also live under the app state directory. Native streaming credentials and audio cache live under `$XDG_CACHE_HOME/spotatui/streaming_cache` (or `${HOME}/.cache/spotatui/streaming_cache`). `sync_token` remains user config and is stored in `config.yml`. +Machine-managed runtime state lives separately in `$XDG_STATE_HOME/spotatui/state.yml` when `XDG_STATE_HOME` is set to an absolute path, or `${HOME}/.local/state/spotatui/state.yml` when it is unset or not absolute. This includes volume, shuffle, active source, seen announcements, resized pane dimensions, and saved radio stations. The Spotify OAuth token cache and local listening history also live under the app state directory. Native streaming credentials and audio cache live under `$XDG_CACHE_HOME/spotatui/streaming_cache` when `XDG_CACHE_HOME` is set to an absolute path, or `${HOME}/.cache/spotatui/streaming_cache` when it is unset or not absolute. `sync_token` remains user config and is stored in `config.yml`. ## Safe by default diff --git a/docs/keybindings.md b/docs/keybindings.md index 043e87b0..764ab763 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -37,8 +37,9 @@ Press `?` in spotatui to see the help menu with all keybindings. ## Customizing Keybindings -Edit `config.yml` in the spotatui app config directory (`$XDG_CONFIG_HOME/spotatui`, -or `~/.config/spotatui` when `XDG_CONFIG_HOME` is not set): +Edit `config.yml` in the spotatui app config directory (`$XDG_CONFIG_HOME/spotatui` +when `XDG_CONFIG_HOME` is set to an absolute path, or `~/.config/spotatui` when +it is unset or not absolute): ```yaml keybindings: diff --git a/docs/native-streaming.md b/docs/native-streaming.md index 3ae1ec93..f3ff4857 100644 --- a/docs/native-streaming.md +++ b/docs/native-streaming.md @@ -27,14 +27,15 @@ streaming_bitrate: 320 # 96, 160, or 320 kbps ``` `client.yml` is in the spotatui app config directory (for example, -`$XDG_CONFIG_HOME/spotatui/client.yml`, or `~/.config/spotatui/client.yml` when -`XDG_CONFIG_HOME` is not set). This setting controls the librespot native player -directly; the Spotify app may still describe a Connect device's quality as -"Automatic". +`$XDG_CONFIG_HOME/spotatui/client.yml` when `XDG_CONFIG_HOME` is set to an +absolute path, or `~/.config/spotatui/client.yml` when it is unset or not +absolute). This setting controls the librespot native player directly; the +Spotify app may still describe a Connect device's quality as "Automatic". Native-streaming credentials and audio cache are stored in the app cache -directory, for example `$XDG_CACHE_HOME/spotatui/streaming_cache`, or -`~/.cache/spotatui/streaming_cache` when `XDG_CACHE_HOME` is not set. +directory, for example `$XDG_CACHE_HOME/spotatui/streaming_cache` when +`XDG_CACHE_HOME` is set to an absolute path, or +`~/.cache/spotatui/streaming_cache` when it is unset or not absolute. ## Notes diff --git a/docs/scripting.md b/docs/scripting.md index 6025d4b3..e3cfa89e 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -6,8 +6,9 @@ feature, which is enabled in the default build. ## File locations -Plugins are loaded from your app config directory (`$XDG_CONFIG_HOME/spotatui`, or -`~/.config/spotatui` when `XDG_CONFIG_HOME` is not set) at startup, in this order: +Plugins are loaded from your app config directory (`$XDG_CONFIG_HOME/spotatui` +when `XDG_CONFIG_HOME` is set to an absolute path, or `~/.config/spotatui` +when it is unset or not absolute) at startup, in this order: 1. `init.lua`, if present. 2. Single-file plugins: every `plugins/*.lua` file, sorted by filename. diff --git a/docs/themes.md b/docs/themes.md index 540cee02..74257aaf 100644 --- a/docs/themes.md +++ b/docs/themes.md @@ -22,8 +22,8 @@ spotatui comes with several built-in theme presets. Access them via `Alt-,` > Th ## Custom Themes You can create custom themes in `config.yml` in the spotatui app config directory -(`$XDG_CONFIG_HOME/spotatui`, or `~/.config/spotatui` when `XDG_CONFIG_HOME` is -not set): +(`$XDG_CONFIG_HOME/spotatui` when `XDG_CONFIG_HOME` is set to an absolute path, +or `~/.config/spotatui` when it is unset or not absolute): ```yaml theme: diff --git a/examples/plugins/README.md b/examples/plugins/README.md index f7b54c2a..2ae9125a 100644 --- a/examples/plugins/README.md +++ b/examples/plugins/README.md @@ -21,7 +21,10 @@ privileges, so read anything you install from elsewhere (see Single-file plugins go straight into `plugins/`: ```bash -config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +case "${XDG_CONFIG_HOME:-}" in + /*) config_home="$XDG_CONFIG_HOME" ;; + *) config_home="$HOME/.config" ;; +esac mkdir -p "$config_home/spotatui/plugins" cp track-notifier.lua "$config_home/spotatui/plugins/" ``` @@ -29,7 +32,10 @@ cp track-notifier.lua "$config_home/spotatui/plugins/" Directory plugins (a folder with a `main.lua` entry point) are copied as a whole: ```bash -config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +case "${XDG_CONFIG_HOME:-}" in + /*) config_home="$XDG_CONFIG_HOME" ;; + *) config_home="$HOME/.config" ;; +esac mkdir -p "$config_home/spotatui/plugins" cp -r session-stats "$config_home/spotatui/plugins/" ``` diff --git a/examples/plugins/accent-cycler.lua b/examples/plugins/accent-cycler.lua index 411c3cde..051e2e9d 100644 --- a/examples/plugins/accent-cycler.lua +++ b/examples/plugins/accent-cycler.lua @@ -3,7 +3,10 @@ -- Theme overrides from set_theme are runtime-only; they reset when spotatui restarts. -- -- Install (single file): --- config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +-- case "${XDG_CONFIG_HOME:-}" in +-- /*) config_home="$XDG_CONFIG_HOME" ;; +-- *) config_home="$HOME/.config" ;; +-- esac -- mkdir -p "$config_home/spotatui/plugins" -- cp accent-cycler.lua "$config_home/spotatui/plugins/" -- diff --git a/examples/plugins/now-playing-webhook.lua b/examples/plugins/now-playing-webhook.lua index e0160ffe..e94f44af 100644 --- a/examples/plugins/now-playing-webhook.lua +++ b/examples/plugins/now-playing-webhook.lua @@ -4,7 +4,10 @@ -- webhooks, home-automation triggers, or a "what am I listening to" endpoint. -- -- Install (single file): --- config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +-- case "${XDG_CONFIG_HOME:-}" in +-- /*) config_home="$XDG_CONFIG_HOME" ;; +-- *) config_home="$HOME/.config" ;; +-- esac -- mkdir -p "$config_home/spotatui/plugins" -- cp now-playing-webhook.lua "$config_home/spotatui/plugins/" -- Edit WEBHOOK_URL below first. diff --git a/examples/plugins/queue-browser.lua b/examples/plugins/queue-browser.lua index 7e6c7912..aed49302 100644 --- a/examples/plugins/queue-browser.lua +++ b/examples/plugins/queue-browser.lua @@ -3,7 +3,10 @@ -- async data reads, timers, storage, navigation). -- -- Install (single file): --- config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +-- case "${XDG_CONFIG_HOME:-}" in +-- /*) config_home="$XDG_CONFIG_HOME" ;; +-- *) config_home="$HOME/.config" ;; +-- esac -- mkdir -p "$config_home/spotatui/plugins" -- cp queue-browser.lua "$config_home/spotatui/plugins/" -- diff --git a/examples/plugins/session-stats/main.lua b/examples/plugins/session-stats/main.lua index 1b7911d2..3eba6e60 100644 --- a/examples/plugins/session-stats/main.lua +++ b/examples/plugins/session-stats/main.lua @@ -4,7 +4,10 @@ -- helper module (stats.lua). This is the layout `spotatui plugin add owner/repo` produces. -- -- Install (directory): --- config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +-- case "${XDG_CONFIG_HOME:-}" in +-- /*) config_home="$XDG_CONFIG_HOME" ;; +-- *) config_home="$HOME/.config" ;; +-- esac -- mkdir -p "$config_home/spotatui/plugins" -- cp -r session-stats "$config_home/spotatui/plugins/" -- diff --git a/examples/plugins/track-info-popup.lua b/examples/plugins/track-info-popup.lua index 56c24fa0..7d84eeeb 100644 --- a/examples/plugins/track-info-popup.lua +++ b/examples/plugins/track-info-popup.lua @@ -1,7 +1,10 @@ -- track-info-popup: a `track_info` command that opens a popup with the current track details. -- -- Install (single file): --- config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +-- case "${XDG_CONFIG_HOME:-}" in +-- /*) config_home="$XDG_CONFIG_HOME" ;; +-- *) config_home="$HOME/.config" ;; +-- esac -- mkdir -p "$config_home/spotatui/plugins" -- cp track-info-popup.lua "$config_home/spotatui/plugins/" -- diff --git a/examples/plugins/track-notifier.lua b/examples/plugins/track-notifier.lua index 49870def..9982d139 100644 --- a/examples/plugins/track-notifier.lua +++ b/examples/plugins/track-notifier.lua @@ -1,7 +1,10 @@ -- track-notifier: show a "Now playing" toast and a playbar segment on every track change. -- -- Install (single file): --- config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +-- case "${XDG_CONFIG_HOME:-}" in +-- /*) config_home="$XDG_CONFIG_HOME" ;; +-- *) config_home="$HOME/.config" ;; +-- esac -- mkdir -p "$config_home/spotatui/plugins" -- cp track-notifier.lua "$config_home/spotatui/plugins/" -- Then restart spotatui. From f454876e589aecf4ceaf7e489e2499915b5ecba7 Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Tue, 28 Jul 2026 22:48:31 +0930 Subject: [PATCH 12/23] fix: preserve runtime volume and layout state Persist decoded-backend volume changes through scheduled runtime state saves. Apply configured volume and layout defaults only when the corresponding runtime state fields are missing, so saved state.yml values remain authoritative after startup. Clamp config-derived playbar height during layout reset and document the initial-default behavior. --- docs/configuration.md | 10 +-- src/core/user_config.rs | 14 ++-- src/infra/local/dispatch.rs | 4 +- src/infra/queue/dispatch.rs | 4 +- src/infra/radio/dispatch.rs | 4 +- src/infra/subsonic/dispatch.rs | 4 +- src/infra/youtube/dispatch.rs | 4 +- src/runtime.rs | 122 ++++++++++++++++++++++++++++----- src/tui/handlers/resize.rs | 20 ++++-- 9 files changed, 149 insertions(+), 37 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 2c1965c1..3ee00970 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,7 +39,7 @@ behavior: # Volume volume_increment: 10 # step for + / - (0..=100, fatal if outside) - volume_percent: 100 # optional startup volume; omit to restore last runtime volume + volume_percent: 100 # initial volume default; saved runtime volume wins once present # Scrolling table_scroll_padding: 5 # rows kept visible below the selection before @@ -105,15 +105,15 @@ Stations saved from inside the app are stored in `state.yml`. The sidebar merges behavior: sidebar_position: left # left | right | hidden playbar_position: bottom # bottom | top - sidebar_width_percent: 20 # optional startup sidebar width; omit to restore last runtime size - library_height_percent: 30 # optional startup library height; omit to restore last runtime size - playbar_height_rows: 6 # optional startup playbar height; 0 hides it; omit to restore last runtime size + sidebar_width_percent: 20 # initial sidebar width default; saved runtime size wins once present + library_height_percent: 30 # initial library height default; saved runtime size wins once present + playbar_height_rows: 6 # initial playbar height default, 0..=50; 0 hides it; saved runtime size wins once present small_terminal_width: 150 small_terminal_height: 45 ``` - `sidebar_position: hidden` gives the content the full width, but the sidebar auto-reveals while the Library or Playlists panel has keyboard focus or is hovered, so it never becomes unreachable. -- `sidebar_width_percent`, `library_height_percent`, and `playbar_height_rows` are optional startup overrides. Runtime resize changes use `{` / `}` for sidebar width, `(` / `)` for playbar height, `(` / `)` while hovering Library or Playlists for the library/sidebar split, and `|` to reset sizes; those changes persist in `state.yml`. +- `sidebar_width_percent`, `library_height_percent`, and `playbar_height_rows` are configured initial defaults. Runtime resize changes use `{` / `}` for sidebar width, `(` / `)` for playbar height, `(` / `)` while hovering Library or Playlists for the library/sidebar split, and `|` to reset sizes; those changes persist in `state.yml` and are not overwritten by configured defaults after state exists. Configured `playbar_height_rows` is capped at 50 rows when applied at startup or when resetting the layout. - `small_terminal_width` / `small_terminal_height` are the responsive-layout breakpoints. At or above `small_terminal_width` columns the app uses the wide layout (search box inside the sidebar); below it the search box gets its own full-width top row. `enforce_wide_search_bar: true` forces the full-width search row regardless of width. - Unknown position strings fall back to the default with a warning. - Mouse hit-testing follows every arrangement automatically. diff --git a/src/core/user_config.rs b/src/core/user_config.rs index f6a1c191..434e6bd9 100644 --- a/src/core/user_config.rs +++ b/src/core/user_config.rs @@ -862,8 +862,9 @@ pub struct BehaviorConfigString { pub struct BehaviorConfig { pub seek_milliseconds: u32, pub volume_increment: u8, - /// Optional startup volume override. Runtime volume changes are persisted in - /// `state.yml`; this config value wins only when explicitly present. + /// User-configured initial volume default. It seeds `state.yml` only when + /// runtime state has no saved volume yet; later runtime volume changes stay + /// authoritative. pub volume_percent: Option, pub tick_rate_milliseconds: u64, pub animation_tick_rate_milliseconds: u64, @@ -948,8 +949,9 @@ pub struct BehaviorConfig { // --- Phase 6: layout arrangement --- pub sidebar_position: String, pub playbar_position: String, - /// Optional startup pane-size overrides. Runtime resize changes are persisted - /// in `state.yml`; these config values win only when explicitly present. + /// User-configured initial pane-size defaults. They seed `state.yml` only + /// when runtime state has no saved sizes yet; later runtime resize changes + /// stay authoritative. pub sidebar_width_percent: Option, pub playbar_height_rows: Option, pub library_height_percent: Option, @@ -2774,7 +2776,7 @@ mod tests { } #[test] - fn volume_percent_loads_as_optional_startup_override() { + fn volume_percent_loads_as_configured_runtime_default() { use super::{BehaviorConfigString, UserConfig}; let behavior: BehaviorConfigString = serde_yaml::from_str("volume_percent: 150\n").unwrap(); @@ -2789,7 +2791,7 @@ mod tests { } #[test] - fn pane_sizes_load_as_optional_startup_overrides() { + fn pane_sizes_load_as_configured_runtime_defaults() { use super::{BehaviorConfigString, UserConfig}; let behavior: BehaviorConfigString = serde_yaml::from_str( diff --git a/src/infra/local/dispatch.rs b/src/infra/local/dispatch.rs index 1d67cb54..cbb40286 100644 --- a/src/infra/local/dispatch.rs +++ b/src/infra/local/dispatch.rs @@ -109,7 +109,9 @@ pub async fn route_local_event(app: &Arc>, event: &IoEvent) -> bool { Some(player) => { player.set_volume(*volume as f32 / 100.0); // Keep the playbar's volume readout in sync. - app.lock().await.runtime_state.volume_percent = *volume; + let mut app = app.lock().await; + app.runtime_state.volume_percent = *volume; + app.schedule_state_save(); true } None => false, diff --git a/src/infra/queue/dispatch.rs b/src/infra/queue/dispatch.rs index 26374d3d..975df250 100644 --- a/src/infra/queue/dispatch.rs +++ b/src/infra/queue/dispatch.rs @@ -94,7 +94,9 @@ async fn route_queue_transport(app: &Arc>, event: &IoEvent) -> Option } IoEvent::ChangeVolume(volume) => { player.set_volume(*volume as f32 / 100.0); - app.lock().await.runtime_state.volume_percent = *volume; + let mut app = app.lock().await; + app.runtime_state.volume_percent = *volume; + app.schedule_state_save(); Some(true) } // Skip the queued track: advance to the next queued item (or resume). diff --git a/src/infra/radio/dispatch.rs b/src/infra/radio/dispatch.rs index f84f7e0c..e501ba09 100644 --- a/src/infra/radio/dispatch.rs +++ b/src/infra/radio/dispatch.rs @@ -89,7 +89,9 @@ pub async fn route_radio_event(app: &Arc>, event: &IoEvent) -> bool { IoEvent::ChangeVolume(volume) => match player(app).await { Some(p) => { p.set_volume(*volume as f32 / 100.0); - app.lock().await.runtime_state.volume_percent = *volume; + let mut app = app.lock().await; + app.runtime_state.volume_percent = *volume; + app.schedule_state_save(); true } None => false, diff --git a/src/infra/subsonic/dispatch.rs b/src/infra/subsonic/dispatch.rs index fa2967c9..604bc8b8 100644 --- a/src/infra/subsonic/dispatch.rs +++ b/src/infra/subsonic/dispatch.rs @@ -120,7 +120,9 @@ pub async fn route_subsonic_event(app: &Arc>, event: &IoEvent) -> boo IoEvent::ChangeVolume(volume) => match player(app).await { Some(p) => { p.set_volume(*volume as f32 / 100.0); - app.lock().await.runtime_state.volume_percent = *volume; + let mut app = app.lock().await; + app.runtime_state.volume_percent = *volume; + app.schedule_state_save(); true } None => false, diff --git a/src/infra/youtube/dispatch.rs b/src/infra/youtube/dispatch.rs index 377f9e81..fd426854 100644 --- a/src/infra/youtube/dispatch.rs +++ b/src/infra/youtube/dispatch.rs @@ -122,7 +122,9 @@ pub async fn route_youtube_event(app: &Arc>, event: &IoEvent) -> bool IoEvent::ChangeVolume(volume) => match player(app).await { Some(p) => { p.set_volume(*volume as f32 / 100.0); - app.lock().await.runtime_state.volume_percent = *volume; + let mut app = app.lock().await; + app.runtime_state.volume_percent = *volume; + app.schedule_state_save(); true } None => false, diff --git a/src/runtime.rs b/src/runtime.rs index 3cdb9e76..89f1ae8b 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -29,8 +29,9 @@ use crate::cli; use crate::core::app::App; use crate::core::auth; use crate::core::config::ClientConfig; +use crate::core::state::{PersistedRuntimeState, RuntimeState}; use crate::core::user_config::{ - validate_tick_rate_milliseconds, StartupBehavior, UserConfig, UserConfigPaths, + validate_tick_rate_milliseconds, BehaviorConfig, StartupBehavior, UserConfig, UserConfigPaths, }; #[cfg(feature = "discord-rpc")] use crate::infra::discord_rpc; @@ -44,6 +45,7 @@ use crate::infra::network::{IoEvent, Network}; #[cfg(feature = "streaming")] use crate::infra::player; use crate::tui::banner::BANNER; +use crate::tui::handlers::resize::MAX_PLAYBAR_ROWS; use anyhow::{anyhow, Result}; use backtrace::Backtrace; @@ -976,6 +978,41 @@ fn prompt_global_song_count_opt_in(user_config: &mut UserConfig) -> Result<()> { Ok(()) } +fn apply_configured_runtime_defaults( + runtime_state: &mut RuntimeState, + persisted_state: &PersistedRuntimeState, + behavior: &BehaviorConfig, +) -> bool { + let mut applied_default = false; + + if persisted_state.volume_percent.is_none() { + if let Some(volume_percent) = behavior.volume_percent { + runtime_state.volume_percent = volume_percent.min(100); + applied_default = true; + } + } + if persisted_state.sidebar_width_percent.is_none() { + if let Some(sidebar_width_percent) = behavior.sidebar_width_percent { + runtime_state.sidebar_width_percent = sidebar_width_percent.min(100); + applied_default = true; + } + } + if persisted_state.playbar_height_rows.is_none() { + if let Some(playbar_height_rows) = behavior.playbar_height_rows { + runtime_state.playbar_height_rows = playbar_height_rows.min(MAX_PLAYBAR_ROWS); + applied_default = true; + } + } + if persisted_state.library_height_percent.is_none() { + if let Some(library_height_percent) = behavior.library_height_percent { + runtime_state.library_height_percent = library_height_percent.min(100); + applied_default = true; + } + } + + applied_default +} + pub async fn run() -> Result<()> { setup_logging()?; info!("spotatui {} starting up", env!("CARGO_PKG_VERSION")); @@ -1085,18 +1122,22 @@ screens more often and cost more CPU. Animation-heavy views keep their separate user_config.path_to_config.replace(path); } user_config.load_config()?; - let mut runtime_state = crate::core::state::RuntimeState::default(); + let mut runtime_state = RuntimeState::default(); + let mut persisted_state = PersistedRuntimeState::default(); let mut state_path = None; let mut should_save_initial_state = false; + let mut can_save_initial_state = false; match crate::core::state::default_state_path() { Ok(path) => { let state_file_exists = path.exists(); match crate::core::state::load(&path) { Ok(state) => { - runtime_state.apply_persisted(&state); + persisted_state = state; + runtime_state.apply_persisted(&persisted_state); if !state_file_exists { should_save_initial_state = true; } + can_save_initial_state = true; state_path = Some(path); } Err(e) => { @@ -1108,17 +1149,10 @@ screens more often and cost more CPU. Animation-heavy views keep their separate log::warn!("[state] runtime state path is unavailable: {e}"); } } - if let Some(startup_volume_percent) = user_config.behavior.volume_percent { - runtime_state.volume_percent = startup_volume_percent.min(100); - } - if let Some(sidebar_width_percent) = user_config.behavior.sidebar_width_percent { - runtime_state.sidebar_width_percent = sidebar_width_percent.min(100); - } - if let Some(playbar_height_rows) = user_config.behavior.playbar_height_rows { - runtime_state.playbar_height_rows = playbar_height_rows; - } - if let Some(library_height_percent) = user_config.behavior.library_height_percent { - runtime_state.library_height_percent = library_height_percent.min(100); + if apply_configured_runtime_defaults(&mut runtime_state, &persisted_state, &user_config.behavior) + && can_save_initial_state + { + should_save_initial_state = true; } if should_save_initial_state { if let Some(path) = &state_path { @@ -2722,14 +2756,70 @@ async fn route_decoded_windows_event( #[cfg(test)] mod tests { - use super::{startup_device_decision, StartupDeviceEvent}; - use crate::core::user_config::StartupBehavior; + use super::{apply_configured_runtime_defaults, startup_device_decision, StartupDeviceEvent}; + use crate::core::state::{PersistedRuntimeState, RuntimeState}; + use crate::core::user_config::{StartupBehavior, UserConfig}; + use crate::tui::handlers::resize::MAX_PLAYBAR_ROWS; use rspotify::model::{device::Device, DeviceType}; const NATIVE_NAME: &str = "spotatui"; const NATIVE_ID: &str = "native-device"; const EXTERNAL_ID: &str = "phone-device"; + fn user_config_with_runtime_defaults() -> UserConfig { + let mut config = UserConfig::new(); + config.behavior.volume_percent = Some(80); + config.behavior.sidebar_width_percent = Some(40); + config.behavior.playbar_height_rows = Some(MAX_PLAYBAR_ROWS + 1); + config.behavior.library_height_percent = Some(60); + config + } + + #[test] + fn configured_runtime_defaults_do_not_override_persisted_state() { + let config = user_config_with_runtime_defaults(); + let persisted = PersistedRuntimeState { + volume_percent: Some(42), + sidebar_width_percent: Some(25), + playbar_height_rows: Some(5), + library_height_percent: Some(35), + ..Default::default() + }; + let mut runtime = RuntimeState::default(); + runtime.apply_persisted(&persisted); + + assert!(!apply_configured_runtime_defaults( + &mut runtime, + &persisted, + &config.behavior, + )); + assert_eq!(runtime.volume_percent, 42); + assert_eq!(runtime.sidebar_width_percent, 25); + assert_eq!(runtime.playbar_height_rows, 5); + assert_eq!(runtime.library_height_percent, 35); + } + + #[test] + fn configured_runtime_defaults_seed_only_missing_persisted_fields() { + let config = user_config_with_runtime_defaults(); + let persisted = PersistedRuntimeState { + volume_percent: Some(42), + ..Default::default() + }; + let mut runtime = RuntimeState::default(); + runtime.apply_persisted(&persisted); + + assert!(apply_configured_runtime_defaults( + &mut runtime, + &persisted, + &config.behavior, + )); + assert_eq!(runtime.volume_percent, 42); + assert_eq!(runtime.sidebar_width_percent, 40); + assert_eq!(runtime.playbar_height_rows, MAX_PLAYBAR_ROWS); + assert_eq!(runtime.library_height_percent, 60); + } + #[allow(deprecated)] fn device(id: &str, name: &str) -> Device { Device { diff --git a/src/tui/handlers/resize.rs b/src/tui/handlers/resize.rs index fc809d17..0d788d39 100644 --- a/src/tui/handlers/resize.rs +++ b/src/tui/handlers/resize.rs @@ -2,9 +2,9 @@ use crate::core::app::App; use crate::core::state::RuntimeState; const SIDEBAR_STEP: u8 = 5; -const MAX_PLAYBAR_ROWS: u16 = 50; const PLAYBAR_STEP: u16 = 1; const LIBRARY_STEP: u8 = 5; +pub(crate) const MAX_PLAYBAR_ROWS: u16 = 50; /// Decrease sidebar width by SIDEBAR_STEP percent (minimum 0%). pub fn decrease_sidebar_width(app: &mut App) { @@ -63,7 +63,7 @@ pub fn increase_library_height(app: &mut App) { app.schedule_state_save(); } -/// Reset all pane sizes to explicit config startup overrides, or runtime defaults. +/// Reset all pane sizes to configured defaults, or runtime defaults. pub fn reset_layout(app: &mut App) { let defaults = RuntimeState::default(); app.runtime_state.sidebar_width_percent = app @@ -76,7 +76,8 @@ pub fn reset_layout(app: &mut App) { .user_config .behavior .playbar_height_rows - .unwrap_or(defaults.playbar_height_rows); + .unwrap_or(defaults.playbar_height_rows) + .min(MAX_PLAYBAR_ROWS); app.runtime_state.library_height_percent = app .user_config .behavior @@ -195,7 +196,7 @@ mod tests { } #[test] - fn reset_layout_restores_runtime_defaults_without_config_overrides() { + fn reset_layout_restores_runtime_defaults_without_configured_defaults() { let mut app = App::default(); app.runtime_state.sidebar_width_percent = 50; app.runtime_state.playbar_height_rows = 0; @@ -217,7 +218,7 @@ mod tests { } #[test] - fn reset_layout_prefers_config_startup_overrides() { + fn reset_layout_prefers_configured_defaults() { let mut app = App::default(); app.runtime_state.sidebar_width_percent = 50; app.runtime_state.playbar_height_rows = 0; @@ -230,4 +231,13 @@ mod tests { assert_eq!(app.runtime_state.playbar_height_rows, 9); assert_eq!(app.runtime_state.library_height_percent, 45); } + + #[test] + fn reset_layout_clamps_configured_playbar_height_to_max() { + let mut app = App::default(); + app.runtime_state.playbar_height_rows = 0; + app.user_config.behavior.playbar_height_rows = Some(MAX_PLAYBAR_ROWS + 1); + reset_layout(&mut app); + assert_eq!(app.runtime_state.playbar_height_rows, MAX_PLAYBAR_ROWS); + } } From 09a82c7a148f142581cec033824daecb58889894 Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Tue, 28 Jul 2026 23:07:07 +0930 Subject: [PATCH 13/23] test: cover runtime-backed plugin config snapshot Add regression coverage for config_snapshot and the Lua-facing spotatui.config() path to ensure migrated behavior fields come from RuntimeState without mutating UserConfig. Share radio station sanitization between runtime state and user config. --- src/core/plugin_api.rs | 36 ++++++++++++++++++++++++++++++ src/core/state.rs | 6 ++++- src/core/user_config.rs | 23 ++----------------- src/infra/scripting/tests.rs | 43 ++++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 22 deletions(-) diff --git a/src/core/plugin_api.rs b/src/core/plugin_api.rs index 29079570..bc4da086 100644 --- a/src/core/plugin_api.rs +++ b/src/core/plugin_api.rs @@ -822,6 +822,7 @@ pub fn lyrics_snapshot(app: &App) -> LyricsSnapshot { #[cfg(test)] mod tests { use super::*; + use crate::core::{source::Source, state::RuntimeState, user_config::UserConfig}; use chrono::Utc; use rspotify::model::{ context::{Actions, CurrentPlaybackContext}, @@ -969,6 +970,41 @@ mod tests { assert_eq!(dev.kind, "tv"); } + // --- config_snapshot --- + + #[test] + fn config_snapshot_uses_runtime_state_for_migrated_fields_without_mutating_config() { + let mut config = UserConfig::new(); + config.behavior.volume_percent = Some(10); + config.behavior.sidebar_width_percent = Some(20); + config.behavior.playbar_height_rows = Some(6); + config.behavior.library_height_percent = Some(30); + config.behavior.stop_after_current_track = true; + + let runtime_state = RuntimeState { + shuffle_enabled: true, + sidebar_width_percent: 42, + playbar_height_rows: 12, + library_height_percent: 64, + active_source: Source::Radio, + ..RuntimeState::default() + }; + + let snapshot = config_snapshot(&config, &runtime_state); + + assert!(snapshot.behavior.shuffle_enabled); + assert_eq!(snapshot.behavior.sidebar_width_percent, 42); + assert_eq!(snapshot.behavior.playbar_height_rows, 12); + assert_eq!(snapshot.behavior.library_height_percent, 64); + assert_eq!(snapshot.behavior.active_source, "radio"); + assert!(snapshot.behavior.stop_after_current_track); + + assert_eq!(config.behavior.volume_percent, Some(10)); + assert_eq!(config.behavior.sidebar_width_percent, Some(20)); + assert_eq!(config.behavior.playbar_height_rows, Some(6)); + assert_eq!(config.behavior.library_height_percent, Some(30)); + } + // --- PlaylistInfo::from_simplified --- #[test] diff --git a/src/core/state.rs b/src/core/state.rs index 9fec6d16..4ff4e5d4 100644 --- a/src/core/state.rs +++ b/src/core/state.rs @@ -252,7 +252,7 @@ fn sanitized_ids(ids: &[String]) -> Vec { .collect() } -fn sanitized_radio_stations(stations: &[RadioStationConfig]) -> Vec { +pub(crate) fn sanitized_radio_stations(stations: &[RadioStationConfig]) -> Vec { stations .iter() .filter_map(|station| { @@ -360,6 +360,10 @@ mod tests { name: " Good ".to_string(), url: " https://example.test ".to_string(), }, + RadioStationConfig { + name: "Duplicate".to_string(), + url: "https://example.test".to_string(), + }, RadioStationConfig { name: "Broken".to_string(), url: " ".to_string(), diff --git a/src/core/user_config.rs b/src/core/user_config.rs index 434e6bd9..5a8c2dcb 100644 --- a/src/core/user_config.rs +++ b/src/core/user_config.rs @@ -1,5 +1,5 @@ use crate::core::format::Template; -use crate::core::state::RadioStationConfig; +use crate::core::state::{sanitized_radio_stations, RadioStationConfig}; use crate::tui::event::Key; use anyhow::{anyhow, Result}; use ratatui::style::{Color, Style}; @@ -44,25 +44,6 @@ pub fn normalize_tick_rate_milliseconds(value: i64) -> u64 { value.clamp(1, MAX_TICK_RATE_MILLISECONDS as i64) as u64 } -fn sanitized_radio_stations(stations: Vec) -> Vec { - let mut sanitized = Vec::new(); - for station in stations { - let name = station.name.trim(); - let url = station.url.trim(); - if name.is_empty() - || url.is_empty() - || sanitized.iter().any(|s: &RadioStationConfig| s.url == url) - { - continue; - } - sanitized.push(RadioStationConfig { - name: name.to_string(), - url: url.to_string(), - }); - } - sanitized -} - /// Parse a human-readable update delay into seconds. /// Accepts: "0", "30s", "10m", "2h", "7d", or a bare second count. pub fn parse_update_delay_secs(value: &str) -> Result { @@ -1568,7 +1549,7 @@ impl UserConfig { } if let Some(radio_stations) = behavior_config.radio_stations { - self.behavior.radio_stations = sanitized_radio_stations(radio_stations); + self.behavior.radio_stations = sanitized_radio_stations(&radio_stations); } if let Some(sidebar_width_percent) = behavior_config.sidebar_width_percent { diff --git a/src/infra/scripting/tests.rs b/src/infra/scripting/tests.rs index b98383ef..27eabe03 100644 --- a/src/infra/scripting/tests.rs +++ b/src/infra/scripting/tests.rs @@ -3251,6 +3251,7 @@ mod screen_tests { mod config_tests { use super::*; use crate::core::app::App; + use crate::core::source::Source; use crate::core::user_config::UserConfig; use crate::infra::network::IoEvent; use std::sync::mpsc::channel; @@ -3285,6 +3286,48 @@ mod config_tests { } } + #[test] + fn config_exposes_runtime_backed_behavior_scalars() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, _rx) = make_app(); + app.user_config.behavior.sidebar_width_percent = Some(20); + app.user_config.behavior.playbar_height_rows = Some(6); + app.user_config.behavior.library_height_percent = Some(30); + app.runtime_state.shuffle_enabled = true; + app.runtime_state.sidebar_width_percent = 42; + app.runtime_state.playbar_height_rows = 12; + app.runtime_state.library_height_percent = 64; + app.runtime_state.active_source = Source::Radio; + engine.on_tick(&mut app); + + engine + .load_source( + "cfg", + r#" + local c = spotatui.config() + spotatui.notify( + table.concat({ + tostring(c.behavior.shuffle_enabled), + tostring(c.behavior.sidebar_width_percent), + tostring(c.behavior.playbar_height_rows), + tostring(c.behavior.library_height_percent), + c.behavior.active_source + }, ":"), + 1 + ) + "#, + ) + .unwrap(); + + match one(&engine) { + ScriptEffect::Notify(msg, 1) => assert_eq!(msg, "true:42:12:64:radio"), + _ => panic!("expected runtime-backed config notify"), + } + assert_eq!(app.user_config.behavior.sidebar_width_percent, Some(20)); + assert_eq!(app.user_config.behavior.playbar_height_rows, Some(6)); + assert_eq!(app.user_config.behavior.library_height_percent, Some(30)); + } + #[test] fn config_excludes_secrets() { let mut engine = ScriptEngine::new().unwrap(); From 81168decfcfbba366dad14631b8f1e805b830c53 Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Tue, 28 Jul 2026 23:19:45 +0930 Subject: [PATCH 14/23] fix: dedupe shared radio station sanitization --- src/core/state.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/core/state.rs b/src/core/state.rs index 4ff4e5d4..058943ac 100644 --- a/src/core/state.rs +++ b/src/core/state.rs @@ -253,19 +253,26 @@ fn sanitized_ids(ids: &[String]) -> Vec { } pub(crate) fn sanitized_radio_stations(stations: &[RadioStationConfig]) -> Vec { + let mut seen_urls = std::collections::HashSet::new(); + stations .iter() .filter_map(|station| { let name = station.name.trim(); let url = station.url.trim(); if name.is_empty() || url.is_empty() { - None - } else { - Some(RadioStationConfig { - name: name.to_string(), - url: url.to_string(), - }) + return None; } + + let url = url.to_string(); + if !seen_urls.insert(url.clone()) { + return None; + } + + Some(RadioStationConfig { + name: name.to_string(), + url, + }) }) .collect() } From 0d403d9212aec6f8698313f9edbf678ee11c84b3 Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Thu, 30 Jul 2026 13:05:27 +0930 Subject: [PATCH 15/23] chore: reuse state radio station sanitizer in dispatch --- src/infra/radio/dispatch.rs | 36 +++++++++++++----------------------- 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/src/infra/radio/dispatch.rs b/src/infra/radio/dispatch.rs index e501ba09..d789c365 100644 --- a/src/infra/radio/dispatch.rs +++ b/src/infra/radio/dispatch.rs @@ -34,7 +34,7 @@ use crate::core::app::{App, SearchResultBlock}; use crate::core::pagination::Paged; use crate::core::plugin_api::TrackInfo; use crate::core::source::Searcher; -use crate::core::state::RadioStationConfig; +use crate::core::state::{sanitized_radio_stations, RadioStationConfig}; use crate::infra::audio::LocalPlayer; use crate::infra::network::IoEvent; @@ -117,10 +117,18 @@ pub async fn route_radio_event(app: &Arc>, event: &IoEvent) -> bool { /// Stations panel). No network. async fn load_radio_stations(app: &Arc>) { let mut guard = app.lock().await; - let stations = merged_radio_stations( - &guard.user_config.behavior.radio_stations, - &guard.runtime_state.radio_stations, - ); + let merged_stations: Vec = guard + .user_config + .behavior + .radio_stations + .iter() + .chain(guard.runtime_state.radio_stations.iter()) + .cloned() + .collect(); + let stations: Vec = sanitized_radio_stations(&merged_stations) + .into_iter() + .map(|station| config_station_to_track_info(&station.name, &station.url)) + .collect(); let empty = stations.is_empty(); guard.radio_stations = stations; guard.radio_stations_index = 0; @@ -132,24 +140,6 @@ async fn load_radio_stations(app: &Arc>) { } } -fn merged_radio_stations( - configured: &[RadioStationConfig], - saved: &[RadioStationConfig], -) -> Vec { - let mut stations = Vec::new(); - let mut seen_urls: Vec = Vec::new(); - for station in configured.iter().chain(saved.iter()) { - let name = station.name.trim(); - let url = station.url.trim(); - if name.is_empty() || url.is_empty() || seen_urls.iter().any(|seen| seen == url) { - continue; - } - seen_urls.push(url.to_string()); - stations.push(config_station_to_track_info(name, url)); - } - stations -} - /// Search the radio-browser.info directory and populate `app.search_results`. /// /// Like the Subsonic search, only the songs block is populated — each row is a From 26a0d5f2ac658c361e899b526f965661e3dcd508 Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Thu, 30 Jul 2026 13:32:59 +0930 Subject: [PATCH 16/23] chore: use platform-neutral fallback path joins --- src/core/paths.rs | 111 +++++++++++++++++++++++++--------------------- 1 file changed, 61 insertions(+), 50 deletions(-) diff --git a/src/core/paths.rs b/src/core/paths.rs index e7f7c6b0..c52315fb 100644 --- a/src/core/paths.rs +++ b/src/core/paths.rs @@ -1,10 +1,10 @@ use std::{ffi::OsString, path::PathBuf}; const APP_DIR: &str = "spotatui"; -const FALLBACK_CONFIG_DIR: &str = ".config"; +const FALLBACK_CONFIG_DIR: [&str; 1] = [".config"]; #[cfg(any(feature = "streaming", test))] -const FALLBACK_CACHE_DIR: &str = ".cache"; -const FALLBACK_STATE_DIR: &str = ".local/state"; +const FALLBACK_CACHE_DIR: [&str; 1] = [".cache"]; +const FALLBACK_STATE_DIR: [&str; 2] = [".local", "state"]; #[cfg(feature = "streaming")] const XDG_CACHE_HOME_ENV: &str = "XDG_CACHE_HOME"; const XDG_CONFIG_HOME_ENV: &str = "XDG_CONFIG_HOME"; @@ -18,7 +18,7 @@ pub(crate) fn app_config_dir() -> Option { app_dir_from( std::env::var_os(XDG_CONFIG_HOME_ENV), dirs::home_dir(), - FALLBACK_CONFIG_DIR, + &FALLBACK_CONFIG_DIR, ) } @@ -31,7 +31,7 @@ pub(crate) fn app_cache_dir() -> Option { app_dir_from( std::env::var_os(XDG_CACHE_HOME_ENV), dirs::home_dir(), - FALLBACK_CACHE_DIR, + &FALLBACK_CACHE_DIR, ) } @@ -43,18 +43,24 @@ pub(crate) fn app_state_dir() -> Option { app_dir_from( std::env::var_os(XDG_STATE_HOME_ENV), dirs::home_dir(), - FALLBACK_STATE_DIR, + &FALLBACK_STATE_DIR, ) } fn app_dir_from( xdg_home: Option, home: Option, - fallback_dir: &str, + fallback_dir: &[&str], ) -> Option { xdg_home .and_then(valid_xdg_home) - .or_else(|| home.map(|home| home.join(fallback_dir))) + .or_else(|| { + home.map(|home| { + fallback_dir + .iter() + .fold(home, |home, component| home.join(component)) + }) + }) .map(|dir| dir.join(APP_DIR)) } @@ -74,87 +80,92 @@ fn valid_xdg_home(value: OsString) -> Option { #[cfg(test)] mod tests { use super::*; - use std::path::Path; + + fn test_home() -> PathBuf { + std::env::temp_dir().join("spotatui-home-alice") + } + + fn test_xdg_home(name: &str) -> PathBuf { + std::env::temp_dir().join(name) + } #[test] fn app_config_dir_uses_absolute_xdg_config_home() { + let xdg_home = test_xdg_home("xdg-config"); let path = app_dir_from( - Some(OsString::from("/tmp/xdg-config")), - Some(Path::new("/home/alice").to_path_buf()), - FALLBACK_CONFIG_DIR, + Some(xdg_home.clone().into_os_string()), + Some(test_home()), + &FALLBACK_CONFIG_DIR, ); - assert_eq!( - path, - Some(Path::new("/tmp/xdg-config/spotatui").to_path_buf()) - ); + assert_eq!(path, Some(xdg_home.join(APP_DIR))); } #[test] fn app_config_dir_falls_back_to_home_when_xdg_is_unset() { - let path = app_dir_from( - None, - Some(Path::new("/home/alice").to_path_buf()), - FALLBACK_CONFIG_DIR, - ); + let home = test_home(); + let path = app_dir_from(None, Some(home.clone()), &FALLBACK_CONFIG_DIR); - assert_eq!( - path, - Some(Path::new("/home/alice/.config/spotatui").to_path_buf()) - ); + assert_eq!(path, Some(home.join(".config").join(APP_DIR))); } #[test] fn app_config_dir_ignores_empty_or_relative_xdg_config_home() { - let home = Some(Path::new("/home/alice").to_path_buf()); + let home = test_home(); assert_eq!( - app_dir_from(Some(OsString::new()), home.clone(), FALLBACK_CONFIG_DIR), - Some(Path::new("/home/alice/.config/spotatui").to_path_buf()) + app_dir_from( + Some(OsString::new()), + Some(home.clone()), + &FALLBACK_CONFIG_DIR + ), + Some(home.clone().join(".config").join(APP_DIR)) ); assert_eq!( - app_dir_from(Some(OsString::from("relative")), home, FALLBACK_CONFIG_DIR), - Some(Path::new("/home/alice/.config/spotatui").to_path_buf()) + app_dir_from( + Some(OsString::from("relative")), + Some(home.clone()), + &FALLBACK_CONFIG_DIR + ), + Some(home.join(".config").join(APP_DIR)) ); } #[test] fn app_cache_dir_uses_xdg_cache_home_or_home_fallback() { + let xdg_home = test_xdg_home("xdg-cache"); assert_eq!( app_dir_from( - Some(OsString::from("/tmp/xdg-cache")), - Some(Path::new("/home/alice").to_path_buf()), - FALLBACK_CACHE_DIR, + Some(xdg_home.clone().into_os_string()), + Some(test_home()), + &FALLBACK_CACHE_DIR, ), - Some(Path::new("/tmp/xdg-cache/spotatui").to_path_buf()) + Some(xdg_home.join(APP_DIR)) ); + + let home = test_home(); assert_eq!( - app_dir_from( - None, - Some(Path::new("/home/alice").to_path_buf()), - FALLBACK_CACHE_DIR, - ), - Some(Path::new("/home/alice/.cache/spotatui").to_path_buf()) + app_dir_from(None, Some(home.clone()), &FALLBACK_CACHE_DIR,), + Some(home.join(".cache").join(APP_DIR)) ); } #[test] fn app_state_dir_uses_xdg_state_home_or_home_fallback() { + let xdg_home = test_xdg_home("xdg-state"); assert_eq!( app_dir_from( - Some(OsString::from("/tmp/xdg-state")), - Some(Path::new("/home/alice").to_path_buf()), - FALLBACK_STATE_DIR, + Some(xdg_home.clone().into_os_string()), + Some(test_home()), + &FALLBACK_STATE_DIR, ), - Some(Path::new("/tmp/xdg-state/spotatui").to_path_buf()) + Some(xdg_home.join(APP_DIR)) ); + + let home = test_home(); assert_eq!( - app_dir_from( - None, - Some(Path::new("/home/alice").to_path_buf()), - FALLBACK_STATE_DIR, - ), - Some(Path::new("/home/alice/.local/state/spotatui").to_path_buf()) + app_dir_from(None, Some(home.clone()), &FALLBACK_STATE_DIR,), + Some(home.join(".local").join("state").join(APP_DIR)) ); } } From a102b38da2fed0cdbb6f7f74479686dc30d0592e Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Thu, 30 Jul 2026 14:38:29 +0930 Subject: [PATCH 17/23] fix: create state dir with private permissions before token cache --- src/core/config.rs | 2 +- src/core/state.rs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/config.rs b/src/core/config.rs index 6a16aeae..0ad5611f 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -85,7 +85,7 @@ impl ClientConfig { ) { (Some(app_config_dir), Some(app_state_dir)) => { fs::create_dir_all(&app_config_dir)?; - fs::create_dir_all(&app_state_dir)?; + crate::core::state::ensure_private_state_dir(&app_state_dir)?; // Create .gitignore to protect sensitive config from being committed. let gitignore_path = app_config_dir.join(GITIGNORE_FILE); diff --git a/src/core/state.rs b/src/core/state.rs index 058943ac..eb929f04 100644 --- a/src/core/state.rs +++ b/src/core/state.rs @@ -231,7 +231,8 @@ pub fn save(path: &Path, state: &PersistedRuntimeState) -> Result<()> { Ok(()) } -fn ensure_private_state_dir(dir: &Path) -> Result<()> { +/// Ensure a state directory exists and is owner-only where supported. +pub(crate) fn ensure_private_state_dir(dir: &Path) -> Result<()> { std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; #[cfg(unix)] From bee9afdc1dcd6075904c6037f24829683aa38418 Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Thu, 30 Jul 2026 15:49:42 +0930 Subject: [PATCH 18/23] fix: create private dirs for token and streaming credential caches --- src/core/config.rs | 2 +- src/core/paths.rs | 21 ++++++++++++++++++++- src/core/state.rs | 16 +--------------- src/infra/player/streaming.rs | 8 ++++---- 4 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/core/config.rs b/src/core/config.rs index 0ad5611f..362a8678 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -85,7 +85,7 @@ impl ClientConfig { ) { (Some(app_config_dir), Some(app_state_dir)) => { fs::create_dir_all(&app_config_dir)?; - crate::core::state::ensure_private_state_dir(&app_state_dir)?; + crate::core::paths::ensure_private_dir(&app_state_dir)?; // Create .gitignore to protect sensitive config from being committed. let gitignore_path = app_config_dir.join(GITIGNORE_FILE); diff --git a/src/core/paths.rs b/src/core/paths.rs index c52315fb..30a5be3b 100644 --- a/src/core/paths.rs +++ b/src/core/paths.rs @@ -1,4 +1,8 @@ -use std::{ffi::OsString, path::PathBuf}; +use anyhow::{Context, Result}; +use std::{ + ffi::OsString, + path::{Path, PathBuf}, +}; const APP_DIR: &str = "spotatui"; const FALLBACK_CONFIG_DIR: [&str; 1] = [".config"]; @@ -47,6 +51,21 @@ pub(crate) fn app_state_dir() -> Option { ) } +/// Ensure a directory that stores credentials or other private app data exists +/// and is owner-only where supported. +pub(crate) fn ensure_private_dir(dir: &Path) -> Result<()> { + std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) + .with_context(|| format!("setting private permissions on {}", dir.display()))?; + } + + Ok(()) +} + fn app_dir_from( xdg_home: Option, home: Option, diff --git a/src/core/state.rs b/src/core/state.rs index eb929f04..570e45a9 100644 --- a/src/core/state.rs +++ b/src/core/state.rs @@ -222,7 +222,7 @@ pub fn load(path: &Path) -> Result { pub fn save(path: &Path, state: &PersistedRuntimeState) -> Result<()> { let yaml = serde_yaml::to_string(state).context("serializing runtime state")?; if let Some(dir) = path.parent() { - ensure_private_state_dir(dir)?; + crate::core::paths::ensure_private_dir(dir)?; } let tmp = path.with_extension("yml.tmp"); crate::core::auth::write_private_file(&tmp, yaml.as_bytes()) @@ -231,20 +231,6 @@ pub fn save(path: &Path, state: &PersistedRuntimeState) -> Result<()> { Ok(()) } -/// Ensure a state directory exists and is owner-only where supported. -pub(crate) fn ensure_private_state_dir(dir: &Path) -> Result<()> { - std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) - .with_context(|| format!("setting private permissions on {}", dir.display()))?; - } - - Ok(()) -} - fn sanitized_ids(ids: &[String]) -> Vec { ids .iter() diff --git a/src/infra/player/streaming.rs b/src/infra/player/streaming.rs index a48c0bb9..9eacdb55 100644 --- a/src/infra/player/streaming.rs +++ b/src/infra/player/streaming.rs @@ -577,7 +577,7 @@ fn request_streaming_oauth_credentials() -> Result { pub fn ensure_streaming_credentials_cached() -> Result<()> { let cache_path = get_default_cache_path(); if let Some(path) = cache_path.as_ref() { - std::fs::create_dir_all(path)?; + crate::core::paths::ensure_private_dir(path)?; } let cache = Cache::new(cache_path, None, None, None)?; if cache.credentials().is_none() { @@ -591,7 +591,7 @@ pub fn ensure_streaming_credentials_cached() -> Result<()> { pub fn streaming_credentials_are_cached() -> Result { let cache_path = get_default_cache_path(); if let Some(path) = cache_path.as_ref() { - std::fs::create_dir_all(path)?; + crate::core::paths::ensure_private_dir(path)?; } Ok( Cache::new(cache_path, None, None, None)? @@ -756,7 +756,7 @@ impl StreamingPlayer { // Ensure cache directories exist if let Some(ref path) = cache_path { - std::fs::create_dir_all(path).ok(); + crate::core::paths::ensure_private_dir(path).ok(); } if let Some(ref path) = audio_cache_path { std::fs::create_dir_all(path).ok(); @@ -1288,7 +1288,7 @@ fn get_or_create_device_id(cache_path: Option<&std::path::Path>) -> Option Date: Fri, 31 Jul 2026 12:44:44 +0930 Subject: [PATCH 19/23] fix: merge runtime state saves without clobbering fields --- src/core/app.rs | 54 ++-- src/core/first_run.rs | 5 +- src/core/layout.rs | 1 + src/core/state.rs | 362 +++++++++++++++++++++++- src/infra/local/dispatch.rs | 4 +- src/infra/network/native_shuffle.rs | 4 +- src/infra/network/playback.rs | 34 ++- src/infra/player/events.rs | 4 +- src/infra/queue/dispatch.rs | 4 +- src/infra/radio/dispatch.rs | 25 +- src/infra/subsonic/dispatch.rs | 4 +- src/infra/youtube/dispatch.rs | 4 +- src/runtime.rs | 64 +++-- src/tui/handlers/announcement_prompt.rs | 6 +- src/tui/handlers/library.rs | 6 +- src/tui/handlers/resize.rs | 34 ++- src/tui/handlers/select_device.rs | 4 +- src/tui/runner.rs | 6 +- 18 files changed, 531 insertions(+), 94 deletions(-) diff --git a/src/core/app.rs b/src/core/app.rs index 7aa8c8e4..f9b2769f 100644 --- a/src/core/app.rs +++ b/src/core/app.rs @@ -1726,6 +1726,8 @@ pub struct App { /// Deadline for a debounced state save scheduled by auto-repeating runtime /// state changes such as volume and shuffle. pub state_save_due: Option, + /// Runtime-state patch waiting for the next debounced save. + pub pending_state_save_patch: PersistedRuntimeState, /// Reference to the native streaming player for direct control (bypasses event channel) #[cfg(feature = "streaming")] pub streaming_player: Option>, @@ -2082,6 +2084,7 @@ impl Default for App { playlist_sort_fetch_in_flight: HashSet::new(), is_volume_change_in_flight: false, state_save_due: None, + pending_state_save_patch: PersistedRuntimeState::default(), pending_volume: None, last_dispatched_volume: None, #[cfg(feature = "streaming")] @@ -3950,21 +3953,32 @@ impl App { /// instead of `save_runtime_state()` so a held key doesn't pay /// disk + YAML work on every auto-repeat; the save lands once, shortly /// after the last change. - pub fn schedule_state_save(&mut self) { + pub fn schedule_state_save(&mut self, patch: PersistedRuntimeState) { + if patch.is_empty() { + return; + } const STATE_SAVE_DEBOUNCE_MS: u64 = 500; + self.pending_state_save_patch.merge_patch(&patch); self.state_save_due = Some(Instant::now() + Duration::from_millis(STATE_SAVE_DEBOUNCE_MS)); } - pub fn current_runtime_state(&self) -> PersistedRuntimeState { - self.runtime_state.to_persisted() + pub fn save_runtime_state(&self, patch: &PersistedRuntimeState) -> anyhow::Result<()> { + if patch.is_empty() { + return Ok(()); + } + let path = match &self.state_path { + Some(path) => path.clone(), + None => crate::core::state::default_state_path()?, + }; + crate::core::state::save(&path, patch) } - pub fn save_runtime_state(&self) -> anyhow::Result<()> { + fn save_removed_radio_station(&self, url: &str) -> anyhow::Result<()> { let path = match &self.state_path { Some(path) => path.clone(), None => crate::core::state::default_state_path()?, }; - crate::core::state::save(&path, &self.current_runtime_state()) + crate::core::state::save_removing_radio_station(&path, url) } pub fn add_radio_station( @@ -3978,7 +3992,10 @@ impl App { let before_len = self.runtime_state.radio_stations.len(); let outcome = self.runtime_state.add_radio_station(name, url)?; if outcome == RadioStationAddOutcome::Added { - if let Err(error) = self.save_runtime_state() { + let Some(station) = self.runtime_state.radio_stations.get(before_len).cloned() else { + return Ok(outcome); + }; + if let Err(error) = self.save_runtime_state(&PersistedRuntimeState::radio_station(station)) { self.runtime_state.radio_stations.truncate(before_len); return Err(error); } @@ -4001,16 +4018,17 @@ impl App { &mut self, url: impl AsRef, ) -> anyhow::Result> { + let url = url.as_ref().trim().to_string(); let Some(index) = self .runtime_state .radio_stations .iter() - .position(|station| station.url.trim() == url.as_ref().trim()) + .position(|station| station.url.trim() == url) else { return Ok(None); }; - let removed = self.runtime_state.remove_radio_station_by_url(url)?; - if let Err(error) = self.save_runtime_state() { + let removed = self.runtime_state.remove_radio_station_by_url(&url)?; + if let Err(error) = self.save_removed_radio_station(&url) { if let Some(removed) = removed { self.runtime_state.radio_stations.insert(index, removed); } @@ -4026,8 +4044,10 @@ impl App { return; }; if force || Instant::now() >= due { + let patch = std::mem::take(&mut self.pending_state_save_patch); self.state_save_due = None; - if let Err(e) = self.save_runtime_state() { + if let Err(e) = self.save_runtime_state(&patch) { + self.pending_state_save_patch.merge_patch(&patch); self.handle_error(anyhow!("Failed to save state: {}", e)); } } @@ -4103,7 +4123,7 @@ impl App { if self.active_decoded_source() { self.dispatch(IoEvent::ChangeVolume(next_volume)); self.runtime_state.volume_percent = next_volume; - self.schedule_state_save(); + self.schedule_state_save(PersistedRuntimeState::volume_percent(next_volume)); self.pending_volume = Some(next_volume); return; } @@ -4118,7 +4138,7 @@ impl App { ctx.device.volume_percent = Some(next_volume.into()); } self.runtime_state.volume_percent = next_volume; - self.schedule_state_save(); + self.schedule_state_save(PersistedRuntimeState::volume_percent(next_volume)); self.pending_volume = Some(next_volume); // Notify MPRIS clients of the change (VolumeChanged is never emitted by @@ -4159,7 +4179,7 @@ impl App { if self.active_decoded_source() { self.dispatch(IoEvent::ChangeVolume(next_volume)); self.runtime_state.volume_percent = next_volume; - self.schedule_state_save(); + self.schedule_state_save(PersistedRuntimeState::volume_percent(next_volume)); self.pending_volume = Some(next_volume); return; } @@ -4174,7 +4194,7 @@ impl App { ctx.device.volume_percent = Some(next_volume.into()); } self.runtime_state.volume_percent = next_volume; - self.schedule_state_save(); + self.schedule_state_save(PersistedRuntimeState::volume_percent(next_volume)); self.pending_volume = Some(next_volume); // Notify MPRIS clients of the change (VolumeChanged is never emitted by @@ -4220,7 +4240,7 @@ impl App { if self.active_decoded_source() { self.dispatch(IoEvent::ChangeVolume(next_volume_u8)); self.runtime_state.volume_percent = next_volume_u8; - self.schedule_state_save(); + self.schedule_state_save(PersistedRuntimeState::volume_percent(next_volume_u8)); self.pending_volume = Some(next_volume_u8); return; } @@ -4235,7 +4255,7 @@ impl App { ctx.device.volume_percent = Some(next_volume_u8.into()); } self.runtime_state.volume_percent = next_volume_u8; - self.schedule_state_save(); + self.schedule_state_save(PersistedRuntimeState::volume_percent(next_volume_u8)); self.pending_volume = Some(next_volume_u8); // Notify MPRIS clients of the change (VolumeChanged is never emitted by @@ -6315,7 +6335,7 @@ impl App { ctx.shuffle_state = new_shuffle_state; } self.runtime_state.shuffle_enabled = new_shuffle_state; - self.schedule_state_save(); + self.schedule_state_save(PersistedRuntimeState::shuffle_enabled(new_shuffle_state)); // Notify MPRIS clients of the change #[cfg(all(feature = "mpris", target_os = "linux"))] diff --git a/src/core/first_run.rs b/src/core/first_run.rs index d8733bf2..9e7acf8b 100644 --- a/src/core/first_run.rs +++ b/src/core/first_run.rs @@ -105,7 +105,10 @@ async fn apply_selections( // The active source is runtime state, so save it separately. user_config.save_config()?; let state_path = crate::core::state::default_state_path()?; - crate::core::state::save(&state_path, &runtime_state.to_persisted())?; + crate::core::state::save( + &state_path, + &crate::core::state::PersistedRuntimeState::active_source(runtime_state.active_source), + )?; // Collect credentials / check prerequisites for each chosen free source. for source in &selections { diff --git a/src/core/layout.rs b/src/core/layout.rs index c76bf3e7..19f672bb 100644 --- a/src/core/layout.rs +++ b/src/core/layout.rs @@ -4,6 +4,7 @@ use crate::core::user_config::BehaviorConfig; use ratatui::layout::{Constraint, Layout, Rect}; pub const COMPACT_TOP_ROW_THRESHOLD: u16 = 60; +pub const MAX_PLAYBAR_ROWS: u16 = 50; const COMPACT_HELP_WIDTH: u16 = 6; const COMPACT_SETTINGS_WIDTH: u16 = 10; diff --git a/src/core/state.rs b/src/core/state.rs index 570e45a9..76b5ab15 100644 --- a/src/core/state.rs +++ b/src/core/state.rs @@ -5,7 +5,7 @@ //! edits do not churn every time volume, source, pane sizes, announcements, or //! radio stations change. -use crate::core::source::Source; +use crate::core::{layout::MAX_PLAYBAR_ROWS, source::Source}; use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; @@ -76,7 +76,7 @@ impl RuntimeState { self.sidebar_width_percent = sidebar_width_percent.min(100); } if let Some(playbar_height_rows) = state.playbar_height_rows { - self.playbar_height_rows = playbar_height_rows; + self.playbar_height_rows = playbar_height_rows.min(MAX_PLAYBAR_ROWS); } if let Some(library_height_percent) = state.library_height_percent { self.library_height_percent = library_height_percent.min(100); @@ -94,7 +94,7 @@ impl RuntimeState { seen_announcement_ids: Some(sanitized_ids(&self.seen_announcement_ids)), dismissed_announcements: Some(sanitized_ids(&self.dismissed_announcements)), sidebar_width_percent: Some(self.sidebar_width_percent.min(100)), - playbar_height_rows: Some(self.playbar_height_rows), + playbar_height_rows: Some(self.playbar_height_rows.min(MAX_PLAYBAR_ROWS)), library_height_percent: Some(self.library_height_percent.min(100)), radio_stations: Some(sanitized_radio_stations(&self.radio_stations)), } @@ -194,6 +194,97 @@ pub struct PersistedRuntimeState { pub radio_stations: Option>, } +impl PersistedRuntimeState { + pub fn volume_percent(volume_percent: u8) -> Self { + Self { + volume_percent: Some(volume_percent.min(100)), + ..Default::default() + } + } + + pub fn shuffle_enabled(shuffle_enabled: bool) -> Self { + Self { + shuffle_enabled: Some(shuffle_enabled), + ..Default::default() + } + } + + pub fn active_source(active_source: Source) -> Self { + Self { + active_source: Some(active_source), + ..Default::default() + } + } + + pub fn announcements( + seen_announcement_ids: &[String], + dismissed_announcements: &[String], + ) -> Self { + Self { + seen_announcement_ids: Some(sanitized_ids(seen_announcement_ids)), + dismissed_announcements: Some(sanitized_ids(dismissed_announcements)), + ..Default::default() + } + } + + pub fn sidebar_width_percent(sidebar_width_percent: u8) -> Self { + Self { + sidebar_width_percent: Some(sidebar_width_percent.min(100)), + ..Default::default() + } + } + + pub fn playbar_height_rows(playbar_height_rows: u16) -> Self { + Self { + playbar_height_rows: Some(playbar_height_rows.min(MAX_PLAYBAR_ROWS)), + ..Default::default() + } + } + + pub fn library_height_percent(library_height_percent: u8) -> Self { + Self { + library_height_percent: Some(library_height_percent.min(100)), + ..Default::default() + } + } + + pub fn layout( + sidebar_width_percent: u8, + playbar_height_rows: u16, + library_height_percent: u8, + ) -> Self { + Self { + sidebar_width_percent: Some(sidebar_width_percent.min(100)), + playbar_height_rows: Some(playbar_height_rows.min(MAX_PLAYBAR_ROWS)), + library_height_percent: Some(library_height_percent.min(100)), + ..Default::default() + } + } + + pub fn radio_station(radio_station: RadioStationConfig) -> Self { + Self { + radio_stations: Some(vec![radio_station]), + ..Default::default() + } + } + + pub fn merge_patch(&mut self, patch: &Self) { + merge_state_patch(self, patch); + } + + pub fn is_empty(&self) -> bool { + self.volume_percent.is_none() + && self.shuffle_enabled.is_none() + && self.active_source.is_none() + && self.seen_announcement_ids.is_none() + && self.dismissed_announcements.is_none() + && self.sidebar_width_percent.is_none() + && self.playbar_height_rows.is_none() + && self.library_height_percent.is_none() + && self.radio_stations.is_none() + } +} + /// Location of the app runtime-state file: `/state.yml`. pub fn default_state_path() -> Result { crate::core::paths::app_state_dir() @@ -209,8 +300,9 @@ pub fn load(path: &Path) -> Result { if contents.trim().is_empty() { Ok(PersistedRuntimeState::default()) } else { - serde_yaml::from_str(&contents) - .with_context(|| format!("malformed runtime state file: {}", path.display())) + let state = serde_yaml::from_str(&contents) + .with_context(|| format!("malformed runtime state file: {}", path.display()))?; + Ok(sanitized_persisted_state(&state)) } } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(PersistedRuntimeState::default()), @@ -218,9 +310,33 @@ pub fn load(path: &Path) -> Result { } } -/// Save state atomically and privately. +/// Save a state patch atomically and privately. +/// +/// Runtime saves are intentionally read-modify-write: most save call sites only +/// own one field, so blindly writing a whole in-memory snapshot would clobber +/// updates made by another running instance. pub fn save(path: &Path, state: &PersistedRuntimeState) -> Result<()> { - let yaml = serde_yaml::to_string(state).context("serializing runtime state")?; + let mut merged = load(path)?; + merge_state_patch(&mut merged, state); + write_state(path, &merged) +} + +/// Remove a radio station without sending a stale full station list. +pub fn save_removing_radio_station(path: &Path, url: &str) -> Result<()> { + let mut merged = load(path)?; + if let Some(stations) = &mut merged.radio_stations { + let url = url.trim(); + stations.retain(|station| station.url.trim() != url); + *stations = sanitized_radio_stations(stations); + } else { + merged.radio_stations = Some(Vec::new()); + } + write_state(path, &merged) +} + +fn write_state(path: &Path, state: &PersistedRuntimeState) -> Result<()> { + let state = sanitized_persisted_state(state); + let yaml = serde_yaml::to_string(&state).context("serializing runtime state")?; if let Some(dir) = path.parent() { crate::core::paths::ensure_private_dir(dir)?; } @@ -231,6 +347,64 @@ pub fn save(path: &Path, state: &PersistedRuntimeState) -> Result<()> { Ok(()) } +fn sanitized_persisted_state(state: &PersistedRuntimeState) -> PersistedRuntimeState { + PersistedRuntimeState { + volume_percent: state.volume_percent.map(|volume| volume.min(100)), + shuffle_enabled: state.shuffle_enabled, + active_source: state.active_source, + seen_announcement_ids: state.seen_announcement_ids.as_deref().map(sanitized_ids), + dismissed_announcements: state.dismissed_announcements.as_deref().map(sanitized_ids), + sidebar_width_percent: state.sidebar_width_percent.map(|width| width.min(100)), + playbar_height_rows: state + .playbar_height_rows + .map(|rows| rows.min(MAX_PLAYBAR_ROWS)), + library_height_percent: state.library_height_percent.map(|height| height.min(100)), + radio_stations: state + .radio_stations + .as_deref() + .map(sanitized_radio_stations), + } +} + +fn merge_state_patch(merged: &mut PersistedRuntimeState, patch: &PersistedRuntimeState) { + if let Some(volume_percent) = patch.volume_percent { + merged.volume_percent = Some(volume_percent.min(100)); + } + if let Some(shuffle_enabled) = patch.shuffle_enabled { + merged.shuffle_enabled = Some(shuffle_enabled); + } + if let Some(active_source) = patch.active_source { + merged.active_source = Some(active_source); + } + if let Some(seen_announcement_ids) = &patch.seen_announcement_ids { + merged.seen_announcement_ids = Some(merged_ids( + merged.seen_announcement_ids.as_deref().unwrap_or(&[]), + seen_announcement_ids, + )); + } + if let Some(dismissed_announcements) = &patch.dismissed_announcements { + merged.dismissed_announcements = Some(merged_ids( + merged.dismissed_announcements.as_deref().unwrap_or(&[]), + dismissed_announcements, + )); + } + if let Some(sidebar_width_percent) = patch.sidebar_width_percent { + merged.sidebar_width_percent = Some(sidebar_width_percent.min(100)); + } + if let Some(playbar_height_rows) = patch.playbar_height_rows { + merged.playbar_height_rows = Some(playbar_height_rows.min(MAX_PLAYBAR_ROWS)); + } + if let Some(library_height_percent) = patch.library_height_percent { + merged.library_height_percent = Some(library_height_percent.min(100)); + } + if let Some(radio_stations) = &patch.radio_stations { + merged.radio_stations = Some(merged_radio_stations( + merged.radio_stations.as_deref().unwrap_or(&[]), + radio_stations, + )); + } +} + fn sanitized_ids(ids: &[String]) -> Vec { ids .iter() @@ -239,6 +413,21 @@ fn sanitized_ids(ids: &[String]) -> Vec { .collect() } +fn merged_ids(existing: &[String], incoming: &[String]) -> Vec { + let mut seen = std::collections::HashSet::new(); + existing + .iter() + .chain(incoming.iter()) + .filter_map(|id| { + let id = id.trim(); + if id.is_empty() || !seen.insert(id.to_string()) { + return None; + } + Some(id.to_string()) + }) + .collect() +} + pub(crate) fn sanitized_radio_stations(stations: &[RadioStationConfig]) -> Vec { let mut seen_urls = std::collections::HashSet::new(); @@ -264,6 +453,25 @@ pub(crate) fn sanitized_radio_stations(stations: &[RadioStationConfig]) -> Vec Vec { + let mut stations = sanitized_radio_stations(existing); + let mut seen_urls = stations + .iter() + .map(|station| station.url.clone()) + .collect::>(); + + for station in sanitized_radio_stations(incoming) { + if seen_urls.insert(station.url.clone()) { + stations.push(station); + } + } + + stations +} + mod source_config { use super::Source; use serde::{Deserialize, Deserializer, Serializer}; @@ -341,6 +549,144 @@ mod tests { } } + #[test] + fn state_load_and_save_caps_playbar_height_rows() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state.yml"); + std::fs::write( + &path, + format!("playbar_height_rows: {}\n", MAX_PLAYBAR_ROWS + 10), + ) + .unwrap(); + + assert_eq!( + load(&path).unwrap().playbar_height_rows, + Some(MAX_PLAYBAR_ROWS) + ); + + save( + &path, + &PersistedRuntimeState { + volume_percent: Some(40), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!( + load(&path).unwrap().playbar_height_rows, + Some(MAX_PLAYBAR_ROWS) + ); + } + + #[test] + fn state_save_merges_sparse_patches_without_clobbering_unmentioned_fields() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state.yml"); + + let existing = PersistedRuntimeState { + volume_percent: Some(20), + active_source: Some(Source::Radio), + seen_announcement_ids: Some(vec!["first".to_string()]), + radio_stations: Some(vec![RadioStationConfig { + name: "Alpha".to_string(), + url: "https://example.test/alpha".to_string(), + }]), + ..Default::default() + }; + save(&path, &existing).unwrap(); + + let patch = PersistedRuntimeState { + shuffle_enabled: Some(true), + seen_announcement_ids: Some(vec!["second".to_string()]), + radio_stations: Some(vec![RadioStationConfig { + name: "Beta".to_string(), + url: "https://example.test/beta".to_string(), + }]), + ..Default::default() + }; + save(&path, &patch).unwrap(); + + assert_eq!( + load(&path).unwrap(), + PersistedRuntimeState { + volume_percent: Some(20), + shuffle_enabled: Some(true), + active_source: Some(Source::Radio), + seen_announcement_ids: Some(vec!["first".to_string(), "second".to_string()]), + radio_stations: Some(vec![ + RadioStationConfig { + name: "Alpha".to_string(), + url: "https://example.test/alpha".to_string(), + }, + RadioStationConfig { + name: "Beta".to_string(), + url: "https://example.test/beta".to_string(), + }, + ]), + ..Default::default() + } + ); + } + + #[test] + fn radio_station_removal_preserves_other_runtime_state() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state.yml"); + + save( + &path, + &PersistedRuntimeState { + volume_percent: Some(55), + radio_stations: Some(vec![ + RadioStationConfig { + name: "Alpha".to_string(), + url: "https://example.test/alpha".to_string(), + }, + RadioStationConfig { + name: "Beta".to_string(), + url: "https://example.test/beta".to_string(), + }, + ]), + ..Default::default() + }, + ) + .unwrap(); + + save_removing_radio_station(&path, " https://example.test/alpha ").unwrap(); + + assert_eq!( + load(&path).unwrap(), + PersistedRuntimeState { + volume_percent: Some(55), + radio_stations: Some(vec![RadioStationConfig { + name: "Beta".to_string(), + url: "https://example.test/beta".to_string(), + }]), + ..Default::default() + } + ); + } + + #[test] + fn persisted_runtime_state_builds_sparse_patches() { + let mut patch = PersistedRuntimeState::volume_percent(120); + patch.merge_patch(&PersistedRuntimeState::active_source(Source::Subsonic)); + patch.merge_patch(&PersistedRuntimeState::playbar_height_rows( + MAX_PLAYBAR_ROWS + 10, + )); + + assert_eq!( + patch, + PersistedRuntimeState { + volume_percent: Some(100), + active_source: Some(Source::Subsonic), + playbar_height_rows: Some(MAX_PLAYBAR_ROWS), + ..Default::default() + } + ); + } + #[test] fn runtime_state_applies_and_sanitizes_persisted_values() { let state = PersistedRuntimeState { @@ -348,6 +694,7 @@ mod tests { active_source: Some(Source::Subsonic), seen_announcement_ids: Some(vec![" seen ".to_string(), " ".to_string()]), sidebar_width_percent: Some(120), + playbar_height_rows: Some(MAX_PLAYBAR_ROWS + 10), library_height_percent: Some(101), radio_stations: Some(vec![ RadioStationConfig { @@ -373,6 +720,7 @@ mod tests { assert_eq!(runtime.active_source, Source::Subsonic); assert_eq!(runtime.seen_announcement_ids, vec!["seen"]); assert_eq!(runtime.sidebar_width_percent, 100); + assert_eq!(runtime.playbar_height_rows, MAX_PLAYBAR_ROWS); assert_eq!(runtime.library_height_percent, 100); assert_eq!( runtime.radio_stations, diff --git a/src/infra/local/dispatch.rs b/src/infra/local/dispatch.rs index cbb40286..83c8576e 100644 --- a/src/infra/local/dispatch.rs +++ b/src/infra/local/dispatch.rs @@ -111,7 +111,9 @@ pub async fn route_local_event(app: &Arc>, event: &IoEvent) -> bool { // Keep the playbar's volume readout in sync. let mut app = app.lock().await; app.runtime_state.volume_percent = *volume; - app.schedule_state_save(); + app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( + *volume, + )); true } None => false, diff --git a/src/infra/network/native_shuffle.rs b/src/infra/network/native_shuffle.rs index 86191379..c1ec720f 100644 --- a/src/infra/network/native_shuffle.rs +++ b/src/infra/network/native_shuffle.rs @@ -336,7 +336,9 @@ impl Network { ctx.shuffle_state = true; } app.runtime_state.shuffle_enabled = true; - app.schedule_state_save(); + app.schedule_state_save(crate::core::state::PersistedRuntimeState::shuffle_enabled( + true, + )); generation }; diff --git a/src/infra/network/playback.rs b/src/infra/network/playback.rs index 62e4489f..b621644c 100644 --- a/src/infra/network/playback.rs +++ b/src/infra/network/playback.rs @@ -750,7 +750,9 @@ async fn start_native_context_via_api( ctx.shuffle_state = desired_shuffle_state; } app.runtime_state.shuffle_enabled = desired_shuffle_state; - app.schedule_state_save(); + app.schedule_state_save(crate::core::state::PersistedRuntimeState::shuffle_enabled( + desired_shuffle_state, + )); // Keep the recovery chain alive: if the API accepted the start but the // native device never emits a player event, the watchdog fires again and // the bounded attempt counter eventually drops the request with a @@ -1521,7 +1523,9 @@ impl PlaybackNetwork for Network { ctx.shuffle_state = desired_shuffle_state; } app.runtime_state.shuffle_enabled = desired_shuffle_state; - app.schedule_state_save(); + app.schedule_state_save(crate::core::state::PersistedRuntimeState::shuffle_enabled( + desired_shuffle_state, + )); } Err(load_err) => { let Some((device_id, context)) = api_fallback else { @@ -1572,7 +1576,9 @@ impl PlaybackNetwork for Network { ctx.shuffle_state = desired_shuffle_state; } app.runtime_state.shuffle_enabled = desired_shuffle_state; - app.schedule_state_save(); + app.schedule_state_save(crate::core::state::PersistedRuntimeState::shuffle_enabled( + desired_shuffle_state, + )); } Err(e) => { #[cfg(feature = "streaming")] @@ -1647,7 +1653,11 @@ impl PlaybackNetwork for Network { ctx.shuffle_state = desired_shuffle_state; } app.runtime_state.shuffle_enabled = desired_shuffle_state; - app.schedule_state_save(); + app.schedule_state_save( + crate::core::state::PersistedRuntimeState::shuffle_enabled( + desired_shuffle_state, + ), + ); } return; } @@ -1961,7 +1971,9 @@ impl PlaybackNetwork for Network { ctx.shuffle_state = shuffle_state; } app.runtime_state.shuffle_enabled = shuffle_state; - app.schedule_state_save(); + app.schedule_state_save(crate::core::state::PersistedRuntimeState::shuffle_enabled( + shuffle_state, + )); return; } @@ -1980,7 +1992,9 @@ impl PlaybackNetwork for Network { ctx.shuffle_state = shuffle_state; } app.runtime_state.shuffle_enabled = shuffle_state; - app.schedule_state_save(); + app.schedule_state_save(crate::core::state::PersistedRuntimeState::shuffle_enabled( + shuffle_state, + )); } Err(e) => { #[cfg(feature = "streaming")] @@ -2050,7 +2064,9 @@ impl PlaybackNetwork for Network { ctx.device.volume_percent = Some(volume.into()); } app.runtime_state.volume_percent = volume.min(100); - app.schedule_state_save(); + app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( + volume.min(100), + )); app.is_volume_change_in_flight = false; app.last_dispatched_volume = Some(volume); // Keep pending_volume set — cleared when API confirms the value matches @@ -2072,7 +2088,9 @@ impl PlaybackNetwork for Network { ctx.device.volume_percent = Some(volume.into()); } app.runtime_state.volume_percent = volume.min(100); - app.schedule_state_save(); + app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( + volume.min(100), + )); app.is_volume_change_in_flight = false; app.last_dispatched_volume = Some(volume); // Keep pending_volume set — cleared when get_current_playback confirms diff --git a/src/infra/player/events.rs b/src/infra/player/events.rs index 2c14a43b..231cd5c6 100644 --- a/src/infra/player/events.rs +++ b/src/infra/player/events.rs @@ -877,7 +877,9 @@ async fn handle_player_events( ctx.device.volume_percent = Some(volume_percent as u32); } app.runtime_state.volume_percent = volume_percent.min(100); - let _ = app.save_runtime_state(); + let _ = app.save_runtime_state( + &crate::core::state::PersistedRuntimeState::volume_percent(volume_percent.min(100)), + ); } } } diff --git a/src/infra/queue/dispatch.rs b/src/infra/queue/dispatch.rs index 975df250..d745976f 100644 --- a/src/infra/queue/dispatch.rs +++ b/src/infra/queue/dispatch.rs @@ -96,7 +96,9 @@ async fn route_queue_transport(app: &Arc>, event: &IoEvent) -> Option player.set_volume(*volume as f32 / 100.0); let mut app = app.lock().await; app.runtime_state.volume_percent = *volume; - app.schedule_state_save(); + app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( + *volume, + )); Some(true) } // Skip the queued track: advance to the next queued item (or resume). diff --git a/src/infra/radio/dispatch.rs b/src/infra/radio/dispatch.rs index d789c365..10623e26 100644 --- a/src/infra/radio/dispatch.rs +++ b/src/infra/radio/dispatch.rs @@ -34,7 +34,7 @@ use crate::core::app::{App, SearchResultBlock}; use crate::core::pagination::Paged; use crate::core::plugin_api::TrackInfo; use crate::core::source::Searcher; -use crate::core::state::{sanitized_radio_stations, RadioStationConfig}; +use crate::core::state::merged_radio_stations; use crate::infra::audio::LocalPlayer; use crate::infra::network::IoEvent; @@ -91,7 +91,9 @@ pub async fn route_radio_event(app: &Arc>, event: &IoEvent) -> bool { p.set_volume(*volume as f32 / 100.0); let mut app = app.lock().await; app.runtime_state.volume_percent = *volume; - app.schedule_state_save(); + app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( + *volume, + )); true } None => false, @@ -117,18 +119,13 @@ pub async fn route_radio_event(app: &Arc>, event: &IoEvent) -> bool { /// Stations panel). No network. async fn load_radio_stations(app: &Arc>) { let mut guard = app.lock().await; - let merged_stations: Vec = guard - .user_config - .behavior - .radio_stations - .iter() - .chain(guard.runtime_state.radio_stations.iter()) - .cloned() - .collect(); - let stations: Vec = sanitized_radio_stations(&merged_stations) - .into_iter() - .map(|station| config_station_to_track_info(&station.name, &station.url)) - .collect(); + let stations: Vec = merged_radio_stations( + &guard.user_config.behavior.radio_stations, + &guard.runtime_state.radio_stations, + ) + .into_iter() + .map(|station| config_station_to_track_info(&station.name, &station.url)) + .collect(); let empty = stations.is_empty(); guard.radio_stations = stations; guard.radio_stations_index = 0; diff --git a/src/infra/subsonic/dispatch.rs b/src/infra/subsonic/dispatch.rs index 604bc8b8..d0ebb1ed 100644 --- a/src/infra/subsonic/dispatch.rs +++ b/src/infra/subsonic/dispatch.rs @@ -122,7 +122,9 @@ pub async fn route_subsonic_event(app: &Arc>, event: &IoEvent) -> boo p.set_volume(*volume as f32 / 100.0); let mut app = app.lock().await; app.runtime_state.volume_percent = *volume; - app.schedule_state_save(); + app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( + *volume, + )); true } None => false, diff --git a/src/infra/youtube/dispatch.rs b/src/infra/youtube/dispatch.rs index fd426854..da5b9d1a 100644 --- a/src/infra/youtube/dispatch.rs +++ b/src/infra/youtube/dispatch.rs @@ -124,7 +124,9 @@ pub async fn route_youtube_event(app: &Arc>, event: &IoEvent) -> bool p.set_volume(*volume as f32 / 100.0); let mut app = app.lock().await; app.runtime_state.volume_percent = *volume; - app.schedule_state_save(); + app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( + *volume, + )); true } None => false, diff --git a/src/runtime.rs b/src/runtime.rs index 89f1ae8b..b3daf946 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -29,6 +29,7 @@ use crate::cli; use crate::core::app::App; use crate::core::auth; use crate::core::config::ClientConfig; +use crate::core::layout::MAX_PLAYBAR_ROWS; use crate::core::state::{PersistedRuntimeState, RuntimeState}; use crate::core::user_config::{ validate_tick_rate_milliseconds, BehaviorConfig, StartupBehavior, UserConfig, UserConfigPaths, @@ -45,7 +46,6 @@ use crate::infra::network::{IoEvent, Network}; #[cfg(feature = "streaming")] use crate::infra::player; use crate::tui::banner::BANNER; -use crate::tui::handlers::resize::MAX_PLAYBAR_ROWS; use anyhow::{anyhow, Result}; use backtrace::Backtrace; @@ -982,35 +982,35 @@ fn apply_configured_runtime_defaults( runtime_state: &mut RuntimeState, persisted_state: &PersistedRuntimeState, behavior: &BehaviorConfig, -) -> bool { - let mut applied_default = false; +) -> PersistedRuntimeState { + let mut applied_defaults = PersistedRuntimeState::default(); if persisted_state.volume_percent.is_none() { if let Some(volume_percent) = behavior.volume_percent { runtime_state.volume_percent = volume_percent.min(100); - applied_default = true; + applied_defaults.volume_percent = Some(runtime_state.volume_percent); } } if persisted_state.sidebar_width_percent.is_none() { if let Some(sidebar_width_percent) = behavior.sidebar_width_percent { runtime_state.sidebar_width_percent = sidebar_width_percent.min(100); - applied_default = true; + applied_defaults.sidebar_width_percent = Some(runtime_state.sidebar_width_percent); } } if persisted_state.playbar_height_rows.is_none() { if let Some(playbar_height_rows) = behavior.playbar_height_rows { runtime_state.playbar_height_rows = playbar_height_rows.min(MAX_PLAYBAR_ROWS); - applied_default = true; + applied_defaults.playbar_height_rows = Some(runtime_state.playbar_height_rows); } } if persisted_state.library_height_percent.is_none() { if let Some(library_height_percent) = behavior.library_height_percent { runtime_state.library_height_percent = library_height_percent.min(100); - applied_default = true; + applied_defaults.library_height_percent = Some(runtime_state.library_height_percent); } } - applied_default + applied_defaults } pub async fn run() -> Result<()> { @@ -1149,14 +1149,15 @@ screens more often and cost more CPU. Animation-heavy views keep their separate log::warn!("[state] runtime state path is unavailable: {e}"); } } - if apply_configured_runtime_defaults(&mut runtime_state, &persisted_state, &user_config.behavior) - && can_save_initial_state - { - should_save_initial_state = true; - } - if should_save_initial_state { + let default_fields = + apply_configured_runtime_defaults(&mut runtime_state, &persisted_state, &user_config.behavior); + let state = if should_save_initial_state { + runtime_state.to_persisted() + } else { + default_fields + }; + if can_save_initial_state && !state.is_empty() { if let Some(path) = &state_path { - let state = runtime_state.to_persisted(); if let Err(e) = crate::core::state::save(path, &state) { log::warn!("[state] failed to save initial runtime state: {e}"); } @@ -2283,7 +2284,9 @@ async fn handle_mpris_events( ctx.shuffle_state = shuffle; } app_lock.runtime_state.shuffle_enabled = shuffle; - app_lock.schedule_state_save(); + app_lock.schedule_state_save( + crate::core::state::PersistedRuntimeState::shuffle_enabled(shuffle), + ); } continue; } @@ -2294,7 +2297,9 @@ async fn handle_mpris_events( ctx.shuffle_state = shuffle; } app_lock.runtime_state.shuffle_enabled = shuffle; - app_lock.schedule_state_save(); + app_lock.schedule_state_save(crate::core::state::PersistedRuntimeState::shuffle_enabled( + shuffle, + )); app_lock.dispatch(IoEvent::Shuffle(shuffle)); } MprisEvent::SetLoopStatus(loop_status) => { @@ -2757,9 +2762,9 @@ async fn route_decoded_windows_event( #[cfg(test)] mod tests { use super::{apply_configured_runtime_defaults, startup_device_decision, StartupDeviceEvent}; + use crate::core::layout::MAX_PLAYBAR_ROWS; use crate::core::state::{PersistedRuntimeState, RuntimeState}; use crate::core::user_config::{StartupBehavior, UserConfig}; - use crate::tui::handlers::resize::MAX_PLAYBAR_ROWS; use rspotify::model::{device::Device, DeviceType}; const NATIVE_NAME: &str = "spotatui"; @@ -2788,11 +2793,10 @@ mod tests { let mut runtime = RuntimeState::default(); runtime.apply_persisted(&persisted); - assert!(!apply_configured_runtime_defaults( - &mut runtime, - &persisted, - &config.behavior, - )); + assert_eq!( + apply_configured_runtime_defaults(&mut runtime, &persisted, &config.behavior), + PersistedRuntimeState::default() + ); assert_eq!(runtime.volume_percent, 42); assert_eq!(runtime.sidebar_width_percent, 25); assert_eq!(runtime.playbar_height_rows, 5); @@ -2809,11 +2813,15 @@ mod tests { let mut runtime = RuntimeState::default(); runtime.apply_persisted(&persisted); - assert!(apply_configured_runtime_defaults( - &mut runtime, - &persisted, - &config.behavior, - )); + assert_eq!( + apply_configured_runtime_defaults(&mut runtime, &persisted, &config.behavior), + PersistedRuntimeState { + sidebar_width_percent: Some(40), + playbar_height_rows: Some(MAX_PLAYBAR_ROWS), + library_height_percent: Some(60), + ..Default::default() + } + ); assert_eq!(runtime.volume_percent, 42); assert_eq!(runtime.sidebar_width_percent, 40); assert_eq!(runtime.playbar_height_rows, MAX_PLAYBAR_ROWS); diff --git a/src/tui/handlers/announcement_prompt.rs b/src/tui/handlers/announcement_prompt.rs index 086713d2..c57d685d 100644 --- a/src/tui/handlers/announcement_prompt.rs +++ b/src/tui/handlers/announcement_prompt.rs @@ -6,7 +6,11 @@ pub fn handler(key: Key, app: &mut App) { Key::Enter | Key::Esc | Key::Char('q') | Key::Char(' ') => { if let Some(dismissed_id) = app.dismiss_active_announcement() { app.runtime_state.mark_announcement_seen(dismissed_id); - if let Err(error) = app.save_runtime_state() { + let patch = crate::core::state::PersistedRuntimeState::announcements( + &app.runtime_state.seen_announcement_ids, + &app.runtime_state.dismissed_announcements, + ); + if let Err(error) = app.save_runtime_state(&patch) { app.handle_error(anyhow::anyhow!( "Failed to persist dismissed announcement: {}", error diff --git a/src/tui/handlers/library.rs b/src/tui/handlers/library.rs index dc0284ef..ea8ddfba 100644 --- a/src/tui/handlers/library.rs +++ b/src/tui/handlers/library.rs @@ -91,7 +91,11 @@ pub fn handler(key: Key, app: &mut App) { app.active_source = Source::Local; // Mirror the persisted value so the selection survives restarts. app.runtime_state.active_source = Source::Local; - if let Err(e) = app.save_runtime_state() { + if let Err(e) = + app.save_runtime_state(&crate::core::state::PersistedRuntimeState::active_source( + app.runtime_state.active_source, + )) + { log::warn!("[source] failed to persist active_source: {e}"); } app.selected_playlist_index = Some(0); diff --git a/src/tui/handlers/resize.rs b/src/tui/handlers/resize.rs index 0d788d39..6d7c0dfc 100644 --- a/src/tui/handlers/resize.rs +++ b/src/tui/handlers/resize.rs @@ -1,10 +1,10 @@ use crate::core::app::App; -use crate::core::state::RuntimeState; +use crate::core::layout::MAX_PLAYBAR_ROWS; +use crate::core::state::{PersistedRuntimeState, RuntimeState}; const SIDEBAR_STEP: u8 = 5; const PLAYBAR_STEP: u16 = 1; const LIBRARY_STEP: u8 = 5; -pub(crate) const MAX_PLAYBAR_ROWS: u16 = 50; /// Decrease sidebar width by SIDEBAR_STEP percent (minimum 0%). pub fn decrease_sidebar_width(app: &mut App) { @@ -12,7 +12,9 @@ pub fn decrease_sidebar_width(app: &mut App) { .runtime_state .sidebar_width_percent .saturating_sub(SIDEBAR_STEP); - app.schedule_state_save(); + app.schedule_state_save(PersistedRuntimeState::sidebar_width_percent( + app.runtime_state.sidebar_width_percent, + )); } /// Increase sidebar width by SIDEBAR_STEP percent (maximum 100%). @@ -22,7 +24,9 @@ pub fn increase_sidebar_width(app: &mut App) { .sidebar_width_percent .saturating_add(SIDEBAR_STEP) .min(100); - app.schedule_state_save(); + app.schedule_state_save(PersistedRuntimeState::sidebar_width_percent( + app.runtime_state.sidebar_width_percent, + )); } /// Decrease playbar height by PLAYBAR_STEP rows (minimum 0 = hidden). @@ -31,7 +35,9 @@ pub fn decrease_playbar_height(app: &mut App) { .runtime_state .playbar_height_rows .saturating_sub(PLAYBAR_STEP); - app.schedule_state_save(); + app.schedule_state_save(PersistedRuntimeState::playbar_height_rows( + app.runtime_state.playbar_height_rows, + )); } /// Increase playbar height by PLAYBAR_STEP rows (capped at MAX_PLAYBAR_ROWS). @@ -41,7 +47,9 @@ pub fn increase_playbar_height(app: &mut App) { .playbar_height_rows .saturating_add(PLAYBAR_STEP) .min(MAX_PLAYBAR_ROWS); - app.schedule_state_save(); + app.schedule_state_save(PersistedRuntimeState::playbar_height_rows( + app.runtime_state.playbar_height_rows, + )); } /// Decrease the library section height within the sidebar (minimum 0% = hidden). @@ -50,7 +58,9 @@ pub fn decrease_library_height(app: &mut App) { .runtime_state .library_height_percent .saturating_sub(LIBRARY_STEP); - app.schedule_state_save(); + app.schedule_state_save(PersistedRuntimeState::library_height_percent( + app.runtime_state.library_height_percent, + )); } /// Increase the library section height within the sidebar (maximum 100%). @@ -60,7 +70,9 @@ pub fn increase_library_height(app: &mut App) { .library_height_percent .saturating_add(LIBRARY_STEP) .min(100); - app.schedule_state_save(); + app.schedule_state_save(PersistedRuntimeState::library_height_percent( + app.runtime_state.library_height_percent, + )); } /// Reset all pane sizes to configured defaults, or runtime defaults. @@ -84,7 +96,11 @@ pub fn reset_layout(app: &mut App) { .library_height_percent .unwrap_or(defaults.library_height_percent) .min(100); - app.schedule_state_save(); + app.schedule_state_save(PersistedRuntimeState::layout( + app.runtime_state.sidebar_width_percent, + app.runtime_state.playbar_height_rows, + app.runtime_state.library_height_percent, + )); } #[cfg(test)] diff --git a/src/tui/handlers/select_device.rs b/src/tui/handlers/select_device.rs index d46f77c0..9c392555 100644 --- a/src/tui/handlers/select_device.rs +++ b/src/tui/handlers/select_device.rs @@ -93,7 +93,9 @@ fn select_source(app: &mut App) { app.active_source = source; // Mirror the persisted value so it survives restarts. app.runtime_state.active_source = source; - if let Err(e) = app.save_runtime_state() { + if let Err(e) = app.save_runtime_state( + &crate::core::state::PersistedRuntimeState::active_source(app.runtime_state.active_source), + ) { log::warn!("[source] failed to persist active_source: {e}"); } // Reset the sidebar playlist cursor to the top of the new source's list. diff --git a/src/tui/runner.rs b/src/tui/runner.rs index b6a71800..adaf038d 100644 --- a/src/tui/runner.rs +++ b/src/tui/runner.rs @@ -848,7 +848,11 @@ pub async fn start_ui( } else if app.get_current_route().active_block == ActiveBlock::AnnouncementPrompt { if let Some(dismissed_id) = app.dismiss_active_announcement() { app.runtime_state.mark_announcement_seen(dismissed_id); - if let Err(error) = app.save_runtime_state() { + let patch = crate::core::state::PersistedRuntimeState::announcements( + &app.runtime_state.seen_announcement_ids, + &app.runtime_state.dismissed_announcements, + ); + if let Err(error) = app.save_runtime_state(&patch) { app.handle_error(anyhow!( "Failed to persist dismissed announcement: {}", error From b20d9c494a917e9e959d717980c07bf47b042462 Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Fri, 31 Jul 2026 16:10:11 +0930 Subject: [PATCH 20/23] fix: migrate legacy config runtime state before rewrite --- src/core/migrations.rs | 793 +++++++++++++++++++++++++++++++++ src/core/mod.rs | 1 + src/core/persisted_playback.rs | 8 +- src/infra/history.rs | 20 +- src/runtime.rs | 71 ++- 5 files changed, 877 insertions(+), 16 deletions(-) create mode 100644 src/core/migrations.rs diff --git a/src/core/migrations.rs b/src/core/migrations.rs new file mode 100644 index 00000000..8e47f644 --- /dev/null +++ b/src/core/migrations.rs @@ -0,0 +1,793 @@ +//! Short-lived upgrade shims for moving old on-disk shapes into current state. +//! +//! Keep each migration isolated here so it can be deleted cleanly once the +//! minimum supported upgrade path no longer includes the legacy release. + +use crate::core::source::Source; +use crate::core::state::{sanitized_radio_stations, PersistedRuntimeState, RuntimeState}; +use crate::core::user_config::BehaviorConfig; +use anyhow::{anyhow, Context, Result}; +use serde_yaml::{Mapping, Value}; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +const LEGACY_RUNTIME_STATE_BEHAVIOR_KEYS: [&str; 4] = [ + "active_source", + "shuffle_enabled", + "seen_announcement_ids", + "dismissed_announcements", +]; + +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct LegacyConfigCleanupTargets { + runtime_state_keys: Vec<&'static str>, + radio_stations: bool, +} + +impl LegacyConfigCleanupTargets { + pub(crate) fn is_empty(&self) -> bool { + self.runtime_state_keys.is_empty() && !self.radio_stations + } + + pub(crate) fn removes_radio_stations(&self) -> bool { + self.radio_stations + } +} + +/// Resolve a file that now belongs under the XDG state directory, migrating the +/// legacy file from the config directory when the new location is still empty. +/// +/// This intentionally never overwrites an existing state file: once the new +/// path has data, merging the old file is file-format-specific and unsafe for a +/// generic shim. Remove this after the config-to-state upgrade window closes. +pub(crate) fn state_file_path_with_legacy_config_rename( + relative_path: impl AsRef, +) -> Result { + let relative_path = relative_path.as_ref(); + if relative_path.is_absolute() { + return Err(anyhow!( + "state migration path must be relative: {}", + relative_path.display() + )); + } + + let state_dir = crate::core::paths::app_state_dir() + .ok_or_else(|| anyhow!("cannot resolve the spotatui state directory"))?; + let state_path = state_dir.join(relative_path); + + if let Some(config_dir) = crate::core::paths::app_config_dir() { + let legacy_path = config_dir.join(relative_path); + rename_legacy_file_if_unclaimed(&legacy_path, &state_path)?; + } + + Ok(state_path) +} + +pub(crate) fn rename_legacy_file_if_unclaimed( + legacy_path: &Path, + state_path: &Path, +) -> Result { + if path_exists(state_path)? || !path_exists(legacy_path)? { + return Ok(false); + } + + if let Some(parent) = state_path.parent() { + fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; + } + + match fs::hard_link(legacy_path, state_path) { + Ok(()) => { + remove_legacy_file_after_migration(legacy_path, state_path); + log::info!( + "migrated legacy app data from {} to {}", + legacy_path.display(), + state_path.display() + ); + Ok(true) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(link_error) => { + if !copy_legacy_file_if_unclaimed(legacy_path, state_path, &link_error)? { + return Ok(false); + } + remove_legacy_file_after_migration(legacy_path, state_path); + log::info!( + "migrated legacy app data from {} to {}", + legacy_path.display(), + state_path.display() + ); + Ok(true) + } + } +} + +fn copy_legacy_file_if_unclaimed( + legacy_path: &Path, + state_path: &Path, + link_error: &std::io::Error, +) -> Result { + let mut source = match fs::File::open(legacy_path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(error).with_context(|| format!("opening {}", legacy_path.display())); + } + }; + let mut target = match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(state_path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => return Ok(false), + Err(error) => { + return Err(error).with_context(|| { + format!( + "linking {} to {} failed ({link_error}); creating copy target also failed", + legacy_path.display(), + state_path.display() + ) + }); + } + }; + + if let Err(error) = std::io::copy(&mut source, &mut target) { + let _ = fs::remove_file(state_path); + return Err(error).with_context(|| { + format!( + "linking {} to {} failed ({link_error}); copying fallback also failed", + legacy_path.display(), + state_path.display() + ) + }); + } + + Ok(true) +} + +fn remove_legacy_file_after_migration(legacy_path: &Path, state_path: &Path) { + match fs::remove_file(legacy_path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => log::warn!( + "migrated {} to {}, but failed to remove the legacy file: {}", + legacy_path.display(), + state_path.display(), + error + ), + } +} + +/// Move legacy in-app radio favorites from `config.yml` ownership into +/// `state.yml` ownership. +/// +/// Remove this migration after one release cycle where users could have started +/// the app and converted pre-existing `behavior.radio_stations`. +pub(crate) fn apply_legacy_config_radio_station_migration( + runtime_state: &mut RuntimeState, + persisted_state: &PersistedRuntimeState, + behavior: &BehaviorConfig, +) -> PersistedRuntimeState { + if persisted_state.radio_stations.is_some() || behavior.radio_stations.is_empty() { + return PersistedRuntimeState::default(); + } + + runtime_state.radio_stations = behavior.radio_stations.clone(); + PersistedRuntimeState { + radio_stations: Some(runtime_state.radio_stations.clone()), + ..Default::default() + } +} + +/// Seed runtime-state fields that used to live under `behavior` in `config.yml`. +/// +/// This reads the raw YAML rather than `BehaviorConfigString` because those +/// fields were removed from the typed config. Remove this with the radio shim +/// after the legacy upgrade path no longer needs to be supported. +pub(crate) fn apply_legacy_config_runtime_state_migration( + path: &Path, + runtime_state: &mut RuntimeState, + persisted_state: &PersistedRuntimeState, +) -> Result { + let Some(config) = read_config_value(path)? else { + return Ok(PersistedRuntimeState::default()); + }; + let Some(behavior) = behavior_mapping(&config) else { + return Ok(PersistedRuntimeState::default()); + }; + + let mut patch = PersistedRuntimeState::default(); + if persisted_state.active_source.is_none() { + if let Some(source) = behavior + .get(yaml_key("active_source")) + .and_then(source_from_value) + { + runtime_state.active_source = source; + patch.active_source = Some(source); + } + } + if persisted_state.shuffle_enabled.is_none() { + if let Some(shuffle_enabled) = behavior + .get(yaml_key("shuffle_enabled")) + .and_then(Value::as_bool) + { + runtime_state.shuffle_enabled = shuffle_enabled; + patch.shuffle_enabled = Some(shuffle_enabled); + } + } + if persisted_state.seen_announcement_ids.is_none() { + if let Some(ids) = behavior + .get(yaml_key("seen_announcement_ids")) + .and_then(string_sequence) + { + runtime_state.seen_announcement_ids = ids.clone(); + patch.seen_announcement_ids = Some(ids); + } + } + if persisted_state.dismissed_announcements.is_none() { + if let Some(ids) = behavior + .get(yaml_key("dismissed_announcements")) + .and_then(string_sequence) + { + runtime_state.dismissed_announcements = ids.clone(); + patch.dismissed_announcements = Some(ids); + } + } + + Ok(patch) +} + +pub(crate) fn legacy_config_cleanup_targets( + path: &Path, + state: &PersistedRuntimeState, +) -> Result { + let Some(config) = read_config_value(path)? else { + return Ok(LegacyConfigCleanupTargets::default()); + }; + let Some(behavior) = behavior_mapping(&config) else { + return Ok(LegacyConfigCleanupTargets::default()); + }; + + let mut targets = LegacyConfigCleanupTargets::default(); + for key in LEGACY_RUNTIME_STATE_BEHAVIOR_KEYS { + if behavior.get(yaml_key(key)).is_some() && state_owns_legacy_runtime_key(key, state) { + targets.runtime_state_keys.push(key); + } + } + + if let Some(config_stations) = behavior + .get(yaml_key("radio_stations")) + .and_then(radio_stations_from_value) + { + if state_owns_radio_stations( + &config_stations, + state.radio_stations.as_deref().unwrap_or(&[]), + ) { + targets.radio_stations = true; + } + } + + Ok(targets) +} + +pub(crate) fn remove_legacy_config_fields( + path: &Path, + targets: &LegacyConfigCleanupTargets, +) -> Result { + let mut keys = targets.runtime_state_keys.clone(); + if targets.radio_stations { + keys.push("radio_stations"); + } + remove_behavior_keys_from_config(path, &keys) +} + +fn remove_behavior_keys_from_config(path: &Path, keys: &[&str]) -> Result { + if keys.is_empty() { + return Ok(false); + } + + let Some(mut config) = read_config_value(path)? else { + return Ok(false); + }; + let Some(behavior_map) = behavior_mapping_mut(&mut config) else { + return Ok(false); + }; + + let mut removed = false; + for key in keys { + removed |= behavior_map.remove(yaml_key(key)).is_some(); + } + if !removed { + return Ok(false); + } + + let updated_config = serde_yaml::to_string(&config)?; + let tmp_path = path.with_extension("yml.tmp"); + crate::core::auth::write_private_file(&tmp_path, updated_config.as_bytes())?; + fs::rename(&tmp_path, path)?; + + Ok(true) +} + +fn path_exists(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error).with_context(|| format!("checking {}", path.display())), + } +} + +fn read_config_value(path: &Path) -> Result> { + let config_yml = match fs::read_to_string(path) { + Ok(config_yml) => config_yml, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.into()), + }; + if config_yml.trim().is_empty() { + return Ok(None); + } + + Ok(Some(serde_yaml::from_str(&config_yml)?)) +} + +fn behavior_mapping(config: &Value) -> Option<&Mapping> { + config + .as_mapping() + .and_then(|map| map.get(yaml_key("behavior"))) + .and_then(Value::as_mapping) +} + +fn behavior_mapping_mut(config: &mut Value) -> Option<&mut Mapping> { + config + .as_mapping_mut() + .and_then(|map| map.get_mut(yaml_key("behavior"))) + .and_then(Value::as_mapping_mut) +} + +fn yaml_key(key: &str) -> Value { + Value::String(key.to_string()) +} + +fn source_from_value(value: &Value) -> Option { + let source = value.as_str()?.trim(); + if source.is_empty() { + return None; + } + + Some(Source::from_config_str(source)) +} + +fn radio_stations_from_value(value: &Value) -> Option> { + serde_yaml::from_value(value.clone()).ok() +} + +fn state_owns_legacy_runtime_key(key: &str, state: &PersistedRuntimeState) -> bool { + match key { + "active_source" => state.active_source.is_some(), + "shuffle_enabled" => state.shuffle_enabled.is_some(), + "seen_announcement_ids" => state.seen_announcement_ids.is_some(), + "dismissed_announcements" => state.dismissed_announcements.is_some(), + _ => false, + } +} + +fn state_owns_radio_stations( + config_stations: &[crate::core::state::RadioStationConfig], + state_stations: &[crate::core::state::RadioStationConfig], +) -> bool { + let config_stations = sanitized_radio_stations(config_stations); + if config_stations.is_empty() { + return false; + } + + let state_stations = sanitized_radio_stations(state_stations); + config_stations.iter().all(|config_station| { + state_stations.iter().any(|state_station| { + state_station.name == config_station.name && state_station.url == config_station.url + }) + }) +} + +fn string_sequence(value: &Value) -> Option> { + value.as_sequence().map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + .collect() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::source::Source; + use crate::core::state::RadioStationConfig; + use crate::core::user_config::UserConfig; + + #[test] + fn legacy_file_rename_moves_file_when_state_path_is_empty() { + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir + .path() + .join("config") + .join("history") + .join("listens.jsonl"); + let state_path = dir + .path() + .join("state") + .join("history") + .join("listens.jsonl"); + std::fs::create_dir_all(legacy_path.parent().unwrap()).unwrap(); + std::fs::write(&legacy_path, "legacy listens").unwrap(); + + assert!(rename_legacy_file_if_unclaimed(&legacy_path, &state_path).unwrap()); + assert!(!legacy_path.exists()); + assert_eq!( + std::fs::read_to_string(&state_path).unwrap(), + "legacy listens" + ); + } + + #[test] + fn legacy_file_rename_ignores_missing_legacy_file() { + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("config").join("last_session.yml"); + let state_path = dir.path().join("state").join("last_session.yml"); + + assert!(!rename_legacy_file_if_unclaimed(&legacy_path, &state_path).unwrap()); + assert!(!state_path.exists()); + } + + #[test] + fn legacy_file_rename_does_not_overwrite_existing_state_file() { + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir + .path() + .join("config") + .join("history") + .join("last_synced_at.txt"); + let state_path = dir + .path() + .join("state") + .join("history") + .join("last_synced_at.txt"); + std::fs::create_dir_all(legacy_path.parent().unwrap()).unwrap(); + std::fs::create_dir_all(state_path.parent().unwrap()).unwrap(); + std::fs::write(&legacy_path, "legacy cursor").unwrap(); + std::fs::write(&state_path, "offset:42").unwrap(); + + assert!(!rename_legacy_file_if_unclaimed(&legacy_path, &state_path).unwrap()); + assert_eq!( + std::fs::read_to_string(&legacy_path).unwrap(), + "legacy cursor" + ); + assert_eq!(std::fs::read_to_string(&state_path).unwrap(), "offset:42"); + } + + #[test] + fn legacy_config_radio_stations_seed_missing_persisted_runtime_field() { + let mut config = UserConfig::new(); + config.behavior.radio_stations = vec![RadioStationConfig { + name: "Groove Salad".to_string(), + url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), + }]; + let persisted = PersistedRuntimeState { + volume_percent: Some(42), + ..Default::default() + }; + let mut runtime = RuntimeState::default(); + runtime.apply_persisted(&persisted); + + let patch = + apply_legacy_config_radio_station_migration(&mut runtime, &persisted, &config.behavior); + + assert_eq!( + runtime.radio_stations, + vec![RadioStationConfig { + name: "Groove Salad".to_string(), + url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), + }] + ); + assert_eq!(patch.radio_stations, Some(runtime.radio_stations.clone())); + } + + #[test] + fn legacy_config_radio_stations_do_not_override_existing_runtime_field() { + let mut config = UserConfig::new(); + config.behavior.radio_stations = vec![RadioStationConfig { + name: "Configured Groove".to_string(), + url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), + }]; + let persisted = PersistedRuntimeState { + radio_stations: Some(vec![RadioStationConfig { + name: "Secret Agent".to_string(), + url: "https://ice1.somafm.com/secretagent-128-mp3".to_string(), + }]), + ..Default::default() + }; + let mut runtime = RuntimeState::default(); + runtime.apply_persisted(&persisted); + + let patch = + apply_legacy_config_radio_station_migration(&mut runtime, &persisted, &config.behavior); + + assert_eq!( + runtime.radio_stations, + vec![RadioStationConfig { + name: "Secret Agent".to_string(), + url: "https://ice1.somafm.com/secretagent-128-mp3".to_string(), + }] + ); + assert_eq!(patch, PersistedRuntimeState::default()); + } + + #[test] + fn legacy_runtime_state_fields_seed_missing_persisted_fields_from_raw_config() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yml"); + std::fs::write( + &path, + r#" +behavior: + active_source: Radio + shuffle_enabled: true + seen_announcement_ids: + - " seen " + - "" + dismissed_announcements: + - old +"#, + ) + .unwrap(); + let persisted = PersistedRuntimeState { + volume_percent: Some(42), + ..Default::default() + }; + let mut runtime = RuntimeState::default(); + runtime.apply_persisted(&persisted); + + let patch = + apply_legacy_config_runtime_state_migration(&path, &mut runtime, &persisted).unwrap(); + + assert_eq!(runtime.active_source, Source::Radio); + assert!(runtime.shuffle_enabled); + assert_eq!(runtime.seen_announcement_ids, vec!["seen"]); + assert_eq!(runtime.dismissed_announcements, vec!["old"]); + assert_eq!( + patch, + PersistedRuntimeState { + active_source: Some(Source::Radio), + shuffle_enabled: Some(true), + seen_announcement_ids: Some(vec!["seen".to_string()]), + dismissed_announcements: Some(vec!["old".to_string()]), + ..Default::default() + } + ); + } + + #[test] + fn legacy_runtime_state_fields_do_not_override_existing_persisted_fields() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yml"); + std::fs::write( + &path, + r#" +behavior: + active_source: Radio + shuffle_enabled: true + seen_announcement_ids: + - new + dismissed_announcements: + - new-dismissed +"#, + ) + .unwrap(); + let persisted = PersistedRuntimeState { + active_source: Some(Source::Local), + shuffle_enabled: Some(false), + seen_announcement_ids: Some(vec!["existing".to_string()]), + dismissed_announcements: Some(vec!["existing-dismissed".to_string()]), + ..Default::default() + }; + let mut runtime = RuntimeState::default(); + runtime.apply_persisted(&persisted); + + let patch = + apply_legacy_config_runtime_state_migration(&path, &mut runtime, &persisted).unwrap(); + + assert_eq!(runtime.active_source, Source::Local); + assert!(!runtime.shuffle_enabled); + assert_eq!(runtime.seen_announcement_ids, vec!["existing"]); + assert_eq!(runtime.dismissed_announcements, vec!["existing-dismissed"]); + assert_eq!(patch, PersistedRuntimeState::default()); + } + + #[test] + fn legacy_runtime_cleanup_removes_only_migrated_runtime_behavior_fields() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yml"); + std::fs::write( + &path, + r#" +behavior: + active_source: Radio + shuffle_enabled: true + seen_announcement_ids: + - seen + dismissed_announcements: + - dismissed + seek_milliseconds: 7000 + radio_stations: + - name: Groove Salad + url: https://ice1.somafm.com/groovesalad-128-mp3 +"#, + ) + .unwrap(); + + let targets = legacy_config_cleanup_targets( + &path, + &PersistedRuntimeState { + active_source: Some(Source::Radio), + shuffle_enabled: Some(true), + seen_announcement_ids: Some(vec!["seen".to_string()]), + dismissed_announcements: Some(vec!["dismissed".to_string()]), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + targets.runtime_state_keys.as_slice(), + LEGACY_RUNTIME_STATE_BEHAVIOR_KEYS + ); + assert!(!targets.removes_radio_stations()); + + assert!(remove_legacy_config_fields(&path, &targets).unwrap()); + + let raw = std::fs::read_to_string(&path).unwrap(); + let config: Value = serde_yaml::from_str(&raw).unwrap(); + let behavior = behavior_mapping(&config).unwrap(); + for key in LEGACY_RUNTIME_STATE_BEHAVIOR_KEYS { + assert!(behavior.get(yaml_key(key)).is_none()); + } + assert_eq!( + behavior + .get(yaml_key("seek_milliseconds")) + .and_then(Value::as_i64), + Some(7000) + ); + assert!(behavior.get(yaml_key("radio_stations")).is_some()); + } + + #[test] + fn radio_station_config_migration_removes_only_behavior_radio_stations() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yml"); + std::fs::write( + &path, + r#" +behavior: + seek_milliseconds: 7000 + radio_stations: + - name: Groove Salad + url: https://ice1.somafm.com/groovesalad-128-mp3 +keybindings: + back: q +"#, + ) + .unwrap(); + + let targets = legacy_config_cleanup_targets( + &path, + &PersistedRuntimeState { + radio_stations: Some(vec![RadioStationConfig { + name: "Groove Salad".to_string(), + url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), + }]), + ..Default::default() + }, + ) + .unwrap(); + assert!(targets.runtime_state_keys.is_empty()); + assert!(targets.removes_radio_stations()); + + assert!(remove_legacy_config_fields(&path, &targets).unwrap()); + + let raw = std::fs::read_to_string(&path).unwrap(); + let config: serde_yaml::Value = serde_yaml::from_str(&raw).unwrap(); + let behavior = config + .as_mapping() + .and_then(|map| map.get(serde_yaml::Value::String("behavior".to_string()))) + .and_then(|behavior| behavior.as_mapping()) + .unwrap(); + assert!(behavior + .get(serde_yaml::Value::String("radio_stations".to_string())) + .is_none()); + assert_eq!( + behavior + .get(serde_yaml::Value::String("seek_milliseconds".to_string())) + .and_then(|value| value.as_i64()), + Some(7000) + ); + assert_eq!( + config + .as_mapping() + .and_then(|map| map.get(serde_yaml::Value::String("keybindings".to_string()))) + .and_then(|keybindings| keybindings.as_mapping()) + .and_then(|keybindings| { keybindings.get(serde_yaml::Value::String("back".to_string())) }) + .and_then(|value| value.as_str()), + Some("q") + ); + } + + #[test] + fn radio_station_cleanup_retries_after_state_already_owns_migrated_stations() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yml"); + std::fs::write( + &path, + r#" +behavior: + radio_stations: + - name: Groove Salad + url: https://ice1.somafm.com/groovesalad-128-mp3 +"#, + ) + .unwrap(); + + let targets = legacy_config_cleanup_targets( + &path, + &PersistedRuntimeState { + radio_stations: Some(vec![ + RadioStationConfig { + name: "Groove Salad".to_string(), + url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), + }, + RadioStationConfig { + name: "Secret Agent".to_string(), + url: "https://ice1.somafm.com/secretagent-128-mp3".to_string(), + }, + ]), + ..Default::default() + }, + ) + .unwrap(); + + assert!(targets.removes_radio_stations()); + } + + #[test] + fn radio_station_cleanup_keeps_config_owned_stations_not_present_in_state() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yml"); + std::fs::write( + &path, + r#" +behavior: + radio_stations: + - name: Configured Groove + url: https://ice1.somafm.com/groovesalad-128-mp3 +"#, + ) + .unwrap(); + + let targets = legacy_config_cleanup_targets( + &path, + &PersistedRuntimeState { + radio_stations: Some(vec![RadioStationConfig { + name: "Secret Agent".to_string(), + url: "https://ice1.somafm.com/secretagent-128-mp3".to_string(), + }]), + ..Default::default() + }, + ) + .unwrap(); + + assert!(!targets.removes_radio_stations()); + assert!(targets.is_empty()); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index ba17555b..7ed5adaa 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -4,6 +4,7 @@ pub mod config; pub mod first_run; pub mod format; pub mod layout; +pub mod migrations; pub mod pagination; pub mod paths; pub mod persisted_playback; diff --git a/src/core/persisted_playback.rs b/src/core/persisted_playback.rs index 1323c7d4..5d019282 100644 --- a/src/core/persisted_playback.rs +++ b/src/core/persisted_playback.rs @@ -12,7 +12,7 @@ //! A playback session is a machine-written, frequently-updated blob (a queue of //! [`TrackInfo`], an index, a live position). Serializing that into the //! hand-editable `config.yml` would bury the user's real settings under churning -//! metadata. So it lives in its own `last_session.yml` next to the app config, +//! metadata. So it lives in its own `last_session.yml` under the app state dir, //! mirroring the `youtube_playlists.yml` precedent. //! //! ## Per-source shape @@ -27,7 +27,7 @@ //! reconnect to. use crate::core::plugin_api::TrackInfo; -use anyhow::{anyhow, Context, Result}; +use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; @@ -119,9 +119,7 @@ pub fn default_session_path() -> Result { if let Ok(path) = std::env::var(PATH_ENV) { return Ok(PathBuf::from(path)); } - crate::core::paths::app_state_dir() - .map(|dir| dir.join(FILE_NAME)) - .ok_or_else(|| anyhow!("cannot resolve the spotatui state directory")) + crate::core::migrations::state_file_path_with_legacy_config_rename(Path::new(FILE_NAME)) } /// Load the persisted session. A missing file means "no session to resume" diff --git a/src/infra/history.rs b/src/infra/history.rs index 39ad6525..b09accdb 100644 --- a/src/infra/history.rs +++ b/src/infra/history.rs @@ -16,6 +16,8 @@ use tokio::task::{JoinHandle, JoinSet}; const HISTORY_SUBDIR: &str = "history"; const LISTENS_FILE_NAME: &str = "listens.jsonl"; +const LAST_RECAP_FILE_NAME: &str = "last_recap_at.txt"; +const LAST_SYNCED_FILE_NAME: &str = "last_synced_at.txt"; const SYNC_BASE_URL: &str = "https://spotatui.com"; const CLOUD_SYNC_PATH: &str = "/api/sync"; const NOW_PLAYING_SYNC_PATH: &str = "/api/sync/now-playing"; @@ -548,9 +550,7 @@ pub fn recap_output_path() -> Result { } fn last_recap_file_path() -> Result { - crate::core::paths::app_state_dir() - .map(|dir| dir.join(HISTORY_SUBDIR).join("last_recap_at.txt")) - .ok_or_else(|| anyhow!("cannot resolve the spotatui state directory")) + history_state_file_path(LAST_RECAP_FILE_NAME) } fn auto_recap_is_due(last_recap_contents: Option<&str>, now_ts: i64) -> bool { @@ -835,9 +835,13 @@ impl ListenRecord { } fn listens_file_path() -> Result { - crate::core::paths::app_state_dir() - .map(|dir| dir.join(HISTORY_SUBDIR).join(LISTENS_FILE_NAME)) - .ok_or_else(|| anyhow!("cannot resolve the spotatui state directory")) + history_state_file_path(LISTENS_FILE_NAME) +} + +fn history_state_file_path(file_name: &str) -> Result { + crate::core::migrations::state_file_path_with_legacy_config_rename( + Path::new(HISTORY_SUBDIR).join(file_name), + ) } fn append_listen_record(record: &ListenRecord) -> Result<()> { @@ -2223,9 +2227,7 @@ fn js_string(input: &str) -> String { } fn last_synced_file_path() -> Result { - crate::core::paths::app_state_dir() - .map(|dir| dir.join(HISTORY_SUBDIR).join("last_synced_at.txt")) - .ok_or_else(|| anyhow!("cannot resolve the spotatui state directory")) + history_state_file_path(LAST_SYNCED_FILE_NAME) } #[derive(Clone, Copy, Debug)] diff --git a/src/runtime.rs b/src/runtime.rs index b3daf946..3ec69397 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -30,6 +30,10 @@ use crate::core::app::App; use crate::core::auth; use crate::core::config::ClientConfig; use crate::core::layout::MAX_PLAYBAR_ROWS; +use crate::core::migrations::{ + apply_legacy_config_radio_station_migration, apply_legacy_config_runtime_state_migration, + legacy_config_cleanup_targets, remove_legacy_config_fields, +}; use crate::core::state::{PersistedRuntimeState, RuntimeState}; use crate::core::user_config::{ validate_tick_rate_milliseconds, BehaviorConfig, StartupBehavior, UserConfig, UserConfigPaths, @@ -1151,15 +1155,78 @@ screens more often and cost more CPU. Animation-heavy views keep their separate } let default_fields = apply_configured_runtime_defaults(&mut runtime_state, &persisted_state, &user_config.behavior); + let legacy_radio_fields = apply_legacy_config_radio_station_migration( + &mut runtime_state, + &persisted_state, + &user_config.behavior, + ); + let legacy_runtime_fields = match user_config + .path_to_config + .as_ref() + .map(|paths| paths.config_file_path.clone()) + { + Some(config_path) => match apply_legacy_config_runtime_state_migration( + &config_path, + &mut runtime_state, + &persisted_state, + ) { + Ok(fields) => fields, + Err(e) => { + log::warn!("[state] failed to read legacy runtime fields from config.yml: {e}"); + PersistedRuntimeState::default() + } + }, + None => PersistedRuntimeState::default(), + }; let state = if should_save_initial_state { runtime_state.to_persisted() } else { - default_fields + let mut state = default_fields; + state.merge_patch(&legacy_radio_fields); + state.merge_patch(&legacy_runtime_fields); + state }; - if can_save_initial_state && !state.is_empty() { + let mut state_available_for_cleanup = persisted_state.clone(); + let mut state_save_succeeded = state.is_empty(); + if can_save_initial_state && state_save_succeeded { + state_available_for_cleanup.merge_patch(&state); + } else if can_save_initial_state && !state.is_empty() { if let Some(path) = &state_path { if let Err(e) = crate::core::state::save(path, &state) { log::warn!("[state] failed to save initial runtime state: {e}"); + } else { + state_save_succeeded = true; + state_available_for_cleanup.merge_patch(&state); + } + } + } + + let config_path = user_config + .path_to_config + .as_ref() + .map(|paths| paths.config_file_path.clone()); + if state_save_succeeded { + if let Some(config_path) = config_path { + match legacy_config_cleanup_targets(&config_path, &state_available_for_cleanup) { + Ok(targets) => { + if targets.removes_radio_stations() { + user_config.behavior.radio_stations.clear(); + } + if !targets.is_empty() { + match remove_legacy_config_fields(&config_path, &targets) { + Ok(true) => { + info!("[state] migrated legacy config fields to state.yml"); + } + Ok(false) => {} + Err(e) => { + log::warn!("[state] failed to remove migrated fields from config.yml: {e}"); + } + } + } + } + Err(e) => { + log::warn!("[state] failed to inspect legacy config fields: {e}"); + } } } } From c0b8cbe3ed5a82a71b9c7c5a5881f1ba670f8190 Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Fri, 31 Jul 2026 18:16:34 +0930 Subject: [PATCH 21/23] chore: cover legacy free-source migration --- src/core/migrations.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/core/migrations.rs b/src/core/migrations.rs index 8e47f644..4bb5afbe 100644 --- a/src/core/migrations.rs +++ b/src/core/migrations.rs @@ -570,6 +570,34 @@ behavior: ); } + #[test] + fn legacy_runtime_state_migration_preserves_free_source_startup() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yml"); + std::fs::write( + &path, + r#" +behavior: + active_source: Local +"#, + ) + .unwrap(); + let persisted = PersistedRuntimeState::default(); + let mut runtime = RuntimeState::default(); + + let patch = + apply_legacy_config_runtime_state_migration(&path, &mut runtime, &persisted).unwrap(); + + assert_eq!(runtime.active_source, Source::Local); + assert_eq!( + patch, + PersistedRuntimeState { + active_source: Some(Source::Local), + ..Default::default() + } + ); + } + #[test] fn legacy_runtime_state_fields_do_not_override_existing_persisted_fields() { let dir = tempfile::tempdir().unwrap(); From cccc3d0941c8046cfa2239882c217f3c62dc562b Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Fri, 31 Jul 2026 19:05:07 +0930 Subject: [PATCH 22/23] fix: harden legacy state path migration --- src/core/migrations.rs | 139 +++++++++++++++++++++++++++++++++++------ 1 file changed, 119 insertions(+), 20 deletions(-) diff --git a/src/core/migrations.rs b/src/core/migrations.rs index 4bb5afbe..42e25abe 100644 --- a/src/core/migrations.rs +++ b/src/core/migrations.rs @@ -55,17 +55,18 @@ pub(crate) fn state_file_path_with_legacy_config_rename( let state_dir = crate::core::paths::app_state_dir() .ok_or_else(|| anyhow!("cannot resolve the spotatui state directory"))?; + crate::core::paths::ensure_private_dir(&state_dir)?; let state_path = state_dir.join(relative_path); if let Some(config_dir) = crate::core::paths::app_config_dir() { let legacy_path = config_dir.join(relative_path); - rename_legacy_file_if_unclaimed(&legacy_path, &state_path)?; + migrate_legacy_path_if_unclaimed(&legacy_path, &state_path)?; } Ok(state_path) } -pub(crate) fn rename_legacy_file_if_unclaimed( +pub(crate) fn migrate_legacy_path_if_unclaimed( legacy_path: &Path, state_path: &Path, ) -> Result { @@ -77,9 +78,8 @@ pub(crate) fn rename_legacy_file_if_unclaimed( fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; } - match fs::hard_link(legacy_path, state_path) { + match fs::rename(legacy_path, state_path) { Ok(()) => { - remove_legacy_file_after_migration(legacy_path, state_path); log::info!( "migrated legacy app data from {} to {}", legacy_path.display(), @@ -89,11 +89,11 @@ pub(crate) fn rename_legacy_file_if_unclaimed( } Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(link_error) => { - if !copy_legacy_file_if_unclaimed(legacy_path, state_path, &link_error)? { + Err(rename_error) => { + if !copy_legacy_path_if_unclaimed(legacy_path, state_path, &rename_error)? { return Ok(false); } - remove_legacy_file_after_migration(legacy_path, state_path); + remove_legacy_path_after_migration(legacy_path, state_path); log::info!( "migrated legacy app data from {} to {}", legacy_path.display(), @@ -104,11 +104,23 @@ pub(crate) fn rename_legacy_file_if_unclaimed( } } -fn copy_legacy_file_if_unclaimed( +fn copy_legacy_path_if_unclaimed( legacy_path: &Path, state_path: &Path, - link_error: &std::io::Error, + rename_error: &std::io::Error, ) -> Result { + let metadata = match fs::symlink_metadata(legacy_path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(error).with_context(|| format!("checking {}", legacy_path.display())); + } + }; + + if metadata.is_dir() { + return copy_legacy_dir_if_unclaimed(legacy_path, state_path, rename_error); + } + let mut source = match fs::File::open(legacy_path) { Ok(file) => file, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), @@ -116,17 +128,20 @@ fn copy_legacy_file_if_unclaimed( return Err(error).with_context(|| format!("opening {}", legacy_path.display())); } }; - let mut target = match fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(state_path) + let mut target_options = fs::OpenOptions::new(); + target_options.write(true).create_new(true); + #[cfg(unix)] { + use std::os::unix::fs::OpenOptionsExt; + target_options.mode(0o600); + } + let mut target = match target_options.open(state_path) { Ok(file) => file, Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => return Ok(false), Err(error) => { return Err(error).with_context(|| { format!( - "linking {} to {} failed ({link_error}); creating copy target also failed", + "renaming {} to {} failed ({rename_error}); creating copy target also failed", legacy_path.display(), state_path.display() ) @@ -138,7 +153,7 @@ fn copy_legacy_file_if_unclaimed( let _ = fs::remove_file(state_path); return Err(error).with_context(|| { format!( - "linking {} to {} failed ({link_error}); copying fallback also failed", + "renaming {} to {} failed ({rename_error}); copying fallback also failed", legacy_path.display(), state_path.display() ) @@ -148,8 +163,69 @@ fn copy_legacy_file_if_unclaimed( Ok(true) } -fn remove_legacy_file_after_migration(legacy_path: &Path, state_path: &Path) { - match fs::remove_file(legacy_path) { +fn copy_legacy_dir_if_unclaimed( + legacy_path: &Path, + state_path: &Path, + rename_error: &std::io::Error, +) -> Result { + match fs::create_dir(state_path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => return Ok(false), + Err(error) => { + return Err(error).with_context(|| { + format!( + "renaming {} to {} failed ({rename_error}); creating copy target also failed", + legacy_path.display(), + state_path.display() + ) + }); + } + } + + if let Err(error) = copy_dir_contents(legacy_path, state_path) { + let _ = fs::remove_dir_all(state_path); + return Err(error); + } + + Ok(true) +} + +fn copy_dir_contents(legacy_path: &Path, state_path: &Path) -> Result<()> { + for entry in + fs::read_dir(legacy_path).with_context(|| format!("reading {}", legacy_path.display()))? + { + let entry = entry?; + let source = entry.path(); + let target = state_path.join(entry.file_name()); + let metadata = entry + .metadata() + .with_context(|| format!("checking {}", source.display()))?; + + if metadata.is_dir() { + fs::create_dir(&target).with_context(|| format!("creating {}", target.display()))?; + copy_dir_contents(&source, &target)?; + } else { + fs::copy(&source, &target).with_context(|| { + format!( + "copying legacy app data from {} to {}", + source.display(), + target.display() + ) + })?; + } + } + + Ok(()) +} + +fn remove_legacy_path_after_migration(legacy_path: &Path, state_path: &Path) { + let remove_result = match fs::symlink_metadata(legacy_path) { + Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(legacy_path), + Ok(_) => fs::remove_file(legacy_path), + Err(error) => Err(error), + }; + + match remove_result { Ok(()) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => log::warn!( @@ -425,7 +501,7 @@ mod tests { std::fs::create_dir_all(legacy_path.parent().unwrap()).unwrap(); std::fs::write(&legacy_path, "legacy listens").unwrap(); - assert!(rename_legacy_file_if_unclaimed(&legacy_path, &state_path).unwrap()); + assert!(migrate_legacy_path_if_unclaimed(&legacy_path, &state_path).unwrap()); assert!(!legacy_path.exists()); assert_eq!( std::fs::read_to_string(&state_path).unwrap(), @@ -439,7 +515,7 @@ mod tests { let legacy_path = dir.path().join("config").join("last_session.yml"); let state_path = dir.path().join("state").join("last_session.yml"); - assert!(!rename_legacy_file_if_unclaimed(&legacy_path, &state_path).unwrap()); + assert!(!migrate_legacy_path_if_unclaimed(&legacy_path, &state_path).unwrap()); assert!(!state_path.exists()); } @@ -461,7 +537,7 @@ mod tests { std::fs::write(&legacy_path, "legacy cursor").unwrap(); std::fs::write(&state_path, "offset:42").unwrap(); - assert!(!rename_legacy_file_if_unclaimed(&legacy_path, &state_path).unwrap()); + assert!(!migrate_legacy_path_if_unclaimed(&legacy_path, &state_path).unwrap()); assert_eq!( std::fs::read_to_string(&legacy_path).unwrap(), "legacy cursor" @@ -469,6 +545,29 @@ mod tests { assert_eq!(std::fs::read_to_string(&state_path).unwrap(), "offset:42"); } + #[test] + fn legacy_path_migration_moves_directory_when_target_is_empty() { + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("config").join("streaming_cache"); + let state_path = dir.path().join("cache").join("streaming_cache"); + std::fs::create_dir_all(&legacy_path).unwrap(); + std::fs::write(legacy_path.join("credentials.json"), "legacy credentials").unwrap(); + std::fs::create_dir_all(legacy_path.join("audio")).unwrap(); + std::fs::write(legacy_path.join("audio").join("chunk"), "audio cache").unwrap(); + + assert!(migrate_legacy_path_if_unclaimed(&legacy_path, &state_path).unwrap()); + + assert!(!legacy_path.exists()); + assert_eq!( + std::fs::read_to_string(state_path.join("credentials.json")).unwrap(), + "legacy credentials" + ); + assert_eq!( + std::fs::read_to_string(state_path.join("audio").join("chunk")).unwrap(), + "audio cache" + ); + } + #[test] fn legacy_config_radio_stations_seed_missing_persisted_runtime_field() { let mut config = UserConfig::new(); From d3bc833204c1cf2e9e5898b73726e4ecc2f57ac7 Mon Sep 17 00:00:00 2001 From: Dino Leung Date: Fri, 31 Jul 2026 19:10:00 +0930 Subject: [PATCH 23/23] docs: clarify XDG cache migration behavior --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc67a80e..ef95b5c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Changed + +- **Generated app state now uses XDG state/cache directories**: `config.yml` stays in the app config directory for user-authored settings, while runtime-managed state (`state.yml`), listening history, last playback session, and Spotify token caches move to the app state directory. Native streaming credentials and audio cache move to the app cache directory. Existing config-dir runtime fields, radio favorites, listening history, and playback-session files are migrated on first use when the new path is still empty, preserving free-source startup and existing in-app radio favorites during upgrade. Existing Spotify token caches and native streaming caches are not migrated automatically. + ## [v0.40.3] 2026-07-27 ### Added