Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 7 additions & 5 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,31 +28,33 @@ jobs:
# there, which is why `portaudio-backend` stays on the macOS rows).
# The youtube source needs a yt-dlp binary at runtime (ffmpeg
# recommended); without one it shows an actionable error.
# `soloist` adds the opt-in Soloist engine; it needs the Soloist binary
# the user downloads from Spotify, so nothing changes until it is set.
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact_prefix: linux-x86_64 # most Linux distros
binary_postfix: ""
audio_features: "audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz"
audio_features: "audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist"
- os: ubuntu-24.04-arm # free native ARM64 runner for public repos
target: aarch64-unknown-linux-gnu
artifact_prefix: linux-aarch64 # Raspberry Pi 4/5, ARM servers, Asahi Linux
binary_postfix: ""
audio_features: "audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz"
audio_features: "audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist"
- os: macos-15-intel # Intel runner for x86_64 (available until Aug 2027)
target: x86_64-apple-darwin
artifact_prefix: macos-x86_64
binary_postfix: ""
audio_features: "audio-viz-cpal,portaudio-backend,macos-media,local-files,subsonic,internet-radio,youtube,qobuz,apple-music"
audio_features: "audio-viz-cpal,portaudio-backend,macos-media,local-files,subsonic,internet-radio,youtube,qobuz,soloist,apple-music"
- os: macos-latest # Apple Silicon runner for aarch64
target: aarch64-apple-darwin
artifact_prefix: macos-aarch64
binary_postfix: ""
audio_features: "audio-viz-cpal,portaudio-backend,macos-media,local-files,subsonic,internet-radio,youtube,qobuz,apple-music"
audio_features: "audio-viz-cpal,portaudio-backend,macos-media,local-files,subsonic,internet-radio,youtube,qobuz,soloist,apple-music"
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact_prefix: windows-x86_64
binary_postfix: ".exe"
audio_features: "audio-viz-cpal,windows-media,local-files,subsonic,internet-radio,youtube,qobuz"
audio_features: "audio-viz-cpal,windows-media,local-files,subsonic,internet-radio,youtube,qobuz,soloist"

