From 29f26172dd3933b2a3ca99fa70941af47acf8382 Mon Sep 17 00:00:00 2001 From: LargeModGames <84450916+LargeModGames@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:37:30 +0200 Subject: [PATCH 1/7] feat(sources): add Qobuz Play a Qobuz library through the shared LocalPlayer: browser login, favorites, playlists, albums and search in the sidebar, and playback through the web player's encrypted CMAF stream (each track is downloaded, decrypted and rebuilt as a FLAC tempfile). The three web-player constants are scraped at runtime, cached in state.yml, and overridable through SPOTATUI_QOBUZ_* env vars. Behind the `qobuz` feature, in `all-sources`. --- .coderabbit.yaml | 2 +- .github/copilot-instructions.md | 30 +- .github/scripts/triage.mjs | 2 +- .github/workflows/cd.yml | 10 +- .github/workflows/ci.yml | 10 +- AGENTS.md | 30 +- CHANGELOG.md | 2 + CLAUDE.md | 30 +- Cargo.lock | 63 +- Cargo.toml | 17 +- README.md | 28 +- context7.json | 4 +- docs/configuration.md | 13 +- docs/scripting.md | 6 +- src/core/action/apply.rs | 3 + src/core/action/mod.rs | 2 +- src/core/action/tests.rs | 29 + src/core/app/construction.rs | 4 + src/core/app/mod.rs | 10 + src/core/app/models.rs | 1 + src/core/app/persistence.rs | 52 +- src/core/app/playback_routing.rs | 38 +- src/core/app/playlist_pages.rs | 5 + src/core/app/queue.rs | 39 +- src/core/app/queue_suspend.rs | 34 +- src/core/app/route.rs | 1 + src/core/app/settings_apply.rs | 6 + src/core/app/settings_schema.rs | 10 + src/core/app/shuffle_repeat.rs | 35 +- src/core/app/transport.rs | 18 +- src/core/art.rs | 2 +- src/core/driver/mod.rs | 37 +- src/core/driver/plan.rs | 35 +- src/core/first_run.rs | 70 ++- src/core/onboarding.rs | 5 +- src/core/paths.rs | 6 + src/core/persisted_playback.rs | 33 ++ src/core/plugin_api.rs | 2 +- src/core/queue.rs | 24 +- src/core/source.rs | 19 +- src/core/state.rs | 62 ++ src/core/user_config.rs | 41 ++ src/infra/audio/mod.rs | 6 +- src/infra/audio/player.rs | 6 +- src/infra/dj/tools.rs | 11 +- src/infra/local/dispatch.rs | 9 + src/infra/media_metadata.rs | 26 +- src/infra/mod.rs | 2 + src/infra/network/mod.rs | 31 +- src/infra/player/events.rs | 14 +- src/infra/qobuz/auth.rs | 519 +++++++++++++++++ src/infra/qobuz/dispatch.rs | 835 +++++++++++++++++++++++++++ src/infra/qobuz/mod.rs | 895 +++++++++++++++++++++++++++++ src/infra/qobuz/sign.rs | 65 +++ src/infra/qobuz/stream/cmaf.rs | 438 ++++++++++++++ src/infra/qobuz/stream/crypto.rs | 127 ++++ src/infra/qobuz/stream/download.rs | 201 +++++++ src/infra/qobuz/stream/mod.rs | 5 + src/infra/qobuz/types.rs | 162 ++++++ src/infra/queue/dispatch.rs | 265 ++++++++- src/infra/queue/mod.rs | 29 +- src/infra/radio/dispatch.rs | 8 + src/infra/redirect_uri.rs | 54 +- src/infra/subsonic/dispatch.rs | 8 + src/infra/youtube/dispatch.rs | 8 + src/runtime/pump.rs | 16 +- src/runtime/startup.rs | 42 ++ src/tui/first_run.rs | 1 + src/tui/handlers/playlist.rs | 13 + src/tui/handlers/track_table.rs | 17 +- src/tui/ui/library.rs | 40 +- src/tui/ui/player.rs | 50 +- src/tui/ui/popups.rs | 14 +- tools/gates.count | 2 +- 74 files changed, 4616 insertions(+), 173 deletions(-) create mode 100644 src/infra/qobuz/auth.rs create mode 100644 src/infra/qobuz/dispatch.rs create mode 100644 src/infra/qobuz/mod.rs create mode 100644 src/infra/qobuz/sign.rs create mode 100644 src/infra/qobuz/stream/cmaf.rs create mode 100644 src/infra/qobuz/stream/crypto.rs create mode 100644 src/infra/qobuz/stream/download.rs create mode 100644 src/infra/qobuz/stream/mod.rs create mode 100644 src/infra/qobuz/types.rs 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..34d18668 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 free 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 @@ -388,7 +389,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 +405,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 +424,19 @@ 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/`) downloads each track through the web player's + encrypted CMAF stream, decrypts it, and rebuilds a FLAC `NamedTempFile`. The + download runs off the pump (a track is 30 to 200 MB) behind a `fetch_id` + guard. 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..2f2ff38c 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 free 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 @@ -390,7 +391,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 +407,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 +426,19 @@ 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/`) downloads each track through the web player's + encrypted CMAF stream, decrypts it, and rebuilds a FLAC `NamedTempFile`. The + download runs off the pump (a track is 30 to 200 MB) behind a `fetch_id` + guard. 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..1301248b 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 owner-only in `qobuz_credentials.yml`, 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 downloaded, decrypted, and rebuilt as a FLAC temporary file, off the event pump so the UI stays responsive, then played through spotatui's own engine. `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..a8803d80 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 free 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 @@ -390,7 +391,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 +407,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 +426,19 @@ 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/`) downloads each track through the web player's + encrypted CMAF stream, decrypts it, and rebuilds a FLAC `NamedTempFile`. The + download runs off the pump (a track is 30 to 200 MB) behind a `fetch_id` + guard. 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..17e1f144 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,26 @@ dependencies = [ name = "spotatui" version = "0.41.0" dependencies = [ + "aes", "anyhow", "arboard", "backtrace", + "base64", "block2", + "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 +5813,7 @@ dependencies = [ "ratatui", "ratatui-image", "realfft", + "regex", "reqwest 0.12.28", "rodio 0.22.2", "rspotify", @@ -5837,7 +5896,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..597b45a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,9 +59,17 @@ 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 } # Internet-radio dependencies: stream-download buffers an infinite HTTP (ICY) # stream behind a Read+Seek adapter that rodio can decode without seeking; @@ -171,14 +179,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 +195,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: every track is downloaded, decrypted +# and rebuilt as a FLAC tempfile, then played through the shared LocalPlayer. +qobuz = ["audio-decode", "dep:tempfile", "reqwest/stream", "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..2ef5b7e4 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.

[![Crates.io](https://img.shields.io/crates/v/spotatui.svg)](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) in FLAC 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). No app registration is needed. The sidebar lists your favorite tracks, playlists, and favorite albums; search finds tracks. Each track is downloaded and decrypted to a temporary file before it plays, so a hi-res track takes a moment to start. + +```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..48f30754 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -99,6 +99,17 @@ 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 +``` + +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 +189,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..43ae95f8 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,7 @@ 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. The snapshot is populated once the app is running; reading it at load time (before the `start` event) returns empty defaults. @@ -608,7 +608,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..b589a2b1 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; @@ -152,6 +165,7 @@ impl App { #[cfg(any( feature = "local-files", feature = "subsonic", + feature = "qobuz", feature = "internet-radio", feature = "youtube" ))] @@ -166,7 +180,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 +202,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 +225,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 +247,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..611faf55 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; diff --git a/src/core/app/queue.rs b/src/core/app/queue.rs index b4d7cd56..7bf2894d 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(), } @@ -174,7 +181,12 @@ impl App { /// exactly those three 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..2f871799 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; @@ -72,6 +82,16 @@ impl App { station: radio.station, }); } + #[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); + s.advancing = true; + self.queue_suspended = Some(crate::core::queue::SuspendedContext::Qobuz { + resume_index, + resume_position_ms: 0, + }); + } } /// Suspend the active decoded context with **mid-track** semantics (resume the @@ -118,6 +138,16 @@ impl App { station: radio.station, }); } + #[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; + s.advancing = true; + self.queue_suspended = Some(crate::core::queue::SuspendedContext::Qobuz { + resume_index: Some(index), + resume_position_ms: position_ms, + }); + } } /// Snapshot how to resume the underlying native-Spotify context once the 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..fe5d4dbb 100644 --- a/src/core/app/settings_apply.rs +++ b/src/core/app/settings_apply.rs @@ -83,6 +83,12 @@ impl App { self.user_config.behavior.status_message_ttl_percent = (*v).clamp(10, 1000) as u16; } } + "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..06c6c947 100644 --- a/src/core/app/settings_schema.rs +++ b/src/core/app/settings_schema.rs @@ -194,6 +194,16 @@ 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), }, + 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..f15ae094 100644 --- a/src/core/first_run.rs +++ b/src/core/first_run.rs @@ -19,7 +19,7 @@ use crate::core::onboarding::Onboarding; use crate::core::source::Source; use crate::core::state::RuntimeState; use crate::core::user_config::UserConfig; -#[cfg(feature = "subsonic")] +#[cfg(any(feature = "subsonic", feature = "qobuz"))] use anyhow::anyhow; use anyhow::Result; @@ -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,73 @@ 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 = auth::LoginAttempt::bind(constants.clone()).await?; + 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(()); + } + }; + let path = crate::core::paths::qobuz_credentials_path() + .ok_or_else(|| anyhow!("cannot resolve the spotatui config directory"))?; + auth::write_credentials(&path, &credentials)?; + auth::set_token(Some(credentials.user_auth_token.clone())); + 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..b6911b5b 100644 --- a/src/core/user_config.rs +++ b/src/core/user_config.rs @@ -49,6 +49,38 @@ 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. +pub const QOBUZ_QUALITY_LABELS: &[&str] = &["MP3 320", "FLAC 16/44.1", "FLAC 24/96", "FLAC 24/192"]; + +/// The settings label of a Qobuz `format_id` (unknown ids read as the default). +pub fn qobuz_quality_label(quality: u8) -> &'static str { + let index = QOBUZ_QUALITY_IDS + .iter() + .position(|&id| id == quality) + .unwrap_or(1); + QOBUZ_QUALITY_LABELS[index] +} + +/// The Qobuz `format_id` behind a settings label (unknown labels read as the default). +pub fn qobuz_quality_from_label(label: &str) -> u8 { + let index = QOBUZ_QUALITY_LABELS + .iter() + .position(|&l| l == label) + .unwrap_or(1); + 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 { + 6 + } +} + /// 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 +531,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 +713,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 +1101,7 @@ impl UserConfig { subsonic_username: None, subsonic_password: None, ytdlp_path: None, + qobuz_quality: 6, radio_stations: Vec::new(), // --- Phase 2: icons / glyphs / labels (defaults = today's glyphs) --- gauge_filled_icon: "⣿".to_string(), @@ -1610,6 +1647,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 +2083,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..1b30c9b4 100644 --- a/src/infra/audio/mod.rs +++ b/src/infra/audio/mod.rs @@ -4,10 +4,10 @@ // - 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")] diff --git a/src/infra/audio/player.rs b/src/infra/audio/player.rs index 9f4cbc1a..d53f1b0f 100644 --- a/src/infra/audio/player.rs +++ b/src/infra/audio/player.rs @@ -74,7 +74,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<()> { @@ -171,7 +171,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 +183,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<()> { 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..feb57295 100644 --- a/src/infra/local/dispatch.rs +++ b/src/infra/local/dispatch.rs @@ -274,6 +274,15 @@ async fn start_local_queue(app: &Arc>, queue: Vec, start_idx: } } + // Tear down any Qobuz session, for the same short-circuit reason. + #[cfg(feature = "qobuz")] + { + let qobuz = app.lock().await.qobuz_playback.take(); + if let Some(qobuz) = qobuz { + qobuz.player.stop(); + } + } + // Tear down any radio session, for the same short-circuit reason. #[cfg(feature = "internet-radio")] { 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..78b1e9ef --- /dev/null +++ b/src/infra/qobuz/auth.rs @@ -0,0 +1,519 @@ +//! 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); + +pub const APP_ID_ENV: &str = "SPOTATUI_QOBUZ_APP_ID"; +pub const APP_SECRET_ENV: &str = "SPOTATUI_QOBUZ_APP_SECRET"; +pub const OAUTH_KEY_ENV: &str = "SPOTATUI_QOBUZ_OAUTH_KEY"; +/// Overrides the saved user token (the value of `X-User-Auth-Token`). +pub 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. +pub 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. +pub 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. +pub 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. +pub 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. +pub 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. +pub 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())) +} + +/// 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 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> { + static TOKEN: std::sync::OnceLock>> = std::sync::OnceLock::new(); + TOKEN.get_or_init(|| std::sync::Mutex::new(load_token())) +} + +/// The token in use: loaded once from env or file, replaced by [`set_token`]. +pub fn current_token() -> Option { + token_slot().lock().map(|t| t.clone()).unwrap_or(None) +} + +/// Replace the in-memory token: `Some` after a login, `None` after a 401. +pub fn set_token(token: Option) { + if let Ok(mut slot) = token_slot().lock() { + *slot = token; + } +} + +// --------------------------------------------------------------------------- +// Browser login +// --------------------------------------------------------------------------- + +const OAUTH_URL: &str = "https://www.qobuz.com/signin/oauth"; +/// How long the browser round trip may take before the login is abandoned. +pub const LOGIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180); + +/// The authorization URL for `app_id` with a `localhost:` redirect. +pub fn login_url(app_id: &str, port: u16) -> String { + format!("{OAUTH_URL}?ext_app_id={app_id}&redirect_url=http%3A%2F%2Flocalhost%3A{port}") +} + +/// The authorization code from the callback URL (`code_autorisation`, else `code`). +pub fn callback_code(url: &str) -> Option { + let parsed = url::Url::parse(url).ok()?; + let mut fallback = None; + for (key, value) in parsed.query_pairs() { + if value.is_empty() { + continue; + } + match key.as_ref() { + "code_autorisation" => return Some(value.into_owned()), + "code" => fallback = Some(value.into_owned()), + _ => {} + } + } + fallback +} + +fn accepts_qobuz_callback(path: &str) -> bool { + path.contains("code_autorisation=") || path.contains("code=") +} + +/// A bound login: open [`url`](Self::url) in the browser, then [`wait`](Self::wait). +pub struct LoginAttempt { + constants: QobuzBundleCache, + v4: tokio::net::TcpListener, + /// A second listener for browsers that resolve `localhost` to `::1` first. + v6: Option, + port: u16, +} + +impl LoginAttempt { + /// Bind the loopback callback listener(s) on a free port. + pub async fn bind(constants: QobuzBundleCache) -> Result { + let v4 = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .context("binding the Qobuz login callback listener")?; + let port = v4.local_addr()?.port(); + let v6 = tokio::net::TcpListener::bind(("::1", port)).await.ok(); + Ok(LoginAttempt { + constants, + v4, + v6, + port, + }) + } + + pub fn url(&self) -> String { + login_url(&self.constants.app_id, self.port) + } + + /// Wait for the browser callback and exchange its code for credentials. + pub async fn wait(self) -> Result { + use crate::infra::redirect_uri::redirect_uri_web_server_on; + let LoginAttempt { + constants, v4, v6, .. + } = self; + // Both accept loops live inside this future, so the timeout drops both + // listeners with it. + let callback = async move { + match v6 { + None => redirect_uri_web_server_on(v4, accepts_qobuz_callback).await, + Some(v6) => tokio::select! { + r = redirect_uri_web_server_on(v4, accepts_qobuz_callback) => r, + r = redirect_uri_web_server_on(v6, accepts_qobuz_callback) => r, + }, + } + }; + let url = tokio::time::timeout(LOGIN_TIMEOUT, callback) + .await + .map_err(|_| anyhow!("login timed out after {}s", LOGIN_TIMEOUT.as_secs()))? + .map_err(|()| anyhow!("login callback server failed"))?; + let code = + callback_code(&url).ok_or_else(|| anyhow!("no authorization code in the callback"))?; + exchange_code(&constants, &code).await + } +} + +/// `GET oauth/callback`: trade the browser's code for the user token. +pub async fn exchange_code(constants: &QobuzBundleCache, code: &str) -> Result { + let source = super::QobuzSource::new(&constants.app_id, &constants.app_secret, ""); + let reply: super::types::OauthCallback = source + .get( + "oauth/callback", + &[ + ("code", code.to_string()), + ("private_key", constants.oauth_key.clone()), + ], + ) + .await + .context("oauth callback")?; + let user_auth_token = reply + .token + .or(reply.user_auth_token) + .filter(|t| !t.is_empty()) + .ok_or_else(|| anyhow!("oauth callback returned no token"))?; + let user_id = reply.user_id.or(reply.user.and_then(|u| u.id)); + Ok(QobuzCredentials { + user_auth_token, + user_id, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SECRET: &str = "0123456789abcdef0123456789abcdef"; + + /// A bundle snippet in the live shapes, with synthetic values. + fn bundle() -> String { + let encoded = base64::engine::general_purpose::STANDARD.encode(SECRET); + let (seed, info) = encoded.split_at(30); + let extras = "A".repeat(44); + format!( + concat!( + r#"x={{production:{{api:{{appId:"123456789",appSecret:"{legacy}"}}}}}};"#, + r#"c.initialSeed("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",window.utimezone.london);"#, + r#"c.initialSeed("{seed}",window.utimezone.berlin);"#, + r#"[{{name:"Europe/London",info:"BBBB",extras:"CCCC"}},"#, + r#"{{name:"Europe/Berlin",info:"{info}",extras:"{extras}"}}];"#, + r#"r.authenticate({{privateKey:"oauth-private-key"}})"# + ), + legacy = "f".repeat(32), + seed = seed, + info = info, + extras = extras, + ) + } + + #[test] + fn parse_bundle_extracts_all_three_constants() { + let constants = parse_bundle(&bundle(), "8.2.0-b034").unwrap(); + assert_eq!(constants.bundle_version, "8.2.0-b034"); + assert_eq!(constants.app_id, "123456789"); + assert_eq!(constants.app_secret, SECRET); + assert_eq!(constants.oauth_key, "oauth-private-key"); + } + + #[test] + fn parse_bundle_reports_a_missing_seed() { + let js = r#"production:{api:{appId:"123456789",appSecret:"x"}}"#; + let err = parse_bundle(js, "v").unwrap_err().to_string(); + assert!(err.contains("seed"), "{err}"); + } + + #[test] + fn secret_from_seed_rejects_a_non_hex_result() { + let encoded = + base64::engine::general_purpose::STANDARD.encode("not a hex secret, 32 chars!!!!!!"); + let (seed, info) = encoded.split_at(30); + assert!(secret_from_seed(seed, info, &"A".repeat(44)).is_err()); + } + + #[test] + fn bundle_path_and_version_parse_the_login_page() { + let html = r#""#; + let path = bundle_path(html).unwrap(); + assert_eq!(path, "/resources/8.2.0-b034/bundle.js"); + assert_eq!(bundle_version(&path).as_deref(), Some("8.2.0-b034")); + assert!(bundle_path("").is_none()); + } + + #[test] + fn env_overrides_need_all_three_values() { + let all = + constants_from_env_with(Some("1".into()), Some("s".into()), Some("k".into())).unwrap(); + assert_eq!(all.bundle_version, "env"); + assert_eq!(all.app_id, "1"); + assert!(constants_from_env_with(Some("1".into()), Some("s".into()), None).is_none()); + assert!( + constants_from_env_with(Some("1".into()), Some(" ".into()), Some("k".into())).is_none() + ); + } + + #[test] + fn decode_base64_accepts_both_alphabets_and_padding() { + assert_eq!(decode_base64("-_8=").unwrap(), vec![0xfb, 0xff]); + assert_eq!(decode_base64("+/8").unwrap(), vec![0xfb, 0xff]); + } + + #[test] + fn token_prefers_env_over_the_file() { + let saved = QobuzCredentials { + user_auth_token: "file".into(), + user_id: None, + }; + assert_eq!( + token_with(Some("env".into()), Some(saved.clone())).as_deref(), + Some("env") + ); + assert_eq!( + token_with(Some(" ".into()), Some(saved)).as_deref(), + Some("file") + ); + assert!(token_with(None, None).is_none()); + } + + #[test] + fn callback_code_prefers_code_autorisation_and_falls_back_to_code() { + assert_eq!( + callback_code("http://localhost:4321/?code_autorisation=ab%2Fcd&x=1").as_deref(), + Some("ab/cd") + ); + assert_eq!( + callback_code("http://localhost:4321/?code=legacy").as_deref(), + Some("legacy") + ); + assert!(callback_code("http://localhost:4321/?state=only").is_none()); + assert!(callback_code("not a url").is_none()); + } + + #[test] + fn login_url_targets_localhost_on_the_bound_port() { + assert_eq!( + login_url("123456789", 4321), + "https://www.qobuz.com/signin/oauth?ext_app_id=123456789&redirect_url=http%3A%2F%2Flocalhost%3A4321" + ); + assert!(accepts_qobuz_callback("/?code_autorisation=x")); + assert!(accepts_qobuz_callback("/?code=x")); + assert!(!accepts_qobuz_callback("/favicon.ico")); + } + + #[test] + fn credentials_round_trip_through_the_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("qobuz_credentials.yml"); + assert!(read_credentials(&path).is_none()); + let creds = QobuzCredentials { + user_auth_token: "tok".into(), + user_id: Some("42".into()), + }; + write_credentials(&path, &creds).unwrap(); + assert_eq!(read_credentials(&path), Some(creds)); + } +} diff --git a/src/infra/qobuz/dispatch.rs b/src/infra/qobuz/dispatch.rs new file mode 100644 index 00000000..ccc8b566 --- /dev/null +++ b/src/infra/qobuz/dispatch.rs @@ -0,0 +1,835 @@ +//! Qobuz browse/search/login/playback routing. +//! +//! The seam that keeps the Spotify [`Network`](crate::infra::network) +//! Spotify-only: [`route_qobuz_event`] is called from the runtime IoEvent pump +//! after the Subsonic dispatch and before Radio. An event that targets the +//! Qobuz source (a browse, search, or login request, or a `qobuz:` playback +//! URI) is handled here and consumed; anything else falls through. +//! +//! ## Playback +//! +//! Qobuz playback owns [`App::qobuz_playback`] and never writes Spotify or +//! librespot fields. A track is a 30 to 200 MB download, so the session is +//! published at once (marked `advancing`, like the native queue slot) and the +//! download runs on a detached task: the pump keeps serving every other event +//! and a skip during the download supersedes it through `fetch_id`. +//! +//! Failures are status messages (never `handle_error`): the CLI never reaches +//! this router, so no exit signal is lost. A 401 clears the in-memory token so +//! the next browse re-runs the browser login. A failed download tears the +//! session down instead of skipping (a skip would walk the queue at tick speed). + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use tempfile::NamedTempFile; +use tokio::sync::Mutex; + +use super::auth::{self, QobuzBundleCache}; +use super::stream::download::TrackDownload; +use super::{track_id_from_uri, QobuzPlaybackState, QobuzSource, Unauthorized}; +use crate::core::app::{App, SearchResultBlock, TrackTableContext}; +use crate::core::pagination::Paged; +use crate::core::plugin_api::TrackInfo; +use crate::core::source::{MediaSource, Searcher}; +use crate::core::state::PersistedRuntimeState; +use crate::infra::audio::LocalPlayer; +use crate::infra::network::IoEvent; +use crate::infra::queue::{advance_index, replay_file}; + +const LOGIN_EXPIRED: &str = "Qobuz: login expired, press `d` and pick Qobuz to log in again"; +/// Downloads above this size get a status message that names the size. +const LARGE_DOWNLOAD_BYTES: u64 = 20_000_000; + +/// Whether a URI is owned by the Qobuz source. +pub fn is_qobuz_uri(uri: &str) -> bool { + uri.starts_with("qobuz:") +} + +/// Skip direction within the queue. +#[derive(Clone, Copy)] +enum Direction { + Next, + Prev, +} + +/// Intercept events that target the Qobuz source. +/// +/// Returns `true` if the event was handled (and must **not** be forwarded to +/// the Spotify network), `false` to let the normal dispatch run. +pub async fn route_qobuz_event(app: &Arc>, event: &IoEvent) -> bool { + match event { + IoEvent::GetQobuzPlaylists => { + load_qobuz_playlists(app).await; + true + } + IoEvent::GetQobuzTracks(uri) => { + load_qobuz_tracks(app, uri).await; + true + } + IoEvent::GetQobuzSearchResults(query) => { + run_qobuz_search(app, query).await; + true + } + IoEvent::QobuzLogin => { + begin_login(app).await; + true + } + // Start a list of Qobuz tracks: queue all and start at the offset. + IoEvent::StartPlayback(None, Some(uris), offset) + if uris.first().is_some_and(|u| is_qobuz_uri(u)) => + { + start_qobuz_queue(app, uris, offset.unwrap_or(0)).await; + true + } + // A single Qobuz track with no surrounding list: a one-track queue. + IoEvent::StartPlayback(Some(uri), _, _) if is_qobuz_uri(uri) => { + start_qobuz_queue(app, std::slice::from_ref(uri), 0).await; + true + } + // Bare "resume current": ours only while Qobuz owns the session. + IoEvent::StartPlayback(None, None, None) => match player(app).await { + Some(p) => { + p.resume(); + true + } + None => false, + }, + // Any other start is a foreign play: relinquish the device, then let the + // normal dispatch run. + IoEvent::StartPlayback(..) => { + teardown_qobuz(app).await; + false + } + IoEvent::PausePlayback => match player(app).await { + Some(p) => { + p.pause(); + true + } + None => false, + }, + IoEvent::Seek(position_ms) => match player(app).await { + Some(p) => { + let _ = p.seek(Duration::from_millis(*position_ms as u64)); + true + } + None => false, + }, + IoEvent::ChangeVolume(volume) => match player(app).await { + Some(p) => { + p.set_volume(*volume as f32 / 100.0); + let mut app = app.lock().await; + app.runtime_state.volume_percent = *volume; + app.schedule_state_save(PersistedRuntimeState::volume_percent(*volume)); + true + } + None => false, + }, + IoEvent::NextTrack => skip(app, Direction::Next).await, + IoEvent::PreviousTrack | IoEvent::ForcePreviousTrack => skip(app, Direction::Prev).await, + IoEvent::ReplayCurrentTrack => replay_current(app).await, + IoEvent::Repeat(state) => app.lock().await.set_decoded_repeat_from_state(*state), + _ => false, + } +} + +// --------------------------------------------------------------------------- +// Constants, token, source +// --------------------------------------------------------------------------- + +/// The bundle cache saved in `state.yml`, when the app has a state path. +async fn cached_bundle(app: &Arc>) -> Option { + let path = app.lock().await.state_path.clone()?; + crate::core::state::load(&path).ok()?.qobuz_bundle_cache +} + +/// The bundle constants; a fresh scrape is saved into `state.yml`. The first +/// call of a process runs the login-page GET (and, on a new bundle version, the +/// 9 MB bundle GET) inline on the serial pump; accepted for v1. +async fn constants(app: &Arc>) -> Result { + let http = super::shared_qobuz_client(); + if auth::constants_resolved() { + return auth::resolve_constants(&http, None).await; + } + let cached = cached_bundle(app).await; + let resolved = auth::resolve_constants(&http, cached.as_ref()).await?; + if cached.as_ref() != Some(&resolved) && resolved.bundle_version != "env" { + app + .lock() + .await + .schedule_state_save(PersistedRuntimeState::qobuz_bundle_cache(resolved.clone())); + } + Ok(resolved) +} + +/// What to do when no token is available. +#[derive(Clone, Copy)] +enum WhenLoggedOut { + /// Browse paths start the browser login. + Login, + /// Playback paths only show the login message. + Message, +} + +/// Build a [`QobuzSource`] from the in-memory token, or `None` after handling +/// the logged-out case as `when_logged_out` says. +async fn build_source( + app: &Arc>, + when_logged_out: WhenLoggedOut, +) -> Option { + let Some(token) = auth::current_token() else { + match when_logged_out { + WhenLoggedOut::Login => app.lock().await.dispatch(IoEvent::QobuzLogin), + WhenLoggedOut::Message => set_error(app, LOGIN_EXPIRED.to_string()).await, + } + return None; + }; + match constants(app).await { + Ok(c) => Some(QobuzSource::new(c.app_id, c.app_secret, token)), + Err(e) => { + report(app, "web player constants", e).await; + None + } + } +} + +/// A source for a playback path (the native queue lane); shows the login +/// message and returns `None` when logged out. +pub(crate) async fn build_playback_source(app: &Arc>) -> Option { + build_source(app, WhenLoggedOut::Message).await +} + +/// Report a failed call as one status message; a 401 also clears the token. +async fn report(app: &Arc>, step: &str, err: anyhow::Error) { + let mut guard = app.lock().await; + report_locked(&mut guard, step, err); +} + +fn report_locked(app: &mut App, step: &str, err: anyhow::Error) { + if err.downcast_ref::().is_some() { + auth::set_token(None); + app.set_error_status_message(LOGIN_EXPIRED, 8); + } else { + log::warn!("[qobuz] {step}: {err:#}"); + app.set_status_message(format!("Qobuz: {step}: {err:#}"), 6); + } +} + +/// Deliberate divergence from `handle_error`: a status message, no error route. +async fn set_error(app: &Arc>, message: String) { + app.lock().await.set_status_message(message, 6); +} + +// --------------------------------------------------------------------------- +// Login +// --------------------------------------------------------------------------- + +static LOGIN_IN_PROGRESS: AtomicBool = AtomicBool::new(false); + +/// Start the in-TUI browser login on a detached task so the pump keeps running. +async fn begin_login(app: &Arc>) { + if LOGIN_IN_PROGRESS.swap(true, Ordering::SeqCst) { + set_error(app, "Qobuz login already in progress...".to_string()).await; + return; + } + let constants = match constants(app).await { + Ok(c) => c, + Err(e) => { + LOGIN_IN_PROGRESS.store(false, Ordering::SeqCst); + report(app, "web player constants", e).await; + return; + } + }; + let attempt = match auth::LoginAttempt::bind(constants).await { + Ok(a) => a, + Err(e) => { + LOGIN_IN_PROGRESS.store(false, Ordering::SeqCst); + report(app, "login", e).await; + return; + } + }; + let url = attempt.url(); + { + let mut guard = app.lock().await; + if let Err(e) = open::that(&url) { + log::warn!("[qobuz] failed to open browser automatically: {e}"); + guard.set_status_message( + format!("Open this URL in your browser to log in to Qobuz: {url}"), + 30, + ); + } else { + guard.set_status_message("Qobuz: open the browser window to log in", 12); + } + } + let io_tx = app.lock().await.io_tx_clone(); + let app = Arc::clone(app); + tokio::spawn(async move { + match attempt.wait().await { + Ok(credentials) => { + match crate::core::paths::qobuz_credentials_path() { + Some(path) => { + if let Err(e) = auth::write_credentials(&path, &credentials) { + log::warn!("[qobuz] cannot save credentials: {e:#}"); + } + } + None => log::warn!("[qobuz] no config directory to save credentials in"), + } + auth::set_token(Some(credentials.user_auth_token)); + app.lock().await.set_status_message("Qobuz: logged in", 4); + if let Some(tx) = io_tx { + let _ = tx.send(IoEvent::GetQobuzPlaylists); + } + } + Err(e) => { + log::warn!("[qobuz] login failed: {e:#}"); + app + .lock() + .await + .set_status_message(format!("Qobuz login failed: {e:#}"), 8); + } + } + LOGIN_IN_PROGRESS.store(false, Ordering::SeqCst); + }); +} + +// --------------------------------------------------------------------------- +// Browse + search +// --------------------------------------------------------------------------- + +/// Fetch the sidebar rows (favorites, playlists, albums) into `app.qobuz_playlists`. +async fn load_qobuz_playlists(app: &Arc>) { + let Some(source) = build_source(app, WhenLoggedOut::Login).await else { + return; + }; + match source.playlists().await { + Ok(playlists) => app.lock().await.qobuz_playlists = playlists, + Err(e) => report(app, "library", e).await, + } +} + +/// Fetch a listing's tracks into the shared track table, tagged +/// [`TrackTableContext::QobuzPlaylist`] so selecting a row plays it. +async fn load_qobuz_tracks(app: &Arc>, playlist_uri: &str) { + let Some(source) = build_source(app, WhenLoggedOut::Login).await else { + return; + }; + match source.tracks(playlist_uri).await { + Ok(tracks) => { + let mut app = app.lock().await; + app.track_table.tracks = tracks; + app.track_table.selected_index = 0; + app.track_table.context = Some(TrackTableContext::QobuzPlaylist); + } + Err(e) => report(app, "tracks", e).await, + } +} + +/// Run a catalog search and populate the songs block of `app.search_results`. +async fn run_qobuz_search(app: &Arc>, query: &str) { + let Some(source) = build_source(app, WhenLoggedOut::Login).await else { + return; + }; + match source.search(query).await { + Ok(results) => { + let total = results.tracks.len() as u32; + let mut app = app.lock().await; + app.search_results.tracks = Some(Paged { + items: results.tracks, + total, + ..Default::default() + }); + app.search_results.albums = None; + app.search_results.artists = None; + app.search_results.playlists = None; + app.search_results.shows = None; + app.search_results.selected_tracks_index = Some(0); + app.search_results.hovered_block = SearchResultBlock::SongSearch; + app.search_results.selected_block = SearchResultBlock::Empty; + } + Err(e) => report(app, "search", e).await, + } +} + +// --------------------------------------------------------------------------- +// Playback +// --------------------------------------------------------------------------- + +/// The live Qobuz player, if a Qobuz session is active. +async fn player(app: &Arc>) -> Option> { + app + .lock() + .await + .qobuz_playback + .as_ref() + .map(|s| Arc::clone(&s.player)) +} + +/// Snapshot the `TrackInfo`s for `uris` in order from the track table, then +/// the search results (a play can come from either view). Unknown URIs are dropped. +fn snapshot_tracks( + table: &[TrackInfo], + search: Option<&[TrackInfo]>, + uris: &[String], +) -> Vec { + uris + .iter() + .filter_map(|uri| find_track(table, search, uri).cloned()) + .collect() +} + +fn find_track<'a>( + table: &'a [TrackInfo], + search: Option<&'a [TrackInfo]>, + uri: &str, +) -> Option<&'a TrackInfo> { + let matches = |t: &&TrackInfo| t.uri.as_deref() == Some(uri); + table + .iter() + .find(matches) + .or_else(|| search.and_then(|s| s.iter().find(matches))) +} + +/// Release every other backend so only Qobuz holds the output device. +#[cfg_attr( + not(any( + feature = "streaming", + feature = "local-files", + feature = "subsonic", + feature = "internet-radio", + feature = "youtube" + )), + allow(unused_variables) +)] +async fn release_other_backends(app: &Arc>) { + // Pause native Spotify so librespot releases the device. + #[cfg(feature = "streaming")] + { + let streaming = app.lock().await.streaming_player.clone(); + if let Some(player) = streaming { + player.pause(); + } + } + // The other decoded sources never see this `qobuz:` start (the pump + // short-circuits), so their sessions are torn down here. + #[cfg(feature = "local-files")] + { + let local = app.lock().await.local_playback.take(); + if let Some(local) = local { + local.player.stop(); + } + } + #[cfg(feature = "subsonic")] + { + let subsonic = app.lock().await.subsonic_playback.take(); + if let Some(subsonic) = subsonic { + subsonic.player.stop(); + } + } + #[cfg(feature = "internet-radio")] + { + let radio = app.lock().await.radio_playback.take(); + if let Some(radio) = radio { + radio.player.stop(); + } + } + #[cfg(feature = "youtube")] + { + let youtube = app.lock().await.youtube_playback.take(); + if let Some(youtube) = youtube { + youtube.player.stop(); + } + } +} + +/// Reuse the live Qobuz player, or open a fresh output device for one. A +/// freshly opened player is **not** published to `App` here. +async fn acquire_player(app: &Arc>) -> Option> { + if let Some(p) = player(app).await { + return Some(p); + } + match tokio::task::spawn_blocking(LocalPlayer::new).await { + Ok(Ok(p)) => Some(Arc::new(p)), + Ok(Err(e)) => { + set_error(app, format!("No audio output for Qobuz playback: {e}")).await; + None + } + Err(e) => { + set_error(app, format!("Audio output init failed: {e}")).await; + None + } + } +} + +static FETCH_SEQ: AtomicU64 = AtomicU64::new(0); + +fn next_fetch_id() -> u64 { + FETCH_SEQ.fetch_add(1, Ordering::Relaxed) +} + +/// Start a track download into a fresh tempfile: segment 0 is parsed and the +/// header written, so the total size is known before the long part. +async fn begin_track_download( + source: &QobuzSource, + track_id: &str, + quality: u8, +) -> Result<(NamedTempFile, TrackDownload)> { + let tmp = NamedTempFile::new().context("creating temp file for Qobuz stream")?; + let download = source.begin_download(track_id, quality, tmp.path()).await?; + Ok((tmp, download)) +} + +/// Download the track at `uri` into a tempfile, for the native queue engine's +/// off-pump fetch (playing is the queue's job). +pub(crate) async fn download_for_queue( + source: &QobuzSource, + uri: &str, + quality: u8, +) -> Result { + let track_id = track_id_from_uri(uri)?.to_string(); + let (tmp, download) = begin_track_download(source, &track_id, quality).await?; + download.finish().await?; + Ok(tmp) +} + +/// Whether the published session still waits for the download stamped `fetch_id`. +async fn is_current(app: &Arc>, fetch_id: u64) -> bool { + app + .lock() + .await + .qobuz_playback + .as_ref() + .is_some_and(|s| s.fetch_id == fetch_id) +} + +/// Run the download for the published session on a detached task, then play +/// it when the session still waits for this fetch. The task's abort handle is +/// stored on the session, so a skip, a new queue, or a teardown cancels it. +async fn spawn_fetch( + app: &Arc>, + source: Arc, + track_id: String, + fetch_id: u64, +) { + let task_app = Arc::clone(app); + let task = tokio::spawn(async move { + let app = task_app; + let quality = app.lock().await.user_config.behavior.qobuz_quality; + let (tmp, download) = match begin_track_download(&source, &track_id, quality).await { + Ok(started) => started, + Err(e) => return fail_fetch(&app, fetch_id, e).await, + }; + // The size is known after segment 0, before the long part. + let total = download.total_bytes(); + if total > LARGE_DOWNLOAD_BYTES && is_current(&app, fetch_id).await { + app + .lock() + .await + .set_status_message(format!("Qobuz: downloading {} MB", total / 1_000_000), 6); + } + if let Err(e) = download.finish().await { + return fail_fetch(&app, fetch_id, e).await; + } + commit_fetch(&app, fetch_id, tmp).await; + }); + if let Some(s) = app + .lock() + .await + .qobuz_playback + .as_mut() + .filter(|s| s.fetch_id == fetch_id) + { + s.fetch = Some(task.abort_handle()); + } +} + +/// A failed download: tear the session down only if it still waits for this +/// fetch, under one lock, and report the error from the same guard. +async fn fail_fetch(app: &Arc>, fetch_id: u64, err: anyhow::Error) { + let mut guard = app.lock().await; + let Some(s) = guard.qobuz_playback.take_if(|s| s.fetch_id == fetch_id) else { + return; + }; + s.player.stop(); + report_locked(&mut guard, "download", err); +} + +/// Play the downloaded file and finalize the session, under one lock so a +/// concurrent skip (which restamps `fetch_id` under the same lock) cannot +/// interleave. A decode failure tears the session down. +async fn commit_fetch(app: &Arc>, fetch_id: u64, tmp: NamedTempFile) { + let mut guard = app.lock().await; + let Some((player, was_paused)) = guard + .qobuz_playback + .as_ref() + .filter(|s| s.fetch_id == fetch_id) + // A pause pressed during the download window applies to the previous + // track's sink; a fresh player starts paused, so only a session that + // already played something counts. + .map(|s| { + ( + Arc::clone(&s.player), + s.tempfile.is_some() && s.player.is_paused(), + ) + }) + else { + return; + }; + let path = tmp.path().to_path_buf(); + let decode_player = Arc::clone(&player); + let played = tokio::task::spawn_blocking(move || decode_player.play_file(&path)) + .await + .map(|r| r.map_err(|e| e.to_string())) + .unwrap_or_else(|e| Err(e.to_string())); + if let Err(e) = played { + if let Some(s) = guard.qobuz_playback.take_if(|s| s.fetch_id == fetch_id) { + s.player.stop(); + } + guard.set_status_message(format!("Qobuz: play: {e}"), 6); + return; + } + player.set_volume(guard.runtime_state.volume_percent as f32 / 100.0); + let display = match guard.qobuz_playback.as_mut() { + Some(s) => { + s.tempfile = Some(tmp); + s.advancing = false; + s.fetch = None; + let resume = s.resume_at.take(); + if let Some(position_ms) = resume.map(|r| r.position_ms).filter(|&ms| ms > 0) { + let _ = player.seek(Duration::from_millis(position_ms)); + } + if was_paused || resume.is_some_and(|r| r.paused) { + player.pause(); + } + s.current().map(|t| t.name.clone()) + } + None => None, + }; + if let Some(display) = display { + guard.set_status_message(format!("\u{266a} {display}"), 4); + } +} + +/// Begin playing a list of Qobuz tracks, taking over the session and starting +/// at `start_idx` (clamped into range). +async fn start_qobuz_queue(app: &Arc>, uris: &[String], start_idx: usize) { + let tracks = { + let guard = app.lock().await; + let search = guard + .search_results + .tracks + .as_ref() + .map(|p| p.items.as_slice()); + snapshot_tracks(&guard.track_table.tracks, search, uris) + }; + if tracks.is_empty() { + set_error(app, "No Qobuz tracks to play".to_string()).await; + return; + } + let index = start_idx.min(tracks.len() - 1); + let track_id = match tracks[index].uri.as_deref().map(track_id_from_uri) { + Some(Ok(id)) => id.to_string(), + _ => { + set_error(app, "Invalid Qobuz track URI".to_string()).await; + return; + } + }; + let Some(source) = build_source(app, WhenLoggedOut::Message).await else { + return; + }; + let source = Arc::new(source); + + // Only one backend owns the device at a time. + release_other_backends(app).await; + let Some(player) = acquire_player(app).await else { + return; + }; + + // Publish the session now, marked advancing, so the playbar and the skip + // keys see it during the download; `commit_fetch` finalizes it. + let fetch_id = next_fetch_id(); + { + let mut guard = app.lock().await; + let mut state = QobuzPlaybackState { + player, + source: Arc::clone(&source), + tracks, + index, + advancing: true, + tempfile: None, + shuffle_backup: None, + fetch_id, + resume_at: None, + fetch: None, + }; + // Honor the player-global decoded shuffle for the freshly built queue. + if guard.decoded_shuffle { + state.set_shuffle(true); + } + // Dropping a previous session aborts its download. + guard.qobuz_playback = Some(state); + } + spawn_fetch(app, source, track_id, fetch_id).await; +} + +/// Move the queue index in `direction` and play the new track. Returns `true` +/// if Qobuz owns the session (so the event is consumed). +async fn skip(app: &Arc>, direction: Direction) -> bool { + let target = { + let mut guard = app.lock().await; + let mode = guard.decoded_repeat; + let Some(s) = guard.qobuz_playback.as_mut() else { + return false; + }; + s.advancing = true; + let forward = matches!(direction, Direction::Next); + advance_index(s.index, s.tracks.len(), mode, forward) + }; + match target { + Some(idx) => play_index(app, idx).await, + None => { + // Queue boundary: clear the guard so auto-advance is not wedged off. A + // pending first download keeps it (the sink is still empty). + if let Some(s) = app.lock().await.qobuz_playback.as_mut() { + if s.tempfile.is_some() { + s.advancing = false; + } + } + } + } + true +} + +/// Replay the current track (repeat-one) from its retained tempfile, with no +/// second download. Returns `true` if Qobuz owns the session. +async fn replay_current(app: &Arc>) -> bool { + let replay = { + let mut guard = app.lock().await; + let Some(s) = guard.qobuz_playback.as_mut() else { + return false; + }; + s.advancing = true; + s.tempfile + .as_ref() + .map(|t| (Arc::clone(&s.player), t.path().to_path_buf())) + }; + let Some((player, path)) = replay else { + // Still downloading: the commit clears `advancing` and starts playback. + return true; + }; + if replay_file(player, path).await { + if let Some(s) = app.lock().await.qobuz_playback.as_mut() { + s.advancing = false; + } + } else { + teardown_qobuz(app).await; + set_error(app, "Cannot replay Qobuz track".to_string()).await; + } + true +} + +/// Play the queued track at `target` in the published session: the index moves +/// at once and the download runs off the pump. Used by Next/Previous, the tick's +/// auto-advance, and the native queue's resume. +pub(crate) async fn play_index(app: &Arc>, target: usize) { + let plan = { + let mut guard = app.lock().await; + let Some(s) = guard.qobuz_playback.as_mut() else { + return; // session torn down between dispatch and here + }; + match s.tracks.get(target) { + None => { + s.advancing = false; + return; + } + Some(track) => match track.uri.as_deref().map(track_id_from_uri) { + Some(Ok(id)) => { + let fetch_id = next_fetch_id(); + // Cancel a superseded download and forget its restore point. + if let Some(fetch) = s.fetch.take() { + fetch.abort(); + } + s.resume_at = None; + s.index = target; + s.advancing = true; + s.fetch_id = fetch_id; + Ok((Arc::clone(&s.source), id.to_string(), fetch_id)) + } + _ => Err(()), + }, + } + }; + match plan { + Ok((source, track_id, fetch_id)) => spawn_fetch(app, source, track_id, fetch_id).await, + Err(()) => { + teardown_qobuz(app).await; + set_error(app, "Invalid Qobuz track URI".to_string()).await; + } + } +} + +/// End the Qobuz session, releasing the output device and the tempfile. +async fn teardown_qobuz(app: &Arc>) { + if let Some(s) = app.lock().await.qobuz_playback.take() { + s.player.stop(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn track(uri: &str, name: &str) -> TrackInfo { + TrackInfo { + uri: Some(uri.to_string()), + name: name.to_string(), + artists: vec!["Artist".to_string()], + album: "Album".to_string(), + duration_ms: 1000, + id: None, + album_id: None, + artist_refs: vec![], + is_playable: true, + is_local: false, + track_number: 0, + explicit: false, + image_url: None, + } + } + + #[test] + fn snapshot_finds_tracks_in_table_preserving_order() { + let table = vec![track("qobuz:track:a", "A"), track("qobuz:track:b", "B")]; + let snap = snapshot_tracks( + &table, + None, + &["qobuz:track:b".to_string(), "qobuz:track:a".to_string()], + ); + assert_eq!(snap.len(), 2); + assert_eq!(snap[0].name, "B"); + assert_eq!(snap[1].name, "A"); + } + + #[test] + fn snapshot_falls_back_to_search_results_for_search_to_play() { + let table = vec![track("qobuz:track:browsed", "Browsed")]; + let search = vec![track("qobuz:track:searched", "Searched")]; + let snap = snapshot_tracks(&table, Some(&search), &["qobuz:track:searched".to_string()]); + assert_eq!(snap.len(), 1); + assert_eq!(snap[0].name, "Searched"); + } + + #[test] + fn snapshot_drops_unknown_uris() { + let table = vec![track("qobuz:track:a", "A")]; + let snap = snapshot_tracks(&table, None, &["qobuz:track:missing".to_string()]); + assert!(snap.is_empty()); + } + + #[test] + fn qobuz_uris_are_recognised_by_scheme() { + assert!(is_qobuz_uri("qobuz:track:1")); + assert!(!is_qobuz_uri("subsonic:track:1")); + } +} diff --git a/src/infra/qobuz/mod.rs b/src/infra/qobuz/mod.rs new file mode 100644 index 00000000..58d7506d --- /dev/null +++ b/src/infra/qobuz/mod.rs @@ -0,0 +1,895 @@ +//! Qobuz media source. +//! +//! Browses the user's Qobuz library and plays tracks through the encrypted CMAF +//! stream: every track is downloaded, decrypted and rebuilt as a FLAC tempfile, +//! then decoded by the shared [`LocalPlayer`](crate::infra::audio::LocalPlayer). +//! The three bundle constants are scraped at runtime (see [`auth`]); nothing +//! secret is embedded. +//! +//! ## URIs +//! +//! Tracks: `qobuz:track:`. Sidebar rows (each opens the shared track table): +//! `qobuz:favorites:tracks`, `qobuz:playlist:`, `qobuz:album:`. + +pub mod auth; +pub mod dispatch; +mod sign; +pub mod stream; +mod types; + +use std::fmt; +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, Context, Result}; +use reqwest::Client; +use serde::de::DeserializeOwned; +use tokio::sync::Mutex; + +use crate::core::plugin_api::{ArtistRef, PlaylistInfo, SearchResults, TrackInfo}; +use crate::core::source::{MediaSource, Searcher}; +use crate::infra::audio::LocalPlayer; +use stream::download::{self, TrackDownload}; + +/// The active Qobuz playback session; the playbar reads position and pause +/// live from `player`, like the Subsonic twin. +pub struct QobuzPlaybackState { + pub player: Arc, + /// Source handle, reused to download each track on Next/advance. + pub source: Arc, + /// The playing listing's tracks in order; the playbar reads `tracks[index]`. + pub tracks: Vec, + pub index: usize, + /// Set for the whole download window so the tick never reads the empty sink + /// as end-of-track. + pub advancing: bool, + /// The decrypted audio of the current track; `None` while it downloads. + pub tempfile: Option, + /// Backup of the pre-shuffle order while shuffle is on. + pub shuffle_backup: Option, + /// Stamp of the download in flight; a finished download with another stamp is dropped. + pub fetch_id: u64, + /// A seek and pause to apply when the pending download plays (session restore). + pub resume_at: Option, + /// The download task in flight; aborted when the session is replaced or restamped. + pub fetch: Option, +} + +impl Drop for QobuzPlaybackState { + fn drop(&mut self) { + if let Some(fetch) = self.fetch.take() { + fetch.abort(); + } + } +} + +/// Where a restored session continues once its first download plays. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ResumePoint { + pub position_ms: u64, + pub paused: bool, +} + +impl QobuzPlaybackState { + /// The currently playing track, if `index` is in range. + pub fn current(&self) -> Option<&TrackInfo> { + self.tracks.get(self.index) + } + + /// Turn in-place shuffle on or off (see `infra::queue::toggle_shuffle`). + pub fn set_shuffle(&mut self, on: bool) { + crate::infra::queue::toggle_shuffle( + &mut self.tracks, + &mut self.index, + &mut self.shuffle_backup, + on, + ); + } +} + +const API_BASE: &str = "https://www.qobuz.com/api.json/0.2/"; + +pub const TRACK_PREFIX: &str = "qobuz:track:"; +pub const PLAYLIST_PREFIX: &str = "qobuz:playlist:"; +pub const ALBUM_PREFIX: &str = "qobuz:album:"; +pub const FAVORITES_URI: &str = "qobuz:favorites:tracks"; + +/// Per-request caps; they bound each segment GET, never a whole track. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +const PAGE_LIMIT: u32 = 500; +const MAX_ITEMS: usize = 10_000; +const SEARCH_LIMIT: u32 = 20; +/// Renew the stream session this long before `expires_at`. +const SESSION_MARGIN_SECS: u64 = 60; + +/// A Qobuz call answered HTTP 401: the saved token is no longer valid. +#[derive(Debug)] +pub struct Unauthorized; + +impl fmt::Display for Unauthorized { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("login expired") + } +} + +impl std::error::Error for Unauthorized {} + +/// A `session/start` session with its derived key. +#[derive(Clone)] +pub struct StreamSession { + pub session_id: String, + pub expires_at: u64, + pub session_key: [u8; 16], +} + +impl StreamSession { + fn is_valid_at(&self, now: u64) -> bool { + self.expires_at > now + SESSION_MARGIN_SECS + } +} + +/// What `file/url` returned for one track, with the content key unwrapped. +pub struct TrackStream { + pub url_template: String, + pub content_key: [u8; 16], +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Process-wide HTTP client with per-request timeouts (see Subsonic's twin). +pub fn shared_qobuz_client() -> Client { + static CLIENT: std::sync::OnceLock = std::sync::OnceLock::new(); + CLIENT + .get_or_init(|| { + Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .build() + .unwrap_or_default() + }) + .clone() +} + +/// The one stream session per process; sources are rebuilt per event. +fn shared_session() -> &'static Mutex> { + static SESSION: std::sync::OnceLock>> = std::sync::OnceLock::new(); + SESSION.get_or_init(|| Mutex::new(None)) +} + +#[derive(Clone, Copy)] +enum Method { + Get, + PostForm, +} + +/// Which track listing a sidebar URI names. +enum Listing { + Favorites, + Playlist(String), + Album(String), +} + +/// A media source backed by the Qobuz API. +pub struct QobuzSource { + app_id: String, + secret: String, + token: String, + http: Client, +} + +impl QobuzSource { + pub fn new( + app_id: impl Into, + secret: impl Into, + token: impl Into, + ) -> Self { + QobuzSource { + app_id: app_id.into(), + secret: secret.into(), + token: token.into(), + http: shared_qobuz_client(), + } + } + + /// One API call; `signed` adds `request_ts` and `request_sig` over `args`. + async fn request( + &self, + method: Method, + endpoint: &str, + args: &[(&str, String)], + signed: bool, + session_id: Option<&str>, + ) -> Result { + let mut query: Vec<(&str, String)> = args.to_vec(); + if signed { + let ts = unix_now(); + let sig_args: Vec<(&str, &str)> = args.iter().map(|(k, v)| (*k, v.as_str())).collect(); + query.push(("request_ts", ts.to_string())); + query.push(( + "request_sig", + sign::request_sig(endpoint, &sig_args, ts, &self.secret), + )); + } + let url = format!("{API_BASE}{endpoint}"); + let mut request = match method { + Method::Get => self.http.get(&url).query(&query), + Method::PostForm => self.http.post(&url).form(&query), + }; + request = request.header("X-App-Id", &self.app_id); + if !self.token.is_empty() { + request = request.header("X-User-Auth-Token", &self.token); + } + if let Some(id) = session_id { + request = request.header("X-Session-Id", id); + } + // `without_url`: a reqwest error would otherwise print the query, which + // carries the OAuth code and the private key on `oauth/callback`. + let response = request + .send() + .await + .map_err(reqwest::Error::without_url) + .with_context(|| format!("{endpoint} request"))?; + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(Unauthorized.into()); + } + let body = response + .text() + .await + .map_err(reqwest::Error::without_url) + .with_context(|| format!("{endpoint} body"))?; + if !status.is_success() { + let excerpt: String = body.chars().take(120).collect(); + return Err(anyhow!("{endpoint} returned HTTP {status}: {excerpt}")); + } + serde_json::from_str(&body).with_context(|| format!("{endpoint} response parse")) + } + + async fn get(&self, endpoint: &str, args: &[(&str, String)]) -> Result { + self.request(Method::Get, endpoint, args, false, None).await + } + + // ------------------------------------------------------------------------- + // Stream session and file URL + // ------------------------------------------------------------------------- + + /// `POST session/start`: the signed profile as a form body (the live shape). + pub async fn session_start(&self) -> Result { + let started: types::SessionStart = self + .request( + Method::PostForm, + "session/start", + &[("profile", "qbz-1".to_string())], + true, + None, + ) + .await + .context("session start")?; + let (salt, info) = started + .infos + .split_once('.') + .ok_or_else(|| anyhow!("session infos has no separator"))?; + let session_key = stream::crypto::session_key( + &self.secret, + &auth::decode_base64(salt)?, + &auth::decode_base64(info)?, + )?; + Ok(StreamSession { + session_id: started.session_id, + expires_at: started.expires_at, + session_key, + }) + } + + /// The shared session, renewed when expired. + pub async fn stream_session(&self) -> Result { + let mut slot = shared_session().lock().await; + if let Some(session) = slot.as_ref().filter(|s| s.is_valid_at(unix_now())) { + return Ok(session.clone()); + } + let session = self.session_start().await?; + *slot = Some(session.clone()); + Ok(session) + } + + /// `GET file/url` for one track at `format_id` (5, 6, 7, or 27). + pub async fn file_url( + &self, + session: &StreamSession, + track_id: &str, + format_id: u8, + ) -> Result { + let file: types::FileUrl = self + .request( + Method::Get, + "file/url", + &[ + ("track_id", track_id.to_string()), + ("format_id", format_id.to_string()), + ("intent", "stream".to_string()), + ], + true, + Some(&session.session_id), + ) + .await + .context("file url")?; + let parts: Vec<&str> = file.key.split('.').collect(); + if parts.len() < 3 { + return Err(anyhow!( + "file url key has {} parts, expected 3", + parts.len() + )); + } + let content_key = stream::crypto::unwrap_content_key( + &session.session_key, + &auth::decode_base64(parts[1])?, + &auth::decode_base64(parts[2])?, + )?; + Ok(TrackStream { + url_template: file.url_template, + content_key, + }) + } + + /// Start a track download into `dest`; a stale session is renewed once. + pub async fn begin_download( + &self, + track_id: &str, + format_id: u8, + dest: &Path, + ) -> Result { + let session = self.stream_session().await?; + let stream = match self.file_url(&session, track_id, format_id).await { + Ok(stream) => stream, + Err(e) if e.downcast_ref::().is_some() => return Err(e), + Err(_) => { + *shared_session().lock().await = None; + let session = self.stream_session().await?; + self.file_url(&session, track_id, format_id).await? + } + }; + download::begin(&self.http, &stream.url_template, stream.content_key, dest).await + } + + // ------------------------------------------------------------------------- + // Catalog + // ------------------------------------------------------------------------- + + async fn user_playlists(&self) -> Result> { + let mut out = Vec::new(); + loop { + let page: types::UserPlaylists = self + .get( + "playlist/getUserPlaylists", + &[ + ("limit", PAGE_LIMIT.to_string()), + ("offset", out.len().to_string()), + ], + ) + .await?; + let got = page.playlists.items.len(); + let total = page.playlists.total as usize; + out.extend(page.playlists.items); + if got == 0 || out.len() >= total || out.len() >= MAX_ITEMS { + return Ok(out); + } + } + } + + async fn favorite_albums(&self) -> Result> { + let mut out = Vec::new(); + loop { + let page: types::Favorites = self + .get( + "favorite/getUserFavorites", + &[ + ("type", "albums".to_string()), + ("limit", PAGE_LIMIT.to_string()), + ("offset", out.len().to_string()), + ], + ) + .await?; + let albums = page.albums.unwrap_or_default(); + let got = albums.items.len(); + let total = albums.total as usize; + out.extend(albums.items); + if got == 0 || out.len() >= total || out.len() >= MAX_ITEMS { + return Ok(out); + } + } + } + + async fn favorite_track_count(&self) -> Result { + let page: types::Favorites = self + .get( + "favorite/getUserFavorites", + &[ + ("type", "tracks".to_string()), + ("limit", "1".to_string()), + ("offset", "0".to_string()), + ], + ) + .await?; + Ok(page.tracks.map(|t| t.total).unwrap_or(0)) + } + + /// One page of a listing: the tracks plus the album context, when any. + async fn listing_page( + &self, + listing: &Listing, + offset: usize, + ) -> Result<(types::Page, Option)> { + let limit = PAGE_LIMIT.to_string(); + let offset = offset.to_string(); + match listing { + Listing::Favorites => { + let page: types::Favorites = self + .get( + "favorite/getUserFavorites", + &[ + ("type", "tracks".to_string()), + ("limit", limit), + ("offset", offset), + ], + ) + .await?; + Ok((page.tracks.unwrap_or_default(), None)) + } + Listing::Playlist(id) => { + let playlist: types::Playlist = self + .get( + "playlist/get", + &[ + ("playlist_id", id.clone()), + ("extra", "tracks".to_string()), + ("limit", limit), + ("offset", offset), + ], + ) + .await?; + Ok((playlist.tracks.unwrap_or_default(), None)) + } + Listing::Album(id) => { + let mut album: types::Album = self + .get( + "album/get", + &[ + ("album_id", id.clone()), + ("limit", limit), + ("offset", offset), + ], + ) + .await?; + let tracks = album.tracks.take().unwrap_or_default(); + Ok((tracks, Some(album))) + } + } + } + + async fn listing_tracks(&self, listing: &Listing) -> Result> { + let mut out = Vec::new(); + loop { + let (page, album) = self.listing_page(listing, out.len()).await?; + let got = page.items.len(); + let total = page.total as usize; + out.extend( + page + .items + .iter() + .map(|t| track_to_track_info(t, album.as_ref())), + ); + if got == 0 || out.len() >= total || out.len() >= MAX_ITEMS { + return Ok(out); + } + } + } +} + +// --------------------------------------------------------------------------- +// URIs and domain type conversions +// --------------------------------------------------------------------------- + +/// Strip the `qobuz:track:` prefix and return the raw track id. +pub fn track_id_from_uri(uri: &str) -> Result<&str> { + uri + .strip_prefix(TRACK_PREFIX) + .ok_or_else(|| anyhow!("Not a qobuz track URI: {}", uri)) +} + +fn listing_from_uri(uri: &str) -> Result { + if uri == FAVORITES_URI { + Ok(Listing::Favorites) + } else if let Some(id) = uri.strip_prefix(PLAYLIST_PREFIX) { + Ok(Listing::Playlist(id.to_string())) + } else if let Some(id) = uri.strip_prefix(ALBUM_PREFIX) { + Ok(Listing::Album(id.to_string())) + } else { + Err(anyhow!("Not a qobuz playlist URI: {}", uri)) + } +} + +fn favorites_playlist(track_count: u32) -> PlaylistInfo { + PlaylistInfo { + uri: FAVORITES_URI.to_string(), + name: "Favorite tracks".to_string(), + owner: String::new(), + track_count, + id: None, + owner_id: None, + collaborative: false, + public: Some(false), + image_url: None, + } +} + +fn playlist_to_playlist_info(p: &types::Playlist) -> PlaylistInfo { + PlaylistInfo { + uri: format!("{PLAYLIST_PREFIX}{}", p.id), + name: p.name.clone(), + owner: p.owner.as_ref().map(|o| o.name.clone()).unwrap_or_default(), + track_count: p.tracks_count, + id: Some(p.id.clone()), + owner_id: p.owner.as_ref().and_then(|o| o.id.clone()), + collaborative: false, + public: p.is_public, + image_url: p.images300.first().cloned(), + } +} + +fn album_to_playlist_info(a: &types::Album) -> PlaylistInfo { + let artist = a.artist.as_ref().map(|n| n.name.as_str()).unwrap_or(""); + PlaylistInfo { + uri: format!("{ALBUM_PREFIX}{}", a.id), + name: if artist.is_empty() { + a.title.clone() + } else { + format!("{} - {artist}", a.title) + }, + owner: artist.to_string(), + track_count: a.tracks_count, + id: Some(a.id.clone()), + owner_id: None, + collaborative: false, + public: Some(true), + image_url: a.image.large.clone(), + } +} + +/// Map a Qobuz track onto [`TrackInfo`]; `parent` is the album of an `album/get`. +fn track_to_track_info(t: &types::Track, parent: Option<&types::Album>) -> TrackInfo { + let album = t.album.as_ref().or(parent); + let performer = t + .performer + .as_ref() + .or(album.and_then(|a| a.artist.as_ref())); + let artist_refs: Vec = performer + .filter(|n| !n.name.is_empty()) + .map(|n| { + vec![ArtistRef { + id: n.id.clone(), + name: n.name.clone(), + }] + }) + .unwrap_or_default(); + let name = match t.version.as_deref().filter(|v| !v.is_empty()) { + Some(version) => format!("{} ({version})", t.title), + None => t.title.clone(), + }; + TrackInfo { + uri: Some(format!("{TRACK_PREFIX}{}", t.id)), + name, + artists: artist_refs.iter().map(|a| a.name.clone()).collect(), + album: album.map(|a| a.title.clone()).unwrap_or_default(), + duration_ms: t.duration * 1000, + id: Some(t.id.clone()), + album_id: album.map(|a| a.id.clone()), + artist_refs, + is_playable: t.streamable, + is_local: false, + track_number: t.track_number, + explicit: t.parental_warning, + image_url: album.and_then(|a| a.image.large.clone()), + } +} + +// --------------------------------------------------------------------------- +// Trait implementations +// --------------------------------------------------------------------------- + +impl MediaSource for QobuzSource { + fn name(&self) -> &str { + "Qobuz" + } + + fn scheme(&self) -> &str { + "qobuz" + } + + /// Favorite tracks, then the user's playlists, then favorite albums. + async fn playlists(&self) -> Result> { + let mut out = vec![favorites_playlist(self.favorite_track_count().await?)]; + out.extend( + self + .user_playlists() + .await? + .iter() + .map(playlist_to_playlist_info), + ); + out.extend( + self + .favorite_albums() + .await? + .iter() + .map(album_to_playlist_info), + ); + Ok(out) + } + + async fn tracks(&self, playlist_uri: &str) -> Result> { + let listing = listing_from_uri(playlist_uri)?; + self.listing_tracks(&listing).await + } +} + +impl Searcher for QobuzSource { + /// Tracks only: album and artist rows would route to Spotify-bound events. + async fn search(&self, query: &str) -> Result { + let found: types::Search = self + .get( + "catalog/search", + &[ + ("query", query.to_string()), + ("limit", SEARCH_LIMIT.to_string()), + ], + ) + .await?; + let tracks = found + .tracks + .unwrap_or_default() + .items + .iter() + .map(|t| track_to_track_info(t, None)) + .collect(); + Ok(SearchResults { + tracks, + albums: vec![], + artists: vec![], + playlists: vec![], + shows: vec![], + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const USER_PLAYLISTS: &str = r#"{ + "playlists": { + "offset": 0, "limit": 500, "total": 2, + "items": [ + { "id": 111, "name": "Morning", "owner": { "id": 9, "name": "jay" }, + "tracks_count": 12, "is_public": false, "images300": ["https://img/1.jpg"] }, + { "id": 222, "name": "Evening", "owner": { "id": 9, "name": "jay" }, + "tracks_count": 3, "is_public": true } + ] + } + }"#; + + const PLAYLIST_TRACKS: &str = r#"{ + "id": 111, "name": "Morning", + "tracks": { + "offset": 0, "limit": 500, "total": 1, + "items": [ + { "id": 5001, "title": "Around the World", "version": "Radio Edit", + "duration": 429, "track_number": 7, + "performer": { "id": 36819, "name": "Daft Punk" }, + "album": { "id": "0060254730301", "title": "Homework", + "artist": { "id": 36819, "name": "Daft Punk" }, + "image": { "large": "https://img/large.jpg", "small": "https://img/s.jpg" } }, + "streamable": true, "parental_warning": false } + ] + } + }"#; + + const ALBUM: &str = r#"{ + "id": "0060254730301", "title": "Homework", "tracks_count": 2, + "artist": { "id": 36819, "name": "Daft Punk" }, + "image": { "large": "https://img/album.jpg" }, + "tracks": { "total": 2, "items": [ + { "id": 1, "title": "Daftendirekt", "duration": 164, "track_number": 1 }, + { "id": 2, "title": "WDPK 83.7 FM", "duration": 28, "track_number": 2, "streamable": false } + ] } + }"#; + + const FAVORITES: &str = r#"{ + "albums": { "total": 1, "items": [ + { "id": "abc", "title": "Discovery", "artist": { "id": 36819, "name": "Daft Punk" }, + "tracks_count": 14, "image": { "large": "https://img/d.jpg" } } + ] } + }"#; + + const SESSION_START: &str = r#"{ "session_id": "sess-1", "expires_at": 1700000000, + "infos": "c2FsdA.aW5mbw" }"#; + + const FILE_URL: &str = r#"{ "url_template": "https://cdn/$SEGMENT$.m4s", "n_segments": 3, + "key": "p.d3JhcHBlZA.aXY", "mime_type": "audio/flac", "format_id": 27 }"#; + + #[test] + fn user_playlists_map_to_playlist_info() { + let page: types::UserPlaylists = serde_json::from_str(USER_PLAYLISTS).unwrap(); + assert_eq!(page.playlists.total, 2); + let info = playlist_to_playlist_info(&page.playlists.items[0]); + assert_eq!(info.uri, "qobuz:playlist:111"); + assert_eq!(info.name, "Morning"); + assert_eq!(info.owner, "jay"); + assert_eq!(info.owner_id.as_deref(), Some("9")); + assert_eq!(info.track_count, 12); + assert_eq!(info.public, Some(false)); + assert_eq!(info.image_url.as_deref(), Some("https://img/1.jpg")); + assert!(playlist_to_playlist_info(&page.playlists.items[1]) + .image_url + .is_none()); + } + + #[test] + fn playlist_tracks_map_to_track_info() { + let playlist: types::Playlist = serde_json::from_str(PLAYLIST_TRACKS).unwrap(); + let tracks = playlist.tracks.unwrap(); + let track = track_to_track_info(&tracks.items[0], None); + assert_eq!(track.uri.as_deref(), Some("qobuz:track:5001")); + assert_eq!(track.name, "Around the World (Radio Edit)"); + assert_eq!(track.artists, vec!["Daft Punk"]); + assert_eq!(track.album, "Homework"); + assert_eq!(track.duration_ms, 429_000); + assert_eq!(track.track_number, 7); + assert_eq!(track.id.as_deref(), Some("5001")); + assert_eq!(track.album_id.as_deref(), Some("0060254730301")); + assert_eq!(track.artist_refs[0].id.as_deref(), Some("36819")); + assert_eq!(track.image_url.as_deref(), Some("https://img/large.jpg")); + assert!(track.is_playable); + assert!(!track.explicit); + } + + #[test] + fn album_tracks_take_the_parent_album_as_context() { + let album: types::Album = serde_json::from_str(ALBUM).unwrap(); + let tracks = album.tracks.as_ref().unwrap(); + let first = track_to_track_info(&tracks.items[0], Some(&album)); + assert_eq!(first.album, "Homework"); + assert_eq!(first.artists, vec!["Daft Punk"]); + assert_eq!(first.image_url.as_deref(), Some("https://img/album.jpg")); + let second = track_to_track_info(&tracks.items[1], Some(&album)); + assert!(!second.is_playable); + let row = album_to_playlist_info(&album); + assert_eq!(row.uri, "qobuz:album:0060254730301"); + assert_eq!(row.name, "Homework - Daft Punk"); + assert_eq!(row.track_count, 2); + } + + #[test] + fn favorite_albums_parse_and_favorites_row_is_first() { + let favorites: types::Favorites = serde_json::from_str(FAVORITES).unwrap(); + assert!(favorites.tracks.is_none()); + let albums = favorites.albums.unwrap(); + assert_eq!( + album_to_playlist_info(&albums.items[0]).uri, + "qobuz:album:abc" + ); + let row = favorites_playlist(7); + assert_eq!(row.uri, FAVORITES_URI); + assert_eq!(row.track_count, 7); + } + + #[test] + fn session_start_and_file_url_parse() { + let session: types::SessionStart = serde_json::from_str(SESSION_START).unwrap(); + assert_eq!(session.session_id, "sess-1"); + let (salt, info) = session.infos.split_once('.').unwrap(); + assert_eq!(auth::decode_base64(salt).unwrap(), b"salt"); + assert_eq!(auth::decode_base64(info).unwrap(), b"info"); + let file: types::FileUrl = serde_json::from_str(FILE_URL).unwrap(); + assert_eq!(file.url_template, "https://cdn/$SEGMENT$.m4s"); + assert_eq!(file.key.split('.').count(), 3); + } + + #[test] + fn listing_from_uri_covers_every_sidebar_shape() { + assert!(matches!( + listing_from_uri(FAVORITES_URI), + Ok(Listing::Favorites) + )); + assert!(matches!(listing_from_uri("qobuz:playlist:5"), Ok(Listing::Playlist(id)) if id == "5")); + assert!(matches!(listing_from_uri("qobuz:album:x"), Ok(Listing::Album(id)) if id == "x")); + assert!(listing_from_uri("spotify:playlist:5").is_err()); + assert_eq!(track_id_from_uri("qobuz:track:9").unwrap(), "9"); + assert!(track_id_from_uri("qobuz:album:9").is_err()); + } + + #[test] + fn session_validity_keeps_a_renewal_margin() { + let session = StreamSession { + session_id: String::new(), + expires_at: 1_000, + session_key: [0; 16], + }; + assert!(session.is_valid_at(1_000 - SESSION_MARGIN_SECS - 1)); + assert!(!session.is_valid_at(1_000 - SESSION_MARGIN_SECS)); + } + + /// Live end to end: scrape the bundle, start a session, download and decrypt + /// one track, and play it through the shared sink. Needs + /// `SPOTATUI_QOBUZ_TOKEN` (and `SPOTATUI_QOBUZ_TEST_FORMAT`, default 27). Run: + /// `cargo test --features qobuz -- --ignored live_qobuz --nocapture` + #[tokio::test] + #[ignore = "needs SPOTATUI_QOBUZ_TOKEN, the network, and an audio output device"] + async fn live_qobuz_download_plays_through_sink() { + use crate::infra::audio::LocalPlayer; + use std::time::Instant; + + let Some(token) = auth::load_token() else { + eprintln!("no token: set {}", auth::TOKEN_ENV); + return; + }; + let format: u8 = std::env::var("SPOTATUI_QOBUZ_TEST_FORMAT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(27); + + let http = shared_qobuz_client(); + let constants = auth::resolve_constants(&http, None) + .await + .expect("bundle scrape"); + eprintln!( + "bundle {} app_id {} secret_len {} oauth_key_len {}", + constants.bundle_version, + constants.app_id, + constants.app_secret.len(), + constants.oauth_key.len() + ); + let source = QobuzSource::new(constants.app_id, constants.app_secret, token); + + let playlists = source.playlists().await.expect("playlists"); + eprintln!("{} sidebar rows", playlists.len()); + let results = source.search("Daft Punk").await.expect("search"); + let track = results.tracks.first().expect("a search hit"); + let track_id = track_id_from_uri(track.uri.as_deref().unwrap()).unwrap(); + eprintln!("track {} ({})", track.name, track_id); + + let tmp = tempfile::NamedTempFile::new().unwrap(); + let started = Instant::now(); + let download = source + .begin_download(track_id, format, tmp.path()) + .await + .expect("session start + file url + init segment"); + let total = download.total_bytes(); + eprintln!("init after {:?}, total {} bytes", started.elapsed(), total); + download.finish().await.expect("segments"); + eprintln!("download done after {:?}", started.elapsed()); + + let bytes = std::fs::read(tmp.path()).unwrap(); + assert_eq!(bytes.len() as u64, total); + if format != 5 { + assert!(bytes.starts_with(b"fLaC"), "got {:02X?}", &bytes[..4]); + } + + let player = LocalPlayer::new().expect("open default output device"); + player.play_file(tmp.path()).expect("play decrypted track"); + eprintln!("playing after {:?}", started.elapsed()); + tokio::time::sleep(Duration::from_millis(600)).await; + assert!( + player.position() >= Duration::from_millis(200), + "playback position should advance, got {:?}", + player.position() + ); + player.stop(); + } +} diff --git a/src/infra/qobuz/sign.rs b/src/infra/qobuz/sign.rs new file mode 100644 index 00000000..bc2f2714 --- /dev/null +++ b/src/infra/qobuz/sign.rs @@ -0,0 +1,65 @@ +//! Request signature for the signed Qobuz endpoints. +//! +//! `request_sig` is the lowercase hex MD5 of ``, +//! where `` is the endpoint with its slash removed and `` +//! is every query argument as `key` + `value`, keys sorted, no separators. + +use md5::{Digest, Md5}; + +/// The `request_sig` value for `endpoint` (for example `"session/start"`). +pub fn request_sig(endpoint: &str, args: &[(&str, &str)], request_ts: u64, secret: &str) -> String { + let mut sorted: Vec<&(&str, &str)> = args.iter().collect(); + sorted.sort_by(|a, b| a.0.cmp(b.0)); + let mut input = endpoint.replace('/', ""); + for (key, value) in sorted { + input.push_str(key); + input.push_str(value); + } + input.push_str(&request_ts.to_string()); + input.push_str(secret); + hex::encode(Md5::digest(input.as_bytes())) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SECRET: &str = "abcdef0123456789abcdef0123456789"; + + #[test] + fn session_start_signature_matches_precomputed_md5() { + let sig = request_sig( + "session/start", + &[("profile", "qbz-1")], + 1_700_000_000, + SECRET, + ); + assert_eq!(sig, "37a4a7a6ca79344aaf11aa0d3b05e677"); + } + + #[test] + fn arguments_are_sorted_by_key_before_hashing() { + let ordered = request_sig( + "file/url", + &[ + ("format_id", "27"), + ("intent", "stream"), + ("track_id", "123"), + ], + 1_700_000_000, + SECRET, + ); + let reversed = request_sig( + "file/url", + &[ + ("track_id", "123"), + ("intent", "stream"), + ("format_id", "27"), + ], + 1_700_000_000, + SECRET, + ); + assert_eq!(ordered, "7ea61d336e3ff5c0c46b3d984ad36834"); + assert_eq!(reversed, ordered); + } +} diff --git a/src/infra/qobuz/stream/cmaf.rs b/src/infra/qobuz/stream/cmaf.rs new file mode 100644 index 00000000..728e46e8 --- /dev/null +++ b/src/infra/qobuz/stream/cmaf.rs @@ -0,0 +1,438 @@ +//! Parsers for the Qobuz CMAF segments, pure over `&[u8]`. +//! +//! Segment 0 carries a `uuid` box ([`INIT_UUID`]) with the raw FLAC header and a +//! table of per-segment byte lengths. Every later segment carries a `uuid` box +//! ([`SEGMENT_UUID`]) with one entry per frame (length, flags, IV) and the frames +//! themselves at `data_offset` from the box start. Only frames whose flags are +//! not zero are encrypted. + +use anyhow::{anyhow, Result}; + +use super::crypto::decrypt_frame; + +/// Box type of the init payload. +pub const INIT_UUID: [u8; 16] = [ + 0xc7, 0xc7, 0x5d, 0xf0, 0xfd, 0xd9, 0x51, 0xe9, 0x8f, 0xc2, 0x29, 0x71, 0xe4, 0xac, 0xf8, 0xd2, +]; +/// Box type of the per-segment frame table. +pub const SEGMENT_UUID: [u8; 16] = [ + 0x3b, 0x42, 0x12, 0x92, 0x56, 0xf3, 0x5f, 0x75, 0x92, 0x36, 0x63, 0xb6, 0x9a, 0x1f, 0x52, 0xb2, +]; + +/// The parsed init segment. +#[derive(Debug, Clone, PartialEq)] +pub struct InitSegment { + /// The codec header to write first (a FLAC header with the last-block bit set). + pub header: Vec, + /// Byte length of each audio segment, in order (segment 1 first). + pub segment_lengths: Vec, +} + +impl InitSegment { + /// The size of the finished file: header plus every segment. + pub fn total_bytes(&self) -> u64 { + self.header.len() as u64 + self.segment_lengths.iter().map(|&l| l as u64).sum::() + } +} + +/// One frame entry of an audio segment. +#[derive(Debug, Clone, PartialEq)] +pub struct FrameEntry { + pub len: usize, + pub flags: u16, + pub iv: Vec, +} + +/// The frame table of an audio segment. +#[derive(Debug, Clone, PartialEq)] +pub struct SegmentTable { + /// Offset of the first frame in the segment buffer. + pub frames_start: usize, + pub frames: Vec, +} + +impl SegmentTable { + /// Offset one past the last frame. + pub fn frames_end(&self) -> usize { + self.frames_start + self.frames.iter().map(|f| f.len).sum::() + } +} + +struct Reader<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + fn take(&mut self, n: usize) -> Result<&'a [u8]> { + let end = self + .pos + .checked_add(n) + .filter(|&end| end <= self.buf.len()) + .ok_or_else(|| anyhow!("segment truncated at byte {}", self.pos))?; + let out = &self.buf[self.pos..end]; + self.pos = end; + Ok(out) + } + + fn be(&mut self, n: usize) -> Result { + Ok( + self + .take(n)? + .iter() + .fold(0u64, |acc, &b| (acc << 8) | b as u64), + ) + } + + fn u8(&mut self) -> Result { + Ok(self.be(1)? as u8) + } + + fn u16(&mut self) -> Result { + Ok(self.be(2)? as u16) + } + + fn u32(&mut self) -> Result { + Ok(self.be(4)? as u32) + } +} + +/// Start offset and payload offset of the top-level `uuid` box with type `uuid`. +fn find_uuid_box(buf: &[u8], uuid: &[u8; 16]) -> Result<(usize, usize)> { + let mut pos = 0usize; + while pos.checked_add(8).is_some_and(|end| end <= buf.len()) { + let mut r = Reader { buf, pos }; + let size32 = r.u32()?; + let kind = r.take(4)?; + let remaining = buf.len() - pos; + let (header_len, size) = match size32 { + 0 => (8usize, remaining), + 1 => (16usize, usize::try_from(r.be(8)?).unwrap_or(usize::MAX)), + n => (8usize, n as usize), + }; + // A box that runs past the buffer cannot hold the target; this also keeps + // `pos + size` from overflowing. + if size < header_len || size > remaining { + return Err(anyhow!("invalid box size {size} at byte {pos}")); + } + if kind == b"uuid" && r.take(16).is_ok_and(|found| found == uuid) { + // Payload = box header, 16-byte type, 4 bytes of version and flags. + return Ok((pos, pos + header_len + 16 + 4)); + } + pos += size; + } + Err(anyhow!("no {} box in segment", hex::encode(uuid))) +} + +/// Parse segment 0 into the codec header and the segment length table. +pub fn parse_init(buf: &[u8]) -> Result { + let (_, payload) = find_uuid_box(buf, &INIT_UUID)?; + let mut r = Reader { buf, pos: payload }; + let _track_id = r.u32()?; + let _file_id = r.u32()?; + let _sample_rate = r.u32()?; + let _bits_per_sample = r.u8()?; + let _channels = r.take(3)?; + let _samples_count = r.be(6)?; + let header_len = r.u16()? as usize; + let mut header = r.take(header_len)?.to_vec(); + let key_id_len = r.u8()? as usize; + let _key_id = r.take(key_id_len)?; + let segment_count = r.u16()? as usize; + let mut segment_lengths = Vec::with_capacity(segment_count); + for _ in 0..segment_count { + segment_lengths.push(r.u32()?); + let _samples = r.u32()?; + } + mark_last_metadata_block(&mut header)?; + Ok(InitSegment { + header, + segment_lengths, + }) +} + +/// Set the last-metadata-block flag on the final block of a FLAC header. +fn mark_last_metadata_block(header: &mut [u8]) -> Result<()> { + if !header.starts_with(b"fLaC") { + return Ok(()); + } + let mut pos = 4usize; + loop { + let block = header + .get(pos..pos + 4) + .ok_or_else(|| anyhow!("FLAC header truncated at byte {pos}"))?; + if block[0] & 0x80 != 0 { + return Ok(()); + } + let len = ((block[1] as usize) << 16) | ((block[2] as usize) << 8) | block[3] as usize; + let next = pos + 4 + len; + if next >= header.len() { + header[pos] |= 0x80; + return Ok(()); + } + pos = next; + } +} + +/// Parse the frame table of an audio segment (segments 1 and later). +pub fn parse_segment(buf: &[u8]) -> Result { + let (box_start, payload) = find_uuid_box(buf, &SEGMENT_UUID)?; + let mut r = Reader { buf, pos: payload }; + let data_offset = r.u32()? as usize; + let iv_size = r.u8()? as usize; + let frame_count = r.be(3)? as usize; + let mut frames = Vec::with_capacity(frame_count.min(4096)); + for _ in 0..frame_count { + let len = r.u32()? as usize; + let _reserved = r.u16()?; + let flags = r.u16()?; + let iv = r.take(iv_size)?.to_vec(); + frames.push(FrameEntry { len, flags, iv }); + } + let table = SegmentTable { + frames_start: box_start + data_offset, + frames, + }; + if table.frames_end() > buf.len() { + return Err(anyhow!( + "frame table ends at byte {} but the segment holds {} bytes", + table.frames_end(), + buf.len() + )); + } + Ok(table) +} + +/// Decrypt every flagged frame in place; returns the byte range of all frames. +pub fn decrypt_frames( + buf: &mut [u8], + table: &SegmentTable, + key: &[u8; 16], +) -> Result> { + let mut pos = table.frames_start; + for (i, frame) in table.frames.iter().enumerate() { + let end = pos + frame.len; + let data = buf + .get_mut(pos..end) + .ok_or_else(|| anyhow!("frame {i} runs past the segment"))?; + if frame.flags != 0 { + decrypt_frame(key, &frame.iv, data)?; + } + pos = end; + } + Ok(table.frames_start..pos) +} + +/// Segment builders in the live byte layout, shared with the download tests. +#[cfg(test)] +pub(super) mod fixtures { + use super::*; + + pub fn mp4_box(kind: &[u8; 4], payload: &[u8]) -> Vec { + let mut out = ((payload.len() + 8) as u32).to_be_bytes().to_vec(); + out.extend_from_slice(kind); + out.extend_from_slice(payload); + out + } + + pub fn uuid_box(uuid: &[u8; 16], payload: &[u8]) -> Vec { + let mut body = uuid.to_vec(); + body.extend_from_slice(&[0, 0, 0, 0]); + body.extend_from_slice(payload); + mp4_box(b"uuid", &body) + } + + /// A 42-byte `fLaC` + STREAMINFO header without the last-block bit. + pub fn flac_header() -> Vec { + let mut h = b"fLaC".to_vec(); + h.extend_from_slice(&[0x00, 0, 0, 34]); + h.extend((0u8..34).map(|i| i.wrapping_mul(7))); + h + } + + pub fn init_payload(header: &[u8], lengths: &[u32]) -> Vec { + let mut p = Vec::new(); + p.extend_from_slice(&7u32.to_be_bytes()); // track_id + p.extend_from_slice(&9u32.to_be_bytes()); // file_id + p.extend_from_slice(&96_000u32.to_be_bytes()); + p.push(24); // bits + p.extend_from_slice(&[2, 0, 0]); // channels + padding + p.extend_from_slice(&1_234_567u64.to_be_bytes()[2..]); // 6-byte samples + p.extend_from_slice(&(header.len() as u16).to_be_bytes()); + p.extend_from_slice(header); + p.push(3); + p.extend_from_slice(b"kid"); + p.extend_from_slice(&(lengths.len() as u16).to_be_bytes()); + for (i, len) in lengths.iter().enumerate() { + p.extend_from_slice(&len.to_be_bytes()); + p.extend_from_slice(&(4096 * (i as u32 + 1)).to_be_bytes()); + } + p + } + + /// Segment 0: an `ftyp` box then the init `uuid` box. + pub fn init_segment(header: &[u8], lengths: &[u32]) -> Vec { + let mut seg = mp4_box(b"ftyp", b"cmfc"); + seg.extend(uuid_box(&INIT_UUID, &init_payload(header, lengths))); + seg + } + + /// An audio segment from `(plain bytes, flags, iv)` frames; flagged frames are + /// encrypted with `key`. Returns the segment and the expected plain output. + pub fn audio_segment(frames: &[(Vec, u16, [u8; 8])], key: &[u8; 16]) -> (Vec, Vec) { + let mut table = Vec::new(); + let mut data = Vec::new(); + let mut plain = Vec::new(); + for (bytes, flags, iv) in frames { + table.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); + table.extend_from_slice(&[0, 0]); + table.extend_from_slice(&flags.to_be_bytes()); + table.extend_from_slice(iv); + let mut on_wire = bytes.clone(); + if *flags != 0 { + decrypt_frame(key, iv, &mut on_wire).unwrap(); + } + data.extend_from_slice(&on_wire); + plain.extend_from_slice(bytes); + } + let mut payload = Vec::new(); + // uuid box = 8 header + 16 uuid + 4 flags + 8 fixed + table; mdat header = 8. + let uuid_len = 8 + 16 + 4 + 8 + table.len(); + payload.extend_from_slice(&((uuid_len + 8) as u32).to_be_bytes()); + payload.push(8); + payload.extend_from_slice(&(frames.len() as u32).to_be_bytes()[1..]); + payload.extend_from_slice(&table); + let mut seg = mp4_box(b"moof", b""); + seg.extend(uuid_box(&SEGMENT_UUID, &payload)); + seg.extend(mp4_box(b"mdat", &data)); + (seg, plain) + } +} + +#[cfg(test)] +mod tests { + use super::fixtures::*; + use super::*; + + fn init_segment(lengths: &[u32]) -> Vec { + fixtures::init_segment(&flac_header(), lengths) + } + + #[test] + fn init_parses_header_and_length_table() { + let init = parse_init(&init_segment(&[100, 200, 300])).unwrap(); + assert_eq!(init.header.len(), 42); + assert_eq!(&init.header[..4], b"fLaC"); + assert_eq!(init.header[4], 0x80, "last-metadata-block bit is set"); + assert_eq!(&init.header[5..], &flac_header()[5..]); + assert_eq!(init.segment_lengths, vec![100, 200, 300]); + assert_eq!(init.total_bytes(), 642); + } + + #[test] + fn oversized_boxes_are_errors_without_panic() { + // A largesize box (size32 == 1) claiming u64::MAX bytes. + let mut huge = 1u32.to_be_bytes().to_vec(); + huge.extend_from_slice(b"free"); + huge.extend_from_slice(&u64::MAX.to_be_bytes()); + huge.extend(init_segment(&[1])); + assert!(parse_init(&huge).is_err()); + // A plain box whose size runs past the buffer. + let mut long = 4096u32.to_be_bytes().to_vec(); + long.extend_from_slice(b"free"); + long.extend(init_segment(&[1])); + assert!(parse_init(&long).is_err()); + assert!(parse_segment(&huge).is_err()); + } + + #[test] + fn init_leaves_an_already_final_block_alone() { + let mut header = flac_header(); + header[4] = 0x80; + let mut seg = mp4_box(b"ftyp", b"cmfc"); + seg.extend(uuid_box(&INIT_UUID, &init_payload(&header, &[1]))); + assert_eq!(parse_init(&seg).unwrap().header, header); + } + + #[test] + fn init_marks_the_last_of_several_metadata_blocks() { + let mut header = flac_header(); + header.extend_from_slice(&[0x04, 0, 0, 2, 0xaa, 0xbb]); // VORBIS_COMMENT + let mut seg = mp4_box(b"ftyp", b"cmfc"); + seg.extend(uuid_box(&INIT_UUID, &init_payload(&header, &[1]))); + let parsed = parse_init(&seg).unwrap().header; + assert_eq!(parsed[4], 0x00); + assert_eq!(parsed[42], 0x84); + } + + #[test] + fn init_leaves_a_non_flac_header_untouched() { + let header = vec![0xff, 0xfb, 0x90, 0x00, 0x11, 0x22]; + let mut seg = mp4_box(b"ftyp", b"cmfc"); + seg.extend(uuid_box(&INIT_UUID, &init_payload(&header, &[1]))); + assert_eq!(parse_init(&seg).unwrap().header, header); + } + + #[test] + fn init_parses_a_300_entry_table() { + let lengths: Vec = (1..=300).collect(); + let init = parse_init(&init_segment(&lengths)).unwrap(); + assert_eq!(init.segment_lengths, lengths); + } + + #[test] + fn truncated_init_returns_an_error_without_panic() { + let full = init_segment(&[100, 200, 300]); + for cut in 0..full.len() { + assert!(parse_init(&full[..cut]).is_err(), "prefix of {cut} bytes"); + } + } + + #[test] + fn init_without_the_uuid_box_is_an_error() { + let seg = mp4_box(b"ftyp", b"cmfc"); + assert!(parse_init(&seg).is_err()); + } + + const KEY: [u8; 16] = [0x5a; 16]; + + #[test] + fn segment_decrypts_flagged_frames_and_passes_others_through() { + let frames = vec![ + ((0u8..40).collect(), 1u16, [1u8, 2, 3, 4, 5, 6, 7, 8]), + ((100u8..130).collect(), 0u16, [0u8; 8]), + ]; + let (mut seg, plain) = audio_segment(&frames, &KEY); + let table = parse_segment(&seg).unwrap(); + assert_eq!(table.frames.len(), 2); + assert_eq!(table.frames[0].flags, 1); + assert_eq!(table.frames[0].iv, vec![1, 2, 3, 4, 5, 6, 7, 8]); + assert_eq!(table.frames[1].len, 30); + assert_ne!( + &seg[table.frames_start..table.frames_start + 40], + &plain[..40] + ); + let range = decrypt_frames(&mut seg, &table, &KEY).unwrap(); + assert_eq!(&seg[range], &plain[..]); + } + + #[test] + fn truncated_segment_returns_an_error_without_panic() { + let frames = vec![((0u8..40).collect(), 1u16, [9u8; 8])]; + let (full, _) = audio_segment(&frames, &KEY); + for cut in 0..full.len() { + assert!( + parse_segment(&full[..cut]).is_err(), + "prefix of {cut} bytes" + ); + } + } + + #[test] + fn find_uuid_box_skips_a_box_with_a_different_uuid() { + let other = [0x99u8; 16]; + let mut seg = uuid_box(&other, &[0; 8]); + seg.extend(uuid_box(&INIT_UUID, &init_payload(&flac_header(), &[5]))); + assert_eq!(parse_init(&seg).unwrap().segment_lengths, vec![5]); + } +} diff --git a/src/infra/qobuz/stream/crypto.rs b/src/infra/qobuz/stream/crypto.rs new file mode 100644 index 00000000..3988fa73 --- /dev/null +++ b/src/infra/qobuz/stream/crypto.rs @@ -0,0 +1,127 @@ +//! Key derivation and frame decryption for the Qobuz stream, pure over byte slices. +//! +//! Session key = HKDF-SHA256(ikm = the 16 raw bytes of the hex secret, salt and +//! info from `session/start`), expanded to 16 bytes. Content key = AES-128-CBC +//! (PKCS7) decrypt of the wrapped key with the session key. Frames are +//! AES-128-CTR with a 64-bit big-endian counter and a nonce of the frame IV +//! followed by zero bytes. + +use aes::cipher::block_padding::Pkcs7; +use aes::cipher::{BlockDecryptMut, KeyIvInit, StreamCipher}; +use anyhow::{anyhow, Result}; +use hkdf::Hkdf; +use sha2::Sha256; + +type CbcDecryptor = cbc::Decryptor; +type FrameCipher = ctr::Ctr64BE; + +/// Derive the 16-byte session key from the hex secret and the `infos` parts. +pub fn session_key(secret_hex: &str, salt: &[u8], info: &[u8]) -> Result<[u8; 16]> { + let ikm = hex::decode(secret_hex).map_err(|e| anyhow!("Qobuz secret is not hex: {e}"))?; + hkdf_16(&ikm, salt, info) +} + +fn hkdf_16(ikm: &[u8], salt: &[u8], info: &[u8]) -> Result<[u8; 16]> { + let hk = Hkdf::::new(Some(salt), ikm); + let mut okm = [0u8; 16]; + hk.expand(info, &mut okm) + .map_err(|_| anyhow!("HKDF expand failed"))?; + Ok(okm) +} + +/// Unwrap the 16-byte content key (AES-128-CBC, PKCS7) with the session key. +pub fn unwrap_content_key(session_key: &[u8; 16], wrapped: &[u8], iv: &[u8]) -> Result<[u8; 16]> { + let iv: &[u8; 16] = iv + .try_into() + .map_err(|_| anyhow!("content key IV is {} bytes, expected 16", iv.len()))?; + let mut buf = wrapped.to_vec(); + let plain = CbcDecryptor::new(session_key.into(), iv.into()) + .decrypt_padded_mut::(&mut buf) + .map_err(|e| anyhow!("content key unwrap failed: {e}"))?; + plain + .try_into() + .map_err(|_| anyhow!("content key is {} bytes, expected 16", plain.len())) +} + +/// Decrypt one frame in place with AES-128-CTR; `frame_iv` fills the nonce head. +pub fn decrypt_frame(content_key: &[u8; 16], frame_iv: &[u8], frame: &mut [u8]) -> Result<()> { + if frame_iv.len() > 16 { + return Err(anyhow!( + "frame IV is {} bytes, expected at most 16", + frame_iv.len() + )); + } + let mut nonce = [0u8; 16]; + nonce[..frame_iv.len()].copy_from_slice(frame_iv); + FrameCipher::new(content_key.into(), (&nonce).into()).apply_keystream(frame); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use aes::cipher::BlockEncryptMut; + + type CbcEncryptor = cbc::Encryptor; + + #[test] + fn hkdf_matches_rfc_5869_test_case_1() { + let ikm = [0x0bu8; 22]; + let salt: Vec = (0u8..=0x0c).collect(); + let info: Vec = (0xf0u8..=0xf9).collect(); + let okm = hkdf_16(&ikm, &salt, &info).unwrap(); + assert_eq!(hex::encode(okm), "3cb25f25faacd57a90434f64d0362f2a"); + } + + #[test] + fn session_key_decodes_the_hex_secret_as_ikm() { + let key = session_key("000102030405060708090a0b0c0d0e0f", b"salt", b"info").unwrap(); + assert_eq!(hex::encode(key), "f83a387899f405fb64e48ee655b78972"); + } + + #[test] + fn session_key_rejects_a_non_hex_secret() { + assert!(session_key("not hex at all", b"salt", b"info").is_err()); + } + + #[test] + fn unwrap_content_key_inverts_cbc_pkcs7_encryption() { + let session = [0x11u8; 16]; + let iv = [0x22u8; 16]; + let content = [0x33u8; 16]; + let mut buf = [0u8; 32]; + buf[..16].copy_from_slice(&content); + let wrapped = CbcEncryptor::new((&session).into(), (&iv).into()) + .encrypt_padded_mut::(&mut buf, 16) + .unwrap() + .to_vec(); + assert_eq!(wrapped.len(), 32); + assert_eq!( + unwrap_content_key(&session, &wrapped, &iv).unwrap(), + content + ); + } + + #[test] + fn unwrap_content_key_rejects_a_short_iv() { + assert!(unwrap_content_key(&[0u8; 16], &[0u8; 32], &[0u8; 8]).is_err()); + } + + #[test] + fn decrypt_frame_is_an_involution_with_an_8_byte_iv() { + let key = [0x44u8; 16]; + let iv = [1u8, 2, 3, 4, 5, 6, 7, 8]; + let plain: Vec = (0u8..100).collect(); + let mut frame = plain.clone(); + decrypt_frame(&key, &iv, &mut frame).unwrap(); + assert_ne!(frame, plain); + decrypt_frame(&key, &iv, &mut frame).unwrap(); + assert_eq!(frame, plain); + } + + #[test] + fn decrypt_frame_rejects_an_oversized_iv() { + let mut frame = [0u8; 4]; + assert!(decrypt_frame(&[0u8; 16], &[0u8; 17], &mut frame).is_err()); + } +} diff --git a/src/infra/qobuz/stream/download.rs b/src/infra/qobuz/stream/download.rs new file mode 100644 index 00000000..c4a394d1 --- /dev/null +++ b/src/infra/qobuz/stream/download.rs @@ -0,0 +1,201 @@ +//! Segment download: fetch, decrypt, and write the track as segments arrive. +//! +//! Two phases so the caller can read the total size before the long part: +//! [`begin`] fetches segment 0 and writes the codec header, then +//! [`TrackDownload::finish`] streams every audio segment into the same file. + +use std::fs::File; +use std::io::Write; +use std::path::Path; + +use anyhow::{anyhow, Context, Result}; +use reqwest::Client; + +use super::cmaf::{self, InitSegment}; + +const SEGMENT_PLACEHOLDER: &str = "$SEGMENT$"; + +/// A track download after segment 0 was parsed and the header was written. +pub struct TrackDownload { + http: Client, + url_template: String, + content_key: [u8; 16], + init: InitSegment, + file: File, +} + +/// Fetch and parse segment 0 and write the codec header to `dest`. +pub async fn begin( + http: &Client, + url_template: &str, + content_key: [u8; 16], + dest: &Path, +) -> Result { + if !url_template.contains(SEGMENT_PLACEHOLDER) { + return Err(anyhow!("stream URL template has no {SEGMENT_PLACEHOLDER}")); + } + let bytes = fetch_segment(http, url_template, 0).await?; + let init = cmaf::parse_init(&bytes).context("segment 0 parse")?; + let mut file = + File::create(dest).with_context(|| format!("creating stream file {}", dest.display()))?; + file + .write_all(&init.header) + .with_context(|| format!("writing stream to {}", dest.display()))?; + Ok(TrackDownload { + http: http.clone(), + url_template: url_template.to_string(), + content_key, + init, + file, + }) +} + +impl TrackDownload { + /// The size of the finished file, from the init table. + pub fn total_bytes(&self) -> u64 { + self.init.total_bytes() + } + + /// Fetch, decrypt and append every audio segment, then flush the file. + pub async fn finish(mut self) -> Result<()> { + let count = self.init.segment_lengths.len() as u32; + for index in 1..=count { + let mut bytes = fetch_segment(&self.http, &self.url_template, index).await?; + let table = cmaf::parse_segment(&bytes).with_context(|| format!("segment {index} parse"))?; + let range = cmaf::decrypt_frames(&mut bytes, &table, &self.content_key) + .with_context(|| format!("segment {index} decrypt"))?; + self + .file + .write_all(&bytes[range]) + .with_context(|| format!("segment {index} write"))?; + } + self.file.flush().context("flushing stream file") + } +} + +fn segment_url(template: &str, index: u32) -> String { + template.replace(SEGMENT_PLACEHOLDER, &index.to_string()) +} + +async fn fetch_segment(http: &Client, template: &str, index: u32) -> Result> { + let response = http + .get(segment_url(template, index)) + .send() + .await + .with_context(|| format!("segment {index} request"))?; + let status = response.status(); + if !status.is_success() { + return Err(anyhow!("segment {index} returned HTTP {status}")); + } + Ok( + response + .bytes() + .await + .with_context(|| format!("segment {index} body"))? + .to_vec(), + ) +} + +#[cfg(test)] +mod tests { + use super::super::cmaf::fixtures; + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + const KEY: [u8; 16] = [0x5a; 16]; + + /// Serve `segments[i]` at `/seg/i`; returns the URL template. + async fn serve(segments: Vec>) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + let mut buf = vec![0u8; 2048]; + let n = stream.read(&mut buf).await.unwrap_or(0); + let request = String::from_utf8_lossy(&buf[..n]); + let path = request.split_whitespace().nth(1).unwrap_or(""); + let index: usize = path + .rsplit('/') + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(usize::MAX); + let (status, body) = match segments.get(index) { + Some(b) => ("200 OK", b.clone()), + None => ("404 Not Found", Vec::new()), + }; + let head = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(head.as_bytes()).await; + let _ = stream.write_all(&body).await; + let _ = stream.shutdown().await; + } + }); + format!("http://127.0.0.1:{port}/seg/{SEGMENT_PLACEHOLDER}") + } + + fn fixture_track() -> (Vec>, Vec) { + let (seg1, plain1) = fixtures::audio_segment( + &[ + ((0u8..40).collect(), 1, [1, 2, 3, 4, 5, 6, 7, 8]), + ((100u8..130).collect(), 0, [0; 8]), + ], + &KEY, + ); + let (seg2, plain2) = fixtures::audio_segment(&[((200u8..220).collect(), 1, [9; 8])], &KEY); + let init = fixtures::init_segment(&fixtures::flac_header(), &[70, 20]); + let mut expected = fixtures::flac_header(); + expected[4] |= 0x80; + expected.extend(plain1); + expected.extend(plain2); + (vec![init, seg1, seg2], expected) + } + + #[tokio::test] + async fn download_rebuilds_header_and_decrypted_frames() { + let (segments, expected) = fixture_track(); + let template = serve(segments).await; + let tmp = tempfile::NamedTempFile::new().unwrap(); + let download = begin(&Client::new(), &template, KEY, tmp.path()) + .await + .unwrap(); + assert_eq!(download.total_bytes(), 42 + 70 + 20); + download.finish().await.unwrap(); + assert_eq!(std::fs::read(tmp.path()).unwrap(), expected); + } + + #[tokio::test] + async fn missing_segment_fails_the_download() { + let (mut segments, _) = fixture_track(); + segments.pop(); + let template = serve(segments).await; + let tmp = tempfile::NamedTempFile::new().unwrap(); + let download = begin(&Client::new(), &template, KEY, tmp.path()) + .await + .unwrap(); + let err = download.finish().await.unwrap_err().to_string(); + assert!(err.contains("segment 2"), "{err}"); + } + + #[tokio::test] + async fn template_without_placeholder_is_rejected() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + assert!( + begin(&Client::new(), "http://127.0.0.1:1/x", KEY, tmp.path()) + .await + .is_err() + ); + } + + #[test] + fn segment_url_substitutes_the_index() { + assert_eq!( + segment_url("https://h/t/$SEGMENT$.m4s?x=1", 12), + "https://h/t/12.m4s?x=1" + ); + } +} diff --git a/src/infra/qobuz/stream/mod.rs b/src/infra/qobuz/stream/mod.rs new file mode 100644 index 00000000..1520ca38 --- /dev/null +++ b/src/infra/qobuz/stream/mod.rs @@ -0,0 +1,5 @@ +//! The encrypted CMAF transport: pure key and box parsers plus the downloader. + +pub mod cmaf; +pub mod crypto; +pub mod download; diff --git a/src/infra/qobuz/types.rs b/src/infra/qobuz/types.rs new file mode 100644 index 00000000..680e95f9 --- /dev/null +++ b/src/infra/qobuz/types.rs @@ -0,0 +1,162 @@ +//! Serde types for the Qobuz API responses, private to the module. + +use serde::{Deserialize, Deserializer}; + +/// A Qobuz id: integers for tracks, playlists and artists, strings for albums. +fn de_id<'de, D: Deserializer<'de>>(d: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum RawId { + Num(u64), + Str(String), + } + Ok(match RawId::deserialize(d)? { + RawId::Num(n) => n.to_string(), + RawId::Str(s) => s, + }) +} + +fn de_opt_id<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + #[derive(Deserialize)] + struct Wrap(#[serde(deserialize_with = "de_id")] String); + Ok(Option::::deserialize(d)?.map(|w| w.0)) +} + +fn default_true() -> bool { + true +} + +/// `session/start`. +#[derive(Debug, Deserialize)] +pub struct SessionStart { + pub session_id: String, + #[serde(default)] + pub expires_at: u64, + /// `"."`, both base64url. + pub infos: String, +} + +/// `file/url` with `intent=stream`; the segment count comes from the init table. +#[derive(Debug, Deserialize)] +pub struct FileUrl { + pub url_template: String, + /// `".."`, base64url. + pub key: String, +} + +/// `oauth/callback`. +#[derive(Debug, Deserialize)] +pub struct OauthCallback { + pub token: Option, + pub user_auth_token: Option, + #[serde(default, deserialize_with = "de_opt_id")] + pub user_id: Option, + pub user: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UserRef { + #[serde(default, deserialize_with = "de_opt_id")] + pub id: Option, +} + +/// One page of a paginated list: `{ offset, limit, total, items }`. +#[derive(Debug, Deserialize)] +pub struct Page { + #[serde(default)] + pub total: u32, + #[serde(default = "Vec::new")] + pub items: Vec, +} + +impl Default for Page { + fn default() -> Self { + Page { + total: 0, + items: Vec::new(), + } + } +} + +#[derive(Debug, Deserialize)] +pub struct Named { + #[serde(default, deserialize_with = "de_opt_id")] + pub id: Option, + #[serde(default)] + pub name: String, +} + +#[derive(Debug, Default, Deserialize)] +pub struct Image { + pub large: Option, +} + +#[derive(Debug, Deserialize)] +pub struct Album { + #[serde(deserialize_with = "de_id")] + pub id: String, + #[serde(default)] + pub title: String, + pub artist: Option, + #[serde(default)] + pub image: Image, + #[serde(default)] + pub tracks_count: u32, + pub tracks: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct Track { + #[serde(deserialize_with = "de_id")] + pub id: String, + #[serde(default)] + pub title: String, + pub version: Option, + /// Seconds. + #[serde(default)] + pub duration: u64, + #[serde(default)] + pub track_number: u32, + pub performer: Option, + /// Absent inside `album/get`, where the parent album is the context. + pub album: Option, + #[serde(default = "default_true")] + pub streamable: bool, + #[serde(default)] + pub parental_warning: bool, +} + +#[derive(Debug, Deserialize)] +pub struct Playlist { + #[serde(deserialize_with = "de_id")] + pub id: String, + #[serde(default)] + pub name: String, + pub owner: Option, + #[serde(default)] + pub tracks_count: u32, + pub is_public: Option, + #[serde(default)] + pub images300: Vec, + pub tracks: Option>, +} + +/// `playlist/getUserPlaylists`. +#[derive(Debug, Deserialize)] +pub struct UserPlaylists { + #[serde(default)] + pub playlists: Page, +} + +/// `favorite/getUserFavorites`. +#[derive(Debug, Deserialize)] +pub struct Favorites { + pub tracks: Option>, + pub albums: Option>, +} + +/// `catalog/search`. +#[derive(Debug, Deserialize)] +pub struct Search { + pub tracks: Option>, +} diff --git a/src/infra/queue/dispatch.rs b/src/infra/queue/dispatch.rs index ee4ad784..a549ad33 100644 --- a/src/infra/queue/dispatch.rs +++ b/src/infra/queue/dispatch.rs @@ -19,6 +19,7 @@ use crate::core::plugin_api::TrackInfo; #[cfg(any( feature = "local-files", feature = "subsonic", + feature = "qobuz", feature = "youtube", feature = "streaming" ))] @@ -28,9 +29,19 @@ use crate::infra::network::IoEvent; // The decoded queue slot exists only for the sources that own a finite track // list; internet radio enables `audio-decode` but is never queueable. -#[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))] +#[cfg(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" +))] use crate::infra::audio::LocalPlayer; -#[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))] +#[cfg(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" +))] use std::time::Duration; /// Intercept queue-owned events before the per-source dispatchers. @@ -56,7 +67,12 @@ pub async fn route_queue_event(app: &Arc>, event: &IoEvent) -> bool { // Transport for the queue slot's own player (Pause / Seek / Volume / Next / // bare-resume). Only meaningful when a decoded queued track owns the sink; // compiles out entirely without a queueable decoded source. - #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))] + #[cfg(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" + ))] if let Some(handled) = route_queue_transport(app, event).await { return handled; } @@ -80,7 +96,12 @@ pub async fn route_queue_event(app: &Arc>, event: &IoEvent) -> bool { /// Transport controls for the queue slot's player, when a decoded queued track /// owns the sink. Returns `Some(true)` when consumed, `None` when this event is /// not a queue-slot transport control (so the caller falls through). -#[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))] +#[cfg(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" +))] async fn route_queue_transport(app: &Arc>, event: &IoEvent) -> Option { let player = { let guard = app.lock().await; @@ -171,7 +192,12 @@ async fn route_spotify_queue_transport(app: &Arc>, event: &IoEvent) - /// Drop the queue slot (stopping its player) and forget any suspended context, /// but keep the queued items. Called when the user starts an unrelated playback. async fn clear_queue_playback(app: &Arc>) { - #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))] + #[cfg(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" + ))] { let player = { let mut guard = app.lock().await; @@ -184,7 +210,12 @@ async fn clear_queue_playback(app: &Arc>) { } #[cfg(all( feature = "streaming", - not(any(feature = "local-files", feature = "subsonic", feature = "youtube")) + not(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" + )) ))] { let mut guard = app.lock().await; @@ -198,6 +229,7 @@ async fn clear_queue_playback(app: &Arc>) { feature = "streaming", feature = "local-files", feature = "subsonic", + feature = "qobuz", feature = "youtube" )))] { @@ -258,6 +290,8 @@ async fn try_play_queued(app: &Arc>, track: &TrackInfo) -> bool { QueueItemSource::LocalFile => play_queued_local(app, track, &uri).await, #[cfg(feature = "subsonic")] QueueItemSource::Subsonic => play_queued_subsonic(app, track, &uri).await, + #[cfg(feature = "qobuz")] + QueueItemSource::Qobuz => play_queued_qobuz(app, track, &uri).await, #[cfg(feature = "youtube")] QueueItemSource::YouTube => play_queued_youtube(app, track, &uri).await, #[cfg(feature = "streaming")] @@ -326,6 +360,28 @@ async fn play_queued_subsonic(app: &Arc>, track: &TrackInfo, uri: &st true } +#[cfg(feature = "qobuz")] +async fn play_queued_qobuz(app: &Arc>, track: &TrackInfo, uri: &str) -> bool { + release_librespot(app).await; + let Some(source) = crate::infra::qobuz::dispatch::build_playback_source(app).await else { + return false; // build_playback_source surfaced its own status + }; + let Some(player) = acquire_queue_player(app).await else { + return false; + }; + let fetch_id = publish_pending_decoded(app, &player, track).await; + let quality = app.lock().await.user_config.behavior.qobuz_quality; + // Fetch off the IoEvent pump, like Subsonic: a Qobuz track is a long download. + let app = Arc::clone(app); + let uri = uri.to_string(); + let name = track.name.clone(); + tokio::spawn(async move { + let result = crate::infra::qobuz::dispatch::download_for_queue(&source, &uri, quality).await; + finish_decoded_fetch(&app, fetch_id, result, &name).await; + }); + true +} + #[cfg(feature = "youtube")] async fn play_queued_youtube(app: &Arc>, track: &TrackInfo, uri: &str) -> bool { release_librespot(app).await; @@ -374,7 +430,12 @@ async fn play_queued_spotify(app: &Arc>, track: &TrackInfo, uri: &str // its player for resume) is paused — resume reloads its sink either way. // Both lookups only ever see the queueable sources (radio is torn down at // suspension rather than kept for reuse), so this compiles out without them. - #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))] + #[cfg(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" + ))] { if let Some(p) = { app.lock().await.take_queue_now_decoded_player() } { p.stop(); @@ -479,10 +540,20 @@ fn preload_next_queued_spotify(app: &App) { } /// Monotonic source for [`DecodedQueuePlayback::fetch_id`] stamps. -#[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))] +#[cfg(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" +))] static QUEUE_FETCH_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); -#[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))] +#[cfg(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" +))] fn next_fetch_id() -> u64 { QUEUE_FETCH_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) } @@ -496,7 +567,12 @@ fn next_fetch_id() -> u64 { /// which re-suspended the context and dispatched a second advance — dropping /// one queued item on the floor. Returns the slot's fetch stamp, which a /// background fetch passes back to [`finish_decoded_fetch`]. -#[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))] +#[cfg(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" +))] async fn publish_pending_decoded( app: &Arc>, player: &Arc, @@ -510,7 +586,7 @@ async fn publish_pending_decoded( track: track.clone(), advancing: true, fetch_id, - #[cfg(any(feature = "subsonic", feature = "youtube"))] + #[cfg(any(feature = "subsonic", feature = "qobuz", feature = "youtube"))] tempfile: None, })); fetch_id @@ -522,7 +598,7 @@ async fn publish_pending_decoded( /// decode failure the queue advances past the item, exactly like the old /// inline path. Play + finalize happen under one `App` lock so a concurrent /// advance (which pops under the same lock) can never interleave. -#[cfg(any(feature = "subsonic", feature = "youtube"))] +#[cfg(any(feature = "subsonic", feature = "qobuz", feature = "youtube"))] async fn finish_decoded_fetch( app: &Arc>, fetch_id: u64, @@ -572,8 +648,12 @@ async fn publish_decoded( app: &Arc>, player: Arc, track: TrackInfo, - #[cfg(any(feature = "subsonic", feature = "youtube"))] tempfile: Option, - #[cfg(not(any(feature = "subsonic", feature = "youtube")))] _tempfile: Option<()>, + #[cfg(any(feature = "subsonic", feature = "qobuz", feature = "youtube"))] tempfile: Option< + tempfile::NamedTempFile, + >, + #[cfg(not(any(feature = "subsonic", feature = "qobuz", feature = "youtube")))] _tempfile: Option< + (), + >, ) { use crate::infra::queue::{DecodedQueuePlayback, QueueNowPlaying}; let name = track.name.clone(); @@ -583,7 +663,7 @@ async fn publish_decoded( track, advancing: false, fetch_id: next_fetch_id(), - #[cfg(any(feature = "subsonic", feature = "youtube"))] + #[cfg(any(feature = "subsonic", feature = "qobuz", feature = "youtube"))] tempfile, })); guard.set_status_message(format!("\u{266a} {name} (queue)"), 4); @@ -600,7 +680,12 @@ async fn publish_decoded( /// fresh-device path: the outgoing queue slot can be a still-playing Spotify /// track (mid-track skip / Enter-jump), and on the reuse paths nothing else /// silences it — it would keep playing under the whole download window. -#[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))] +#[cfg(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" +))] async fn acquire_queue_player(app: &Arc>) -> Option> { if let Some(p) = { let guard = app.lock().await; @@ -631,7 +716,12 @@ async fn acquire_queue_player(app: &Arc>) -> Option> /// That exclusion is exactly why this is gated on the three queueable sources /// rather than `audio-decode` — under radio alone every arm below is cfg'd out /// and the function is unreachable. -#[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))] +#[cfg(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" +))] async fn suspended_context_player(app: &Arc>) -> Option> { let guard = app.lock().await; #[cfg(feature = "local-files")] @@ -642,6 +732,10 @@ async fn suspended_context_player(app: &Arc>) -> Option>) -> Option>) { #[cfg(feature = "streaming")] { @@ -687,6 +786,7 @@ async fn resume_or_finish(app: &Arc>) { feature = "streaming", feature = "local-files", feature = "subsonic", + feature = "qobuz", feature = "youtube", feature = "internet-radio" ))] @@ -713,11 +813,21 @@ async fn resume_or_finish(app: &Arc>) { } // Take the queue slot's player so we can decide whether to stop it. - #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))] + #[cfg(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" + ))] let queue_player = { app.lock().await.take_queue_now_decoded_player() }; #[cfg(all( feature = "streaming", - not(any(feature = "local-files", feature = "subsonic", feature = "youtube")) + not(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" + )) ))] { app.lock().await.queue_now = None; @@ -727,7 +837,12 @@ async fn resume_or_finish(app: &Arc>) { None => { // Nothing was suspended: the queue was playing over an idle app (or a // context finished before the queue started). Stop the slot and note it. - #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))] + #[cfg(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" + ))] if let Some(player) = queue_player { player.stop(); app @@ -746,6 +861,11 @@ async fn resume_or_finish(app: &Arc>) { resume_index, resume_position_ms, }) => resume_subsonic(app, resume_index, resume_position_ms, queue_player).await, + #[cfg(feature = "qobuz")] + Some(SuspendedContext::Qobuz { + resume_index, + resume_position_ms, + }) => resume_qobuz(app, resume_index, resume_position_ms, queue_player).await, #[cfg(feature = "youtube")] Some(SuspendedContext::YouTube { resume_index, @@ -755,7 +875,12 @@ async fn resume_or_finish(app: &Arc>) { Some(SuspendedContext::Radio { station }) => { // Radio uses its own fresh player, so always stop the queue slot. A // radio-only build has no queueable source, hence no slot to stop. - #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))] + #[cfg(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" + ))] if let Some(player) = queue_player { player.stop(); } @@ -775,7 +900,12 @@ async fn resume_or_finish(app: &Arc>) { // The network handler reloads the session's app-owned track list at the // resume index — same order, no refetch, no reshuffle. Stop the decoded // queue slot if one exists. - #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))] + #[cfg(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" + ))] if let Some(player) = queue_player { player.stop(); } @@ -794,7 +924,12 @@ async fn resume_or_finish(app: &Arc>) { }) => { // The network handler re-loads the Spotify context (offset by the resume // track) on the native device. Stop the decoded queue slot if one exists. - #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))] + #[cfg(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" + ))] if let Some(player) = queue_player { player.stop(); } @@ -808,7 +943,12 @@ async fn resume_or_finish(app: &Arc>) { #[allow(unreachable_patterns)] _ => { - #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))] + #[cfg(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" + ))] if let Some(player) = queue_player { player.stop(); } @@ -922,6 +1062,57 @@ async fn resume_subsonic( } } +#[cfg(feature = "qobuz")] +async fn resume_qobuz( + app: &Arc>, + resume_index: Option, + resume_position_ms: u64, + queue_player: Option>, +) { + let Some(index) = resume_index else { + if let Some(s) = app.lock().await.qobuz_playback.take() { + s.player.stop(); + } + if let Some(player) = queue_player { + player.stop(); + } + return; + }; + // Same track with its tempfile still on disk: replay it and seek, with no + // second download. Otherwise re-download the target through play_index. + let replay = { + let mut guard = app.lock().await; + match guard.qobuz_playback.as_mut() { + Some(s) if index == s.index && s.tempfile.is_some() => { + s.advancing = true; + s.tempfile + .as_ref() + .map(|t| (Arc::clone(&s.player), t.path().to_path_buf())) + } + Some(_) => None, + None => return, + } + }; + // The queue slot shares the context player, so it is never stopped here. + let _ = queue_player; + match replay { + Some((player, path)) => { + let decode_player = Arc::clone(&player); + let ok = tokio::task::spawn_blocking(move || decode_player.play_file(&path)) + .await + .map(|r| r.is_ok()) + .unwrap_or(false); + if let Some(s) = app.lock().await.qobuz_playback.as_mut() { + s.advancing = false; + } + if ok && resume_position_ms > 0 { + let _ = player.seek(Duration::from_millis(resume_position_ms)); + } + } + None => crate::infra::qobuz::dispatch::play_index(app, index).await, + } +} + #[cfg(feature = "youtube")] async fn resume_youtube( app: &Arc>, @@ -1031,6 +1222,30 @@ mod tests { /// with an actionable status message — never panic, never stall the queue. /// In the slim CI build every alternative source is unavailable, so a /// `subsonic:` item exercises exactly that path. + #[cfg(not(feature = "qobuz"))] + #[tokio::test] + async fn advance_skips_unavailable_qobuz_item_without_panicking() { + let app = test_app(); + app + .lock() + .await + .native_queue + .push(track("qobuz:track:1", "Unplayable")); + + assert!(route_queue_event(&app, &IoEvent::AdvanceNativeQueue).await); + + let guard = app.lock().await; + assert!(guard.native_queue.is_empty(), "the item is consumed"); + assert!( + guard + .status_message + .as_deref() + .is_some_and(|m| m.contains("Qobuz playback isn't available in this build")), + "expected an unavailable-source message, got {:?}", + guard.status_message + ); + } + #[cfg(not(feature = "subsonic"))] #[tokio::test] async fn advance_skips_unavailable_source_without_panicking() { diff --git a/src/infra/queue/mod.rs b/src/infra/queue/mod.rs index d2dbbe54..92db72f8 100644 --- a/src/infra/queue/mod.rs +++ b/src/infra/queue/mod.rs @@ -339,7 +339,12 @@ fn restore_in_place(items: &mut Vec, backup: &ShuffleBackup, current: usiz /// Gated on the *queueable* decoded sources rather than `audio-decode`: replay /// is the repeat-one path of a finite track list. Internet radio decodes audio /// too, but a live stream has no track to re-decode. -#[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))] +#[cfg(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" +))] pub async fn replay_file( player: std::sync::Arc, path: std::path::PathBuf, @@ -358,7 +363,12 @@ pub async fn replay_file( /// [`dispatch::try_play_queued`] can play. Internet radio pulls `audio-decode` /// in as well, but a live stream is never a queue item, so a radio-only build /// can never construct this. -#[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))] +#[cfg(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" +))] pub struct DecodedQueuePlayback { /// The output-device sink. Shared (`Arc::ptr_eq`) with the suspended context's /// player when there is one, so no second device is opened. @@ -375,13 +385,16 @@ pub struct DecodedQueuePlayback { /// one, and the stale result is silently discarded. Only *read* by the /// Subsonic/YouTube fetch-completion path, so a build with neither (e.g. a /// local-files-only build) writes it without reading it. - #[cfg_attr(not(any(feature = "subsonic", feature = "youtube")), allow(dead_code))] + #[cfg_attr( + not(any(feature = "subsonic", feature = "qobuz", feature = "youtube")), + allow(dead_code) + )] pub fetch_id: u64, /// The tempfile backing a downloaded track (Subsonic / YouTube). `None` for a /// local file, which is played straight from disk. Held purely to keep the /// file alive on disk for the duration of playback (dropped with the slot), so /// it is never read back. - #[cfg(any(feature = "subsonic", feature = "youtube"))] + #[cfg(any(feature = "subsonic", feature = "qobuz", feature = "youtube"))] #[allow(dead_code)] pub tempfile: Option, } @@ -399,10 +412,16 @@ pub struct DecodedQueuePlayback { feature = "streaming", feature = "local-files", feature = "subsonic", + feature = "qobuz", feature = "youtube" ))] pub enum QueueNowPlaying { - #[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))] + #[cfg(any( + feature = "local-files", + feature = "subsonic", + feature = "qobuz", + feature = "youtube" + ))] Decoded(DecodedQueuePlayback), /// A Spotify track playing via native streaming (`player.load`, no Spirc /// context). diff --git a/src/infra/radio/dispatch.rs b/src/infra/radio/dispatch.rs index 39ebc0f7..06321fa2 100644 --- a/src/infra/radio/dispatch.rs +++ b/src/infra/radio/dispatch.rs @@ -229,6 +229,14 @@ async fn release_other_backends(_app: &Arc>) { subsonic.player.stop(); } } + // Tear down any Qobuz session. + #[cfg(feature = "qobuz")] + { + let qobuz = _app.lock().await.qobuz_playback.take(); + if let Some(qobuz) = qobuz { + qobuz.player.stop(); + } + } // Tear down any YouTube session (the pump's short-circuit means the YouTube // dispatch never sees this radio: start). #[cfg(feature = "youtube")] diff --git a/src/infra/redirect_uri.rs b/src/infra/redirect_uri.rs index 88675a4c..99a8571f 100644 --- a/src/infra/redirect_uri.rs +++ b/src/infra/redirect_uri.rs @@ -1,17 +1,16 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; /// Acceptance logic for the callback server: given a raw HTTP request, return -/// the full callback URL when it carries an OAuth `code=` query parameter, -/// else `None` (browser noise like /favicon.ico, pre-flight requests, or -/// malformed input). -fn extract_callback_url(request: &str) -> Option { +/// the full callback URL when `accept` approves its path, else `None` +/// (browser noise like /favicon.ico, pre-flight requests, or malformed input). +fn extract_callback_url(request: &str, accept: fn(&str) -> bool) -> Option { let split: Vec<&str> = request.split_whitespace().collect(); if split.len() <= 1 { return None; } // The path is the second whitespace-separated token, e.g. "/callback?code=...". let path = split[1]; - if !path.contains("code=") { + if !accept(path) { return None; } @@ -25,6 +24,11 @@ fn extract_callback_url(request: &str) -> Option { Some(format!("http://{}{}", host, path)) } +/// The Spotify callback carries an OAuth `code=` query parameter. +fn spotify_callback(path: &str) -> bool { + path.contains("code=") +} + /// OAuth callback server, used by both the pre-TUI startup login and the /// in-TUI login flow: it never blocks the caller's thread (the old blocking /// variant parked a tokio worker in a std accept() loop with no timeout and @@ -36,12 +40,24 @@ pub async fn redirect_uri_web_server_async(port: u16) -> Result { let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{}", port)) .await .map_err(|e| log::warn!("[login] failed to bind callback server on port {port}: {e}"))?; - run_accept_loop(listener).await + run_accept_loop(listener, spotify_callback).await +} + +/// The callback server on a pre-bound listener, for callbacks `accept` approves. +#[cfg_attr(not(feature = "qobuz"), allow(dead_code))] +pub async fn redirect_uri_web_server_on( + listener: tokio::net::TcpListener, + accept: fn(&str) -> bool, +) -> Result { + run_accept_loop(listener, accept).await } /// Accept-loop extracted so tests can inject a pre-bound listener (port 0) and /// avoid races caused by hard-coding a port that might already be in use. -async fn run_accept_loop(listener: tokio::net::TcpListener) -> Result { +async fn run_accept_loop( + listener: tokio::net::TcpListener, + accept: fn(&str) -> bool, +) -> Result { const MAX_CONSECUTIVE_ACCEPT_ERRORS: u8 = 20; let mut consecutive_accept_errors = 0u8; loop { @@ -68,7 +84,7 @@ async fn run_accept_loop(listener: tokio::net::TcpListener) -> Result>) { youtube.player.stop(); } } + // Tear down any Qobuz session (it is routed after Subsonic in the pump). + #[cfg(feature = "qobuz")] + { + let qobuz = app.lock().await.qobuz_playback.take(); + if let Some(qobuz) = qobuz { + qobuz.player.stop(); + } + } } /// Reuse the live subsonic player, or open a fresh output device for one. A diff --git a/src/infra/youtube/dispatch.rs b/src/infra/youtube/dispatch.rs index da5b9d1a..ac561398 100644 --- a/src/infra/youtube/dispatch.rs +++ b/src/infra/youtube/dispatch.rs @@ -465,6 +465,14 @@ async fn release_other_backends(app: &Arc>) { subsonic.player.stop(); } } + // Tear down any Qobuz session. + #[cfg(feature = "qobuz")] + { + let qobuz = app.lock().await.qobuz_playback.take(); + if let Some(qobuz) = qobuz { + qobuz.player.stop(); + } + } // Tear down any radio session. #[cfg(feature = "internet-radio")] { diff --git a/src/runtime/pump.rs b/src/runtime/pump.rs index 482d8f61..a62faa81 100644 --- a/src/runtime/pump.rs +++ b/src/runtime/pump.rs @@ -83,6 +83,15 @@ pub(super) async fn start_tokio(io_rx: std::sync::mpsc::Receiver, netwo && crate::infra::subsonic::dispatch::route_subsonic_event(&network.app, &io_event).await; #[cfg(not(feature = "subsonic"))] let handled_subsonic = false; + // Qobuz is intercepted after Subsonic. A `qobuz:` URI falls through the + // earlier dispatches and is caught here (see infra::qobuz::dispatch). + #[cfg(feature = "qobuz")] + let handled_qobuz = !handled_queue + && !handled_locally + && !handled_subsonic + && crate::infra::qobuz::dispatch::route_qobuz_event(&network.app, &io_event).await; + #[cfg(not(feature = "qobuz"))] + let handled_qobuz = false; // Internet radio is intercepted last before the Spotify network. A // `radio:` URI falls through both earlier dispatches and is caught here // (see infra::radio::dispatch). Skipped when already consumed. @@ -90,6 +99,7 @@ pub(super) async fn start_tokio(io_rx: std::sync::mpsc::Receiver, netwo let handled_radio = !handled_queue && !handled_locally && !handled_subsonic + && !handled_qobuz && crate::infra::radio::dispatch::route_radio_event(&network.app, &io_event).await; #[cfg(not(feature = "internet-radio"))] let handled_radio = false; @@ -100,6 +110,7 @@ pub(super) async fn start_tokio(io_rx: std::sync::mpsc::Receiver, netwo let handled_youtube = !handled_queue && !handled_locally && !handled_subsonic + && !handled_qobuz && !handled_radio && crate::infra::youtube::dispatch::route_youtube_event(&network.app, &io_event).await; #[cfg(not(feature = "youtube"))] @@ -107,6 +118,7 @@ pub(super) async fn start_tokio(io_rx: std::sync::mpsc::Receiver, netwo if !handled_queue && !handled_locally && !handled_subsonic + && !handled_qobuz && !handled_radio && !handled_youtube { @@ -115,8 +127,8 @@ pub(super) async fn start_tokio(io_rx: std::sync::mpsc::Receiver, netwo // A source router consumed the event and returned without touching // `is_loading`, which `App::dispatch` set to true. Only // `handle_network_event` resets it, and we skipped that path, so clear - // it here — otherwise selecting/loading Local, Subsonic, Radio, or - // YouTube content leaves the UI stuck on the loading indicator. + // it here — otherwise selecting/loading Local, Subsonic, Qobuz, Radio, + // or YouTube content leaves the UI stuck on the loading indicator. network.app.lock().await.is_loading = false; } } diff --git a/src/runtime/startup.rs b/src/runtime/startup.rs index f45e2a34..d2971358 100644 --- a/src/runtime/startup.rs +++ b/src/runtime/startup.rs @@ -665,6 +665,7 @@ async fn restore_playback_session( #[cfg(any( feature = "youtube", feature = "subsonic", + feature = "qobuz", feature = "local-files", feature = "internet-radio" ))] @@ -755,6 +756,41 @@ async fn restore_playback_session( s.shuffle_backup = shuffle; } } + #[cfg(feature = "qobuz")] + PersistedPlayback::Qobuz { + tracks, + index, + position_ms, + paused, + repeat, + shuffle_on, + shuffle, + } => { + let uris: Vec = tracks.iter().filter_map(|t| t.uri.clone()).collect(); + if uris.is_empty() { + return; + } + // Seed the browse table so the start path's snapshot resolves metadata. + app.lock().await.track_table.tracks = tracks; + crate::infra::qobuz::dispatch::route_qobuz_event( + app, + &IoEvent::StartPlayback(None, Some(uris), Some(index)), + ) + .await; + // The download runs off the pump; the seek and pause apply when it plays. + let mut guard = app.lock().await; + if guard.qobuz_playback.is_some() { + guard.decoded_repeat = repeat; + guard.decoded_shuffle = shuffle_on; + } + if let Some(s) = guard.qobuz_playback.as_mut() { + s.shuffle_backup = shuffle; + s.resume_at = Some(crate::infra::qobuz::ResumePoint { + position_ms, + paused: resolve_paused(paused), + }); + } + } #[cfg(feature = "youtube")] PersistedPlayback::YouTube { tracks, @@ -844,6 +880,7 @@ async fn handle_mpris_events( #[cfg(any( feature = "local-files", feature = "subsonic", + feature = "qobuz", feature = "internet-radio", feature = "youtube" ))] @@ -1069,6 +1106,7 @@ async fn handle_mpris_events( any( feature = "local-files", feature = "subsonic", + feature = "qobuz", feature = "internet-radio", feature = "youtube" ) @@ -1227,6 +1265,7 @@ async fn handle_macos_media_events( #[cfg(any( feature = "local-files", feature = "subsonic", + feature = "qobuz", feature = "internet-radio", feature = "youtube" ))] @@ -1283,6 +1322,7 @@ async fn handle_macos_media_events( any( feature = "local-files", feature = "subsonic", + feature = "qobuz", feature = "internet-radio", feature = "youtube" ) @@ -1346,6 +1386,7 @@ async fn handle_windows_media_events( #[cfg(any( feature = "local-files", feature = "subsonic", + feature = "qobuz", feature = "internet-radio", feature = "youtube" ))] @@ -1432,6 +1473,7 @@ async fn handle_windows_media_events( any( feature = "local-files", feature = "subsonic", + feature = "qobuz", feature = "internet-radio", feature = "youtube" ) diff --git a/src/tui/first_run.rs b/src/tui/first_run.rs index c01ccc74..6ed7d055 100644 --- a/src/tui/first_run.rs +++ b/src/tui/first_run.rs @@ -152,6 +152,7 @@ fn source_note(source: Source) -> &'static str { Source::Subsonic => " (free, needs a Subsonic/Navidrome server)", Source::Radio => " (free)", Source::Local => " (free)", + Source::Qobuz => " (paid subscription, logs in through the browser)", } } diff --git a/src/tui/handlers/playlist.rs b/src/tui/handlers/playlist.rs index 7e467ade..6db9354c 100644 --- a/src/tui/handlers/playlist.rs +++ b/src/tui/handlers/playlist.rs @@ -15,6 +15,7 @@ pub(crate) fn total_display_count(app: &App) -> usize { Source::Radio => app.radio_stations.len(), // Local YouTube playlists + the "+ New Playlist" entry. Source::YouTube => app.youtube_playlists.len() + 1, + Source::Qobuz => app.qobuz_playlists.len(), Source::Spotify => app.get_playlist_display_count() + 1, } } @@ -47,6 +48,17 @@ fn open_subsonic_folder(app: &mut App) { open_source_playlist(app, uri); } +/// Qobuz: open the highlighted row (favorites, playlist, or album) in the +/// shared track table. +fn open_qobuz_folder(app: &mut App) { + let uri = app + .view + .selected_playlist_index + .and_then(|idx| app.qobuz_playlists.get(idx)) + .map(|playlist| playlist.uri.clone()); + open_source_playlist(app, uri); +} + /// YouTube: open the highlighted local playlist's saved videos in the shared /// track table, or the create-playlist form on the trailing "+ New Playlist" /// entry. @@ -204,6 +216,7 @@ pub(super) fn activate_selected(app: &mut App) { Source::Subsonic => open_subsonic_folder(app), Source::Radio => play_radio_station(app), Source::YouTube => open_youtube_playlist(app), + Source::Qobuz => open_qobuz_folder(app), Source::Spotify => open_spotify_row(app), } } diff --git a/src/tui/handlers/track_table.rs b/src/tui/handlers/track_table.rs index 42fe3bfa..4ae43baf 100644 --- a/src/tui/handlers/track_table.rs +++ b/src/tui/handlers/track_table.rs @@ -91,10 +91,11 @@ pub fn handler(key: Key, app: &mut App) { } TrackTableContext::AlbumSearch => {} TrackTableContext::DiscoverPlaylist => {} - // Local folders and Subsonic/YouTube playlists have no pagination. + // Local folders and Subsonic/YouTube/Qobuz playlists have no pagination. TrackTableContext::LocalPlaylist | TrackTableContext::SubsonicPlaylist - | TrackTableContext::YouTubePlaylist => {} + | TrackTableContext::YouTubePlaylist + | TrackTableContext::QobuzPlaylist => {} } }; } @@ -111,10 +112,11 @@ pub fn handler(key: Key, app: &mut App) { } TrackTableContext::AlbumSearch => {} TrackTableContext::DiscoverPlaylist => {} - // Local folders and Subsonic/YouTube playlists have no pagination. + // Local folders and Subsonic/YouTube/Qobuz playlists have no pagination. TrackTableContext::LocalPlaylist | TrackTableContext::SubsonicPlaylist - | TrackTableContext::YouTubePlaylist => {} + | TrackTableContext::YouTubePlaylist + | TrackTableContext::QobuzPlaylist => {} } }; } @@ -221,7 +223,9 @@ fn play_random_song(app: &mut App) { }); } } - TrackTableContext::LocalPlaylist | TrackTableContext::SubsonicPlaylist => { + TrackTableContext::LocalPlaylist + | TrackTableContext::SubsonicPlaylist + | TrackTableContext::QobuzPlaylist => { // Single-file playback: play one random track from the folder/playlist. // Wire shape changed with the Action conversion: this used to send the // track in the context slot (`StartPlayback(Some(uri), None, None)`). @@ -336,7 +340,8 @@ fn on_enter(app: &mut App) { TrackTableContext::AlbumSearch => {} TrackTableContext::LocalPlaylist | TrackTableContext::SubsonicPlaylist - | TrackTableContext::YouTubePlaylist => { + | TrackTableContext::YouTubePlaylist + | TrackTableContext::QobuzPlaylist => { // Queue the whole folder/playlist (in displayed order) and start at the // selected track, so Next/Previous/auto-advance have a queue to move // through. Routed to the local, subsonic or youtube player by URI diff --git a/src/tui/ui/library.rs b/src/tui/ui/library.rs index 0f417168..6441f4ba 100644 --- a/src/tui/ui/library.rs +++ b/src/tui/ui/library.rs @@ -87,6 +87,39 @@ pub fn draw_playlist_block(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { return; } + // Qobuz: the sidebar Playlists panel lists the favorites row, the user's + // playlists, and the favorite albums, each opening the shared track table. + if app.active_source == Source::Qobuz { + let items: Vec = if app.qobuz_playlists.is_empty() { + vec!["(not logged in \u{2014} press `d`, pick Qobuz)".to_string()] + } else { + app + .qobuz_playlists + .iter() + .map(|p| { + let icon = if p.uri.starts_with("qobuz:favorites:") { + "\u{2665}" + } else if p.uri.starts_with("qobuz:album:") { + "\u{1F4BF}" + } else { + "\u{1F3B5}" + }; + format!("{icon} {}", p.name) + }) + .collect() + }; + draw_selectable_list( + f, + app, + layout_chunk, + "Qobuz", + &items, + highlight_state, + app.view.selected_playlist_index, + ); + return; + } + // Internet Radio: the sidebar Playlists panel lists the configured stations. // A station is a leaf — Enter plays it directly instead of drilling in. if app.active_source == Source::Radio { @@ -192,10 +225,11 @@ pub fn draw_user_block(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { return; } - // Subsonic, Radio and YouTube support search but have no Spotify-style saved - // library, so keep the search input and show the source's list, but hide the - // Library panel. + // Subsonic, Qobuz, Radio and YouTube support search but have no Spotify-style + // saved library, so keep the search input and show the source's list, but + // hide the Library panel. if app.active_source == Source::Subsonic + || app.active_source == Source::Qobuz || app.active_source == Source::Radio || app.active_source == Source::YouTube { diff --git a/src/tui/ui/player.rs b/src/tui/ui/player.rs index f1290b37..84cf7b81 100644 --- a/src/tui/ui/player.rs +++ b/src/tui/ui/player.rs @@ -552,6 +552,7 @@ fn non_spotify_source_playback_active(app: &App) -> bool { #[cfg(not(any( feature = "local-files", feature = "subsonic", + feature = "qobuz", feature = "internet-radio", feature = "youtube" )))] @@ -567,6 +568,11 @@ fn non_spotify_source_playback_active(app: &App) -> bool { return true; } + #[cfg(feature = "qobuz")] + if app.qobuz_playback.is_some() { + return true; + } + #[cfg(feature = "internet-radio")] if app.radio_playback.is_some() { return true; @@ -836,6 +842,7 @@ fn extract_track_info(app: &App) -> (Option, Option) { #[cfg(any( feature = "local-files", feature = "subsonic", + feature = "qobuz", feature = "internet-radio", feature = "youtube", feature = "streaming", @@ -913,6 +920,34 @@ fn draw_subsonic_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { render_local_playbar(f, app, layout_chunk, &view); } +/// Render the playbar for an active Qobuz playback session; the download +/// window (`advancing`) shows as a loading suffix, like the queue slot. +#[cfg(feature = "qobuz")] +fn draw_qobuz_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { + let Some(qobuz) = app.qobuz_playback.as_ref() else { + return; + }; + let track = qobuz.current(); + let name = track.map(|t| t.name.clone()).unwrap_or_default(); + let view = LocalPlaybarView { + source_label: "Qobuz", + name: if qobuz.advancing { + format!("{name} (loading\u{2026})") + } else { + name + }, + artists: track.map(|t| t.artists.join(", ")).unwrap_or_default(), + is_playing: !qobuz.player.is_paused(), + position_ms: qobuz.player.position().as_millis(), + duration_ms: track.map(|t| t.duration_ms).unwrap_or(0), + volume_percent: app.runtime_state.volume_percent, + queue_position: (qobuz.tracks.len() > 1).then(|| (qobuz.index + 1, qobuz.tracks.len())), + live: false, + show_modes: true, + }; + render_local_playbar(f, app, layout_chunk, &view); +} + /// Render the playbar for an active YouTube playback session, reading /// progress/pause live from the player just like the Subsonic path. #[cfg(feature = "youtube")] @@ -972,6 +1007,7 @@ fn draw_radio_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { #[cfg(any( feature = "local-files", feature = "subsonic", + feature = "qobuz", feature = "internet-radio", feature = "youtube", feature = "streaming", @@ -1129,7 +1165,12 @@ pub fn draw_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { // suspended context (whose `*_playback` is still `Some`) and not the stale // Spotify context (still cached when a Spotify context was suspended). // Checked first so the playbar always shows what is actually audible. - #[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 source_label = d .track @@ -1197,6 +1238,13 @@ pub fn draw_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { return; } + // Qobuz playback likewise renders from its own session state. + #[cfg(feature = "qobuz")] + if app.qobuz_playback.is_some() { + draw_qobuz_playbar(f, app, layout_chunk); + return; + } + // Internet radio likewise renders from its own session state. #[cfg(feature = "internet-radio")] if app.radio_playback.is_some() { diff --git a/src/tui/ui/popups.rs b/src/tui/ui/popups.rs index 54de7ecb..0fa3af53 100644 --- a/src/tui/ui/popups.rs +++ b/src/tui/ui/popups.rs @@ -338,7 +338,7 @@ fn queue_item_line(item: &PlayableInfo) -> String { /// tracks. Rows read from the still-alive per-source `*_playback` state. fn context_preview_lines(app: &App, max: usize) -> Vec { // Format the upcoming rows of a Subsonic/YouTube `TrackInfo` context list. - #[cfg(any(feature = "subsonic", feature = "youtube"))] + #[cfg(any(feature = "subsonic", feature = "qobuz", feature = "youtube"))] fn track_rows( tracks: &[crate::core::plugin_api::TrackInfo], start: usize, @@ -385,6 +385,7 @@ fn context_preview_lines(app: &App, max: usize) -> Vec { feature = "streaming", feature = "local-files", feature = "subsonic", + feature = "qobuz", feature = "youtube", feature = "internet-radio" ))] @@ -416,6 +417,13 @@ fn context_preview_lines(app: &App, max: usize) -> Vec { _ => Vec::new(), } } + #[cfg(feature = "qobuz")] + SuspendedContext::Qobuz { resume_index, .. } => { + match (resume_index, app.qobuz_playback.as_ref()) { + (Some(i), Some(s)) => track_rows(&s.tracks, *i, max), + _ => Vec::new(), + } + } #[cfg(feature = "youtube")] SuspendedContext::YouTube { resume_index, .. } => { match (resume_index, app.youtube_playback.as_ref()) { @@ -439,6 +447,10 @@ fn context_preview_lines(app: &App, max: usize) -> Vec { if let Some(s) = app.subsonic_playback.as_ref() { return track_rows(&s.tracks, s.index + 1, max); } + #[cfg(feature = "qobuz")] + if let Some(s) = app.qobuz_playback.as_ref() { + return track_rows(&s.tracks, s.index + 1, max); + } #[cfg(feature = "youtube")] if let Some(s) = app.youtube_playback.as_ref() { return track_rows(&s.tracks, s.index + 1, max); diff --git a/tools/gates.count b/tools/gates.count index 5cff75fa..03eb074f 100644 --- a/tools/gates.count +++ b/tools/gates.count @@ -13,4 +13,4 @@ synthetic_keys_in_mouse_handler = 3 # target 0 (the content-table re-entries wildcard_arms_in_action_tree = 0 # target 0, must stay 0 view_writes_outside_tui = 12 # target 0 (producers outside tui/ and core/app/ writing App::view) action_refs_in_tui_handlers = 173 # adoption: may only rise -test_attribute_total = 1666 # adoption: may only rise +test_attribute_total = 1718 # adoption: may only rise From 9c9dd651b8f4d76cb41eec884c040154a259ab9e Mon Sep 17 00:00:00 2001 From: LargeModGames <84450916+LargeModGames@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:17:44 +0200 Subject: [PATCH 2/7] feat(qobuz): show delivered format in the playbar --- README.md | 2 +- docs/configuration.md | 2 + src/infra/qobuz/dispatch.rs | 15 +++++-- src/infra/qobuz/mod.rs | 4 ++ src/infra/qobuz/stream/cmaf.rs | 61 ++++++++++++++++++++++++++++- src/infra/qobuz/stream/download.rs | 8 +++- src/tui/ui/player.rs | 63 +++++++++++++++++++++++++++++- tools/gates.count | 2 +- 8 files changed, 147 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 2ef5b7e4..ddd5ddcf 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ Requires the [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) binary (`ffmpeg` recom ### 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). No app registration is needed. The sidebar lists your favorite tracks, playlists, and favorite albums; search finds tracks. Each track is downloaded and decrypted to a temporary file before it plays, so a hi-res track takes a moment to start. +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). No app registration is needed. The sidebar lists your favorite tracks, playlists, and favorite albums; search finds tracks. Each track is downloaded and decrypted to a temporary file before it plays, so a hi-res track takes a moment to start. 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: diff --git a/docs/configuration.md b/docs/configuration.md index 48f30754..4d5dad44 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -108,6 +108,8 @@ 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 diff --git a/src/infra/qobuz/dispatch.rs b/src/infra/qobuz/dispatch.rs index ccc8b566..94658095 100644 --- a/src/infra/qobuz/dispatch.rs +++ b/src/infra/qobuz/dispatch.rs @@ -29,7 +29,7 @@ use tokio::sync::Mutex; use super::auth::{self, QobuzBundleCache}; use super::stream::download::TrackDownload; -use super::{track_id_from_uri, QobuzPlaybackState, QobuzSource, Unauthorized}; +use super::{track_id_from_uri, QobuzPlaybackState, QobuzSource, StreamQuality, Unauthorized}; use crate::core::app::{App, SearchResultBlock, TrackTableContext}; use crate::core::pagination::Paged; use crate::core::plugin_api::TrackInfo; @@ -528,10 +528,11 @@ async fn spawn_fetch( .await .set_status_message(format!("Qobuz: downloading {} MB", total / 1_000_000), 6); } + let delivered = download.quality(); if let Err(e) = download.finish().await { return fail_fetch(&app, fetch_id, e).await; } - commit_fetch(&app, fetch_id, tmp).await; + commit_fetch(&app, fetch_id, tmp, delivered).await; }); if let Some(s) = app .lock() @@ -558,7 +559,12 @@ async fn fail_fetch(app: &Arc>, fetch_id: u64, err: anyhow::Error) { /// Play the downloaded file and finalize the session, under one lock so a /// concurrent skip (which restamps `fetch_id` under the same lock) cannot /// interleave. A decode failure tears the session down. -async fn commit_fetch(app: &Arc>, fetch_id: u64, tmp: NamedTempFile) { +async fn commit_fetch( + app: &Arc>, + fetch_id: u64, + tmp: NamedTempFile, + quality: StreamQuality, +) { let mut guard = app.lock().await; let Some((player, was_paused)) = guard .qobuz_playback @@ -593,6 +599,7 @@ async fn commit_fetch(app: &Arc>, fetch_id: u64, tmp: NamedTempFile) let display = match guard.qobuz_playback.as_mut() { Some(s) => { s.tempfile = Some(tmp); + s.quality = Some(quality); s.advancing = false; s.fetch = None; let resume = s.resume_at.take(); @@ -658,6 +665,7 @@ async fn start_qobuz_queue(app: &Arc>, uris: &[String], start_idx: us index, advancing: true, tempfile: None, + quality: None, shuffle_backup: None, fetch_id, resume_at: None, @@ -751,6 +759,7 @@ pub(crate) async fn play_index(app: &Arc>, target: usize) { fetch.abort(); } s.resume_at = None; + s.quality = None; s.index = target; s.advancing = true; s.fetch_id = fetch_id; diff --git a/src/infra/qobuz/mod.rs b/src/infra/qobuz/mod.rs index 58d7506d..75f55889 100644 --- a/src/infra/qobuz/mod.rs +++ b/src/infra/qobuz/mod.rs @@ -32,6 +32,8 @@ use crate::core::source::{MediaSource, Searcher}; use crate::infra::audio::LocalPlayer; use stream::download::{self, TrackDownload}; +pub use stream::cmaf::StreamQuality; + /// The active Qobuz playback session; the playbar reads position and pause /// live from `player`, like the Subsonic twin. pub struct QobuzPlaybackState { @@ -46,6 +48,8 @@ pub struct QobuzPlaybackState { pub advancing: bool, /// The decrypted audio of the current track; `None` while it downloads. pub tempfile: Option, + /// The delivered format of the current track; `None` while it downloads. + pub quality: Option, /// Backup of the pre-shuffle order while shuffle is on. pub shuffle_backup: Option, /// Stamp of the download in flight; a finished download with another stamp is dropped. diff --git a/src/infra/qobuz/stream/cmaf.rs b/src/infra/qobuz/stream/cmaf.rs index 728e46e8..48db0044 100644 --- a/src/infra/qobuz/stream/cmaf.rs +++ b/src/infra/qobuz/stream/cmaf.rs @@ -26,6 +26,8 @@ pub struct InitSegment { pub header: Vec, /// Byte length of each audio segment, in order (segment 1 first). pub segment_lengths: Vec, + pub sample_rate: u32, + pub bits_per_sample: u8, } impl InitSegment { @@ -33,6 +35,39 @@ impl InitSegment { pub fn total_bytes(&self) -> u64 { self.header.len() as u64 + self.segment_lengths.iter().map(|&l| l as u64).sum::() } + + /// The delivered audio format, which can be lower than the requested one. + pub fn quality(&self) -> StreamQuality { + StreamQuality { + sample_rate: self.sample_rate, + bits_per_sample: self.bits_per_sample, + flac: self.header.starts_with(b"fLaC"), + } + } +} + +/// The audio format of a delivered stream, read from segment 0. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StreamQuality { + pub sample_rate: u32, + pub bits_per_sample: u8, + /// `false` for the MP3 format, the only non-FLAC one Qobuz serves. + pub flac: bool, +} + +impl StreamQuality { + /// Playbar label, worded like the quality setting: `FLAC 24/96` or `MP3 320`. + pub fn label(&self) -> String { + if !self.flac { + return "MP3 320".to_string(); + } + let khz = if self.sample_rate.is_multiple_of(1000) { + (self.sample_rate / 1000).to_string() + } else { + format!("{:.1}", self.sample_rate as f64 / 1000.0) + }; + format!("FLAC {}/{khz}", self.bits_per_sample) + } } /// One frame entry of an audio segment. @@ -130,8 +165,8 @@ pub fn parse_init(buf: &[u8]) -> Result { let mut r = Reader { buf, pos: payload }; let _track_id = r.u32()?; let _file_id = r.u32()?; - let _sample_rate = r.u32()?; - let _bits_per_sample = r.u8()?; + let sample_rate = r.u32()?; + let bits_per_sample = r.u8()?; let _channels = r.take(3)?; let _samples_count = r.be(6)?; let header_len = r.u16()? as usize; @@ -148,6 +183,8 @@ pub fn parse_init(buf: &[u8]) -> Result { Ok(InitSegment { header, segment_lengths, + sample_rate, + bits_per_sample, }) } @@ -327,6 +364,26 @@ mod tests { assert_eq!(&init.header[5..], &flac_header()[5..]); assert_eq!(init.segment_lengths, vec![100, 200, 300]); assert_eq!(init.total_bytes(), 642); + assert_eq!(init.sample_rate, 96_000); + assert_eq!(init.bits_per_sample, 24); + assert_eq!(init.quality().label(), "FLAC 24/96"); + } + + #[test] + fn quality_label_matches_the_setting_wording() { + let flac = |sample_rate, bits_per_sample| StreamQuality { + sample_rate, + bits_per_sample, + flac: true, + }; + assert_eq!(flac(44_100, 16).label(), "FLAC 16/44.1"); + assert_eq!(flac(88_200, 24).label(), "FLAC 24/88.2"); + assert_eq!(flac(192_000, 24).label(), "FLAC 24/192"); + let mp3 = StreamQuality { + flac: false, + ..flac(44_100, 16) + }; + assert_eq!(mp3.label(), "MP3 320"); } #[test] diff --git a/src/infra/qobuz/stream/download.rs b/src/infra/qobuz/stream/download.rs index c4a394d1..11ca4d6c 100644 --- a/src/infra/qobuz/stream/download.rs +++ b/src/infra/qobuz/stream/download.rs @@ -11,7 +11,7 @@ use std::path::Path; use anyhow::{anyhow, Context, Result}; use reqwest::Client; -use super::cmaf::{self, InitSegment}; +use super::cmaf::{self, InitSegment, StreamQuality}; const SEGMENT_PLACEHOLDER: &str = "$SEGMENT$"; @@ -56,6 +56,11 @@ impl TrackDownload { self.init.total_bytes() } + /// The delivered audio format, from the init table. + pub fn quality(&self) -> StreamQuality { + self.init.quality() + } + /// Fetch, decrypt and append every audio segment, then flush the file. pub async fn finish(mut self) -> Result<()> { let count = self.init.segment_lengths.len() as u32; @@ -164,6 +169,7 @@ mod tests { .await .unwrap(); assert_eq!(download.total_bytes(), 42 + 70 + 20); + assert_eq!(download.quality().label(), "FLAC 24/96"); download.finish().await.unwrap(); assert_eq!(std::fs::read(tmp.path()).unwrap(), expected); } diff --git a/src/tui/ui/player.rs b/src/tui/ui/player.rs index 84cf7b81..c6a7e476 100644 --- a/src/tui/ui/player.rs +++ b/src/tui/ui/player.rs @@ -868,6 +868,8 @@ struct LocalPlaybarView { /// internet radio and native queue slots, whose playbar drops the mode /// segments entirely rather than showing blank `Shuffle:`/`Repeat:` labels. show_modes: bool, + /// The delivered audio format (Qobuz, e.g. `FLAC 24/96`), shown after the artists. + quality: Option, } /// Render the playbar for an active local-file playback session. @@ -892,6 +894,7 @@ fn draw_local_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { queue_position: (local.queue.len() > 1).then(|| (local.index + 1, local.queue.len())), live: false, show_modes: true, + quality: None, }; render_local_playbar(f, app, layout_chunk, &view); } @@ -916,6 +919,7 @@ fn draw_subsonic_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { .then(|| (subsonic.index + 1, subsonic.tracks.len())), live: false, show_modes: true, + quality: None, }; render_local_playbar(f, app, layout_chunk, &view); } @@ -944,6 +948,7 @@ fn draw_qobuz_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { queue_position: (qobuz.tracks.len() > 1).then(|| (qobuz.index + 1, qobuz.tracks.len())), live: false, show_modes: true, + quality: qobuz.quality.map(|q| q.label()), }; render_local_playbar(f, app, layout_chunk, &view); } @@ -967,6 +972,7 @@ fn draw_youtube_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { queue_position: (youtube.tracks.len() > 1).then(|| (youtube.index + 1, youtube.tracks.len())), live: false, show_modes: true, + quality: None, }; render_local_playbar(f, app, layout_chunk, &view); } @@ -1000,6 +1006,7 @@ fn draw_radio_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { queue_position: None, live: true, show_modes: false, + quality: None, }; render_local_playbar(f, app, layout_chunk, &view); } @@ -1091,10 +1098,24 @@ fn render_local_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect, view: .border_style(get_color(highlight_state, app.user_config.theme)); f.render_widget(title_block, layout_chunk); - let lines = Text::from(Span::styled( + let mut subtitle = vec![Span::styled( view.artists.clone(), Style::default().fg(app.user_config.theme.playbar_text.into()), - )); + )]; + if let Some(quality) = view.quality.as_ref() { + let separator = if view.artists.is_empty() { + "" + } else { + " \u{2022} " + }; + subtitle.push(Span::styled( + format!("{separator}{quality}"), + Style::default() + .fg(app.user_config.theme.playbar_text.into()) + .add_modifier(app.user_config.behavior.emphasis(Modifier::DIM)), + )); + } + let lines = Text::from(Line::from(subtitle)); let artist = Paragraph::new(lines) .style(Style::default().fg(app.user_config.theme.playbar_text.into())) .block( @@ -1199,6 +1220,7 @@ pub fn draw_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { // The native queue ignores the decoded shuffle/repeat modes (they belong // to the suspended source resumed once the queue drains), so hide them. show_modes: false, + quality: None, }; render_local_playbar(f, app, layout_chunk, &view); return; @@ -1218,6 +1240,7 @@ pub fn draw_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { queue_position: None, live: false, show_modes: false, + quality: None, }; render_local_playbar(f, app, layout_chunk, &view); return; @@ -2075,6 +2098,7 @@ mod tests { queue_position: Some((3, 12)), live: false, show_modes: true, + quality: None, }; let content = rendered_text(Rect::new(0, 0, 160, 6), &view); @@ -2118,6 +2142,7 @@ mod tests { queue_position: None, live: false, show_modes: true, + quality: None, }; let content = rendered_text(Rect::new(0, 0, 160, 6), &view); assert!(content.contains("Paused"), "should show Paused: {content}"); @@ -2127,6 +2152,39 @@ mod tests { ); } + #[cfg(feature = "local-files")] + #[test] + fn playbar_renders_delivered_quality_after_artists() { + let view = LocalPlaybarView { + source_label: "Qobuz", + name: "Track".to_string(), + artists: "Artist".to_string(), + is_playing: true, + position_ms: 0, + duration_ms: 200_000, + volume_percent: 50, + queue_position: None, + live: false, + show_modes: true, + quality: Some("FLAC 16/44.1".to_string()), + }; + let content = rendered_text(Rect::new(0, 0, 160, 6), &view); + assert!( + content.contains("Artist \u{2022} FLAC 16/44.1"), + "the delivered format should follow the artists: {content}" + ); + + let no_artists = LocalPlaybarView { + artists: String::new(), + ..view + }; + let content = rendered_text(Rect::new(0, 0, 160, 6), &no_artists); + assert!( + content.contains("FLAC 16/44.1") && !content.contains('\u{2022}'), + "no separator without artists: {content}" + ); + } + /// A live (radio) view must render the station, the ICY subtitle and a LIVE /// elapsed label — and must not divide by the zero duration (which would show /// a bogus `0:00/0:00` countdown instead). @@ -2144,6 +2202,7 @@ mod tests { queue_position: None, live: true, show_modes: false, + quality: None, }; let content = rendered_text(Rect::new(0, 0, 160, 6), &view); diff --git a/tools/gates.count b/tools/gates.count index 03eb074f..da4b9578 100644 --- a/tools/gates.count +++ b/tools/gates.count @@ -13,4 +13,4 @@ synthetic_keys_in_mouse_handler = 3 # target 0 (the content-table re-entries wildcard_arms_in_action_tree = 0 # target 0, must stay 0 view_writes_outside_tui = 12 # target 0 (producers outside tui/ and core/app/ writing App::view) action_refs_in_tui_handlers = 173 # adoption: may only rise -test_attribute_total = 1718 # adoption: may only rise +test_attribute_total = 1720 # adoption: may only rise From ddf2a7238e482c4e58a39e1742f7c2940be1ca18 Mon Sep 17 00:00:00 2001 From: LargeModGames <84450916+LargeModGames@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:33:04 +0200 Subject: [PATCH 3/7] feat(qobuz): play tracks while they download A track now streams through a stream-download source over the segment transport: playback starts after segment 0 and the first bytes, a skip cancels the download in flight, and a seek past the downloaded part restarts the stream at that segment. The session keeps the tempfile, so repeat-one replay is unchanged. The Qobuz sidebar rows lose their glyphs. --- .github/copilot-instructions.md | 11 +- AGENTS.md | 11 +- CLAUDE.md | 11 +- Cargo.lock | 1 + Cargo.toml | 17 +- src/infra/audio/mod.rs | 2 + src/infra/audio/player.rs | 53 +++- src/infra/qobuz/dispatch.rs | 126 ++++---- src/infra/qobuz/mod.rs | 124 ++++++-- src/infra/qobuz/stream/download.rs | 74 +++-- src/infra/qobuz/stream/mod.rs | 4 +- src/infra/qobuz/stream/progressive.rs | 429 ++++++++++++++++++++++++++ src/tui/ui/library.rs | 15 +- tools/gates.count | 2 +- 14 files changed, 713 insertions(+), 167 deletions(-) create mode 100644 src/infra/qobuz/stream/progressive.rs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 34d18668..64792c46 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -430,10 +430,13 @@ working in those directories. (`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/`) downloads each track through the web player's - encrypted CMAF stream, decrypts it, and rebuilds a FLAC `NamedTempFile`. The - download runs off the pump (a track is 30 to 200 MB) behind a `fetch_id` - guard. The transport (`sign.rs`, `stream/`) is pure and unit tested. The +- 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`. diff --git a/AGENTS.md b/AGENTS.md index 2f2ff38c..daa45925 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -432,10 +432,13 @@ working in those directories. (`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/`) downloads each track through the web player's - encrypted CMAF stream, decrypts it, and rebuilds a FLAC `NamedTempFile`. The - download runs off the pump (a track is 30 to 200 MB) behind a `fetch_id` - guard. The transport (`sign.rs`, `stream/`) is pure and unit tested. The +- 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`. diff --git a/CLAUDE.md b/CLAUDE.md index a8803d80..cc1c6700 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -432,10 +432,13 @@ working in those directories. (`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/`) downloads each track through the web player's - encrypted CMAF stream, decrypts it, and rebuilds a FLAC `NamedTempFile`. The - download runs off the pump (a track is 30 to 200 MB) behind a `fetch_id` - guard. The transport (`sign.rs`, `stream/`) is pure and unit tested. The +- 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`. diff --git a/Cargo.lock b/Cargo.lock index 17e1f144..6050dd4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5778,6 +5778,7 @@ dependencies = [ "backtrace", "base64", "block2", + "bytes", "cbc", "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 597b45a1..50f675f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,11 +70,14 @@ 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 } @@ -195,9 +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: every track is downloaded, decrypted -# and rebuilt as a FLAC tempfile, then played through the shared LocalPlayer. -qobuz = ["audio-decode", "dep:tempfile", "reqwest/stream", "dep:md-5", "dep:sha2", "dep:hex", "dep:base64", "dep:regex", "dep:aes", "dep:cbc", "dep:ctr", "dep:hkdf"] +# 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/src/infra/audio/mod.rs b/src/infra/audio/mod.rs index 1b30c9b4..b616ae9a 100644 --- a/src/infra/audio/mod.rs +++ b/src/infra/audio/mod.rs @@ -12,6 +12,8 @@ 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 d53f1b0f..5ed2d13d 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. /// @@ -100,35 +104,62 @@ 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`. #[cfg(feature = "internet-radio")] pub fn play_stream(&self, reader: R, mime_type: Option<&str>) -> Result<()> + where + R: std::io::Read + std::io::Seek + Send + Sync + 'static, + { + 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(false); + 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. Does not block. + #[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. diff --git a/src/infra/qobuz/dispatch.rs b/src/infra/qobuz/dispatch.rs index 94658095..9ababe68 100644 --- a/src/infra/qobuz/dispatch.rs +++ b/src/infra/qobuz/dispatch.rs @@ -9,15 +9,18 @@ //! ## Playback //! //! Qobuz playback owns [`App::qobuz_playback`] and never writes Spotify or -//! librespot fields. A track is a 30 to 200 MB download, so the session is -//! published at once (marked `advancing`, like the native queue slot) and the -//! download runs on a detached task: the pump keeps serving every other event -//! and a skip during the download supersedes it through `fetch_id`. +//! librespot fields. A track plays while it downloads (`stream::progressive`): +//! the session is published at once (marked `advancing`, like the native queue +//! slot) and a detached task fetches segment 0, opens the stream, and builds +//! the decoder, which waits for the first bytes. The pump keeps serving every +//! other event, and a skip during that window supersedes it through +//! `fetch_id`; the superseded stream is dropped, which cancels its download. //! //! Failures are status messages (never `handle_error`): the CLI never reaches //! this router, so no exit signal is lost. A 401 clears the in-memory token so -//! the next browse re-runs the browser login. A failed download tears the -//! session down instead of skipping (a skip would walk the queue at tick speed). +//! the next browse re-runs the browser login. A failure before the first bytes +//! tears the session down instead of skipping (a skip would walk the queue at +//! tick speed); a failure after that ends the track early. use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; @@ -29,19 +32,18 @@ use tokio::sync::Mutex; use super::auth::{self, QobuzBundleCache}; use super::stream::download::TrackDownload; +use super::stream::progressive; use super::{track_id_from_uri, QobuzPlaybackState, QobuzSource, StreamQuality, Unauthorized}; use crate::core::app::{App, SearchResultBlock, TrackTableContext}; use crate::core::pagination::Paged; use crate::core::plugin_api::TrackInfo; use crate::core::source::{MediaSource, Searcher}; use crate::core::state::PersistedRuntimeState; -use crate::infra::audio::LocalPlayer; +use crate::infra::audio::{LocalPlayer, PreparedStream}; use crate::infra::network::IoEvent; use crate::infra::queue::{advance_index, replay_file}; const LOGIN_EXPIRED: &str = "Qobuz: login expired, press `d` and pick Qobuz to log in again"; -/// Downloads above this size get a status message that names the size. -const LARGE_DOWNLOAD_BYTES: u64 = 20_000_000; /// Whether a URI is owned by the Qobuz source. pub fn is_qobuz_uri(uri: &str) -> bool { @@ -112,7 +114,11 @@ pub async fn route_qobuz_event(app: &Arc>, event: &IoEvent) -> bool { }, IoEvent::Seek(position_ms) => match player(app).await { Some(p) => { - let _ = p.seek(Duration::from_millis(*position_ms as u64)); + // A seek past the downloaded part waits for that segment: off the pump. + let position = Duration::from_millis(*position_ms as u64); + tokio::task::spawn_blocking(move || { + let _ = p.seek(position); + }); true } None => false, @@ -493,19 +499,11 @@ pub(crate) async fn download_for_queue( Ok(tmp) } -/// Whether the published session still waits for the download stamped `fetch_id`. -async fn is_current(app: &Arc>, fetch_id: u64) -> bool { - app - .lock() - .await - .qobuz_playback - .as_ref() - .is_some_and(|s| s.fetch_id == fetch_id) -} - -/// Run the download for the published session on a detached task, then play -/// it when the session still waits for this fetch. The task's abort handle is -/// stored on the session, so a skip, a new queue, or a teardown cancels it. +/// Open the track's stream for the published session on a detached task and +/// build its decoder, then play it when the session still waits for this +/// fetch. The task's abort handle is stored on the session, so a skip, a new +/// queue, or a teardown cancels it; a decoder build already in progress ends +/// on its own and drops its stream, which cancels that download. async fn spawn_fetch( app: &Arc>, source: Arc, @@ -516,23 +514,36 @@ async fn spawn_fetch( let task = tokio::spawn(async move { let app = task_app; let quality = app.lock().await.user_config.behavior.qobuz_quality; - let (tmp, download) = match begin_track_download(&source, &track_id, quality).await { + let tmp = match NamedTempFile::new().context("creating temp file for Qobuz stream") { + Ok(tmp) => tmp, + Err(e) => return fail_fetch(&app, fetch_id, "stream", e).await, + }; + let (init, stream) = match source.begin_stream(&track_id, quality).await { Ok(started) => started, - Err(e) => return fail_fetch(&app, fetch_id, e).await, + Err(e) => return fail_fetch(&app, fetch_id, "stream", e).await, }; - // The size is known after segment 0, before the long part. - let total = download.total_bytes(); - if total > LARGE_DOWNLOAD_BYTES && is_current(&app, fetch_id).await { - app - .lock() - .await - .set_status_message(format!("Qobuz: downloading {} MB", total / 1_000_000), 6); - } - let delivered = download.quality(); - if let Err(e) = download.finish().await { - return fail_fetch(&app, fetch_id, e).await; - } - commit_fetch(&app, fetch_id, tmp, delivered).await; + let delivered = init.quality(); + let total = init.total_bytes(); + let reader = match progressive::open(stream, &tmp).await { + Ok(reader) => reader, + Err(e) => return fail_fetch(&app, fetch_id, "stream", e).await, + }; + // The decoder's first read waits for the prefetch: off the App lock. + let mime = if delivered.flac { + "audio/flac" + } else { + "audio/mpeg" + }; + let prepared = tokio::task::spawn_blocking(move || { + LocalPlayer::prepare_stream(reader, Some(mime), Some(total)) + }) + .await; + let prepared = match prepared { + Ok(Ok(prepared)) => prepared, + Ok(Err(e)) => return fail_fetch(&app, fetch_id, "play", e).await, + Err(e) => return fail_fetch(&app, fetch_id, "play", anyhow::anyhow!(e)).await, + }; + commit_fetch(&app, fetch_id, tmp, delivered, prepared).await; }); if let Some(s) = app .lock() @@ -545,32 +556,33 @@ async fn spawn_fetch( } } -/// A failed download: tear the session down only if it still waits for this +/// A failed fetch: tear the session down only if it still waits for this /// fetch, under one lock, and report the error from the same guard. -async fn fail_fetch(app: &Arc>, fetch_id: u64, err: anyhow::Error) { +async fn fail_fetch(app: &Arc>, fetch_id: u64, step: &str, err: anyhow::Error) { let mut guard = app.lock().await; let Some(s) = guard.qobuz_playback.take_if(|s| s.fetch_id == fetch_id) else { return; }; s.player.stop(); - report_locked(&mut guard, "download", err); + report_locked(&mut guard, step, err); } -/// Play the downloaded file and finalize the session, under one lock so a +/// Play the prepared stream and finalize the session, under one lock so a /// concurrent skip (which restamps `fetch_id` under the same lock) cannot -/// interleave. A decode failure tears the session down. +/// interleave. A stale stream is dropped, which cancels its download. async fn commit_fetch( app: &Arc>, fetch_id: u64, tmp: NamedTempFile, quality: StreamQuality, + prepared: PreparedStream, ) { let mut guard = app.lock().await; let Some((player, was_paused)) = guard .qobuz_playback .as_ref() .filter(|s| s.fetch_id == fetch_id) - // A pause pressed during the download window applies to the previous + // A pause pressed during the fetch window applies to the previous // track's sink; a fresh player starts paused, so only a session that // already played something counts. .map(|s| { @@ -582,20 +594,9 @@ async fn commit_fetch( else { return; }; - let path = tmp.path().to_path_buf(); - let decode_player = Arc::clone(&player); - let played = tokio::task::spawn_blocking(move || decode_player.play_file(&path)) - .await - .map(|r| r.map_err(|e| e.to_string())) - .unwrap_or_else(|e| Err(e.to_string())); - if let Err(e) = played { - if let Some(s) = guard.qobuz_playback.take_if(|s| s.fetch_id == fetch_id) { - s.player.stop(); - } - guard.set_status_message(format!("Qobuz: play: {e}"), 6); - return; - } + player.play_prepared(prepared); player.set_volume(guard.runtime_state.volume_percent as f32 / 100.0); + let mut seek_to = None; let display = match guard.qobuz_playback.as_mut() { Some(s) => { s.tempfile = Some(tmp); @@ -603,9 +604,7 @@ async fn commit_fetch( s.advancing = false; s.fetch = None; let resume = s.resume_at.take(); - if let Some(position_ms) = resume.map(|r| r.position_ms).filter(|&ms| ms > 0) { - let _ = player.seek(Duration::from_millis(position_ms)); - } + seek_to = resume.map(|r| r.position_ms).filter(|&ms| ms > 0); if was_paused || resume.is_some_and(|r| r.paused) { player.pause(); } @@ -616,6 +615,13 @@ async fn commit_fetch( if let Some(display) = display { guard.set_status_message(format!("\u{266a} {display}"), 4); } + drop(guard); + // The restore seek waits for that part of the download: off the App lock. + if let Some(position_ms) = seek_to { + tokio::task::spawn_blocking(move || { + let _ = player.seek(Duration::from_millis(position_ms)); + }); + } } /// Begin playing a list of Qobuz tracks, taking over the session and starting diff --git a/src/infra/qobuz/mod.rs b/src/infra/qobuz/mod.rs index 75f55889..3677fd16 100644 --- a/src/infra/qobuz/mod.rs +++ b/src/infra/qobuz/mod.rs @@ -1,8 +1,9 @@ //! Qobuz media source. //! //! Browses the user's Qobuz library and plays tracks through the encrypted CMAF -//! stream: every track is downloaded, decrypted and rebuilt as a FLAC tempfile, -//! then decoded by the shared [`LocalPlayer`](crate::infra::audio::LocalPlayer). +//! stream: each track is decrypted and rebuilt as a FLAC tempfile while the +//! shared [`LocalPlayer`](crate::infra::audio::LocalPlayer) decodes it +//! (see [`stream::progressive`]). //! The three bundle constants are scraped at runtime (see [`auth`]); nothing //! secret is embedded. //! @@ -30,7 +31,9 @@ use tokio::sync::Mutex; use crate::core::plugin_api::{ArtistRef, PlaylistInfo, SearchResults, TrackInfo}; use crate::core::source::{MediaSource, Searcher}; use crate::infra::audio::LocalPlayer; +use stream::cmaf::InitSegment; use stream::download::{self, TrackDownload}; +use stream::progressive::SegmentStream; pub use stream::cmaf::StreamQuality; @@ -43,12 +46,13 @@ pub struct QobuzPlaybackState { /// The playing listing's tracks in order; the playbar reads `tracks[index]`. pub tracks: Vec, pub index: usize, - /// Set for the whole download window so the tick never reads the empty sink - /// as end-of-track. + /// Set until the track starts to play so the tick never reads the empty + /// sink as end-of-track. pub advancing: bool, - /// The decrypted audio of the current track; `None` while it downloads. + /// The current track's file, filled while it plays; `None` until playback + /// starts. pub tempfile: Option, - /// The delivered format of the current track; `None` while it downloads. + /// The delivered format of the current track; `None` until playback starts. pub quality: Option, /// Backup of the pre-shuffle order while shuffle is on. pub shuffle_backup: Option, @@ -343,26 +347,49 @@ impl QobuzSource { }) } - /// Start a track download into `dest`; a stale session is renewed once. + /// The stream URL and key of a track; a stale session is renewed once. + async fn track_stream(&self, track_id: &str, format_id: u8) -> Result { + let session = self.stream_session().await?; + match self.file_url(&session, track_id, format_id).await { + Ok(stream) => Ok(stream), + Err(e) if e.downcast_ref::().is_some() => Err(e), + Err(_) => { + *shared_session().lock().await = None; + let session = self.stream_session().await?; + self.file_url(&session, track_id, format_id).await + } + } + } + + /// Start a track download into `dest`. pub async fn begin_download( &self, track_id: &str, format_id: u8, dest: &Path, ) -> Result { - let session = self.stream_session().await?; - let stream = match self.file_url(&session, track_id, format_id).await { - Ok(stream) => stream, - Err(e) if e.downcast_ref::().is_some() => return Err(e), - Err(_) => { - *shared_session().lock().await = None; - let session = self.stream_session().await?; - self.file_url(&session, track_id, format_id).await? - } - }; + let stream = self.track_stream(track_id, format_id).await?; download::begin(&self.http, &stream.url_template, stream.content_key, dest).await } + /// Start a progressive stream of a track: segment 0 is fetched, so the + /// total size and the delivered format are known before any audio. + pub async fn begin_stream( + &self, + track_id: &str, + format_id: u8, + ) -> Result<(InitSegment, SegmentStream)> { + let stream = self.track_stream(track_id, format_id).await?; + let init = download::fetch_init(&self.http, &stream.url_template).await?; + let segments = SegmentStream::new( + self.http.clone(), + stream.url_template, + stream.content_key, + &init, + ); + Ok((init, segments)) + } + // ------------------------------------------------------------------------- // Catalog // ------------------------------------------------------------------------- @@ -829,8 +856,8 @@ mod tests { assert!(!session.is_valid_at(1_000 - SESSION_MARGIN_SECS)); } - /// Live end to end: scrape the bundle, start a session, download and decrypt - /// one track, and play it through the shared sink. Needs + /// Live end to end: scrape the bundle, start a session, stream one track + /// through the shared sink while it downloads, and seek ahead. Needs /// `SPOTATUI_QOBUZ_TOKEN` (and `SPOTATUI_QOBUZ_TEST_FORMAT`, default 27). Run: /// `cargo test --features qobuz -- --ignored live_qobuz --nocapture` #[tokio::test] @@ -870,23 +897,35 @@ mod tests { let tmp = tempfile::NamedTempFile::new().unwrap(); let started = Instant::now(); - let download = source - .begin_download(track_id, format, tmp.path()) + let (init, segments) = source + .begin_stream(track_id, format) .await .expect("session start + file url + init segment"); - let total = download.total_bytes(); - eprintln!("init after {:?}, total {} bytes", started.elapsed(), total); - download.finish().await.expect("segments"); - eprintln!("download done after {:?}", started.elapsed()); - - let bytes = std::fs::read(tmp.path()).unwrap(); - assert_eq!(bytes.len() as u64, total); - if format != 5 { - assert!(bytes.starts_with(b"fLaC"), "got {:02X?}", &bytes[..4]); - } + let total = init.total_bytes(); + eprintln!( + "init after {:?}, total {} bytes, {}", + started.elapsed(), + total, + init.quality().label() + ); + let reader = stream::progressive::open(segments, &tmp) + .await + .expect("open stream"); + let mime = if init.quality().flac { + "audio/flac" + } else { + "audio/mpeg" + }; + let prepared = tokio::task::spawn_blocking(move || { + LocalPlayer::prepare_stream(reader, Some(mime), Some(total)) + }) + .await + .unwrap() + .expect("decode the stream head"); + eprintln!("decoder ready after {:?}", started.elapsed()); let player = LocalPlayer::new().expect("open default output device"); - player.play_file(tmp.path()).expect("play decrypted track"); + player.play_prepared(prepared); eprintln!("playing after {:?}", started.elapsed()); tokio::time::sleep(Duration::from_millis(600)).await; assert!( @@ -894,6 +933,25 @@ mod tests { "playback position should advance, got {:?}", player.position() ); - player.stop(); + // A seek past the downloaded part restarts the stream at that segment. + let seek_started = Instant::now(); + tokio::task::spawn_blocking(move || { + player.seek(Duration::from_secs(90)).expect("seek ahead"); + eprintln!("seek to 90 s took {:?}", seek_started.elapsed()); + std::thread::sleep(Duration::from_millis(600)); + assert!( + player.position() >= Duration::from_secs(90), + "position after seek: {:?}", + player.position() + ); + player.stop(); + }) + .await + .unwrap(); + + if format != 5 { + let bytes = std::fs::read(tmp.path()).unwrap(); + assert!(bytes.starts_with(b"fLaC"), "got {:02X?}", &bytes[..4]); + } } } diff --git a/src/infra/qobuz/stream/download.rs b/src/infra/qobuz/stream/download.rs index 11ca4d6c..6d57ee32 100644 --- a/src/infra/qobuz/stream/download.rs +++ b/src/infra/qobuz/stream/download.rs @@ -3,6 +3,8 @@ //! Two phases so the caller can read the total size before the long part: //! [`begin`] fetches segment 0 and writes the codec header, then //! [`TrackDownload::finish`] streams every audio segment into the same file. +//! The progressive player (`progressive`) shares the segment fetch and skips +//! the file: it yields each decrypted segment to `stream-download` instead. use std::fs::File; use std::io::Write; @@ -11,7 +13,7 @@ use std::path::Path; use anyhow::{anyhow, Context, Result}; use reqwest::Client; -use super::cmaf::{self, InitSegment, StreamQuality}; +use super::cmaf::{self, InitSegment}; const SEGMENT_PLACEHOLDER: &str = "$SEGMENT$"; @@ -24,6 +26,15 @@ pub struct TrackDownload { file: File, } +/// Fetch and parse segment 0. +pub async fn fetch_init(http: &Client, url_template: &str) -> Result { + if !url_template.contains(SEGMENT_PLACEHOLDER) { + return Err(anyhow!("stream URL template has no {SEGMENT_PLACEHOLDER}")); + } + let bytes = fetch_segment(http, url_template, 0).await?; + cmaf::parse_init(&bytes).context("segment 0 parse") +} + /// Fetch and parse segment 0 and write the codec header to `dest`. pub async fn begin( http: &Client, @@ -31,11 +42,7 @@ pub async fn begin( content_key: [u8; 16], dest: &Path, ) -> Result { - if !url_template.contains(SEGMENT_PLACEHOLDER) { - return Err(anyhow!("stream URL template has no {SEGMENT_PLACEHOLDER}")); - } - let bytes = fetch_segment(http, url_template, 0).await?; - let init = cmaf::parse_init(&bytes).context("segment 0 parse")?; + let init = fetch_init(http, url_template).await?; let mut file = File::create(dest).with_context(|| format!("creating stream file {}", dest.display()))?; file @@ -51,33 +58,37 @@ pub async fn begin( } impl TrackDownload { - /// The size of the finished file, from the init table. - pub fn total_bytes(&self) -> u64 { - self.init.total_bytes() - } - - /// The delivered audio format, from the init table. - pub fn quality(&self) -> StreamQuality { - self.init.quality() - } - /// Fetch, decrypt and append every audio segment, then flush the file. pub async fn finish(mut self) -> Result<()> { let count = self.init.segment_lengths.len() as u32; for index in 1..=count { - let mut bytes = fetch_segment(&self.http, &self.url_template, index).await?; - let table = cmaf::parse_segment(&bytes).with_context(|| format!("segment {index} parse"))?; - let range = cmaf::decrypt_frames(&mut bytes, &table, &self.content_key) - .with_context(|| format!("segment {index} decrypt"))?; + let bytes = + fetch_audio_segment(&self.http, &self.url_template, index, &self.content_key).await?; self .file - .write_all(&bytes[range]) + .write_all(&bytes) .with_context(|| format!("segment {index} write"))?; } self.file.flush().context("flushing stream file") } } +/// Fetch audio segment `index` (1 or later) and return its decrypted frames. +pub(super) async fn fetch_audio_segment( + http: &Client, + url_template: &str, + index: u32, + content_key: &[u8; 16], +) -> Result> { + let mut bytes = fetch_segment(http, url_template, index).await?; + let table = cmaf::parse_segment(&bytes).with_context(|| format!("segment {index} parse"))?; + let range = cmaf::decrypt_frames(&mut bytes, &table, content_key) + .with_context(|| format!("segment {index} decrypt"))?; + bytes.truncate(range.end); + bytes.drain(..range.start); + Ok(bytes) +} + fn segment_url(template: &str, index: u32) -> String { template.replace(SEGMENT_PLACEHOLDER, &index.to_string()) } @@ -101,16 +112,18 @@ async fn fetch_segment(http: &Client, template: &str, index: u32) -> Result>) -> String { + pub async fn serve(segments: Vec>) -> String { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); tokio::spawn(async move { @@ -143,7 +156,8 @@ mod tests { format!("http://127.0.0.1:{port}/seg/{SEGMENT_PLACEHOLDER}") } - fn fixture_track() -> (Vec>, Vec) { + /// The segments of a track (init first) and the expected rebuilt file. + pub fn fixture_track() -> (Vec>, Vec) { let (seg1, plain1) = fixtures::audio_segment( &[ ((0u8..40).collect(), 1, [1, 2, 3, 4, 5, 6, 7, 8]), @@ -159,6 +173,12 @@ mod tests { expected.extend(plain2); (vec![init, seg1, seg2], expected) } +} + +#[cfg(test)] +mod tests { + use super::test_support::*; + use super::*; #[tokio::test] async fn download_rebuilds_header_and_decrypted_frames() { @@ -168,8 +188,6 @@ mod tests { let download = begin(&Client::new(), &template, KEY, tmp.path()) .await .unwrap(); - assert_eq!(download.total_bytes(), 42 + 70 + 20); - assert_eq!(download.quality().label(), "FLAC 24/96"); download.finish().await.unwrap(); assert_eq!(std::fs::read(tmp.path()).unwrap(), expected); } diff --git a/src/infra/qobuz/stream/mod.rs b/src/infra/qobuz/stream/mod.rs index 1520ca38..c111ccca 100644 --- a/src/infra/qobuz/stream/mod.rs +++ b/src/infra/qobuz/stream/mod.rs @@ -1,5 +1,7 @@ -//! The encrypted CMAF transport: pure key and box parsers plus the downloader. +//! The encrypted CMAF transport: pure key and box parsers, the downloader, and +//! the progressive stream the player decodes while the download runs. pub mod cmaf; pub mod crypto; pub mod download; +pub mod progressive; diff --git a/src/infra/qobuz/stream/progressive.rs b/src/infra/qobuz/stream/progressive.rs new file mode 100644 index 00000000..af63d21b --- /dev/null +++ b/src/infra/qobuz/stream/progressive.rs @@ -0,0 +1,429 @@ +//! Progressive delivery: the track plays while it downloads. +//! +//! [`SegmentStream`] is a `stream-download` source over the segment transport. +//! It yields the codec header, then each decrypted audio segment in order, and +//! restarts at any byte offset when the decoder seeks past the downloaded +//! part. `stream-download` writes the bytes into the session's tempfile +//! ([`TempfileStorage`]), blocks the decoder's reads until they exist, and +//! stops the download when the reader is dropped. The session keeps the +//! `NamedTempFile`, so the finished file outlives the reader (repeat-one). + +use std::convert::Infallible; +use std::fs::File; +use std::future::Future; +use std::io::{self, BufReader}; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::time::Duration; + +use anyhow::{anyhow, Context as _, Result}; +use bytes::Bytes; +use futures::Stream; +use reqwest::Client; +use stream_download::source::SourceStream; +use stream_download::storage::StorageProvider; +use stream_download::{Settings, StreamDownload}; +use tempfile::NamedTempFile; +use tokio::task::JoinHandle; + +use super::cmaf::InitSegment; +use super::download::fetch_audio_segment; + +/// Consecutive failed fetches of one segment before the stream fails. +const MAX_ATTEMPTS: u32 = 3; +/// Bytes downloaded before the first read returns: a cushion against jitter +/// that costs well under a second on a normal connection. +const PREFETCH_BYTES: u64 = 512 * 1024; +/// Time without a chunk before `stream-download` asks for a reconnect. Above +/// the HTTP client's request timeout, so a stalled segment fails there first +/// and counts as an attempt. +const STALL_TIMEOUT: Duration = Duration::from_secs(60); + +/// The reader the decoder pulls from; dropping it cancels the download. +pub type TrackReader = StreamDownload; + +/// Start the download into `file` and return the decoder's reader. +pub async fn open(stream: SegmentStream, file: &NamedTempFile) -> Result { + let storage = TempfileStorage::new(file).context("reopening stream file")?; + let settings = Settings::default() + .prefetch_bytes(PREFETCH_BYTES) + .retry_timeout(STALL_TIMEOUT); + StreamDownload::from_stream(stream, storage, settings) + .await + .map_err(|e| anyhow!("{e}")) +} + +/// Storage over the session's own tempfile: one reopened handle for the +/// download's writes and one for the decoder's reads. +pub struct TempfileStorage { + reader: File, + writer: File, +} + +impl TempfileStorage { + fn new(file: &NamedTempFile) -> io::Result { + Ok(Self { + reader: file.reopen()?, + writer: file.reopen()?, + }) + } +} + +impl StorageProvider for TempfileStorage { + type Reader = BufReader; + type Writer = File; + + fn into_reader_writer( + self, + _content_length: Option, + ) -> io::Result<(Self::Reader, Self::Writer)> { + Ok((BufReader::new(self.reader), self.writer)) + } +} + +/// A track as a byte stream: chunk 0 is the codec header, chunk `i >= 1` is +/// the decrypted audio segment `i`. +pub struct SegmentStream { + http: Client, + url_template: String, + content_key: [u8; 16], + header: Bytes, + /// `starts[i]` is the byte offset of chunk `i`; the last entry is the total. + starts: Vec, + /// The next chunk to yield. + next: usize, + /// Bytes to drop from the front of the next chunk (a seek into a chunk). + skip: usize, + /// Chunks at or past this index are not yielded until the next seek. + end: usize, + in_flight: Option>>>, + attempts: u32, + failure: Option, +} + +impl SegmentStream { + pub fn new( + http: Client, + url_template: String, + content_key: [u8; 16], + init: &InitSegment, + ) -> Self { + let mut starts = Vec::with_capacity(init.segment_lengths.len() + 2); + let mut position = 0u64; + starts.push(position); + position += init.header.len() as u64; + starts.push(position); + for &len in &init.segment_lengths { + position += u64::from(len); + starts.push(position); + } + let chunks = starts.len() - 1; + Self { + http, + url_template, + content_key, + header: Bytes::copy_from_slice(&init.header), + starts, + next: 0, + skip: 0, + end: chunks, + in_flight: None, + attempts: 0, + failure: None, + } + } + + fn chunk_count(&self) -> usize { + self.starts.len() - 1 + } + + /// The size of the finished file. + pub fn total_bytes(&self) -> u64 { + self.starts[self.chunk_count()] + } + + /// The chunk that holds byte `position` and the offset inside that chunk; + /// `(chunk_count, 0)` at or past the end. + fn locate(&self, position: u64) -> (usize, usize) { + let chunks = self.chunk_count(); + let index = self.starts[1..].partition_point(|&end| end <= position); + if index >= chunks { + (chunks, 0) + } else { + (index, (position - self.starts[index]) as usize) + } + } + + /// The exclusive chunk bound that covers the exclusive byte bound `end`. + fn chunk_bound(&self, end: Option) -> usize { + match end { + None => self.chunk_count(), + Some(0) => 0, + Some(end) => (self.locate(end - 1).0 + 1).min(self.chunk_count()), + } + } + + /// Continue from byte `start`, yielding chunks below `end_chunk` only. + fn restart(&mut self, start: u64, end_chunk: usize) -> io::Result<()> { + if let Some(message) = &self.failure { + return Err(io::Error::other(message.clone())); + } + self.abort_in_flight(); + let (chunk, skip) = self.locate(start); + self.next = chunk; + self.skip = skip; + self.end = end_chunk; + self.attempts = 0; + Ok(()) + } + + fn abort_in_flight(&mut self) { + if let Some(task) = self.in_flight.take() { + task.abort(); + } + } + + fn spawn_fetch(&self, index: u32) -> JoinHandle>> { + let http = self.http.clone(); + let template = self.url_template.clone(); + let key = self.content_key; + tokio::spawn(async move { fetch_audio_segment(&http, &template, index, &key).await }) + } +} + +impl Drop for SegmentStream { + fn drop(&mut self) { + self.abort_in_flight(); + } +} + +impl Stream for SegmentStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = &mut *self; + if this.failure.is_some() || this.next >= this.end { + return Poll::Ready(None); + } + let chunk = if this.next == 0 { + this.header.clone() + } else { + let index = this.next as u32; + if this.in_flight.is_none() { + let task = this.spawn_fetch(index); + this.in_flight = Some(task); + } + let task = this.in_flight.as_mut().expect("fetch task was just set"); + let outcome = match Pin::new(task).poll(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Ok(outcome)) => outcome, + Poll::Ready(Err(join)) => Err(anyhow!("segment {index} fetch task: {join}")), + }; + this.in_flight = None; + match outcome { + Ok(bytes) => Bytes::from(bytes), + Err(e) => { + // `stream-download` logs the error and polls again; the failure + // flag ends the stream after the last attempt. + this.attempts += 1; + if this.attempts >= MAX_ATTEMPTS { + this.failure = Some(format!("{e:#}")); + } + return Poll::Ready(Some(Err(e))); + } + } + }; + let skip = this.skip.min(chunk.len()); + this.next += 1; + this.skip = 0; + this.attempts = 0; + Poll::Ready(Some(Ok(chunk.slice(skip..)))) + } +} + +impl SourceStream for SegmentStream { + type Params = Self; + type StreamCreationError = Infallible; + + async fn create(params: Self) -> Result { + Ok(params) + } + + fn content_length(&self) -> Option { + Some(self.total_bytes()) + } + + async fn seek_range(&mut self, start: u64, end: Option) -> io::Result<()> { + let bound = self.chunk_bound(end); + self.restart(start, bound) + } + + async fn reconnect(&mut self, current_position: u64) -> io::Result<()> { + let bound = self.end; + self.restart(current_position, bound) + } + + fn supports_seek(&self) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use std::io::{Read, Seek, SeekFrom}; + + use futures::StreamExt; + + use super::super::download::fetch_init; + use super::super::download::test_support::*; + use super::*; + + fn stream_of(lengths: &[u32]) -> SegmentStream { + let init = InitSegment { + header: vec![0; 10], + segment_lengths: lengths.to_vec(), + sample_rate: 44_100, + bits_per_sample: 16, + }; + SegmentStream::new( + Client::new(), + "http://127.0.0.1:1/$SEGMENT$".to_string(), + KEY, + &init, + ) + } + + async fn fixture_stream(segments: Vec>) -> SegmentStream { + let template = serve(segments).await; + let http = Client::new(); + let init = fetch_init(&http, &template).await.unwrap(); + SegmentStream::new(http, template, KEY, &init) + } + + async fn collect(stream: &mut SegmentStream) -> Vec { + let mut out = Vec::new(); + while let Some(chunk) = stream.next().await { + out.extend_from_slice(&chunk.unwrap()); + } + out + } + + #[tokio::test] + async fn locate_maps_offsets_to_chunks() { + let s = stream_of(&[70, 20]); + assert_eq!(s.total_bytes(), 100); + assert_eq!(s.locate(0), (0, 0)); + assert_eq!(s.locate(9), (0, 9)); + assert_eq!(s.locate(10), (1, 0)); + assert_eq!(s.locate(45), (1, 35)); + assert_eq!(s.locate(80), (2, 0)); + assert_eq!(s.locate(99), (2, 19)); + assert_eq!(s.locate(100), (3, 0)); + assert_eq!(s.locate(500), (3, 0)); + } + + #[tokio::test] + async fn chunk_bound_covers_the_chunk_that_holds_the_last_byte() { + let s = stream_of(&[70, 20]); + assert_eq!(s.chunk_bound(None), 3); + assert_eq!(s.chunk_bound(Some(0)), 0); + assert_eq!(s.chunk_bound(Some(10)), 1); + assert_eq!(s.chunk_bound(Some(11)), 2); + assert_eq!(s.chunk_bound(Some(100)), 3); + assert_eq!(s.chunk_bound(Some(1000)), 3); + } + + #[tokio::test] + async fn stream_yields_header_then_decrypted_segments() { + let (segments, expected) = fixture_track(); + let mut stream = fixture_stream(segments).await; + assert_eq!(stream.total_bytes(), expected.len() as u64); + assert_eq!(collect(&mut stream).await, expected); + } + + #[tokio::test] + async fn seek_range_restarts_inside_a_segment_and_honors_the_end_bound() { + let (segments, expected) = fixture_track(); + let mut stream = fixture_stream(segments).await; + stream.seek_range(45, None).await.unwrap(); + assert_eq!(collect(&mut stream).await, expected[45..]); + // Bytes 0..50 lie in the header and segment 1 (0..112): both are yielded. + stream.seek_range(0, Some(50)).await.unwrap(); + assert_eq!(collect(&mut stream).await, expected[..112]); + stream.seek_range(112, None).await.unwrap(); + assert_eq!(collect(&mut stream).await, expected[112..]); + } + + #[tokio::test] + async fn missing_segment_fails_after_the_last_attempt() { + let (mut segments, expected) = fixture_track(); + segments.pop(); + let mut stream = fixture_stream(segments).await; + let mut out = Vec::new(); + let mut errors = 0; + while let Some(chunk) = stream.next().await { + match chunk { + Ok(bytes) => out.extend_from_slice(&bytes), + Err(_) => errors += 1, + } + } + assert_eq!(out, expected[..112]); + assert_eq!(errors, MAX_ATTEMPTS); + assert!(stream.seek_range(112, None).await.is_err()); + } + + #[tokio::test] + async fn reader_returns_the_track_and_fills_the_tempfile() { + let (segments, expected) = fixture_track(); + let stream = fixture_stream(segments).await; + let tmp = NamedTempFile::new().unwrap(); + let mut reader = open(stream, &tmp).await.unwrap(); + let bytes = tokio::task::spawn_blocking(move || { + let mut out = Vec::new(); + reader.read_to_end(&mut out).map(|_| out) + }) + .await + .unwrap() + .unwrap(); + assert_eq!(bytes, expected); + assert_eq!(std::fs::read(tmp.path()).unwrap(), expected); + } + + #[tokio::test] + async fn reader_seeks_within_the_track() { + let (segments, expected) = fixture_track(); + let stream = fixture_stream(segments).await; + let tmp = NamedTempFile::new().unwrap(); + let mut reader = open(stream, &tmp).await.unwrap(); + let tail_start = expected.len() - 15; + let (tail, all) = tokio::task::spawn_blocking(move || { + reader.seek(SeekFrom::Start(tail_start as u64)).unwrap(); + let mut tail = Vec::new(); + reader.read_to_end(&mut tail).unwrap(); + reader.seek(SeekFrom::Start(0)).unwrap(); + let mut all = Vec::new(); + reader.read_to_end(&mut all).unwrap(); + (tail, all) + }) + .await + .unwrap(); + assert_eq!(tail, expected[tail_start..]); + assert_eq!(all, expected); + } + + #[tokio::test] + async fn reader_fails_when_a_segment_is_missing() { + let (mut segments, _) = fixture_track(); + segments.pop(); + let stream = fixture_stream(segments).await; + let tmp = NamedTempFile::new().unwrap(); + let mut reader = open(stream, &tmp).await.unwrap(); + let result = tokio::task::spawn_blocking(move || { + let mut out = Vec::new(); + reader.read_to_end(&mut out) + }) + .await + .unwrap(); + assert!(result.is_err()); + } +} diff --git a/src/tui/ui/library.rs b/src/tui/ui/library.rs index 6441f4ba..83bef1bd 100644 --- a/src/tui/ui/library.rs +++ b/src/tui/ui/library.rs @@ -93,20 +93,7 @@ pub fn draw_playlist_block(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { let items: Vec = if app.qobuz_playlists.is_empty() { vec!["(not logged in \u{2014} press `d`, pick Qobuz)".to_string()] } else { - app - .qobuz_playlists - .iter() - .map(|p| { - let icon = if p.uri.starts_with("qobuz:favorites:") { - "\u{2665}" - } else if p.uri.starts_with("qobuz:album:") { - "\u{1F4BF}" - } else { - "\u{1F3B5}" - }; - format!("{icon} {}", p.name) - }) - .collect() + app.qobuz_playlists.iter().map(|p| p.name.clone()).collect() }; draw_selectable_list( f, diff --git a/tools/gates.count b/tools/gates.count index da4b9578..1493fe14 100644 --- a/tools/gates.count +++ b/tools/gates.count @@ -13,4 +13,4 @@ synthetic_keys_in_mouse_handler = 3 # target 0 (the content-table re-entries wildcard_arms_in_action_tree = 0 # target 0, must stay 0 view_writes_outside_tui = 12 # target 0 (producers outside tui/ and core/app/ writing App::view) action_refs_in_tui_handlers = 173 # adoption: may only rise -test_attribute_total = 1720 # adoption: may only rise +test_attribute_total = 1728 # adoption: may only rise From 3c75301a16322ce7ed29d6a1c7d4c9dcb098ec1f Mon Sep 17 00:00:00 2001 From: LargeModGames <84450916+LargeModGames@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:41:18 +0200 Subject: [PATCH 4/7] fix(sources): review fixes, shared helpers, log volume - Qobuz: the sink clear and append never run under the App lock; a queue takeover aborts the fetch in flight; the abort handle is stored under the lock that stamps fetch_id; play_index drops the previous track's file; the queue lane rides the progressive stream and shows the delivered format label. - Shared across sources: snapshot_tracks, App::take_decoded_sessions_except, App::show_source_search_tracks, auth::save_login, one paginate loop. - Decoded sources apply the volume percent on the same logarithmic curve as native streaming, so one setting is equally loud on every source. --- .github/copilot-instructions.md | 10 +- AGENTS.md | 10 +- CLAUDE.md | 12 +- src/core/app/playback_routing.rs | 46 ++- src/core/app/playlist_pages.rs | 25 ++ src/core/app/queue.rs | 4 +- src/core/app/queue_suspend.rs | 38 +- src/core/first_run.rs | 7 +- src/core/user_config.rs | 11 +- src/infra/audio/player.rs | 48 ++- src/infra/local/dispatch.rs | 47 +-- src/infra/qobuz/auth.rs | 38 +- src/infra/qobuz/dispatch.rs | 482 ++++++++++---------------- src/infra/qobuz/mod.rs | 126 +++---- src/infra/qobuz/stream/download.rs | 102 +----- src/infra/qobuz/stream/progressive.rs | 6 +- src/infra/queue/dispatch.rs | 50 +-- src/infra/queue/mod.rs | 88 +++++ src/infra/radio/dispatch.rs | 69 +--- src/infra/subsonic/dispatch.rs | 172 +-------- src/infra/youtube/dispatch.rs | 121 +------ src/tui/handlers/track_table.rs | 8 +- src/tui/ui/player.rs | 2 +- tools/gates.count | 2 +- 24 files changed, 606 insertions(+), 918 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 64792c46..41ac1fed 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -361,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 | @@ -370,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 @@ -436,10 +437,9 @@ working in those directories. 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`. + 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/AGENTS.md b/AGENTS.md index daa45925..9abce686 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -363,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 | @@ -372,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 @@ -438,10 +439,9 @@ working in those directories. 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`. + 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/CLAUDE.md b/CLAUDE.md index cc1c6700..d14db585 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ cargo run --features all-sources ## CI Checks (run before opening a PR) ```bash -cargo fmt --all +car2go fmt --all cargo clippy --no-default-features --features telemetry,tui -- -D warnings cargo test --no-default-features --features telemetry,tui ``` @@ -363,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 | @@ -372,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 @@ -438,10 +439,9 @@ working in those directories. 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`. + 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/src/core/app/playback_routing.rs b/src/core/app/playback_routing.rs index b589a2b1..cd1d0f47 100644 --- a/src/core/app/playback_routing.rs +++ b/src/core/app/playback_routing.rs @@ -157,9 +157,49 @@ 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( diff --git a/src/core/app/playlist_pages.rs b/src/core/app/playlist_pages.rs index 611faf55..2e5a283f 100644 --- a/src/core/app/playlist_pages.rs +++ b/src/core/app/playlist_pages.rs @@ -136,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 7bf2894d..8961d3a4 100644 --- a/src/core/app/queue.rs +++ b/src/core/app/queue.rs @@ -177,8 +177,8 @@ 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( diff --git a/src/core/app/queue_suspend.rs b/src/core/app/queue_suspend.rs index 2f871799..f35979ab 100644 --- a/src/core/app/queue_suspend.rs +++ b/src/core/app/queue_suspend.rs @@ -75,22 +75,27 @@ impl App { }); return; } - #[cfg(feature = "internet-radio")] - if let Some(radio) = self.radio_playback.take() { - radio.player.stop(); - self.queue_suspended = Some(crate::core::queue::SuspendedContext::Radio { - station: radio.station, - }); - } #[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(); + self.queue_suspended = Some(crate::core::queue::SuspendedContext::Radio { + station: radio.station, + }); } } @@ -131,22 +136,27 @@ impl App { }); return; } - #[cfg(feature = "internet-radio")] - if let Some(radio) = self.radio_playback.take() { - radio.player.stop(); - self.queue_suspended = Some(crate::core::queue::SuspendedContext::Radio { - station: radio.station, - }); - } #[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(); + self.queue_suspended = Some(crate::core::queue::SuspendedContext::Radio { + station: radio.station, + }); } } diff --git a/src/core/first_run.rs b/src/core/first_run.rs index f15ae094..0fe54287 100644 --- a/src/core/first_run.rs +++ b/src/core/first_run.rs @@ -19,7 +19,7 @@ use crate::core::onboarding::Onboarding; use crate::core::source::Source; use crate::core::state::RuntimeState; use crate::core::user_config::UserConfig; -#[cfg(any(feature = "subsonic", feature = "qobuz"))] +#[cfg(feature = "subsonic")] use anyhow::anyhow; use anyhow::Result; @@ -223,10 +223,7 @@ async fn configure_qobuz(onboarding: &dyn Onboarding) -> Result<()> { return Ok(()); } }; - let path = crate::core::paths::qobuz_credentials_path() - .ok_or_else(|| anyhow!("cannot resolve the spotatui config directory"))?; - auth::write_credentials(&path, &credentials)?; - auth::set_token(Some(credentials.user_auth_token.clone())); + auth::save_login(&credentials)?; onboarding.info("Logged in to Qobuz."); // Best-effort stream check: a failure is not fatal, the login is saved. diff --git a/src/core/user_config.rs b/src/core/user_config.rs index b6911b5b..31a2533c 100644 --- a/src/core/user_config.rs +++ b/src/core/user_config.rs @@ -53,13 +53,16 @@ pub fn normalize_tick_rate_milliseconds(value: i64) -> u64 { const QOBUZ_QUALITY_IDS: [u8; 4] = [5, 6, 7, 27]; /// Settings-screen labels for the Qobuz qualities, in [`QOBUZ_QUALITY_IDS`] order. 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). pub fn qobuz_quality_label(quality: u8) -> &'static str { let index = QOBUZ_QUALITY_IDS .iter() .position(|&id| id == quality) - .unwrap_or(1); + .unwrap_or(QOBUZ_QUALITY_DEFAULT_INDEX); QOBUZ_QUALITY_LABELS[index] } @@ -68,7 +71,7 @@ pub fn qobuz_quality_from_label(label: &str) -> u8 { let index = QOBUZ_QUALITY_LABELS .iter() .position(|&l| l == label) - .unwrap_or(1); + .unwrap_or(QOBUZ_QUALITY_DEFAULT_INDEX); QOBUZ_QUALITY_IDS[index] } @@ -77,7 +80,7 @@ fn qobuz_quality_or_default(quality: u8) -> u8 { if QOBUZ_QUALITY_IDS.contains(&quality) { quality } else { - 6 + QOBUZ_QUALITY_DEFAULT } } @@ -1101,7 +1104,7 @@ impl UserConfig { subsonic_username: None, subsonic_password: None, ytdlp_path: None, - qobuz_quality: 6, + qobuz_quality: QOBUZ_QUALITY_DEFAULT, radio_stations: Vec::new(), // --- Phase 2: icons / glyphs / labels (defaults = today's glyphs) --- gauge_filled_icon: "⣿".to_string(), diff --git a/src/infra/audio/player.rs b/src/infra/audio/player.rs index 5ed2d13d..2471fe94 100644 --- a/src/infra/audio/player.rs +++ b/src/infra/audio/player.rs @@ -154,7 +154,9 @@ impl LocalPlayer { Ok(PreparedStream(Box::new(decoder))) } - /// Play a prepared stream, replacing whatever was playing. Does not block. + /// 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(); @@ -184,10 +186,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. @@ -285,9 +293,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/local/dispatch.rs b/src/infra/local/dispatch.rs index feb57295..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,42 +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 Qobuz session, for the same short-circuit reason. - #[cfg(feature = "qobuz")] - { - let qobuz = app.lock().await.qobuz_playback.take(); - if let Some(qobuz) = qobuz { - qobuz.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 { @@ -319,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/qobuz/auth.rs b/src/infra/qobuz/auth.rs index 78b1e9ef..17641ab0 100644 --- a/src/infra/qobuz/auth.rs +++ b/src/infra/qobuz/auth.rs @@ -21,11 +21,11 @@ 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); -pub const APP_ID_ENV: &str = "SPOTATUI_QOBUZ_APP_ID"; -pub const APP_SECRET_ENV: &str = "SPOTATUI_QOBUZ_APP_SECRET"; -pub const OAUTH_KEY_ENV: &str = "SPOTATUI_QOBUZ_OAUTH_KEY"; +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 const TOKEN_ENV: &str = "SPOTATUI_QOBUZ_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( @@ -56,7 +56,7 @@ pub fn decode_base64(text: &str) -> Result> { // --------------------------------------------------------------------------- /// The constants from the env overrides, when all three are set and non-empty. -pub fn constants_from_env_with( +fn constants_from_env_with( app_id: Option, app_secret: Option, oauth_key: Option, @@ -79,7 +79,7 @@ fn constants_from_env() -> Option { } /// The bundle path (`/resources//bundle.js`) from the login page. -pub fn bundle_path(login_html: &str) -> Option { +fn bundle_path(login_html: &str) -> Option { Regex::new(r#"src="(/resources/[^"/]+/bundle\.js)""#) .ok()? .captures(login_html) @@ -87,7 +87,7 @@ pub fn bundle_path(login_html: &str) -> Option { } /// The version segment of a bundle path. -pub fn bundle_version(path: &str) -> Option { +fn bundle_version(path: &str) -> Option { path .strip_prefix("/resources/")? .strip_suffix("/bundle.js") @@ -102,7 +102,7 @@ fn capture(js: &str, pattern: &str, what: &str) -> Result { } /// Extract the three constants from the bundle source. -pub fn parse_bundle(js: &str, version: &str) -> Result { +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]+)\)"#)?; @@ -220,7 +220,7 @@ pub struct QobuzCredentials { } /// Read the credentials file; `None` when missing, unreadable, or without a token. -pub fn read_credentials(path: &Path) -> Option { +fn read_credentials(path: &Path) -> Option { std::fs::read_to_string(path) .ok() .and_then(|text| serde_yaml::from_str::(&text).ok()) @@ -228,7 +228,7 @@ pub fn read_credentials(path: &Path) -> Option { } /// Write the credentials file with private permissions. -pub fn write_credentials(path: &Path, credentials: &QobuzCredentials) -> Result<()> { +fn write_credentials(path: &Path, credentials: &QobuzCredentials) -> Result<()> { if let Some(dir) = path.parent() { crate::core::paths::ensure_private_dir(dir)?; } @@ -237,6 +237,14 @@ pub fn write_credentials(path: &Path, credentials: &QobuzCredentials) -> Result< .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 @@ -245,7 +253,7 @@ pub fn token_with(env_token: Option, saved: Option) -> } /// The saved or overridden token, if any. -pub fn load_token() -> Option { +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)), @@ -275,15 +283,15 @@ pub fn set_token(token: Option) { const OAUTH_URL: &str = "https://www.qobuz.com/signin/oauth"; /// How long the browser round trip may take before the login is abandoned. -pub const LOGIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180); +const LOGIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180); /// The authorization URL for `app_id` with a `localhost:` redirect. -pub fn login_url(app_id: &str, port: u16) -> String { +fn login_url(app_id: &str, port: u16) -> String { format!("{OAUTH_URL}?ext_app_id={app_id}&redirect_url=http%3A%2F%2Flocalhost%3A{port}") } /// The authorization code from the callback URL (`code_autorisation`, else `code`). -pub fn callback_code(url: &str) -> Option { +fn callback_code(url: &str) -> Option { let parsed = url::Url::parse(url).ok()?; let mut fallback = None; for (key, value) in parsed.query_pairs() { @@ -360,7 +368,7 @@ impl LoginAttempt { } /// `GET oauth/callback`: trade the browser's code for the user token. -pub async fn exchange_code(constants: &QobuzBundleCache, code: &str) -> Result { +async fn exchange_code(constants: &QobuzBundleCache, code: &str) -> Result { let source = super::QobuzSource::new(&constants.app_id, &constants.app_secret, ""); let reply: super::types::OauthCallback = source .get( diff --git a/src/infra/qobuz/dispatch.rs b/src/infra/qobuz/dispatch.rs index 9ababe68..700d74d6 100644 --- a/src/infra/qobuz/dispatch.rs +++ b/src/infra/qobuz/dispatch.rs @@ -31,17 +31,14 @@ use tempfile::NamedTempFile; use tokio::sync::Mutex; use super::auth::{self, QobuzBundleCache}; -use super::stream::download::TrackDownload; use super::stream::progressive; use super::{track_id_from_uri, QobuzPlaybackState, QobuzSource, StreamQuality, Unauthorized}; -use crate::core::app::{App, SearchResultBlock, TrackTableContext}; -use crate::core::pagination::Paged; -use crate::core::plugin_api::TrackInfo; -use crate::core::source::{MediaSource, Searcher}; +use crate::core::app::{App, TrackTableContext}; +use crate::core::source::{MediaSource, Searcher, Source}; use crate::core::state::PersistedRuntimeState; use crate::infra::audio::{LocalPlayer, PreparedStream}; use crate::infra::network::IoEvent; -use crate::infra::queue::{advance_index, replay_file}; +use crate::infra::queue::{advance_index, replay_file, snapshot_tracks}; const LOGIN_EXPIRED: &str = "Qobuz: login expired, press `d` and pick Qobuz to log in again"; @@ -125,7 +122,7 @@ pub async fn route_qobuz_event(app: &Arc>, event: &IoEvent) -> bool { }, IoEvent::ChangeVolume(volume) => match player(app).await { Some(p) => { - p.set_volume(*volume as f32 / 100.0); + p.set_volume(*volume); let mut app = app.lock().await; app.runtime_state.volume_percent = *volume; app.schedule_state_save(PersistedRuntimeState::volume_percent(*volume)); @@ -274,15 +271,9 @@ async fn begin_login(app: &Arc>) { tokio::spawn(async move { match attempt.wait().await { Ok(credentials) => { - match crate::core::paths::qobuz_credentials_path() { - Some(path) => { - if let Err(e) = auth::write_credentials(&path, &credentials) { - log::warn!("[qobuz] cannot save credentials: {e:#}"); - } - } - None => log::warn!("[qobuz] no config directory to save credentials in"), + if let Err(e) = auth::save_login(&credentials) { + log::warn!("[qobuz] cannot save credentials: {e:#}"); } - auth::set_token(Some(credentials.user_auth_token)); app.lock().await.set_status_message("Qobuz: logged in", 4); if let Some(tx) = io_tx { let _ = tx.send(IoEvent::GetQobuzPlaylists); @@ -338,22 +329,7 @@ async fn run_qobuz_search(app: &Arc>, query: &str) { return; }; match source.search(query).await { - Ok(results) => { - let total = results.tracks.len() as u32; - let mut app = app.lock().await; - app.search_results.tracks = Some(Paged { - items: results.tracks, - total, - ..Default::default() - }); - app.search_results.albums = None; - app.search_results.artists = None; - app.search_results.playlists = None; - app.search_results.shows = None; - app.search_results.selected_tracks_index = Some(0); - app.search_results.hovered_block = SearchResultBlock::SongSearch; - app.search_results.selected_block = SearchResultBlock::Empty; - } + Ok(results) => app.lock().await.show_source_search_tracks(results.tracks), Err(e) => report(app, "search", e).await, } } @@ -372,42 +348,7 @@ async fn player(app: &Arc>) -> Option> { .map(|s| Arc::clone(&s.player)) } -/// Snapshot the `TrackInfo`s for `uris` in order from the track table, then -/// the search results (a play can come from either view). Unknown URIs are dropped. -fn snapshot_tracks( - table: &[TrackInfo], - search: Option<&[TrackInfo]>, - uris: &[String], -) -> Vec { - uris - .iter() - .filter_map(|uri| find_track(table, search, uri).cloned()) - .collect() -} - -fn find_track<'a>( - table: &'a [TrackInfo], - search: Option<&'a [TrackInfo]>, - uri: &str, -) -> Option<&'a TrackInfo> { - let matches = |t: &&TrackInfo| t.uri.as_deref() == Some(uri); - table - .iter() - .find(matches) - .or_else(|| search.and_then(|s| s.iter().find(matches))) -} - /// Release every other backend so only Qobuz holds the output device. -#[cfg_attr( - not(any( - feature = "streaming", - feature = "local-files", - feature = "subsonic", - feature = "internet-radio", - feature = "youtube" - )), - allow(unused_variables) -)] async fn release_other_backends(app: &Arc>) { // Pause native Spotify so librespot releases the device. #[cfg(feature = "streaming")] @@ -419,33 +360,9 @@ async fn release_other_backends(app: &Arc>) { } // The other decoded sources never see this `qobuz:` start (the pump // short-circuits), so their sessions are torn down here. - #[cfg(feature = "local-files")] - { - let local = app.lock().await.local_playback.take(); - if let Some(local) = local { - local.player.stop(); - } - } - #[cfg(feature = "subsonic")] - { - let subsonic = app.lock().await.subsonic_playback.take(); - if let Some(subsonic) = subsonic { - subsonic.player.stop(); - } - } - #[cfg(feature = "internet-radio")] - { - let radio = app.lock().await.radio_playback.take(); - if let Some(radio) = radio { - radio.player.stop(); - } - } - #[cfg(feature = "youtube")] - { - let youtube = app.lock().await.youtube_playback.take(); - if let Some(youtube) = youtube { - youtube.player.stop(); - } + let players = app.lock().await.take_decoded_sessions_except(Source::Qobuz); + for player in players { + player.stop_detached(); } } @@ -474,101 +391,105 @@ fn next_fetch_id() -> u64 { FETCH_SEQ.fetch_add(1, Ordering::Relaxed) } -/// Start a track download into a fresh tempfile: segment 0 is parsed and the -/// header written, so the total size is known before the long part. -async fn begin_track_download( - source: &QobuzSource, - track_id: &str, - quality: u8, -) -> Result<(NamedTempFile, TrackDownload)> { - let tmp = NamedTempFile::new().context("creating temp file for Qobuz stream")?; - let download = source.begin_download(track_id, quality, tmp.path()).await?; - Ok((tmp, download)) -} - /// Download the track at `uri` into a tempfile, for the native queue engine's -/// off-pump fetch (playing is the queue's job). +/// off-pump fetch (playing is the queue's job), and return its delivered +/// format label. Reads the progressive stream to its end, so its per-segment +/// retries apply here too. pub(crate) async fn download_for_queue( source: &QobuzSource, uri: &str, quality: u8, -) -> Result { - let track_id = track_id_from_uri(uri)?.to_string(); - let (tmp, download) = begin_track_download(source, &track_id, quality).await?; - download.finish().await?; - Ok(tmp) +) -> Result<(NamedTempFile, String)> { + let track_id = track_id_from_uri(uri)?; + let tmp = NamedTempFile::new().context("creating temp file for Qobuz stream")?; + let (init, stream) = source.begin_stream(track_id, quality).await?; + let mut reader = progressive::open(stream, &tmp).await?; + tokio::task::spawn_blocking(move || std::io::copy(&mut reader, &mut std::io::sink())) + .await + .context("download task")? + .context("downloading the Qobuz stream")?; + Ok((tmp, init.quality().label())) } -/// Open the track's stream for the published session on a detached task and -/// build its decoder, then play it when the session still waits for this -/// fetch. The task's abort handle is stored on the session, so a skip, a new -/// queue, or a teardown cancels it; a decoder build already in progress ends -/// on its own and drops its stream, which cancels that download. -async fn spawn_fetch( +/// Open the track's stream into a fresh tempfile and build its decoder, whose +/// first read waits for the prefetch. +async fn prepare_track( + source: &QobuzSource, + track_id: &str, + quality: u8, +) -> Result<(NamedTempFile, StreamQuality, PreparedStream)> { + let tmp = NamedTempFile::new().context("creating temp file for Qobuz stream")?; + let (init, stream) = source.begin_stream(track_id, quality).await?; + let delivered = init.quality(); + let total = init.total_bytes(); + let reader = progressive::open(stream, &tmp).await?; + let mime = if delivered.flac { + "audio/flac" + } else { + "audio/mpeg" + }; + let prepared = tokio::task::spawn_blocking(move || { + LocalPlayer::prepare_stream(reader, Some(mime), Some(total)) + }) + .await + .context("decoder task")??; + Ok((tmp, delivered, prepared)) +} + +/// Open the track's stream on a detached task and build its decoder, then +/// play it when the session still waits for this fetch. Called under the +/// `App` lock that stamped `session.fetch_id`, so the abort handle is in +/// place before any skip looks for it: a skip, a new queue, a queue takeover, +/// or a teardown cancels the task, and a decoder build already in progress +/// ends on its own and drops its stream, which cancels that download. +fn spawn_fetch( app: &Arc>, - source: Arc, + session: &mut QobuzPlaybackState, track_id: String, - fetch_id: u64, + quality: u8, ) { - let task_app = Arc::clone(app); + let app = Arc::clone(app); + let source = Arc::clone(&session.source); + let fetch_id = session.fetch_id; let task = tokio::spawn(async move { - let app = task_app; - let quality = app.lock().await.user_config.behavior.qobuz_quality; - let tmp = match NamedTempFile::new().context("creating temp file for Qobuz stream") { - Ok(tmp) => tmp, - Err(e) => return fail_fetch(&app, fetch_id, "stream", e).await, - }; - let (init, stream) = match source.begin_stream(&track_id, quality).await { - Ok(started) => started, - Err(e) => return fail_fetch(&app, fetch_id, "stream", e).await, - }; - let delivered = init.quality(); - let total = init.total_bytes(); - let reader = match progressive::open(stream, &tmp).await { - Ok(reader) => reader, - Err(e) => return fail_fetch(&app, fetch_id, "stream", e).await, - }; - // The decoder's first read waits for the prefetch: off the App lock. - let mime = if delivered.flac { - "audio/flac" - } else { - "audio/mpeg" - }; - let prepared = tokio::task::spawn_blocking(move || { - LocalPlayer::prepare_stream(reader, Some(mime), Some(total)) - }) - .await; - let prepared = match prepared { - Ok(Ok(prepared)) => prepared, - Ok(Err(e)) => return fail_fetch(&app, fetch_id, "play", e).await, - Err(e) => return fail_fetch(&app, fetch_id, "play", anyhow::anyhow!(e)).await, - }; - commit_fetch(&app, fetch_id, tmp, delivered, prepared).await; + match prepare_track(&source, &track_id, quality).await { + Ok((tmp, delivered, prepared)) => { + commit_fetch(&app, fetch_id, tmp, delivered, prepared).await + } + Err(e) => fail_fetch(&app, fetch_id, "stream", e).await, + } }); - if let Some(s) = app - .lock() - .await - .qobuz_playback - .as_mut() - .filter(|s| s.fetch_id == fetch_id) - { - s.fetch = Some(task.abort_handle()); - } + session.fetch = Some(task.abort_handle()); } /// A failed fetch: tear the session down only if it still waits for this -/// fetch, under one lock, and report the error from the same guard. +/// fetch, and report the error. Under the native queue the session stays for +/// the queue's resume, which fetches again. async fn fail_fetch(app: &Arc>, fetch_id: u64, step: &str, err: anyhow::Error) { let mut guard = app.lock().await; - let Some(s) = guard.qobuz_playback.take_if(|s| s.fetch_id == fetch_id) else { + if guard + .qobuz_playback + .as_ref() + .is_none_or(|s| s.fetch_id != fetch_id) + { return; - }; - s.player.stop(); + } + if guard.queue_owns_playback() { + log::warn!("[qobuz] {step}: {err:#}"); + return; + } + let session = guard.qobuz_playback.take(); report_locked(&mut guard, step, err); + drop(guard); + if let Some(s) = session { + Arc::clone(&s.player).stop_detached(); + } } -/// Play the prepared stream and finalize the session, under one lock so a -/// concurrent skip (which restamps `fetch_id` under the same lock) cannot +/// Play the prepared stream and finalize the session. The previous track is +/// cleared off the `App` lock first (the sink clear waits for the audio +/// thread, which a stalled stream holds), then the session is checked again +/// under the lock so a concurrent skip (which restamps `fetch_id`) cannot /// interleave. A stale stream is dropped, which cancels its download. async fn commit_fetch( app: &Arc>, @@ -577,47 +498,62 @@ async fn commit_fetch( quality: StreamQuality, prepared: PreparedStream, ) { + let claimed = { + let guard = app.lock().await; + // The native queue owns the sink: the session stays for its resume. + if guard.queue_owns_playback() { + return; + } + guard + .qobuz_playback + .as_ref() + .filter(|s| s.fetch_id == fetch_id) + // A pause pressed during the fetch window applies to the previous + // track's sink; a fresh player starts paused, so only a session that + // already played something counts. + .map(|s| { + ( + Arc::clone(&s.player), + s.tempfile.is_some() && s.player.is_paused(), + ) + }) + }; + let Some((player, was_paused)) = claimed else { + return; + }; + let stop_player = Arc::clone(&player); + if tokio::task::spawn_blocking(move || stop_player.stop()) + .await + .is_err() + { + return; + } let mut guard = app.lock().await; - let Some((player, was_paused)) = guard + let Some(s) = guard .qobuz_playback - .as_ref() + .as_mut() .filter(|s| s.fetch_id == fetch_id) - // A pause pressed during the fetch window applies to the previous - // track's sink; a fresh player starts paused, so only a session that - // already played something counts. - .map(|s| { - ( - Arc::clone(&s.player), - s.tempfile.is_some() && s.player.is_paused(), - ) - }) else { return; }; + s.tempfile = Some(tmp); + s.quality = Some(quality); + s.advancing = false; + s.fetch = None; + let resume = s.resume_at.take(); + let display = s.current().map(|t| t.name.clone()); + // The sink is empty after the stop, so this clear returns at once. player.play_prepared(prepared); - player.set_volume(guard.runtime_state.volume_percent as f32 / 100.0); - let mut seek_to = None; - let display = match guard.qobuz_playback.as_mut() { - Some(s) => { - s.tempfile = Some(tmp); - s.quality = Some(quality); - s.advancing = false; - s.fetch = None; - let resume = s.resume_at.take(); - seek_to = resume.map(|r| r.position_ms).filter(|&ms| ms > 0); - if was_paused || resume.is_some_and(|r| r.paused) { - player.pause(); - } - s.current().map(|t| t.name.clone()) - } - None => None, - }; + player.set_volume(guard.runtime_state.volume_percent); + if was_paused || resume.is_some_and(|r| r.paused) { + player.pause(); + } if let Some(display) = display { guard.set_status_message(format!("\u{266a} {display}"), 4); } drop(guard); // The restore seek waits for that part of the download: off the App lock. - if let Some(position_ms) = seek_to { + if let Some(position_ms) = resume.map(|r| r.position_ms).filter(|&ms| ms > 0) { tokio::task::spawn_blocking(move || { let _ = player.seek(Duration::from_millis(position_ms)); }); @@ -661,30 +597,28 @@ async fn start_qobuz_queue(app: &Arc>, uris: &[String], start_idx: us // Publish the session now, marked advancing, so the playbar and the skip // keys see it during the download; `commit_fetch` finalizes it. - let fetch_id = next_fetch_id(); - { - let mut guard = app.lock().await; - let mut state = QobuzPlaybackState { - player, - source: Arc::clone(&source), - tracks, - index, - advancing: true, - tempfile: None, - quality: None, - shuffle_backup: None, - fetch_id, - resume_at: None, - fetch: None, - }; - // Honor the player-global decoded shuffle for the freshly built queue. - if guard.decoded_shuffle { - state.set_shuffle(true); - } - // Dropping a previous session aborts its download. - guard.qobuz_playback = Some(state); + let mut guard = app.lock().await; + let quality = guard.user_config.behavior.qobuz_quality; + let mut state = QobuzPlaybackState { + player, + source, + tracks, + index, + advancing: true, + tempfile: None, + quality: None, + shuffle_backup: None, + fetch_id: next_fetch_id(), + resume_at: None, + fetch: None, + }; + // Honor the player-global decoded shuffle for the freshly built queue. + if guard.decoded_shuffle { + state.set_shuffle(true); } - spawn_fetch(app, source, track_id, fetch_id).await; + spawn_fetch(app, &mut state, track_id, quality); + // Dropping a previous session aborts its download. + guard.qobuz_playback = Some(state); } /// Move the queue index in `direction` and play the new track. Returns `true` @@ -747,47 +681,44 @@ async fn replay_current(app: &Arc>) -> bool { /// at once and the download runs off the pump. Used by Next/Previous, the tick's /// auto-advance, and the native queue's resume. pub(crate) async fn play_index(app: &Arc>, target: usize) { - let plan = { - let mut guard = app.lock().await; - let Some(s) = guard.qobuz_playback.as_mut() else { - return; // session torn down between dispatch and here - }; - match s.tracks.get(target) { - None => { - s.advancing = false; - return; - } - Some(track) => match track.uri.as_deref().map(track_id_from_uri) { - Some(Ok(id)) => { - let fetch_id = next_fetch_id(); - // Cancel a superseded download and forget its restore point. - if let Some(fetch) = s.fetch.take() { - fetch.abort(); - } - s.resume_at = None; - s.quality = None; - s.index = target; - s.advancing = true; - s.fetch_id = fetch_id; - Ok((Arc::clone(&s.source), id.to_string(), fetch_id)) - } - _ => Err(()), - }, - } + let mut guard = app.lock().await; + let quality = guard.user_config.behavior.qobuz_quality; + let Some(s) = guard.qobuz_playback.as_mut() else { + return; // session torn down between dispatch and here + }; + let Some(track) = s.tracks.get(target) else { + s.advancing = false; + return; }; - match plan { - Ok((source, track_id, fetch_id)) => spawn_fetch(app, source, track_id, fetch_id).await, - Err(()) => { + let track_id = match track.uri.as_deref().map(track_id_from_uri) { + Some(Ok(id)) => id.to_string(), + _ => { + drop(guard); teardown_qobuz(app).await; set_error(app, "Invalid Qobuz track URI".to_string()).await; + return; } + }; + // Cancel a superseded download; the file, format, and restore point of the + // previous track go with it, so the session never describes two tracks. + if let Some(fetch) = s.fetch.take() { + fetch.abort(); } + s.tempfile = None; + s.quality = None; + s.resume_at = None; + s.index = target; + s.advancing = true; + s.fetch_id = next_fetch_id(); + spawn_fetch(app, s, track_id, quality); } -/// End the Qobuz session, releasing the output device and the tempfile. +/// End the Qobuz session, releasing the output device and the tempfile. The +/// player stops off the `App` lock. async fn teardown_qobuz(app: &Arc>) { - if let Some(s) = app.lock().await.qobuz_playback.take() { - s.player.stop(); + let session = app.lock().await.qobuz_playback.take(); + if let Some(s) = session { + Arc::clone(&s.player).stop_detached(); } } @@ -795,53 +726,6 @@ async fn teardown_qobuz(app: &Arc>) { mod tests { use super::*; - fn track(uri: &str, name: &str) -> TrackInfo { - TrackInfo { - uri: Some(uri.to_string()), - name: name.to_string(), - artists: vec!["Artist".to_string()], - album: "Album".to_string(), - duration_ms: 1000, - id: None, - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - } - } - - #[test] - fn snapshot_finds_tracks_in_table_preserving_order() { - let table = vec![track("qobuz:track:a", "A"), track("qobuz:track:b", "B")]; - let snap = snapshot_tracks( - &table, - None, - &["qobuz:track:b".to_string(), "qobuz:track:a".to_string()], - ); - assert_eq!(snap.len(), 2); - assert_eq!(snap[0].name, "B"); - assert_eq!(snap[1].name, "A"); - } - - #[test] - fn snapshot_falls_back_to_search_results_for_search_to_play() { - let table = vec![track("qobuz:track:browsed", "Browsed")]; - let search = vec![track("qobuz:track:searched", "Searched")]; - let snap = snapshot_tracks(&table, Some(&search), &["qobuz:track:searched".to_string()]); - assert_eq!(snap.len(), 1); - assert_eq!(snap[0].name, "Searched"); - } - - #[test] - fn snapshot_drops_unknown_uris() { - let table = vec![track("qobuz:track:a", "A")]; - let snap = snapshot_tracks(&table, None, &["qobuz:track:missing".to_string()]); - assert!(snap.is_empty()); - } - #[test] fn qobuz_uris_are_recognised_by_scheme() { assert!(is_qobuz_uri("qobuz:track:1")); diff --git a/src/infra/qobuz/mod.rs b/src/infra/qobuz/mod.rs index 3677fd16..f14cc8b6 100644 --- a/src/infra/qobuz/mod.rs +++ b/src/infra/qobuz/mod.rs @@ -19,7 +19,6 @@ pub mod stream; mod types; use std::fmt; -use std::path::Path; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -32,7 +31,7 @@ use crate::core::plugin_api::{ArtistRef, PlaylistInfo, SearchResults, TrackInfo} use crate::core::source::{MediaSource, Searcher}; use crate::infra::audio::LocalPlayer; use stream::cmaf::InitSegment; -use stream::download::{self, TrackDownload}; +use stream::download; use stream::progressive::SegmentStream; pub use stream::cmaf::StreamQuality; @@ -98,10 +97,10 @@ impl QobuzPlaybackState { const API_BASE: &str = "https://www.qobuz.com/api.json/0.2/"; -pub const TRACK_PREFIX: &str = "qobuz:track:"; -pub const PLAYLIST_PREFIX: &str = "qobuz:playlist:"; -pub const ALBUM_PREFIX: &str = "qobuz:album:"; -pub const FAVORITES_URI: &str = "qobuz:favorites:tracks"; +const TRACK_PREFIX: &str = "qobuz:track:"; +const PLAYLIST_PREFIX: &str = "qobuz:playlist:"; +const ALBUM_PREFIX: &str = "qobuz:album:"; +const FAVORITES_URI: &str = "qobuz:favorites:tracks"; /// Per-request caps; they bound each segment GET, never a whole track. const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); @@ -140,9 +139,9 @@ impl StreamSession { } /// What `file/url` returned for one track, with the content key unwrapped. -pub struct TrackStream { - pub url_template: String, - pub content_key: [u8; 16], +struct TrackStream { + url_template: String, + content_key: [u8; 16], } fn unix_now() -> u64 { @@ -298,7 +297,7 @@ impl QobuzSource { } /// The shared session, renewed when expired. - pub async fn stream_session(&self) -> Result { + async fn stream_session(&self) -> Result { let mut slot = shared_session().lock().await; if let Some(session) = slot.as_ref().filter(|s| s.is_valid_at(unix_now())) { return Ok(session.clone()); @@ -309,7 +308,7 @@ impl QobuzSource { } /// `GET file/url` for one track at `format_id` (5, 6, 7, or 27). - pub async fn file_url( + async fn file_url( &self, session: &StreamSession, track_id: &str, @@ -361,17 +360,6 @@ impl QobuzSource { } } - /// Start a track download into `dest`. - pub async fn begin_download( - &self, - track_id: &str, - format_id: u8, - dest: &Path, - ) -> Result { - let stream = self.track_stream(track_id, format_id).await?; - download::begin(&self.http, &stream.url_template, stream.content_key, dest).await - } - /// Start a progressive stream of a track: segment 0 is fetched, so the /// total size and the delivered format are known before any audio. pub async fn begin_stream( @@ -395,47 +383,37 @@ impl QobuzSource { // ------------------------------------------------------------------------- async fn user_playlists(&self) -> Result> { - let mut out = Vec::new(); - loop { + paginate(|offset| async move { let page: types::UserPlaylists = self .get( "playlist/getUserPlaylists", &[ ("limit", PAGE_LIMIT.to_string()), - ("offset", out.len().to_string()), + ("offset", offset.to_string()), ], ) .await?; - let got = page.playlists.items.len(); - let total = page.playlists.total as usize; - out.extend(page.playlists.items); - if got == 0 || out.len() >= total || out.len() >= MAX_ITEMS { - return Ok(out); - } - } + Ok((page.playlists.items, page.playlists.total as usize)) + }) + .await } async fn favorite_albums(&self) -> Result> { - let mut out = Vec::new(); - loop { + paginate(|offset| async move { let page: types::Favorites = self .get( "favorite/getUserFavorites", &[ ("type", "albums".to_string()), ("limit", PAGE_LIMIT.to_string()), - ("offset", out.len().to_string()), + ("offset", offset.to_string()), ], ) .await?; let albums = page.albums.unwrap_or_default(); - let got = albums.items.len(); - let total = albums.total as usize; - out.extend(albums.items); - if got == 0 || out.len() >= total || out.len() >= MAX_ITEMS { - return Ok(out); - } - } + Ok((albums.items, albums.total as usize)) + }) + .await } async fn favorite_track_count(&self) -> Result { @@ -506,20 +484,33 @@ impl QobuzSource { } async fn listing_tracks(&self, listing: &Listing) -> Result> { - let mut out = Vec::new(); - loop { - let (page, album) = self.listing_page(listing, out.len()).await?; - let got = page.items.len(); - let total = page.total as usize; - out.extend( - page - .items - .iter() - .map(|t| track_to_track_info(t, album.as_ref())), - ); - if got == 0 || out.len() >= total || out.len() >= MAX_ITEMS { - return Ok(out); - } + paginate(|offset| async move { + let (page, album) = self.listing_page(listing, offset).await?; + let tracks = page + .items + .iter() + .map(|t| track_to_track_info(t, album.as_ref())) + .collect(); + Ok((tracks, page.total as usize)) + }) + .await + } +} + +/// Collect every page of a listing; `fetch(offset)` returns one page's items +/// and the listing's total. +async fn paginate(mut fetch: F) -> Result> +where + F: FnMut(usize) -> Fut, + Fut: std::future::Future, usize)>>, +{ + let mut out = Vec::new(); + loop { + let (items, total) = fetch(out.len()).await?; + let got = items.len(); + out.extend(items); + if got == 0 || out.len() >= total || out.len() >= MAX_ITEMS { + return Ok(out); } } } @@ -646,21 +637,14 @@ impl MediaSource for QobuzSource { /// Favorite tracks, then the user's playlists, then favorite albums. async fn playlists(&self) -> Result> { - let mut out = vec![favorites_playlist(self.favorite_track_count().await?)]; - out.extend( - self - .user_playlists() - .await? - .iter() - .map(playlist_to_playlist_info), - ); - out.extend( - self - .favorite_albums() - .await? - .iter() - .map(album_to_playlist_info), - ); + let (favorite_count, playlists, albums) = tokio::try_join!( + self.favorite_track_count(), + self.user_playlists(), + self.favorite_albums() + )?; + let mut out = vec![favorites_playlist(favorite_count)]; + out.extend(playlists.iter().map(playlist_to_playlist_info)); + out.extend(albums.iter().map(album_to_playlist_info)); Ok(out) } diff --git a/src/infra/qobuz/stream/download.rs b/src/infra/qobuz/stream/download.rs index 6d57ee32..ee520ace 100644 --- a/src/infra/qobuz/stream/download.rs +++ b/src/infra/qobuz/stream/download.rs @@ -1,31 +1,15 @@ -//! Segment download: fetch, decrypt, and write the track as segments arrive. -//! -//! Two phases so the caller can read the total size before the long part: -//! [`begin`] fetches segment 0 and writes the codec header, then -//! [`TrackDownload::finish`] streams every audio segment into the same file. -//! The progressive player (`progressive`) shares the segment fetch and skips -//! the file: it yields each decrypted segment to `stream-download` instead. - -use std::fs::File; -use std::io::Write; -use std::path::Path; +//! Segment download: fetch, parse, and decrypt one segment at a time. The +//! progressive stream (`progressive`) drives it and hands each decrypted +//! segment to `stream-download`. use anyhow::{anyhow, Context, Result}; +use bytes::Bytes; use reqwest::Client; use super::cmaf::{self, InitSegment}; const SEGMENT_PLACEHOLDER: &str = "$SEGMENT$"; -/// A track download after segment 0 was parsed and the header was written. -pub struct TrackDownload { - http: Client, - url_template: String, - content_key: [u8; 16], - init: InitSegment, - file: File, -} - /// Fetch and parse segment 0. pub async fn fetch_init(http: &Client, url_template: &str) -> Result { if !url_template.contains(SEGMENT_PLACEHOLDER) { @@ -35,58 +19,18 @@ pub async fn fetch_init(http: &Client, url_template: &str) -> Result Result { - let init = fetch_init(http, url_template).await?; - let mut file = - File::create(dest).with_context(|| format!("creating stream file {}", dest.display()))?; - file - .write_all(&init.header) - .with_context(|| format!("writing stream to {}", dest.display()))?; - Ok(TrackDownload { - http: http.clone(), - url_template: url_template.to_string(), - content_key, - init, - file, - }) -} - -impl TrackDownload { - /// Fetch, decrypt and append every audio segment, then flush the file. - pub async fn finish(mut self) -> Result<()> { - let count = self.init.segment_lengths.len() as u32; - for index in 1..=count { - let bytes = - fetch_audio_segment(&self.http, &self.url_template, index, &self.content_key).await?; - self - .file - .write_all(&bytes) - .with_context(|| format!("segment {index} write"))?; - } - self.file.flush().context("flushing stream file") - } -} - /// Fetch audio segment `index` (1 or later) and return its decrypted frames. pub(super) async fn fetch_audio_segment( http: &Client, url_template: &str, index: u32, content_key: &[u8; 16], -) -> Result> { +) -> Result { let mut bytes = fetch_segment(http, url_template, index).await?; let table = cmaf::parse_segment(&bytes).with_context(|| format!("segment {index} parse"))?; let range = cmaf::decrypt_frames(&mut bytes, &table, content_key) .with_context(|| format!("segment {index} decrypt"))?; - bytes.truncate(range.end); - bytes.drain(..range.start); - Ok(bytes) + Ok(Bytes::from(bytes).slice(range)) } fn segment_url(template: &str, index: u32) -> String { @@ -180,39 +124,11 @@ mod tests { use super::test_support::*; use super::*; - #[tokio::test] - async fn download_rebuilds_header_and_decrypted_frames() { - let (segments, expected) = fixture_track(); - let template = serve(segments).await; - let tmp = tempfile::NamedTempFile::new().unwrap(); - let download = begin(&Client::new(), &template, KEY, tmp.path()) - .await - .unwrap(); - download.finish().await.unwrap(); - assert_eq!(std::fs::read(tmp.path()).unwrap(), expected); - } - - #[tokio::test] - async fn missing_segment_fails_the_download() { - let (mut segments, _) = fixture_track(); - segments.pop(); - let template = serve(segments).await; - let tmp = tempfile::NamedTempFile::new().unwrap(); - let download = begin(&Client::new(), &template, KEY, tmp.path()) - .await - .unwrap(); - let err = download.finish().await.unwrap_err().to_string(); - assert!(err.contains("segment 2"), "{err}"); - } - #[tokio::test] async fn template_without_placeholder_is_rejected() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - assert!( - begin(&Client::new(), "http://127.0.0.1:1/x", KEY, tmp.path()) - .await - .is_err() - ); + assert!(fetch_init(&Client::new(), "http://127.0.0.1:1/x") + .await + .is_err()); } #[test] diff --git a/src/infra/qobuz/stream/progressive.rs b/src/infra/qobuz/stream/progressive.rs index af63d21b..7129a987 100644 --- a/src/infra/qobuz/stream/progressive.rs +++ b/src/infra/qobuz/stream/progressive.rs @@ -96,7 +96,7 @@ pub struct SegmentStream { skip: usize, /// Chunks at or past this index are not yielded until the next seek. end: usize, - in_flight: Option>>>, + in_flight: Option>>, attempts: u32, failure: Option, } @@ -183,7 +183,7 @@ impl SegmentStream { } } - fn spawn_fetch(&self, index: u32) -> JoinHandle>> { + fn spawn_fetch(&self, index: u32) -> JoinHandle> { let http = self.http.clone(); let template = self.url_template.clone(); let key = self.content_key; @@ -221,7 +221,7 @@ impl Stream for SegmentStream { }; this.in_flight = None; match outcome { - Ok(bytes) => Bytes::from(bytes), + Ok(bytes) => bytes, Err(e) => { // `stream-download` logs the error and polls again; the failure // flag ends the stream after the last attempt. diff --git a/src/infra/queue/dispatch.rs b/src/infra/queue/dispatch.rs index a549ad33..4bfb091d 100644 --- a/src/infra/queue/dispatch.rs +++ b/src/infra/queue/dispatch.rs @@ -122,7 +122,7 @@ async fn route_queue_transport(app: &Arc>, event: &IoEvent) -> Option Some(true) } IoEvent::ChangeVolume(volume) => { - player.set_volume(*volume as f32 / 100.0); + player.set_volume(*volume); let mut app = app.lock().await; app.runtime_state.volume_percent = *volume; app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( @@ -354,7 +354,9 @@ async fn play_queued_subsonic(app: &Arc>, track: &TrackInfo, uri: &st let uri = uri.to_string(); let name = track.name.clone(); tokio::spawn(async move { - let result = crate::infra::subsonic::dispatch::download_for_queue(&source, &uri).await; + let result = crate::infra::subsonic::dispatch::download_for_queue(&source, &uri) + .await + .map(|tmp| (tmp, None)); finish_decoded_fetch(&app, fetch_id, result, &name).await; }); true @@ -376,7 +378,9 @@ async fn play_queued_qobuz(app: &Arc>, track: &TrackInfo, uri: &str) let uri = uri.to_string(); let name = track.name.clone(); tokio::spawn(async move { - let result = crate::infra::qobuz::dispatch::download_for_queue(&source, &uri, quality).await; + let result = crate::infra::qobuz::dispatch::download_for_queue(&source, &uri, quality) + .await + .map(|(tmp, label)| (tmp, Some(label))); finish_decoded_fetch(&app, fetch_id, result, &name).await; }); true @@ -400,7 +404,9 @@ async fn play_queued_youtube(app: &Arc>, track: &TrackInfo, uri: &str let uri = uri.to_string(); let name = track.name.clone(); tokio::spawn(async move { - let result = crate::infra::youtube::dispatch::download_for_queue(&source, &uri).await; + let result = crate::infra::youtube::dispatch::download_for_queue(&source, &uri) + .await + .map(|tmp| (tmp, None)); finish_decoded_fetch(&app, fetch_id, result, &name).await; }); true @@ -588,21 +594,23 @@ async fn publish_pending_decoded( fetch_id, #[cfg(any(feature = "subsonic", feature = "qobuz", feature = "youtube"))] tempfile: None, + quality: None, })); fetch_id } /// Complete a background queue fetch: if the slot still carries `fetch_id`, -/// play the downloaded file and finalize the slot; otherwise (skipped, torn -/// down, or replaced meanwhile) drop the result silently. On a download or -/// decode failure the queue advances past the item, exactly like the old -/// inline path. Play + finalize happen under one `App` lock so a concurrent -/// advance (which pops under the same lock) can never interleave. +/// play the downloaded file and finalize the slot (with the delivered format +/// label, when the source reports one); otherwise (skipped, torn down, or +/// replaced meanwhile) drop the result silently. On a download or decode +/// failure the queue advances past the item, exactly like the old inline +/// path. Play + finalize happen under one `App` lock so a concurrent advance +/// (which pops under the same lock) can never interleave. #[cfg(any(feature = "subsonic", feature = "qobuz", feature = "youtube"))] async fn finish_decoded_fetch( app: &Arc>, fetch_id: u64, - result: anyhow::Result, + result: anyhow::Result<(tempfile::NamedTempFile, Option)>, track_name: &str, ) { use crate::infra::queue::QueueNowPlaying; @@ -611,8 +619,8 @@ async fn finish_decoded_fetch( Some(QueueNowPlaying::Decoded(d)) if d.fetch_id == fetch_id => Arc::clone(&d.player), _ => return, // superseded — the tempfile drops here }; - let tmp = match result { - Ok(tmp) => tmp, + let (tmp, quality) = match result { + Ok(fetched) => fetched, Err(e) => { guard.set_status_message(format!("Cannot play {track_name}: {e}"), 4); guard.dispatch(IoEvent::AdvanceNativeQueue); @@ -630,9 +638,10 @@ async fn finish_decoded_fetch( guard.dispatch(IoEvent::AdvanceNativeQueue); return; } - player.set_volume(guard.runtime_state.volume_percent as f32 / 100.0); + player.set_volume(guard.runtime_state.volume_percent); if let Some(QueueNowPlaying::Decoded(d)) = guard.queue_now.as_mut() { d.tempfile = Some(tmp); + d.quality = quality; d.advancing = false; } guard.set_status_message(format!("\u{266a} {track_name} (queue)"), 4); @@ -665,6 +674,7 @@ async fn publish_decoded( fetch_id: next_fetch_id(), #[cfg(any(feature = "subsonic", feature = "qobuz", feature = "youtube"))] tempfile, + quality: None, })); guard.set_status_message(format!("\u{266a} {name} (queue)"), 4); #[cfg(feature = "streaming")] @@ -771,7 +781,7 @@ async fn release_librespot(app: &Arc>) { #[cfg(feature = "local-files")] async fn apply_volume(app: &Arc>, player: &Arc) { let volume = app.lock().await.runtime_state.volume_percent; - player.set_volume(volume as f32 / 100.0); + player.set_volume(volume); } // --------------------------------------------------------------------------- @@ -1070,8 +1080,10 @@ async fn resume_qobuz( queue_player: Option>, ) { let Some(index) = resume_index else { - if let Some(s) = app.lock().await.qobuz_playback.take() { - s.player.stop(); + // Take under the lock, stop off it: a sink clear waits for the audio thread. + let session = app.lock().await.qobuz_playback.take(); + if let Some(s) = session { + Arc::clone(&s.player).stop_detached(); } if let Some(player) = queue_player { player.stop(); @@ -1097,11 +1109,7 @@ async fn resume_qobuz( let _ = queue_player; match replay { Some((player, path)) => { - let decode_player = Arc::clone(&player); - let ok = tokio::task::spawn_blocking(move || decode_player.play_file(&path)) - .await - .map(|r| r.is_ok()) - .unwrap_or(false); + let ok = super::replay_file(Arc::clone(&player), path).await; if let Some(s) = app.lock().await.qobuz_playback.as_mut() { s.advancing = false; } diff --git a/src/infra/queue/mod.rs b/src/infra/queue/mod.rs index 92db72f8..4684f563 100644 --- a/src/infra/queue/mod.rs +++ b/src/infra/queue/mod.rs @@ -19,6 +19,31 @@ pub mod dispatch; +#[cfg(any(feature = "subsonic", feature = "qobuz", feature = "youtube"))] +use crate::core::plugin_api::TrackInfo; + +/// Snapshot the `TrackInfo`s for `uris` in order from the track table, then +/// the search results (a play can come from either view). Unknown URIs are +/// dropped. +#[cfg(any(feature = "subsonic", feature = "qobuz", feature = "youtube"))] +pub fn snapshot_tracks( + table: &[TrackInfo], + search: Option<&[TrackInfo]>, + uris: &[String], +) -> Vec { + uris + .iter() + .filter_map(|uri| { + let matches = |t: &&TrackInfo| t.uri.as_deref() == Some(uri.as_str()); + table + .iter() + .find(matches) + .or_else(|| search.and_then(|s| s.iter().find(matches))) + .cloned() + }) + .collect() +} + /// The runner-tick decision at a decoded source's auto-advance point, once the /// native queue is in the picture. /// @@ -397,6 +422,9 @@ pub struct DecodedQueuePlayback { #[cfg(any(feature = "subsonic", feature = "qobuz", feature = "youtube"))] #[allow(dead_code)] pub tempfile: Option, + /// The delivered audio format of a downloaded track (Qobuz, e.g. + /// `FLAC 24/96`), shown after the artists in the playbar. + pub quality: Option, } /// What the native queue's playback slot is currently playing. @@ -435,6 +463,66 @@ pub enum QueueNowPlaying { mod tests { use super::*; + #[cfg(any(feature = "subsonic", feature = "qobuz", feature = "youtube"))] + mod snapshot { + use super::*; + + fn track(uri: &str, name: &str) -> TrackInfo { + TrackInfo { + uri: Some(uri.to_string()), + name: name.to_string(), + artists: vec!["Artist".to_string()], + album: "Album".to_string(), + duration_ms: 1000, + id: None, + album_id: None, + artist_refs: vec![], + is_playable: true, + is_local: false, + track_number: 0, + explicit: false, + image_url: None, + } + } + + #[test] + fn snapshot_finds_tracks_in_table_preserving_order() { + let table = vec![ + track("subsonic:track:a", "A"), + track("subsonic:track:b", "B"), + ]; + let snap = snapshot_tracks( + &table, + None, + &[ + "subsonic:track:b".to_string(), + "subsonic:track:a".to_string(), + ], + ); + assert_eq!(snap.len(), 2); + assert_eq!(snap[0].name, "B"); + assert_eq!(snap[1].name, "A"); + } + + #[test] + fn snapshot_falls_back_to_search_results_for_search_to_play() { + // The track table holds a browsed playlist while the played uris come + // from the search results: both views must be consulted. + let table = vec![track("spotify:track:x", "Browsed")]; + let search = vec![track("youtube:searched", "Searched")]; + let snap = snapshot_tracks(&table, Some(&search), &["youtube:searched".to_string()]); + assert_eq!(snap.len(), 1); + assert_eq!(snap[0].name, "Searched"); + } + + #[test] + fn snapshot_drops_unknown_uris() { + let table = vec![track("qobuz:track:a", "A")]; + let snap = snapshot_tracks(&table, None, &["qobuz:track:missing".to_string()]); + assert!(snap.is_empty()); + } + } + #[test] fn advance_decision_full_table() { use RepeatMode::Off; diff --git a/src/infra/radio/dispatch.rs b/src/infra/radio/dispatch.rs index 06321fa2..9f1c1e40 100644 --- a/src/infra/radio/dispatch.rs +++ b/src/infra/radio/dispatch.rs @@ -30,10 +30,9 @@ use super::{ config_station_to_track_info, is_radio_uri, open_radio_stream, stream_url_from_uri, uri_for_stream_url, RadioPlaybackState, RadioSource, }; -use crate::core::app::{App, SearchResultBlock}; -use crate::core::pagination::Paged; +use crate::core::app::App; use crate::core::plugin_api::TrackInfo; -use crate::core::source::Searcher; +use crate::core::source::{Searcher, Source}; use crate::core::state::merged_radio_stations; use crate::infra::audio::LocalPlayer; use crate::infra::network::IoEvent; @@ -88,7 +87,7 @@ pub async fn route_radio_event(app: &Arc>, event: &IoEvent) -> bool { }, IoEvent::ChangeVolume(volume) => match player(app).await { Some(p) => { - p.set_volume(*volume as f32 / 100.0); + p.set_volume(*volume); let mut app = app.lock().await; app.runtime_state.volume_percent = *volume; app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( @@ -143,23 +142,7 @@ async fn load_radio_stations(app: &Arc>) { async fn run_radio_search(app: &Arc>, query: &str) { let source = RadioSource::new(); match source.search(query).await { - Ok(results) => { - let total = results.tracks.len() as u32; - let mut app = app.lock().await; - app.search_results.tracks = Some(Paged { - items: results.tracks, - total, - ..Default::default() - }); - app.search_results.albums = None; - app.search_results.artists = None; - app.search_results.playlists = None; - app.search_results.shows = None; - // Focus the songs block so the first hit is selectable immediately. - app.search_results.selected_tracks_index = Some(0); - app.search_results.hovered_block = SearchResultBlock::SongSearch; - app.search_results.selected_block = SearchResultBlock::Empty; - } + Ok(results) => app.lock().await.show_source_search_tracks(results.tracks), Err(e) => set_error(app, format!("Radio search failed: {e}")).await, } } @@ -204,47 +187,20 @@ fn snapshot_station(app: &App, uri: &str) -> TrackInfo { } /// Release the other backends so only radio holds the output device. -async fn release_other_backends(_app: &Arc>) { +async fn release_other_backends(app: &Arc>) { // Pause native Spotify so librespot releases the device. #[cfg(feature = "streaming")] { - let streaming = _app.lock().await.streaming_player.clone(); + let streaming = app.lock().await.streaming_player.clone(); if let Some(player) = streaming { player.pause(); } } - // Tear down any local-file session (dropping it releases its device handle). - #[cfg(feature = "local-files")] - { - let local = _app.lock().await.local_playback.take(); - if let Some(local) = local { - local.player.stop(); - } - } - // Tear down any Subsonic session. - #[cfg(feature = "subsonic")] - { - let subsonic = _app.lock().await.subsonic_playback.take(); - if let Some(subsonic) = subsonic { - subsonic.player.stop(); - } - } - // Tear down any Qobuz session. - #[cfg(feature = "qobuz")] - { - let qobuz = _app.lock().await.qobuz_playback.take(); - if let Some(qobuz) = qobuz { - qobuz.player.stop(); - } - } - // Tear down any YouTube session (the pump's short-circuit means the YouTube - // dispatch never sees this radio: start). - #[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 radio: start (the pump's + // short-circuit), so their sessions are torn down here. + let players = app.lock().await.take_decoded_sessions_except(Source::Radio); + for player in players { + player.stop_detached(); } } @@ -322,7 +278,7 @@ async fn start_radio(app: &Arc>, uri: &str) { match result { Ok(Ok(())) => { let volume = app.lock().await.runtime_state.volume_percent; - player.set_volume(volume as f32 / 100.0); + player.set_volume(volume); let display = station.name.clone(); let mut guard = app.lock().await; @@ -355,6 +311,7 @@ async fn set_error(app: &Arc>, message: String) { #[cfg(test)] mod tests { use super::*; + use crate::core::pagination::Paged; use crate::core::user_config::UserConfig; use std::sync::mpsc::channel; use std::time::SystemTime; diff --git a/src/infra/subsonic/dispatch.rs b/src/infra/subsonic/dispatch.rs index d1e76aa0..6cd4a608 100644 --- a/src/infra/subsonic/dispatch.rs +++ b/src/infra/subsonic/dispatch.rs @@ -35,13 +35,11 @@ use tempfile::NamedTempFile; use tokio::sync::Mutex; use super::{track_id_from_uri, SubsonicPlaybackState, SubsonicSource}; -use crate::core::app::{App, SearchResultBlock, TrackTableContext}; -use crate::core::pagination::Paged; -use crate::core::plugin_api::TrackInfo; -use crate::core::source::{MediaSource, Searcher}; +use crate::core::app::{App, TrackTableContext}; +use crate::core::source::{MediaSource, Searcher, Source}; use crate::infra::audio::LocalPlayer; use crate::infra::network::IoEvent; -use crate::infra::queue::{advance_index, replay_file}; +use crate::infra::queue::{advance_index, replay_file, snapshot_tracks}; /// Environment variable that overrides the configured Subsonic password. Prefer /// it over the plaintext config field so the secret is never written to disk. @@ -119,7 +117,7 @@ pub async fn route_subsonic_event(app: &Arc>, event: &IoEvent) -> boo }, IoEvent::ChangeVolume(volume) => match player(app).await { Some(p) => { - p.set_volume(*volume as f32 / 100.0); + p.set_volume(*volume); let mut app = app.lock().await; app.runtime_state.volume_percent = *volume; app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( @@ -225,23 +223,7 @@ async fn run_subsonic_search(app: &Arc>, query: &str) { return; }; match source.search(query).await { - Ok(results) => { - let total = results.tracks.len() as u32; - let mut app = app.lock().await; - app.search_results.tracks = Some(Paged { - items: results.tracks, - total, - ..Default::default() - }); - app.search_results.albums = None; - app.search_results.artists = None; - app.search_results.playlists = None; - app.search_results.shows = None; - // Focus the songs block so the first hit is selectable immediately. - app.search_results.selected_tracks_index = Some(0); - app.search_results.hovered_block = SearchResultBlock::SongSearch; - app.search_results.selected_block = SearchResultBlock::Empty; - } + Ok(results) => app.lock().await.show_source_search_tracks(results.tracks), Err(e) => set_error(app, format!("Subsonic search failed: {e}")).await, } } @@ -260,47 +242,7 @@ async fn player(app: &Arc>) -> Option> { .map(|s| Arc::clone(&s.player)) } -/// Snapshot the `TrackInfo`s for `uris`, preserving order, looking each up in -/// **both** browse views: the track table (browse→play) and the search results -/// (search→play). A playback request can originate from either — the track-table -/// Enter sends `track_table.tracks` uris, the search-result Enter sends -/// `search_results.tracks` uris — so the metadata may live in either. Any uri -/// found in neither is dropped. -fn snapshot_tracks( - table: &[TrackInfo], - search: Option<&[TrackInfo]>, - uris: &[String], -) -> Vec { - uris - .iter() - .filter_map(|uri| find_track(table, search, uri).cloned()) - .collect() -} - -/// Find a track's metadata by URI in the track table, falling back to the search -/// results. -fn find_track<'a>( - table: &'a [TrackInfo], - search: Option<&'a [TrackInfo]>, - uri: &str, -) -> Option<&'a TrackInfo> { - let matches = |t: &&TrackInfo| t.uri.as_deref() == Some(uri); - table - .iter() - .find(matches) - .or_else(|| search.and_then(|s| s.iter().find(matches))) -} - -/// Release the other two backends so only subsonic holds the output device. -#[cfg_attr( - not(any( - feature = "streaming", - feature = "local-files", - feature = "internet-radio", - feature = "youtube" - )), - allow(unused_variables) -)] +/// Release the other backends so only subsonic holds the output device. async fn release_other_backends(app: &Arc>) { // Pause native Spotify so librespot releases the device. #[cfg(feature = "streaming")] @@ -310,38 +252,14 @@ async fn release_other_backends(app: &Arc>) { player.pause(); } } - // Tear down any local-file session (dropping it releases its device handle). - #[cfg(feature = "local-files")] - { - let local = app.lock().await.local_playback.take(); - if let Some(local) = local { - local.player.stop(); - } - } - // Tear down any radio session (the `!handled_subsonic` short-circuit in the - // runtime means the radio dispatch never sees this subsonic: start). - #[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(); - } - } - // Tear down any Qobuz session (it is routed after Subsonic in the pump). - #[cfg(feature = "qobuz")] - { - let qobuz = app.lock().await.qobuz_playback.take(); - if let Some(qobuz) = qobuz { - qobuz.player.stop(); - } + // The other decoded sources never see this subsonic: start (the pump's + // `!handled_subsonic` short-circuit), so their sessions are torn down here. + let players = app + .lock() + .await + .take_decoded_sessions_except(Source::Subsonic); + for player in players { + player.stop_detached(); } } @@ -439,7 +357,7 @@ async fn start_subsonic_queue(app: &Arc>, uris: &[String], start_idx: match result { Ok(Ok(())) => { let volume = app.lock().await.runtime_state.volume_percent; - player.set_volume(volume as f32 / 100.0); + player.set_volume(volume); let display = tracks[index].name.clone(); let mut guard = app.lock().await; @@ -624,66 +542,6 @@ async fn set_error(app: &Arc>, message: String) { mod tests { use super::*; - fn track(uri: &str, name: &str) -> TrackInfo { - TrackInfo { - uri: Some(uri.to_string()), - name: name.to_string(), - artists: vec!["Artist".to_string()], - album: "Album".to_string(), - duration_ms: 1000, - id: None, - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - } - } - - #[test] - fn snapshot_finds_tracks_in_table_preserving_order() { - let table = vec![ - track("subsonic:track:a", "A"), - track("subsonic:track:b", "B"), - ]; - let snap = snapshot_tracks( - &table, - None, - &[ - "subsonic:track:b".to_string(), - "subsonic:track:a".to_string(), - ], - ); - assert_eq!(snap.len(), 2); - assert_eq!(snap[0].name, "B"); - assert_eq!(snap[1].name, "A"); - } - - #[test] - fn snapshot_falls_back_to_search_results_for_search_to_play() { - // The track table holds a previously-browsed playlist; the played uris come - // from the search results. The lookup must consult both. (Regression: the - // search->play path looked only at the table and found nothing.) - let table = vec![track("subsonic:track:browsed", "Browsed")]; - let search = vec![track("subsonic:track:searched", "Searched")]; - let snap = snapshot_tracks( - &table, - Some(&search), - &["subsonic:track:searched".to_string()], - ); - assert_eq!(snap.len(), 1, "search-sourced uri must resolve"); - assert_eq!(snap[0].name, "Searched"); - } - - #[test] - fn snapshot_drops_unknown_uris() { - let table = vec![track("subsonic:track:a", "A")]; - let snap = snapshot_tracks(&table, None, &["subsonic:track:missing".to_string()]); - assert!(snap.is_empty()); - } - /// End-to-end dispatch test: drive `route_subsonic_event` exactly as the /// runtime pump does — browse, play, advance the queue, and tear down on a /// foreign start. Exercises the dispatch glue/state machine the direct-client diff --git a/src/infra/youtube/dispatch.rs b/src/infra/youtube/dispatch.rs index ac561398..2f6fe25a 100644 --- a/src/infra/youtube/dispatch.rs +++ b/src/infra/youtube/dispatch.rs @@ -30,13 +30,12 @@ use tempfile::NamedTempFile; use tokio::sync::Mutex; use super::{is_youtube_uri, video_id_from_uri, YouTubePlaybackState, YouTubeSource}; -use crate::core::app::{App, SearchResultBlock}; -use crate::core::pagination::Paged; +use crate::core::app::App; use crate::core::plugin_api::TrackInfo; -use crate::core::source::Searcher; +use crate::core::source::{Searcher, Source}; use crate::infra::audio::LocalPlayer; use crate::infra::network::IoEvent; -use crate::infra::queue::{advance_index, replay_file}; +use crate::infra::queue::{advance_index, replay_file, snapshot_tracks}; /// Skip direction within the YouTube queue. #[derive(Clone, Copy)] @@ -121,7 +120,7 @@ pub async fn route_youtube_event(app: &Arc>, event: &IoEvent) -> bool }, IoEvent::ChangeVolume(volume) => match player(app).await { Some(p) => { - p.set_volume(*volume as f32 / 100.0); + p.set_volume(*volume); let mut app = app.lock().await; app.runtime_state.volume_percent = *volume; app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( @@ -164,23 +163,7 @@ pub(crate) async fn build_source(app: &Arc>) -> YouTubeSource { async fn run_youtube_search(app: &Arc>, query: &str) { let source = build_source(app).await; match source.search(query).await { - Ok(results) => { - let total = results.tracks.len() as u32; - let mut app = app.lock().await; - app.search_results.tracks = Some(Paged { - items: results.tracks, - total, - ..Default::default() - }); - app.search_results.albums = None; - app.search_results.artists = None; - app.search_results.playlists = None; - app.search_results.shows = None; - // Focus the songs block so the first hit is selectable immediately. - app.search_results.selected_tracks_index = Some(0); - app.search_results.hovered_block = SearchResultBlock::SongSearch; - app.search_results.selected_block = SearchResultBlock::Empty; - } + Ok(results) => app.lock().await.show_source_search_tracks(results.tracks), Err(e) => set_error(app, format!("YouTube search failed: {e}")).await, } } @@ -416,31 +399,8 @@ async fn player(app: &Arc>) -> Option> { .map(|s| Arc::clone(&s.player)) } -/// Snapshot the `TrackInfo`s for `uris`, preserving order, looking each up in -/// both browse views a play can originate from (the shared track table and the -/// search results). Any uri found in neither is dropped. -fn snapshot_tracks( - table: &[TrackInfo], - search: Option<&[TrackInfo]>, - uris: &[String], -) -> Vec { - uris - .iter() - .filter_map(|uri| { - let matches = |t: &&TrackInfo| t.uri.as_deref() == Some(uri.as_str()); - table - .iter() - .find(matches) - .or_else(|| search.and_then(|s| s.iter().find(matches))) - .cloned() - }) - .collect() -} - /// Release the other backends so only YouTube holds the output device. async fn release_other_backends(app: &Arc>) { - // In a youtube-only build every block below is compiled out. - let _ = app; // Pause native Spotify so librespot releases the device. #[cfg(feature = "streaming")] { @@ -449,37 +409,12 @@ async fn release_other_backends(app: &Arc>) { player.pause(); } } - // Tear down any local-file session (dropping it releases its device handle). - #[cfg(feature = "local-files")] - { - let local = app.lock().await.local_playback.take(); - if let Some(local) = local { - local.player.stop(); - } - } - // Tear down any Subsonic session. - #[cfg(feature = "subsonic")] - { - let subsonic = app.lock().await.subsonic_playback.take(); - if let Some(subsonic) = subsonic { - subsonic.player.stop(); - } - } - // Tear down any Qobuz session. - #[cfg(feature = "qobuz")] - { - let qobuz = app.lock().await.qobuz_playback.take(); - if let Some(qobuz) = qobuz { - qobuz.player.stop(); - } - } - // Tear down any radio session. - #[cfg(feature = "internet-radio")] - { - let radio = app.lock().await.radio_playback.take(); - if let Some(radio) = radio { - radio.player.stop(); - } + let players = app + .lock() + .await + .take_decoded_sessions_except(Source::YouTube); + for player in players { + player.stop_detached(); } } @@ -594,7 +529,7 @@ async fn start_youtube_queue(app: &Arc>, uris: &[String], start_idx: match result { Ok(Ok(())) => { let volume = app.lock().await.runtime_state.volume_percent; - player.set_volume(volume as f32 / 100.0); + player.set_volume(volume); let display = tracks[index].name.clone(); let mut guard = app.lock().await; @@ -803,6 +738,7 @@ async fn set_error(app: &Arc>, message: String) { #[cfg(test)] mod tests { use super::*; + use crate::core::pagination::Paged; use crate::core::user_config::UserConfig; use std::sync::mpsc::channel; use std::time::SystemTime; @@ -830,37 +766,6 @@ mod tests { } } - #[test] - fn snapshot_finds_tracks_in_table_preserving_order() { - let table = vec![video("youtube:aaa", "A"), video("youtube:bbb", "B")]; - let snap = snapshot_tracks( - &table, - None, - &["youtube:bbb".to_string(), "youtube:aaa".to_string()], - ); - assert_eq!(snap.len(), 2); - assert_eq!(snap[0].name, "B"); - assert_eq!(snap[1].name, "A"); - } - - #[test] - fn snapshot_falls_back_to_search_results() { - // Search->play is the primary YouTube path: the track table may hold a - // stale Spotify playlist while the played uris come from the search view. - let table = vec![video("spotify:track:x", "Spotify Row")]; - let search = vec![video("youtube:searched123", "Searched")]; - let snap = snapshot_tracks(&table, Some(&search), &["youtube:searched123".to_string()]); - assert_eq!(snap.len(), 1); - assert_eq!(snap[0].name, "Searched"); - } - - #[test] - fn snapshot_drops_unknown_uris() { - let table = vec![video("youtube:aaa", "A")]; - let snap = snapshot_tracks(&table, None, &["youtube:missing".to_string()]); - assert!(snap.is_empty()); - } - /// Search/transport events that are not YouTube's must fall through. #[tokio::test] async fn foreign_events_fall_through_without_a_session() { diff --git a/src/tui/handlers/track_table.rs b/src/tui/handlers/track_table.rs index 4ae43baf..21797b8f 100644 --- a/src/tui/handlers/track_table.rs +++ b/src/tui/handlers/track_table.rs @@ -223,9 +223,7 @@ fn play_random_song(app: &mut App) { }); } } - TrackTableContext::LocalPlaylist - | TrackTableContext::SubsonicPlaylist - | TrackTableContext::QobuzPlaylist => { + TrackTableContext::LocalPlaylist | TrackTableContext::SubsonicPlaylist => { // Single-file playback: play one random track from the folder/playlist. // Wire shape changed with the Action conversion: this used to send the // track in the context slot (`StartPlayback(Some(uri), None, None)`). @@ -246,9 +244,9 @@ fn play_random_song(app: &mut App) { }); } } - TrackTableContext::YouTubePlaylist => { + TrackTableContext::YouTubePlaylist | TrackTableContext::QobuzPlaylist => { // Queue the whole playlist and start at a random offset, so - // Next/Previous keep working within the playlist. + // Next/Previous and auto-advance keep working within the playlist. let playable_ids: Vec = app .track_table .tracks diff --git a/src/tui/ui/player.rs b/src/tui/ui/player.rs index c6a7e476..e2acb194 100644 --- a/src/tui/ui/player.rs +++ b/src/tui/ui/player.rs @@ -1220,7 +1220,7 @@ pub fn draw_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { // The native queue ignores the decoded shuffle/repeat modes (they belong // to the suspended source resumed once the queue drains), so hide them. show_modes: false, - quality: None, + quality: d.quality.clone(), }; render_local_playbar(f, app, layout_chunk, &view); return; diff --git a/tools/gates.count b/tools/gates.count index 1493fe14..d7f2c140 100644 --- a/tools/gates.count +++ b/tools/gates.count @@ -13,4 +13,4 @@ synthetic_keys_in_mouse_handler = 3 # target 0 (the content-table re-entries wildcard_arms_in_action_tree = 0 # target 0, must stay 0 view_writes_outside_tui = 12 # target 0 (producers outside tui/ and core/app/ writing App::view) action_refs_in_tui_handlers = 173 # adoption: may only rise -test_attribute_total = 1728 # adoption: may only rise +test_attribute_total = 1721 # adoption: may only rise From b25e48b8aca57996a1d7a6703282ac2207bc88ce Mon Sep 17 00:00:00 2001 From: LargeModGames <84450916+LargeModGames@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:57:08 +0200 Subject: [PATCH 5/7] fix(qobuz): drop secret lengths from the live test log --- src/infra/qobuz/mod.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/infra/qobuz/mod.rs b/src/infra/qobuz/mod.rs index f14cc8b6..d0ae9fcb 100644 --- a/src/infra/qobuz/mod.rs +++ b/src/infra/qobuz/mod.rs @@ -864,11 +864,8 @@ mod tests { .await .expect("bundle scrape"); eprintln!( - "bundle {} app_id {} secret_len {} oauth_key_len {}", - constants.bundle_version, - constants.app_id, - constants.app_secret.len(), - constants.oauth_key.len() + "bundle {} app_id {}", + constants.bundle_version, constants.app_id ); let source = QobuzSource::new(constants.app_id, constants.app_secret, token); From 2b8972c177dccb858760d9ca34c8486ec1c5925e Mon Sep 17 00:00:00 2001 From: LargeModGames <84450916+LargeModGames@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:06:25 +0200 Subject: [PATCH 6/7] fix(qobuz): address review comments - Set the session-restore resume point before the fetch starts. - Make the decoder's seekability explicit for streams with a byte length. - Skip Qobuz setup on a listener bind failure instead of failing first run. - Gate the Qobuz Quality setting on the qobuz feature. - Run the delivered-format playbar test under qobuz too. - Docs: alternative sources are not all free, the cargo typo, progressive playback wording, the configured quality, the scripting snapshot scope. --- .github/copilot-instructions.md | 2 +- AGENTS.md | 2 +- CHANGELOG.md | 2 +- CLAUDE.md | 4 ++-- README.md | 4 ++-- docs/scripting.md | 3 ++- src/core/app/settings_apply.rs | 1 + src/core/app/settings_schema.rs | 1 + src/core/first_run.rs | 9 ++++++++- src/core/user_config.rs | 3 +++ src/infra/audio/player.rs | 4 +++- src/infra/qobuz/dispatch.rs | 20 ++++++++++++++------ src/runtime/startup.rs | 14 +++++--------- src/tui/ui/player.rs | 4 ++-- 14 files changed, 46 insertions(+), 27 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 41ac1fed..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/Qobuz). 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 diff --git a/AGENTS.md b/AGENTS.md index 9abce686..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/Qobuz). 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1301248b..2fec0f72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### 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 owner-only in `qobuz_credentials.yml`, 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 downloaded, decrypted, and rebuilt as a FLAC temporary file, off the event pump so the UI stays responsive, then played through spotatui's own engine. `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). +- **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 owner-only in `qobuz_credentials.yml`, 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)). diff --git a/CLAUDE.md b/CLAUDE.md index d14db585..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/Qobuz). 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 @@ -23,7 +23,7 @@ cargo run --features all-sources ## CI Checks (run before opening a PR) ```bash -car2go fmt --all +cargo fmt --all cargo clippy --no-default-features --features telemetry,tui -- -D warnings cargo test --no-default-features --features telemetry,tui ``` diff --git a/README.md b/README.md index ddd5ddcf..87fee824 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ 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) in FLAC through spotatui's own engine | A paid Qobuz account | +| **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`). @@ -246,7 +246,7 @@ Requires the [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) binary (`ffmpeg` recom ### 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). No app registration is needed. The sidebar lists your favorite tracks, playlists, and favorite albums; search finds tracks. Each track is downloaded and decrypted to a temporary file before it plays, so a hi-res track takes a moment to start. 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. +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). 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: diff --git a/docs/scripting.md b/docs/scripting.md index 43ae95f8..898bab20 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -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, the Qobuz token, 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. diff --git a/src/core/app/settings_apply.rs b/src/core/app/settings_apply.rs index fe5d4dbb..638c269c 100644 --- a/src/core/app/settings_apply.rs +++ b/src/core/app/settings_apply.rs @@ -83,6 +83,7 @@ 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 = diff --git a/src/core/app/settings_schema.rs b/src/core/app/settings_schema.rs index 06c6c947..2a59808f 100644 --- a/src/core/app/settings_schema.rs +++ b/src/core/app/settings_schema.rs @@ -194,6 +194,7 @@ 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(), diff --git a/src/core/first_run.rs b/src/core/first_run.rs index 0fe54287..338d280b 100644 --- a/src/core/first_run.rs +++ b/src/core/first_run.rs @@ -205,7 +205,14 @@ async fn configure_qobuz(onboarding: &dyn Onboarding) -> Result<()> { } }; - let attempt = auth::LoginAttempt::bind(constants.clone()).await?; + 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")); diff --git a/src/core/user_config.rs b/src/core/user_config.rs index 31a2533c..0755fa84 100644 --- a/src/core/user_config.rs +++ b/src/core/user_config.rs @@ -52,12 +52,14 @@ pub fn normalize_tick_rate_milliseconds(value: i64) -> 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() @@ -67,6 +69,7 @@ pub fn qobuz_quality_label(quality: u8) -> &'static str { } /// 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() diff --git a/src/infra/audio/player.rs b/src/infra/audio/player.rs index 2471fe94..1e5f6cc8 100644 --- a/src/infra/audio/player.rs +++ b/src/infra/audio/player.rs @@ -141,7 +141,9 @@ 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 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); } diff --git a/src/infra/qobuz/dispatch.rs b/src/infra/qobuz/dispatch.rs index 700d74d6..238e0f03 100644 --- a/src/infra/qobuz/dispatch.rs +++ b/src/infra/qobuz/dispatch.rs @@ -32,7 +32,9 @@ use tokio::sync::Mutex; use super::auth::{self, QobuzBundleCache}; use super::stream::progressive; -use super::{track_id_from_uri, QobuzPlaybackState, QobuzSource, StreamQuality, Unauthorized}; +use super::{ + track_id_from_uri, QobuzPlaybackState, QobuzSource, ResumePoint, StreamQuality, Unauthorized, +}; use crate::core::app::{App, TrackTableContext}; use crate::core::source::{MediaSource, Searcher, Source}; use crate::core::state::PersistedRuntimeState; @@ -80,12 +82,12 @@ pub async fn route_qobuz_event(app: &Arc>, event: &IoEvent) -> bool { IoEvent::StartPlayback(None, Some(uris), offset) if uris.first().is_some_and(|u| is_qobuz_uri(u)) => { - start_qobuz_queue(app, uris, offset.unwrap_or(0)).await; + start_qobuz_queue(app, uris, offset.unwrap_or(0), None).await; true } // A single Qobuz track with no surrounding list: a one-track queue. IoEvent::StartPlayback(Some(uri), _, _) if is_qobuz_uri(uri) => { - start_qobuz_queue(app, std::slice::from_ref(uri), 0).await; + start_qobuz_queue(app, std::slice::from_ref(uri), 0, None).await; true } // Bare "resume current": ours only while Qobuz owns the session. @@ -561,8 +563,14 @@ async fn commit_fetch( } /// Begin playing a list of Qobuz tracks, taking over the session and starting -/// at `start_idx` (clamped into range). -async fn start_qobuz_queue(app: &Arc>, uris: &[String], start_idx: usize) { +/// at `start_idx` (clamped into range). `resume` is applied when the first +/// track plays (session restore), so it is in place before the fetch starts. +pub(crate) async fn start_qobuz_queue( + app: &Arc>, + uris: &[String], + start_idx: usize, + resume: Option, +) { let tracks = { let guard = app.lock().await; let search = guard @@ -609,7 +617,7 @@ async fn start_qobuz_queue(app: &Arc>, uris: &[String], start_idx: us quality: None, shuffle_backup: None, fetch_id: next_fetch_id(), - resume_at: None, + resume_at: resume, fetch: None, }; // Honor the player-global decoded shuffle for the freshly built queue. diff --git a/src/runtime/startup.rs b/src/runtime/startup.rs index d2971358..7a209ba3 100644 --- a/src/runtime/startup.rs +++ b/src/runtime/startup.rs @@ -772,12 +772,12 @@ async fn restore_playback_session( } // Seed the browse table so the start path's snapshot resolves metadata. app.lock().await.track_table.tracks = tracks; - crate::infra::qobuz::dispatch::route_qobuz_event( - app, - &IoEvent::StartPlayback(None, Some(uris), Some(index)), - ) - .await; // The download runs off the pump; the seek and pause apply when it plays. + let resume = crate::infra::qobuz::ResumePoint { + position_ms, + paused: resolve_paused(paused), + }; + crate::infra::qobuz::dispatch::start_qobuz_queue(app, &uris, index, Some(resume)).await; let mut guard = app.lock().await; if guard.qobuz_playback.is_some() { guard.decoded_repeat = repeat; @@ -785,10 +785,6 @@ async fn restore_playback_session( } if let Some(s) = guard.qobuz_playback.as_mut() { s.shuffle_backup = shuffle; - s.resume_at = Some(crate::infra::qobuz::ResumePoint { - position_ms, - paused: resolve_paused(paused), - }); } } #[cfg(feature = "youtube")] diff --git a/src/tui/ui/player.rs b/src/tui/ui/player.rs index e2acb194..0a2587b3 100644 --- a/src/tui/ui/player.rs +++ b/src/tui/ui/player.rs @@ -2065,7 +2065,7 @@ mod tests { /// Collect every rendered cell symbol in `area` into one string for substring /// assertions. - #[cfg(feature = "local-files")] + #[cfg(any(feature = "local-files", feature = "qobuz"))] fn rendered_text(area: Rect, view: &LocalPlaybarView) -> String { use ratatui::{backend::TestBackend, Terminal}; @@ -2152,7 +2152,7 @@ mod tests { ); } - #[cfg(feature = "local-files")] + #[cfg(any(feature = "local-files", feature = "qobuz"))] #[test] fn playbar_renders_delivered_quality_after_artists() { let view = LocalPlaybarView { From 279255497380f8cb5bbde7241e0fc2e19e93997f Mon Sep 17 00:00:00 2001 From: LargeModGames <84450916+LargeModGames@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:09:01 +0200 Subject: [PATCH 7/7] docs(qobuz): qualify the credentials file permissions per platform --- CHANGELOG.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fec0f72..025e7e4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### 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 owner-only in `qobuz_credentials.yml`, 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). +- **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)). diff --git a/README.md b/README.md index 87fee824..afa29b1f 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ Requires the [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) binary (`ffmpeg` recom ### 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). 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. +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: