Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
e0ca6d1
feat: add centralised path helper
DinoLeung Jul 24, 2026
cb8adba
Merge branch 'fix/upddate-flake' into xdg-config-path
DinoLeung Jul 24, 2026
6260779
chore: cargo fmt path helper
DinoLeung Jul 24, 2026
f8843ec
chore: use xdg config path for client config
DinoLeung Jul 24, 2026
1085069
feat: add cache and state paths to path helper
DinoLeung Jul 25, 2026
46e98b0
chore: refactor to read and wirte from xdg paths
DinoLeung Jul 25, 2026
dcd2113
chore: document XDG-aware config paths
DinoLeung Jul 26, 2026
3f4aa8a
chore: document XDG-aware config paths in plugins
DinoLeung Jul 26, 2026
04b9460
feat: add runtime state persistence model
DinoLeung Jul 27, 2026
9de4fc7
feat: move runtime state out of config
DinoLeung Jul 28, 2026
7b8140d
Merge branch 'LargeModGames:main' into xdg-config-path
DinoLeung Jul 28, 2026
bf31e42
feat: move generated app state into XDG state/cache dirs
DinoLeung Jul 28, 2026
a3d4444
docs: clarify absolute-only XDG path handling
DinoLeung Jul 28, 2026
f454876
fix: preserve runtime volume and layout state
DinoLeung Jul 28, 2026
09a82c7
test: cover runtime-backed plugin config snapshot
DinoLeung Jul 28, 2026
81168de
fix: dedupe shared radio station sanitization
DinoLeung Jul 28, 2026
0d403d9
chore: reuse state radio station sanitizer in dispatch
DinoLeung Jul 30, 2026
26a0d5f
chore: use platform-neutral fallback path joins
DinoLeung Jul 30, 2026
a102b38
fix: create state dir with private permissions before token cache
DinoLeung Jul 30, 2026
bee9afd
fix: create private dirs for token and streaming credential caches
DinoLeung Jul 30, 2026
863be30
fix: merge runtime state saves without clobbering fields
DinoLeung Jul 31, 2026
b20d9c4
fix: migrate legacy config runtime state before rewrite
DinoLeung Jul 31, 2026
c0b8cbe
chore: cover legacy free-source migration
DinoLeung Jul 31, 2026
cccc3d0
fix: harden legacy state path migration
DinoLeung Jul 31, 2026
d3bc833
docs: clarify XDG cache migration behavior
DinoLeung Jul 31, 2026
cb44bd8
docs: simplify manual install examples
DinoLeung Aug 1, 2026
931aeb3
fix: run legacy state-file migrations at startup
DinoLeung Aug 1, 2026
33ceada
fix: migrate legacy auth and streaming caches
DinoLeung Aug 1, 2026
ccb675e
fix: preserve declarative radio station config during migration
DinoLeung Aug 1, 2026
249c0fb
fix: propagate streaming private cache setup failures
DinoLeung Aug 1, 2026
8b9e0b6
fix: skip symlinks during legacy directory migration
DinoLeung Aug 1, 2026
a066afc
Merge LargeModGames/main into xdg-config-path
DinoLeung Aug 1, 2026
e8dcef5
docs: clarify XDG cache migration behavior
DinoLeung Aug 1, 2026
8aa6203
docs: centralize plugin config path guidance
DinoLeung Aug 3, 2026
7776290
Merge branch 'main' into xdg-config-path
LargeModGames Aug 3, 2026
4003ccf
Merge branch 'main' into xdg-config-path
LargeModGames Aug 6, 2026
710e600
fix: heal corrupt state.yml and make state/config writes collision-safe
LargeModGames Aug 6, 2026
0016e5d
fix: heal non-utf8 state.yml and clean up temp on write failure
LargeModGames Aug 6, 2026
372043a
refactor: return anyhow::Result from write_private_file_atomic
LargeModGames Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 10 additions & 4 deletions PLUGINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,20 @@ spotatui plugin remove <name> # uninstall
spotatui plugin new <name> # scaffold a new plugin to start from
```

Plugins are cloned into `~/.config/spotatui/plugins/<name>/` 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/<name>/` 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

Expand Down
20 changes: 12 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -231,15 +231,15 @@ 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.

- Customization guide: [`docs/configuration.md`](docs/configuration.md), with a commented [`examples/config.example.yml`](examples/config.example.yml)
- 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

Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down
30 changes: 24 additions & 6 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@

spotatui reads `config.yml` from the app config directory:

- Linux / macOS: `~/.config/spotatui/config.yml`
- Windows: `C:\Users\<you>\.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 <path>`.

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<pid>`).
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 10 additions & 4 deletions docs/native-streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 14 additions & 10 deletions docs/scripting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -54,10 +56,11 @@ spotatui plugin remove <name> # uninstall
spotatui plugin new <name> # scaffold a new plugin to start from
```

`add` clones the repository into `~/.config/spotatui/plugins/<name>/` (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/<name>/` 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".
Expand All @@ -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:

Expand Down Expand Up @@ -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/<plugin>.json`. Values must be JSON-serializable (tables,
strings, numbers, booleans); functions and userdata raise.
`plugin-data/<plugin>.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.
Expand Down
4 changes: 3 additions & 1 deletion docs/themes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 8 additions & 5 deletions examples/plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
6 changes: 3 additions & 3 deletions examples/plugins/accent-cycler.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Comment thread
LargeModGames marked this conversation as resolved.
--
-- Suggested binding, in ~/.config/spotatui/config.yml:
-- Suggested binding, in config.yml in the spotatui app config directory:
-- plugin_commands:
-- cycle_accent: "ctrl-y"

Expand Down
4 changes: 2 additions & 2 deletions examples/plugins/now-playing-webhook.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading