diff --git a/.coderabbit.yaml b/.coderabbit.yaml
index 9da4efb6..1d0be457 100644
--- a/.coderabbit.yaml
+++ b/.coderabbit.yaml
@@ -51,7 +51,7 @@ reviews:
ttl_secs)` in the async network layer. The TTL is in seconds.
Code behind a feature flag (streaming, cover-art, local-files, subsonic,
- internet-radio, youtube, scripting, mpris, discord-rpc) must compile in
+ internet-radio, youtube, qobuz, scripting, mpris, discord-rpc) must compile in
the slim build (`--no-default-features --features telemetry`). Flag
unguarded use of a gated item.
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index fed66a60..b49dbb81 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -12,7 +12,7 @@ cargo run
# Slim build - no librespot/audio/scripting; fastest iteration, one of CI's seven legs
cargo run --no-default-features --features telemetry,tui
-# With the free alternative sources (Local/Subsonic/Radio/YouTube). These are NOT
+# With the alternative sources (Local/Subsonic/Radio/YouTube/Qobuz). These are NOT
# in `default`, so a plain `cargo run` is Spotify-only; use the `all-sources` alias
# (or list them individually) to exercise the first-run source picker and playback.
cargo run --features all-sources
@@ -33,7 +33,7 @@ across a **seven-leg** feature matrix:
| 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 four sources) |
+| `all-sources` | the **Linux** release feature set from `cd.yml` (adds cover-art, mcp-server, ai-dj, audio-viz, all five sources) |
| `mcp-only` | `telemetry,tui,mcp-server` |
| `ai-dj-only` | `telemetry,tui,ai-dj` |
| `slim` | `telemetry,tui` |
@@ -45,7 +45,7 @@ across a **seven-leg** feature matrix:
front door has to build without the other.
- Reproducing legs locally: `cargo test` reproduces `default`. For `all-sources`,
copy the exact `--no-default-features --features …` string out of `ci.yml` -
- `cargo test --features all-sources` is **not** it (the alias only adds the four
+ `cargo test --features all-sources` is **not** it (the alias only adds the five
sources on top of default), and the leg includes `audio-viz` (PipeWire), so it
only compiles on Linux.
- Every CI leg passes `--locked`; the local commands do not. Regenerate
@@ -85,7 +85,7 @@ units under `src/`:
| Unit | Role |
|------|------|
| `core/` | Centralized state (`App`), the frontend-neutral tick scheduler (`driver/`), the shared action vocabulary (`action/`), config/state persistence, and the rspotify-free domain types (`plugin_api`, `pagination`, `source`) |
-| `infra/` | Spotify Web API (`network/`), native librespot streaming (`player/`), alternative sources (`local/`, `subsonic/`, `radio/`, `youtube/`, `queue/`), audio viz (`audio/`), Lua scripting (`scripting/`), AI DJ + MCP (`dj/`, `mcp/`), OS integrations (Discord RPC, MPRIS, macOS/Windows media) |
+| `infra/` | Spotify Web API (`network/`), native librespot streaming (`player/`), alternative sources (`local/`, `subsonic/`, `qobuz/`, `radio/`, `youtube/`, `queue/`), audio viz (`audio/`), Lua scripting (`scripting/`), AI DJ + MCP (`dj/`, `mcp/`), OS integrations (Discord RPC, MPRIS, macOS/Windows media) |
| `tui/` | Terminal UI: the event/render loop (`runner.rs`), key plumbing (`event/`), per-block input handlers (`handlers/`), immutable draw fns (`ui/`) |
| `cli/` | clap subcommands: playback control, listening history, self-update, MCP relay, plugin management |
| `runtime/` | `mod.rs::run_cli` (entry point + CLI dispatch), `bootstrap.rs::boot` (frontend-neutral config/auth/`App` construction, `run_cli` its sole caller), `cli.rs` (clap assembly + self-update), `pump.rs::start_tokio` (the IoEvent pump), `streaming/` (native-streaming startup every frontend shares: the pure saved-device decision in `mod.rs`, the librespot bring-up in `launch.rs`, gated on `streaming`), `startup.rs` (the UI-launch half, gated on `tui`) |
@@ -120,8 +120,9 @@ worth knowing before adding an event:
- **Source routing**: non-Spotify playback is routed by URI scheme *before* the
Spotify handler, in this order: `route_queue_event` → `route_local_event`
- (`file:`) → `route_subsonic_event` (`subsonic:`) → `route_radio_event`
- (`radio:`) → `route_youtube_event` (`youtube:`) → `Network::handle_network_event`.
+ (`file:`) → `route_subsonic_event` (`subsonic:`) → `route_qobuz_event`
+ (`qobuz:`) → `route_radio_event` (`radio:`) → `route_youtube_event`
+ (`youtube:`) → `Network::handle_network_event`.
This is what keeps `infra/network/` Spotify-only.
- **Service lane**: `Network::runs_on_service_lane` lists events that run on a
detached task so slow, source-agnostic work cannot head-of-line-block the serial
@@ -188,7 +189,7 @@ regressions. Check in this order: `queue_owns_playback()` /
`queue_now_is_spotify()`, then `active_decoded_source()`, then
`is_native_streaming_active_for_playback()`.
-- Starting a decoded source (Local/Subsonic/Radio/YouTube) only **pauses**
+- Starting a decoded source (Local/Subsonic/Qobuz/Radio/YouTube) only **pauses**
librespot - the native flag stays true, so driving librespot directly resumes
the wrong player.
- While the native queue slot owns the sink, `current_playback_context` names the
@@ -360,7 +361,7 @@ navigation. Adding a binding means fields on both `KeyBindings` and
### Config & on-disk files
-Four files, four owners - a value that changes as the app runs goes in state,
+Five files, five owners - a value that changes as the app runs goes in state,
never config:
| File | Owner | Contents |
@@ -369,6 +370,7 @@ never config:
| `client.yml` (config dir) | `core/config.rs` | Spotify app credentials |
| `state.yml` (state dir) | `core/state.rs` | machine-written runtime values |
| `last_session.yml` (state dir) | `core/persisted_playback.rs` | non-Spotify playback + native queue |
+| `qobuz_credentials.yml` (config dir) | `infra/qobuz/auth.rs` | the Qobuz login token (feature `qobuz`) |
- All paths resolve through `core/paths.rs`, never `dirs::` directly.
- `state.yml` saves are read-modify-write **sparse patches** so a second running
@@ -388,7 +390,7 @@ never config:
- `default` = `telemetry, tui, streaming, audio-viz-cpal, macos-media,
windows-media, mpris, discord-rpc, self-update, scripting`. Notably **not**
in default: `cover-art` (so a plain `cargo run` has no album art, though
- every shipped binary enables it), the four sources, and the DJ features.
+ every shipped binary enables it), the five sources, and the DJ features.
- `tui` gates `mod tui` and owns the terminal-only crates (ratatui, crossterm,
tui-bar-graph, colorgrad - the last also pulled by `art-decode` for the
adaptive-theme HSV math). `gui` is a reserved placeholder that only gates the
@@ -404,7 +406,7 @@ never config:
the non-default picks avoid librespot's `pipe` sink writing raw audio to stdout
and destroying the TUI.
- `audio-decode` (rodio) is the shared engine pulled in by the sources;
- `all-sources` = `local-files, subsonic, internet-radio, youtube`.
+ `all-sources` = `local-files, subsonic, internet-radio, youtube, qobuz`.
- `dj-core` is a shared implementation feature pulled in by `mcp-server` and
`ai-dj`; none of the three are in `default`, and neither front door may assume
the other (or `streaming`) is present.
@@ -423,12 +425,21 @@ See `.claude/skills/add-tui-screen/SKILL.md`.
plugins), and `src/cli/CLAUDE.md` (CLI subcommands) load automatically when
working in those directories.
-### Alternative sources (Local / Subsonic / Radio / YouTube)
+### Alternative sources (Local / Subsonic / Radio / YouTube / Qobuz)
-- All four decode through **one** shared rodio sink, `LocalPlayer`
+- All five decode through **one** shared rodio sink, `LocalPlayer`
(`src/infra/audio/player.rs`) - the only file where rodio types appear.
Subsonic and YouTube download each track to a `NamedTempFile` first (YouTube by
shelling out to `yt-dlp`); Radio streams through a non-seekable ring buffer.
+- Qobuz (`src/infra/qobuz/`) plays each track through the web player's
+ encrypted CMAF stream while it downloads: `stream/progressive.rs` is a
+ `stream-download` source that yields the decrypted segments (and restarts at
+ a seek), rebuilt as a FLAC `NamedTempFile` the session keeps. The fetch runs
+ off the pump behind a `fetch_id` guard; a superseded stream is dropped, which
+ cancels its download. The transport (`sign.rs`, `stream/`) is pure and unit
+ tested. The three web-player constants are scraped at runtime (`auth.rs`),
+ cached in `state.yml`, and overridable through `SPOTATUI_QOBUZ_*` env vars;
+ they are never embedded. Failures are status messages, never `handle_error`.
- macOS cannot play any decoded source: `LocalPlayer::new()` bails there because
rodio SIGSEGVs on CoreAudio/Bluetooth (#9/#20) - which is why macOS releases
exclude the source features.
diff --git a/.github/scripts/triage.mjs b/.github/scripts/triage.mjs
index f8ebcd83..be8ded08 100644
--- a/.github/scripts/triage.mjs
+++ b/.github/scripts/triage.mjs
@@ -77,7 +77,7 @@ openIssues = openIssues.filter((i) => i.number !== issue.number);
const compared = openIssues.length;
const system = `You triage GitHub issues for "spotatui", a Rust terminal UI Spotify client
-(it also has non-Spotify sources: local files, Subsonic, internet radio, YouTube).
+(it also has non-Spotify sources: local files, Subsonic, internet radio, YouTube, Qobuz).
Return ONLY a JSON object — no prose, no Markdown fences — with this exact shape:
{
"category": one of ${JSON.stringify(ALLOWED_LABELS)} or null,
diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
index a54a150e..09a50304 100644
--- a/.github/workflows/cd.yml
+++ b/.github/workflows/cd.yml
@@ -21,7 +21,7 @@ jobs:
fail-fast: false
matrix:
include:
- # Multi-source features (local-files, subsonic, internet-radio, youtube)
+ # Multi-source features (local-files, subsonic, internet-radio, youtube, qobuz)
# ship on Linux and Windows only: they share the rodio output engine,
# which is gated off at runtime on macOS (CoreAudio/Bluetooth crashes,
# #9/#20). Add them to the macOS rows once the macOS output path lands.
@@ -31,12 +31,12 @@ jobs:
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"
+ audio_features: "audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz"
- 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"
+ audio_features: "audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz"
- os: macos-15-intel # Intel runner for x86_64 (available until Aug 2027)
target: x86_64-apple-darwin
artifact_prefix: macos-x86_64
@@ -51,7 +51,7 @@ jobs:
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"
+ audio_features: "audio-viz-cpal,windows-media,local-files,subsonic,internet-radio,youtube,qobuz"
steps:
- name: Checkout sources
@@ -196,7 +196,7 @@ jobs:
Checksums (`.sha256`) are optional and only needed if you want to verify the download.
- The Linux and Windows builds include the extra music sources (Local Files, Subsonic, Internet Radio, YouTube). The YouTube source needs [yt-dlp](https://github.com/yt-dlp/yt-dlp) installed (ffmpeg recommended).
+ The Linux and Windows builds include the extra music sources (Local Files, Subsonic, Internet Radio, YouTube, Qobuz). The YouTube source needs [yt-dlp](https://github.com/yt-dlp/yt-dlp) installed (ffmpeg recommended).
To update use "spotatui update --install" or use your package manager of choice.
draft: false
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f160e294..6aa8c15c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,13 +38,13 @@ jobs:
- name: default
args: "--locked"
# The exact feature set shipped for Linux by cd.yml, so every new
- # source subsystem (local-files, subsonic, internet-radio, youtube),
+ # source subsystem (local-files, subsonic, internet-radio, youtube, qobuz),
# cover-art, and both DJ front doors are compiled and tested in CI.
# Keep this list in sync with cd.yml's `Build release binary` step
# 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"
+ 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"
# `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
@@ -100,7 +100,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"
+ 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"
# 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"
@@ -155,7 +155,7 @@ jobs:
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@cargo-llvm-cov
- name: Run tests with coverage
- run: cargo llvm-cov --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 --lcov --output-path lcov.info
+ run: cargo llvm-cov --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 --lcov --output-path lcov.info
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
@@ -192,7 +192,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"
+ 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"
# 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"
diff --git a/AGENTS.md b/AGENTS.md
index 0d5c6cda..b49fd84e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -14,7 +14,7 @@ cargo run
# Slim build - no librespot/audio/scripting; fastest iteration, one of CI's seven legs
cargo run --no-default-features --features telemetry,tui
-# With the free alternative sources (Local/Subsonic/Radio/YouTube). These are NOT
+# With the alternative sources (Local/Subsonic/Radio/YouTube/Qobuz). These are NOT
# in `default`, so a plain `cargo run` is Spotify-only; use the `all-sources` alias
# (or list them individually) to exercise the first-run source picker and playback.
cargo run --features all-sources
@@ -35,7 +35,7 @@ across a **seven-leg** feature matrix:
| 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 four sources) |
+| `all-sources` | the **Linux** release feature set from `cd.yml` (adds cover-art, mcp-server, ai-dj, audio-viz, all five sources) |
| `mcp-only` | `telemetry,tui,mcp-server` |
| `ai-dj-only` | `telemetry,tui,ai-dj` |
| `slim` | `telemetry,tui` |
@@ -47,7 +47,7 @@ across a **seven-leg** feature matrix:
front door has to build without the other.
- Reproducing legs locally: `cargo test` reproduces `default`. For `all-sources`,
copy the exact `--no-default-features --features …` string out of `ci.yml` -
- `cargo test --features all-sources` is **not** it (the alias only adds the four
+ `cargo test --features all-sources` is **not** it (the alias only adds the five
sources on top of default), and the leg includes `audio-viz` (PipeWire), so it
only compiles on Linux.
- Every CI leg passes `--locked`; the local commands do not. Regenerate
@@ -87,7 +87,7 @@ units under `src/`:
| Unit | Role |
|------|------|
| `core/` | Centralized state (`App`), the frontend-neutral tick scheduler (`driver/`), the shared action vocabulary (`action/`), config/state persistence, and the rspotify-free domain types (`plugin_api`, `pagination`, `source`) |
-| `infra/` | Spotify Web API (`network/`), native librespot streaming (`player/`), alternative sources (`local/`, `subsonic/`, `radio/`, `youtube/`, `queue/`), audio viz (`audio/`), Lua scripting (`scripting/`), AI DJ + MCP (`dj/`, `mcp/`), OS integrations (Discord RPC, MPRIS, macOS/Windows media) |
+| `infra/` | Spotify Web API (`network/`), native librespot streaming (`player/`), alternative sources (`local/`, `subsonic/`, `qobuz/`, `radio/`, `youtube/`, `queue/`), audio viz (`audio/`), Lua scripting (`scripting/`), AI DJ + MCP (`dj/`, `mcp/`), OS integrations (Discord RPC, MPRIS, macOS/Windows media) |
| `tui/` | Terminal UI: the event/render loop (`runner.rs`), key plumbing (`event/`), per-block input handlers (`handlers/`), immutable draw fns (`ui/`) |
| `cli/` | clap subcommands: playback control, listening history, self-update, MCP relay, plugin management |
| `runtime/` | `mod.rs::run_cli` (entry point + CLI dispatch), `bootstrap.rs::boot` (frontend-neutral config/auth/`App` construction, `run_cli` its sole caller), `cli.rs` (clap assembly + self-update), `pump.rs::start_tokio` (the IoEvent pump), `streaming/` (native-streaming startup every frontend shares: the pure saved-device decision in `mod.rs`, the librespot bring-up in `launch.rs`, gated on `streaming`), `startup.rs` (the UI-launch half, gated on `tui`) |
@@ -122,8 +122,9 @@ worth knowing before adding an event:
- **Source routing**: non-Spotify playback is routed by URI scheme *before* the
Spotify handler, in this order: `route_queue_event` → `route_local_event`
- (`file:`) → `route_subsonic_event` (`subsonic:`) → `route_radio_event`
- (`radio:`) → `route_youtube_event` (`youtube:`) → `Network::handle_network_event`.
+ (`file:`) → `route_subsonic_event` (`subsonic:`) → `route_qobuz_event`
+ (`qobuz:`) → `route_radio_event` (`radio:`) → `route_youtube_event`
+ (`youtube:`) → `Network::handle_network_event`.
This is what keeps `infra/network/` Spotify-only.
- **Service lane**: `Network::runs_on_service_lane` lists events that run on a
detached task so slow, source-agnostic work cannot head-of-line-block the serial
@@ -190,7 +191,7 @@ regressions. Check in this order: `queue_owns_playback()` /
`queue_now_is_spotify()`, then `active_decoded_source()`, then
`is_native_streaming_active_for_playback()`.
-- Starting a decoded source (Local/Subsonic/Radio/YouTube) only **pauses**
+- Starting a decoded source (Local/Subsonic/Qobuz/Radio/YouTube) only **pauses**
librespot - the native flag stays true, so driving librespot directly resumes
the wrong player.
- While the native queue slot owns the sink, `current_playback_context` names the
@@ -362,7 +363,7 @@ navigation. Adding a binding means fields on both `KeyBindings` and
### Config & on-disk files
-Four files, four owners - a value that changes as the app runs goes in state,
+Five files, five owners - a value that changes as the app runs goes in state,
never config:
| File | Owner | Contents |
@@ -371,6 +372,7 @@ never config:
| `client.yml` (config dir) | `core/config.rs` | Spotify app credentials |
| `state.yml` (state dir) | `core/state.rs` | machine-written runtime values |
| `last_session.yml` (state dir) | `core/persisted_playback.rs` | non-Spotify playback + native queue |
+| `qobuz_credentials.yml` (config dir) | `infra/qobuz/auth.rs` | the Qobuz login token (feature `qobuz`) |
- All paths resolve through `core/paths.rs`, never `dirs::` directly.
- `state.yml` saves are read-modify-write **sparse patches** so a second running
@@ -390,7 +392,7 @@ never config:
- `default` = `telemetry, tui, streaming, audio-viz-cpal, macos-media,
windows-media, mpris, discord-rpc, self-update, scripting`. Notably **not**
in default: `cover-art` (so a plain `cargo run` has no album art, though
- every shipped binary enables it), the four sources, and the DJ features.
+ every shipped binary enables it), the five sources, and the DJ features.
- `tui` gates `mod tui` and owns the terminal-only crates (ratatui, crossterm,
tui-bar-graph, colorgrad - the last also pulled by `art-decode` for the
adaptive-theme HSV math). `gui` is a reserved placeholder that only gates the
@@ -406,7 +408,7 @@ never config:
the non-default picks avoid librespot's `pipe` sink writing raw audio to stdout
and destroying the TUI.
- `audio-decode` (rodio) is the shared engine pulled in by the sources;
- `all-sources` = `local-files, subsonic, internet-radio, youtube`.
+ `all-sources` = `local-files, subsonic, internet-radio, youtube, qobuz`.
- `dj-core` is a shared implementation feature pulled in by `mcp-server` and
`ai-dj`; none of the three are in `default`, and neither front door may assume
the other (or `streaming`) is present.
@@ -425,12 +427,21 @@ See `.claude/skills/add-tui-screen/SKILL.md`.
plugins), and `src/cli/CLAUDE.md` (CLI subcommands) load automatically when
working in those directories.
-### Alternative sources (Local / Subsonic / Radio / YouTube)
+### Alternative sources (Local / Subsonic / Radio / YouTube / Qobuz)
-- All four decode through **one** shared rodio sink, `LocalPlayer`
+- All five decode through **one** shared rodio sink, `LocalPlayer`
(`src/infra/audio/player.rs`) - the only file where rodio types appear.
Subsonic and YouTube download each track to a `NamedTempFile` first (YouTube by
shelling out to `yt-dlp`); Radio streams through a non-seekable ring buffer.
+- Qobuz (`src/infra/qobuz/`) plays each track through the web player's
+ encrypted CMAF stream while it downloads: `stream/progressive.rs` is a
+ `stream-download` source that yields the decrypted segments (and restarts at
+ a seek), rebuilt as a FLAC `NamedTempFile` the session keeps. The fetch runs
+ off the pump behind a `fetch_id` guard; a superseded stream is dropped, which
+ cancels its download. The transport (`sign.rs`, `stream/`) is pure and unit
+ tested. The three web-player constants are scraped at runtime (`auth.rs`),
+ cached in `state.yml`, and overridable through `SPOTATUI_QOBUZ_*` env vars;
+ they are never embedded. Failures are status messages, never `handle_error`.
- macOS cannot play any decoded source: `LocalPlayer::new()` bails there because
rodio SIGSEGVs on CoreAudio/Bluetooth (#9/#20) - which is why macOS releases
exclude the source features.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d3d8d449..025e7e4d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@
### Added
+- **Qobuz source** (`--features qobuz`, included in the Linux and Windows release binaries): spotatui can now play your Qobuz library. Pick Qobuz in the first-run picker or the `d` menu and log in through the browser; the token is saved in `qobuz_credentials.yml` (owner-only on Unix), and `SPOTATUI_QOBUZ_TOKEN` overrides it. The sidebar lists your favorite tracks, playlists, and favorite albums, search finds tracks, and every playback feature the other sources have works: next/previous, seek, repeat and shuffle, the cross-source queue, `last_session.yml` resume, cover art, lyrics, and OS media controls. Playback uses the same encrypted stream the Qobuz web player uses: each track is decrypted and rebuilt as a FLAC temporary file while it plays through spotatui's own engine (playback starts after the first segments arrive, and a seek past the downloaded part continues the download from there), all off the event pump so the UI stays responsive. `behavior.qobuz_quality` picks the stream (5 MP3 320, 6 FLAC 16/44.1, 7 FLAC 24/96, 27 FLAC 24/192; default 6). The constants the API needs are read from the Qobuz web player at runtime and cached by bundle version, never embedded; `SPOTATUI_QOBUZ_APP_ID`, `SPOTATUI_QOBUZ_APP_SECRET`, and `SPOTATUI_QOBUZ_OAUTH_KEY` override them when Qobuz changes its web player. Not on macOS (no decoded source plays there yet).
+
- **Fuzzy filter for the Settings screen**: Press the search key (`/` by default) in Settings to filter the rows instead of scrolling for one. The query is a fuzzy subsequence, so its characters only have to appear in order: `volinc` finds **Volume Increment** and `skms` finds **Seek Duration (ms)**. Rows match on their name and on their `config.yml` key, and are ranked best-first with the matched characters highlighted, so every row the filter leaves shows why it is there. `Enter` applies the filter, `Esc` clears it before it means "leave Settings", and the filter survives `←`/`→` so one query can be walked across tabs. It reuses the Help menu's filter input, so the editing keys (`Ctrl+W`, `Ctrl+U`, `Backspace`) are the same on both screens ([#469](https://github.com/LargeModGames/spotatui/issues/469)).
### Fixed
diff --git a/CLAUDE.md b/CLAUDE.md
index 99345e23..df6b7311 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -14,7 +14,7 @@ cargo run
# Slim build - no librespot/audio/scripting; fastest iteration, one of CI's seven legs
cargo run --no-default-features --features telemetry,tui
-# With the free alternative sources (Local/Subsonic/Radio/YouTube). These are NOT
+# With the alternative sources (Local/Subsonic/Radio/YouTube/Qobuz). These are NOT
# in `default`, so a plain `cargo run` is Spotify-only; use the `all-sources` alias
# (or list them individually) to exercise the first-run source picker and playback.
cargo run --features all-sources
@@ -35,7 +35,7 @@ across a **seven-leg** feature matrix:
| 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 four sources) |
+| `all-sources` | the **Linux** release feature set from `cd.yml` (adds cover-art, mcp-server, ai-dj, audio-viz, all five sources) |
| `mcp-only` | `telemetry,tui,mcp-server` |
| `ai-dj-only` | `telemetry,tui,ai-dj` |
| `slim` | `telemetry,tui` |
@@ -47,7 +47,7 @@ across a **seven-leg** feature matrix:
front door has to build without the other.
- Reproducing legs locally: `cargo test` reproduces `default`. For `all-sources`,
copy the exact `--no-default-features --features …` string out of `ci.yml` -
- `cargo test --features all-sources` is **not** it (the alias only adds the four
+ `cargo test --features all-sources` is **not** it (the alias only adds the five
sources on top of default), and the leg includes `audio-viz` (PipeWire), so it
only compiles on Linux.
- Every CI leg passes `--locked`; the local commands do not. Regenerate
@@ -87,7 +87,7 @@ units under `src/`:
| Unit | Role |
|------|------|
| `core/` | Centralized state (`App`), the frontend-neutral tick scheduler (`driver/`), the shared action vocabulary (`action/`), config/state persistence, and the rspotify-free domain types (`plugin_api`, `pagination`, `source`) |
-| `infra/` | Spotify Web API (`network/`), native librespot streaming (`player/`), alternative sources (`local/`, `subsonic/`, `radio/`, `youtube/`, `queue/`), audio viz (`audio/`), Lua scripting (`scripting/`), AI DJ + MCP (`dj/`, `mcp/`), OS integrations (Discord RPC, MPRIS, macOS/Windows media) |
+| `infra/` | Spotify Web API (`network/`), native librespot streaming (`player/`), alternative sources (`local/`, `subsonic/`, `qobuz/`, `radio/`, `youtube/`, `queue/`), audio viz (`audio/`), Lua scripting (`scripting/`), AI DJ + MCP (`dj/`, `mcp/`), OS integrations (Discord RPC, MPRIS, macOS/Windows media) |
| `tui/` | Terminal UI: the event/render loop (`runner.rs`), key plumbing (`event/`), per-block input handlers (`handlers/`), immutable draw fns (`ui/`) |
| `cli/` | clap subcommands: playback control, listening history, self-update, MCP relay, plugin management |
| `runtime/` | `mod.rs::run_cli` (entry point + CLI dispatch), `bootstrap.rs::boot` (frontend-neutral config/auth/`App` construction, `run_cli` its sole caller), `cli.rs` (clap assembly + self-update), `pump.rs::start_tokio` (the IoEvent pump), `streaming/` (native-streaming startup every frontend shares: the pure saved-device decision in `mod.rs`, the librespot bring-up in `launch.rs`, gated on `streaming`), `startup.rs` (the UI-launch half, gated on `tui`) |
@@ -122,8 +122,9 @@ worth knowing before adding an event:
- **Source routing**: non-Spotify playback is routed by URI scheme *before* the
Spotify handler, in this order: `route_queue_event` → `route_local_event`
- (`file:`) → `route_subsonic_event` (`subsonic:`) → `route_radio_event`
- (`radio:`) → `route_youtube_event` (`youtube:`) → `Network::handle_network_event`.
+ (`file:`) → `route_subsonic_event` (`subsonic:`) → `route_qobuz_event`
+ (`qobuz:`) → `route_radio_event` (`radio:`) → `route_youtube_event`
+ (`youtube:`) → `Network::handle_network_event`.
This is what keeps `infra/network/` Spotify-only.
- **Service lane**: `Network::runs_on_service_lane` lists events that run on a
detached task so slow, source-agnostic work cannot head-of-line-block the serial
@@ -190,7 +191,7 @@ regressions. Check in this order: `queue_owns_playback()` /
`queue_now_is_spotify()`, then `active_decoded_source()`, then
`is_native_streaming_active_for_playback()`.
-- Starting a decoded source (Local/Subsonic/Radio/YouTube) only **pauses**
+- Starting a decoded source (Local/Subsonic/Qobuz/Radio/YouTube) only **pauses**
librespot - the native flag stays true, so driving librespot directly resumes
the wrong player.
- While the native queue slot owns the sink, `current_playback_context` names the
@@ -362,7 +363,7 @@ navigation. Adding a binding means fields on both `KeyBindings` and
### Config & on-disk files
-Four files, four owners - a value that changes as the app runs goes in state,
+Five files, five owners - a value that changes as the app runs goes in state,
never config:
| File | Owner | Contents |
@@ -371,6 +372,7 @@ never config:
| `client.yml` (config dir) | `core/config.rs` | Spotify app credentials |
| `state.yml` (state dir) | `core/state.rs` | machine-written runtime values |
| `last_session.yml` (state dir) | `core/persisted_playback.rs` | non-Spotify playback + native queue |
+| `qobuz_credentials.yml` (config dir) | `infra/qobuz/auth.rs` | the Qobuz login token (feature `qobuz`) |
- All paths resolve through `core/paths.rs`, never `dirs::` directly.
- `state.yml` saves are read-modify-write **sparse patches** so a second running
@@ -390,7 +392,7 @@ never config:
- `default` = `telemetry, tui, streaming, audio-viz-cpal, macos-media,
windows-media, mpris, discord-rpc, self-update, scripting`. Notably **not**
in default: `cover-art` (so a plain `cargo run` has no album art, though
- every shipped binary enables it), the four sources, and the DJ features.
+ every shipped binary enables it), the five sources, and the DJ features.
- `tui` gates `mod tui` and owns the terminal-only crates (ratatui, crossterm,
tui-bar-graph, colorgrad - the last also pulled by `art-decode` for the
adaptive-theme HSV math). `gui` is a reserved placeholder that only gates the
@@ -406,7 +408,7 @@ never config:
the non-default picks avoid librespot's `pipe` sink writing raw audio to stdout
and destroying the TUI.
- `audio-decode` (rodio) is the shared engine pulled in by the sources;
- `all-sources` = `local-files, subsonic, internet-radio, youtube`.
+ `all-sources` = `local-files, subsonic, internet-radio, youtube, qobuz`.
- `dj-core` is a shared implementation feature pulled in by `mcp-server` and
`ai-dj`; none of the three are in `default`, and neither front door may assume
the other (or `streaming`) is present.
@@ -425,12 +427,21 @@ See `.claude/skills/add-tui-screen/SKILL.md`.
plugins), and `src/cli/CLAUDE.md` (CLI subcommands) load automatically when
working in those directories.
-### Alternative sources (Local / Subsonic / Radio / YouTube)
+### Alternative sources (Local / Subsonic / Radio / YouTube / Qobuz)
-- All four decode through **one** shared rodio sink, `LocalPlayer`
+- All five decode through **one** shared rodio sink, `LocalPlayer`
(`src/infra/audio/player.rs`) - the only file where rodio types appear.
Subsonic and YouTube download each track to a `NamedTempFile` first (YouTube by
shelling out to `yt-dlp`); Radio streams through a non-seekable ring buffer.
+- Qobuz (`src/infra/qobuz/`) plays each track through the web player's
+ encrypted CMAF stream while it downloads: `stream/progressive.rs` is a
+ `stream-download` source that yields the decrypted segments (and restarts at
+ a seek), rebuilt as a FLAC `NamedTempFile` the session keeps. The fetch runs
+ off the pump behind a `fetch_id` guard; a superseded stream is dropped, which
+ cancels its download. The transport (`sign.rs`, `stream/`) is pure and unit
+ tested. The three web-player constants are scraped at runtime (`auth.rs`),
+ cached in `state.yml`, and overridable through `SPOTATUI_QOBUZ_*` env vars;
+ they are never embedded. Failures are status messages, never `handle_error`.
- macOS cannot play any decoded source: `LocalPlayer::new()` bails there because
rodio SIGSEGVs on CoreAudio/Bluetooth (#9/#20) - which is why macOS releases
exclude the source features.
diff --git a/Cargo.lock b/Cargo.lock
index a6aad6d6..6050dd4b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -585,6 +585,15 @@ dependencies = [
"hybrid-array",
]
+[[package]]
+name = "block-padding"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
+dependencies = [
+ "generic-array",
+]
+
[[package]]
name = "block2"
version = "0.6.2"
@@ -682,6 +691,15 @@ dependencies = [
"rustversion",
]
+[[package]]
+name = "cbc"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
+dependencies = [
+ "cipher",
+]
+
[[package]]
name = "cc"
version = "1.2.54"
@@ -831,6 +849,12 @@ dependencies = [
"cc",
]
+[[package]]
+name = "cmov"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
+
[[package]]
name = "color_quant"
version = "1.1.0"
@@ -1191,6 +1215,15 @@ dependencies = [
"cipher",
]
+[[package]]
+name = "ctutils"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
+dependencies = [
+ "cmov",
+]
+
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
@@ -1404,6 +1437,7 @@ dependencies = [
"block-buffer 0.12.0",
"const-oid 0.10.2",
"crypto-common 0.2.2",
+ "ctutils",
]
[[package]]
@@ -2349,6 +2383,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+[[package]]
+name = "hkdf"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
+dependencies = [
+ "hmac 0.13.0",
+]
+
[[package]]
name = "hmac"
version = "0.12.1"
@@ -2358,6 +2401,15 @@ dependencies = [
"digest 0.10.7",
]
+[[package]]
+name = "hmac"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
+dependencies = [
+ "digest 0.11.3",
+]
+
[[package]]
name = "home"
version = "0.5.12"
@@ -2761,6 +2813,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
+ "block-padding",
"generic-array",
]
@@ -4026,7 +4079,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest 0.10.7",
- "hmac",
+ "hmac 0.12.1",
]
[[package]]
@@ -5719,21 +5772,27 @@ dependencies = [
name = "spotatui"
version = "0.41.0"
dependencies = [
+ "aes",
"anyhow",
"arboard",
"backtrace",
+ "base64",
"block2",
+ "bytes",
+ "cbc",
"chrono",
"clap",
"clap_complete",
"colorgrad",
"cpal 0.17.1",
"crossterm",
+ "ctr",
"dirs",
"discord-rich-presence",
"fern",
"futures",
"hex",
+ "hkdf",
"icy-metadata",
"image",
"keepawake",
@@ -5755,6 +5814,7 @@ dependencies = [
"ratatui",
"ratatui-image",
"realfft",
+ "regex",
"reqwest 0.12.28",
"rodio 0.22.2",
"rspotify",
@@ -5837,7 +5897,7 @@ dependencies = [
"futures-core",
"futures-util",
"governor",
- "hmac",
+ "hmac 0.12.1",
"http",
"http-body-util",
"httparse",
diff --git a/Cargo.toml b/Cargo.toml
index 276a60d4..50f675f0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -59,14 +59,25 @@ ratatui-image = { version = "11.0.4", optional = true, default-features = false,
image = { version = "0.25", optional = true }
mlua = { version = "0.12", features = ["lua54", "vendored", "serde"], optional = true }
md-5 = { version = "0.11", optional = true }
-# Subsonic streams each track to a tempfile before playing it through the shared
+# Subsonic, YouTube and Qobuz stream each track to a tempfile before playing it through the shared
# rodio sink. Also a dev-dependency (used by tests across features).
tempfile = { version = "3", optional = true }
+# Qobuz transport: the signed-request and key-derivation primitives plus the
+# bundle scrape. Same `cipher 0.4` line as librespot, so no graph fork.
+base64 = { version = "0.22", optional = true }
+regex = { version = "1", optional = true }
+aes = { version = "0.8", optional = true }
+cbc = { version = "0.1", optional = true }
+ctr = { version = "0.9", optional = true }
+hkdf = { version = "0.13", optional = true }
+# Qobuz progressive delivery: the chunk type of a stream-download source.
+bytes = { version = "1", optional = true }
-# Internet-radio dependencies: stream-download buffers an infinite HTTP (ICY)
-# stream behind a Read+Seek adapter that rodio can decode without seeking;
-# icy-metadata parses in-band StreamTitle now-playing metadata. Both are only
-# compiled under the opt-in `internet-radio` feature.
+# stream-download buffers a byte stream behind a Read+Seek adapter that rodio
+# can decode while the download runs. Internet radio uses it on an infinite HTTP
+# (ICY) stream, without seeking; Qobuz uses it on its segment transport, with
+# seeking. icy-metadata parses in-band StreamTitle now-playing metadata and is
+# only compiled under the opt-in `internet-radio` feature.
stream-download = { version = "0.24", optional = true, default-features = false, features = ["reqwest-rustls"] }
icy-metadata = { version = "0.6", optional = true, default-features = false }
@@ -171,14 +182,14 @@ art-decode = ["dep:image", "dep:colorgrad"]
cover-art = ["tui", "art-decode", "dep:ratatui-image"]
scripting = ["dep:mlua"]
# Shared audio decode/output engine (rodio bundles symphonia). Pulled by any
-# source that plays decoded audio through the local sink (local files, Subsonic).
+# source that plays decoded audio through the local sink (local files, Subsonic, Qobuz).
audio-decode = ["dep:rodio"]
# Convenience alias to pull in every alternative (non-Spotify) source at once,
# 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"]
+all-sources = ["local-files", "subsonic", "internet-radio", "youtube", "qobuz"]
local-files = ["audio-decode", "dep:lofty", "dep:symphonia"]
subsonic = ["dep:md-5", "audio-decode", "dep:tempfile", "reqwest/stream"]
internet-radio = ["audio-decode", "dep:stream-download", "dep:icy-metadata"]
@@ -187,6 +198,9 @@ internet-radio = ["audio-decode", "dep:stream-download", "dep:icy-metadata"]
# rodio's default `mp4` feature decodes) to a tempfile, play through the shared
# LocalPlayer. Unofficial-fragile by design — see plan-multi-source.md.
youtube = ["audio-decode", "dep:tempfile"]
+# 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", "dep:tempfile", "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"]
# --- AI DJ / MCP -------------------------------------------------------------
# Shared tool layer behind both front doors: the taste brief built from the local
diff --git a/README.md b/README.md
index 432203f0..afa29b1f 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
A terminal music player written in Rust, powered by Ratatui
- Native Spotify streaming, synced lyrics, a real-time audio visualizer, local file, Subsonic/Navidrome, Internet Radio, and YouTube sources. Spotify is optional.
+ Native Spotify streaming, synced lyrics, a real-time audio visualizer, local file, Subsonic/Navidrome, Internet Radio, YouTube, and Qobuz sources. Spotify is optional.
[](https://crates.io/crates/spotatui)
@@ -33,6 +33,7 @@
- [Subsonic / Navidrome](#subsonic--navidrome)
- [Internet Radio](#internet-radio)
- [YouTube](#youtube)
+ - [Qobuz](#qobuz)
- [AI DJ](#ai-dj)
- [Native Streaming](#native-streaming)
- [Configuration](#configuration)
@@ -64,11 +65,11 @@ A community-maintained, actively developed fork of [spotify-tui](https://github.
| Key differences | spotify-tui | spotatui |
|---|---|---|
| Spotify backend | external Connect device (spotifyd, official client, ...) | built-in native client |
-| Source(s) | Spotify | Spotify, YouTube, local, Subsonic, Navidrome, Internet Radio |
+| Source(s) | Spotify | Spotify, YouTube, local, Subsonic, Navidrome, Internet Radio, Qobuz |
## Features
-- **Multiple sources — Spotify optional.** Play from Spotify, [Local Files](#local-files), a [Subsonic/Navidrome](#subsonic--navidrome) server, [Internet Radio](#internet-radio), or [YouTube](#youtube). The free sources need no Spotify account; press `d` to switch between them at any time.
+- **Multiple sources — Spotify optional.** Play from Spotify, [Local Files](#local-files), a [Subsonic/Navidrome](#subsonic--navidrome) server, [Internet Radio](#internet-radio), [YouTube](#youtube), or [Qobuz](#qobuz). The free sources need no Spotify account; press `d` to switch between them at any time.
- **[Native streaming](#native-streaming).** Play Spotify audio directly, no official app or spotifyd required — spotatui appears as its own Spotify Connect device (Premium required).
- **Synced lyrics.** Line-by-line lyrics that follow playback.
- **Real-time audio visualizer.** A system-wide FFT visualizer (press `v`) that reacts to whatever is playing.
@@ -80,7 +81,7 @@ A community-maintained, actively developed fork of [spotify-tui](https://github.
## Installation
-> **Spotify is optional.** On first launch spotatui asks which source you want to use. YouTube, Subsonic/Navidrome, Internet Radio, and Local Files all work with no Spotify account. Spotify Premium is only needed for the Spotify source; you can add it anytime from the `d` menu.
+> **Spotify is optional.** On first launch spotatui asks which source you want to use. YouTube, Subsonic/Navidrome, Internet Radio, Local Files, and Qobuz all work with no Spotify account. Spotify Premium is only needed for the Spotify source; you can add it anytime from the `d` menu.
```bash
# One-line installer — macOS, Linux, WSL. Grabs the prebuilt binary for your
@@ -150,7 +151,7 @@ Or if you use pipewire:
Or download pre-built binaries from [GitHub Releases](https://github.com/LargeModGames/spotatui/releases/latest).
-> **Which build has which sources?** The prebuilt Linux and Windows binaries (the one-line installer, GitHub Releases, and `cargo binstall`) include the extra music sources — Local Files, Subsonic, Internet Radio, and YouTube. macOS builds and a plain `cargo install --locked spotatui` do not; enable them with `--features local-files,subsonic,internet-radio,youtube`.
+> **Which build has which sources?** The prebuilt Linux and Windows binaries (the one-line installer, GitHub Releases, and `cargo binstall`) include the extra music sources — Local Files, Subsonic, Internet Radio, YouTube, and Qobuz. macOS builds and a plain `cargo install --locked spotatui` do not; enable them with `--features local-files,subsonic,internet-radio,youtube,qobuz`.
See the [Installation Wiki](https://github.com/LargeModGames/spotatui/wiki/Installation) for platform-specific requirements and building from source.
@@ -166,6 +167,7 @@ Welcome to spotatui! Choose your music source:
3) Subsonic (free, needs a Subsonic/Navidrome server)
4) Internet Radio (free)
5) Local Files (free)
+ 6) Qobuz (paid subscription, logs in through the browser)
```
Pick a **free source** to skip Spotify entirely, or pick **Spotify** to run the auth wizard (you'll create a Spotify Developer app — see the [Installation Wiki](https://github.com/LargeModGames/spotatui/wiki/Installation#connecting-to-spotify)). Only sources compiled into your build are listed.
@@ -201,13 +203,14 @@ spotatui is a general music player, not just a Spotify client. Press `d` to open
| **Subsonic** | Browse, search, and stream from any Subsonic-compatible server (Navidrome, Gonic, Airsonic, Funkwhale, …) | A server account |
| **Internet Radio** | Play icecast/shoutcast streams with live now-playing metadata; search the [radio-browser.info](https://www.radio-browser.info) directory (30k+ stations) | Nothing |
| **YouTube** | Search YouTube and play audio; build **local playlists** stored in a plain file | [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) on your `PATH` (ffmpeg recommended) |
+| **Qobuz** | Stream your Qobuz library (favorites, playlists, albums, search) at the configured quality, MP3 320 up to FLAC 24/192, through spotatui's own engine | A paid Qobuz account |
**Resuming your last session:** quit while playing from a non-Spotify source and spotatui restores that track and its position on the next launch, following the `startup_behavior` setting (`continue`, `play`, or `pause`).
**Availability:** included in the Linux and Windows release binaries. Not yet on macOS (the shared audio output path is disabled there pending a fix; contributions welcome). When building from source, enable them with cargo features:
```bash
-cargo install --locked spotatui --features local-files,subsonic,internet-radio,youtube
+cargo install --locked spotatui --features local-files,subsonic,internet-radio,youtube,qobuz
```
Each source has a few config keys; the essentials are below, and the full reference lives in the [Configuration Wiki](https://github.com/LargeModGames/spotatui/wiki/Configuration).
@@ -241,6 +244,17 @@ Requires the [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) binary (`ffmpeg` recom
**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`.
+### Qobuz
+
+Pick **Qobuz** in the first-run picker or the `d` menu: spotatui opens the Qobuz login page in your browser and saves the token to `qobuz_credentials.yml` in the config directory (owner-only on Unix; on Windows the file keeps the config folder's permissions). No app registration is needed. The sidebar lists your favorite tracks, playlists, and favorite albums; search finds tracks. Each track plays while it downloads: the encrypted stream is decrypted and rebuilt as a FLAC temporary file, playback starts after the first segments arrive, and a seek past the downloaded part continues the download from there. When a track plays from a Qobuz listing, the playbar shows the format that Qobuz delivered, for example `FLAC 16/44.1`. This can be lower than the configured quality.
+
+```yaml
+behavior:
+ qobuz_quality: 6 # 5 = MP3 320, 6 = FLAC 16/44.1, 7 = FLAC 24/96, 27 = FLAC 24/192
+```
+
+Environment variables: `SPOTATUI_QOBUZ_TOKEN` overrides the saved token. spotatui reads the three constants it needs from the Qobuz web player at runtime; when Qobuz changes its web player and the login stops working, set `SPOTATUI_QOBUZ_APP_ID`, `SPOTATUI_QOBUZ_APP_SECRET`, and `SPOTATUI_QOBUZ_OAUTH_KEY` from the bundle instead of waiting for a release.
+
## AI DJ
spotatui can pick music for you, from your own listening history. Two ways in:
@@ -336,7 +350,7 @@ spotatui is extremely lightweight compared to the official Electron client.
## Playback Requirements
-The free sources (Local Files, Subsonic, Internet Radio, YouTube) play through spotatui's own audio engine and need no extra setup.
+The non-Spotify sources (Local Files, Subsonic, Internet Radio, YouTube, Qobuz) play through spotatui's own audio engine and need no extra setup.
Spotify is different: it uses the [Web API](https://developer.spotify.com/documentation/web-api/), which doesn't stream audio itself. To play Spotify tracks you need **one** of:
diff --git a/context7.json b/context7.json
index 0d04e99a..70123f37 100644
--- a/context7.json
+++ b/context7.json
@@ -1,7 +1,7 @@
{
"$schema": "https://context7.com/schema/context7.json",
"projectTitle": "spotatui",
- "description": "A fast, standalone terminal music player in Rust: native Spotify streaming plus local, Subsonic, radio, and YouTube sources.",
+ "description": "A fast, standalone terminal music player in Rust: native Spotify streaming plus local, Subsonic, radio, YouTube, and Qobuz sources.",
"folders": [
"docs",
"examples"
@@ -25,7 +25,7 @@
"how_to_release.md"
],
"rules": [
- "spotatui is a terminal application, not a library. A plain `cargo run` builds the Spotify-only default; use `cargo run --features all-sources` to enable the Local, Subsonic, Radio, and YouTube sources.",
+ "spotatui is a terminal application, not a library. A plain `cargo run` builds the Spotify-only default; use `cargo run --features all-sources` to enable the Local, Subsonic, Radio, YouTube, and Qobuz sources.",
"Native streaming (librespot) and audio visualization are part of the default features; the slim CI build uses `--no-default-features --features telemetry`.",
"Keybindings and most behavior are user-configurable via the YAML config; see docs/configuration.md and examples/config.example.yml.",
"The Lua scripting/plugin API is documented in docs/scripting.md with runnable examples under examples/plugins/."
diff --git a/docs/configuration.md b/docs/configuration.md
index d7e05f0c..4d5dad44 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -99,6 +99,19 @@ behavior:
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`.
+## Qobuz
+
+Qobuz logs in through the browser (first-run picker, or `d` then Qobuz) and keeps the token in `qobuz_credentials.yml` next to `config.yml`. One key selects the stream quality:
+
+```yaml
+behavior:
+ qobuz_quality: 6 # 5 = MP3 320, 6 = FLAC 16/44.1, 7 = FLAC 24/96, 27 = FLAC 24/192
+```
+
+This is the requested quality. Qobuz serves some tracks at a lower one, so when a track plays from a Qobuz listing, the playbar shows the format it delivered, for example `FLAC 16/44.1`.
+
+Environment variables: `SPOTATUI_QOBUZ_TOKEN` overrides the saved token. `SPOTATUI_QOBUZ_APP_ID`, `SPOTATUI_QOBUZ_APP_SECRET`, and `SPOTATUI_QOBUZ_OAUTH_KEY` (all three together) replace the constants spotatui otherwise reads from the Qobuz web player at runtime.
+
## Layout
```yaml
@@ -178,7 +191,7 @@ format:
The defaults above reproduce the built-in output exactly.
-### `playbar_status` (Spotify playback) and `playbar_status_source` (local/Subsonic/Radio/YouTube playback)
+### `playbar_status` (Spotify playback) and `playbar_status_source` (local/Subsonic/Qobuz/Radio/YouTube playback)
Both templates accept the same keys; keys that don't apply to the current mode render empty.
diff --git a/docs/scripting.md b/docs/scripting.md
index b38972e6..898bab20 100644
--- a/docs/scripting.md
+++ b/docs/scripting.md
@@ -180,7 +180,7 @@ tracks); `album_id` is the album's base62 id, when known; `artist_refs` is an ar
and empty when only the combined `artists` display list is available (e.g. native-playback
snapshots); `is_playable` and `is_local` default to `true`/`false`; `track_number` defaults to
`0` and `explicit` to `false` when unknown; `image_url` is a directly-fetchable cover-art URL
-when the source provides one (e.g. Subsonic, YouTube), otherwise `nil`.
+when the source provides one (e.g. Subsonic, YouTube, Qobuz), otherwise `nil`.
The cached reads for playlists, queue and search results refresh when the underlying data
changes; they are cheap to call but can be empty until the app has actually fetched that data.
@@ -333,7 +333,8 @@ spotatui.storage_set("play_count", plays)
Theme values use the same string forms as `config.yml` (named color or `"r, g, b"`), so they
can round-trip through `spotatui.set_theme`. Secrets and service credentials (sync token,
-relay URL, Subsonic credentials, Discord client id) are never exposed. The snapshot is
+relay URL, Subsonic credentials, the Qobuz token, Discord client id) are never exposed, and
+source-specific settings (Subsonic, YouTube, Qobuz) are not part of the snapshot. The snapshot is
populated once the app is running; reading it at load time (before the `start` event) returns
empty defaults.
@@ -608,7 +609,7 @@ Trees are capped at 8 levels of nesting and 256 widgets per screen.
`{ type = "cover_art" }` renders the artwork spotatui has *already* downloaded and decoded for
the current track - there is no second download, and the app keeps ownership of the terminal
graphics protocol, resizing, placement and cleanup. It works for every source that supplies
-art (Spotify, Subsonic, YouTube, local files with an embedded picture).
+art (Spotify, Subsonic, YouTube, Qobuz, local files with an embedded picture).
- `source` - optional; only `"current"` is accepted today. Any other value is an error, so a
future source fails loudly on an old build rather than showing the wrong image.
diff --git a/src/core/action/apply.rs b/src/core/action/apply.rs
index 62a74891..258d2d25 100644
--- a/src/core/action/apply.rs
+++ b/src/core/action/apply.rs
@@ -86,6 +86,9 @@ impl App {
crate::core::source::Source::YouTube => {
self.dispatch(IoEvent::GetYouTubeSearchResults(query));
}
+ crate::core::source::Source::Qobuz => {
+ self.dispatch(IoEvent::GetQobuzSearchResults(query));
+ }
// Spotify and Local both land on the Web API search, exactly like
// the search input's if-chain (which has no Local branch).
crate::core::source::Source::Spotify | crate::core::source::Source::Local => {
diff --git a/src/core/action/mod.rs b/src/core/action/mod.rs
index cb4e67c0..e40d6df4 100644
--- a/src/core/action/mod.rs
+++ b/src/core/action/mod.rs
@@ -460,7 +460,7 @@ pub enum OpenTarget {
/// what each opening path has always passed.
Playlist { id: String, from_search: bool },
/// A decoded source's playlist or folder, routed by URI scheme (`file:`,
- /// `subsonic:`, `youtube:playlist:`); an unknown scheme is a no-op.
+ /// `subsonic:`, `youtube:playlist:`, `qobuz:`); an unknown scheme is a no-op.
SourcePlaylist(String),
/// Scope the playlist sidebar to a rootlist folder id (session-local, not
/// to be persisted).
diff --git a/src/core/action/tests.rs b/src/core/action/tests.rs
index 708a0e9a..64bce834 100644
--- a/src/core/action/tests.rs
+++ b/src/core/action/tests.rs
@@ -1518,6 +1518,13 @@ fn search_active_source_routes_by_the_active_source() {
Ok(IoEvent::GetYouTubeSearchResults(q)) if q == "coltrane"
));
+ app.active_source = Source::Qobuz;
+ app.apply(Action::SearchActiveSource(query.clone()));
+ assert!(matches!(
+ rx.try_recv(),
+ Ok(IoEvent::GetQobuzSearchResults(q)) if q == "coltrane"
+ ));
+
app.active_source = Source::Spotify;
app.apply(Action::SearchActiveSource(query.clone()));
assert!(matches!(
@@ -2107,6 +2114,9 @@ fn select_source_fetches_each_scopes_own_sidebar() {
app.apply(Action::SelectSource(Source::YouTube));
assert!(matches!(rx.try_recv(), Ok(IoEvent::GetYouTubePlaylists)));
+
+ app.apply(Action::SelectSource(Source::Qobuz));
+ assert!(matches!(rx.try_recv(), Ok(IoEvent::GetQobuzPlaylists)));
}
#[test]
@@ -2332,6 +2342,25 @@ fn open_source_playlist_local_clears_the_table_and_fetches() {
}
}
+#[test]
+fn open_source_playlist_qobuz_uses_the_qobuz_context() {
+ let (mut app, rx) = app_with_channel();
+
+ app.apply(Action::Open(OpenTarget::SourcePlaylist(
+ "qobuz:album:0060254730301".to_string(),
+ )));
+
+ assert_eq!(
+ app.track_table.context,
+ Some(crate::core::app::TrackTableContext::QobuzPlaylist)
+ );
+ assert_eq!(app.get_current_route().id, RouteId::TrackTable);
+ match rx.try_recv() {
+ Ok(IoEvent::GetQobuzTracks(uri)) => assert_eq!(uri, "qobuz:album:0060254730301"),
+ _other => panic!("expected GetQobuzTracks (IoEvent is not Debug)"),
+ }
+}
+
#[test]
fn open_source_playlist_subsonic_uses_the_subsonic_context() {
let (mut app, rx) = app_with_channel();
diff --git a/src/core/app/construction.rs b/src/core/app/construction.rs
index 558bfdb0..6b17795b 100644
--- a/src/core/app/construction.rs
+++ b/src/core/app/construction.rs
@@ -18,6 +18,7 @@ impl Default for App {
community_pin_item: PlaylistFolderItem::CommunityPin,
local_playlists: Vec::new(),
subsonic_playlists: Vec::new(),
+ qobuz_playlists: Vec::new(),
radio_stations: Vec::new(),
youtube_playlists: Vec::new(),
youtube_open_playlist: None,
@@ -60,6 +61,7 @@ impl Default for App {
feature = "streaming",
feature = "local-files",
feature = "subsonic",
+ feature = "qobuz",
feature = "youtube"
))]
queue_now: None,
@@ -181,6 +183,8 @@ impl Default for App {
local_playback: None,
#[cfg(feature = "subsonic")]
subsonic_playback: None,
+ #[cfg(feature = "qobuz")]
+ qobuz_playback: None,
#[cfg(feature = "internet-radio")]
radio_playback: None,
#[cfg(feature = "youtube")]
diff --git a/src/core/app/mod.rs b/src/core/app/mod.rs
index 06e496c9..806327e7 100644
--- a/src/core/app/mod.rs
+++ b/src/core/app/mod.rs
@@ -17,6 +17,7 @@ use crate::infra::network::IoEvent;
feature = "streaming",
feature = "local-files",
feature = "subsonic",
+ feature = "qobuz",
feature = "youtube"
))]
use crate::infra::queue::QueueNowPlaying;
@@ -39,6 +40,7 @@ use std::sync::mpsc::Sender;
feature = "streaming",
feature = "local-files",
feature = "subsonic",
+ feature = "qobuz",
feature = "youtube",
all(feature = "mpris", target_os = "linux")
))]
@@ -193,6 +195,7 @@ pub struct App {
feature = "streaming",
feature = "local-files",
feature = "subsonic",
+ feature = "qobuz",
feature = "youtube"
))]
pub queue_now: Option,
@@ -300,6 +303,9 @@ pub struct App {
/// The user's Subsonic server playlists shown by the Subsonic browser.
/// Populated by `GetSubsonicPlaylists` dispatch.
pub subsonic_playlists: Vec,
+ /// The Qobuz sidebar rows (favorites, playlists, albums) shown by the Qobuz
+ /// browser. Populated by `GetQobuzPlaylists` dispatch.
+ pub qobuz_playlists: Vec,
/// The user's configured internet-radio stations (as playable rows, uri
/// `radio:`) shown by the sidebar when the Radio source is active.
/// Populated by `GetRadioStations` dispatch.
@@ -490,6 +496,10 @@ pub struct App {
/// live from the player here, never touching Spotify/librespot fields.
#[cfg(feature = "subsonic")]
pub subsonic_playback: Option,
+ /// The active Qobuz playback session, or `None` when another backend owns
+ /// playback. Same decoupling contract as [`local_playback`](Self::local_playback).
+ #[cfg(feature = "qobuz")]
+ pub qobuz_playback: Option,
/// The active internet-radio playback session (multi-source Phase 5), or
/// `None` when another backend owns playback. Same decoupling contract as
/// [`local_playback`](Self::local_playback); unlike it there is no queue —
diff --git a/src/core/app/models.rs b/src/core/app/models.rs
index 8bd73979..67469873 100644
--- a/src/core/app/models.rs
+++ b/src/core/app/models.rs
@@ -36,6 +36,7 @@ pub enum TrackTableContext {
LocalPlaylist,
SubsonicPlaylist,
YouTubePlaylist,
+ QobuzPlaylist,
}
pub struct SearchResult {
diff --git a/src/core/app/persistence.rs b/src/core/app/persistence.rs
index ff4ed9f1..fc6af047 100644
--- a/src/core/app/persistence.rs
+++ b/src/core/app/persistence.rs
@@ -26,7 +26,12 @@ impl App {
/// active decoded context was suspended under the native queue, or `None` when
/// no decoded context is suspended. Only one context is ever active, so this
/// unambiguously describes it.
- #[cfg(any(feature = "youtube", feature = "subsonic", feature = "local-files"))]
+ #[cfg(any(
+ feature = "youtube",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "local-files"
+ ))]
fn suspended_resume(&self) -> Option<(Option, u64)> {
match self.queue_suspended.as_ref()? {
#[cfg(feature = "local-files")]
@@ -39,6 +44,11 @@ impl App {
resume_index,
resume_position_ms,
} => Some((*resume_index, *resume_position_ms)),
+ #[cfg(feature = "qobuz")]
+ crate::core::queue::SuspendedContext::Qobuz {
+ resume_index,
+ resume_position_ms,
+ } => Some((*resume_index, *resume_position_ms)),
#[cfg(feature = "youtube")]
crate::core::queue::SuspendedContext::YouTube {
resume_index,
@@ -58,6 +68,7 @@ impl App {
#[cfg(any(
feature = "youtube",
feature = "subsonic",
+ feature = "qobuz",
feature = "local-files",
feature = "internet-radio"
))]
@@ -122,6 +133,34 @@ impl App {
}
}
}
+ #[cfg(feature = "qobuz")]
+ if let Some(s) = self.qobuz_playback.as_ref() {
+ match self.suspended_resume() {
+ Some((Some(index), position_ms)) => {
+ return Some(PersistedPlayback::Qobuz {
+ tracks: s.tracks.clone(),
+ index,
+ position_ms,
+ paused: false,
+ repeat: self.decoded_repeat,
+ shuffle_on: self.decoded_shuffle,
+ shuffle: s.shuffle_backup.clone(),
+ });
+ }
+ Some((None, _)) => {}
+ None => {
+ return Some(PersistedPlayback::Qobuz {
+ tracks: s.tracks.clone(),
+ index: s.index,
+ position_ms: s.player.position().as_millis() as u64,
+ paused: s.player.is_paused(),
+ repeat: self.decoded_repeat,
+ shuffle_on: self.decoded_shuffle,
+ shuffle: s.shuffle_backup.clone(),
+ });
+ }
+ }
+ }
#[cfg(feature = "local-files")]
if let Some(s) = self.local_playback.as_ref() {
match self.suspended_resume() {
@@ -194,7 +233,12 @@ impl App {
fn has_persistable_playback(&self) -> bool {
// Mirrors `current_persisted_playback`: a decoded context suspended past
// its end (resume_index == None) does not persist.
- #[cfg(any(feature = "youtube", feature = "subsonic", feature = "local-files"))]
+ #[cfg(any(
+ feature = "youtube",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "local-files"
+ ))]
{
let context_resumable = !matches!(self.suspended_resume(), Some((None, _)));
#[cfg(feature = "youtube")]
@@ -205,6 +249,10 @@ impl App {
if self.subsonic_playback.is_some() && context_resumable {
return true;
}
+ #[cfg(feature = "qobuz")]
+ if self.qobuz_playback.is_some() && context_resumable {
+ return true;
+ }
#[cfg(feature = "local-files")]
if self.local_playback.is_some() && context_resumable {
return true;
diff --git a/src/core/app/playback_routing.rs b/src/core/app/playback_routing.rs
index d39e1ca1..cd1d0f47 100644
--- a/src/core/app/playback_routing.rs
+++ b/src/core/app/playback_routing.rs
@@ -87,7 +87,12 @@ impl App {
pub(crate) fn active_decoded_source(&self) -> bool {
// The native queue slot playing a decoded track owns the sink even when no
// per-source `*_playback` context is set (e.g. queueing from an idle app).
- #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+ #[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ ))]
if self.queue_now_decoded_player().is_some() {
return true;
}
@@ -104,6 +109,10 @@ impl App {
if self.subsonic_playback.is_some() {
return true;
}
+ #[cfg(feature = "qobuz")]
+ if self.qobuz_playback.is_some() {
+ return true;
+ }
#[cfg(feature = "internet-radio")]
if self.radio_playback.is_some() {
return true;
@@ -137,6 +146,10 @@ impl App {
if self.subsonic_playback.is_some() {
return true;
}
+ #[cfg(feature = "qobuz")]
+ if self.qobuz_playback.is_some() {
+ return true;
+ }
#[cfg(feature = "youtube")]
if self.youtube_playback.is_some() {
return true;
@@ -144,14 +157,55 @@ impl App {
false
}
- /// The player of whichever decoded source (local file, Subsonic, internet
- /// radio, or YouTube) currently owns the session, or `None` when Spotify (or
- /// nothing) owns it. All four sources decode through the same `LocalPlayer`
+ /// Take every decoded session except `keep`'s, so one backend can own the
+ /// output device, and return their players. Stop them off the `App` lock: a
+ /// sink clear waits for the audio thread.
+ #[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "internet-radio",
+ feature = "youtube"
+ ))]
+ pub(crate) fn take_decoded_sessions_except(
+ &mut self,
+ keep: crate::core::source::Source,
+ ) -> Vec> {
+ use crate::core::source::Source;
+ use std::sync::Arc;
+ let mut players = Vec::new();
+ #[cfg(feature = "local-files")]
+ if keep != Source::Local {
+ players.extend(self.local_playback.take().map(|s| Arc::clone(&s.player)));
+ }
+ #[cfg(feature = "subsonic")]
+ if keep != Source::Subsonic {
+ players.extend(self.subsonic_playback.take().map(|s| Arc::clone(&s.player)));
+ }
+ #[cfg(feature = "qobuz")]
+ if keep != Source::Qobuz {
+ players.extend(self.qobuz_playback.take().map(|s| Arc::clone(&s.player)));
+ }
+ #[cfg(feature = "internet-radio")]
+ if keep != Source::Radio {
+ players.extend(self.radio_playback.take().map(|s| Arc::clone(&s.player)));
+ }
+ #[cfg(feature = "youtube")]
+ if keep != Source::YouTube {
+ players.extend(self.youtube_playback.take().map(|s| Arc::clone(&s.player)));
+ }
+ players
+ }
+
+ /// The player of whichever decoded source (local file, Subsonic, Qobuz,
+ /// internet radio, or YouTube) currently owns the session, or `None` when
+ /// Spotify (or nothing) owns it. All five decode through the same `LocalPlayer`
/// sink, so a single accessor covers transport/seek routing for every one.
/// Ordering mirrors [`Self::active_decoded_source`].
#[cfg(any(
feature = "local-files",
feature = "subsonic",
+ feature = "qobuz",
feature = "internet-radio",
feature = "youtube"
))]
@@ -166,7 +220,12 @@ impl App {
allow(dead_code)
)]
pub fn active_decoded_player(&self) -> Option<&std::sync::Arc> {
- #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+ #[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ ))]
if let Some(p) = self.queue_now_decoded_player() {
return Some(p);
}
@@ -183,6 +242,10 @@ impl App {
if let Some(s) = &self.subsonic_playback {
return Some(&s.player);
}
+ #[cfg(feature = "qobuz")]
+ if let Some(s) = &self.qobuz_playback {
+ return Some(&s.player);
+ }
#[cfg(feature = "internet-radio")]
if let Some(s) = &self.radio_playback {
return Some(&s.player);
@@ -202,7 +265,12 @@ impl App {
/// and seek keys become correct no-ops for radio. In a build with all seekable
/// source features off this reduces to `None`.
pub(super) fn active_source_position_ms(&self) -> Option {
- #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+ #[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ ))]
if let Some(p) = self.queue_now_decoded_player() {
return Some(p.position().as_millis());
}
@@ -219,6 +287,10 @@ impl App {
if let Some(subsonic) = &self.subsonic_playback {
return Some(subsonic.player.position().as_millis());
}
+ #[cfg(feature = "qobuz")]
+ if let Some(qobuz) = &self.qobuz_playback {
+ return Some(qobuz.player.position().as_millis());
+ }
#[cfg(feature = "youtube")]
if let Some(youtube) = &self.youtube_playback {
return Some(youtube.player.position().as_millis());
diff --git a/src/core/app/playlist_pages.rs b/src/core/app/playlist_pages.rs
index 54f420bd..2e5a283f 100644
--- a/src/core/app/playlist_pages.rs
+++ b/src/core/app/playlist_pages.rs
@@ -123,6 +123,11 @@ impl App {
TrackTableContext::YouTubePlaylist,
IoEvent::GetYouTubeTracks(uri),
)
+ } else if uri.starts_with("qobuz:") {
+ (
+ TrackTableContext::QobuzPlaylist,
+ IoEvent::GetQobuzTracks(uri),
+ )
} else {
// Unknown scheme: silent no-op, like the other opening paths.
return;
@@ -131,6 +136,31 @@ impl App {
self.show_tracks_in_table(Vec::new(), context);
}
+ /// Show a decoded source's search hits as a songs-only result set with the
+ /// songs block focused. The album/artist blocks stay empty: their Enter
+ /// paths dispatch Spotify-bound events.
+ #[cfg(any(
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "internet-radio",
+ feature = "youtube"
+ ))]
+ pub(crate) fn show_source_search_tracks(&mut self, tracks: Vec) {
+ let total = tracks.len() as u32;
+ self.search_results.tracks = Some(Paged {
+ items: tracks,
+ total,
+ ..Default::default()
+ });
+ self.search_results.albums = None;
+ self.search_results.artists = None;
+ self.search_results.playlists = None;
+ self.search_results.shows = None;
+ self.search_results.selected_tracks_index = Some(0);
+ self.search_results.hovered_block = SearchResultBlock::SongSearch;
+ self.search_results.selected_block = SearchResultBlock::Empty;
+ }
+
pub fn reset_playlist_tracks_view(
&mut self,
playlist_id: PlaylistId<'static>,
diff --git a/src/core/app/queue.rs b/src/core/app/queue.rs
index b4d7cd56..8961d3a4 100644
--- a/src/core/app/queue.rs
+++ b/src/core/app/queue.rs
@@ -112,6 +112,7 @@ impl App {
feature = "streaming",
feature = "local-files",
feature = "subsonic",
+ feature = "qobuz",
feature = "youtube"
))]
{
@@ -121,6 +122,7 @@ impl App {
feature = "streaming",
feature = "local-files",
feature = "subsonic",
+ feature = "qobuz",
feature = "youtube"
)))]
{
@@ -158,7 +160,12 @@ impl App {
#[cfg(feature = "streaming")]
{
match self.queue_now.as_ref()? {
- #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+ #[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ ))]
QueueNowPlaying::Decoded(_) => None,
QueueNowPlaying::Spotify { track } => track.uri.clone(),
}
@@ -170,11 +177,16 @@ impl App {
}
/// The queue slot's player when it is playing a *decoded* queued track (local /
- /// Subsonic / YouTube). `None` for a Spotify slot or an empty slot. Gated on
- /// exactly those three sources: they are the decoded ones a queue item can
+ /// Subsonic / Qobuz / YouTube). `None` for a Spotify slot or an empty slot. Gated
+ /// on exactly those four sources: they are the decoded ones a queue item can
/// name, so a build whose only decoded source is internet radio has no
/// decoded slot to look up.
- #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+ #[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ ))]
pub fn queue_now_decoded_player(&self) -> Option<&Arc> {
match self.queue_now.as_ref()? {
QueueNowPlaying::Decoded(d) => Some(&d.player),
@@ -185,7 +197,12 @@ impl App {
/// Take the queue slot, returning its player when it was a decoded track (so
/// the caller can stop it). Clears [`Self::queue_now`] either way.
- #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+ #[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ ))]
pub fn take_queue_now_decoded_player(&mut self) -> Option> {
match self.queue_now.take() {
Some(QueueNowPlaying::Decoded(d)) => Some(d.player),
@@ -201,11 +218,17 @@ impl App {
feature = "streaming",
feature = "local-files",
feature = "subsonic",
+ feature = "qobuz",
feature = "youtube"
))]
{
match self.queue_now.as_ref()? {
- #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+ #[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ ))]
QueueNowPlaying::Decoded(d) => Some(&d.track),
#[cfg(feature = "streaming")]
QueueNowPlaying::Spotify { track } => Some(track),
@@ -215,6 +238,7 @@ impl App {
feature = "streaming",
feature = "local-files",
feature = "subsonic",
+ feature = "qobuz",
feature = "youtube"
)))]
{
@@ -295,7 +319,12 @@ impl App {
playing_base62_id: &str,
) -> Option {
let queued_uri = match self.queue_now.as_ref()? {
- #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+ #[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ ))]
QueueNowPlaying::Decoded(_) => return None,
QueueNowPlaying::Spotify { track } => track.uri.clone(),
}?;
diff --git a/src/core/app/queue_suspend.rs b/src/core/app/queue_suspend.rs
index 3e59379a..f35979ab 100644
--- a/src/core/app/queue_suspend.rs
+++ b/src/core/app/queue_suspend.rs
@@ -13,7 +13,12 @@ impl App {
pub(crate) fn suspend_active_decoded_context_for_skip(
&mut self,
#[cfg_attr(
- not(any(feature = "local-files", feature = "subsonic", feature = "youtube")),
+ not(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ )),
allow(unused_variables)
)]
cause: crate::infra::queue::SuspendCause,
@@ -24,7 +29,12 @@ impl App {
// the *same* track on an auto-advance (a queued song must not consume the
// repeat) but advances on a manual skip; Off clamps to `None` at the boundary.
#[cfg_attr(
- not(any(feature = "local-files", feature = "subsonic", feature = "youtube")),
+ not(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ )),
allow(unused_variables)
)]
let repeat = self.decoded_repeat;
@@ -65,6 +75,21 @@ impl App {
});
return;
}
+ #[cfg(feature = "qobuz")]
+ if let Some(s) = self.qobuz_playback.as_mut() {
+ let resume_index =
+ crate::infra::queue::resume_index_after_queue(s.index, s.tracks.len(), repeat, cause);
+ // The queue takes the sink: a download in flight must not commit onto it.
+ if let Some(fetch) = s.fetch.take() {
+ fetch.abort();
+ }
+ s.advancing = true;
+ self.queue_suspended = Some(crate::core::queue::SuspendedContext::Qobuz {
+ resume_index,
+ resume_position_ms: 0,
+ });
+ return;
+ }
#[cfg(feature = "internet-radio")]
if let Some(radio) = self.radio_playback.take() {
radio.player.stop();
@@ -111,6 +136,21 @@ impl App {
});
return;
}
+ #[cfg(feature = "qobuz")]
+ if let Some(s) = self.qobuz_playback.as_mut() {
+ let position_ms = s.player.position().as_millis() as u64;
+ let index = s.index;
+ // The queue takes the sink: a download in flight must not commit onto it.
+ if let Some(fetch) = s.fetch.take() {
+ fetch.abort();
+ }
+ s.advancing = true;
+ self.queue_suspended = Some(crate::core::queue::SuspendedContext::Qobuz {
+ resume_index: Some(index),
+ resume_position_ms: position_ms,
+ });
+ return;
+ }
#[cfg(feature = "internet-radio")]
if let Some(radio) = self.radio_playback.take() {
radio.player.stop();
diff --git a/src/core/app/route.rs b/src/core/app/route.rs
index 89955a18..6157de02 100644
--- a/src/core/app/route.rs
+++ b/src/core/app/route.rs
@@ -241,6 +241,7 @@ impl App {
Source::Subsonic => IoEvent::GetSubsonicPlaylists,
Source::Radio => IoEvent::GetRadioStations,
Source::YouTube => IoEvent::GetYouTubePlaylists,
+ Source::Qobuz => IoEvent::GetQobuzPlaylists,
Source::Spotify => return,
};
self.dispatch(event);
diff --git a/src/core/app/settings_apply.rs b/src/core/app/settings_apply.rs
index 79954f37..638c269c 100644
--- a/src/core/app/settings_apply.rs
+++ b/src/core/app/settings_apply.rs
@@ -83,6 +83,13 @@ impl App {
self.user_config.behavior.status_message_ttl_percent = (*v).clamp(10, 1000) as u16;
}
}
+ #[cfg(feature = "qobuz")]
+ "behavior.qobuz_quality" => {
+ if let SettingValue::Cycle(v, _) = &setting.value {
+ self.user_config.behavior.qobuz_quality =
+ crate::core::user_config::qobuz_quality_from_label(v);
+ }
+ }
"behavior.playback_poll_seconds" => {
if let SettingValue::Number(v) = &setting.value {
self.user_config.behavior.playback_poll_seconds = (*v).max(1) as u64;
diff --git a/src/core/app/settings_schema.rs b/src/core/app/settings_schema.rs
index ef75f366..2a59808f 100644
--- a/src/core/app/settings_schema.rs
+++ b/src/core/app/settings_schema.rs
@@ -194,6 +194,17 @@ impl App {
description: "Scale status message duration from 10% to 1000%".to_string(),
value: SettingValue::Number(self.user_config.behavior.status_message_ttl_percent as i64),
},
+ #[cfg(feature = "qobuz")]
+ SettingItem {
+ id: "behavior.qobuz_quality".to_string(),
+ name: "Qobuz Quality".to_string(),
+ description: "Stream quality for Qobuz tracks (hi-res files are large)".to_string(),
+ value: SettingValue::Cycle(
+ crate::core::user_config::qobuz_quality_label(self.user_config.behavior.qobuz_quality)
+ .to_string(),
+ crate::core::user_config::QOBUZ_QUALITY_LABELS,
+ ),
+ },
SettingItem {
id: "behavior.playback_poll_seconds".to_string(),
name: "Playback Poll Seconds".to_string(),
diff --git a/src/core/app/shuffle_repeat.rs b/src/core/app/shuffle_repeat.rs
index 98988eb9..7edae10d 100644
--- a/src/core/app/shuffle_repeat.rs
+++ b/src/core/app/shuffle_repeat.rs
@@ -8,7 +8,12 @@ impl App {
/// Call only when [`active_queueable_decoded_source`](Self::active_queueable_decoded_source)
/// holds, so exactly one branch applies.
#[cfg_attr(
- not(any(feature = "local-files", feature = "subsonic", feature = "youtube")),
+ not(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ )),
allow(unused_variables)
)]
fn apply_decoded_shuffle(&mut self) {
@@ -38,6 +43,12 @@ impl App {
s.set_shuffle(on);
}
}
+ #[cfg(feature = "qobuz")]
+ if let Some(s) = self.qobuz_playback.as_mut() {
+ if !s.advancing {
+ s.set_shuffle(on);
+ }
+ }
}
/// Re-sync the active queueable source's queue order to `decoded_shuffle` when
@@ -45,7 +56,12 @@ impl App {
/// because a track change was in flight. Driven by the runner tick, so it lands
/// as soon as the advance commits (`advancing` clears). A no-op whenever the
/// order already matches `decoded_shuffle`, so it is cheap to call every tick.
- #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+ #[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ ))]
pub(crate) fn reconcile_decoded_shuffle(&mut self) {
// The native queue owns the sink: any per-source struct is a suspended
// context whose order is reconciled when it resumes, not now.
@@ -73,6 +89,12 @@ impl App {
s.set_shuffle(on);
}
}
+ #[cfg(feature = "qobuz")]
+ if let Some(s) = self.qobuz_playback.as_mut() {
+ if !s.advancing && s.shuffle_backup.is_some() != on {
+ s.set_shuffle(on);
+ }
+ }
}
/// Store a Spotify `RepeatState` as the decoded repeat mode. Used by the
@@ -85,7 +107,12 @@ impl App {
/// handler) when the native queue owns playback or no queueable decoded source
/// is active — the same ownership gate as the MPRIS mode setters, so a
/// suspended context under the queue is never touched.
- #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+ #[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ ))]
pub(crate) fn set_decoded_repeat_from_state(
&mut self,
state: rspotify::model::enums::RepeatState,
@@ -117,6 +144,7 @@ impl App {
any(
feature = "local-files",
feature = "subsonic",
+ feature = "qobuz",
feature = "internet-radio",
feature = "youtube"
)
@@ -141,6 +169,7 @@ impl App {
any(
feature = "local-files",
feature = "subsonic",
+ feature = "qobuz",
feature = "internet-radio",
feature = "youtube"
)
diff --git a/src/core/app/transport.rs b/src/core/app/transport.rs
index 8259d2c9..0cf1a2b8 100644
--- a/src/core/app/transport.rs
+++ b/src/core/app/transport.rs
@@ -24,7 +24,12 @@ impl App {
pub fn toggle_playback(&mut self) {
// The native queue slot owns the sink: toggle its player directly (covers the
// idle-app case where no per-source context is set).
- #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+ #[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ ))]
if let Some(player) = self.queue_now_decoded_player() {
if player.is_paused() {
player.resume();
@@ -72,6 +77,17 @@ impl App {
return;
}
+ // Qobuz playback owns the session the same way: toggle its sink directly.
+ #[cfg(feature = "qobuz")]
+ if let Some(qobuz) = &self.qobuz_playback {
+ if qobuz.player.is_paused() {
+ qobuz.player.resume();
+ } else {
+ qobuz.player.pause();
+ }
+ return;
+ }
+
// YouTube playback owns the session the same way: toggle its sink directly.
#[cfg(feature = "youtube")]
if let Some(youtube) = &self.youtube_playback {
diff --git a/src/core/art.rs b/src/core/art.rs
index cf91929c..0293725b 100644
--- a/src/core/art.rs
+++ b/src/core/art.rs
@@ -44,7 +44,7 @@ fn client() -> anyhow::Result<&'static reqwest::Client> {
#[derive(Clone, Debug)]
pub enum CoverArtRequest {
/// Download and decode an image from a URL (Spotify album art, YouTube
- /// thumbnail, Subsonic getCoverArt).
+ /// thumbnail, Subsonic getCoverArt, Qobuz album art).
Url(String),
/// Read the embedded cover picture out of a local audio file. `key` is the
/// track's `file://` URI (used as the cache identity); `path` is the resolved
diff --git a/src/core/driver/mod.rs b/src/core/driver/mod.rs
index 60b2f348..e1553c0b 100644
--- a/src/core/driver/mod.rs
+++ b/src/core/driver/mod.rs
@@ -391,7 +391,12 @@ impl Driver {
// Native queue slot: when the queued track finishes, advance the queue
// (play the next queued item, or resume the suspended context). Runs
// before the per-source blocks so it takes precedence over them.
- #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+ #[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ ))]
{
use crate::infra::queue::QueueNowPlaying;
let advance = match app.queue_now.as_mut() {
@@ -442,7 +447,12 @@ impl Driver {
// dispatching) because the sink stays empty for the whole decode — or,
// for Subsonic/YouTube, multi-second download — window; without it the
// next tick would re-dispatch and skip several tracks per advance.
- #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+ #[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ ))]
macro_rules! decoded_auto_advance {
($app:ident, $playback:ident, $queue:ident) => {
if !$app.queue_owns_playback() {
@@ -487,13 +497,20 @@ impl Driver {
decoded_auto_advance!(app, local_playback, queue);
#[cfg(feature = "subsonic")]
decoded_auto_advance!(app, subsonic_playback, tracks);
+ #[cfg(feature = "qobuz")]
+ decoded_auto_advance!(app, qobuz_playback, tracks);
#[cfg(feature = "youtube")]
decoded_auto_advance!(app, youtube_playback, tracks);
// Apply any shuffle toggle that was deferred while a track change was in
// flight, now that the advance may have committed (a cheap no-op when the
// queue order already matches the decoded shuffle state).
- #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+ #[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ ))]
app.reconcile_decoded_shuffle();
// Internet radio has no queue to auto-advance; instead the tick watches
@@ -524,7 +541,12 @@ impl Driver {
// The native queue slot owns the sink when playing a decoded track; read
// progress from its player first (it may share the suspended context's
// player, in which case a per-source block below reads the same value).
- #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+ #[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ ))]
if let Some(crate::infra::queue::QueueNowPlaying::Decoded(d)) = app.queue_now.as_ref() {
source_owns_playback = true;
app.song_progress_ms = d.player.position().as_millis();
@@ -547,6 +569,13 @@ impl Driver {
app.song_progress_ms = position_ms;
}
}
+ #[cfg(feature = "qobuz")]
+ if !spotify_queue_slot {
+ if let Some(qobuz) = app.qobuz_playback.as_ref() {
+ source_owns_playback = true;
+ app.song_progress_ms = qobuz.player.position().as_millis();
+ }
+ }
#[cfg(feature = "internet-radio")]
if !spotify_queue_slot {
if let Some(radio) = app.radio_playback.as_ref() {
diff --git a/src/core/driver/plan.rs b/src/core/driver/plan.rs
index 2488656c..b32f2741 100644
--- a/src/core/driver/plan.rs
+++ b/src/core/driver/plan.rs
@@ -7,7 +7,12 @@
#[cfg(feature = "art-decode")]
use crate::core::art::CoverArtStatus;
-#[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+#[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+))]
use crate::infra::queue::{advance_decision, Decision, RepeatMode};
use std::time::{Duration, Instant};
@@ -56,7 +61,12 @@ pub(super) fn session_persist_action(
/// `advancing` is the atomic check-and-set flag that guarantees one dispatch
/// per finished track: the sink stays empty for the whole decode/download of
/// the next item, so without it every tick in that window would re-dispatch.
-#[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+#[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+))]
pub(super) fn native_queue_advance_due(finished: bool, advancing: bool) -> bool {
finished && !advancing
}
@@ -64,7 +74,12 @@ pub(super) fn native_queue_advance_due(finished: bool, advancing: bool) -> bool
/// What the tick does about a decoded-source session, as plain data so the
/// glue between [`advance_decision`] and the dispatch/suspend/teardown calls
/// is testable without a sink.
-#[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+#[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+))]
#[derive(Debug, PartialEq)]
pub(super) enum DecodedAdvance {
/// Mark the session advancing and dispatch the track change: replay the
@@ -81,7 +96,12 @@ pub(super) enum DecodedAdvance {
/// Decoded-source auto-advance for one tick, composing [`advance_decision`]
/// (the shared end-of-track policy) with the action the driver takes on it.
-#[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+#[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+))]
pub(super) fn decoded_advance(
finished: bool,
advancing: bool,
@@ -184,7 +204,12 @@ mod tests {
);
}
- #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+ #[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ ))]
mod decoded {
use super::*;
diff --git a/src/core/first_run.rs b/src/core/first_run.rs
index 8d505ecb..338d280b 100644
--- a/src/core/first_run.rs
+++ b/src/core/first_run.rs
@@ -139,7 +139,8 @@ fn compiled_in_sources() -> Vec {
feature = "youtube",
feature = "subsonic",
feature = "internet-radio",
- feature = "local-files"
+ feature = "local-files",
+ feature = "qobuz"
)),
allow(unused_mut)
)]
@@ -152,11 +153,13 @@ fn compiled_in_sources() -> Vec {
options.push(Source::Radio);
#[cfg(feature = "local-files")]
options.push(Source::Local);
+ #[cfg(feature = "qobuz")]
+ options.push(Source::Qobuz);
options
}
// `user_config` and `onboarding` are only read by credential/config-collecting
-// sources; a build with none of them (slim) leaves them unused.
+// sources; a build with none of them (slim, or Qobuz alone) leaves them unused.
#[cfg_attr(
not(any(feature = "subsonic", feature = "youtube", feature = "local-files")),
allow(unused_variables)
@@ -173,12 +176,77 @@ async fn configure_source(
Source::YouTube => configure_youtube(user_config, onboarding),
#[cfg(feature = "local-files")]
Source::Local => configure_local(user_config, onboarding),
+ #[cfg(feature = "qobuz")]
+ Source::Qobuz => configure_qobuz(onboarding).await?,
// Radio needs no setup; other sources are handled above when compiled in.
_ => {}
}
Ok(())
}
+/// Log in to Qobuz through the browser and save the credentials file.
+#[cfg(feature = "qobuz")]
+async fn configure_qobuz(onboarding: &dyn Onboarding) -> Result<()> {
+ use crate::infra::qobuz::{auth, shared_qobuz_client, QobuzSource};
+
+ onboarding.info(
+ "\nQobuz setup: spotatui logs in through your browser (a paid Qobuz subscription is needed).",
+ );
+ onboarding.progress("Fetching the Qobuz web player constants... ");
+ let constants = match auth::resolve_constants(&shared_qobuz_client(), None).await {
+ Ok(constants) => {
+ onboarding.info("OK");
+ constants
+ }
+ Err(e) => {
+ onboarding.info(&format!("failed: {e:#}"));
+ onboarding.info("Press `d` in the app and pick Qobuz to try again.");
+ return Ok(());
+ }
+ };
+
+ let attempt = match auth::LoginAttempt::bind(constants.clone()).await {
+ Ok(attempt) => attempt,
+ Err(e) => {
+ onboarding.info(&format!("Qobuz login could not start: {e:#}"));
+ onboarding.info("Press `d` in the app and pick Qobuz to try again.");
+ return Ok(());
+ }
+ };
+ let url = attempt.url();
+ onboarding.info("\nAttempting to open this URL in your browser:");
+ onboarding.info(&format!("{url}\n"));
+ if let Err(e) = open::that(&url) {
+ onboarding.info(&format!("Failed to open browser automatically: {e}"));
+ onboarding.info("Please manually open the URL above in your browser.");
+ }
+ onboarding.info("Waiting for the Qobuz login to complete...");
+
+ let credentials = match attempt.wait().await {
+ Ok(credentials) => credentials,
+ Err(e) => {
+ onboarding.info(&format!("Qobuz login failed: {e:#}"));
+ onboarding.info("Press `d` in the app and pick Qobuz to try again.");
+ return Ok(());
+ }
+ };
+ auth::save_login(&credentials)?;
+ onboarding.info("Logged in to Qobuz.");
+
+ // Best-effort stream check: a failure is not fatal, the login is saved.
+ onboarding.progress("Testing the stream session... ");
+ let source = QobuzSource::new(
+ constants.app_id,
+ constants.app_secret,
+ credentials.user_auth_token,
+ );
+ match source.session_start().await {
+ Ok(_) => onboarding.info("OK"),
+ Err(e) => onboarding.info(&format!("failed: {e:#}")),
+ }
+ Ok(())
+}
+
#[cfg(feature = "subsonic")]
async fn configure_subsonic(
user_config: &mut UserConfig,
diff --git a/src/core/onboarding.rs b/src/core/onboarding.rs
index 7f21b72f..6a094f77 100644
--- a/src/core/onboarding.rs
+++ b/src/core/onboarding.rs
@@ -67,7 +67,10 @@ pub trait Onboarding: Send + Sync {
/// completed by a later [`Self::info`] (a flushed `print!` on the console).
// Only the source-configuration flows emit progress fragments; builds
// without those sources have no caller.
- #[cfg_attr(not(any(feature = "subsonic", feature = "youtube")), allow(dead_code))]
+ #[cfg_attr(
+ not(any(feature = "subsonic", feature = "qobuz", feature = "youtube")),
+ allow(dead_code)
+ )]
fn progress(&self, text: &str);
/// Show `prompt` verbatim (nothing appended) and read one line of input,
diff --git a/src/core/paths.rs b/src/core/paths.rs
index 49ed755c..cf5c5a59 100644
--- a/src/core/paths.rs
+++ b/src/core/paths.rs
@@ -51,6 +51,12 @@ pub(crate) fn app_state_dir() -> Option {
)
}
+/// The Qobuz credentials file: `/qobuz_credentials.yml`.
+#[cfg(feature = "qobuz")]
+pub(crate) fn qobuz_credentials_path() -> Option {
+ app_config_dir().map(|dir| dir.join("qobuz_credentials.yml"))
+}
+
/// Directory holding this run's log file.
///
/// The OS temp directory rather than the state dir: a log file is written per
diff --git a/src/core/persisted_playback.rs b/src/core/persisted_playback.rs
index 1c49489b..7cd61426 100644
--- a/src/core/persisted_playback.rs
+++ b/src/core/persisted_playback.rs
@@ -95,6 +95,19 @@ pub enum PersistedPlayback {
#[serde(default)]
shuffle: Option,
},
+ /// Qobuz: full track metadata (from the API) plus the queue position.
+ Qobuz {
+ tracks: Vec,
+ index: usize,
+ position_ms: u64,
+ paused: bool,
+ #[serde(default)]
+ repeat: crate::infra::queue::RepeatMode,
+ #[serde(default)]
+ shuffle_on: bool,
+ #[serde(default)]
+ shuffle: Option,
+ },
/// YouTube: full track metadata (from `yt-dlp`) plus the queue position.
YouTube {
tracks: Vec,
@@ -253,6 +266,26 @@ mod tests {
assert_eq!(load(&path).unwrap(), Some(s));
}
+ #[test]
+ fn save_then_load_round_trips_qobuz_playback() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("last_session.yml");
+ let s = session(
+ Some(PersistedPlayback::Qobuz {
+ tracks: vec![track("qobuz:track:1", "Hi-res")],
+ index: 0,
+ position_ms: 12_000,
+ paused: true,
+ repeat: RepeatMode::Context,
+ shuffle_on: false,
+ shuffle: None,
+ }),
+ vec![],
+ );
+ save(&path, &s).unwrap();
+ assert_eq!(load(&path).unwrap(), Some(s));
+ }
+
#[test]
fn save_then_load_round_trips_playback_with_queue() {
let dir = tempfile::tempdir().unwrap();
diff --git a/src/core/plugin_api.rs b/src/core/plugin_api.rs
index d0c4360e..c36a636d 100644
--- a/src/core/plugin_api.rs
+++ b/src/core/plugin_api.rs
@@ -62,7 +62,7 @@ pub struct TrackInfo {
#[serde(default)]
pub explicit: bool,
/// A directly-fetchable cover-art image URL for this track, when the source
- /// can provide one (Subsonic getCoverArt, YouTube thumbnail). `None` for
+ /// can provide one (Subsonic getCoverArt, YouTube thumbnail, Qobuz album art). `None` for
/// sources without per-track art. Additive: only adds a key to the serialized
/// snapshot, preserving the api_version = 4 plugin contract.
#[serde(default)]
diff --git a/src/core/queue.rs b/src/core/queue.rs
index 234692c9..b34670a3 100644
--- a/src/core/queue.rs
+++ b/src/core/queue.rs
@@ -12,12 +12,13 @@ pub enum QueueItemSource {
LocalFile,
Subsonic,
YouTube,
+ Qobuz,
}
/// Classify a queue item by its URI scheme. Anything that is not a local file,
-/// Subsonic, or YouTube URI is treated as Spotify (the `spotify:track:` scheme).
-/// Radio URIs (`radio:`) are never queued, so they are rejected before reaching
-/// this function.
+/// Subsonic, YouTube, or Qobuz URI is treated as Spotify (the `spotify:track:`
+/// scheme). Radio URIs (`radio:`) are never queued, so they are rejected before
+/// reaching this function.
pub fn queue_item_source(uri: &str) -> QueueItemSource {
if uri.starts_with("file:") {
QueueItemSource::LocalFile
@@ -25,6 +26,8 @@ pub fn queue_item_source(uri: &str) -> QueueItemSource {
QueueItemSource::Subsonic
} else if uri.starts_with("youtube:") {
QueueItemSource::YouTube
+ } else if uri.starts_with("qobuz:") {
+ QueueItemSource::Qobuz
} else {
QueueItemSource::Spotify
}
@@ -51,7 +54,7 @@ pub fn queue_item_source(uri: &str) -> QueueItemSource {
/// than have an empty handle queued for it.
#[cfg_attr(not(any(feature = "mcp-server", feature = "ai-dj")), allow(dead_code))]
pub fn is_playable_track_uri(uri: &str) -> bool {
- ["spotify:track:", "file:", "subsonic:", "youtube:"]
+ ["spotify:track:", "file:", "subsonic:", "youtube:", "qobuz:"]
.iter()
.any(|prefix| {
uri
@@ -84,6 +87,7 @@ pub fn missing_source_feature(uri: &str) -> Option<&'static str> {
QueueItemSource::LocalFile => "local-files",
QueueItemSource::Subsonic => "subsonic",
QueueItemSource::YouTube => "youtube",
+ QueueItemSource::Qobuz => "qobuz",
QueueItemSource::Spotify => unreachable!("returned above"),
})
}
@@ -96,6 +100,7 @@ pub fn source_label(source: QueueItemSource) -> &'static str {
QueueItemSource::LocalFile => "Local",
QueueItemSource::Subsonic => "Subsonic",
QueueItemSource::YouTube => "YouTube",
+ QueueItemSource::Qobuz => "Qobuz",
}
}
@@ -110,6 +115,7 @@ pub fn source_available(source: QueueItemSource) -> bool {
QueueItemSource::LocalFile => cfg!(feature = "local-files"),
QueueItemSource::Subsonic => cfg!(feature = "subsonic"),
QueueItemSource::YouTube => cfg!(feature = "youtube"),
+ QueueItemSource::Qobuz => cfg!(feature = "qobuz"),
}
}
@@ -160,6 +166,11 @@ pub enum SuspendedContext {
resume_index: Option,
resume_position_ms: u64,
},
+ #[cfg(feature = "qobuz")]
+ Qobuz {
+ resume_index: Option,
+ resume_position_ms: u64,
+ },
#[cfg(feature = "youtube")]
YouTube {
resume_index: Option,
@@ -197,6 +208,7 @@ mod tests {
queue_item_source("youtube:5NV6Rdv1a3I"),
QueueItemSource::YouTube
);
+ assert_eq!(queue_item_source("qobuz:track:42"), QueueItemSource::Qobuz);
// Unknown schemes fall back to Spotify.
assert_eq!(
queue_item_source("something-else"),
@@ -211,6 +223,7 @@ mod tests {
"file:/home/jay/Music/a.flac",
"subsonic:track:1",
"youtube:5NV6Rdv1a3I",
+ "qobuz:track:42",
] {
assert!(is_playable_track_uri(uri), "{uri} should be playable");
}
@@ -233,6 +246,7 @@ mod tests {
"file:",
"subsonic:",
"youtube:",
+ "qobuz:",
] {
assert!(!is_playable_track_uri(uri), "{uri} should not be playable");
}
@@ -244,6 +258,7 @@ mod tests {
assert_eq!(source_label(QueueItemSource::LocalFile), "Local");
assert_eq!(source_label(QueueItemSource::Subsonic), "Subsonic");
assert_eq!(source_label(QueueItemSource::YouTube), "YouTube");
+ assert_eq!(source_label(QueueItemSource::Qobuz), "Qobuz");
}
#[test]
@@ -267,6 +282,7 @@ mod tests {
),
("subsonic:abc", "subsonic", cfg!(feature = "subsonic")),
("youtube:abc", "youtube", cfg!(feature = "youtube")),
+ ("qobuz:track:1", "qobuz", cfg!(feature = "qobuz")),
] {
assert!(is_playable_track_uri(uri), "{uri} should be well-formed");
assert_eq!(
diff --git a/src/core/source.rs b/src/core/source.rs
index a28edfc6..3232ad0f 100644
--- a/src/core/source.rs
+++ b/src/core/source.rs
@@ -48,16 +48,18 @@ pub enum Source {
Subsonic,
Radio,
YouTube,
+ Qobuz,
}
impl Source {
/// Every selectable source, in display order. Add new sources here.
- pub const ALL: [Source; 5] = [
+ pub const ALL: [Source; 6] = [
Source::Spotify,
Source::Local,
Source::Subsonic,
Source::Radio,
Source::YouTube,
+ Source::Qobuz,
];
/// Human-readable label shown in the source picker.
@@ -68,6 +70,7 @@ impl Source {
Source::Subsonic => "Subsonic",
Source::Radio => "Internet Radio",
Source::YouTube => "YouTube",
+ Source::Qobuz => "Qobuz",
}
}
@@ -81,6 +84,7 @@ impl Source {
Source::Subsonic => "Subsonic",
Source::Radio => "Radio",
Source::YouTube => "YouTube",
+ Source::Qobuz => "Qobuz",
}
}
@@ -93,6 +97,7 @@ impl Source {
"Subsonic" => Source::Subsonic,
"Radio" => Source::Radio,
"YouTube" => Source::YouTube,
+ "Qobuz" => Source::Qobuz,
_ => Source::Spotify,
}
}
@@ -101,7 +106,7 @@ impl Source {
pub fn supports_search(&self) -> bool {
matches!(
self,
- Source::Spotify | Source::Subsonic | Source::Radio | Source::YouTube
+ Source::Spotify | Source::Subsonic | Source::Radio | Source::YouTube | Source::Qobuz
)
}
@@ -184,6 +189,16 @@ mod tests {
assert!(!Source::YouTube.supports_like());
}
+ #[test]
+ fn qobuz_supports_search_only() {
+ // `catalog/search` is searchable; favorites and playlists are read through
+ // the sidebar, and like/playlist writes are follow-ups.
+ assert!(Source::Qobuz.supports_search());
+ assert!(!Source::Qobuz.supports_library());
+ assert!(!Source::Qobuz.supports_playlist_write());
+ assert!(!Source::Qobuz.supports_like());
+ }
+
#[test]
fn config_str_round_trips_every_source() {
for source in Source::ALL {
diff --git a/src/core/state.rs b/src/core/state.rs
index 61c6f8bd..0c41dfa9 100644
--- a/src/core/state.rs
+++ b/src/core/state.rs
@@ -19,6 +19,24 @@ pub struct RadioStationConfig {
pub url: String,
}
+/// The Qobuz web-player constants, cached by bundle version (public values only).
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct QobuzBundleCache {
+ pub bundle_version: String,
+ pub app_id: String,
+ pub app_secret: String,
+ pub oauth_key: String,
+}
+
+impl QobuzBundleCache {
+ fn is_complete(&self) -> bool {
+ !self.bundle_version.is_empty()
+ && !self.app_id.is_empty()
+ && !self.app_secret.is_empty()
+ && !self.oauth_key.is_empty()
+ }
+}
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RadioStationAddOutcome {
Added,
@@ -104,6 +122,7 @@ impl RuntimeState {
library_height_percent: Some(self.library_height_percent.min(100)),
radio_stations: Some(sanitized_radio_stations(&self.radio_stations)),
community_pin_prompt_shown: Some(self.community_pin_prompt_shown),
+ qobuz_bundle_cache: None,
}
}
@@ -201,6 +220,8 @@ pub struct PersistedRuntimeState {
pub radio_stations: Option>,
#[serde(skip_serializing_if = "Option::is_none")]
pub community_pin_prompt_shown: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub qobuz_bundle_cache: Option,
}
impl PersistedRuntimeState {
@@ -284,6 +305,14 @@ impl PersistedRuntimeState {
}
}
+ #[cfg_attr(not(feature = "qobuz"), allow(dead_code))]
+ pub fn qobuz_bundle_cache(qobuz_bundle_cache: QobuzBundleCache) -> Self {
+ Self {
+ qobuz_bundle_cache: Some(qobuz_bundle_cache),
+ ..Default::default()
+ }
+ }
+
pub fn merge_patch(&mut self, patch: &Self) {
merge_state_patch(self, patch);
}
@@ -299,6 +328,7 @@ impl PersistedRuntimeState {
&& self.library_height_percent.is_none()
&& self.radio_stations.is_none()
&& self.community_pin_prompt_shown.is_none()
+ && self.qobuz_bundle_cache.is_none()
}
}
@@ -455,6 +485,10 @@ fn sanitized_persisted_state(state: &PersistedRuntimeState) -> PersistedRuntimeS
.as_deref()
.map(sanitized_radio_stations),
community_pin_prompt_shown: state.community_pin_prompt_shown,
+ qobuz_bundle_cache: state
+ .qobuz_bundle_cache
+ .clone()
+ .filter(QobuzBundleCache::is_complete),
}
}
@@ -498,6 +532,9 @@ fn merge_state_patch(merged: &mut PersistedRuntimeState, patch: &PersistedRuntim
if let Some(community_pin_prompt_shown) = patch.community_pin_prompt_shown {
merged.community_pin_prompt_shown = Some(community_pin_prompt_shown);
}
+ if let Some(qobuz_bundle_cache) = &patch.qobuz_bundle_cache {
+ merged.qobuz_bundle_cache = Some(qobuz_bundle_cache.clone());
+ }
}
fn sanitized_ids(ids: &[String]) -> Vec {
@@ -623,6 +660,12 @@ mod tests {
url: "https://example.test/stream".to_string(),
}]),
community_pin_prompt_shown: Some(true),
+ qobuz_bundle_cache: Some(QobuzBundleCache {
+ bundle_version: "8.2.0-b034".to_string(),
+ app_id: "123456789".to_string(),
+ app_secret: "s".repeat(32),
+ oauth_key: "k".to_string(),
+ }),
};
save(&path, &state).unwrap();
@@ -659,6 +702,25 @@ mod tests {
assert_eq!(load(&path).unwrap().community_pin_prompt_shown, Some(true));
}
+ #[test]
+ fn incomplete_qobuz_bundle_cache_is_dropped_on_save() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("state.yml");
+
+ save(
+ &path,
+ &PersistedRuntimeState::qobuz_bundle_cache(QobuzBundleCache {
+ bundle_version: "8.2.0-b034".to_string(),
+ app_id: String::new(),
+ app_secret: "s".to_string(),
+ oauth_key: "k".to_string(),
+ }),
+ )
+ .unwrap();
+
+ assert_eq!(load(&path).unwrap().qobuz_bundle_cache, None);
+ }
+
#[test]
fn state_load_and_save_caps_playbar_height_rows() {
let dir = tempfile::tempdir().unwrap();
diff --git a/src/core/user_config.rs b/src/core/user_config.rs
index 1734590a..0755fa84 100644
--- a/src/core/user_config.rs
+++ b/src/core/user_config.rs
@@ -49,6 +49,44 @@ pub fn normalize_tick_rate_milliseconds(value: i64) -> u64 {
value.clamp(1, MAX_TICK_RATE_MILLISECONDS as i64) as u64
}
+/// The Qobuz `format_id` values, in the order of [`QOBUZ_QUALITY_LABELS`].
+const QOBUZ_QUALITY_IDS: [u8; 4] = [5, 6, 7, 27];
+/// Settings-screen labels for the Qobuz qualities, in [`QOBUZ_QUALITY_IDS`] order.
+#[cfg_attr(not(feature = "qobuz"), allow(dead_code))]
+pub const QOBUZ_QUALITY_LABELS: &[&str] = &["MP3 320", "FLAC 16/44.1", "FLAC 24/96", "FLAC 24/192"];
+/// The default quality (FLAC 16/44.1) as an index into both tables.
+const QOBUZ_QUALITY_DEFAULT_INDEX: usize = 1;
+const QOBUZ_QUALITY_DEFAULT: u8 = QOBUZ_QUALITY_IDS[QOBUZ_QUALITY_DEFAULT_INDEX];
+
+/// The settings label of a Qobuz `format_id` (unknown ids read as the default).
+#[cfg_attr(not(feature = "qobuz"), allow(dead_code))]
+pub fn qobuz_quality_label(quality: u8) -> &'static str {
+ let index = QOBUZ_QUALITY_IDS
+ .iter()
+ .position(|&id| id == quality)
+ .unwrap_or(QOBUZ_QUALITY_DEFAULT_INDEX);
+ QOBUZ_QUALITY_LABELS[index]
+}
+
+/// The Qobuz `format_id` behind a settings label (unknown labels read as the default).
+#[cfg_attr(not(feature = "qobuz"), allow(dead_code))]
+pub fn qobuz_quality_from_label(label: &str) -> u8 {
+ let index = QOBUZ_QUALITY_LABELS
+ .iter()
+ .position(|&l| l == label)
+ .unwrap_or(QOBUZ_QUALITY_DEFAULT_INDEX);
+ QOBUZ_QUALITY_IDS[index]
+}
+
+/// A configured Qobuz quality, or the default for an unknown id.
+fn qobuz_quality_or_default(quality: u8) -> u8 {
+ if QOBUZ_QUALITY_IDS.contains(&quality) {
+ quality
+ } else {
+ QOBUZ_QUALITY_DEFAULT
+ }
+}
+
/// Parse a human-readable update delay into seconds.
/// Accepts: "0", "30s", "10m", "2h", "7d", or a bare second count.
pub fn parse_update_delay_secs(value: &str) -> Result {
@@ -499,6 +537,7 @@ pub struct BehaviorConfigString {
pub subsonic_username: Option,
pub subsonic_password: Option,
pub ytdlp_path: Option,
+ pub qobuz_quality: Option,
#[serde(skip_serializing_if = "Option::is_none")]
pub radio_stations: Option>,
// --- Phase 2: icons / glyphs / labels (defaults = today's glyphs) ---
@@ -680,6 +719,9 @@ pub struct BehaviorConfig {
/// Path to the `yt-dlp` binary used by the YouTube source. `None` resolves
/// plain `yt-dlp` through `$PATH`.
pub ytdlp_path: Option,
+ /// Qobuz stream quality: 5 (MP3 320), 6 (FLAC 16/44.1), 7 (FLAC 24/96),
+ /// 27 (FLAC 24/192). Default 6.
+ pub qobuz_quality: u8,
/// User-authored stations shown alongside stations saved at runtime.
/// In-app favorite/remove actions mutate `state.yml`, not this list.
pub radio_stations: Vec,
@@ -1065,6 +1107,7 @@ impl UserConfig {
subsonic_username: None,
subsonic_password: None,
ytdlp_path: None,
+ qobuz_quality: QOBUZ_QUALITY_DEFAULT,
radio_stations: Vec::new(),
// --- Phase 2: icons / glyphs / labels (defaults = today's glyphs) ---
gauge_filled_icon: "⣿".to_string(),
@@ -1610,6 +1653,9 @@ impl UserConfig {
if let Some(ytdlp_path) = trim_to_none(behavior_config.ytdlp_path) {
self.behavior.ytdlp_path = Some(ytdlp_path);
}
+ if let Some(qobuz_quality) = behavior_config.qobuz_quality {
+ self.behavior.qobuz_quality = qobuz_quality_or_default(qobuz_quality);
+ }
// ===== Phase 2: icons / glyphs / labels =====
// Width-restricted glyphs (column math depends on them) are validated to
@@ -2043,6 +2089,7 @@ impl UserConfig {
subsonic_username: self.behavior.subsonic_username.clone(),
subsonic_password: self.behavior.subsonic_password.clone(),
ytdlp_path: self.behavior.ytdlp_path.clone(),
+ qobuz_quality: Some(self.behavior.qobuz_quality),
radio_stations: if self.behavior.radio_stations.is_empty() {
None
} else {
diff --git a/src/infra/audio/mod.rs b/src/infra/audio/mod.rs
index 6a36ccbe..b616ae9a 100644
--- a/src/infra/audio/mod.rs
+++ b/src/infra/audio/mod.rs
@@ -4,14 +4,16 @@
// - Windows/macOS: cpal (WASAPI/CoreAudio)
//
// It also hosts the shared decoded-audio output engine (`LocalPlayer`), used by
-// any source that plays through the local rodio sink (local files, Subsonic).
+// any source that plays through the local rodio sink (local files, Subsonic, Qobuz).
-// Shared decode/output engine. Gated on `audio-decode`, which both `local-files`
-// and `subsonic` pull in, so the player is reachable from either source.
+// Shared decode/output engine. Gated on `audio-decode`, which `local-files`,
+// `subsonic` and `qobuz` pull in, so the player is reachable from each source.
#[cfg(feature = "audio-decode")]
mod player;
#[cfg(feature = "audio-decode")]
pub use player::LocalPlayer;
+#[cfg(feature = "qobuz")]
+pub use player::PreparedStream;
#[cfg(any(feature = "audio-viz", feature = "audio-viz-cpal"))]
mod analyzer;
diff --git a/src/infra/audio/player.rs b/src/infra/audio/player.rs
index 9f4cbc1a..1e5f6cc8 100644
--- a/src/infra/audio/player.rs
+++ b/src/infra/audio/player.rs
@@ -49,6 +49,10 @@ pub struct LocalPlayer {
_keepalive: mpsc::Sender<()>,
}
+/// A decoded stream, ready for [`LocalPlayer::play_prepared`].
+#[cfg(any(feature = "internet-radio", feature = "qobuz"))]
+pub struct PreparedStream(Box);
+
impl LocalPlayer {
/// Open the default audio output device and return a ready player.
///
@@ -74,7 +78,7 @@ impl LocalPlayer {
/// Only the tempfile-based sources play files; a build with just
/// `internet-radio` uses [`play_stream`](Self::play_stream) instead.
#[cfg_attr(
- not(any(feature = "local-files", feature = "subsonic")),
+ not(any(feature = "local-files", feature = "subsonic", feature = "qobuz")),
allow(dead_code)
)]
pub fn play_file(&self, path: &Path) -> Result<()> {
@@ -100,15 +104,8 @@ impl LocalPlayer {
}
/// Decode an already-opened **live stream** and play it, replacing whatever
- /// was playing.
- ///
- /// Unlike [`play_file`](Self::play_file) the reader is treated as
- /// non-seekable: the decoder is built with `with_seekable(false)` so the
- /// symphonia probe never issues the `Seek` that breaks on an infinite HTTP
- /// (internet-radio) stream — the `Seek` bound is only there to satisfy
- /// rodio's type signature. A live stream has no filename, so format
- /// detection is primed from `mime_type` (e.g. `"audio/mpeg"` from the ICY
- /// response's Content-Type) when available.
+ /// was playing: [`prepare_stream`](Self::prepare_stream) with no byte
+ /// length, then [`play_prepared`](Self::play_prepared).
///
/// **Blocking:** the probe reads from the network reader; call it off the
/// async runtime (e.g. `spawn_blocking`) like `play_file`.
@@ -117,18 +114,56 @@ impl LocalPlayer {
where
R: std::io::Read + std::io::Seek + Send + Sync + 'static,
{
- let mut builder = Decoder::builder().with_data(reader).with_seekable(false);
+ let prepared = Self::prepare_stream(reader, mime_type, None)?;
+ self.play_prepared(prepared);
+ Ok(())
+ }
+
+ /// Build the decoder for a stream without a sink change, so the caller can
+ /// decide under its own lock if the stream is still wanted.
+ ///
+ /// With `byte_len` the reader is seekable and the decoder knows the total
+ /// size (a progressive download of a known file). Without it the reader is
+ /// treated as non-seekable: the decoder is built with `with_seekable(false)`
+ /// so the symphonia probe never issues the `Seek` that breaks on an infinite
+ /// HTTP (internet-radio) stream. The `Seek` bound is only there to satisfy
+ /// rodio's type signature. A stream has no filename, so format detection is
+ /// primed from `mime_type` (e.g. `"audio/mpeg"`) when available.
+ ///
+ /// **Blocking:** the probe reads from the network reader; call it off the
+ /// async runtime (e.g. `spawn_blocking`) like `play_file`.
+ #[cfg(any(feature = "internet-radio", feature = "qobuz"))]
+ pub fn prepare_stream(
+ reader: R,
+ mime_type: Option<&str>,
+ byte_len: Option,
+ ) -> Result
+ where
+ R: std::io::Read + std::io::Seek + Send + Sync + 'static,
+ {
+ let mut builder = Decoder::builder()
+ .with_data(reader)
+ .with_seekable(byte_len.is_some());
+ if let Some(len) = byte_len {
+ builder = builder.with_byte_len(len);
+ }
if let Some(mime) = mime_type {
builder = builder.with_mime_type(mime);
}
let decoder = builder
.build()
.map_err(|e| anyhow::anyhow!("decoding audio stream: {e}"))?;
+ Ok(PreparedStream(Box::new(decoder)))
+ }
+ /// Play a prepared stream, replacing whatever was playing. The clear waits
+ /// for the audio thread to drop the previous source: call it off the `App`
+ /// lock (see `stop_detached`).
+ #[cfg(any(feature = "internet-radio", feature = "qobuz"))]
+ pub fn play_prepared(&self, stream: PreparedStream) {
self.sink.clear();
- self.sink.append(decoder);
+ self.sink.append(stream.0);
self.sink.play();
- Ok(())
}
/// Pause playback, keeping the current position.
@@ -153,10 +188,16 @@ impl LocalPlayer {
self.sink.clear();
}
- /// Set the output volume. `volume` is a linear gain clamped to `0.0..=1.0`
- /// (1.0 = original file level).
- pub fn set_volume(&self, volume: f32) {
- self.sink.set_volume(volume.clamp(0.0, 1.0));
+ /// Stop on the blocking pool and return at once. `stop` waits for the audio
+ /// thread, which a stalled network source holds until its stall timeout.
+ pub fn stop_detached(self: std::sync::Arc) {
+ tokio::task::spawn_blocking(move || self.stop());
+ }
+
+ /// Set the output volume from the user's percent, on the same logarithmic
+ /// curve as native streaming, so one setting is equally loud on every source.
+ pub fn set_volume(&self, percent: u8) {
+ self.sink.set_volume(volume_gain(percent));
}
/// The playback position of the current source.
@@ -171,7 +212,7 @@ impl LocalPlayer {
/// Radio never polls this (an infinite stream has no end-of-track), so it is
/// dead code in a build with just `internet-radio`.
#[cfg_attr(
- not(any(feature = "local-files", feature = "subsonic")),
+ not(any(feature = "local-files", feature = "subsonic", feature = "qobuz")),
allow(dead_code)
)]
pub fn is_finished(&self) -> bool {
@@ -183,7 +224,7 @@ impl LocalPlayer {
/// Radio consumes `Seek` as a no-op (nothing to seek within a live stream),
/// so this is dead code in a build with just `internet-radio`.
#[cfg_attr(
- not(any(feature = "local-files", feature = "subsonic")),
+ not(any(feature = "local-files", feature = "subsonic", feature = "qobuz")),
allow(dead_code)
)]
pub fn seek(&self, pos: Duration) -> Result<()> {
@@ -254,9 +295,39 @@ fn open_sink() -> Result<(Player, mpsc::Sender<()>)> {
// Tests
// ---------------------------------------------------------------------------
+/// The gain for a volume percent: librespot's `VolumeCtrl::Log` mapping over
+/// a 60 dB range (`1000^(p/100 - 1)`), which the native player uses by default.
+/// 0% is silence, 100% is the file level, and 80% is about -12 dB.
+pub fn volume_gain(percent: u8) -> f32 {
+ const DB_RATIO: f64 = 1000.0;
+ match percent {
+ 0 => 0.0,
+ p if p >= 100 => 1.0,
+ p => ((f64::from(p) / 100.0 - 1.0) * DB_RATIO.ln()).exp() as f32,
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
+
+ #[test]
+ fn volume_gain_follows_the_native_log_curve() {
+ assert_eq!(volume_gain(0), 0.0);
+ assert_eq!(volume_gain(100), 1.0);
+ assert_eq!(volume_gain(150), 1.0);
+ let at_80 = volume_gain(80);
+ assert!(
+ (at_80 - 0.251).abs() < 0.001,
+ "80% is about -12 dB: {at_80}"
+ );
+ let at_50 = volume_gain(50);
+ assert!(
+ (at_50 - 0.0316).abs() < 0.001,
+ "50% is about -30 dB: {at_50}"
+ );
+ assert!(volume_gain(20) < at_50 && at_50 < at_80);
+ }
use std::io::Write;
/// Write a minimal valid WAV file (44-byte header + silence) that symphonia
diff --git a/src/infra/dj/tools.rs b/src/infra/dj/tools.rs
index 1df502cc..7c02bce9 100644
--- a/src/infra/dj/tools.rs
+++ b/src/infra/dj/tools.rs
@@ -396,8 +396,8 @@ pub fn parse_call(name: &str, args: &Value) -> Result
if !crate::core::queue::is_playable_track_uri(&uri) {
return Err(invalid(
"uri must name a single playable track (spotify:track:…, file:…, \
- subsonic:…, or youtube:…); album, playlist and https links are not \
- playable here",
+ subsonic:…, youtube:…, or qobuz:track:…); album, playlist and https \
+ links are not playable here",
));
}
Ok(DjToolCall::PlayNow { uri })
@@ -804,7 +804,12 @@ mod tests {
assert!(detail.contains("spotify:track:"), "{uri}: {detail}");
}
- for uri in ["spotify:track:abc", "file:/music/a.flac", "youtube:abc"] {
+ for uri in [
+ "spotify:track:abc",
+ "file:/music/a.flac",
+ "youtube:abc",
+ "qobuz:track:1",
+ ] {
assert!(
parse_call("play_now", &json!({"uri": uri})).is_ok(),
"{uri}"
diff --git a/src/infra/local/dispatch.rs b/src/infra/local/dispatch.rs
index 032cb5eb..ec911ffd 100644
--- a/src/infra/local/dispatch.rs
+++ b/src/infra/local/dispatch.rs
@@ -35,7 +35,7 @@ use tokio::sync::Mutex;
use super::{file_uri_to_path, track_info_from_path, LocalPlaybackState, LocalSource};
use crate::core::app::{App, TrackTableContext};
-use crate::core::source::MediaSource;
+use crate::core::source::{MediaSource, Source};
use crate::infra::audio::LocalPlayer;
use crate::infra::network::IoEvent;
use crate::infra::queue::{advance_index, replay_file};
@@ -107,7 +107,7 @@ pub async fn route_local_event(app: &Arc>, event: &IoEvent) -> bool {
},
IoEvent::ChangeVolume(volume) => match player(app).await {
Some(player) => {
- player.set_volume(*volume as f32 / 100.0);
+ player.set_volume(*volume);
// Keep the playbar's volume readout in sync.
let mut app = app.lock().await;
app.runtime_state.volume_percent = *volume;
@@ -263,33 +263,11 @@ async fn start_local_queue(app: &Arc>, queue: Vec, start_idx:
}
}
- // Tear down any Subsonic session (the `!handled_locally` short-circuit in the
- // runtime means the subsonic dispatch never sees this file:// start, so the
- // teardown must happen here — see infra::subsonic::dispatch device ownership).
- #[cfg(feature = "subsonic")]
- {
- let subsonic = app.lock().await.subsonic_playback.take();
- if let Some(subsonic) = subsonic {
- subsonic.player.stop();
- }
- }
-
- // Tear down any radio session, for the same short-circuit reason.
- #[cfg(feature = "internet-radio")]
- {
- let radio = app.lock().await.radio_playback.take();
- if let Some(radio) = radio {
- radio.player.stop();
- }
- }
-
- // Tear down any YouTube session, for the same short-circuit reason.
- #[cfg(feature = "youtube")]
- {
- let youtube = app.lock().await.youtube_playback.take();
- if let Some(youtube) = youtube {
- youtube.player.stop();
- }
+ // The other decoded sources never see this file:// start (the pump's
+ // `!handled_locally` short-circuit), so their sessions are torn down here.
+ let players = app.lock().await.take_decoded_sessions_except(Source::Local);
+ for player in players {
+ player.stop_detached();
}
let player = match acquire_player(app).await {
@@ -310,7 +288,7 @@ async fn start_local_queue(app: &Arc>, queue: Vec, start_idx:
match result {
Ok(Ok(info)) => {
let volume = app.lock().await.runtime_state.volume_percent;
- player.set_volume(volume as f32 / 100.0);
+ player.set_volume(volume);
// Publish the session exactly once, now that the source is decoding.
// Publish-once covers the empty-sink race here: `local_playback` is `None`
diff --git a/src/infra/media_metadata.rs b/src/infra/media_metadata.rs
index c9f29860..055926bd 100644
--- a/src/infra/media_metadata.rs
+++ b/src/infra/media_metadata.rs
@@ -67,6 +67,7 @@ pub fn current_playback_snapshot(app: &App) -> Option {
#[cfg(any(
feature = "local-files",
feature = "subsonic",
+ feature = "qobuz",
feature = "internet-radio",
feature = "youtube"
))]
@@ -176,6 +177,7 @@ pub fn current_playback_snapshot(app: &App) -> Option {
#[cfg(any(
feature = "local-files",
feature = "subsonic",
+ feature = "qobuz",
feature = "internet-radio",
feature = "youtube"
))]
@@ -183,7 +185,12 @@ fn source_playback_snapshot(app: &App) -> Option {
// The native queue slot playing a decoded track wins over every per-source
// context: it is what is actually audible, and it drives the playbar / MPRIS /
// cover art / lyrics via the shared track-change detector.
- #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+ #[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ ))]
if let Some(crate::infra::queue::QueueNowPlaying::Decoded(d)) = app.queue_now.as_ref() {
let mut snapshot = source_snapshot(
d.track.name.clone(),
@@ -247,6 +254,22 @@ fn source_playback_snapshot(app: &App) -> Option {
));
}
+ #[cfg(feature = "qobuz")]
+ if let Some(qobuz) = app.qobuz_playback.as_ref() {
+ let track = qobuz.tracks.get(qobuz.index)?;
+ return Some(source_snapshot(
+ track.name.clone(),
+ track.artists.clone(),
+ track.album.clone(),
+ track.duration_ms as u32,
+ track.uri.clone(),
+ track.image_url.clone(),
+ qobuz.player.position().as_millis(),
+ !qobuz.player.is_paused(),
+ app,
+ ));
+ }
+
#[cfg(feature = "youtube")]
if let Some(youtube) = app.youtube_playback.as_ref() {
let track = youtube.tracks.get(youtube.index)?;
@@ -305,6 +328,7 @@ fn source_playback_snapshot(app: &App) -> Option {
#[cfg(any(
feature = "local-files",
feature = "subsonic",
+ feature = "qobuz",
feature = "internet-radio",
feature = "youtube"
))]
diff --git a/src/infra/mod.rs b/src/infra/mod.rs
index 325b6340..61c107e9 100644
--- a/src/infra/mod.rs
+++ b/src/infra/mod.rs
@@ -20,6 +20,8 @@ pub mod mpris;
pub mod network;
#[cfg(feature = "streaming")]
pub mod player;
+#[cfg(feature = "qobuz")]
+pub mod qobuz;
pub mod queue;
#[cfg(feature = "internet-radio")]
pub mod radio;
diff --git a/src/infra/network/mod.rs b/src/infra/network/mod.rs
index 635b84ee..c9fb1847 100644
--- a/src/infra/network/mod.rs
+++ b/src/infra/network/mod.rs
@@ -148,7 +148,12 @@ pub enum IoEvent {
/// `route_youtube_event`); it never reaches the Spotify network handler. Only
/// dispatched by the runner tick under a decoded source feature.
#[cfg_attr(
- not(any(feature = "local-files", feature = "subsonic", feature = "youtube")),
+ not(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "youtube",
+ feature = "qobuz"
+ )),
allow(dead_code)
)]
ReplayCurrentTrack,
@@ -240,6 +245,20 @@ pub enum IoEvent {
/// by `infra::subsonic::dispatch`; an inert no-op without the `subsonic` feature.
#[cfg_attr(not(feature = "subsonic"), allow(dead_code))]
GetSubsonicSearchResults(String),
+ /// List the Qobuz sidebar rows: favorites, playlists, albums (handled by
+ /// `infra::qobuz::dispatch`; a no-op on the Spotify network).
+ GetQobuzPlaylists,
+ /// List the tracks of a Qobuz sidebar row by its `qobuz:` URI. Only read by
+ /// `infra::qobuz::dispatch`; without the feature the event is an inert no-op.
+ #[cfg_attr(not(feature = "qobuz"), allow(dead_code))]
+ GetQobuzTracks(String),
+ /// Run a Qobuz catalog search and populate `app.search_results`. Only read
+ /// by `infra::qobuz::dispatch`; an inert no-op without the `qobuz` feature.
+ #[cfg_attr(not(feature = "qobuz"), allow(dead_code))]
+ GetQobuzSearchResults(String),
+ /// Start the in-TUI Qobuz browser login (handled by `infra::qobuz::dispatch`).
+ #[cfg_attr(not(feature = "qobuz"), allow(dead_code))]
+ QobuzLogin,
/// Load the configured internet-radio stations into the sidebar (handled by
/// `infra::radio::dispatch`; a no-op on the Spotify network).
GetRadioStations,
@@ -505,6 +524,10 @@ impl Network {
| IoEvent::GetSubsonicPlaylists
| IoEvent::GetSubsonicTracks(_)
| IoEvent::GetSubsonicSearchResults(_)
+ | IoEvent::GetQobuzPlaylists
+ | IoEvent::GetQobuzTracks(_)
+ | IoEvent::GetQobuzSearchResults(_)
+ | IoEvent::QobuzLogin
| IoEvent::GetRadioStations
| IoEvent::GetRadioSearchResults(_)
| IoEvent::GetYouTubeSearchResults(_)
@@ -981,6 +1004,12 @@ impl Network {
IoEvent::GetSubsonicPlaylists
| IoEvent::GetSubsonicTracks(_)
| IoEvent::GetSubsonicSearchResults(_) => {}
+ // Qobuz browse/search/login events are handled by infra::qobuz::dispatch
+ // before reaching the network; they only arrive here when the feature is off.
+ IoEvent::GetQobuzPlaylists
+ | IoEvent::GetQobuzTracks(_)
+ | IoEvent::GetQobuzSearchResults(_)
+ | IoEvent::QobuzLogin => {}
// Radio browse/search events are handled by infra::radio::dispatch before
// reaching the network; they only arrive here when the feature is off.
IoEvent::GetRadioStations | IoEvent::GetRadioSearchResults(_) => {}
diff --git a/src/infra/player/events.rs b/src/infra/player/events.rs
index d9a0783b..58db05dd 100644
--- a/src/infra/player/events.rs
+++ b/src/infra/player/events.rs
@@ -563,14 +563,24 @@ async fn handle_player_events(
let stray_over_queue = {
let guard = app.lock().await;
let decoded_slot = {
- #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))]
+ #[cfg(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ ))]
{
guard.queue_now_decoded_player().is_some()
}
// Without a queueable decoded source the slot can never be
// decoded (internet radio enables `audio-decode` but is never
// queued), so there is nothing to shadow librespot here.
- #[cfg(not(any(feature = "local-files", feature = "subsonic", feature = "youtube")))]
+ #[cfg(not(any(
+ feature = "local-files",
+ feature = "subsonic",
+ feature = "qobuz",
+ feature = "youtube"
+ )))]
{
false
}
diff --git a/src/infra/qobuz/auth.rs b/src/infra/qobuz/auth.rs
new file mode 100644
index 00000000..17641ab0
--- /dev/null
+++ b/src/infra/qobuz/auth.rs
@@ -0,0 +1,527 @@
+//! Bundle constants and the credentials file.
+//!
+//! The three constants the API needs (app id, signature secret, OAuth key) are
+//! served to every visitor of the Qobuz web player; they are scraped from its
+//! bundle at runtime, cached by bundle version, and never embedded here. Env
+//! overrides skip the scrape when all three are set.
+
+use std::path::Path;
+
+use anyhow::{anyhow, Context, Result};
+use base64::engine::general_purpose::GeneralPurpose;
+use base64::engine::{DecodePaddingMode, GeneralPurposeConfig};
+use base64::{alphabet, Engine};
+use regex::Regex;
+use reqwest::Client;
+use serde::{Deserialize, Serialize};
+
+pub use crate::core::state::QobuzBundleCache;
+
+const LOGIN_URL: &str = "https://play.qobuz.com/login";
+const PLAY_ORIGIN: &str = "https://play.qobuz.com";
+const BUNDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
+
+const APP_ID_ENV: &str = "SPOTATUI_QOBUZ_APP_ID";
+const APP_SECRET_ENV: &str = "SPOTATUI_QOBUZ_APP_SECRET";
+const OAUTH_KEY_ENV: &str = "SPOTATUI_QOBUZ_OAUTH_KEY";
+/// Overrides the saved user token (the value of `X-User-Auth-Token`).
+pub(super) const TOKEN_ENV: &str = "SPOTATUI_QOBUZ_TOKEN";
+
+/// Base64 that accepts both alphabets, with or without padding.
+const LENIENT_BASE64: GeneralPurpose = GeneralPurpose::new(
+ &alphabet::STANDARD,
+ GeneralPurposeConfig::new()
+ .with_decode_allow_trailing_bits(true)
+ .with_decode_padding_mode(DecodePaddingMode::Indifferent),
+);
+
+/// Decode base64 or base64url, padded or not.
+pub fn decode_base64(text: &str) -> Result> {
+ let standard: String = text
+ .trim_end_matches('=')
+ .chars()
+ .map(|c| match c {
+ '-' => '+',
+ '_' => '/',
+ other => other,
+ })
+ .collect();
+ LENIENT_BASE64
+ .decode(standard)
+ .map_err(|e| anyhow!("base64 decode failed: {e}"))
+}
+
+// ---------------------------------------------------------------------------
+// Bundle constants
+// ---------------------------------------------------------------------------
+
+/// The constants from the env overrides, when all three are set and non-empty.
+fn constants_from_env_with(
+ app_id: Option,
+ app_secret: Option,
+ oauth_key: Option,
+) -> Option {
+ let non_empty = |v: Option| v.filter(|s| !s.trim().is_empty());
+ Some(QobuzBundleCache {
+ bundle_version: "env".to_string(),
+ app_id: non_empty(app_id)?,
+ app_secret: non_empty(app_secret)?,
+ oauth_key: non_empty(oauth_key)?,
+ })
+}
+
+fn constants_from_env() -> Option {
+ constants_from_env_with(
+ std::env::var(APP_ID_ENV).ok(),
+ std::env::var(APP_SECRET_ENV).ok(),
+ std::env::var(OAUTH_KEY_ENV).ok(),
+ )
+}
+
+/// The bundle path (`/resources//bundle.js`) from the login page.
+fn bundle_path(login_html: &str) -> Option {
+ Regex::new(r#"src="(/resources/[^"/]+/bundle\.js)""#)
+ .ok()?
+ .captures(login_html)
+ .map(|c| c[1].to_string())
+}
+
+/// The version segment of a bundle path.
+fn bundle_version(path: &str) -> Option {
+ path
+ .strip_prefix("/resources/")?
+ .strip_suffix("/bundle.js")
+ .map(str::to_string)
+}
+
+fn capture(js: &str, pattern: &str, what: &str) -> Result {
+ Regex::new(pattern)?
+ .captures(js)
+ .map(|c| c[1].to_string())
+ .ok_or_else(|| anyhow!("{what} not found in the Qobuz bundle"))
+}
+
+/// Extract the three constants from the bundle source.
+fn parse_bundle(js: &str, version: &str) -> Result {
+ let app_id = capture(js, r#"production:\{api:\{appId:"(\d+)""#, "app id")?;
+ // ASCII classes only: the captures are sliced by byte index further down.
+ let seed_re = Regex::new(r#"initialSeed\("([A-Za-z0-9+/=]+)",window\.utimezone\.([A-Za-z]+)\)"#)?;
+ let (seed, timezone) = seed_re
+ .captures_iter(js)
+ .last()
+ .map(|c| (c[1].to_string(), c[2].to_string()))
+ .ok_or_else(|| anyhow!("signature seed not found in the Qobuz bundle"))?;
+ let mut zone = timezone.chars();
+ let zone_name: String = zone
+ .next()
+ .map(|c| c.to_uppercase().collect::())
+ .unwrap_or_default()
+ + zone.as_str();
+ let zone_re = Regex::new(&format!(
+ r#"name:"[A-Za-z_]+/{zone_name}",info:"([A-Za-z0-9+/=]+)",extras:"([A-Za-z0-9+/=]+)""#
+ ))?;
+ let caps = zone_re
+ .captures(js)
+ .ok_or_else(|| anyhow!("timezone block {zone_name} not found in the Qobuz bundle"))?;
+ let app_secret = secret_from_seed(&seed, &caps[1], &caps[2])?;
+ let oauth_key = capture(js, r#"authenticate\(\{privateKey:"([^"]+)""#, "OAuth key")?;
+ Ok(QobuzBundleCache {
+ bundle_version: version.to_string(),
+ app_id,
+ app_secret,
+ oauth_key,
+ })
+}
+
+/// The signature secret: base64 of `seed + info + extras` minus its last 44 chars.
+fn secret_from_seed(seed: &str, info: &str, extras: &str) -> Result {
+ let joined = format!("{seed}{info}{extras}");
+ let keep = joined
+ .len()
+ .checked_sub(44)
+ .ok_or_else(|| anyhow!("signature seed is too short"))?;
+ let secret = String::from_utf8(decode_base64(&joined[..keep])?)
+ .map_err(|_| anyhow!("signature secret is not text"))?;
+ let is_hex = secret.len() == 32
+ && secret
+ .bytes()
+ .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
+ if !is_hex {
+ return Err(anyhow!("signature secret has an unexpected shape"));
+ }
+ let seed_prefix = decode_base64(&seed[..seed.len() / 4 * 4])?;
+ if !secret.as_bytes().starts_with(&seed_prefix) {
+ return Err(anyhow!("signature secret does not start with its seed"));
+ }
+ Ok(secret)
+}
+
+static RESOLVED: tokio::sync::OnceCell = tokio::sync::OnceCell::const_new();
+
+/// Whether [`resolve_constants`] already succeeded in this process.
+pub fn constants_resolved() -> bool {
+ RESOLVED.get().is_some()
+}
+
+/// Resolve the constants once per process: env, then the cached version, then a scrape.
+pub async fn resolve_constants(
+ http: &Client,
+ cached: Option<&QobuzBundleCache>,
+) -> Result {
+ RESOLVED
+ .get_or_try_init(|| async {
+ if let Some(constants) = constants_from_env() {
+ return Ok(constants);
+ }
+ let html = http
+ .get(LOGIN_URL)
+ .send()
+ .await
+ .context("login page request")?
+ .error_for_status()
+ .context("login page")?
+ .text()
+ .await
+ .context("login page body")?;
+ let path =
+ bundle_path(&html).ok_or_else(|| anyhow!("bundle path not found in the login page"))?;
+ let version = bundle_version(&path).ok_or_else(|| anyhow!("bundle path has no version"))?;
+ if let Some(constants) = cached.filter(|c| c.bundle_version == version) {
+ return Ok(constants.clone());
+ }
+ log::info!("[qobuz] scraping bundle {version}");
+ let js = http
+ .get(format!("{PLAY_ORIGIN}{path}"))
+ .timeout(BUNDLE_TIMEOUT)
+ .send()
+ .await
+ .context("bundle request")?
+ .error_for_status()
+ .context("bundle")?
+ .text()
+ .await
+ .context("bundle body")?;
+ parse_bundle(&js, &version)
+ })
+ .await
+ .cloned()
+}
+
+// ---------------------------------------------------------------------------
+// Credentials file
+// ---------------------------------------------------------------------------
+
+/// The saved login: the token every Qobuz call carries, plus the user id.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct QobuzCredentials {
+ pub user_auth_token: String,
+ #[serde(default)]
+ pub user_id: Option,
+}
+
+/// Read the credentials file; `None` when missing, unreadable, or without a token.
+fn read_credentials(path: &Path) -> Option {
+ std::fs::read_to_string(path)
+ .ok()
+ .and_then(|text| serde_yaml::from_str::(&text).ok())
+ .filter(|c| !c.user_auth_token.is_empty())
+}
+
+/// Write the credentials file with private permissions.
+fn write_credentials(path: &Path, credentials: &QobuzCredentials) -> Result<()> {
+ if let Some(dir) = path.parent() {
+ crate::core::paths::ensure_private_dir(dir)?;
+ }
+ let yaml = serde_yaml::to_string(credentials).context("serializing Qobuz credentials")?;
+ crate::core::auth::write_private_file_atomic(path, yaml.as_bytes())
+ .with_context(|| format!("writing {}", path.display()))
+}
+
+/// Keep the token in memory and save the credentials file.
+pub fn save_login(credentials: &QobuzCredentials) -> Result<()> {
+ set_token(Some(credentials.user_auth_token.clone()));
+ let path = crate::core::paths::qobuz_credentials_path()
+ .ok_or_else(|| anyhow!("no config directory to save credentials in"))?;
+ write_credentials(&path, credentials)
+}
+
+/// The token to use: the env override first, then the credentials file.
+pub fn token_with(env_token: Option, saved: Option) -> Option {
+ env_token
+ .filter(|t| !t.trim().is_empty())
+ .or_else(|| saved.map(|c| c.user_auth_token))
+}
+
+/// The saved or overridden token, if any.
+pub(super) fn load_token() -> Option {
+ token_with(
+ std::env::var(TOKEN_ENV).ok(),
+ crate::core::paths::qobuz_credentials_path().and_then(|p| read_credentials(&p)),
+ )
+}
+
+fn token_slot() -> &'static std::sync::Mutex