steps:
- name: Checkout sources
Expand Down
20 changes: 16 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ jobs:
# whenever a feature is added there, or the release build becomes the
# first place a break shows up.
- name: all-sources
args: "--locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz"
args: "--locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist"
# `mcp-server` and `ai-dj` are two front doors on the same `dj-core`
# implementation, and each has to build without the other: the
# field-level `#[cfg(feature = "ai-dj")]` on `DjState::setup` exists
Expand Down Expand Up @@ -77,6 +77,10 @@ jobs:
# frontend.
- name: gui
args: "--locked --no-default-features --features telemetry,gui,streaming,discord-rpc,self-update,scripting,mcp-server,mpris,local-files,subsonic,internet-radio,youtube,qobuz,audio-viz,audio-viz-cpal"
# The Soloist engine with both frontends: the GUI `owns_sound` arm and
# the TUI badge. all-sources carries `soloist` without `gui`.
- name: soloist
args: "--locked --no-default-features --features telemetry,tui,gui,streaming,discord-rpc,self-update,scripting,mcp-server,mpris,local-files,subsonic,internet-radio,youtube,qobuz,audio-viz,audio-viz-cpal,soloist"
env:
RUSTFLAGS: "-C linker-features=-lld"
steps:
Expand Down Expand Up @@ -107,7 +111,7 @@ jobs:
- name: default
args: "--locked"
- name: all-sources
args: "--locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz"
args: "--locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist"
# See the check job for why the two single-door DJ legs exist.
- name: mcp-only
args: "--locked --no-default-features --features telemetry,tui,mcp-server"
Expand All @@ -121,6 +125,10 @@ jobs:
# See the check job for why the gui leg exists.
- name: gui
args: "--locked --no-default-features --features telemetry,gui,streaming,discord-rpc,self-update,scripting,mcp-server,mpris,local-files,subsonic,internet-radio,youtube,qobuz,audio-viz,audio-viz-cpal"
# The Soloist engine with both frontends: the GUI `owns_sound` arm and
# the TUI badge. all-sources carries `soloist` without `gui`.
- name: soloist
args: "--locked --no-default-features --features telemetry,tui,gui,streaming,discord-rpc,self-update,scripting,mcp-server,mpris,local-files,subsonic,internet-radio,youtube,qobuz,audio-viz,audio-viz-cpal,soloist"
env:
RUSTFLAGS: "-C linker-features=-lld"
steps:
Expand Down Expand Up @@ -209,7 +217,7 @@ jobs:
- name: default
args: "--locked"
- name: all-sources
args: "--locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz"
args: "--locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist"
# See the check job for why the two single-door DJ legs exist.
- name: mcp-only
args: "--locked --no-default-features --features telemetry,tui,mcp-server"
Expand All @@ -226,6 +234,10 @@ jobs:
# See the check job for why the gui leg exists.
- name: gui
args: "--locked --no-default-features --features telemetry,gui,streaming,discord-rpc,self-update,scripting,mcp-server,mpris,local-files,subsonic,internet-radio,youtube,qobuz,audio-viz,audio-viz-cpal"
# The Soloist engine with both frontends: the GUI `owns_sound` arm and
# the TUI badge. all-sources carries `soloist` without `gui`.
- name: soloist
args: "--locked --no-default-features --features telemetry,tui,gui,streaming,discord-rpc,self-update,scripting,mcp-server,mpris,local-files,subsonic,internet-radio,youtube,qobuz,audio-viz,audio-viz-cpal,soloist"
env:
RUSTFLAGS: "-C linker-features=-lld"
steps:
Expand Down Expand Up @@ -268,7 +280,7 @@ jobs:
runs-on: macos-latest
needs: prepare
env:
FEATURES: "telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz-cpal,portaudio-backend,macos-media,local-files,subsonic,internet-radio,youtube,qobuz,apple-music"
FEATURES: "telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz-cpal,portaudio-backend,macos-media,local-files,subsonic,internet-radio,youtube,qobuz,soloist,apple-music"
steps:
- uses: actions/checkout@master
- uses: dtolnay/rust-toolchain@stable
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
os: ${{ github.event_name == 'pull_request' && fromJSON('["ubuntu-24.04"]') || fromJSON('["ubuntu-24.04", "macos-latest", "windows-latest"]') }}
env:
# Keep in sync with the spotatui dependency's features in desktop/Cargo.toml.
FEATURES: "telemetry,gui,streaming,discord-rpc,self-update,scripting,mcp-server,mpris,macos-media,windows-media,local-files,subsonic,internet-radio,youtube,qobuz,apple-music,audio-viz,audio-viz-cpal"
FEATURES: "telemetry,gui,streaming,discord-rpc,self-update,scripting,mcp-server,mpris,macos-media,windows-media,local-files,subsonic,internet-radio,youtube,qobuz,soloist,apple-music,audio-viz,audio-viz-cpal"
defaults:
run:
shell: bash
Expand Down
19 changes: 13 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ when working with code in this repository. `CLAUDE.md` imports it: edit this fil
# Full build (native streaming + audio viz + Lua scripting + OS integrations)
cargo run

# Slim build - no librespot/audio/scripting; fastest iteration, one of CI's eight legs
# Slim build - no librespot/audio/scripting; fastest iteration, one of CI's nine legs
cargo run --no-default-features --features telemetry,tui

# With the alternative sources (Local/Subsonic/Radio/YouTube/Qobuz). These are NOT
Expand All @@ -33,18 +33,19 @@ cargo test --no-default-features --features telemetry,tui

These slim commands are the *fast local gate*, not the full picture. GitHub Actions
(`.github/workflows/ci.yml`) runs `check`, `test`, and `clippy` on `ubuntu-latest`
across an **eight-leg** feature matrix, plus one `macos-latest` job (below):
across a **nine-leg** feature matrix, plus one `macos-latest` job (below):

