diff --git a/CHANGELOG.md b/CHANGELOG.md index cae7c205..d87dd9f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - **Help search highlights its matches**: While filtering the Help menu (search key, `/` by default), every occurrence of your search terms is now highlighted in the visible rows, so you can see at a glance which part of a row matched. Highlighting follows the same smart-case rule as the filter itself ([#408](https://github.com/LargeModGames/spotatui/issues/408)). +- **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, playback-session files, Spotify token caches, and legacy native streaming credentials and audio cache are migrated on first use when the new target path does not already exist, preserving free-source startup, existing in-app radio favorites, Spotify login sessions, and native streaming setup during upgrade. If a new state/cache target already exists, the legacy file or directory is left in place instead of being merged or overwritten. ### Fixed diff --git a/PLUGINS.md b/PLUGINS.md index 4dd19d20..eae10e7b 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -17,14 +17,20 @@ 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. Manual examples use `${XDG_CONFIG_HOME:-$HOME/.config}` for +brevity. That matches spotatui when `XDG_CONFIG_HOME` is unset or absolute; if +it is relative, spotatui ignores it and loads plugins from +`$HOME/.config/spotatui/plugins`, so copy there instead. ## First-party examples diff --git a/README.md b/README.md index 9d39873a..bd2b9b85 100644 --- a/README.md +++ b/README.md @@ -211,13 +211,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 @@ -231,7 +231,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. @@ -239,7 +239,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 @@ -255,7 +255,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 @@ -338,13 +338,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 4bda8b4a..0e9e23a4 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -40,7 +40,9 @@ Press `Enter` to apply the filter and `Esc` to clear it. ## 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..a53104f5 100644 --- a/examples/plugins/README.md +++ b/examples/plugins/README.md @@ -18,21 +18,24 @@ privileges, so read anything you install from elsewhere (see ## Installing -Single-file plugins go straight into `plugins/`: +Single-file plugins go straight into `plugins/` under the spotatui app config +directory (see [`PLUGINS.md`](../../PLUGINS.md) for config path rules): ```bash -cp track-notifier.lua ~/.config/spotatui/plugins/ +mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/spotatui/plugins" +cp track-notifier.lua "${XDG_CONFIG_HOME:-$HOME/.config}/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/ +mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/spotatui/plugins" +cp -r session-stats "${XDG_CONFIG_HOME:-$HOME/.config}/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..8a641431 100644 --- a/examples/plugins/accent-cycler.lua +++ b/examples/plugins/accent-cycler.lua @@ -2,10 +2,10 @@ -- -- Theme overrides from set_theme are runtime-only; they reset when spotatui restarts. -- --- Install (single file): --- cp accent-cycler.lua ~/.config/spotatui/plugins/ +-- Install (single file; see PLUGINS.md for config path rules): +-- cp accent-cycler.lua "${XDG_CONFIG_HOME:-$HOME/.config}/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..62bc6545 100644 --- a/examples/plugins/now-playing-webhook.lua +++ b/examples/plugins/now-playing-webhook.lua @@ -3,8 +3,8 @@ -- A small example of the async HTTP + JSON API. Useful for scrobblers, Discord/Slack -- webhooks, home-automation triggers, or a "what am I listening to" endpoint. -- --- Install (single file): --- cp now-playing-webhook.lua ~/.config/spotatui/plugins/ +-- Install (single file; see PLUGINS.md for config path rules): +-- cp now-playing-webhook.lua "${XDG_CONFIG_HOME:-$HOME/.config}/spotatui/plugins/" -- Edit WEBHOOK_URL below first. local WEBHOOK_URL = "https://example.com/webhook" diff --git a/examples/plugins/now-playing.lua b/examples/plugins/now-playing.lua index a1361fdc..ae4015a5 100644 --- a/examples/plugins/now-playing.lua +++ b/examples/plugins/now-playing.lua @@ -2,10 +2,10 @@ -- widget beside synchronized lyrics, laid out in a row container with -- per-axis size hints. -- --- Install (single file): --- cp now-playing.lua ~/.config/spotatui/plugins/ +-- Install (single file; see PLUGINS.md for config path rules): +-- cp now-playing.lua "${XDG_CONFIG_HOME:-$HOME/.config}/spotatui/plugins/" -- --- Suggested binding, in ~/.config/spotatui/config.yml: +-- Suggested binding, in config.yml in the spotatui app config directory: -- plugin_commands: -- now_playing: "ctrl-n" -- diff --git a/examples/plugins/queue-browser.lua b/examples/plugins/queue-browser.lua index 63b09611..bc0678ce 100644 --- a/examples/plugins/queue-browser.lua +++ b/examples/plugins/queue-browser.lua @@ -2,10 +2,10 @@ -- queue more music from your library, showing off the v5 API (custom screens, -- async data reads, timers, storage, navigation). -- --- Install (single file): --- cp queue-browser.lua ~/.config/spotatui/plugins/ +-- Install (single file; see PLUGINS.md for config path rules): +-- cp queue-browser.lua "${XDG_CONFIG_HOME:-$HOME/.config}/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..b10e9eb3 100644 --- a/examples/plugins/session-stats/main.lua +++ b/examples/plugins/session-stats/main.lua @@ -3,10 +3,10 @@ -- This is a *directory* plugin: it ships as a folder with an entry point (main.lua) plus a -- helper module (stats.lua). This is the layout `spotatui plugin add owner/repo` produces. -- --- Install (directory): --- cp -r session-stats ~/.config/spotatui/plugins/ +-- Install (directory; see PLUGINS.md for config path rules): +-- cp -r session-stats "${XDG_CONFIG_HOME:-$HOME/.config}/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..5bf1306e 100644 --- a/examples/plugins/track-info-popup.lua +++ b/examples/plugins/track-info-popup.lua @@ -1,9 +1,9 @@ -- 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/ +-- Install (single file; see PLUGINS.md for config path rules): +-- cp track-info-popup.lua "${XDG_CONFIG_HOME:-$HOME/.config}/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..afa8bca8 100644 --- a/examples/plugins/track-notifier.lua +++ b/examples/plugins/track-notifier.lua @@ -1,7 +1,7 @@ -- 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/ +-- Install (single file; see PLUGINS.md for config path rules): +-- cp track-notifier.lua "${XDG_CONFIG_HOME:-$HOME/.config}/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 88cd96c4..9dc91c4f 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}; @@ -1397,6 +1400,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, @@ -1740,13 +1745,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>, @@ -1923,6 +1926,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(), @@ -2101,7 +2106,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")] @@ -2175,14 +2181,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. @@ -2230,6 +2253,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(), @@ -2847,7 +2872,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() @@ -3947,32 +3972,121 @@ 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(); + self.is_config_owned_radio_station_url(url) && !self.is_state_owned_radio_station_url(url) + } + + pub fn is_config_owned_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 is_state_owned_radio_station_url(&self, url: &str) -> bool { + let url = url.trim(); + !url.is_empty() + && self + .runtime_state + .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)); } } } @@ -4028,7 +4142,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 @@ -4046,8 +4160,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; } @@ -4061,8 +4175,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 @@ -4102,8 +4216,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; } @@ -4117,8 +4231,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 @@ -4163,8 +4277,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; } @@ -4178,8 +4292,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 @@ -4241,7 +4355,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, @@ -6258,8 +6372,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"))] @@ -9926,7 +10040,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/auth.rs b/src/core/auth.rs index 25e139fc..c026dccd 100644 --- a/src/core/auth.rs +++ b/src/core/auth.rs @@ -107,6 +107,40 @@ pub(crate) fn write_private_file(path: &Path, contents: &[u8]) -> std::io::Resul std::fs::write(path, contents) } +/// Atomically replace `path` with `contents`, written privately (0o600 on Unix +/// via [`write_private_file`]). +/// +/// The bytes go to a process-unique temporary sibling first, then a rename swaps +/// it over `path`. The unique suffix (pid plus a per-process counter) keeps two +/// concurrent writers from sharing one temp file and renaming each other's +/// half-written bytes into place. On a failed write or rename the temp file is +/// removed best-effort so aborted writes do not accumulate. +pub(crate) fn write_private_file_atomic(path: &Path, contents: &[u8]) -> Result<()> { + let tmp = unique_temp_path(path); + // Clean up on either failure (write or rename). Each attempt uses a fresh + // unique name, so a leaked temp from a partial write would otherwise persist. + if let Err(error) = write_private_file(&tmp, contents).and_then(|()| std::fs::rename(&tmp, path)) + { + let _ = std::fs::remove_file(&tmp); + return Err(error.into()); + } + Ok(()) +} + +/// A temporary sibling of `path` that no other process (or concurrent save in +/// this one) will pick, so atomic writes never collide on a shared temp file. +fn unique_temp_path(path: &Path) -> std::path::PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let mut name = path + .file_name() + .map(|name| name.to_os_string()) + .unwrap_or_default(); + name.push(format!(".{}.{}.tmp", std::process::id(), seq)); + path.with_file_name(name) +} + fn persist_token_to_file(mut token: Token, path: &Path) -> Result<()> { preserve_refresh_token_from_file(&mut token, path); let token_json = serde_json::to_string_pretty(&token)?; diff --git a/src/core/config.rs b/src/core/config.rs index 1372840c..14395036 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -1,5 +1,5 @@ use crate::tui::banner::BANNER; -use anyhow::{anyhow, Error, Result}; +use anyhow::{anyhow, Context, Error, Result}; use serde::{Deserialize, Serialize}; use std::{ fs, @@ -9,8 +9,6 @@ use std::{ 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,21 @@ 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); + if let Err(error) = + migrate_legacy_token_caches_if_unclaimed(&app_config_dir, &app_state_dir) + { + log::warn!( + "failed to migrate legacy Spotify token cache from {} to {}: {error}", + app_config_dir.display(), + app_state_dir.display() + ); + } let paths = ConfigPaths { config_file_path: config_file_path.to_path_buf(), @@ -124,7 +119,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")), } } @@ -330,3 +325,108 @@ impl ClientConfig { } } } + +fn migrate_legacy_token_caches_if_unclaimed( + app_config_dir: &Path, + app_state_dir: &Path, +) -> Result<()> { + // OAuth tokens used to live next to client.yml; preserve them when moving + // app-managed state out of the config directory. + crate::core::migrations::migrate_legacy_path_if_unclaimed( + &app_config_dir.join(TOKEN_CACHE_FILE), + &app_state_dir.join(TOKEN_CACHE_FILE), + )?; + + for entry in + fs::read_dir(app_config_dir).with_context(|| format!("reading {}", app_config_dir.display()))? + { + let entry = entry?; + let file_name = entry.file_name(); + let Some(file_name) = file_name.to_str() else { + continue; + }; + + if file_name == TOKEN_CACHE_FILE || !is_client_token_cache_file(file_name) { + continue; + } + + crate::core::migrations::migrate_legacy_path_if_unclaimed( + &entry.path(), + &app_state_dir.join(file_name), + )?; + } + + Ok(()) +} + +fn is_client_token_cache_file(file_name: &str) -> bool { + file_name.starts_with(".spotify_token_cache_") && file_name.ends_with(".json") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_token_cache_migration_moves_base_and_client_token_files() { + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path().join("config"); + let state_dir = dir.path().join("state"); + std::fs::create_dir_all(&config_dir).unwrap(); + + std::fs::write(config_dir.join(TOKEN_CACHE_FILE), "base token").unwrap(); + std::fs::write( + config_dir.join(".spotify_token_cache_d420a117.json"), + "client token", + ) + .unwrap(); + std::fs::write(config_dir.join("client.yml"), "client_id: abc").unwrap(); + + migrate_legacy_token_caches_if_unclaimed(&config_dir, &state_dir).unwrap(); + + assert!(!config_dir.join(TOKEN_CACHE_FILE).exists()); + assert!(!config_dir + .join(".spotify_token_cache_d420a117.json") + .exists()); + assert_eq!( + std::fs::read_to_string(state_dir.join(TOKEN_CACHE_FILE)).unwrap(), + "base token" + ); + assert_eq!( + std::fs::read_to_string(state_dir.join(".spotify_token_cache_d420a117.json")).unwrap(), + "client token" + ); + assert!(config_dir.join("client.yml").exists()); + } + + #[test] + fn legacy_token_cache_migration_does_not_overwrite_existing_state_token() { + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path().join("config"); + let state_dir = dir.path().join("state"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::create_dir_all(&state_dir).unwrap(); + + std::fs::write( + config_dir.join(".spotify_token_cache_d420a117.json"), + "legacy token", + ) + .unwrap(); + std::fs::write( + state_dir.join(".spotify_token_cache_d420a117.json"), + "state token", + ) + .unwrap(); + + migrate_legacy_token_caches_if_unclaimed(&config_dir, &state_dir).unwrap(); + + assert_eq!( + std::fs::read_to_string(config_dir.join(".spotify_token_cache_d420a117.json")).unwrap(), + "legacy token" + ); + assert_eq!( + std::fs::read_to_string(state_dir.join(".spotify_token_cache_d420a117.json")).unwrap(), + "state token" + ); + } +} 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..96116088 --- /dev/null +++ b/src/core/migrations.rs @@ -0,0 +1,668 @@ +//! 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::{PersistedRuntimeState, RuntimeState}; +use crate::core::user_config::BehaviorConfig; +use anyhow::{anyhow, Context, Result}; +use serde_yaml::{Mapping, Value}; +use std::{fs, path::Path}; + +const LEGACY_STATE_FILE_RELATIVE_PATHS: [&str; 4] = [ + "last_session.yml", + "history/listens.jsonl", + "history/last_recap_at.txt", + "history/last_synced_at.txt", +]; + +/// Move app-managed files that used to live under the XDG config directory into +/// the XDG state directory. +/// +/// 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. Run once at startup and remove after the config-to-state +/// upgrade window closes. +pub(crate) fn apply_legacy_state_file_migrations() -> Result<()> { + let Some(config_dir) = crate::core::paths::app_config_dir() else { + return Ok(()); + }; + 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)?; + + for relative_path in LEGACY_STATE_FILE_RELATIVE_PATHS { + migrate_legacy_path_if_unclaimed( + &config_dir.join(relative_path), + &state_dir.join(relative_path), + )?; + } + + Ok(()) +} + +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(()) => set_private_dir_permissions(state_path)?, + 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 create_private_dir(path: &Path) -> Result<()> { + fs::create_dir(path).with_context(|| format!("creating {}", path.display()))?; + set_private_dir_permissions(path) +} + +fn set_private_dir_permissions(path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .with_context(|| format!("setting private permissions on {}", path.display()))?; + } + + Ok(()) +} + +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 file_type = entry + .file_type() + .with_context(|| format!("checking {}", source.display()))?; + + if file_type.is_symlink() { + continue; + } + + if file_type.is_dir() { + create_private_dir(&target)?; + 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 + ), + } +} + +/// Seed legacy in-app radio favorites from `config.yml` into `state.yml` when +/// the state field does not exist yet. +/// +/// 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) +} + +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 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 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" + ); + } + + #[cfg(unix)] + #[test] + fn legacy_directory_copy_skips_symlink_entries_before_recursing() { + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("config").join("streaming_cache"); + let state_path = dir.path().join("state").join("streaming_cache"); + std::fs::create_dir_all(legacy_path.join("audio")).unwrap(); + std::fs::create_dir_all(state_path.parent().unwrap()).unwrap(); + std::fs::write(legacy_path.join("audio").join("chunk"), "audio cache").unwrap(); + std::os::unix::fs::symlink(&legacy_path, legacy_path.join("loop")).unwrap(); + let rename_error = std::io::Error::other("forcing copy fallback"); + + assert!(copy_legacy_dir_if_unclaimed(&legacy_path, &state_path, &rename_error).unwrap()); + + assert_eq!( + std::fs::read_to_string(state_path.join("audio").join("chunk")).unwrap(), + "audio cache" + ); + assert!(!state_path.join("loop").exists()); + } + + #[cfg(unix)] + #[test] + fn legacy_directory_copy_sets_private_permissions_on_created_directories() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("config").join("streaming_cache"); + let state_path = dir.path().join("state").join("streaming_cache"); + std::fs::create_dir_all(legacy_path.join("audio")).unwrap(); + std::fs::create_dir_all(state_path.parent().unwrap()).unwrap(); + std::fs::write(legacy_path.join("audio").join("chunk"), "audio cache").unwrap(); + let rename_error = std::io::Error::other("forcing copy fallback"); + + assert!(copy_legacy_dir_if_unclaimed(&legacy_path, &state_path, &rename_error).unwrap()); + + let top_level_mode = std::fs::metadata(&state_path).unwrap().permissions().mode() & 0o777; + let subdirectory_mode = std::fs::metadata(state_path.join("audio")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(top_level_mode, 0o700); + assert_eq!(subdirectory_mode, 0o700); + } + + #[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()); + } +} 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..1c49489b 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 @@ -113,14 +114,14 @@ pub enum PersistedPlayback { } /// Location of the session file: `$SPOTATUI_LAST_SESSION_PATH` when set, else -/// `/last_session.yml` next to the app config. +/// `/last_session.yml`. pub fn default_session_path() -> Result { if let Ok(path) = std::env::var(PATH_ENV) { return Ok(PathBuf::from(path)); } - crate::core::user_config::default_app_config_dir() + crate::core::paths::app_state_dir() .map(|dir| dir.join(FILE_NAME)) - .ok_or_else(|| anyhow!("cannot resolve the spotatui config directory")) + .ok_or_else(|| anyhow!("cannot resolve the spotatui state directory")) } /// Load the persisted session. A missing file means "no session to resume" diff --git a/src/core/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..820b3cca --- /dev/null +++ b/src/core/state.rs @@ -0,0 +1,897 @@ +//! 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")) +} + +/// Outcome of reading `state.yml` off disk, before deciding how to react to a +/// bad file. Loading (startup) surfaces a malformed file as an error, while +/// saving heals it; both share this classification so the read logic lives in +/// one place. +enum StateRead { + /// The file is absent or blank; treat as empty state. + Empty, + /// The file parsed cleanly. + Parsed(PersistedRuntimeState), + /// The file was readable but its contents did not parse. + Malformed(anyhow::Error), + /// The file could not be read (permissions, other I/O error). + Unreadable(anyhow::Error), +} + +fn read_state(path: &Path) -> StateRead { + // Read raw bytes so non-UTF-8 contents count as a corrupt file we can heal on + // save, not an I/O error to propagate. `read_to_string` would fold invalid + // UTF-8 (e.g. a write truncated mid-multibyte-character) into `ErrorKind`, + // leaving it wedged forever. + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return StateRead::Empty, + Err(error) => { + return StateRead::Unreadable( + anyhow::Error::new(error).context(format!("reading {}", path.display())), + ) + } + }; + + let contents = match std::str::from_utf8(&bytes) { + Ok(contents) => contents, + Err(error) => { + return StateRead::Malformed( + anyhow::Error::new(error) + .context(format!("malformed runtime state file: {}", path.display())), + ) + } + }; + + if contents.trim().is_empty() { + return StateRead::Empty; + } + + match serde_yaml::from_str::(contents) { + Ok(state) => StateRead::Parsed(sanitized_persisted_state(&state)), + Err(error) => StateRead::Malformed( + anyhow::Error::new(error) + .context(format!("malformed runtime state file: {}", path.display())), + ), + } +} + +/// 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 read_state(path) { + StateRead::Empty => Ok(PersistedRuntimeState::default()), + StateRead::Parsed(state) => Ok(state), + StateRead::Malformed(error) | StateRead::Unreadable(error) => Err(error), + } +} + +/// Load existing state for a read-modify-write save, healing a corrupt file. +/// +/// `save` re-reads `state.yml` before merging, so a malformed file would +/// otherwise wedge every future save forever and surface a UI error on each +/// runtime change (volume nudge, layout tweak). Mirror the `config.yml` +/// fallback: move the unreadable file aside to `state.yml.bak` (best effort), +/// warn, and continue from empty state. A genuine read failure is still +/// propagated so a transient error never discards good data by overwriting it +/// with defaults. +fn load_for_save(path: &Path) -> Result { + match read_state(path) { + StateRead::Empty => Ok(PersistedRuntimeState::default()), + StateRead::Parsed(state) => Ok(state), + StateRead::Malformed(error) => { + let backup = path.with_extension("yml.bak"); + match std::fs::rename(path, &backup) { + Ok(()) => log::warn!( + "[state] {error:#}; backed up to {} and starting from defaults", + backup.display() + ), + Err(rename_error) => log::warn!( + "[state] {error:#}; could not back up to {} ({rename_error}); overwriting with defaults", + backup.display() + ), + } + Ok(PersistedRuntimeState::default()) + } + StateRead::Unreadable(error) => Err(error), + } +} + +/// 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_for_save(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_for_save(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)?; + } + crate::core::auth::write_private_file_atomic(path, yaml.as_bytes()) + .with_context(|| format!("writing {}", 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 save_heals_a_malformed_state_file_and_keeps_a_backup() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state.yml"); + let garbage = "this: is: not: valid: yaml: [\n"; + std::fs::write(&path, garbage).unwrap(); + + // A malformed file makes `load` fail, but `save` must still succeed. + assert!(load(&path).is_err()); + save(&path, &PersistedRuntimeState::volume_percent(42)).unwrap(); + + // The corrupt bytes are preserved next to the healed file, untouched. + let backup = path.with_extension("yml.bak"); + assert_eq!(std::fs::read_to_string(&backup).unwrap(), garbage); + + // The rewritten file parses and reflects the patch, starting from defaults. + assert_eq!( + load(&path).unwrap(), + PersistedRuntimeState { + volume_percent: Some(42), + ..Default::default() + } + ); + } + + #[test] + fn save_heals_a_non_utf8_state_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state.yml"); + // A write truncated mid-multibyte-character leaves invalid UTF-8 on disk. + let garbage = b"volume_percent: 5\xff\xfe"; + std::fs::write(&path, garbage).unwrap(); + + assert!(load(&path).is_err()); + save(&path, &PersistedRuntimeState::volume_percent(42)).unwrap(); + + let backup = path.with_extension("yml.bak"); + assert_eq!(std::fs::read(&backup).unwrap(), garbage); + assert_eq!( + load(&path).unwrap(), + PersistedRuntimeState { + volume_percent: Some(42), + ..Default::default() + } + ); + } + + #[test] + fn save_leaves_no_temporary_files_behind() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state.yml"); + + save(&path, &PersistedRuntimeState::volume_percent(30)).unwrap(); + + let has_temp = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(Result::ok) + .any(|entry| entry.file_name().to_string_lossy().contains(".tmp")); + assert!(!has_temp, "atomic save left a temporary file behind"); + } + + #[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..a076c2c1 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,19 +2233,16 @@ 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())?; - fs::rename(&tmp_path, &paths.config_file_path)?; + crate::core::auth::write_private_file_atomic(&paths.config_file_path, content_yml.as_bytes())?; Ok(()) } - pub fn padded_liked_icon(&self) -> String { format!("{} ", self.behavior.liked_icon) } @@ -2310,88 +2253,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 +2741,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..f8dd3ddd 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,14 +835,13 @@ 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::paths::app_state_dir() + .map(|dir| dir.join(HISTORY_SUBDIR).join(file_name)) + .ok_or_else(|| anyhow!("cannot resolve the spotatui state directory")) } fn append_listen_record(record: &ListenRecord) -> Result<()> { @@ -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 06cb8415..c9f29860 100644 --- a/src/infra/media_metadata.rs +++ b/src/infra/media_metadata.rs @@ -130,7 +130,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 1491facd..1940bcef 100644 --- a/src/infra/network/utils.rs +++ b/src/infra/network/utils.rs @@ -189,7 +189,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 8b61a3b8..8de13e45 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..092e562a 100644 --- a/src/infra/player/streaming.rs +++ b/src/infra/player/streaming.rs @@ -25,7 +25,7 @@ use librespot_playback::{ use log::{error, info, warn}; use std::any::Any; use std::panic::{catch_unwind, AssertUnwindSafe}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, RwLock}; use std::time::Instant; @@ -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)?; } if let Some(ref path) = audio_cache_path { std::fs::create_dir_all(path).ok(); @@ -775,7 +775,7 @@ impl StreamingPlayer { }; // Reuse a persisted device id so every launch and recovery registers as the // same Spotify Connect device instead of accumulating ghost entries (#297). - if let Some(device_id) = get_or_create_device_id(cache_path.as_deref()) { + if let Some(device_id) = get_or_create_device_id(cache_path.as_deref())? { session_config.device_id = device_id; } @@ -1278,19 +1278,25 @@ fn should_retry_with_fresh_credentials( /// Stable Connect device id, persisted in the streaming cache dir so every /// launch and every in-app recovery registers as the same device (#297). -fn get_or_create_device_id(cache_path: Option<&std::path::Path>) -> Option { - let cache_path = cache_path?; +fn get_or_create_device_id(cache_path: Option<&Path>) -> Result> { + let Some(cache_path) = cache_path else { + return Ok(None); + }; + crate::core::paths::ensure_private_dir(cache_path)?; let id_file = cache_path.join("device_id"); - if let Ok(existing) = std::fs::read_to_string(&id_file) { - let trimmed = existing.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); + match std::fs::read_to_string(&id_file) { + Ok(existing) => { + let trimmed = existing.trim(); + if !trimmed.is_empty() { + return Ok(Some(trimmed.to_string())); + } } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e).with_context(|| format!("reading {}", id_file.display())), } let id = new_device_id_string(); - let _ = std::fs::create_dir_all(cache_path); - let _ = std::fs::write(&id_file, &id); - Some(id) + std::fs::write(&id_file, &id).with_context(|| format!("writing {}", id_file.display()))?; + Ok(Some(id)) } /// Hyphenated UUID-v4-shaped string, matching librespot's default device id format. @@ -1324,8 +1330,9 @@ fn new_device_id_string() -> String { #[cfg(test)] mod tests { use super::{ - get_or_create_device_id, new_device_id_string, should_retry_with_fresh_credentials, - wait_for_oauth_callback_port, RecoveringSink, StreamingConnectionState, + get_or_create_device_id, migrate_legacy_streaming_cache_if_unclaimed, new_device_id_string, + should_retry_with_fresh_credentials, wait_for_oauth_callback_port, RecoveringSink, + StreamingConnectionState, }; use librespot_playback::{audio_backend, convert::Converter, decoder::AudioPacket}; use std::sync::Arc; @@ -1469,8 +1476,8 @@ mod tests { let dir = std::env::temp_dir().join(format!("spotatui_device_id_test_{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); - let first = get_or_create_device_id(Some(&dir)).unwrap(); - let second = get_or_create_device_id(Some(&dir)).unwrap(); + let first = get_or_create_device_id(Some(&dir)).unwrap().unwrap(); + let second = get_or_create_device_id(Some(&dir)).unwrap().unwrap(); assert_eq!(first, second); assert_eq!( std::fs::read_to_string(dir.join("device_id")) @@ -1484,16 +1491,84 @@ mod tests { #[test] fn device_id_none_without_cache_path() { - assert!(get_or_create_device_id(None).is_none()); + assert!(get_or_create_device_id(None).unwrap().is_none()); + } + + #[test] + fn device_id_errors_when_cache_path_setup_fails() { + let dir = tempfile::tempdir().unwrap(); + let cache_path = dir.path().join("not_a_directory"); + std::fs::write(&cache_path, "not a directory").unwrap(); + + let err = get_or_create_device_id(Some(&cache_path)).unwrap_err(); + assert!(err.to_string().contains("creating")); + } + + #[cfg(unix)] + #[test] + fn device_id_errors_when_persistence_write_fails() { + let dir = tempfile::tempdir().unwrap(); + let cache_path = dir.path().join("cache"); + std::fs::create_dir_all(&cache_path).unwrap(); + std::os::unix::fs::symlink( + dir.path().join("missing_parent").join("device_id"), + cache_path.join("device_id"), + ) + .unwrap(); + + let err = get_or_create_device_id(Some(&cache_path)).unwrap_err(); + assert!(err.to_string().contains("writing")); + } + + #[test] + fn legacy_streaming_cache_migration_preserves_connect_device_id() { + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path().join("config"); + let cache_path = dir.path().join("cache").join("streaming_cache"); + let legacy_cache_path = config_dir.join("streaming_cache"); + std::fs::create_dir_all(&legacy_cache_path).unwrap(); + std::fs::write(legacy_cache_path.join("device_id"), "legacy-device-id\n").unwrap(); + std::fs::write( + legacy_cache_path.join("credentials.json"), + "legacy credentials", + ) + .unwrap(); + + assert!(migrate_legacy_streaming_cache_if_unclaimed(&config_dir, &cache_path).unwrap()); + + assert!(!legacy_cache_path.exists()); + assert_eq!( + get_or_create_device_id(Some(&cache_path)).unwrap().unwrap(), + "legacy-device-id" + ); + assert_eq!( + std::fs::read_to_string(cache_path.join("credentials.json")).unwrap(), + "legacy credentials" + ); } } /// Helper to get the default cache path for streaming pub fn get_default_cache_path() -> Option { - dirs::home_dir().map(|home| { - home - .join(".config") - .join("spotatui") - .join("streaming_cache") - }) + let cache_path = crate::core::paths::app_cache_dir()?.join("streaming_cache"); + if let Some(config_dir) = crate::core::paths::app_config_dir() { + if let Err(error) = migrate_legacy_streaming_cache_if_unclaimed(&config_dir, &cache_path) { + log::warn!( + "failed to migrate legacy streaming cache from {} to {}: {error}", + config_dir.join("streaming_cache").display(), + cache_path.display() + ); + } + } + Some(cache_path) +} + +fn migrate_legacy_streaming_cache_if_unclaimed( + app_config_dir: &Path, + cache_path: &Path, +) -> Result { + crate::core::migrations::migrate_legacy_path_if_unclaimed( + &app_config_dir.join("streaming_cache"), + cache_path, + ) } 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 468cdecd..bb56bd7c 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 86c6e2cd..a8999491 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, + apply_legacy_state_file_migrations, +}; +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") @@ -1068,6 +1105,10 @@ screens more often and cost more CPU. Animation-heavy views keep their separate return Ok(()); } + if let Err(e) = apply_legacy_state_file_migrations() { + log::warn!("[state] failed to migrate legacy app data files: {e}"); + } + if let Some(history_matches) = matches.subcommand_matches("history") { println!("{}", cli::handle_history_matches(history_matches)?); return Ok(()); @@ -1089,9 +1130,76 @@ 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 + }; + 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}"); + } + } + } 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 +1250,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 +1296,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 +1366,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, ))); @@ -1286,10 +1401,9 @@ screens more often and cost more CPU. Animation-heavy views keep their separate // Both `Network::new` variants share one signature, so the same call // compiles with or without `streaming`. CLI mode never uses 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"); @@ -1602,7 +1716,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. @@ -1720,7 +1834,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?; } @@ -2198,7 +2313,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; } @@ -2208,7 +2326,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) => { @@ -2670,14 +2791,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..712791dc 100644 --- a/src/tui/handlers/playlist.rs +++ b/src/tui/handlers/playlist.rs @@ -99,16 +99,29 @@ fn remove_radio_station(app: &mut App) { app.set_status_message("Radio station has no stream URL".to_string(), 4); return; }; + let config_owned = app.is_config_owned_radio_station_url(url); + 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() { - None - } else { - Some(idx.min(app.radio_stations.len() - 1)) - }; - app.set_status_message(format!("Removed radio station: {}", removed.name), 4); + if !config_owned { + app.radio_stations.remove(idx); + app.selected_playlist_index = if app.radio_stations.is_empty() { + None + } else { + Some(idx.min(app.radio_stations.len() - 1)) + }; + } + app.set_status_message(format!("Removed saved radio station: {}", removed.name), 4); } Ok(None) => { app.set_status_message( @@ -248,11 +261,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 +380,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 +393,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 +430,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); @@ -407,7 +440,76 @@ mod tests { assert_eq!(app.selected_playlist_index, Some(0)); assert_eq!( app.status_message.as_deref(), - Some("Removed radio station: Groove Salad") + Some("Removed saved radio station: Groove Salad") + ); + } + + #[test] + fn shift_d_on_config_only_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.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!(app.runtime_state.radio_stations.is_empty()); + 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") + ); + } + + #[test] + fn shift_d_on_config_and_state_radio_station_removes_saved_copy_only() { + 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!(app.runtime_state.radio_stations.is_empty()); + assert_eq!(app.radio_stations.len(), 1); + assert_eq!(app.radio_stations[0].name, "Configured Groove"); + assert_eq!(app.selected_playlist_index, Some(0)); + assert_eq!( + app.status_message.as_deref(), + Some("Removed saved radio station: Runtime Duplicate") ); } } 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 7466ec2b..1193974b 100644 --- a/src/tui/runner.rs +++ b/src/tui/runner.rs @@ -853,8 +853,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 @@ -905,7 +909,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() { @@ -1364,7 +1368,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 2b73cb40..8d78986a 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,