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 diff --git a/PLUGINS.md b/PLUGINS.md index 4dd19d20..b5fc5990 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -17,14 +17,17 @@ 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` 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). -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/README.md b/README.md index dac81054..673c42e8 100644 --- a/README.md +++ b/README.md @@ -199,13 +199,13 @@ 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 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` 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 `${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` 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 @@ -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,17 @@ 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/` 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 -mkdir -p ~/.config/spotatui -cp -r ~/.config/spotify-tui/* ~/.config/spotatui/ +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/" ``` You may be asked to re-authenticate with Spotify the first time. diff --git a/docs/configuration.md b/docs/configuration.md index 75ed5775..3ee00970 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,13 +2,17 @@ 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). 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` 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 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`). @@ -35,7 +39,7 @@ behavior: # Volume volume_increment: 10 # step for + / - (0..=100, fatal if outside) - volume_percent: 100 # startup 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 @@ -82,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 # 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 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/docs/keybindings.md b/docs/keybindings.md index 7fae1201..764ab763 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -37,7 +37,9 @@ 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` +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 53d354f1..f3ff4857 100644 --- a/docs/native-streaming.md +++ b/docs/native-streaming.md @@ -26,10 +26,16 @@ 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` 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` 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 9e26346e..e3cfa89e 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -6,7 +6,9 @@ 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` +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. @@ -54,10 +56,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 +74,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: @@ -289,8 +292,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..74257aaf 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` 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 3d302a2b..2ae9125a 100644 --- a/examples/plugins/README.md +++ b/examples/plugins/README.md @@ -21,18 +21,28 @@ privileges, so read anything you install from elsewhere (see Single-file plugins go straight into `plugins/`: ```bash -cp track-notifier.lua ~/.config/spotatui/plugins/ +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/" ``` Directory plugins (a folder with a `main.lua` entry point) are copied as a whole: ```bash -cp -r session-stats ~/.config/spotatui/plugins/ +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/" ``` 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..051e2e9d 100644 --- a/examples/plugins/accent-cycler.lua +++ b/examples/plugins/accent-cycler.lua @@ -3,9 +3,14 @@ -- Theme overrides from set_theme are runtime-only; they reset when spotatui restarts. -- -- Install (single file): --- cp accent-cycler.lua ~/.config/spotatui/plugins/ +-- 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/" -- --- 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..e94f44af 100644 --- a/examples/plugins/now-playing-webhook.lua +++ b/examples/plugins/now-playing-webhook.lua @@ -4,7 +4,12 @@ -- webhooks, home-automation triggers, or a "what am I listening to" endpoint. -- -- Install (single file): --- cp now-playing-webhook.lua ~/.config/spotatui/plugins/ +-- 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. local WEBHOOK_URL = "https://example.com/webhook" diff --git a/examples/plugins/queue-browser.lua b/examples/plugins/queue-browser.lua index 63b09611..aed49302 100644 --- a/examples/plugins/queue-browser.lua +++ b/examples/plugins/queue-browser.lua @@ -3,9 +3,14 @@ -- async data reads, timers, storage, navigation). -- -- Install (single file): --- cp queue-browser.lua ~/.config/spotatui/plugins/ +-- 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/" -- --- 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..3eba6e60 100644 --- a/examples/plugins/session-stats/main.lua +++ b/examples/plugins/session-stats/main.lua @@ -4,9 +4,14 @@ -- helper module (stats.lua). This is the layout `spotatui plugin add owner/repo` produces. -- -- Install (directory): --- cp -r session-stats ~/.config/spotatui/plugins/ +-- 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/" -- --- 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..7d84eeeb 100644 --- a/examples/plugins/track-info-popup.lua +++ b/examples/plugins/track-info-popup.lua @@ -1,9 +1,14 @@ -- 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/ +-- 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/" -- --- 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..9982d139 100644 --- a/examples/plugins/track-notifier.lua +++ b/examples/plugins/track-notifier.lua @@ -1,7 +1,12 @@ -- 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/ +-- 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. spotatui.on("track_change", function(pb) 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/app.rs b/src/core/app.rs index f6d23e6c..f9b2769f 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}; @@ -1378,6 +1381,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, @@ -1718,13 +1723,11 @@ 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, + /// 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>, @@ -1901,6 +1904,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(), @@ -2078,7 +2083,8 @@ 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_state_save_patch: PersistedRuntimeState::default(), pending_volume: None, last_dispatched_volume: None, #[cfg(feature = "streaming")] @@ -2152,14 +2158,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. @@ -2207,6 +2230,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(), @@ -2824,7 +2849,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() @@ -3924,32 +3949,106 @@ 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, 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 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) + } + + 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_removing_radio_station(&path, url) + } + + 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 { + 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); + } + } + 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 url = url.as_ref().trim().to_string(); + let Some(index) = self + .runtime_state + .radio_stations + .iter() + .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_removed_radio_station(&url) { + 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)); + let patch = std::mem::take(&mut self.pending_state_save_patch); + self.state_save_due = None; + 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)); } } } @@ -4005,7 +4104,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 @@ -4023,8 +4122,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(PersistedRuntimeState::volume_percent(next_volume)); self.pending_volume = Some(next_volume); return; } @@ -4038,8 +4137,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(PersistedRuntimeState::volume_percent(next_volume)); self.pending_volume = Some(next_volume); // Notify MPRIS clients of the change (VolumeChanged is never emitted by @@ -4079,8 +4178,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(PersistedRuntimeState::volume_percent(next_volume)); self.pending_volume = Some(next_volume); return; } @@ -4094,8 +4193,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(PersistedRuntimeState::volume_percent(next_volume)); self.pending_volume = Some(next_volume); // Notify MPRIS clients of the change (VolumeChanged is never emitted by @@ -4140,8 +4239,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(PersistedRuntimeState::volume_percent(next_volume_u8)); self.pending_volume = Some(next_volume_u8); return; } @@ -4155,8 +4254,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(PersistedRuntimeState::volume_percent(next_volume_u8)); self.pending_volume = Some(next_volume_u8); // Notify MPRIS clients of the change (VolumeChanged is never emitted by @@ -4218,7 +4317,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, @@ -6235,8 +6334,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(PersistedRuntimeState::shuffle_enabled(new_shuffle_state)); // Notify MPRIS clients of the change #[cfg(all(feature = "mpris", target_os = "linux"))] @@ -9903,7 +10002,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/config.rs b/src/core/config.rs index 1372840c..362a8678 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,21 +79,15 @@ 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)?; - } - - // Create .gitignore to protect sensitive files from being committed + 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)?; + 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); if !gitignore_path.exists() { let gitignore_content = "\ @@ -104,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(), @@ -124,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/first_run.rs b/src/core/first_run.rs index abbd1b11..9e7acf8b 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::{ @@ -25,12 +26,24 @@ 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 /// 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. @@ -61,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 @@ -69,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. @@ -85,10 +99,16 @@ 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, + &crate::core::state::PersistedRuntimeState::active_source(runtime_state.active_source), + )?; // Collect credentials / check prerequisites for each chosen free source. for source in &selections { @@ -305,7 +325,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 +354,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 +366,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/layout.rs b/src/core/layout.rs index 429098a6..19f672bb 100644 --- a/src/core/layout.rs +++ b/src/core/layout.rs @@ -1,8 +1,10 @@ use crate::core::app::{ActiveBlock, App}; +use crate::core::state::RuntimeState; 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; @@ -49,23 +51,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 +80,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 +88,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 +134,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 +151,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 +209,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 +228,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]; @@ -283,28 +286,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)); @@ -312,7 +316,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)); @@ -320,7 +324,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)); @@ -328,7 +332,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)); @@ -337,19 +341,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)); @@ -357,7 +361,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)); @@ -365,7 +369,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)); @@ -374,7 +378,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); @@ -385,7 +389,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/migrations.rs b/src/core/migrations.rs new file mode 100644 index 00000000..42e25abe --- /dev/null +++ b/src/core/migrations.rs @@ -0,0 +1,920 @@ +//! 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"))?; + 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); + migrate_legacy_path_if_unclaimed(&legacy_path, &state_path)?; + } + + Ok(state_path) +} + +pub(crate) fn migrate_legacy_path_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::rename(legacy_path, state_path) { + Ok(()) => { + 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(rename_error) => { + if !copy_legacy_path_if_unclaimed(legacy_path, state_path, &rename_error)? { + return Ok(false); + } + remove_legacy_path_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_path_if_unclaimed( + legacy_path: &Path, + state_path: &Path, + 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), + Err(error) => { + return Err(error).with_context(|| format!("opening {}", legacy_path.display())); + } + }; + 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!( + "renaming {} to {} failed ({rename_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!( + "renaming {} to {} failed ({rename_error}); copying fallback also failed", + legacy_path.display(), + state_path.display() + ) + }); + } + + Ok(true) +} + +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!( + "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!(migrate_legacy_path_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!(!migrate_legacy_path_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!(!migrate_legacy_path_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_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(); + 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_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(); + 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 1bbf21e1..7ed5adaa 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -4,12 +4,15 @@ 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; 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/paths.rs b/src/core/paths.rs new file mode 100644 index 00000000..30a5be3b --- /dev/null +++ b/src/core/paths.rs @@ -0,0 +1,190 @@ +use anyhow::{Context, Result}; +use std::{ + ffi::OsString, + path::{Path, PathBuf}, +}; + +const APP_DIR: &str = "spotatui"; +const FALLBACK_CONFIG_DIR: [&str; 1] = [".config"]; +#[cfg(any(feature = "streaming", test))] +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"; +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_dir_from( + std::env::var_os(XDG_CONFIG_HOME_ENV), + dirs::home_dir(), + &FALLBACK_CONFIG_DIR, + ) +} + +/// 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`. +#[cfg(feature = "streaming")] +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, + ) +} + +/// 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, + fallback_dir: &[&str], +) -> Option { + xdg_home + .and_then(valid_xdg_home) + .or_else(|| { + home.map(|home| { + fallback_dir + .iter() + .fold(home, |home, component| home.join(component)) + }) + }) + .map(|dir| dir.join(APP_DIR)) +} + +fn valid_xdg_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::*; + + 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(xdg_home.clone().into_os_string()), + Some(test_home()), + &FALLBACK_CONFIG_DIR, + ); + + assert_eq!(path, Some(xdg_home.join(APP_DIR))); + } + + #[test] + fn app_config_dir_falls_back_to_home_when_xdg_is_unset() { + let home = test_home(); + let path = app_dir_from(None, Some(home.clone()), &FALLBACK_CONFIG_DIR); + + assert_eq!(path, Some(home.join(".config").join(APP_DIR))); + } + + #[test] + fn app_config_dir_ignores_empty_or_relative_xdg_config_home() { + let home = test_home(); + + assert_eq!( + 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")), + 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(xdg_home.clone().into_os_string()), + Some(test_home()), + &FALLBACK_CACHE_DIR, + ), + Some(xdg_home.join(APP_DIR)) + ); + + let home = test_home(); + assert_eq!( + 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(xdg_home.clone().into_os_string()), + Some(test_home()), + &FALLBACK_STATE_DIR, + ), + Some(xdg_home.join(APP_DIR)) + ); + + let home = test_home(); + assert_eq!( + app_dir_from(None, Some(home.clone()), &FALLBACK_STATE_DIR,), + Some(home.join(".local").join("state").join(APP_DIR)) + ); + } +} diff --git a/src/core/persisted_playback.rs b/src/core/persisted_playback.rs index f0b47403..5d019282 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. @@ -11,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 @@ -26,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}; @@ -113,14 +114,12 @@ 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() - .map(|dir| dir.join(FILE_NAME)) - .ok_or_else(|| anyhow!("cannot resolve the spotatui config 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/core/plugin_api.rs b/src/core/plugin_api.rs index eed28229..bc4da086 100644 --- a/src/core/plugin_api.rs +++ b/src/core/plugin_api.rs @@ -420,9 +420,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 = [ @@ -467,12 +471,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(), }, } } @@ -818,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}, @@ -965,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 new file mode 100644 index 00000000..76b5ab15 --- /dev/null +++ b/src/core/state.rs @@ -0,0 +1,762 @@ +//! 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, pane sizes, announcements, or +//! radio stations change. + +use crate::core::{layout::MAX_PLAYBAR_ROWS, 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.min(MAX_PLAYBAR_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.min(MAX_PLAYBAR_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 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", + serialize_with = "source_config::serialize_option", + 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>, +} + +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() + .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 { + 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()), + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), + } +} + +/// 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 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)?; + } + 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 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() + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + .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(); + + stations + .iter() + .filter_map(|station| { + let name = station.name.trim(); + let url = station.url.trim(); + if name.is_empty() || url.is_empty() { + return None; + } + + let url = url.to_string(); + if !seen_urls.insert(url.clone()) { + return None; + } + + Some(RadioStationConfig { + name: name.to_string(), + url, + }) + }) + .collect() +} + +pub(crate) fn merged_radio_stations( + existing: &[RadioStationConfig], + incoming: &[RadioStationConfig], +) -> 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}; + + 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(), + }]), + }; + + 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 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 { + volume_percent: Some(150), + 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 { + 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(), + }, + ]), + ..Default::default() + }; + + let mut runtime = RuntimeState::default(); + runtime.apply_persisted(&state); + + 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.playbar_height_rows, MAX_PLAYBAR_ROWS); + assert_eq!(runtime.library_height_percent, 100); + assert_eq!( + runtime.radio_stations, + vec![RadioStationConfig { + name: "Good".to_string(), + url: "https://example.test".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 b9d11b84..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::source::Source; +use crate::core::state::{sanitized_radio_stations, RadioStationConfig}; use crate::tui::event::Key; use anyhow::{anyhow, Result}; use ratatui::style::{Color, Style}; @@ -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)] @@ -760,25 +758,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, @@ -793,10 +777,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, @@ -805,12 +786,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, @@ -827,8 +804,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, @@ -851,6 +829,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, } @@ -859,7 +843,10 @@ pub struct BehaviorConfigString { pub struct BehaviorConfig { pub seek_milliseconds: u32, pub volume_increment: u8, - pub volume_percent: u8, + /// 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, pub enable_text_emphasis: bool, @@ -875,11 +862,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, @@ -888,12 +871,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, @@ -921,13 +900,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, @@ -952,6 +930,12 @@ pub struct BehaviorConfig { // --- Phase 6: layout arrangement --- pub sidebar_position: String, pub playbar_position: String, + /// 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, pub small_terminal_width: u16, pub small_terminal_height: u16, } @@ -1105,8 +1089,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() @@ -1175,7 +1159,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, @@ -1189,10 +1173,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(), @@ -1201,12 +1182,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(), @@ -1223,8 +1200,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(), @@ -1247,6 +1224,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, }, @@ -1438,7 +1418,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; @@ -1542,45 +1522,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() { @@ -1589,21 +1544,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 { @@ -1668,14 +1626,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); } @@ -2070,7 +2020,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), @@ -2084,10 +2034,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()), @@ -2096,23 +2043,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()), @@ -2148,6 +2091,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), }; @@ -2287,11 +2233,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())?; @@ -2299,7 +2245,6 @@ impl UserConfig { Ok(()) } - pub fn padded_liked_icon(&self) -> String { format!("{} ", self.behavior.liked_icon) } @@ -2310,88 +2255,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) @@ -2880,189 +2743,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_configured_runtime_default() { + 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_configured_runtime_defaults() { 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/history.rs b/src/infra/history.rs index 4cda8fbc..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"; @@ -542,24 +544,13 @@ pub fn spawn_history_collector(app: Arc>) -> HistoryCollectorHandle { /// 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"), - ) + history_state_file_path(LAST_RECAP_FILE_NAME) } fn auto_recap_is_due(last_recap_contents: Option<&str>, now_ts: i64) -> bool { @@ -844,13 +835,12 @@ 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), + 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), ) } @@ -2237,14 +2227,7 @@ 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"), - ) + history_state_file_path(LAST_SYNCED_FILE_NAME) } #[derive(Clone, Copy, Debug)] diff --git a/src/infra/local/dispatch.rs b/src/infra/local/dispatch.rs index 7021e822..83c8576e 100644 --- a/src/infra/local/dispatch.rs +++ b/src/infra/local/dispatch.rs @@ -109,7 +109,11 @@ 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; + let mut app = app.lock().await; + app.runtime_state.volume_percent = *volume; + app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( + *volume, + )); true } None => false, @@ -305,7 +309,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 047c56fa..c8c48169 100644 --- a/src/infra/media_metadata.rs +++ b/src/infra/media_metadata.rs @@ -131,7 +131,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..c1ec720f 100644 --- a/src/infra/network/native_shuffle.rs +++ b/src/infra/network/native_shuffle.rs @@ -335,7 +335,10 @@ 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(crate::core::state::PersistedRuntimeState::shuffle_enabled( + true, + )); generation }; diff --git a/src/infra/network/playback.rs b/src/infra/network/playback.rs index 636c51b7..b621644c 100644 --- a/src/infra/network/playback.rs +++ b/src/infra/network/playback.rs @@ -749,7 +749,10 @@ 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(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 @@ -1025,10 +1028,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 +1283,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 +1522,10 @@ 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(crate::core::state::PersistedRuntimeState::shuffle_enabled( + desired_shuffle_state, + )); } Err(load_err) => { let Some((device_id, context)) = api_fallback else { @@ -1569,7 +1575,10 @@ 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(crate::core::state::PersistedRuntimeState::shuffle_enabled( + desired_shuffle_state, + )); } Err(e) => { #[cfg(feature = "streaming")] @@ -1643,7 +1652,12 @@ 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( + crate::core::state::PersistedRuntimeState::shuffle_enabled( + desired_shuffle_state, + ), + ); } return; } @@ -1956,6 +1970,10 @@ 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(crate::core::state::PersistedRuntimeState::shuffle_enabled( + shuffle_state, + )); return; } @@ -1973,6 +1991,10 @@ 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(crate::core::state::PersistedRuntimeState::shuffle_enabled( + shuffle_state, + )); } Err(e) => { #[cfg(feature = "streaming")] @@ -2041,6 +2063,10 @@ 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(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 @@ -2061,6 +2087,10 @@ 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(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/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 6e199a01..231cd5c6 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 { @@ -876,8 +876,10 @@ 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( + &crate::core::state::PersistedRuntimeState::volume_percent(volume_percent.min(100)), + ); } } } diff --git a/src/infra/player/streaming.rs b/src/infra/player/streaming.rs index cdb3784b..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 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/queue/dispatch.rs b/src/infra/queue/dispatch.rs index bf94264d..d745976f 100644 --- a/src/infra/queue/dispatch.rs +++ b/src/infra/queue/dispatch.rs @@ -94,7 +94,11 @@ 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; + let mut app = app.lock().await; + app.runtime_state.volume_percent = *volume; + 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). @@ -542,7 +546,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 +663,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..10623e26 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::merged_radio_stations; use crate::infra::audio::LocalPlayer; use crate::infra::network::IoEvent; @@ -88,7 +89,11 @@ 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; + let mut app = app.lock().await; + app.runtime_state.volume_percent = *volume; + app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( + *volume, + )); true } None => false, @@ -110,24 +115,23 @@ 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: 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; 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, ); } @@ -310,7 +314,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 +434,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 +480,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 497b1026..e3e59fb8 100644 --- a/src/infra/scripting/engine.rs +++ b/src/infra/scripting/engine.rs @@ -925,7 +925,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/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(); diff --git a/src/infra/subsonic/dispatch.rs b/src/infra/subsonic/dispatch.rs index e763fb7b..d0ebb1ed 100644 --- a/src/infra/subsonic/dispatch.rs +++ b/src/infra/subsonic/dispatch.rs @@ -120,7 +120,11 @@ 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; + let mut app = app.lock().await; + app.runtime_state.volume_percent = *volume; + app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( + *volume, + )); true } None => false, @@ -427,7 +431,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..da5b9d1a 100644 --- a/src/infra/youtube/dispatch.rs +++ b/src/infra/youtube/dispatch.rs @@ -122,7 +122,11 @@ 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; + let mut app = app.lock().await; + app.runtime_state.volume_percent = *volume; + app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( + *volume, + )); true } None => false, @@ -581,7 +585,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/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 3bab3dc8..3ec69397 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -29,8 +29,14 @@ 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::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, StartupBehavior, UserConfig, UserConfigPaths, + validate_tick_rate_milliseconds, BehaviorConfig, StartupBehavior, UserConfig, UserConfigPaths, }; #[cfg(feature = "discord-rpc")] use crate::infra::discord_rpc; @@ -478,12 +484,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() { @@ -980,6 +982,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, +) -> 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_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_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_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_defaults.library_height_percent = Some(runtime_state.library_height_percent); + } + } + + applied_defaults +} + pub async fn run() -> Result<()> { setup_logging()?; info!("spotatui {} starting up", env!("CARGO_PKG_VERSION")); @@ -997,7 +1034,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") @@ -1089,9 +1126,113 @@ 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 = 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) => { + 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) => { + log::warn!("[state] ignoring unreadable runtime state: {e}"); + } + } + } + Err(e) => { + log::warn!("[state] runtime state path is unavailable: {e}"); + } + } + 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 { + let mut state = default_fields; + state.merge_patch(&legacy_radio_fields); + state.merge_patch(&legacy_runtime_fields); + state + }; + 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}"); + } + } + } + } 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 @@ -1142,7 +1283,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"); @@ -1183,7 +1329,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 @@ -1253,9 +1399,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, ))); @@ -1287,10 +1435,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"); @@ -1603,7 +1750,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. @@ -1724,7 +1871,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?; } @@ -2202,7 +2350,10 @@ 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( + crate::core::state::PersistedRuntimeState::shuffle_enabled(shuffle), + ); } continue; } @@ -2212,7 +2363,10 @@ 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(crate::core::state::PersistedRuntimeState::shuffle_enabled( + shuffle, + )); app_lock.dispatch(IoEvent::Shuffle(shuffle)); } MprisEvent::SetLoopStatus(loop_status) => { @@ -2674,14 +2828,73 @@ 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::layout::MAX_PLAYBAR_ROWS; + use crate::core::state::{PersistedRuntimeState, RuntimeState}; + use crate::core::user_config::{StartupBehavior, UserConfig}; 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_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); + 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_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); + assert_eq!(runtime.library_height_percent, 60); + } + #[allow(deprecated)] fn device(id: &str, name: &str) -> Device { Device { diff --git a/src/tui/handlers/announcement_prompt.rs b/src/tui/handlers/announcement_prompt.rs index 384b1cfd..c57d685d 100644 --- a/src/tui/handlers/announcement_prompt.rs +++ b/src/tui/handlers/announcement_prompt.rs @@ -5,8 +5,12 @@ 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); + 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 5788b943..ea8ddfba 100644 --- a/src/tui/handlers/library.rs +++ b/src/tui/handlers/library.rs @@ -90,8 +90,12 @@ 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(&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/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..6d7c0dfc 100644 --- a/src/tui/handlers/resize.rs +++ b/src/tui/handlers/resize.rs @@ -1,84 +1,106 @@ use crate::core::app::App; +use crate::core::layout::MAX_PLAYBAR_ROWS; +use crate::core::state::{PersistedRuntimeState, 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(PersistedRuntimeState::sidebar_width_percent( + app.runtime_state.sidebar_width_percent, + )); } /// 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(PersistedRuntimeState::sidebar_width_percent( + app.runtime_state.sidebar_width_percent, + )); } /// 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(PersistedRuntimeState::playbar_height_rows( + app.runtime_state.playbar_height_rows, + )); } /// 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(PersistedRuntimeState::playbar_height_rows( + app.runtime_state.playbar_height_rows, + )); } /// 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(PersistedRuntimeState::library_height_percent( + app.runtime_state.library_height_percent, + )); } /// 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(PersistedRuntimeState::library_height_percent( + app.runtime_state.library_height_percent, + )); } -/// Reset all pane sizes to their defaults. +/// Reset all pane sizes to configured defaults, 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) + .min(MAX_PLAYBAR_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(PersistedRuntimeState::layout( + app.runtime_state.sidebar_width_percent, + app.runtime_state.playbar_height_rows, + app.runtime_state.library_height_percent, + )); } #[cfg(test)] @@ -88,128 +110,150 @@ 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_configured_defaults() { 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_configured_defaults() { + 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); + } + + #[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); + } } 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..9c392555 100644 --- a/src/tui/handlers/select_device.rs +++ b/src/tui/handlers/select_device.rs @@ -92,8 +92,10 @@ 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( + &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 9551baa6..adaf038d 100644 --- a/src/tui/runner.rs +++ b/src/tui/runner.rs @@ -847,8 +847,12 @@ 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); + 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 @@ -899,7 +903,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() { @@ -1355,7 +1359,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 61741cba..e506d52b 100644 --- a/src/tui/ui/player.rs +++ b/src/tui/ui/player.rs @@ -694,7 +694,7 @@ fn draw_playbar_controls(f: &mut Frame<'_>, app: &App, controls_area: 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 { @@ -873,7 +873,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, @@ -897,7 +897,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, @@ -921,7 +921,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, @@ -954,7 +954,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, @@ -1145,7 +1145,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 @@ -1166,7 +1166,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,