| Leg | Features |
|-----|----------|
| `default` | a plain `cargo test`: streaming + audio-viz-cpal + scripting + self-update + OS integrations |
| `all-sources` | the **Linux** release feature set from `cd.yml` (adds cover-art, mcp-server, ai-dj, audio-viz, all five sources) |
| `all-sources` | the **Linux** release feature set from `cd.yml` (adds cover-art, mcp-server, ai-dj, audio-viz, all five sources, soloist) |
| `mcp-only` | `telemetry,tui,mcp-server` |
| `ai-dj-only` | `telemetry,tui,ai-dj` |
| `slim` | `telemetry,tui` |
| `headless` | `telemetry` - one of three legs without `tui`. `mod tui` is feature-gated, so this leg turns any `crate::tui` import from core/infra/cli into a compile error - what keeps a second frontend from silently re-coupling to the terminal one |
| `headless-streaming` | `telemetry,streaming` - `check` + `clippy` only, no `test` job. Proves native-streaming startup (`runtime/streaming/`) and the player-event wiring type-check and pass clippy with no terminal frontend in scope. Its two entry points carry `allow(dead_code)` there, so the leg does not prove they are live |
| `headless-streaming` | `telemetry,streaming` - `check` + `clippy` only, no `test` job. Proves native-streaming startup (`runtime/streaming/`) and the player-event wiring type-check and pass clippy with no terminal frontend in scope. Its entry points carry `allow(dead_code)` there, so the leg does not prove they are live |
| `gui` | `telemetry,gui,streaming,discord-rpc,self-update,scripting,mcp-server,mpris,local-files,subsonic,internet-radio,youtube,qobuz,audio-viz,audio-viz-cpal` - the third leg without `tui`: the Linux feature set a `spotatui-gui` build carries, the five sources and the player bar's visualizer included. `check`, `clippy` and `test` |
| `soloist` | the `gui` leg's list plus `tui,soloist`: the Soloist engine with both frontends (the GUI `owns_sound` arm, the TUI badge). `check`, `clippy` and `test` |

- Every CI leg passes `--locked`; the local commands do not. Regenerate
`Cargo.lock` after any `Cargo.toml` edit or all legs fail at once.
Expand Down Expand Up @@ -81,7 +82,7 @@ across an **eight-leg** feature matrix, plus one `macos-latest` job (below):
`cargo clippy --manifest-path desktop/Cargo.toml -- -D warnings` and
`cargo test --manifest-path desktop/Cargo.toml`.
- **Two locks.** `desktop/` has its own committed `Cargo.lock`, separate from the
root `Cargo.lock` the eight legs check. Desktop jobs run **without**
root `Cargo.lock` the nine legs check. Desktop jobs run **without**
`--locked`, so a root dependency change only makes the desktop lock stale: the
job prints a `::warning::` and keeps going. Refresh it in the release commit
(`how_to_release.md` step 1), together with the `desktop/Cargo.toml` version.
Expand Down Expand Up @@ -343,7 +344,7 @@ same predicate the row uses, so the two cannot disagree.

### Config & on-disk files

Seven files, seven owners - a value that changes as the app runs goes in state,
Eight files, eight owners - a value that changes as the app runs goes in state,
never config:

| File | Owner | Contents |
Expand All @@ -354,6 +355,7 @@ never config:
| `last_session.yml` (state dir) | `core/persisted_playback.rs` | non-Spotify playback + native queue |
| `playlist_sync.yml` (state dir) | `core/playlist_sync/store.rs` | playlist links + match cache |
| `qobuz_credentials.yml` (config dir) | `infra/qobuz/auth.rs` | the Qobuz login token (feature `qobuz`) |
| `soloist_credentials.yml` (config dir) | `infra/soloist/auth.rs` | the Soloist API key (feature `soloist`), written by the Soloist API Key settings row; `SPOTATUI_SOLOIST_API_KEY` wins over it. Soloist owns its own `<state dir>/soloist/` and `<cache dir>/soloist/`; `infra/soloist/` writes only its playback keys (quality, normalization) in `settings/Users/*/prefs` and two `.spotatui-*` dot-files there. `infra/soloist/download.rs` owns `<state dir>/soloist-bin/`, one folder per build it downloaded from Spotify |
| `visualizer.yml` (config dir) | `core/visualizer_look.rs` (read only) | the GUI player-bar visualizer's look; `runtime/gui.rs` reads it at launch and again on each edit (feature `gui`, only where `gui::visualizer::AVAILABLE`) |

- All paths resolve through `core/paths.rs`, never `dirs::` directly.
Expand Down Expand Up @@ -402,6 +404,10 @@ never config:
macOS rows enable it). Its code sits behind
`all(feature = "apple-music", target_os = "macos")`; the `Source` variant and
the `App` state compile everywhere so callers need no `#[cfg]`.
- `soloist` (pulls `streaming`) is the opt-in Spotify Soloist engine behind
spotatui's own device: `behavior.playback_engine: soloist` starts the binary
the user downloaded instead of a librespot Spirc device. Not in `default`;
`all-sources`, the release rows in cd.yml and the macOS job carry it.
- `scripting` (mlua Lua plugins) is default-on and gates `src/infra/scripting/` +
`src/cli/plugin.rs`; the slim gate never compiles it, so verify scripting
changes with a default `cargo test`.
Expand Down Expand Up @@ -432,6 +438,7 @@ reads a file in that directory. Other agents must open it.
| Decoded repeat/shuffle | `src/infra/queue/AGENTS.md` |
| Radio tune-in | `src/infra/radio/AGENTS.md` |
| Qobuz streaming | `src/infra/qobuz/AGENTS.md` |
| The Soloist engine | `src/infra/soloist/AGENTS.md` |

### Alternative sources (Local / Subsonic / Radio / YouTube / Qobuz)

Expand Down
13 changes: 10 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ arboard = { version = "3.4", features = ["wayland-data-control"] }
librespot-playback = { version = "=0.8.6", package = "spotatui-librespot-playback", optional = true, default-features = false, features = ["rodio-backend"] }

[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Globalization"] }
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Foundation", "Win32_Security", "Win32_System_JobObjects"] }
arboard = "3.4"
# On Windows we default to rodio for audio output.
# Without this, librespot-playback falls back to the `pipe` sink which writes raw audio bytes
Expand All @@ -155,6 +155,8 @@ smtc-tokio = { version = "0.1.0", optional = true }
# MPRIS D-Bus support (Linux only)
[target.'cfg(target_os = "linux")'.dependencies]
mpris-server = { version = "0.10", optional = true }
# The Soloist child's parent-death signal.
libc = "0.2"

# macOS: librespot-playback + Now Playing / Media Key support
# On macOS we use portaudio-backend by default to avoid the pipe sink fallback (which outputs to stdout).
Expand Down Expand Up @@ -224,8 +226,9 @@ queue-download = ["audio-decode-queue", "dep:tempfile"]
# e.g. `cargo run --features all-sources`. Deliberately NOT part of `default`:
# the release binaries enable these explicitly (see .github/workflows/cd.yml) and
# adding them to `default` would pull lofty/symphonia/md-5/stream-download into
# every plain `cargo build`.
all-sources = ["local-files", "subsonic", "internet-radio", "youtube", "qobuz"]
# every plain `cargo build`. It also carries `soloist`, so the release binaries
# ship the Soloist engine.
all-sources = ["local-files", "subsonic", "internet-radio", "youtube", "qobuz", "soloist"]
local-files = ["audio-decode-queue", "queue", "dep:lofty", "dep:symphonia"]
subsonic = ["dep:md-5", "audio-decode-queue", "queue", "queue-download", "dep:tempfile", "reqwest/stream"]
internet-radio = ["audio-decode", "dep:stream-download", "dep:icy-metadata"]
Expand All @@ -237,6 +240,10 @@ youtube = ["audio-decode-queue", "queue", "queue-download"]
# Qobuz through its encrypted CMAF stream: each track is decrypted and rebuilt
# as a FLAC tempfile while it plays through the shared LocalPlayer.
qobuz = ["audio-decode-queue", "queue", "queue-download", "reqwest/stream", "dep:stream-download", "dep:bytes", "dep:md-5", "dep:sha2", "dep:hex", "dep:base64", "dep:regex", "dep:aes", "dep:cbc", "dep:ctr", "dep:hkdf"]
# Spotify Soloist as the engine behind spotatui's own Connect device: a child
# process the user downloads from Spotify, driven over its loopback WebSocket.
# No new crates. Opt-in at runtime through `behavior.playback_engine`.
soloist = ["streaming"]
# Apple Music on macOS: a remote for the Music app, which plays the audio itself.
# No decoder and no Apple crates; off by default and not in `all-sources`, since
# it only works on macOS. The macOS rows in cd.yml and the `macos` CI job enable it.
Expand Down
1 change: 1 addition & 0 deletions desktop/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ spotatui = { path = "..", default-features = false, features = [
"internet-radio",
"youtube",
"qobuz",
"soloist",
"apple-music",
"audio-viz",
"audio-viz-cpal",
Expand Down
19 changes: 18 additions & 1 deletion gui/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1021,7 +1021,14 @@ function setting(
description: string,
value: SettingRow["value"],
): SettingRow {
return { id: `behavior.${id}`, group, name, description, value };
return {
id: `behavior.${id}`,
group,
name,
description,
value,
button: null,
};
}

function visualizerRow(mode: "Spectrum" | "Waveform"): SettingRow {
Expand Down Expand Up @@ -1152,6 +1159,16 @@ export const settings: ServerMessage[] = [
labels: ["96", "160", "320"],
},
),
{
...setting(
"soloist_path",
"streaming",
"Soloist Path",
"The Soloist program the Soloist engine runs",
{ kind: "text", value: "" },
),
button: { label: "Download from Spotify", busy: false },
},
setting(
"enable_discord_rpc",
"integrations",
Expand Down
Loading
Loading