From 47db92ff92b3777d5f85d3f716849ab66049d176 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 25 May 2026 15:08:14 -0600 Subject: [PATCH] chore: remove qbzd headless daemon and its client integration qbzd was a premature daemon experiment that never shipped (gated off behind QBZD_ENABLED, no user access). Its ratatui 0.29 dependency pinned unicode-width =0.2.0, which conflicts with slint's annotate-snippets in a shared workspace. Remove it entirely; it will be reimplemented from scratch later on the clean, UI-agnostic shell. - Delete crates/qbzd daemon crate + workspace member (drops ratatui, crossterm and the other qbzd-only deps from the lock). - Delete src-tauri qbzd_discovery (mDNS) + its commands/state wiring in lib.rs; drop the direct mdns-sd dep (Chromecast keeps its own via qbz-cast). - Delete remoteApi/remoteEvents/playbackTargetStore; neutralize commandRouter to local-only dispatch (isRemote/skipIfRemote are now always-false no-ops so the ~100 guard call-sites keep compiling). - Strip qbzd branches from playbackService and the remote banner from NowPlayingBar; excise qbzd from CastPicker (Chromecast/DLNA kept). - Remove qbzd i18n keys (5 locales), README daemon node, gitignore note. cargo check (crates + src-tauri) and svelte-check all green. cc --- .gitignore | 2 +- README.md | 10 - crates/Cargo.lock | 939 +------------------- crates/Cargo.toml | 1 - crates/qbzd/Cargo.toml | 77 -- crates/qbzd/service-files/qbzd.openrc | 16 - crates/qbzd/service-files/qbzd.runit | 3 - crates/qbzd/service-files/qbzd.service | 23 - crates/qbzd/service-files/qbzd.toml.example | 42 - crates/qbzd/src/adapter.rs | 51 -- crates/qbzd/src/api/audio.rs | 127 --- crates/qbzd/src/api/catalog.rs | 51 -- crates/qbzd/src/api/catalog_ext.rs | 106 --- crates/qbzd/src/api/discover.rs | 93 -- crates/qbzd/src/api/events.rs | 96 -- crates/qbzd/src/api/favorites.rs | 62 -- crates/qbzd/src/api/integrations.rs | 92 -- crates/qbzd/src/api/library.rs | 209 ----- crates/qbzd/src/api/middleware.rs | 50 -- crates/qbzd/src/api/mod.rs | 14 - crates/qbzd/src/api/playback.rs | 194 ---- crates/qbzd/src/api/playlists.rs | 118 --- crates/qbzd/src/api/queue.rs | 125 --- crates/qbzd/src/api/search.rs | 53 -- crates/qbzd/src/api/system.rs | 35 - crates/qbzd/src/config.rs | 277 ------ crates/qbzd/src/daemon.rs | 700 --------------- crates/qbzd/src/login.rs | 179 ---- crates/qbzd/src/main.rs | 157 ---- crates/qbzd/src/mpris.rs | 110 --- crates/qbzd/src/qconnect.rs | 681 -------------- crates/qbzd/src/resources.rs | 37 - crates/qbzd/src/session.rs | 77 -- crates/qbzd/src/wizard/audio.rs | 75 -- crates/qbzd/src/wizard/cache.rs | 63 -- crates/qbzd/src/wizard/integrations.rs | 66 -- crates/qbzd/src/wizard/mod.rs | 139 --- crates/qbzd/src/wizard/qconnect.rs | 49 - src-tauri/Cargo.lock | 44 +- src-tauri/Cargo.toml | 1 - src-tauri/src/lib.rs | 5 - src-tauri/src/qbzd_discovery.rs | 129 --- src/lib/components/CastPicker.svelte | 354 +------- src/lib/components/NowPlayingBar.svelte | 46 +- src/lib/i18n/locales/de.json | 5 - src/lib/i18n/locales/en.json | 5 - src/lib/i18n/locales/es.json | 5 - src/lib/i18n/locales/fr.json | 5 - src/lib/i18n/locales/pt.json | 5 - src/lib/services/commandRouter.ts | 212 ++--- src/lib/services/playbackService.ts | 47 +- src/lib/services/remoteApi.ts | 96 -- src/lib/services/remoteEvents.ts | 105 --- src/lib/stores/playbackTargetStore.ts | 52 -- 54 files changed, 90 insertions(+), 6225 deletions(-) delete mode 100644 crates/qbzd/Cargo.toml delete mode 100644 crates/qbzd/service-files/qbzd.openrc delete mode 100644 crates/qbzd/service-files/qbzd.runit delete mode 100644 crates/qbzd/service-files/qbzd.service delete mode 100644 crates/qbzd/service-files/qbzd.toml.example delete mode 100644 crates/qbzd/src/adapter.rs delete mode 100644 crates/qbzd/src/api/audio.rs delete mode 100644 crates/qbzd/src/api/catalog.rs delete mode 100644 crates/qbzd/src/api/catalog_ext.rs delete mode 100644 crates/qbzd/src/api/discover.rs delete mode 100644 crates/qbzd/src/api/events.rs delete mode 100644 crates/qbzd/src/api/favorites.rs delete mode 100644 crates/qbzd/src/api/integrations.rs delete mode 100644 crates/qbzd/src/api/library.rs delete mode 100644 crates/qbzd/src/api/middleware.rs delete mode 100644 crates/qbzd/src/api/mod.rs delete mode 100644 crates/qbzd/src/api/playback.rs delete mode 100644 crates/qbzd/src/api/playlists.rs delete mode 100644 crates/qbzd/src/api/queue.rs delete mode 100644 crates/qbzd/src/api/search.rs delete mode 100644 crates/qbzd/src/api/system.rs delete mode 100644 crates/qbzd/src/config.rs delete mode 100644 crates/qbzd/src/daemon.rs delete mode 100644 crates/qbzd/src/login.rs delete mode 100644 crates/qbzd/src/main.rs delete mode 100644 crates/qbzd/src/mpris.rs delete mode 100644 crates/qbzd/src/qconnect.rs delete mode 100644 crates/qbzd/src/resources.rs delete mode 100644 crates/qbzd/src/session.rs delete mode 100644 crates/qbzd/src/wizard/audio.rs delete mode 100644 crates/qbzd/src/wizard/cache.rs delete mode 100644 crates/qbzd/src/wizard/integrations.rs delete mode 100644 crates/qbzd/src/wizard/mod.rs delete mode 100644 crates/qbzd/src/wizard/qconnect.rs delete mode 100644 src-tauri/src/qbzd_discovery.rs delete mode 100644 src/lib/services/remoteApi.ts delete mode 100644 src/lib/services/remoteEvents.ts delete mode 100644 src/lib/stores/playbackTargetStore.ts diff --git a/.gitignore b/.gitignore index 5f09df81a..6a30d76fc 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,6 @@ CLAUDE.md AGENTS.md /crates/target/ -# /crates/Cargo.lock # tracked for reproducible qbzd CI builds +# /crates/Cargo.lock # tracked for reproducible CI builds test-results/ diff --git a/README.md b/README.md index 6e32ff6f3..11a21e26d 100644 --- a/README.md +++ b/README.md @@ -338,10 +338,6 @@ subgraph group_crates["Rust Crates"] node_rust_integrations["qbz-integrations
external services"] end -subgraph group_service["Daemon"] - node_daemon["qbzd
local daemon"] -end - subgraph group_packaging["Packaging"] node_packaging_ci["Packaging & CI
release automation
[workflows]"] end @@ -365,11 +361,7 @@ node_app_library -->|"indexes"| node_rust_library node_app_library -->|"enriches"| node_rust_qobuz node_app_cast -->|"delegates"| node_rust_cast node_app_connect -->|"implements"| node_rust_connect -node_daemon -->|"coordinates"| node_app_player -node_daemon -->|"serves"| node_app_library -node_daemon -->|"hosts"| node_app_connect node_packaging_ci -.->|"builds"| node_tauri_backend -node_packaging_ci -.->|"packages"| node_daemon node_packaging_ci -.->|"bundles"| node_ui_shell click node_ui_shell "https://github.com/vicrodh/qbz/tree/main/src" @@ -393,7 +385,6 @@ click node_rust_library "https://github.com/vicrodh/qbz/tree/main/crates/qbz-lib click node_rust_cast "https://github.com/vicrodh/qbz/tree/main/crates/qbz-cast/src" click node_rust_connect "https://github.com/vicrodh/qbz/tree/main/crates/qconnect-protocol/src" click node_rust_integrations "https://github.com/vicrodh/qbz/tree/main/crates/qbz-integrations/src" -click node_daemon "https://github.com/vicrodh/qbz/tree/main/crates/qbzd/src" click node_packaging_ci "https://github.com/vicrodh/qbz/blob/main/.github/workflows" classDef toneNeutral fill:#f8fafc,stroke:#334155,stroke-width:1.5px,color:#0f172a @@ -406,7 +397,6 @@ classDef toneTeal fill:#ccfbf1,stroke:#0f766e,stroke-width:1.5px,color:#134e4a class node_ui_shell,node_ui_state,node_ui_views,node_ui_remote toneBlue class node_tauri_backend,node_cmd_v2,node_cmd_legacy,node_app_audio,node_app_player,node_app_library,node_app_cache,node_app_cast,node_app_connect toneAmber class node_rust_player,node_rust_audio,node_rust_cache,node_rust_qobuz,node_rust_library,node_rust_cast,node_rust_connect,node_rust_integrations toneMint -class node_daemon toneRose class node_packaging_ci toneIndigo ``` diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 90237a938..d5c832ab6 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -64,12 +64,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - [[package]] name = "alsa" version = "0.11.0" @@ -101,56 +95,6 @@ dependencies = [ "libc", ] -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - [[package]] name = "anyhow" version = "1.0.102" @@ -359,61 +303,6 @@ dependencies = [ "fs_extra", ] -[[package]] -name = "axum" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" -dependencies = [ - "axum-core", - "base64", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sha1", - "sync_wrapper", - "tokio", - "tokio-tungstenite 0.28.0", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "base64" version = "0.22.1" @@ -432,12 +321,6 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" -[[package]] -name = "block" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" - [[package]] name = "block-buffer" version = "0.10.4" @@ -517,21 +400,6 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -[[package]] -name = "cassowary" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" - -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - [[package]] name = "cbc" version = "0.1.2" @@ -611,46 +479,6 @@ dependencies = [ "inout", ] -[[package]] -name = "clap" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - [[package]] name = "cmake" version = "0.1.58" @@ -660,42 +488,6 @@ dependencies = [ "cc", ] -[[package]] -name = "cocoa" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" -dependencies = [ - "bitflags 1.3.2", - "block", - "cocoa-foundation", - "core-foundation 0.9.4", - "core-graphics", - "foreign-types", - "libc", - "objc", -] - -[[package]] -name = "cocoa-foundation" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" -dependencies = [ - "bitflags 1.3.2", - "block", - "core-foundation 0.9.4", - "core-graphics-types", - "libc", - "objc", -] - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - [[package]] name = "combine" version = "4.6.7" @@ -706,20 +498,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "compact_str" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -764,16 +542,6 @@ dependencies = [ "url", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -790,30 +558,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core-graphics" -version = "0.22.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "core-graphics-types", - "foreign-types", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "libc", -] - [[package]] name = "coreaudio-rs" version = "0.14.1" @@ -855,7 +599,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.62.2", + "windows", ] [[package]] @@ -891,31 +635,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crossterm" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" -dependencies = [ - "bitflags 2.11.0", - "crossterm_winapi", - "mio", - "parking_lot", - "rustix 0.38.44", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - [[package]] name = "crypto-common" version = "0.1.7" @@ -945,40 +664,6 @@ dependencies = [ "cipher", ] -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn", -] - [[package]] name = "dasp_frame" version = "0.11.0" @@ -1011,15 +696,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "dbus-crossroads" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8" -dependencies = [ - "dbus", -] - [[package]] name = "dbus-secret-service" version = "4.1.0" @@ -1090,12 +766,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - [[package]] name = "dispatch2" version = "0.3.1" @@ -1186,29 +856,6 @@ dependencies = [ "syn", ] -[[package]] -name = "env_filter" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "env_logger" -version = "0.11.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "jiff", - "log", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -1323,21 +970,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1524,8 +1156,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash", ] @@ -1589,17 +1219,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "hostname" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" -dependencies = [ - "cfg-if", - "libc", - "windows-link", -] - [[package]] name = "http" version = "1.4.0" @@ -1667,7 +1286,6 @@ dependencies = [ "http", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1728,7 +1346,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -1827,12 +1445,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.1.0" @@ -1902,15 +1514,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - [[package]] name = "inout" version = "0.1.4" @@ -1921,19 +1524,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "instability" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" -dependencies = [ - "darling", - "indoc", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -1950,40 +1540,6 @@ dependencies = [ "serde", ] -[[package]] -name = "is-docker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", -] - -[[package]] -name = "is-wsl" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" -dependencies = [ - "is-docker", - "once_cell", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.14.0" @@ -1999,30 +1555,6 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" -[[package]] -name = "jiff" -version = "0.2.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" -dependencies = [ - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", -] - -[[package]] -name = "jiff-static" -version = "0.2.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "jni" version = "0.21.1" @@ -2199,15 +1731,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "lru-slab" version = "0.1.2" @@ -2223,21 +1746,6 @@ dependencies = [ "libc", ] -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - [[package]] name = "md-5" version = "0.10.6" @@ -2276,12 +1784,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2299,7 +1801,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "log", "wasi", "windows-sys 0.61.2", ] @@ -2356,15 +1857,6 @@ dependencies = [ "memoffset", ] -[[package]] -name = "ntapi" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" -dependencies = [ - "winapi", -] - [[package]] name = "num" version = "0.4.3" @@ -2478,15 +1970,6 @@ dependencies = [ "syn", ] -[[package]] -name = "objc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" -dependencies = [ - "malloc_buf", -] - [[package]] name = "objc2" version = "0.6.4" @@ -2593,29 +2076,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - [[package]] name = "opaque-debug" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "open" -version = "5.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" -dependencies = [ - "is-wsl", - "libc", - "pathdiff", -] - [[package]] name = "openssl-probe" version = "0.2.1" @@ -2673,12 +2139,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - [[package]] name = "percent-encoding" version = "2.3.2" @@ -2775,21 +2235,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "portable-atomic-util" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" -dependencies = [ - "portable-atomic", -] - [[package]] name = "potential_utf" version = "0.1.4" @@ -2839,7 +2284,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.4+spec-1.1.0", + "toml_edit", ] [[package]] @@ -2868,7 +2313,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools", "proc-macro2", "quote", "syn", @@ -3169,48 +2614,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "qbzd" -version = "0.1.9" -dependencies = [ - "async-trait", - "axum", - "clap", - "crossterm", - "dirs", - "env_logger", - "futures-util", - "hostname", - "keyring", - "log", - "mdns-sd", - "open", - "qbz-audio", - "qbz-cache", - "qbz-core", - "qbz-integrations", - "qbz-library", - "qbz-models", - "qbz-player", - "qbz-qobuz", - "qconnect-app", - "qconnect-core", - "qconnect-transport-ws", - "ratatui", - "reqwest", - "rusqlite", - "serde", - "serde_json", - "souvlaki", - "sysinfo", - "tokio", - "tokio-stream", - "toml", - "tower-http", - "urlencoding", - "uuid", -] - [[package]] name = "qconnect-app" version = "0.1.0" @@ -3260,7 +2663,7 @@ dependencies = [ "serde", "thiserror 2.0.18", "tokio", - "tokio-tungstenite 0.24.0", + "tokio-tungstenite", ] [[package]] @@ -3431,27 +2834,6 @@ dependencies = [ "rand 0.10.1", ] -[[package]] -name = "ratatui" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" -dependencies = [ - "bitflags 2.11.0", - "cassowary", - "compact_str", - "crossterm", - "indoc", - "instability", - "itertools 0.13.0", - "lru", - "paste", - "strum", - "unicode-segmentation", - "unicode-truncate", - "unicode-width 0.2.0", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -3804,7 +3186,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -3869,17 +3251,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - [[package]] name = "serde_repr" version = "0.1.20" @@ -3891,15 +3262,6 @@ dependencies = [ "syn", ] -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3951,27 +3313,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -4020,24 +3361,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "souvlaki" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5855c8f31521af07d896b852eaa9eca974ddd3211fc2ae292e58dda8eb129bc8" -dependencies = [ - "base64", - "block", - "cocoa", - "core-graphics", - "dbus", - "dbus-crossroads", - "dispatch", - "objc", - "thiserror 1.0.69", - "windows 0.44.0", -] - [[package]] name = "spin" version = "0.9.8" @@ -4077,34 +3400,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn", -] - [[package]] name = "subtle" version = "2.6.1" @@ -4337,19 +3632,6 @@ dependencies = [ "syn", ] -[[package]] -name = "sysinfo" -version = "0.34.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4b93974b3d3aeaa036504b8eefd4c039dced109171c1ae973f1dc63b2c7e4b2" -dependencies = [ - "libc", - "memchr", - "ntapi", - "objc2-core-foundation", - "windows 0.57.0", -] - [[package]] name = "tempfile" version = "3.26.0" @@ -4534,22 +3816,10 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite 0.24.0", + "tungstenite", "webpki-roots 0.26.11", ] -[[package]] -name = "tokio-tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.28.0", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -4563,27 +3833,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - [[package]] name = "toml_datetime" version = "1.0.0+spec-1.1.0" @@ -4593,20 +3842,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime 0.6.11", - "toml_write", - "winnow 0.7.15", -] - [[package]] name = "toml_edit" version = "0.25.4+spec-1.1.0" @@ -4614,7 +3849,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ "indexmap", - "toml_datetime 1.0.0+spec-1.1.0", + "toml_datetime", "toml_parser", "winnow 0.7.15", ] @@ -4628,12 +3863,6 @@ dependencies = [ "winnow 0.7.15", ] -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "tower" version = "0.5.3" @@ -4647,7 +3876,6 @@ dependencies = [ "tokio", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -4686,7 +3914,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4748,23 +3975,6 @@ dependencies = [ "utf-8", ] -[[package]] -name = "tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.3", - "sha1", - "thiserror 2.0.18", - "utf-8", -] - [[package]] name = "typenum" version = "1.20.0" @@ -4788,35 +3998,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-segmentation" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" - -[[package]] -name = "unicode-truncate" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" -dependencies = [ - "itertools 0.13.0", - "unicode-segmentation", - "unicode-width 0.1.14", -] - -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" - [[package]] name = "unicode-xid" version = "0.2.6" @@ -4869,12 +4050,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "uuid" version = "1.22.0" @@ -5098,22 +4273,6 @@ dependencies = [ "rustix 0.38.44", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" version = "0.1.11" @@ -5123,31 +4282,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" -dependencies = [ - "windows-core 0.57.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.62.2" @@ -5155,7 +4289,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ "windows-collections", - "windows-core 0.62.2", + "windows-core", "windows-future", "windows-numerics", ] @@ -5166,19 +4300,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core 0.62.2", -] - -[[package]] -name = "windows-core" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" -dependencies = [ - "windows-implement 0.57.0", - "windows-interface 0.57.0", - "windows-result 0.1.2", - "windows-targets 0.52.6", + "windows-core", ] [[package]] @@ -5187,10 +4309,10 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", "windows-link", - "windows-result 0.4.1", + "windows-result", "windows-strings", ] @@ -5200,22 +4322,11 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core 0.62.2", + "windows-core", "windows-link", "windows-threading", ] -[[package]] -name = "windows-implement" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-implement" version = "0.60.2" @@ -5227,17 +4338,6 @@ dependencies = [ "syn", ] -[[package]] -name = "windows-interface" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-interface" version = "0.59.3" @@ -5261,19 +4361,10 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core 0.62.2", + "windows-core", "windows-link", ] -[[package]] -name = "windows-result" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-result" version = "0.4.1" diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 7b78d4f07..8efc3ca60 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -18,7 +18,6 @@ members = [ "qconnect-protocol", "qconnect-transport-ws", "qconnect-app", - "qbzd", "qbz-credentials", ] diff --git a/crates/qbzd/Cargo.toml b/crates/qbzd/Cargo.toml deleted file mode 100644 index 8440cc166..000000000 --- a/crates/qbzd/Cargo.toml +++ /dev/null @@ -1,77 +0,0 @@ -[package] -name = "qbzd" -version = "0.1.9" -edition.workspace = true -license.workspace = true -description = "QBZ headless music daemon for SBCs and servers" - -[[bin]] -name = "qbzd" -path = "src/main.rs" - -[dependencies] -# Core crates (all Tauri-free) -qbz-models = { path = "../qbz-models" } -qbz-core = { path = "../qbz-core" } -qbz-player = { path = "../qbz-player" } -qbz-audio = { path = "../qbz-audio" } -qbz-qobuz = { path = "../qbz-qobuz" } -qbz-library = { path = "../qbz-library" } -qbz-cache = { path = "../qbz-cache" } -qbz-integrations = { path = "../qbz-integrations" } -qconnect-app = { path = "../qconnect-app" } -qconnect-transport-ws = { path = "../qconnect-transport-ws" } - -# HTTP server -axum = { version = "0.8", features = ["ws"] } -tower-http = { version = "0.6", features = ["cors"] } - -# Async -tokio = { workspace = true } -tokio-stream = { workspace = true } -async-trait = { workspace = true } -futures-util = { workspace = true } - -# Serialization -serde = { workspace = true } -serde_json = { workspace = true } - -# HTTP client (for status check + OAuth) -reqwest = { workspace = true } - -# Config -toml = "0.8" -clap = { version = "4", features = ["derive"] } - -# Browser open + URL encoding (for OAuth login) -open = "5" -urlencoding = "2" - -# System -dirs = "6" -log = { workspace = true } -env_logger = "0.11" -sysinfo = "0.34" - -# mDNS discovery -mdns-sd = "0.11" -hostname = "0.4" - -# Database -rusqlite = { version = "0.31", features = ["bundled"] } - -# Credentials (shared keyring with desktop app) -keyring = { version = "3", features = ["async-secret-service", "crypto-rust"] } - -# UUID (for QConnect renderer reports) -uuid = { version = "1", features = ["v4"] } - -# QConnect core types -qconnect-core = { path = "../qconnect-core" } - -# MPRIS media controls (Linux D-Bus, headless) -souvlaki = "0.8" - -# TUI wizard -ratatui = "0.29" -crossterm = "0.28" diff --git a/crates/qbzd/service-files/qbzd.openrc b/crates/qbzd/service-files/qbzd.openrc deleted file mode 100644 index 16590e66d..000000000 --- a/crates/qbzd/service-files/qbzd.openrc +++ /dev/null @@ -1,16 +0,0 @@ -#!/sbin/openrc-run -# QBZ Music Daemon - OpenRC service - -name="QBZ Music Daemon" -description="Headless music player daemon for Qobuz" - -command="/usr/bin/qbzd" -command_args="--config /etc/qbz/qbzd.toml" -command_user="qbz:audio" -pidfile="/run/qbzd.pid" -command_background=true - -depend() { - need net - after sound -} diff --git a/crates/qbzd/service-files/qbzd.runit b/crates/qbzd/service-files/qbzd.runit deleted file mode 100644 index 61e973952..000000000 --- a/crates/qbzd/service-files/qbzd.runit +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -# QBZ Music Daemon - runit service -exec chpst -u qbz:audio /usr/bin/qbzd --config /etc/qbz/qbzd.toml 2>&1 diff --git a/crates/qbzd/service-files/qbzd.service b/crates/qbzd/service-files/qbzd.service deleted file mode 100644 index 3b04c1d2c..000000000 --- a/crates/qbzd/service-files/qbzd.service +++ /dev/null @@ -1,23 +0,0 @@ -[Unit] -Description=QBZ Music Daemon -After=network-online.target sound.target -Wants=network-online.target - -[Service] -Type=simple -ExecStart=/usr/bin/qbzd --config /etc/qbz/qbzd.toml -Restart=on-failure -RestartSec=5 -User=qbz -Group=audio -Environment=RUST_LOG=info -Environment=XDG_RUNTIME_DIR=/run/user/%U - -# Sandboxing -ProtectSystem=strict -ReadWritePaths=/var/lib/qbz /run/user -PrivateTmp=true -NoNewPrivileges=true - -[Install] -WantedBy=multi-user.target diff --git a/crates/qbzd/service-files/qbzd.toml.example b/crates/qbzd/service-files/qbzd.toml.example deleted file mode 100644 index ea5692102..000000000 --- a/crates/qbzd/service-files/qbzd.toml.example +++ /dev/null @@ -1,42 +0,0 @@ -# QBZ Daemon Configuration -# Copy to ~/.config/qbz/qbzd.toml or /etc/qbz/qbzd.toml - -[server] -bind = "0.0.0.0" -port = 8182 -token = "auto" # "auto" generates on first run - -[audio] -backend = "pipewire" # pipewire | alsa | pulse | system -device = "" # empty = default device -gapless = true -normalization = false - -[cache] -memory_mb = 0 # 0 = auto-detect from RAM (recommended for SBCs) -disk_mb = 400 # persistent disk cache -prefetch_count = 2 # tracks to prefetch ahead -prefetch_concurrent = 1 # simultaneous track downloads -cmaf_concurrent_segments = 2 # parallel CMAF segments per track - -[cache.auto] -enabled = true # auto-size memory cache from available RAM - # 1 GB RAM -> 100 MB, 4 GB -> 200 MB, 8+ GB -> 400 MB - -[data] -dir = "" # empty = ~/.local/share/qbz (or /var/lib/qbz for system service) - -[mpris] -enabled = true # D-Bus MPRIS media controls (Linux) - -[qconnect] -enabled = true # Qobuz Connect (control from Qobuz mobile app) -device_name = "" # empty = hostname - -[mdns] -enabled = true # LAN discovery via mDNS (_qbz._tcp) -name = "" # empty = hostname - -[logging] -level = "info" # trace | debug | info | warn | error -journal = true # use systemd journal when available diff --git a/crates/qbzd/src/adapter.rs b/crates/qbzd/src/adapter.rs deleted file mode 100644 index a0392c46e..000000000 --- a/crates/qbzd/src/adapter.rs +++ /dev/null @@ -1,51 +0,0 @@ -use async_trait::async_trait; -use qbz_models::FrontendAdapter; -use tokio::sync::broadcast; - -/// Events distributed by the daemon to all connected clients. -#[derive(Debug, Clone)] -pub enum DaemonEvent { - /// Events from qbz-core (track started, queue updated, favorites changed, etc.) - Core(qbz_models::CoreEvent), - /// Periodic playback state snapshot (250ms while playing) - Playback(PlaybackSnapshot), - /// Runtime lifecycle (login, logout, ready, degraded) - Runtime(RuntimeEvent), -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct PlaybackSnapshot { - pub state: String, - pub track_id: u64, - pub position_secs: u64, - pub duration_secs: u64, - pub volume: f32, - pub sample_rate: u32, - pub bit_depth: u32, -} - -#[derive(Debug, Clone, serde::Serialize)] -pub enum RuntimeEvent { - Ready { user_id: u64 }, - LoggedOut, - Degraded { reason: String }, -} - -/// FrontendAdapter implementation for the headless daemon. -/// Routes core events to the broadcast channel for SSE distribution. -pub struct DaemonAdapter { - event_tx: broadcast::Sender, -} - -impl DaemonAdapter { - pub fn new(event_tx: broadcast::Sender) -> Self { - Self { event_tx } - } -} - -#[async_trait] -impl FrontendAdapter for DaemonAdapter { - async fn on_event(&self, event: qbz_models::CoreEvent) { - let _ = self.event_tx.send(DaemonEvent::Core(event)); - } -} diff --git a/crates/qbzd/src/api/audio.rs b/crates/qbzd/src/api/audio.rs deleted file mode 100644 index e253d0e96..000000000 --- a/crates/qbzd/src/api/audio.rs +++ /dev/null @@ -1,127 +0,0 @@ -use std::sync::Arc; -use axum::Json; -use serde::Deserialize; - -use crate::daemon::DaemonCore; - -pub async fn get_settings(_daemon: Arc) -> Json { - let result = qbz_audio::settings::AudioSettingsStore::new() - .ok() - .and_then(|s| s.get_settings().ok()); - match result { - Some(settings) => Json(serde_json::to_value(settings).unwrap_or_default()), - None => Json(serde_json::json!({"error": "no audio settings"})), - } -} - -pub async fn get_backends(_daemon: Arc) -> Json { - use qbz_audio::{AudioBackendType, BackendManager}; - - let backends: Vec = BackendManager::available_backends() - .into_iter() - .map(|bt| { - let backend = BackendManager::create_backend(bt); - let (available, description) = match backend { - Ok(b) => (b.is_available(), b.description().to_string()), - Err(_) => (false, "Not available".to_string()), - }; - let name = match bt { - AudioBackendType::PipeWire => "PipeWire", - AudioBackendType::Alsa => "ALSA Direct", - AudioBackendType::Pulse => "PulseAudio", - AudioBackendType::SystemDefault => "System Audio", - }; - serde_json::json!({ - "backend_type": bt, - "name": name, - "description": description, - "is_available": available, - }) - }) - .collect(); - - Json(serde_json::json!(backends)) -} - -#[derive(Deserialize)] -pub struct DevicesQuery { - pub backend: Option, -} - -pub async fn get_devices( - _daemon: Arc, - axum::extract::Query(q): axum::extract::Query, -) -> Result, String> { - use qbz_audio::{AudioBackendType, BackendManager}; - - let bt = match q.backend.as_deref() { - Some("pipewire") | Some("PipeWire") => AudioBackendType::PipeWire, - Some("alsa") | Some("Alsa") => AudioBackendType::Alsa, - Some("pulse") | Some("PulseAudio") => AudioBackendType::Pulse, - _ => AudioBackendType::SystemDefault, - }; - - let backend = BackendManager::create_backend(bt).map_err(|e| e.to_string())?; - let devices = backend.enumerate_devices().map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(devices).unwrap_or_default())) -} - -pub async fn get_hardware_status(daemon: Arc) -> Json { - let player = daemon.core.player(); - let state = &player.state; - let sr = state.get_sample_rate(); - let bd = state.get_bit_depth(); - let playing = state.is_playing(); - - Json(serde_json::json!({ - "is_active": playing && sr > 0, - "hardware_sample_rate": if sr > 0 { Some(sr) } else { None:: }, - "hardware_format": if sr > 0 && bd > 0 { - Some(format!("{}-bit / {:.1}kHz", bd, sr as f64 / 1000.0)) - } else { - None:: - }, - })) -} - -#[derive(Deserialize)] -pub struct UpdateSettingsRequest { - pub backend_type: Option, - pub output_device: Option, - pub exclusive_mode: Option, - pub dac_passthrough: Option, - pub volume_normalization: Option, -} - -pub async fn update_settings( - _daemon: Arc, - Json(req): Json, -) -> Result, String> { - let store = qbz_audio::settings::AudioSettingsStore::new() - .map_err(|e| format!("Failed to open settings: {}", e))?; - - if let Some(ref bt) = req.backend_type { - let backend = match bt.as_str() { - "PipeWire" | "pipewire" => Some(qbz_audio::AudioBackendType::PipeWire), - "Alsa" | "alsa" => Some(qbz_audio::AudioBackendType::Alsa), - "Pulse" | "pulse" => Some(qbz_audio::AudioBackendType::Pulse), - _ => Some(qbz_audio::AudioBackendType::SystemDefault), - }; - store.set_backend_type(backend).map_err(|e| e.to_string())?; - } - if let Some(ref device) = req.output_device { - let d = if device.is_empty() { None } else { Some(device.as_str()) }; - store.set_output_device(d).map_err(|e| e.to_string())?; - } - if let Some(exc) = req.exclusive_mode { - store.set_exclusive_mode(exc).map_err(|e| e.to_string())?; - } - if let Some(dac) = req.dac_passthrough { - store.set_dac_passthrough(dac).map_err(|e| e.to_string())?; - } - // volume_normalization: no individual setter yet, skip for now - - // Return updated settings - let updated = store.get_settings().unwrap_or_default(); - Ok(Json(serde_json::to_value(updated).unwrap_or_default())) -} diff --git a/crates/qbzd/src/api/catalog.rs b/crates/qbzd/src/api/catalog.rs deleted file mode 100644 index 9ba96e89d..000000000 --- a/crates/qbzd/src/api/catalog.rs +++ /dev/null @@ -1,51 +0,0 @@ -use std::sync::Arc; -use axum::extract::{Path, Query}; -use axum::Json; -use serde::Deserialize; - -use crate::daemon::DaemonCore; - -#[derive(Deserialize)] -pub struct BatchParams { - pub ids: String, -} - -pub async fn get_album( - daemon: Arc, - Path(id): Path, -) -> Result, String> { - let album = daemon.core.get_album(&id).await.map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(album).unwrap_or_default())) -} - -pub async fn get_artist( - daemon: Arc, - Path(id): Path, -) -> Result, String> { - let artist = daemon.core.get_artist(id).await.map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(artist).unwrap_or_default())) -} - -pub async fn get_track( - daemon: Arc, - Path(id): Path, -) -> Result, String> { - let track = daemon.core.get_track(id).await.map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(track).unwrap_or_default())) -} - -pub async fn get_tracks_batch( - daemon: Arc, - Query(params): Query, -) -> Result, String> { - let ids: Vec = params.ids.split(',') - .filter_map(|s| s.trim().parse().ok()) - .collect(); - - if ids.is_empty() { - return Ok(Json(serde_json::json!({"tracks": []}))); - } - - let tracks = daemon.core.get_tracks_batch(&ids).await.map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(tracks).unwrap_or_default())) -} diff --git a/crates/qbzd/src/api/catalog_ext.rs b/crates/qbzd/src/api/catalog_ext.rs deleted file mode 100644 index b4224c7f3..000000000 --- a/crates/qbzd/src/api/catalog_ext.rs +++ /dev/null @@ -1,106 +0,0 @@ -use std::sync::Arc; -use axum::extract::{Path, Query}; -use axum::Json; -use serde::Deserialize; - -use crate::daemon::DaemonCore; - -#[derive(Deserialize)] -pub struct PaginationQuery { - #[serde(default = "default_limit")] - pub limit: u32, - #[serde(default)] - pub offset: u32, -} - -fn default_limit() -> u32 { 20 } - -pub async fn get_artist_page( - daemon: Arc, - Path(id): Path, -) -> Result, String> { - let result = daemon.core.get_artist_page(id, None) - .await - .map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(result).unwrap_or_default())) -} - -pub async fn get_similar_artists( - daemon: Arc, - Path(id): Path, - Query(q): Query, -) -> Result, String> { - let result = daemon.core.get_similar_artists(id, q.limit, q.offset) - .await - .map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(result).unwrap_or_default())) -} - -/// GET /api/labels/{id} -/// -/// Backwards-compatible shape — composes /label/page + /label/getAlbums -/// so existing HTTP clients still see `{ id, name, description, image, -/// albums: { items, total, ... } }`. The legacy `/label/get` one-shot -/// is being retired in the v9.7.0.3 Qobuz API. -pub async fn get_label( - daemon: Arc, - Path(id): Path, - Query(q): Query, -) -> Result, String> { - let (page, albums) = tokio::try_join!( - async { - daemon.core.get_label_page(id) - .await - .map_err(|e| e.to_string()) - }, - async { - daemon.core.get_label_albums(id, q.limit, q.offset, None, None, None, None, None) - .await - .map_err(|e| e.to_string()) - } - )?; - - let composed = serde_json::json!({ - "id": page.id, - "name": page.name, - "description": page.description, - "image": page.image, - "albums": { - "items": albums.items, - "total": albums.total, - "offset": albums.offset, - "limit": albums.limit, - "has_more": albums.has_more, - }, - }); - Ok(Json(composed)) -} - -pub async fn get_label_page( - daemon: Arc, - Path(id): Path, -) -> Result, String> { - let result = daemon.core.get_label_page(id) - .await - .map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(result).unwrap_or_default())) -} - -pub async fn get_label_explore( - daemon: Arc, - Query(q): Query, -) -> Result, String> { - let result = daemon.core.get_label_explore(q.limit, q.offset) - .await - .map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(result).unwrap_or_default())) -} - -pub async fn get_playlist_tags( - daemon: Arc, -) -> Result, String> { - let result = daemon.core.get_playlist_tags() - .await - .map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(result).unwrap_or_default())) -} diff --git a/crates/qbzd/src/api/discover.rs b/crates/qbzd/src/api/discover.rs deleted file mode 100644 index cab874204..000000000 --- a/crates/qbzd/src/api/discover.rs +++ /dev/null @@ -1,93 +0,0 @@ -use std::sync::Arc; -use axum::extract::Query; -use axum::Json; -use serde::Deserialize; - -use crate::daemon::DaemonCore; - -#[derive(Deserialize)] -pub struct DiscoverQuery { - pub genre: Option, -} - -#[derive(Deserialize)] -pub struct DiscoverAlbumsQuery { - pub r#type: Option, - pub genre: Option, - #[serde(default = "default_limit")] - pub limit: u32, - #[serde(default)] - pub offset: u32, -} - -#[derive(Deserialize)] -pub struct DiscoverPlaylistsQuery { - pub tag: Option, - pub genre: Option, - #[serde(default = "default_limit")] - pub limit: u32, - #[serde(default)] - pub offset: u32, -} - -#[derive(Deserialize)] -pub struct FeaturedQuery { - pub r#type: Option, - #[serde(default = "default_limit")] - pub limit: u32, - #[serde(default)] - pub offset: u32, - pub genre: Option, -} - -fn default_limit() -> u32 { 20 } - -fn parse_genre_ids(genre: &Option) -> Option> { - genre.as_ref().map(|g| { - g.split(',').filter_map(|s| s.trim().parse().ok()).collect() - }) -} - -pub async fn get_discover_index( - daemon: Arc, - Query(q): Query, -) -> Result, String> { - let genres = parse_genre_ids(&q.genre); - let result = daemon.core.get_discover_index(genres) - .await - .map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(result).unwrap_or_default())) -} - -pub async fn get_discover_playlists( - daemon: Arc, - Query(q): Query, -) -> Result, String> { - let genres = parse_genre_ids(&q.genre); - let result = daemon.core.get_discover_playlists( - q.tag, - genres, - Some(q.limit), - Some(q.offset), - ).await.map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(result).unwrap_or_default())) -} - -pub async fn get_featured( - daemon: Arc, - Query(q): Query, -) -> Result, String> { - let genre_id = q.genre.as_ref().and_then(|g| g.parse().ok()); - let featured_type = q.r#type.as_deref().unwrap_or("new-releases"); - let result = daemon.core.get_featured_albums(featured_type, q.limit, q.offset, genre_id) - .await - .map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(result).unwrap_or_default())) -} - -pub async fn get_genres(daemon: Arc) -> Result, String> { - let result = daemon.core.get_genres(None) - .await - .map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(result).unwrap_or_default())) -} diff --git a/crates/qbzd/src/api/events.rs b/crates/qbzd/src/api/events.rs deleted file mode 100644 index 568d94a8d..000000000 --- a/crates/qbzd/src/api/events.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! SSE (Server-Sent Events) endpoint for real-time event streaming. -//! -//! Clients connect to `/api/events` and receive a continuous stream of -//! DaemonEvents formatted as SSE. Slow clients that fall behind the -//! broadcast buffer are disconnected (SSE auto-reconnect handles this). - -use std::convert::Infallible; -use std::sync::Arc; - -use axum::response::sse::{Event, KeepAlive, Sse}; -use futures_util::stream::Stream; -use tokio_stream::wrappers::BroadcastStream; -use tokio_stream::StreamExt; - -use crate::adapter::{DaemonEvent, PlaybackSnapshot, RuntimeEvent}; -use crate::daemon::DaemonCore; - -/// SSE endpoint handler. Each connected client gets its own broadcast receiver. -pub async fn sse_handler( - daemon: Arc, -) -> Sse>> { - let rx = daemon.event_bus.subscribe(); - let stream = BroadcastStream::new(rx).filter_map(|result| match result { - Ok(event) => Some(Ok(format_sse_event(&event))), - Err(tokio_stream::wrappers::errors::BroadcastStreamRecvError::Lagged(n)) => { - log::warn!("[SSE] Client lagged, dropped {} events", n); - Some(Ok(Event::default().comment(format!("lagged:{}", n)))) - } - }); - - Sse::new(stream).keep_alive(KeepAlive::default()) -} - -/// Convert a DaemonEvent to an SSE Event with typed event name. -fn format_sse_event(event: &DaemonEvent) -> Event { - match event { - DaemonEvent::Core(core_event) => { - let (event_type, data) = format_core_event(core_event); - Event::default().event(event_type).data(data) - } - DaemonEvent::Playback(snapshot) => Event::default() - .event("playback") - .data(serde_json::to_string(snapshot).unwrap_or_default()), - DaemonEvent::Runtime(runtime_event) => Event::default() - .event("runtime") - .data(serde_json::to_string(runtime_event).unwrap_or_default()), - } -} - -/// Map CoreEvent variants to SSE event type + JSON data. -fn format_core_event(event: &qbz_models::CoreEvent) -> (&'static str, String) { - use qbz_models::CoreEvent; - match event { - CoreEvent::TrackStarted { track, .. } => ( - "track-started", - serde_json::to_string(track).unwrap_or_default(), - ), - CoreEvent::TrackEnded { track_id } => ( - "track-ended", - serde_json::json!({"track_id": track_id}).to_string(), - ), - CoreEvent::QueueUpdated { state } => ( - "queue", - serde_json::to_string(state).unwrap_or_default(), - ), - CoreEvent::FavoritesUpdated { favorite_type } => ( - "favorites-updated", - serde_json::json!({"type": favorite_type}).to_string(), - ), - CoreEvent::PlaylistCreated { playlist } => ( - "playlist-created", - serde_json::to_string(playlist).unwrap_or_default(), - ), - CoreEvent::PlaylistUpdated { playlist_id } => ( - "playlist-updated", - serde_json::json!({"playlist_id": playlist_id}).to_string(), - ), - CoreEvent::Error { - code, - message, - recoverable, - } => ( - "error", - serde_json::json!({"code": code, "message": message, "recoverable": recoverable}) - .to_string(), - ), - CoreEvent::LoggedIn { session } => ( - "logged-in", - serde_json::json!({"user_id": session.user_id, "display_name": session.display_name}) - .to_string(), - ), - CoreEvent::LoggedOut => ("logged-out", "{}".to_string()), - // Catch-all for any future CoreEvent variants - _ => ("core-event", format!("{:?}", event)), - } -} diff --git a/crates/qbzd/src/api/favorites.rs b/crates/qbzd/src/api/favorites.rs deleted file mode 100644 index 0d0e93301..000000000 --- a/crates/qbzd/src/api/favorites.rs +++ /dev/null @@ -1,62 +0,0 @@ -use std::sync::Arc; -use axum::extract::Query; -use axum::Json; -use serde::Deserialize; - -use crate::daemon::DaemonCore; - -#[derive(Deserialize)] -pub struct FavoritesQuery { - pub r#type: String, - #[serde(default = "default_limit")] - pub limit: u32, - #[serde(default)] - pub offset: u32, -} - -fn default_limit() -> u32 { 50 } - -#[derive(Deserialize)] -pub struct FavoriteAction { - pub r#type: String, - pub item_id: String, -} - -pub async fn get_favorites( - daemon: Arc, - Query(q): Query, -) -> Result, String> { - let client = daemon.core.client(); - let guard = client.read().await; - let client = guard.as_ref().ok_or("Not initialized")?; - let result = client.get_favorites(&q.r#type, q.limit, q.offset) - .await - .map_err(|e| e.to_string())?; - Ok(Json(result)) -} - -pub async fn add_favorite( - daemon: Arc, - Json(req): Json, -) -> Result<&'static str, String> { - let client = daemon.core.client(); - let guard = client.read().await; - let client = guard.as_ref().ok_or("Not initialized")?; - client.add_favorite(&req.r#type, &req.item_id) - .await - .map_err(|e| e.to_string())?; - Ok("ok") -} - -pub async fn remove_favorite( - daemon: Arc, - Json(req): Json, -) -> Result<&'static str, String> { - let client = daemon.core.client(); - let guard = client.read().await; - let client = guard.as_ref().ok_or("Not initialized")?; - client.remove_favorite(&req.r#type, &req.item_id) - .await - .map_err(|e| e.to_string())?; - Ok("ok") -} diff --git a/crates/qbzd/src/api/integrations.rs b/crates/qbzd/src/api/integrations.rs deleted file mode 100644 index 7e9f3aa7e..000000000 --- a/crates/qbzd/src/api/integrations.rs +++ /dev/null @@ -1,92 +0,0 @@ -use std::sync::Arc; -use axum::Json; -use serde::Deserialize; - -use crate::daemon::DaemonCore; - -// For now, integrations are read/write via the shared SQLite databases -// in the user's data directory. The V2 integration states -// (ListenBrainzV2State, etc.) live in src-tauri/integrations_v2.rs -// and are not yet in a standalone crate. As a workaround, we read/write -// the cache databases directly. - -pub async fn get_listenbrainz_status( - daemon: Arc, -) -> Result, String> { - let user = daemon.user.read().await; - let session = user.as_ref().ok_or("No active session")?; - let cache_path = session.data_dir.join("cache").join("listenbrainz_v2.db"); - - if !cache_path.exists() { - return Ok(Json(serde_json::json!({"connected": false}))); - } - - let conn = rusqlite::Connection::open(&cache_path) - .map_err(|e| format!("DB error: {}", e))?; - let token: Option = conn - .query_row("SELECT value FROM config WHERE key = 'token'", [], |row| row.get(0)) - .ok(); - - Ok(Json(serde_json::json!({ - "connected": token.is_some(), - "token_preview": token.as_ref().map(|t| { - if t.len() > 8 { format!("{}...{}", &t[..4], &t[t.len()-4..]) } else { "****".to_string() } - }), - }))) -} - -#[derive(Deserialize)] -pub struct ListenBrainzConnectRequest { - pub token: String, -} - -pub async fn connect_listenbrainz( - daemon: Arc, - Json(req): Json, -) -> Result, String> { - let user = daemon.user.read().await; - let session = user.as_ref().ok_or("No active session")?; - let cache_dir = session.data_dir.join("cache"); - std::fs::create_dir_all(&cache_dir).ok(); - let cache_path = cache_dir.join("listenbrainz_v2.db"); - - let conn = rusqlite::Connection::open(&cache_path) - .map_err(|e| format!("DB error: {}", e))?; - conn.execute_batch("PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)") - .map_err(|e| format!("Schema error: {}", e))?; - conn.execute( - "INSERT OR REPLACE INTO config (key, value) VALUES ('token', ?1)", - rusqlite::params![req.token], - ).map_err(|e| format!("Save error: {}", e))?; - - log::info!("[qbzd] ListenBrainz token saved"); - Ok(Json(serde_json::json!({"connected": true}))) -} - -pub async fn disconnect_listenbrainz( - daemon: Arc, -) -> Result<&'static str, String> { - let user = daemon.user.read().await; - let session = user.as_ref().ok_or("No active session")?; - let cache_path = session.data_dir.join("cache").join("listenbrainz_v2.db"); - - if cache_path.exists() { - let conn = rusqlite::Connection::open(&cache_path) - .map_err(|e| format!("DB error: {}", e))?; - let _ = conn.execute("DELETE FROM config WHERE key = 'token'", []); - } - - log::info!("[qbzd] ListenBrainz disconnected"); - Ok("ok") -} - -pub async fn get_lastfm_status( - daemon: Arc, -) -> Result, String> { - // Last.fm V2 state doesn't have persistent cache yet in the crate - // Just report not connected for now - Ok(Json(serde_json::json!({ - "connected": false, - "note": "Last.fm setup requires desktop app or TUI wizard" - }))) -} diff --git a/crates/qbzd/src/api/library.rs b/crates/qbzd/src/api/library.rs deleted file mode 100644 index 621c373ff..000000000 --- a/crates/qbzd/src/api/library.rs +++ /dev/null @@ -1,209 +0,0 @@ -use std::sync::Arc; -use axum::extract::{Path, Query}; -use axum::Json; -use serde::Deserialize; - -use crate::daemon::DaemonCore; - -#[derive(Deserialize)] -pub struct SearchQuery { - pub q: String, - #[serde(default = "default_limit")] - pub limit: u32, - #[serde(default)] - pub offset: u32, -} - -fn default_limit() -> u32 { 50 } - -pub async fn get_albums(daemon: Arc) -> Result, String> { - let user = daemon.user.read().await; - let session = user.as_ref().ok_or("No active session")?; - let db_path = session.data_dir.join("library.db"); - - let db = qbz_library::LibraryDatabase::open(&db_path) - .map_err(|e| format!("Library DB error: {}", e))?; - let albums = db.get_albums(false) - .map_err(|e| format!("Failed to get albums: {}", e))?; - Ok(Json(serde_json::to_value(albums).unwrap_or_default())) -} - -pub async fn get_artists(daemon: Arc) -> Result, String> { - let user = daemon.user.read().await; - let session = user.as_ref().ok_or("No active session")?; - let db_path = session.data_dir.join("library.db"); - - let db = qbz_library::LibraryDatabase::open(&db_path) - .map_err(|e| format!("Library DB error: {}", e))?; - let artists = db.get_artists() - .map_err(|e| format!("Failed to get artists: {}", e))?; - Ok(Json(serde_json::to_value(artists).unwrap_or_default())) -} - -pub async fn get_album_tracks( - daemon: Arc, - Path(key): Path, -) -> Result, String> { - let user = daemon.user.read().await; - let session = user.as_ref().ok_or("No active session")?; - let db_path = session.data_dir.join("library.db"); - - let db = qbz_library::LibraryDatabase::open(&db_path) - .map_err(|e| format!("Library DB error: {}", e))?; - let tracks = db.get_album_tracks(&key) - .map_err(|e| format!("Failed to get tracks: {}", e))?; - Ok(Json(serde_json::to_value(tracks).unwrap_or_default())) -} - -pub async fn search_library( - daemon: Arc, - Query(q): Query, -) -> Result, String> { - let user = daemon.user.read().await; - let session = user.as_ref().ok_or("No active session")?; - let db_path = session.data_dir.join("library.db"); - - let db = qbz_library::LibraryDatabase::open(&db_path) - .map_err(|e| format!("Library DB error: {}", e))?; - let tracks = db.search(&q.q, q.limit) - .map_err(|e| format!("Search failed: {}", e))?; - Ok(Json(serde_json::to_value(tracks).unwrap_or_default())) -} - -pub async fn get_stats(daemon: Arc) -> Result, String> { - let user = daemon.user.read().await; - let session = user.as_ref().ok_or("No active session")?; - let db_path = session.data_dir.join("library.db"); - - let db = qbz_library::LibraryDatabase::open(&db_path) - .map_err(|e| format!("Library DB error: {}", e))?; - let stats = db.get_stats(true) - .map_err(|e| format!("Stats failed: {}", e))?; - Ok(Json(serde_json::to_value(stats).unwrap_or_default())) -} - -#[derive(Deserialize)] -pub struct AddFolderRequest { - pub path: String, -} - -pub async fn get_folders(daemon: Arc) -> Result, String> { - let user = daemon.user.read().await; - let session = user.as_ref().ok_or("No active session")?; - let db_path = session.data_dir.join("library.db"); - - let db = qbz_library::LibraryDatabase::open(&db_path) - .map_err(|e| format!("Library DB error: {}", e))?; - let folders = db.get_folders() - .map_err(|e| format!("Failed to get folders: {}", e))?; - Ok(Json(serde_json::to_value(folders).unwrap_or_default())) -} - -pub async fn add_folder( - daemon: Arc, - Json(req): Json, -) -> Result, String> { - let user = daemon.user.read().await; - let session = user.as_ref().ok_or("No active session")?; - let db_path = session.data_dir.join("library.db"); - - let db = qbz_library::LibraryDatabase::open(&db_path) - .map_err(|e| format!("Library DB error: {}", e))?; - db.add_folder(&req.path) - .map_err(|e| format!("Failed to add folder: {}", e))?; - Ok(Json(serde_json::json!({"path": req.path, "status": "added"}))) -} - -/// Trigger a full library scan on all registered folders. -/// Scans for audio files, extracts metadata, and inserts into the library DB. -/// Runs in background, returns immediately. -pub async fn start_scan(daemon: Arc) -> Result, String> { - let user = daemon.user.read().await; - let session = user.as_ref().ok_or("No active session")?; - let db_path = session.data_dir.join("library.db"); - - let db = qbz_library::LibraryDatabase::open(&db_path) - .map_err(|e| format!("Library DB error: {}", e))?; - let folders = db.get_folders() - .map_err(|e| format!("Failed to get folders: {}", e))?; - - if folders.is_empty() { - return Ok(Json(serde_json::json!({"status": "no_folders", "message": "No folders configured. Add folders first."}))); - } - - let folder_count = folders.len(); - let data_dir = session.data_dir.clone(); - - // Spawn scan in background - tokio::task::spawn_blocking(move || { - let scanner = qbz_library::LibraryScanner::new(); - let db = match qbz_library::LibraryDatabase::open(&data_dir.join("library.db")) { - Ok(db) => db, - Err(e) => { - log::error!("[qbzd] Scan: failed to open DB: {}", e); - return; - } - }; - - let mut total_tracks = 0usize; - - for folder in &folders { - let path = std::path::Path::new(folder); - if !path.exists() { - log::warn!("[qbzd] Scan: folder does not exist: {}", folder); - continue; - } - log::info!("[qbzd] Scanning: {}", folder); - match scanner.scan_directory(path) { - Ok(result) => { - log::info!( - "[qbzd] Found {} audio files in {}", - result.audio_files.len(), folder - ); - for file_path in &result.audio_files { - match qbz_library::MetadataExtractor::extract(file_path) { - Ok(track) => { - if let Err(e) = db.insert_track(&track) { - log::debug!("[qbzd] Insert track: {}", e); - } - total_tracks += 1; - } - Err(e) => { - log::debug!("[qbzd] Metadata extraction failed for {:?}: {}", file_path, e); - } - } - } - } - Err(e) => { - log::error!("[qbzd] Scan failed for {}: {}", folder, e); - } - } - } - log::info!("[qbzd] Library scan complete: {} tracks imported", total_tracks); - }); - - Ok(Json(serde_json::json!({ - "status": "scanning", - "folders": folder_count, - }))) -} - -#[derive(Deserialize)] -pub struct RemoveFolderRequest { - pub path: String, -} - -pub async fn remove_folder( - daemon: Arc, - Json(req): Json, -) -> Result<&'static str, String> { - let user = daemon.user.read().await; - let session = user.as_ref().ok_or("No active session")?; - let db_path = session.data_dir.join("library.db"); - - let db = qbz_library::LibraryDatabase::open(&db_path) - .map_err(|e| format!("Library DB error: {}", e))?; - db.remove_folder(&req.path) - .map_err(|e| format!("Failed to remove folder: {}", e))?; - Ok("ok") -} diff --git a/crates/qbzd/src/api/middleware.rs b/crates/qbzd/src/api/middleware.rs deleted file mode 100644 index c50ebb508..000000000 --- a/crates/qbzd/src/api/middleware.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! LAN-only access restriction. -//! -//! Rejects HTTP requests from non-private IP addresses. -//! If you're on the same network, you're in. If you're not, you're out. -//! Remote control from outside the LAN is exclusively via QConnect (cloud). - -use axum::{ - extract::Request, - http::StatusCode, - middleware::Next, - response::Response, -}; -use std::net::IpAddr; - -/// Middleware: reject requests from non-private IPs. -/// ConnectInfo is injected by axum::serve with into_make_service_with_connect_info. -pub async fn lan_only( - req: Request, - next: Next, -) -> Result { - // Extract client IP from ConnectInfo extension (set by axum::serve) - if let Some(addr) = req.extensions().get::>() { - if !is_private_ip(&addr.0.ip()) { - log::warn!( - "[qbzd] Rejected non-LAN request from {} to {}", - addr.0.ip(), - req.uri().path() - ); - return Err(StatusCode::FORBIDDEN); - } - } - Ok(next.run(req).await) -} - -fn is_private_ip(ip: &IpAddr) -> bool { - match ip { - IpAddr::V4(v4) => { - v4.is_loopback() - || v4.is_private() - || v4.is_link_local() - || v4.is_unspecified() - } - IpAddr::V6(v6) => { - v6.is_loopback() - || v6.is_unspecified() - || (v6.segments()[0] & 0xffc0) == 0xfe80 // link-local - || (v6.segments()[0] & 0xff00) == 0xfd00 // unique local (LAN) - } - } -} diff --git a/crates/qbzd/src/api/mod.rs b/crates/qbzd/src/api/mod.rs deleted file mode 100644 index fbe937f85..000000000 --- a/crates/qbzd/src/api/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub mod audio; -pub mod catalog; -pub mod catalog_ext; -pub mod discover; -pub mod events; -pub mod favorites; -pub mod integrations; -pub mod library; -pub mod middleware; -pub mod playback; -pub mod playlists; -pub mod queue; -pub mod search; -pub mod system; diff --git a/crates/qbzd/src/api/playback.rs b/crates/qbzd/src/api/playback.rs deleted file mode 100644 index faf4841b7..000000000 --- a/crates/qbzd/src/api/playback.rs +++ /dev/null @@ -1,194 +0,0 @@ -use std::sync::Arc; -use axum::Json; -use serde::Deserialize; - -use crate::daemon::DaemonCore; - -#[derive(Deserialize)] -pub struct SeekRequest { - pub position_secs: u64, -} - -#[derive(Deserialize)] -pub struct VolumeRequest { - pub volume: f32, -} - -pub async fn get_playback(daemon: Arc) -> Json { - let player = daemon.core.player(); - let state = &player.state; - Json(serde_json::json!({ - "state": if state.is_playing() { "Playing" } else if state.current_track_id() != 0 { "Paused" } else { "Stopped" }, - "track_id": state.current_track_id(), - "position_secs": state.current_position(), - "duration_secs": state.duration(), - "volume": state.volume(), - "sample_rate": state.get_sample_rate(), - "bit_depth": state.get_bit_depth(), - })) -} - -#[derive(Deserialize)] -pub struct PlayTrackRequest { - pub track_id: u64, - pub quality: Option, -} - -/// Play a specific track by ID. Downloads audio from Qobuz and feeds to player. -pub async fn play_track( - daemon: Arc, - Json(req): Json, -) -> Result, String> { - let quality = match req.quality.as_deref() { - Some("Hi-Res+") | Some("UltraHiRes") => qbz_models::Quality::UltraHiRes, - Some("Hi-Res") | Some("HiRes") => qbz_models::Quality::HiRes, - Some("Lossless") => qbz_models::Quality::Lossless, - _ => qbz_models::Quality::HiRes, // Default to HiRes - }; - - log::info!("[qbzd/play] Playing track {} (quality: {:?})", req.track_id, quality); - - // Signal orchestrator to not auto-advance during download - daemon.skip_auto_advance.store(true, std::sync::atomic::Ordering::Release); - - // Stop current playback - let _ = daemon.core.stop(); - - // Download audio - let audio_data = crate::daemon::download_track(&daemon, req.track_id).await?; - - // Clear the skip flag - daemon.skip_auto_advance.store(false, std::sync::atomic::Ordering::Release); - - // Feed to player - daemon.core.player() - .play_data(audio_data, req.track_id) - .map_err(|e| format!("Player error: {}", e))?; - - Ok(Json(serde_json::json!({ - "playing": true, - "track_id": req.track_id, - }))) -} - -/// Play an album by ID. Sets the queue with all album tracks and starts playing. -#[derive(Deserialize)] -pub struct PlayAlbumRequest { - pub album_id: String, - pub start_index: Option, - pub quality: Option, -} - -pub async fn play_album( - daemon: Arc, - Json(req): Json, -) -> Result, String> { - // Fetch album with tracks - let album = daemon.core.get_album(&req.album_id).await - .map_err(|e| format!("Album fetch failed: {}", e))?; - - let album_tracks = album.tracks.as_ref() - .map(|tc| &tc.items) - .ok_or("Album has no tracks")?; - - let tracks: Vec = album_tracks.iter().map(|track| { - qbz_models::QueueTrack { - id: track.id, - title: track.title.clone(), - artist: track.performer.as_ref().map(|p| p.name.clone()).unwrap_or_default(), - album: album.title.clone(), - duration_secs: track.duration as u64, - artwork_url: album.image.large.clone(), - hires: track.hires_streamable, - bit_depth: track.maximum_bit_depth, - sample_rate: track.maximum_sampling_rate, - is_local: false, - album_id: Some(album.id.clone()), - artist_id: Some(album.artist.id), - streamable: true, - source: Some("qobuz".to_string()), - parental_warning: track.parental_warning, - source_item_id_hint: Some(album.id.clone()), - } - }).collect(); - - if tracks.is_empty() { - return Err("Album has no tracks".to_string()); - } - - let start = req.start_index.unwrap_or(0); - let first_track_id = tracks[start].id; - - // Set queue - daemon.core.set_queue(tracks, Some(start)).await; - - // Play the first track - let quality = match req.quality.as_deref() { - Some("Hi-Res+") | Some("UltraHiRes") => qbz_models::Quality::UltraHiRes, - Some("Hi-Res") | Some("HiRes") => qbz_models::Quality::HiRes, - _ => qbz_models::Quality::HiRes, - }; - - // Signal orchestrator to not auto-advance during download - daemon.skip_auto_advance.store(true, std::sync::atomic::Ordering::Release); - - // Stop current playback - let _ = daemon.core.stop(); - - let audio_data = crate::daemon::download_track(&daemon, first_track_id).await?; - daemon.skip_auto_advance.store(false, std::sync::atomic::Ordering::Release); - daemon.core.player().play_data(audio_data, first_track_id) - .map_err(|e| format!("Player error: {}", e))?; - - Ok(Json(serde_json::json!({ - "playing": true, - "track_id": first_track_id, - "album": album.title, - "tracks_count": album_tracks.len(), - }))) -} - -pub async fn play(daemon: Arc) -> Result<&'static str, String> { - daemon.core.resume().map_err(|e| e.to_string())?; - Ok("ok") -} - -pub async fn pause(daemon: Arc) -> Result<&'static str, String> { - daemon.core.pause().map_err(|e| e.to_string())?; - Ok("ok") -} - -pub async fn stop(daemon: Arc) -> Result<&'static str, String> { - daemon.core.stop().map_err(|e| e.to_string())?; - Ok("ok") -} - -pub async fn next(daemon: Arc) -> Json { - let track = daemon.core.next_track().await; - Json(serde_json::json!({ - "track": track, - })) -} - -pub async fn previous(daemon: Arc) -> Json { - let track = daemon.core.previous_track().await; - Json(serde_json::json!({ - "track": track, - })) -} - -pub async fn seek( - daemon: Arc, - Json(req): Json, -) -> Result<&'static str, String> { - daemon.core.seek(req.position_secs).map_err(|e| e.to_string())?; - Ok("ok") -} - -pub async fn volume( - daemon: Arc, - Json(req): Json, -) -> Result<&'static str, String> { - daemon.core.set_volume(req.volume).map_err(|e| e.to_string())?; - Ok("ok") -} diff --git a/crates/qbzd/src/api/playlists.rs b/crates/qbzd/src/api/playlists.rs deleted file mode 100644 index 845351bfb..000000000 --- a/crates/qbzd/src/api/playlists.rs +++ /dev/null @@ -1,118 +0,0 @@ -use std::sync::Arc; -use axum::extract::Path; -use axum::Json; -use serde::Deserialize; - -use crate::daemon::DaemonCore; - -#[derive(Deserialize)] -pub struct CreatePlaylistRequest { - pub name: String, - pub description: Option, - #[serde(default)] - pub is_public: bool, -} - -#[derive(Deserialize)] -pub struct UpdatePlaylistRequest { - pub name: Option, - pub description: Option, - pub is_public: Option, -} - -#[derive(Deserialize)] -pub struct TrackIdsRequest { - pub track_ids: Vec, -} - -pub async fn get_playlists(daemon: Arc) -> Result, String> { - let client = daemon.core.client(); - let guard = client.read().await; - let client = guard.as_ref().ok_or("Not initialized")?; - let playlists = client.get_user_playlists() - .await - .map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(playlists).unwrap_or_default())) -} - -pub async fn get_playlist( - daemon: Arc, - Path(id): Path, -) -> Result, String> { - let client = daemon.core.client(); - let guard = client.read().await; - let client = guard.as_ref().ok_or("Not initialized")?; - let playlist = client.get_playlist(id) - .await - .map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(playlist).unwrap_or_default())) -} - -pub async fn create_playlist( - daemon: Arc, - Json(req): Json, -) -> Result, String> { - let result = daemon.core.create_playlist(&req.name, req.description.as_deref(), req.is_public) - .await - .map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(result).unwrap_or_default())) -} - -pub async fn update_playlist( - daemon: Arc, - Path(id): Path, - Json(req): Json, -) -> Result, String> { - let result = daemon.core.update_playlist(id, req.name.as_deref(), req.description.as_deref(), req.is_public) - .await - .map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(result).unwrap_or_default())) -} - -pub async fn delete_playlist( - daemon: Arc, - Path(id): Path, -) -> Result<&'static str, String> { - daemon.core.delete_playlist(id) - .await - .map_err(|e| e.to_string())?; - Ok("ok") -} - -pub async fn add_tracks( - daemon: Arc, - Path(id): Path, - Json(req): Json, -) -> Result<&'static str, String> { - let client = daemon.core.client(); - let guard = client.read().await; - let client = guard.as_ref().ok_or("Not initialized")?; - client.add_tracks_to_playlist(id, &req.track_ids) - .await - .map_err(|e| e.to_string())?; - Ok("ok") -} - -pub async fn remove_tracks( - daemon: Arc, - Path(id): Path, - Json(req): Json, -) -> Result<&'static str, String> { - let client = daemon.core.client(); - let guard = client.read().await; - let client = guard.as_ref().ok_or("Not initialized")?; - client.remove_tracks_from_playlist(id, &req.track_ids) - .await - .map_err(|e| e.to_string())?; - Ok("ok") -} - -pub async fn search_playlists( - daemon: Arc, - axum::extract::Query(q): axum::extract::Query, -) -> Result, String> { - let result = daemon.core.search_playlists(&q.q, q.limit, q.offset) - .await - .map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(result).unwrap_or_default())) -} diff --git a/crates/qbzd/src/api/queue.rs b/crates/qbzd/src/api/queue.rs deleted file mode 100644 index 3caf13799..000000000 --- a/crates/qbzd/src/api/queue.rs +++ /dev/null @@ -1,125 +0,0 @@ -use std::sync::Arc; -use axum::Json; -use qbz_models::{QueueTrack, RepeatMode}; -use serde::Deserialize; - -use crate::daemon::DaemonCore; - -#[derive(Deserialize)] -pub struct SetQueueRequest { - pub tracks: Vec, - pub start_index: Option, -} - -#[derive(Deserialize)] -pub struct AddTracksRequest { - pub tracks: Vec, -} - -#[derive(Deserialize)] -pub struct PlayIndexRequest { - pub index: usize, -} - -#[derive(Deserialize)] -pub struct RemoveRequest { - pub index: usize, -} - -#[derive(Deserialize)] -pub struct MoveRequest { - pub from: usize, - pub to: usize, -} - -#[derive(Deserialize)] -pub struct ShuffleRequest { - pub enabled: bool, -} - -#[derive(Deserialize)] -pub struct RepeatRequest { - pub mode: String, -} - -pub async fn get_queue(daemon: Arc) -> Json { - let state = daemon.core.get_queue_state().await; - Json(serde_json::to_value(state).unwrap_or_default()) -} - -pub async fn set_queue( - daemon: Arc, - Json(req): Json, -) -> &'static str { - daemon.core.set_queue(req.tracks, req.start_index).await; - "ok" -} - -pub async fn add( - daemon: Arc, - Json(req): Json, -) -> &'static str { - daemon.core.add_tracks(req.tracks).await; - "ok" -} - -pub async fn add_next( - daemon: Arc, - Json(req): Json, -) -> &'static str { - for track in req.tracks { - daemon.core.add_track_next(track).await; - } - "ok" -} - -pub async fn play_index( - daemon: Arc, - Json(req): Json, -) -> Json { - let track = daemon.core.play_index(req.index).await; - Json(serde_json::json!({ "track": track })) -} - -pub async fn remove( - daemon: Arc, - Json(req): Json, -) -> Json { - let removed = daemon.core.remove_track(req.index).await; - Json(serde_json::json!({ "removed": removed })) -} - -pub async fn move_track( - daemon: Arc, - Json(req): Json, -) -> Json { - let ok = daemon.core.move_track(req.from, req.to).await; - Json(serde_json::json!({ "success": ok })) -} - -pub async fn clear(daemon: Arc) -> &'static str { - // Daemon clear keeps the current track — matches legacy TUI behavior. - daemon.core.clear_queue(true).await; - "ok" -} - -pub async fn shuffle( - daemon: Arc, - Json(req): Json, -) -> Json { - daemon.core.set_shuffle(req.enabled).await; - Json(serde_json::json!({ "shuffle": req.enabled })) -} - -pub async fn repeat( - daemon: Arc, - Json(req): Json, -) -> Json { - let mode = match req.mode.as_str() { - "one" => RepeatMode::One, - "all" => RepeatMode::All, - _ => RepeatMode::Off, - }; - daemon.core.set_repeat_mode(mode.clone()).await; - Json(serde_json::json!({ "repeat": format!("{:?}", mode) })) -} diff --git a/crates/qbzd/src/api/search.rs b/crates/qbzd/src/api/search.rs deleted file mode 100644 index 7eb29beaf..000000000 --- a/crates/qbzd/src/api/search.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::sync::Arc; -use axum::extract::Query; -use axum::Json; -use serde::Deserialize; - -use crate::daemon::DaemonCore; - -#[derive(Deserialize)] -pub struct SearchParams { - pub q: String, - #[serde(default = "default_type")] - pub r#type: String, - #[serde(default = "default_limit")] - pub limit: u32, - #[serde(default)] - pub offset: u32, -} - -fn default_type() -> String { "all".to_string() } -fn default_limit() -> u32 { 30 } - -pub async fn search( - daemon: Arc, - Query(params): Query, -) -> Result, String> { - match params.r#type.as_str() { - "all" => { - let result = daemon.core.catalog_search(¶ms.q, params.limit, params.offset) - .await - .map_err(|e| e.to_string())?; - Ok(Json(result)) - } - "albums" => { - let result = daemon.core.search_albums(¶ms.q, params.limit, params.offset, None) - .await - .map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(result).unwrap_or_default())) - } - "tracks" => { - let result = daemon.core.search_tracks(¶ms.q, params.limit, params.offset, None) - .await - .map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(result).unwrap_or_default())) - } - "artists" => { - let result = daemon.core.search_artists(¶ms.q, params.limit, params.offset, None) - .await - .map_err(|e| e.to_string())?; - Ok(Json(serde_json::to_value(result).unwrap_or_default())) - } - _ => Err(format!("Unknown search type: {}", params.r#type)), - } -} diff --git a/crates/qbzd/src/api/system.rs b/crates/qbzd/src/api/system.rs deleted file mode 100644 index d367eb1a8..000000000 --- a/crates/qbzd/src/api/system.rs +++ /dev/null @@ -1,35 +0,0 @@ -use std::sync::Arc; -use axum::Json; - -use crate::daemon::DaemonCore; - -pub async fn get_resources(daemon: Arc) -> Json { - let sys = sysinfo::System::new_with_specifics( - sysinfo::RefreshKind::nothing() - .with_memory(sysinfo::MemoryRefreshKind::everything()) - .with_cpu(sysinfo::CpuRefreshKind::nothing().with_cpu_usage()), - ); - - let cache_stats = daemon.audio_cache.stats(); - - Json(serde_json::json!({ - "ram": { - "total_mb": sys.total_memory() / (1024 * 1024), - "used_mb": sys.used_memory() / (1024 * 1024), - "available_mb": sys.available_memory() / (1024 * 1024), - }, - "cache": { - "memory_used_bytes": cache_stats.current_size_bytes, - "memory_max_bytes": cache_stats.max_size_bytes, - "memory_tracks": cache_stats.cached_tracks, - "config_memory_mb": daemon.config.cache.memory_mb, - "config_disk_mb": daemon.config.cache.disk_mb, - }, - })) -} - -pub async fn clear_cache(daemon: Arc) -> &'static str { - daemon.audio_cache.clear(); - log::info!("[qbzd] Audio cache cleared via API"); - "ok" -} diff --git a/crates/qbzd/src/config.rs b/crates/qbzd/src/config.rs deleted file mode 100644 index dfd702331..000000000 --- a/crates/qbzd/src/config.rs +++ /dev/null @@ -1,277 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DaemonConfig { - #[serde(default)] - pub server: ServerConfig, - #[serde(default)] - pub audio: AudioConfig, - #[serde(default)] - pub cache: CacheConfig, - #[serde(default)] - pub data: DataConfig, - #[serde(default)] - pub mpris: MprisConfig, - #[serde(default)] - pub qconnect: QConnectConfig, - #[serde(default)] - pub mdns: MdnsConfig, - #[serde(default)] - pub logging: LoggingConfig, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerConfig { - pub bind: String, - pub port: u16, - /// "auto" generates on first run - pub token: String, -} - -impl Default for ServerConfig { - fn default() -> Self { - Self { - bind: "0.0.0.0".to_string(), - port: 8182, - token: "auto".to_string(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AudioConfig { - /// pipewire | alsa | pulse | system - pub backend: String, - /// Empty = default device - pub device: String, - pub gapless: bool, - pub normalization: bool, -} - -impl Default for AudioConfig { - fn default() -> Self { - Self { - backend: "pipewire".to_string(), - device: String::new(), - gapless: true, - normalization: false, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CacheConfig { - /// L1 in-memory cache in MB. 0 = auto-detect from RAM. - pub memory_mb: usize, - /// L2 persistent disk cache in MB. 0 = disable. - pub disk_mb: usize, - /// Tracks to prefetch ahead - pub prefetch_count: usize, - /// Simultaneous track downloads - pub prefetch_concurrent: usize, - /// Parallel CMAF segment downloads per track - pub cmaf_concurrent_segments: usize, - #[serde(default)] - pub auto: CacheAutoConfig, -} - -impl Default for CacheConfig { - fn default() -> Self { - Self { - memory_mb: 0, // 0 = auto-detect - disk_mb: 400, - prefetch_count: 2, - prefetch_concurrent: 1, - cmaf_concurrent_segments: 2, - auto: CacheAutoConfig::default(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CacheAutoConfig { - /// Auto-detect RAM and adjust cache sizes - pub enabled: bool, -} - -impl Default for CacheAutoConfig { - fn default() -> Self { - Self { enabled: true } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DataConfig { - pub dir: String, -} - -impl Default for DataConfig { - fn default() -> Self { - let default_dir = dirs::data_dir() - .unwrap_or_else(|| PathBuf::from("/var/lib")) - .join("qbz") - .to_string_lossy() - .to_string(); - Self { dir: default_dir } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MprisConfig { - pub enabled: bool, -} - -impl Default for MprisConfig { - fn default() -> Self { - Self { enabled: true } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct QConnectConfig { - pub enabled: bool, - /// Empty = hostname - pub device_name: String, -} - -impl Default for QConnectConfig { - fn default() -> Self { - Self { - enabled: true, - device_name: String::new(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MdnsConfig { - pub enabled: bool, - /// Empty = hostname - pub name: String, -} - -impl Default for MdnsConfig { - fn default() -> Self { - Self { - enabled: true, - name: String::new(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LoggingConfig { - pub level: String, - pub journal: bool, -} - -impl Default for LoggingConfig { - fn default() -> Self { - Self { - level: "info".to_string(), - journal: true, - } - } -} - -impl DaemonConfig { - /// Load config from TOML file, falling back to defaults. - pub fn load(path: Option<&str>) -> Self { - let config_path = path - .map(PathBuf::from) - .or_else(|| { - dirs::config_dir().map(|d| d.join("qbz").join("qbzd.toml")) - }) - .unwrap_or_else(|| PathBuf::from("qbzd.toml")); - - if config_path.exists() { - match std::fs::read_to_string(&config_path) { - Ok(content) => match toml::from_str(&content) { - Ok(cfg) => { - log::info!("Loaded config from {}", config_path.display()); - return cfg; - } - Err(e) => { - log::warn!("Failed to parse config {}: {}", config_path.display(), e); - } - }, - Err(e) => { - log::warn!("Failed to read config {}: {}", config_path.display(), e); - } - } - } else { - log::info!( - "No config file at {}, using defaults", - config_path.display() - ); - } - - Self::default() - } - - /// Resolve the token — generate if "auto". - pub fn resolve_token(&mut self) { - if self.token_is_auto() { - self.server.token = generate_token(); - log::info!("Generated API token (first run)"); - } - } - - pub fn token_is_auto(&self) -> bool { - self.server.token == "auto" || self.server.token.is_empty() - } - - /// Memory cache size in bytes - pub fn memory_cache_bytes(&self) -> usize { - self.cache.memory_mb * 1024 * 1024 - } - - /// Disk cache size in bytes - pub fn disk_cache_bytes(&self) -> usize { - self.cache.disk_mb * 1024 * 1024 - } -} - -impl Default for DaemonConfig { - fn default() -> Self { - Self { - server: ServerConfig::default(), - audio: AudioConfig::default(), - cache: CacheConfig::default(), - data: DataConfig::default(), - mpris: MprisConfig::default(), - qconnect: QConnectConfig::default(), - mdns: MdnsConfig::default(), - logging: LoggingConfig::default(), - } - } -} - -/// Generate a random 32-character hex token -pub fn generate_token() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let seed = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - format!("{:032x}", seed) -} - -/// Save config to the default config path (creates dir if needed). -pub fn save_default_config(config: &DaemonConfig) -> Result<(), String> { - let config_dir = dirs::config_dir() - .ok_or("Could not determine config directory")? - .join("qbz"); - std::fs::create_dir_all(&config_dir) - .map_err(|e| format!("Failed to create config dir: {}", e))?; - - let path = config_dir.join("qbzd.toml"); - let content = toml::to_string_pretty(config) - .map_err(|e| format!("Failed to serialize config: {}", e))?; - std::fs::write(&path, content) - .map_err(|e| format!("Failed to write config: {}", e))?; - - log::info!("[qbzd] Config saved to {}", path.display()); - Ok(()) -} diff --git a/crates/qbzd/src/daemon.rs b/crates/qbzd/src/daemon.rs deleted file mode 100644 index 93b2f71ed..000000000 --- a/crates/qbzd/src/daemon.rs +++ /dev/null @@ -1,700 +0,0 @@ -use std::sync::Arc; -use tokio::sync::broadcast; - -use qbz_audio::{AudioDiagnostic, AudioSettings}; -use qbz_audio::settings::AudioSettingsStore; -use qbz_cache::AudioCache; -use qbz_core::QbzCore; -use qbz_player::Player; - -use tokio::sync::RwLock; - -use crate::adapter::{DaemonAdapter, DaemonEvent}; -use crate::config::DaemonConfig; -use crate::session::UserSession; - -/// Central state container for the headless daemon. -/// Replaces Tauri's app.state::() with direct field access. -pub struct DaemonCore { - pub config: DaemonConfig, - pub core: Arc>, - pub audio_cache: Arc, - pub event_bus: broadcast::Sender, - /// Per-user state, populated after login + session activation - pub user: RwLock>, - /// Flag to prevent orchestrator auto-advance during explicit play commands - pub skip_auto_advance: std::sync::atomic::AtomicBool, - /// QConnect next track id (from SetState.next_track) for auto-advance - /// when iOS controls without sending a queue. 0 = no next track known. - pub qconnect_next_track_id: Arc, -} - -/// Run the daemon main loop. -pub async fn run(mut config: DaemonConfig) -> Result<(), String> { - log::info!("[qbzd] Access: LAN-only (no authentication required on local network)"); - - // Create event bus (bounded, slow SSE clients get dropped) - let (event_tx, _) = broadcast::channel::(256); - - // Create adapter for QbzCore events - let adapter = DaemonAdapter::new(event_tx.clone()); - - // Load audio settings (from shared database if exists) - let audio_settings = AudioSettingsStore::new() - .ok() - .and_then(|store| store.get_settings().ok()) - .unwrap_or_else(|| { - log::info!("[qbzd] No saved audio settings, using defaults"); - AudioSettings::default() - }); - - let device_name = audio_settings.output_device.clone(); - log::info!( - "[qbzd] Audio: backend={:?}, device={:?}, exclusive={}, gapless={}", - audio_settings.backend_type, - device_name, - audio_settings.exclusive_mode, - config.audio.gapless, - ); - - // Create player (audio thread starts immediately) - let diagnostic = AudioDiagnostic::new(); - let player = Player::new(device_name, audio_settings, None, diagnostic); - - // Create QbzCore — this extracts Qobuz bundle tokens (requires network) - let core = QbzCore::new(adapter, player); - log::info!("[qbzd] Initializing QbzCore (extracting Qobuz bundle tokens)..."); - core.init().await.map_err(|e| format!("QbzCore init failed: {}", e))?; - log::info!("[qbzd] QbzCore initialized"); - - // Create audio cache with configured sizes - let cache_bytes = config.memory_cache_bytes(); - let audio_cache = Arc::new(AudioCache::new(cache_bytes)); - log::info!("[qbzd] Audio cache: {} MB", config.cache.memory_mb); - - let core = Arc::new(core); - - let daemon = Arc::new(DaemonCore { - config: config.clone(), - core: core.clone(), - audio_cache, - event_bus: event_tx.clone(), - user: RwLock::new(None), - skip_auto_advance: std::sync::atomic::AtomicBool::new(false), - qconnect_next_track_id: Arc::new(std::sync::atomic::AtomicU64::new(0)), - }); - - // Try auto-login from saved OAuth token - match try_auto_login(&core).await { - Some(user_id) => { - // Demoted from info to debug to keep user_id out of default-level - // logs (CodeQL rust/cleartext-logging). Set RUST_LOG=debug to see. - log::debug!("[qbzd] Auto-login successful (user_id: {})", user_id); - // Activate per-user session (initialize stores, sync settings) - match crate::session::activate_session(user_id, &core, &event_tx).await { - Ok(session) => { - *daemon.user.write().await = Some(session); - log::info!("[qbzd] User session activated"); - } - Err(e) => { - log::error!("[qbzd] Session activation failed: {}", e); - } - } - - // Start QConnect if enabled - if config.qconnect.enabled { - let device_name = if config.qconnect.device_name.is_empty() { - hostname::get() - .ok() - .and_then(|h| h.into_string().ok()) - .unwrap_or_else(|| "qbzd".to_string()) - } else { - config.qconnect.device_name.clone() - }; - let _qconnect = crate::qconnect::start_qconnect( - &core, - event_tx.clone(), - &device_name, - daemon.qconnect_next_track_id.clone(), - ) - .await; - } - } - None => { - log::warn!("[qbzd] No saved credentials. Run `qbzd login` to authenticate."); - } - } - - // Start MPRIS media controls (Linux D-Bus, headless) - let mpris_handle = if config.mpris.enabled { - crate::mpris::start_mpris(daemon.core.clone()) - } else { - log::info!("[qbzd] MPRIS disabled via config"); - None - }; - - // Start playback state polling loop (broadcasts to event bus + MPRIS) - spawn_playback_loop(daemon.core.clone(), daemon.event_bus.clone(), mpris_handle.clone()); - - // Playback orchestrator: auto-advance, gapless pre-queue, repeat modes - spawn_playback_orchestrator(daemon.clone()); - - // Spawn MPRIS metadata updater (listens to event bus for TrackStarted) - if let Some(ref mc) = mpris_handle { - spawn_mpris_metadata_updater(daemon.event_bus.subscribe(), mc.clone()); - } - - // Register mDNS service for LAN discovery - let _mdns_handle = if config.mdns.enabled { - match register_mdns(&config) { - Ok(handle) => { - log::info!("[qbzd] mDNS registered: _qbz._tcp on port {}", config.server.port); - Some(handle) - } - Err(e) => { - log::warn!("[qbzd] mDNS registration failed: {}", e); - None - } - } - } else { - None - }; - - log::info!( - "[qbzd] Daemon ready on {}:{}", - config.server.bind, - config.server.port - ); - - // Start HTTP server - let bind_addr = format!("{}:{}", config.server.bind, config.server.port); - let listener = tokio::net::TcpListener::bind(&bind_addr) - .await - .map_err(|e| format!("Failed to bind {}: {}", bind_addr, e))?; - - log::info!("[qbzd] HTTP server listening on {}", bind_addr); - - // Axum router - let app = build_router(daemon.clone()); - - // Graceful shutdown on SIGTERM/SIGINT - let shutdown = async { - let ctrl_c = tokio::signal::ctrl_c(); - #[cfg(unix)] - let mut sigterm = - tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .expect("Failed to register SIGTERM handler"); - - #[cfg(unix)] - tokio::select! { - _ = ctrl_c => log::info!("[qbzd] Received SIGINT, shutting down..."), - _ = sigterm.recv() => log::info!("[qbzd] Received SIGTERM, shutting down..."), - } - - #[cfg(not(unix))] - ctrl_c.await.ok(); - }; - - let app = app.into_make_service_with_connect_info::(); - axum::serve(listener, app) - .with_graceful_shutdown(shutdown) - .await - .map_err(|e| format!("HTTP server error: {}", e))?; - - // Graceful shutdown: stop player - log::info!("[qbzd] Stopping player..."); - let _ = daemon.core.stop(); - - log::info!("[qbzd] Shutdown complete"); - Ok(()) -} - -/// Try to restore session from saved OAuth token in keyring. -/// Returns the user_id on success. -async fn try_auto_login(core: &QbzCore) -> Option { - let token = load_oauth_token()?; - - match core.login_with_token(&token).await { - Ok(session) => { - // Demoted from info to debug to keep user_id out of default-level - // logs (CodeQL rust/cleartext-logging). Set RUST_LOG=debug to see. - log::debug!( - "[qbzd] Session restored for user {} ({})", - session.display_name, - session.user_id - ); - Some(session.user_id) - } - Err(e) => { - log::warn!("[qbzd] Saved token expired or invalid: {}", e); - None - } - } -} - -/// Load OAuth token — tries keyring first, then file fallback. -fn load_oauth_token() -> Option { - const SERVICE: &str = "qbz-player"; - const KEY: &str = "qobuz-oauth-token"; - - // Try keyring first - if let Ok(entry) = keyring::Entry::new(SERVICE, KEY) { - match entry.get_password() { - Ok(token) if !token.is_empty() => { - log::info!("[qbzd] OAuth token loaded from keyring"); - return Some(token); - } - Ok(_) => {} - Err(keyring::Error::NoEntry) => { - log::debug!("[qbzd] No OAuth token in keyring"); - } - Err(e) => { - log::warn!("[qbzd] Keyring access failed: {}", e); - } - } - } - - // Fallback: try file - if let Some(token) = crate::login::load_token_from_file() { - log::info!("[qbzd] OAuth token loaded from file fallback"); - return Some(token); - } - - None -} - -/// Playback orchestrator: handles auto-advance, gapless, and repeat. -/// This replaces the frontend's setOnTrackEnded + setGaplessGetNextTrackId -/// callbacks since the daemon has no frontend. -fn spawn_playback_orchestrator(daemon: Arc) { - tokio::spawn(async move { - let mut last_track_id: u64 = 0; - let mut was_playing = false; - let mut gapless_queued_for: u64 = 0; // Track ID we've already queued gapless for - - loop { - let player = daemon.core.player(); - let state = &player.state; - let track_id = state.current_track_id(); - let is_playing = state.is_playing(); - let gapless_ready = state.is_gapless_ready(); - - // === GAPLESS PRE-QUEUE === - // When player signals gapless_ready (5s before track end), - // download next track and queue it for seamless transition. - if gapless_ready && track_id != 0 && gapless_queued_for != track_id { - gapless_queued_for = track_id; - - let queue_state = daemon.core.get_queue_state().await; - let repeat_mode = queue_state.repeat; - - // Determine next track ID (same logic as desktop's setGaplessGetNextTrackId) - let next_id = if repeat_mode == qbz_models::RepeatMode::One { - Some(track_id) // Repeat same track - } else { - // Peek at upcoming queue - let upcoming = daemon.core.peek_upcoming(2).await; - upcoming.first() - .filter(|t| t.id != track_id) - .or_else(|| upcoming.get(1).filter(|t| t.id != track_id)) - .map(|t| t.id) - }; - - if let Some(next_id) = next_id { - log::info!("[qbzd/gapless] Pre-queuing track {} for gapless", next_id); - if let Ok(audio_data) = download_track(&daemon, next_id).await { - match player.play_next(audio_data, next_id) { - Ok(()) => { - log::info!("[qbzd/gapless] Track {} queued for seamless transition", next_id); - } - Err(e) => { - log::warn!("[qbzd/gapless] play_next failed: {}", e); - } - } - } - } - } - - // === AUTO-ADVANCE === - // When track ends (was playing, now track_id is 0), advance queue. - // This handles the case when gapless wasn't available or failed. - let track_ended = was_playing && !is_playing && track_id == 0 && last_track_id != 0; - - if track_ended && !daemon.skip_auto_advance.load(std::sync::atomic::Ordering::Acquire) { - let queue_state = daemon.core.get_queue_state().await; - let repeat_mode = queue_state.repeat; - - // Repeat-one: replay the same track - if repeat_mode == qbz_models::RepeatMode::One { - log::info!("[qbzd/advance] Repeat-one: replaying track {}", last_track_id); - if let Ok(audio_data) = download_track(&daemon, last_track_id).await { - let _ = player.play_data(audio_data, last_track_id); - } - } else { - // Normal advance: try local queue first, then QConnect next_track fallback - let previous_id = last_track_id; - let local_next = daemon.core.next_track().await - .filter(|t| t.id != previous_id); - - if let Some(next_track) = local_next { - log::info!("[qbzd/advance] Next (local queue): {} - {}", next_track.artist, next_track.title); - if let Ok(audio_data) = download_track(&daemon, next_track.id).await { - let _ = player.play_data(audio_data, next_track.id); - } - } else { - // Fallback: use next_track from last QConnect SetState - let qconnect_next = daemon.qconnect_next_track_id.load(std::sync::atomic::Ordering::Acquire); - if qconnect_next != 0 && qconnect_next != previous_id { - log::info!("[qbzd/advance] Next (QConnect fallback): {}", qconnect_next); - // Clear before downloading to prevent re-use on next iteration - daemon.qconnect_next_track_id.store(0, std::sync::atomic::Ordering::Release); - if let Ok(audio_data) = download_track(&daemon, qconnect_next).await { - let _ = player.play_data(audio_data, qconnect_next); - } - } else { - log::info!("[qbzd/advance] Queue empty, playback stopped"); - } - } - } - gapless_queued_for = 0; - } - - // Reset gapless tracking on track change - if track_id != last_track_id { - gapless_queued_for = 0; - } - - last_track_id = track_id; - was_playing = is_playing; - - tokio::time::sleep(std::time::Duration::from_millis(250)).await; - } - }); -} - -/// Download a track's audio from Qobuz. Returns the raw audio bytes. -pub async fn download_track(daemon: &DaemonCore, track_id: u64) -> Result, String> { - let quality = qbz_models::Quality::HiRes; // TODO: use configured quality - let stream_url = daemon.core.get_stream_url(track_id, quality).await - .map_err(|e| format!("Stream URL failed for {}: {}", track_id, e))?; - - let http = reqwest::Client::builder() - .connect_timeout(std::time::Duration::from_secs(10)) - .build() - .map_err(|e| format!("HTTP client error: {}", e))?; - - let data = http.get(&stream_url.url).send().await - .map_err(|e| format!("Download failed for {}: {}", track_id, e))? - .bytes().await - .map_err(|e| format!("Read failed for {}: {}", track_id, e))? - .to_vec(); - - log::info!("[qbzd/download] Track {} downloaded ({} bytes, {:.0}kHz/{}bit)", - track_id, data.len(), stream_url.sampling_rate, stream_url.bit_depth.unwrap_or(0)); - - Ok(data) -} - -/// Register the daemon as a `_qbz._tcp` mDNS service for LAN discovery. -fn register_mdns(config: &DaemonConfig) -> Result { - let mdns = mdns_sd::ServiceDaemon::new() - .map_err(|e| format!("mDNS daemon error: {}", e))?; - - let service_name = if config.mdns.name.is_empty() { - hostname::get() - .ok() - .and_then(|h| h.into_string().ok()) - .unwrap_or_else(|| "qbzd".to_string()) - } else { - config.mdns.name.clone() - }; - - let service_info = mdns_sd::ServiceInfo::new( - "_qbz._tcp.local.", - &service_name, - &format!("{}.local.", hostname::get().ok().and_then(|h| h.into_string().ok()).unwrap_or_else(|| "localhost".to_string())), - "", - config.server.port, - None, - ) - .map_err(|e| format!("mDNS service info error: {}", e))?; - - mdns.register(service_info) - .map_err(|e| format!("mDNS register error: {}", e))?; - - Ok(mdns) -} - -/// Listen for CoreEvent::TrackStarted on the event bus and update MPRIS metadata. -fn spawn_mpris_metadata_updater( - mut rx: broadcast::Receiver, - mc: Arc>, -) { - tokio::spawn(async move { - loop { - match rx.recv().await { - Ok(DaemonEvent::Core(qbz_models::CoreEvent::TrackStarted { track, .. })) => { - crate::mpris::update_mpris_metadata( - &mc, - &track.title, - &track.artist, - &track.album, - track.duration_secs, - ); - log::debug!("[qbzd/mpris] Metadata: {} - {}", track.artist, track.title); - } - Ok(DaemonEvent::Core(qbz_models::CoreEvent::LoggedOut)) => { - // Clear metadata on logout - if let Ok(mut controls) = mc.lock() { - let _ = controls.set_playback(souvlaki::MediaPlayback::Stopped); - } - } - Err(broadcast::error::RecvError::Lagged(n)) => { - log::debug!("[qbzd/mpris] Lagged {} events", n); - } - Err(broadcast::error::RecvError::Closed) => break, - _ => {} - } - } - }); -} - -/// Spawn the playback state polling loop. -/// Reads player state and broadcasts PlaybackSnapshot events. -/// Also updates MPRIS metadata when track changes. -/// Adaptive polling: 250ms playing, 1s paused, 5s idle. -fn spawn_playback_loop( - core: Arc>, - event_tx: broadcast::Sender, - mpris: Option>>, -) { - tokio::spawn(async move { - let mut last_position: u64 = 0; - let mut last_is_playing = false; - let mut last_track_id: u64 = 0; - - loop { - let player = core.player(); - let state = &player.state; - - let is_playing = state.is_playing(); - let track_id = state.current_track_id(); - let position = state.current_position(); - let duration = state.duration(); - let volume = state.volume(); - let sample_rate = state.get_sample_rate(); - let bit_depth = state.get_bit_depth(); - - let track_cleared = track_id == 0 && last_track_id != 0; - let should_emit = (track_id != 0 - && (is_playing != last_is_playing - || track_id != last_track_id - || (is_playing && position != last_position))) - || track_cleared; - - if should_emit { - let snapshot = crate::adapter::PlaybackSnapshot { - state: if is_playing { - "Playing".to_string() - } else if track_id != 0 { - "Paused".to_string() - } else { - "Stopped".to_string() - }, - track_id, - position_secs: position, - duration_secs: duration, - volume, - sample_rate, - bit_depth, - }; - let _ = event_tx.send(DaemonEvent::Playback(snapshot)); - - // Update MPRIS - if let Some(ref mc) = mpris { - if track_id != last_track_id && track_id != 0 { - // Track changed — update metadata - // We don't have track title here (just ID), so use a - // placeholder. Full metadata comes from CoreEvent::TrackStarted. - crate::mpris::update_mpris_playback(mc, is_playing, position); - } else { - crate::mpris::update_mpris_playback(mc, is_playing, position); - } - if track_id == 0 && last_track_id != 0 { - crate::mpris::update_mpris_playback(mc, false, 0); - } - } - - last_position = position; - last_is_playing = is_playing; - last_track_id = track_id; - } - - // Adaptive polling - let sleep_ms = if is_playing { - 250 - } else if track_id == 0 { - 5000 - } else { - 1000 - }; - tokio::time::sleep(std::time::Duration::from_millis(sleep_ms)).await; - } - }); -} - -/// Macro to reduce boilerplate for route handlers that take Arc -macro_rules! with_daemon { - ($daemon:expr, $handler:path) => {{ - let d = $daemon.clone(); - move || $handler(d.clone()) - }}; - ($daemon:expr, $handler:path, json) => {{ - let d = $daemon.clone(); - move |body| $handler(d.clone(), body) - }}; - ($daemon:expr, $handler:path, query) => {{ - let d = $daemon.clone(); - move |q| $handler(d.clone(), q) - }}; - ($daemon:expr, $handler:path, path) => {{ - let d = $daemon.clone(); - move |p| $handler(d.clone(), p) - }}; - ($daemon:expr, $handler:path, path_query) => {{ - let d = $daemon.clone(); - move |p, q| $handler(d.clone(), p, q) - }}; - ($daemon:expr, $handler:path, path_json) => {{ - let d = $daemon.clone(); - move |p, j| $handler(d.clone(), p, j) - }}; -} - -/// Build the Axum HTTP router. -fn build_router(daemon: Arc) -> axum::Router { - use axum::routing::{get, post, patch, put, delete}; - use axum::middleware as axum_mw; - use crate::api::{audio, catalog, catalog_ext, discover, favorites, integrations, library, middleware, playback, playlists, queue, search, system}; - - axum::Router::new() - // System - .route("/api/ping", get(ping_handler)) - .route("/api/info", get(with_daemon!(daemon, info_handler))) - .route("/api/status", get(with_daemon!(daemon, status_handler))) - .route("/api/events", get(with_daemon!(daemon, crate::api::events::sse_handler))) - // Playback - .route("/api/playback", get(with_daemon!(daemon, playback::get_playback))) - .route("/api/playback/play", post(with_daemon!(daemon, playback::play))) - .route("/api/playback/play-track", post(with_daemon!(daemon, playback::play_track, json))) - .route("/api/playback/play-album", post(with_daemon!(daemon, playback::play_album, json))) - .route("/api/playback/pause", post(with_daemon!(daemon, playback::pause))) - .route("/api/playback/stop", post(with_daemon!(daemon, playback::stop))) - .route("/api/playback/next", post(with_daemon!(daemon, playback::next))) - .route("/api/playback/previous", post(with_daemon!(daemon, playback::previous))) - .route("/api/playback/seek", post(with_daemon!(daemon, playback::seek, json))) - .route("/api/playback/volume", post(with_daemon!(daemon, playback::volume, json))) - // Queue - .route("/api/queue", get(with_daemon!(daemon, queue::get_queue))) - .route("/api/queue/set", post(with_daemon!(daemon, queue::set_queue, json))) - .route("/api/queue/add", post(with_daemon!(daemon, queue::add, json))) - .route("/api/queue/add-next", post(with_daemon!(daemon, queue::add_next, json))) - .route("/api/queue/play-index", post(with_daemon!(daemon, queue::play_index, json))) - .route("/api/queue/remove", post(with_daemon!(daemon, queue::remove, json))) - .route("/api/queue/move", post(with_daemon!(daemon, queue::move_track, json))) - .route("/api/queue/clear", post(with_daemon!(daemon, queue::clear))) - .route("/api/queue/shuffle", post(with_daemon!(daemon, queue::shuffle, json))) - .route("/api/queue/repeat", post(with_daemon!(daemon, queue::repeat, json))) - // Search - .route("/api/search", get(with_daemon!(daemon, search::search, query))) - // Catalog - .route("/api/albums/{id}", get(with_daemon!(daemon, catalog::get_album, path))) - .route("/api/artists/{id}", get(with_daemon!(daemon, catalog::get_artist, path))) - .route("/api/tracks/{id}", get(with_daemon!(daemon, catalog::get_track, path))) - .route("/api/tracks/batch", get(with_daemon!(daemon, catalog::get_tracks_batch, query))) - // Audio settings - .route("/api/audio/settings", get(with_daemon!(daemon, audio::get_settings))) - .route("/api/audio/settings", patch(with_daemon!(daemon, audio::update_settings, json))) - .route("/api/audio/backends", get(with_daemon!(daemon, audio::get_backends))) - .route("/api/audio/devices", get(with_daemon!(daemon, audio::get_devices, query))) - .route("/api/audio/hardware-status", get(with_daemon!(daemon, audio::get_hardware_status))) - // Discover / Home - .route("/api/discover", get(with_daemon!(daemon, discover::get_discover_index, query))) - .route("/api/discover/playlists", get(with_daemon!(daemon, discover::get_discover_playlists, query))) - .route("/api/discover/featured", get(with_daemon!(daemon, discover::get_featured, query))) - .route("/api/genres", get(with_daemon!(daemon, discover::get_genres))) - // Favorites - .route("/api/favorites", get(with_daemon!(daemon, favorites::get_favorites, query))) - .route("/api/favorites", post(with_daemon!(daemon, favorites::add_favorite, json))) - .route("/api/favorites", delete(with_daemon!(daemon, favorites::remove_favorite, json))) - // Playlists - .route("/api/playlists", get(with_daemon!(daemon, playlists::get_playlists))) - .route("/api/playlists", post(with_daemon!(daemon, playlists::create_playlist, json))) - .route("/api/playlists/search", get(with_daemon!(daemon, playlists::search_playlists, query))) - .route("/api/playlists/{id}", get(with_daemon!(daemon, playlists::get_playlist, path))) - .route("/api/playlists/{id}", put(with_daemon!(daemon, playlists::update_playlist, path_json))) - .route("/api/playlists/{id}", delete(with_daemon!(daemon, playlists::delete_playlist, path))) - .route("/api/playlists/{id}/tracks", post(with_daemon!(daemon, playlists::add_tracks, path_json))) - .route("/api/playlists/{id}/tracks", delete(with_daemon!(daemon, playlists::remove_tracks, path_json))) - // Extended catalog - .route("/api/artists/{id}/page", get(with_daemon!(daemon, catalog_ext::get_artist_page, path))) - .route("/api/artists/{id}/similar", get(with_daemon!(daemon, catalog_ext::get_similar_artists, path_query))) - .route("/api/labels/{id}", get(with_daemon!(daemon, catalog_ext::get_label, path_query))) - .route("/api/labels/{id}/page", get(with_daemon!(daemon, catalog_ext::get_label_page, path))) - .route("/api/labels/explore", get(with_daemon!(daemon, catalog_ext::get_label_explore, query))) - .route("/api/playlist-tags", get(with_daemon!(daemon, catalog_ext::get_playlist_tags))) - // Library (local files) - .route("/api/library/albums", get(with_daemon!(daemon, library::get_albums))) - .route("/api/library/artists", get(with_daemon!(daemon, library::get_artists))) - .route("/api/library/albums/{key}/tracks", get(with_daemon!(daemon, library::get_album_tracks, path))) - .route("/api/library/search", get(with_daemon!(daemon, library::search_library, query))) - .route("/api/library/stats", get(with_daemon!(daemon, library::get_stats))) - .route("/api/library/folders", get(with_daemon!(daemon, library::get_folders))) - .route("/api/library/folders", post(with_daemon!(daemon, library::add_folder, json))) - .route("/api/library/folders", delete(with_daemon!(daemon, library::remove_folder, json))) - .route("/api/library/scan", post(with_daemon!(daemon, library::start_scan))) - // Integrations - .route("/api/integrations/listenbrainz", get(with_daemon!(daemon, integrations::get_listenbrainz_status))) - .route("/api/integrations/listenbrainz/connect", post(with_daemon!(daemon, integrations::connect_listenbrainz, json))) - .route("/api/integrations/listenbrainz", delete(with_daemon!(daemon, integrations::disconnect_listenbrainz))) - .route("/api/integrations/lastfm", get(with_daemon!(daemon, integrations::get_lastfm_status))) - // System / Resources - .route("/api/system/resources", get(with_daemon!(daemon, system::get_resources))) - .route("/api/cache", delete(with_daemon!(daemon, system::clear_cache))) - // CORS: allow qbz-control PWA and any LAN origin - .layer( - tower_http::cors::CorsLayer::new() - .allow_origin(tower_http::cors::Any) - .allow_methods(tower_http::cors::Any) - .allow_headers(tower_http::cors::Any) - ) - // LAN-only: reject requests from non-private IPs - .layer(axum_mw::from_fn(middleware::lan_only)) -} - -async fn ping_handler() -> &'static str { - "pong" -} - -async fn info_handler(daemon: Arc) -> axum::Json { - axum::Json(serde_json::json!({ - "name": "qbzd", - "version": env!("CARGO_PKG_VERSION"), - "cache": { - "memory_mb": daemon.config.cache.memory_mb, - "disk_mb": daemon.config.cache.disk_mb, - "prefetch_count": daemon.config.cache.prefetch_count, - }, - })) -} - -async fn status_handler(daemon: Arc) -> axum::Json { - let logged_in = daemon.core.has_session().await; - axum::Json(serde_json::json!({ - "state": if logged_in { "ready" } else { "no_session" }, - "logged_in": logged_in, - "audio": { - "cache_mb": daemon.config.cache.memory_mb, - }, - })) -} diff --git a/crates/qbzd/src/login.rs b/crates/qbzd/src/login.rs deleted file mode 100644 index 4d9ae284e..000000000 --- a/crates/qbzd/src/login.rs +++ /dev/null @@ -1,179 +0,0 @@ -//! Interactive Qobuz login via system browser OAuth. -//! -//! Supports both local and remote (headless) operation: -//! - Local: opens browser automatically, callback to localhost -//! - Remote/headless: prints URL for user to open on another device, -//! callback to the daemon's LAN IP so the redirect works from -//! any browser on the same network. - -use qbz_qobuz::QobuzClient; - -const OAUTH_TIMEOUT_SECS: u64 = 300; // 5 min for remote login (user needs time to copy URL) - -pub async fn interactive_login() -> Result<(), String> { - println!("Initializing Qobuz client..."); - - let client = QobuzClient::new().map_err(|e| format!("Client error: {}", e))?; - client.init().await.map_err(|e| format!("Bundle extraction failed: {}", e))?; - - let app_id = client.app_id().await.map_err(|e| format!("No app_id: {}", e))?; - - // Bind to 0.0.0.0 so the callback works from any device on the LAN - let listener = tokio::net::TcpListener::bind("0.0.0.0:0") - .await - .map_err(|e| format!("Failed to bind listener: {}", e))?; - let port = listener.local_addr().map_err(|e| e.to_string())?.port(); - - // Detect LAN IP for the redirect URL - let lan_ip = detect_lan_ip().unwrap_or_else(|| "localhost".to_string()); - let redirect_url = format!("http://{}:{}", lan_ip, port); - - let oauth_url = format!( - "https://www.qobuz.com/signin/oauth?ext_app_id={}&redirect_url={}", - app_id, - urlencoding::encode(&redirect_url), - ); - - // Channel for the auth code - let (tx, mut rx) = tokio::sync::mpsc::channel::(1); - - // Local HTTP handler - let handler = axum::Router::new().route( - "/", - axum::routing::get(move |axum::extract::Query(params): axum::extract::Query>| { - let tx = tx.clone(); - async move { - if let Some(code) = params.get("code_autorisation").or_else(|| params.get("code")) { - let _ = tx.send(code.clone()).await; - axum::response::Html( - "\ -

Login successful!

\ -

You can close this tab and return to the terminal.

\ - " - ) - } else { - axum::response::Html( - "\ -

Login failed

\ -

No authorization code received.

\ - " - ) - } - } - }), - ); - - let server_handle = tokio::spawn(async move { - axum::serve(listener, handler).await.ok(); - }); - - // Always print the URL (works for both local and headless) - println!("\n╔══════════════════════════════════════════════════╗"); - println!("║ Open this URL in any browser on your network: ║"); - println!("╚══════════════════════════════════════════════════╝\n"); - println!(" {}\n", oauth_url); - println!("Callback listening on: {}", redirect_url); - println!("Waiting for login ({}s timeout)...\n", OAUTH_TIMEOUT_SECS); - - // Try to open browser (works on local, silently fails on headless) - let _ = open::that(&oauth_url); - - // Wait for code - let code = tokio::time::timeout( - std::time::Duration::from_secs(OAUTH_TIMEOUT_SECS), - rx.recv(), - ) - .await; - - server_handle.abort(); - - let code = match code { - Ok(Some(c)) => c, - Ok(None) => return Err("Login cancelled".to_string()), - Err(_) => return Err(format!("Login timed out after {}s", OAUTH_TIMEOUT_SECS)), - }; - - println!("Authorization code received. Exchanging for session..."); - - let session = client - .login_with_oauth_code(&code) - .await - .map_err(|e| format!("OAuth exchange failed: {}", e))?; - - println!( - "\nLogged in as: {} (user_id: {})", - session.display_name, session.user_id - ); - println!("Subscription: {}", session.subscription_label); - - // Save token to keyring - let token = session.user_auth_token.clone(); - save_token_to_keyring(&token)?; - - println!("\nCredentials saved. The daemon will auto-login on next start."); - Ok(()) -} - -/// Save OAuth token — tries keyring first, falls back to encrypted file. -fn save_token_to_keyring(token: &str) -> Result<(), String> { - const SERVICE: &str = "qbz-player"; - const KEY: &str = "qobuz-oauth-token"; - - // Try keyring first - match keyring::Entry::new(SERVICE, KEY) { - Ok(entry) => match entry.set_password(token) { - Ok(()) => { - println!("Token saved to system keyring"); - return Ok(()); - } - Err(e) => { - println!("Keyring unavailable ({}), using file fallback", e); - } - }, - Err(e) => { - println!("Keyring unavailable ({}), using file fallback", e); - } - } - - // Fallback: save to file - save_token_to_file(token) -} - -fn token_file_path() -> Option { - dirs::data_dir().map(|d| d.join("qbz").join(".oauth-token")) -} - -fn save_token_to_file(token: &str) -> Result<(), String> { - let path = token_file_path().ok_or("Cannot determine data directory")?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).ok(); - } - std::fs::write(&path, token).map_err(|e| format!("Failed to write token file: {}", e))?; - // Restrict permissions on Unix - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); - } - println!("Token saved to {}", path.display()); - Ok(()) -} - -pub fn load_token_from_file() -> Option { - let path = token_file_path()?; - std::fs::read_to_string(&path).ok().filter(|t| !t.trim().is_empty()) -} - -/// Detect the primary LAN IP address of this machine. -fn detect_lan_ip() -> Option { - // Try to get the default route interface IP by connecting to a public DNS - // (no actual data is sent, just gets the local IP used for routing) - let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; - socket.connect("8.8.8.8:80").ok()?; - let addr = socket.local_addr().ok()?; - let ip = addr.ip().to_string(); - if ip == "0.0.0.0" || ip == "127.0.0.1" { - return None; - } - Some(ip) -} diff --git a/crates/qbzd/src/main.rs b/crates/qbzd/src/main.rs deleted file mode 100644 index b3f0d9346..000000000 --- a/crates/qbzd/src/main.rs +++ /dev/null @@ -1,157 +0,0 @@ -mod api; -mod config; -mod daemon; -mod adapter; -mod login; -mod mpris; -mod qconnect; -mod resources; -mod wizard; -mod session; - -use clap::{Parser, Subcommand}; - -#[derive(Parser)] -#[command(name = "qbzd", about = "QBZ headless music daemon")] -#[command(version)] -struct Cli { - /// HTTP port - #[arg(short, long, default_value_t = 8182)] - port: u16, - - /// Bind address - #[arg(short, long, default_value = "0.0.0.0")] - bind: String, - - /// Data directory - #[arg(short, long)] - data_dir: Option, - - /// Config file path - #[arg(short, long)] - config: Option, - - /// Auth token (auto-generated if not provided) - #[arg(long)] - token: Option, - - /// Disable MPRIS/D-Bus integration - #[arg(long)] - no_mpris: bool, - - /// Log level - #[arg(long, default_value = "info")] - log_level: String, - - /// Print a new token and exit - #[arg(long)] - generate_token: bool, - - #[command(subcommand)] - command: Option, -} - -#[derive(Subcommand)] -enum Commands { - /// Interactive Qobuz login (OAuth via system browser) - Login, - /// Show daemon status - Status, - /// Show or regenerate API token - Token, - /// Interactive setup wizard (audio, playback, integrations) - Setup { - /// Run only a specific section (audio, playback, cache, integrations, qconnect) - #[arg(long)] - section: Option, - }, -} - -#[tokio::main] -async fn main() { - let cli = Cli::parse(); - - // Init logging - env_logger::Builder::new() - .filter_level(cli.log_level.parse().unwrap_or(log::LevelFilter::Info)) - .format_timestamp_millis() - .init(); - - log::info!("qbzd starting..."); - - // Load config (TOML file -> env vars -> CLI overrides) - let mut cfg = config::DaemonConfig::load(cli.config.as_deref()); - - // CLI overrides - cfg.server.port = cli.port; - cfg.server.bind = cli.bind.clone(); - if let Some(ref dir) = cli.data_dir { - cfg.data.dir = dir.clone(); - } - if let Some(ref token) = cli.token { - cfg.server.token = token.clone(); - } - cfg.mpris.enabled = !cli.no_mpris; - - // Generate token and exit - if cli.generate_token { - let token = config::generate_token(); - println!("{}", token); - return; - } - - // Auto-detect resources - resources::auto_detect_cache_config(&mut cfg); - - // Handle subcommands - match cli.command { - Some(Commands::Login) => { - if let Err(e) = login::interactive_login().await { - eprintln!("Login failed: {}", e); - std::process::exit(1); - } - } - Some(Commands::Status) => { - let port = cfg.server.port; - match reqwest::Client::new() - .get(format!("http://127.0.0.1:{}/api/status", port)) - .timeout(std::time::Duration::from_secs(3)) - .send() - .await - { - Ok(resp) if resp.status().is_success() => { - let body: serde_json::Value = resp.json().await.unwrap_or_default(); - println!("qbzd running on port {}", port); - println!("{}", serde_json::to_string_pretty(&body).unwrap_or_default()); - } - Ok(resp) => { - println!("qbzd responded with HTTP {}", resp.status()); - } - Err(_) => { - println!("qbzd not running (no response on port {})", port); - } - } - } - Some(Commands::Setup { section }) => { - if let Err(e) = wizard::run(section.as_deref()) { - eprintln!("Setup wizard error: {}", e); - std::process::exit(1); - } - } - Some(Commands::Token) => { - if cfg.server.token == "auto" { - let token = config::generate_token(); - println!("Generated token: {}", token); - } else { - println!("Current token: {}", cfg.server.token); - } - } - None => { - // Main daemon mode - if let Err(e) = daemon::run(cfg).await { - log::error!("Daemon exited with error: {}", e); - std::process::exit(1); - } - } - } -} diff --git a/crates/qbzd/src/mpris.rs b/crates/qbzd/src/mpris.rs deleted file mode 100644 index b16ea56b2..000000000 --- a/crates/qbzd/src/mpris.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! Headless MPRIS media controls via D-Bus (Linux). -//! -//! Uses souvlaki with hwnd: None — works without a window on Linux. -//! Receives media key events (play/pause/next/prev) and routes them -//! to QbzCore. - -use std::sync::Arc; -use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig}; - -use crate::adapter::DaemonAdapter; - -/// Start MPRIS media controls in a background thread. -/// Returns a handle to update metadata/playback state. -pub fn start_mpris( - core: Arc>, -) -> Option>> { - let config = PlatformConfig { - dbus_name: "com.blitzfc.qbzd", - display_name: "QBZ Daemon", - hwnd: None, - }; - - let mut controls = match MediaControls::new(config) { - Ok(mc) => mc, - Err(e) => { - log::warn!("[qbzd/mpris] Failed to create media controls: {:?}", e); - return None; - } - }; - - let core_for_handler = core.clone(); - if let Err(e) = controls.attach(move |event: MediaControlEvent| { - let core = core_for_handler.clone(); - match event { - MediaControlEvent::Play => { let _ = core.resume(); } - MediaControlEvent::Pause => { let _ = core.pause(); } - MediaControlEvent::Toggle => { - if core.player().state.is_playing() { - let _ = core.pause(); - } else { - let _ = core.resume(); - } - } - MediaControlEvent::Next => { - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - core.next_track().await; - }); - }); - } - MediaControlEvent::Previous => { - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - core.previous_track().await; - }); - }); - } - MediaControlEvent::Stop => { let _ = core.stop(); } - _ => {} - } - }) { - log::warn!("[qbzd/mpris] Failed to attach handler: {:?}", e); - return None; - } - - // Set initial state - let _ = controls.set_playback(MediaPlayback::Stopped); - log::info!("[qbzd/mpris] MPRIS media controls initialized (D-Bus)"); - - Some(Arc::new(std::sync::Mutex::new(controls))) -} - -/// Update MPRIS metadata from playback state. -pub fn update_mpris_metadata( - controls: &Arc>, - title: &str, - artist: &str, - album: &str, - duration_secs: u64, -) { - if let Ok(mut mc) = controls.lock() { - let _ = mc.set_metadata(MediaMetadata { - title: Some(title), - artist: Some(artist), - album: Some(album), - duration: Some(std::time::Duration::from_secs(duration_secs)), - ..Default::default() - }); - } -} - -/// Update MPRIS playback state. -pub fn update_mpris_playback( - controls: &Arc>, - is_playing: bool, - position_secs: u64, -) { - if let Ok(mut mc) = controls.lock() { - let playback = if is_playing { - MediaPlayback::Playing { - progress: Some(souvlaki::MediaPosition(std::time::Duration::from_secs(position_secs))), - } - } else { - MediaPlayback::Paused { - progress: Some(souvlaki::MediaPosition(std::time::Duration::from_secs(position_secs))), - } - }; - let _ = mc.set_playback(playback); - } -} diff --git a/crates/qbzd/src/qconnect.rs b/crates/qbzd/src/qconnect.rs deleted file mode 100644 index fb69f0470..000000000 --- a/crates/qbzd/src/qconnect.rs +++ /dev/null @@ -1,681 +0,0 @@ -//! Headless QConnect integration. -//! -//! Replicates the exact flow from qconnect_service.rs in the desktop app: -//! 1. Create transport + sink + app -//! 2. Connect transport -//! 3. Subscribe to transport events -//! 4. Start event loop (checks InboundQueueServerEvent for SESSION_STATE) -//! 5. Bootstrap: CtrlSrvrJoinSession + AskForQueueState -//! 6. Event loop receives SESSION_STATE → deferred renderer join - -use std::sync::Arc; -use async_trait::async_trait; -use qconnect_app::{ - QconnectApp, QconnectAppEvent, QconnectEventSink, - QueueCommandType, RendererReport, RendererReportType, -}; -use qconnect_core::{QConnectQueueState, RendererCommand}; -use qconnect_transport_ws::{NativeWsTransport, WsTransportConfig}; -use qbz_models::{QueueTrack, Track}; -use tokio::sync::broadcast; -use uuid::Uuid; - -use crate::adapter::{DaemonAdapter, DaemonEvent}; - -const PLAYING_STATE_STOPPED: i32 = 1; -const PLAYING_STATE_PLAYING: i32 = 2; -const PLAYING_STATE_PAUSED: i32 = 3; -const BUFFER_STATE_OK: i32 = 2; -const JOIN_SESSION_REASON_CONTROLLER_REQUEST: i32 = 1; -const AUDIO_QUALITY_HIRES_LEVEL2: i32 = 4; - -type App = QconnectApp; - -/// Event sink — handles renderer commands for playback. -pub struct HeadlessQconnectSink { - event_tx: broadcast::Sender, - core: Arc>, - qconnect_next_track_id: Arc, -} - -impl HeadlessQconnectSink { - pub fn new( - event_tx: broadcast::Sender, - core: Arc>, - qconnect_next_track_id: Arc, - ) -> Self { - Self { event_tx, core, qconnect_next_track_id } - } -} - -#[async_trait] -impl QconnectEventSink for HeadlessQconnectSink { - async fn on_event(&self, event: QconnectAppEvent) { - match &event { - QconnectAppEvent::TransportConnected => { - log::info!("[qbzd/qconnect] Connected to Qobuz servers"); - } - QconnectAppEvent::TransportDisconnected => { - log::warn!("[qbzd/qconnect] Disconnected"); - } - QconnectAppEvent::RendererCommandApplied { command, .. } => { - log::info!("[qbzd/qconnect] Command: {:?}", command); - handle_renderer_command(&self.core, command, &self.qconnect_next_track_id).await; - } - QconnectAppEvent::QueueUpdated(queue_state) => { - log::info!("[qbzd/qconnect] Queue: {} items", queue_state.queue_items.len()); - if let Err(e) = materialize_remote_queue(&self.core, queue_state).await { - log::warn!("[qbzd/qconnect] Failed to materialize queue: {}", e); - } - } - QconnectAppEvent::SessionManagementEvent { message_type, payload } => { - log::info!("[qbzd/qconnect] Session mgmt: {}", message_type); - } - _ => {} - } - } -} - -async fn handle_renderer_command( - core: &qbz_core::QbzCore, - command: &RendererCommand, - qconnect_next_track_id: &Arc, -) { - match command { - RendererCommand::SetState { playing_state, current_position_ms, current_track, next_track } => { - // Track the next_track for orchestrator's auto-advance fallback - if let Some(nt) = next_track { - qconnect_next_track_id.store(nt.track_id, std::sync::atomic::Ordering::Release); - log::info!("[qbzd/qconnect] Stored next_track for auto-advance: {}", nt.track_id); - } - - // Step 1: If a current_track is specified, ensure it's loaded - // (matches desktop's ensure_remote_track_loaded pattern) - if let Some(track) = current_track { - let player = core.player(); - let loaded_track_id = player.state.current_track_id(); - if loaded_track_id != track.track_id { - log::info!( - "[qbzd/qconnect] New track from renderer: {} (was {})", - track.track_id, loaded_track_id - ); - match download_and_play_track(core, track.track_id).await { - Ok(()) => log::info!("[qbzd/qconnect] Track {} loaded", track.track_id), - Err(e) => { - log::error!("[qbzd/qconnect] Failed to load track {}: {}", track.track_id, e); - return; - } - } - } - } - - // Step 2: Apply playing state (only if it actually changes) - if let Some(state) = playing_state { - let player = core.player(); - let already_playing = player.state.is_playing(); - match *state { - PLAYING_STATE_PLAYING if !already_playing => { let _ = core.resume(); } - PLAYING_STATE_PAUSED if already_playing => { let _ = core.pause(); } - PLAYING_STATE_STOPPED => { let _ = core.stop(); } - _ => {} - } - } - - // Step 3: Seek only for intentional seeks (>2s diff), - // but ignore server echoes that want position 0 when we're already playing - if let Some(pos_ms) = current_position_ms { - let target_secs = *pos_ms / 1000; - let current_secs = core.player().state.current_position(); - let is_playing = core.player().state.is_playing(); - // Skip seek-to-zero echoes from server refresh while playing - let is_server_echo = target_secs <= 1 && is_playing && current_secs > 2; - if current_secs.abs_diff(target_secs) > 2 && !is_server_echo { - let _ = core.seek(target_secs); - } - } - } - RendererCommand::SetVolume { volume, .. } => { - if let Some(vol) = volume { - let _ = core.set_volume((*vol as f32 / 100.0).clamp(0.0, 1.0)); - } - } - RendererCommand::SetShuffleMode { shuffle_mode } => { - core.set_shuffle(*shuffle_mode).await; - } - RendererCommand::SetLoopMode { loop_mode } => { - let mode = match *loop_mode { - 1 => qbz_models::RepeatMode::One, - 2 => qbz_models::RepeatMode::All, - _ => qbz_models::RepeatMode::Off, - }; - core.set_repeat_mode(mode).await; - } - RendererCommand::MuteVolume { value } => { - if *value { let _ = core.set_volume(0.0); } - } - _ => {} - } -} - -/// Download a track and feed it to the player. -/// Mirrors desktop's load_remote_track_into_player → download fallback path. -async fn download_and_play_track( - core: &qbz_core::QbzCore, - track_id: u64, -) -> Result<(), String> { - let quality = qbz_models::Quality::HiRes; - let stream_url = core.get_stream_url(track_id, quality).await - .map_err(|e| format!("Stream URL failed for {}: {}", track_id, e))?; - - log::info!( - "[qbzd/qconnect] Downloading track {} ({:.0}kHz/{}bit)", - track_id, stream_url.sampling_rate, stream_url.bit_depth.unwrap_or(0) - ); - - let http = reqwest::Client::builder() - .connect_timeout(std::time::Duration::from_secs(10)) - .build() - .map_err(|e| format!("HTTP client error: {}", e))?; - - let data = http.get(&stream_url.url).send().await - .map_err(|e| format!("Download failed for {}: {}", track_id, e))? - .bytes().await - .map_err(|e| format!("Read failed for {}: {}", track_id, e))? - .to_vec(); - - log::info!("[qbzd/qconnect] Track {} downloaded ({} bytes)", track_id, data.len()); - - core.player() - .play_data(data, track_id) - .map_err(|e| format!("Player error for {}: {}", track_id, e))?; - - Ok(()) -} - -/// Materialize the QConnect remote queue into the local QbzCore queue. -/// This enables the local orchestrator's auto-advance and gapless logic -/// to work when QConnect is the playback source. -/// Mirrors desktop's materialize_remote_queue from qconnect_service.rs. -async fn materialize_remote_queue( - core: &qbz_core::QbzCore, - queue_state: &QConnectQueueState, -) -> Result<(), String> { - if queue_state.queue_items.is_empty() { - log::info!("[qbzd/qconnect] Empty queue, skipping materialization"); - return Ok(()); - } - - // Dedupe track_ids before batch fetch - let mut unique_ids: Vec = Vec::with_capacity(queue_state.queue_items.len()); - for item in &queue_state.queue_items { - if !unique_ids.contains(&item.track_id) { - unique_ids.push(item.track_id); - } - } - - log::info!( - "[qbzd/qconnect] Materializing remote queue: {} items, {} unique tracks", - queue_state.queue_items.len(), - unique_ids.len() - ); - - let fetched_tracks = core - .get_tracks_batch(&unique_ids) - .await - .map_err(|e| format!("batch fetch failed: {}", e))?; - - let mut tracks_by_id: std::collections::HashMap = - std::collections::HashMap::with_capacity(fetched_tracks.len()); - for track in fetched_tracks { - tracks_by_id.insert(track.id, track_to_queue_track(&track)); - } - - // Build the queue in QConnect order - let mut queue_tracks = Vec::with_capacity(queue_state.queue_items.len()); - for item in &queue_state.queue_items { - if let Some(qt) = tracks_by_id.get(&item.track_id) { - queue_tracks.push(qt.clone()); - } else { - log::warn!( - "[qbzd/qconnect] Track {} missing from batch fetch, skipping", - item.track_id - ); - } - } - - if queue_tracks.is_empty() { - return Err("zero playable tracks after materialization".to_string()); - } - - // Determine start index from currently playing track (if any) - let player = core.player(); - let current_track_id = player.state.current_track_id(); - let start_index = if current_track_id != 0 { - queue_state - .queue_items - .iter() - .position(|item| item.track_id == current_track_id) - } else { - Some(0) - }; - - log::info!( - "[qbzd/qconnect] Setting local queue: {} tracks, start_index={:?}", - queue_tracks.len(), - start_index - ); - - core.set_queue(queue_tracks, start_index).await; - Ok(()) -} - -/// Convert qbz_models::Track to QueueTrack. -/// Mirrors desktop's model_track_to_core_queue_track. -fn track_to_queue_track(track: &Track) -> QueueTrack { - let artwork_url = track - .album - .as_ref() - .and_then(|album| album.image.large.clone()); - let artist = track - .performer - .as_ref() - .map(|p| p.name.clone()) - .unwrap_or_else(|| "Unknown Artist".to_string()); - let album_title = track - .album - .as_ref() - .map(|a| a.title.clone()) - .unwrap_or_else(|| "Unknown Album".to_string()); - let album_id = track.album.as_ref().and_then(|a| { - let trimmed = a.id.trim(); - if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } - }); - let artist_id = track.performer.as_ref().map(|p| p.id); - - QueueTrack { - id: track.id, - title: track.title.clone(), - artist, - album: album_title, - duration_secs: track.duration as u64, - artwork_url, - hires: track.hires, - bit_depth: track.maximum_bit_depth, - sample_rate: track.maximum_sampling_rate, - is_local: false, - album_id: album_id.clone(), - artist_id, - streamable: track.streamable, - source: Some("qobuz".to_string()), - parental_warning: track.parental_warning, - source_item_id_hint: album_id, - } -} - -/// Start QConnect — exact replica of desktop's QconnectServiceState::connect(). -pub async fn start_qconnect( - core: &Arc>, - event_tx: broadcast::Sender, - device_name: &str, - qconnect_next_track_id: Arc, -) -> Option> { - // Step 1: Get credentials - let client_arc = core.client(); - let client_guard = client_arc.read().await; - let client = client_guard.as_ref()?; - let (endpoint_url, jwt_qws) = fetch_qws_credentials(client).await?; - drop(client_guard); - - // Step 2: Create transport + sink + app - let transport = Arc::new(NativeWsTransport::new()); - let sink = Arc::new(HeadlessQconnectSink::new(event_tx, core.clone(), qconnect_next_track_id)); - let app = Arc::new(QconnectApp::new(transport, sink)); - - // Step 3: Connect transport - let config = WsTransportConfig { - endpoint_url, - jwt_qws: Some(jwt_qws), - ..Default::default() - }; - if let Err(e) = app.connect(config).await { - log::warn!("[qbzd/qconnect] Connect failed: {}", e); - return None; - } - - // Step 4: Subscribe to transport events BEFORE bootstrap - // (desktop does this at line 1238, before bootstrap at line 1405) - let mut transport_rx = app.subscribe_transport_events(); - - // Step 5: Start event loop (same pattern as desktop lines 1242-1391) - let app_for_loop = app.clone(); - let device_name_owned = device_name.to_string(); - tokio::spawn(async move { - log::info!("[qbzd/qconnect] Event loop started"); - let mut renderer_joined = false; - let mut has_disconnected = false; - - loop { - match transport_rx.recv().await { - Ok(event) => { - // Check for SESSION_STATE to trigger deferred renderer join - // (desktop line 1249-1265) - if !renderer_joined { - if let qconnect_transport_ws::TransportEvent::InboundQueueServerEvent( - ref evt, - ) = event - { - log::info!("[qbzd/qconnect] Queue server event: {}", evt.message_type()); - if evt.message_type() == "MESSAGE_TYPE_SRVR_CTRL_SESSION_STATE" { - if let Some(session_uuid) = - evt.payload.get("session_uuid").and_then(|v| v.as_str()) - { - renderer_joined = true; - deferred_renderer_join(&app_for_loop, session_uuid, &device_name_owned).await; - } else { - log::warn!("[qbzd/qconnect] SESSION_STATE but no session_uuid: {}", evt.payload); - } - } - } - } - - // Handle transport state changes (desktop lines 1267-1374) - match &event { - qconnect_transport_ws::TransportEvent::Connected => { - log::info!("[qbzd/qconnect] WebSocket connected"); - } - qconnect_transport_ws::TransportEvent::Disconnected => { - log::warn!("[qbzd/qconnect] WebSocket disconnected, resetting renderer_joined"); - renderer_joined = false; - has_disconnected = true; - } - qconnect_transport_ws::TransportEvent::Authenticated => { - log::info!("[qbzd/qconnect] Authenticated with JWT"); - } - qconnect_transport_ws::TransportEvent::Subscribed => { - log::info!("[qbzd/qconnect] Subscribed to channels"); - if has_disconnected { - log::info!("[qbzd/qconnect] Re-bootstrapping after reconnect..."); - if let Err(e) = bootstrap_remote_presence(&app_for_loop, &device_name_owned).await { - log::error!("[qbzd/qconnect] Re-bootstrap failed: {}", e); - } - } - } - _ => {} - } - - // Forward to QconnectApp for protocol handling - // (desktop line 1375) - if let Err(e) = app_for_loop.handle_transport_event(event).await { - log::error!("[qbzd/qconnect] Transport event error: {}", e); - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - log::warn!("[qbzd/qconnect] Transport lagged {} events", n); - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - log::info!("[qbzd/qconnect] Transport channel closed"); - break; - } - } - } - log::info!("[qbzd/qconnect] Event loop ended"); - }); - - // Step 6: Bootstrap AFTER event loop starts - // (desktop line 1405) - let device_name_for_bootstrap = device_name.to_string(); - if let Err(e) = bootstrap_remote_presence(&app, &device_name_for_bootstrap).await { - log::error!("[qbzd/qconnect] Bootstrap failed: {}", e); - // Desktop disconnects on bootstrap failure (line 1406) - let _ = app.disconnect().await; - return None; - } - - // Step 7: Spawn periodic state reporter so iOS knows the renderer's state - spawn_state_reporter(app.clone(), core.clone()); - - log::info!("[qbzd/qconnect] Started as '{}'", device_name); - Some(app) -} - -/// Periodic renderer state reporter — keeps iOS in sync with daemon playback. -/// Reports every 2 seconds, and immediately on track end / state change. -fn spawn_state_reporter(app: Arc, core: Arc>) { - tokio::spawn(async move { - let mut last_playing_state: i32 = 0; - let mut last_track_id: u64 = 0; - loop { - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - - let player = core.player(); - let state = &player.state; - let track_id = state.current_track_id(); - let is_playing = state.is_playing(); - let position = state.current_position(); - let duration = state.duration(); - - let playing_state = if is_playing { - PLAYING_STATE_PLAYING - } else if track_id != 0 { - PLAYING_STATE_PAUSED - } else { - PLAYING_STATE_STOPPED - }; - - let state_changed = playing_state != last_playing_state || track_id != last_track_id; - last_playing_state = playing_state; - last_track_id = track_id; - - let queue_version = app.queue_state_snapshot().await.version; - let payload = serde_json::json!({ - "playing_state": playing_state, - "buffer_state": BUFFER_STATE_OK, - "current_position": position * 1000, // ms - "duration": duration * 1000, // ms - "queue_version": { - "major": queue_version.major, - "minor": queue_version.minor - } - }); - - let report = RendererReport::new( - RendererReportType::RndrSrvrStateUpdated, - Uuid::new_v4().to_string(), - queue_version, - payload, - ); - if let Err(e) = app.send_renderer_report_command(report).await { - log::debug!("[qbzd/qconnect] State report failed: {}", e); - } else if state_changed { - log::info!( - "[qbzd/qconnect] State report sent: playing_state={} track={} pos={}s", - playing_state, track_id, position - ); - } - } - }); -} - -/// Bootstrap: controller JoinSession + AskForQueueState. -/// Exact replica of desktop's bootstrap_remote_presence (lines 3801-3846). -async fn bootstrap_remote_presence(app: &Arc, device_name: &str) -> Result<(), String> { - let device_info = build_device_info(device_name); - - // 1. Controller JoinSession (works without session_uuid) - let join_payload = serde_json::json!({ - "session_uuid": null, - "device_info": device_info, - }); - let join_cmd = app.build_queue_command(QueueCommandType::CtrlSrvrJoinSession, join_payload).await; - let join_uuid = app.send_queue_command(join_cmd).await - .map_err(|e| format!("join_session failed: {}", e))?; - - // Clear pending (desktop line 3825) - { - let handle = app.state_handle(); - let mut state = handle.lock().await; - state.pending.clear(); - } - - // 2. Ask for current queue state from server - let ask_cmd = app.build_queue_command(QueueCommandType::CtrlSrvrAskForQueueState, serde_json::json!({})).await; - let ask_uuid = app.send_queue_command(ask_cmd).await - .map_err(|e| format!("ask_queue_state failed: {}", e))?; - - { - let handle = app.state_handle(); - let mut state = handle.lock().await; - state.pending.clear(); - } - - log::info!("[qbzd/qconnect] Bootstrap: controller joined, queue requested. Renderer join deferred until SESSION_STATE."); - Ok(()) -} - -/// Deferred renderer join — called when SESSION_STATE arrives with session_uuid. -/// Exact replica of desktop's deferred_renderer_join (lines 3849-3948). -async fn deferred_renderer_join(app: &Arc, session_uuid: &str, device_name: &str) { - let device_info = build_device_info(device_name); - let queue_version = app.queue_state_snapshot().await.version; - - log::info!("[qbzd/qconnect] Renderer join with session_uuid={}", session_uuid); - - // 1. Renderer JoinSession with session_uuid - let join_payload = serde_json::json!({ - "session_uuid": session_uuid, - "device_info": device_info, - "is_active": true, - "reason": JOIN_SESSION_REASON_CONTROLLER_REQUEST, - "initial_state": { - "playing_state": PLAYING_STATE_STOPPED, - "buffer_state": BUFFER_STATE_OK, - "current_position": 0, - "duration": 0, - "queue_version": { - "major": queue_version.major, - "minor": queue_version.minor - } - } - }); - let join_report = RendererReport::new( - RendererReportType::RndrSrvrJoinSession, - Uuid::new_v4().to_string(), - queue_version, - join_payload, - ); - if let Err(e) = app.send_renderer_report_command(join_report).await { - log::error!("[qbzd/qconnect] Renderer join failed: {}", e); - return; - } - - // 2. State report - let state_payload = serde_json::json!({ - "playing_state": PLAYING_STATE_STOPPED, - "buffer_state": BUFFER_STATE_OK, - "current_position": 0, - "duration": 0, - "queue_version": { "major": queue_version.major, "minor": queue_version.minor } - }); - let state_report = RendererReport::new( - RendererReportType::RndrSrvrStateUpdated, - Uuid::new_v4().to_string(), - queue_version, - state_payload, - ); - let _ = app.send_renderer_report_command(state_report).await; - - // 3. Volume report - let vol_report = RendererReport::new( - RendererReportType::RndrSrvrVolumeChanged, - Uuid::new_v4().to_string(), - queue_version, - serde_json::json!({ "volume": 100 }), - ); - let _ = app.send_renderer_report_command(vol_report).await; - - // 4. Max quality report - let quality_report = RendererReport::new( - RendererReportType::RndrSrvrMaxAudioQualityChanged, - Uuid::new_v4().to_string(), - queue_version, - serde_json::json!({ "max_audio_quality": AUDIO_QUALITY_HIRES_LEVEL2 }), - ); - let _ = app.send_renderer_report_command(quality_report).await; - - // 5. Refresh session state (desktop line 3936-3947) - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - let refresh_cmd = app.build_queue_command(QueueCommandType::CtrlSrvrAskForQueueState, serde_json::json!({})).await; - let _ = app.send_queue_command(refresh_cmd).await; - - log::info!("[qbzd/qconnect] Renderer join complete — visible to other devices"); -} - -/// Build device_info matching desktop's QconnectDeviceInfoPayload exactly. -/// device_uuid is persistent per instance (generated once, reused). -fn build_device_info(device_name: &str) -> serde_json::Value { - use std::sync::OnceLock; - static DEVICE_UUID: OnceLock = OnceLock::new(); - let uuid = DEVICE_UUID.get_or_init(|| { - // Try to load persisted UUID, or generate a new one - let uuid_path = dirs::data_dir() - .unwrap_or_default() - .join("qbz") - .join(".qconnect-device-uuid"); - if let Ok(existing) = std::fs::read_to_string(&uuid_path) { - let trimmed = existing.trim().to_string(); - if !trimmed.is_empty() { - return trimmed; - } - } - let new_uuid = Uuid::new_v4().to_string(); - let _ = std::fs::create_dir_all(uuid_path.parent().unwrap_or(&uuid_path)); - let _ = std::fs::write(&uuid_path, &new_uuid); - new_uuid - }); - - serde_json::json!({ - "device_uuid": uuid, - "friendly_name": device_name, - "brand": "QBZ", - "model": "QBZ Daemon", - "serial_number": null, - "device_type": 5, - "capabilities": { - "min_audio_quality": 1, - "max_audio_quality": 4, - "volume_remote_control": 2 - }, - "software_version": format!("qbzd/{}", env!("CARGO_PKG_VERSION")), - }) -} - -async fn fetch_qws_credentials(client: &qbz_qobuz::QobuzClient) -> Option<(String, String)> { - let app_id = client.app_id().await.ok()?; - let auth_token = client.auth_token().await.ok()?; - - let http = reqwest::Client::new(); - let resp = http - .post("https://www.qobuz.com/api.json/0.2/qws/createToken") - .header("X-App-Id", &app_id) - .header("X-User-Auth-Token", &auth_token) - .form(&[ - ("jwt", "jwt_qws"), - ("user_auth_token_needed", "true"), - ("strong_auth_needed", "true"), - ]) - .send() - .await - .ok()?; - - if !resp.status().is_success() { - log::warn!("[qbzd/qconnect] qws/createToken: {}", resp.status()); - return None; - } - - let body: serde_json::Value = resp.json().await.ok()?; - let jwt_qws = body.get("jwt_qws")?; - let endpoint = jwt_qws.get("endpoint")?.as_str()?.to_string(); - let jwt = jwt_qws.get("jwt")?.as_str()?.to_string(); - - Some((endpoint, jwt)) -} diff --git a/crates/qbzd/src/resources.rs b/crates/qbzd/src/resources.rs deleted file mode 100644 index bb98612b1..000000000 --- a/crates/qbzd/src/resources.rs +++ /dev/null @@ -1,37 +0,0 @@ -use crate::config::DaemonConfig; - -/// Auto-detect system RAM and adjust cache configuration. -/// Only runs if cache.auto.enabled is true. -pub fn auto_detect_cache_config(config: &mut DaemonConfig) { - let sys = sysinfo::System::new_with_specifics( - sysinfo::RefreshKind::nothing().with_memory(sysinfo::MemoryRefreshKind::everything()), - ); - - let total_ram_mb = sys.total_memory() / (1024 * 1024); - - log::info!("[qbzd] System RAM: {} MB", total_ram_mb); - - if config.cache.auto.enabled && config.cache.memory_mb == 0 { - // Conservative: 1/8 of RAM, capped at 400 MB, minimum 50 MB - let auto_memory = (total_ram_mb / 8).min(400).max(50) as usize; - config.cache.memory_mb = auto_memory; - log::info!("[qbzd] Auto-detected L1 cache: {} MB", auto_memory); - } - - // Adjust prefetch for low-memory devices - if total_ram_mb <= 1024 && config.cache.prefetch_count > 2 { - config.cache.prefetch_count = 2; - config.cache.prefetch_concurrent = 1; - config.cache.cmaf_concurrent_segments = 2; - log::info!("[qbzd] Low RAM detected, reduced prefetch to 2 tracks / 1 concurrent"); - } - - log::info!( - "[qbzd] Cache config: L1={} MB, L2={} MB, prefetch={} tracks, concurrent={}, cmaf_segments={}", - config.cache.memory_mb, - config.cache.disk_mb, - config.cache.prefetch_count, - config.cache.prefetch_concurrent, - config.cache.cmaf_concurrent_segments, - ); -} diff --git a/crates/qbzd/src/session.rs b/crates/qbzd/src/session.rs deleted file mode 100644 index aeaf4503d..000000000 --- a/crates/qbzd/src/session.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! Headless session lifecycle — port of session_lifecycle.rs without Tauri. -//! -//! Initializes per-user stores after login. The stores themselves are -//! Tauri-free (SQLite); only the state lookup mechanism changes. - -use std::path::PathBuf; - -use crate::adapter::{DaemonEvent, RuntimeEvent}; - -/// Per-user state, populated after successful login. -pub struct UserSession { - pub user_id: u64, - pub data_dir: PathBuf, - pub cache_dir: PathBuf, -} - -/// Activate a user session (headless equivalent of session_lifecycle::activate_session). -/// -/// Creates per-user directories, initializes stores, syncs audio settings to player. -pub async fn activate_session( - user_id: u64, - core: &qbz_core::QbzCore, - event_tx: &tokio::sync::broadcast::Sender, -) -> Result { - // Demoted from info to debug to keep user_id out of default-level logs - // (CodeQL rust/cleartext-logging). Set RUST_LOG=debug to see. - log::debug!("[qbzd/session] Activating session for user {}", user_id); - - // Resolve per-user directories (same layout as desktop app) - let global_data = dirs::data_dir() - .ok_or("Could not determine data directory")? - .join("qbz"); - let global_cache = dirs::cache_dir() - .ok_or("Could not determine cache directory")? - .join("qbz"); - - let data_dir = global_data.join("users").join(user_id.to_string()); - let cache_dir = global_cache.join("users").join(user_id.to_string()); - - std::fs::create_dir_all(&data_dir) - .map_err(|e| format!("Failed to create user data dir: {}", e))?; - std::fs::create_dir_all(&cache_dir) - .map_err(|e| format!("Failed to create user cache dir: {}", e))?; - - log::info!("[qbzd/session] User data dir: {}", data_dir.display()); - log::info!("[qbzd/session] User cache dir: {}", cache_dir.display()); - - // Note: audio settings were already loaded at DaemonCore init from the - // global store. Per-user settings sync will be added when qbz-player - // exposes reload_settings() in the crate (currently on legacy player only). - - // Persist last user_id for session restore on next launch - let marker_path = global_data.join("last_user_id"); - let _ = std::fs::write(&marker_path, user_id.to_string()); - log::info!("[qbzd/session] Saved last_user_id marker"); - - // Emit session activated event - let _ = event_tx.send(DaemonEvent::Runtime(RuntimeEvent::Ready { user_id })); - - log::info!("[qbzd/session] Session activated"); - - Ok(UserSession { - user_id, - data_dir, - cache_dir, - }) -} - -/// Load the last user_id from the marker file (for auto-login session restore). -pub fn load_last_user_id() -> Option { - let path = dirs::data_dir()?.join("qbz").join("last_user_id"); - std::fs::read_to_string(&path) - .ok()? - .trim() - .parse() - .ok() -} diff --git a/crates/qbzd/src/wizard/audio.rs b/crates/qbzd/src/wizard/audio.rs deleted file mode 100644 index 9efcc317d..000000000 --- a/crates/qbzd/src/wizard/audio.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Audio backend & device wizard section. - -use qbz_audio::{AudioBackendType, BackendManager}; - -pub fn run_audio_wizard() -> Result<(), String> { - println!("\n=== Audio Backend & Device ===\n"); - - // List available backends - let backends = BackendManager::available_backends(); - println!("Available backends:"); - for (i, bt) in backends.iter().enumerate() { - let backend = BackendManager::create_backend(*bt); - let (available, desc) = match backend { - Ok(b) => (b.is_available(), b.description().to_string()), - Err(_) => (false, "Not available".to_string()), - }; - let name = match bt { - AudioBackendType::PipeWire => "PipeWire", - AudioBackendType::Alsa => "ALSA Direct", - AudioBackendType::Pulse => "PulseAudio", - AudioBackendType::SystemDefault => "System Default", - }; - let status = if available { "available" } else { "not found" }; - println!(" {}. {} - {} ({})", i + 1, name, desc, status); - } - - println!("\nSelect backend (1-{}):", backends.len()); - let mut input = String::new(); - std::io::stdin().read_line(&mut input).map_err(|e| e.to_string())?; - let choice: usize = input.trim().parse().unwrap_or(1); - - if choice == 0 || choice > backends.len() { - return Err("Invalid choice".to_string()); - } - - let selected_backend = backends[choice - 1]; - println!("\nSelected: {:?}", selected_backend); - - // List devices for selected backend - let backend = BackendManager::create_backend(selected_backend) - .map_err(|e| format!("Backend error: {}", e))?; - let devices = backend.enumerate_devices().map_err(|e| format!("Device error: {}", e))?; - - if devices.is_empty() { - println!("No devices found for this backend."); - } else { - println!("\nAvailable devices:"); - for (i, device) in devices.iter().enumerate() { - let default_mark = if device.is_default { " (default)" } else { "" }; - println!(" {}. {}{}", i + 1, device.name, default_mark); - } - - println!("\nSelect device (1-{}, or Enter for default):", devices.len()); - let mut dev_input = String::new(); - std::io::stdin().read_line(&mut dev_input).map_err(|e| e.to_string())?; - let dev_choice: usize = dev_input.trim().parse().unwrap_or(0); - - if dev_choice > 0 && dev_choice <= devices.len() { - println!("Selected device: {}", devices[dev_choice - 1].name); - } else { - println!("Using default device"); - } - } - - // Save to audio settings - if let Ok(store) = qbz_audio::settings::AudioSettingsStore::new() { - let _ = store.set_backend_type(Some(selected_backend)); - println!("\nAudio settings saved."); - } else { - println!("\nNote: Could not save settings (no active session). Settings will apply when qbzd starts."); - } - - println!(); - Ok(()) -} diff --git a/crates/qbzd/src/wizard/cache.rs b/crates/qbzd/src/wizard/cache.rs deleted file mode 100644 index 58eed2554..000000000 --- a/crates/qbzd/src/wizard/cache.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! Cache & Resources wizard section. - -use crate::config::{DaemonConfig, save_default_config}; - -pub fn run_cache_wizard() -> Result<(), String> { - println!("\n=== Cache & Resources ===\n"); - - let sys = sysinfo::System::new_with_specifics( - sysinfo::RefreshKind::nothing().with_memory(sysinfo::MemoryRefreshKind::everything()), - ); - let total_ram_mb = sys.total_memory() / (1024 * 1024); - let auto_cache = (total_ram_mb / 8).min(400).max(50); - - println!("System RAM: {} MB", total_ram_mb); - println!("Recommended L1 cache: {} MB\n", auto_cache); - - // Memory cache - println!("Memory cache (L1) in MB [0 = auto-detect, current recommended: {}]:", auto_cache); - let memory_mb = read_number_or_default(0); - - // Disk cache - println!("Disk cache (L2) in MB [default: 400]:"); - let disk_mb = read_number_or_default(400); - - // Prefetch - println!("Prefetch tracks ahead [default: 2]:"); - let prefetch_count = read_number_or_default(2); - - println!("Concurrent prefetch downloads [default: 1]:"); - let prefetch_concurrent = read_number_or_default(1); - - println!("CMAF parallel segments [default: 2]:"); - let cmaf_concurrent = read_number_or_default(2); - - // Load existing config and update - let mut config = DaemonConfig::load(None); - config.cache.memory_mb = memory_mb; - config.cache.disk_mb = disk_mb; - config.cache.prefetch_count = prefetch_count; - config.cache.prefetch_concurrent = prefetch_concurrent; - config.cache.cmaf_concurrent_segments = cmaf_concurrent; - config.cache.auto.enabled = memory_mb == 0; - - save_default_config(&config)?; - - println!("\nCache settings saved."); - println!(" L1 memory: {} MB {}", memory_mb, if memory_mb == 0 { "(auto-detect)" } else { "" }); - println!(" L2 disk: {} MB", disk_mb); - println!(" Prefetch: {} tracks, {} concurrent", prefetch_count, prefetch_concurrent); - println!(" CMAF segments: {} parallel\n", cmaf_concurrent); - Ok(()) -} - -fn read_number_or_default(default: usize) -> usize { - let mut input = String::new(); - std::io::stdin().read_line(&mut input).unwrap_or_default(); - let trimmed = input.trim(); - if trimmed.is_empty() { - default - } else { - trimmed.parse().unwrap_or(default) - } -} diff --git a/crates/qbzd/src/wizard/integrations.rs b/crates/qbzd/src/wizard/integrations.rs deleted file mode 100644 index 04c9b7c35..000000000 --- a/crates/qbzd/src/wizard/integrations.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! Integrations wizard section. - -pub fn run_integrations_wizard() -> Result<(), String> { - println!("\n=== Integrations ===\n"); - - // ListenBrainz - println!("ListenBrainz token (leave empty to skip):"); - let mut lb_token = String::new(); - std::io::stdin().read_line(&mut lb_token).unwrap_or_default(); - let lb_token = lb_token.trim(); - - if !lb_token.is_empty() { - // Save to the V2 cache database - let data_dir = dirs::data_dir() - .ok_or("Cannot determine data directory")? - .join("qbz"); - - // Find the user's data dir (from last_user_id) - let user_dir = find_user_data_dir(&data_dir)?; - let cache_dir = user_dir.join("cache"); - std::fs::create_dir_all(&cache_dir).ok(); - let db_path = cache_dir.join("listenbrainz_v2.db"); - - let conn = rusqlite::Connection::open(&db_path) - .map_err(|e| format!("DB error: {}", e))?; - conn.execute_batch( - "PRAGMA journal_mode=WAL; \ - CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)" - ).map_err(|e| format!("Schema error: {}", e))?; - conn.execute( - "INSERT OR REPLACE INTO config (key, value) VALUES ('token', ?1)", - rusqlite::params![lb_token], - ).map_err(|e| format!("Save error: {}", e))?; - - println!("ListenBrainz token saved.\n"); - } else { - println!("ListenBrainz skipped.\n"); - } - - // MusicBrainz - println!("Enable MusicBrainz enrichment? [Y/n]:"); - let mut mb_input = String::new(); - std::io::stdin().read_line(&mut mb_input).unwrap_or_default(); - let mb_enabled = !mb_input.trim().eq_ignore_ascii_case("n"); - println!("MusicBrainz: {}\n", if mb_enabled { "enabled" } else { "disabled" }); - - println!("Integrations configured.\n"); - Ok(()) -} - -fn find_user_data_dir(global_data: &std::path::Path) -> Result { - // Read last_user_id - let marker = global_data.join("last_user_id"); - if marker.exists() { - if let Ok(uid) = std::fs::read_to_string(&marker) { - let uid = uid.trim(); - if !uid.is_empty() { - let user_dir = global_data.join("users").join(uid); - if user_dir.exists() { - return Ok(user_dir); - } - } - } - } - Err("No user session found. Run 'qbzd login' first.".to_string()) -} diff --git a/crates/qbzd/src/wizard/mod.rs b/crates/qbzd/src/wizard/mod.rs deleted file mode 100644 index cc991d8a2..000000000 --- a/crates/qbzd/src/wizard/mod.rs +++ /dev/null @@ -1,139 +0,0 @@ -//! Interactive TUI setup wizard for qbzd. -//! -//! Configures audio, playback, cache, integrations without -//! needing the desktop app. Reads/writes config files and -//! databases directly (runs without the daemon). - -mod audio; -mod cache; -mod integrations; -mod qconnect; - -use crossterm::{ - event::{self, Event, KeyCode}, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, - ExecutableCommand, -}; -use ratatui::{prelude::*, widgets::*}; -use std::io::stdout; - -/// Available wizard sections -const SECTIONS: &[(&str, &str)] = &[ - ("audio", "Audio Backend & Device"), - ("cache", "Cache & Resources"), - ("integrations", "Integrations (ListenBrainz)"), - ("qconnect", "QConnect"), -]; - -/// Run the full wizard or a specific section. -pub fn run(section: Option<&str>) -> Result<(), String> { - if let Some(s) = section { - return run_section(s); - } - - // Full wizard: show menu, let user pick sections - enable_raw_mode().map_err(|e| e.to_string())?; - stdout().execute(EnterAlternateScreen).map_err(|e| e.to_string())?; - - let backend = CrosstermBackend::new(stdout()); - let mut terminal = Terminal::new(backend).map_err(|e| e.to_string())?; - - let mut selected = 0usize; - let result = loop { - terminal - .draw(|frame| draw_menu(frame, selected)) - .map_err(|e| e.to_string())?; - - if let Ok(Event::Key(key)) = event::read() { - match key.code { - KeyCode::Char('q') | KeyCode::Esc => break Ok(()), - KeyCode::Up | KeyCode::Char('k') => { - if selected > 0 { - selected -= 1; - } - } - KeyCode::Down | KeyCode::Char('j') => { - if selected < SECTIONS.len() - 1 { - selected += 1; - } - } - KeyCode::Enter => { - // Temporarily leave TUI to run section - disable_raw_mode().ok(); - stdout().execute(LeaveAlternateScreen).ok(); - - let section_key = SECTIONS[selected].0; - if let Err(e) = run_section(section_key) { - eprintln!("Section '{}' error: {}", section_key, e); - } - - // Return to TUI - enable_raw_mode().ok(); - stdout().execute(EnterAlternateScreen).ok(); - terminal = Terminal::new(CrosstermBackend::new(stdout())) - .map_err(|e| e.to_string())?; - } - _ => {} - } - } - }; - - disable_raw_mode().ok(); - stdout().execute(LeaveAlternateScreen).ok(); - result -} - -fn draw_menu(frame: &mut Frame, selected: usize) { - let area = frame.area(); - - let items: Vec = SECTIONS - .iter() - .enumerate() - .map(|(i, (_, label))| { - let style = if i == selected { - Style::default() - .fg(Color::Black) - .bg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - let prefix = if i == selected { "> " } else { " " }; - ListItem::new(format!("{}{}", prefix, label)).style(style) - }) - .collect(); - - let list = List::new(items) - .block( - Block::default() - .title(" QBZ Daemon Setup ") - .title_alignment(Alignment::Center) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)) - .padding(Padding::new(2, 2, 1, 1)), - ) - .highlight_style(Style::default().bg(Color::Cyan).fg(Color::Black)); - - let help = Paragraph::new("↑↓ Navigate Enter Select q Quit") - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Center); - - let layout = Layout::vertical([ - Constraint::Min(SECTIONS.len() as u16 + 4), - Constraint::Length(1), - ]) - .split(area); - - frame.render_widget(list, layout[0]); - frame.render_widget(help, layout[1]); -} - -fn run_section(section: &str) -> Result<(), String> { - match section { - "audio" => audio::run_audio_wizard(), - "cache" => cache::run_cache_wizard(), - "integrations" => integrations::run_integrations_wizard(), - "qconnect" => qconnect::run_qconnect_wizard(), - _ => Err(format!("Unknown section: {}", section)), - } -} diff --git a/crates/qbzd/src/wizard/qconnect.rs b/crates/qbzd/src/wizard/qconnect.rs deleted file mode 100644 index f382ecb90..000000000 --- a/crates/qbzd/src/wizard/qconnect.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! QConnect wizard section. - -use crate::config::{DaemonConfig, save_default_config}; - -pub fn run_qconnect_wizard() -> Result<(), String> { - println!("\n=== QConnect (Qobuz Connect) ===\n"); - - let mut config = DaemonConfig::load(None); - - // Enable/disable - println!("Enable QConnect? [Y/n] (current: {}):", if config.qconnect.enabled { "enabled" } else { "disabled" }); - let mut input = String::new(); - std::io::stdin().read_line(&mut input).unwrap_or_default(); - let trimmed = input.trim(); - if !trimmed.is_empty() { - config.qconnect.enabled = !trimmed.eq_ignore_ascii_case("n"); - } - - if config.qconnect.enabled { - // Device name - let current_name = if config.qconnect.device_name.is_empty() { - hostname::get() - .ok() - .and_then(|h| h.into_string().ok()) - .unwrap_or_else(|| "qbzd".to_string()) - } else { - config.qconnect.device_name.clone() - }; - - println!("Device name [current: {}]:", current_name); - let mut name_input = String::new(); - std::io::stdin().read_line(&mut name_input).unwrap_or_default(); - let name_trimmed = name_input.trim(); - if !name_trimmed.is_empty() { - config.qconnect.device_name = name_trimmed.to_string(); - } - } - - save_default_config(&config)?; - - println!("\nQConnect settings saved."); - println!(" Enabled: {}", config.qconnect.enabled); - if config.qconnect.enabled { - let name = if config.qconnect.device_name.is_empty() { "hostname" } else { &config.qconnect.device_name }; - println!(" Device name: {}", name); - } - println!(); - Ok(()) -} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a2088b557..812acc244 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2995,16 +2995,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "if-addrs" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "image" version = "0.25.10" @@ -3553,27 +3543,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fe7c11a1eb3cfbfcf702d1601c1f5f4c102cdc8665b8a557783ef634741676e" dependencies = [ "flume", - "if-addrs 0.13.4", + "if-addrs", "log", "polling 2.8.0", "socket2 0.5.10", ] -[[package]] -name = "mdns-sd" -version = "0.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2bb8ce26633738d98ffcef71ec58bff967c6675be50229823c2835f6316e67e" -dependencies = [ - "fastrand", - "flume", - "if-addrs 0.15.0", - "log", - "mio", - "socket-pktinfo", - "socket2 0.6.3", -] - [[package]] name = "memchr" version = "2.8.0" @@ -3645,7 +3620,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", - "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -5061,7 +5035,7 @@ version = "0.1.0" dependencies = [ "futures-util", "log", - "mdns-sd 0.11.5", + "mdns-sd", "rupnp", "rust_cast", "serde", @@ -5190,7 +5164,6 @@ dependencies = [ "lofty", "log", "md-5", - "mdns-sd 0.19.1", "mupdf", "notify-rust", "open", @@ -5872,7 +5845,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", - "if-addrs 0.13.4", + "if-addrs", "roxmltree", "ssdp-client", "tokio", @@ -6505,17 +6478,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socket-pktinfo" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f" -dependencies = [ - "libc", - "socket2 0.6.3", - "windows-sys 0.60.2", -] - [[package]] name = "socket2" version = "0.5.10" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bb99eca19..858b44547 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,7 +38,6 @@ qbz-library = { path = "../crates/qbz-library" } qbz-cast = { path = "../crates/qbz-cast" } qbz-cmaf = { path = "../crates/qbz-cmaf" } qbz-secrets = { path = "../crates/qbz-secrets" } -mdns-sd = "0.19" qconnect-app = { path = "../crates/qconnect-app" } qconnect-core = { path = "../crates/qconnect-core" } qconnect-transport-ws = { path = "../crates/qconnect-transport-ws" } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e1be87def..51ea7a84f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -77,7 +77,6 @@ pub mod tray_linux_ksni; pub mod updates; pub mod user_data; pub mod visualizer; -pub mod qbzd_discovery; use rustls::crypto::{aws_lc_rs, CryptoProvider}; use std::sync::{Arc, Mutex as StdMutex}; @@ -964,7 +963,6 @@ pub fn run(qconnect_cli_override: Option) { .manage(app_state) .manage(core_bridge::CoreBridgeState::new()) .manage(commands_v2::OAuthCancelState::new()) - .manage(qbzd_discovery::QbzdDiscoveryState::new()) .manage(runtime::RuntimeManagerState::new()) .manage(qconnect::QconnectServiceState::new()) .manage(qconnect::startup::QconnectCliOverride(qconnect_cli_override)) @@ -1601,9 +1599,6 @@ pub fn run(qconnect_cli_override: Option) { commands_v2::v2_start_system_browser_oauth, commands_v2::v2_cancel_oauth_login, commands_v2::v2_cancel_system_browser_oauth, - qbzd_discovery::v2_qbzd_start_discovery, - qbzd_discovery::v2_qbzd_stop_discovery, - qbzd_discovery::v2_qbzd_get_devices, commands_v2::v2_get_user_info, commands_v2::v2_save_credentials, commands_v2::v2_clear_saved_credentials, diff --git a/src-tauri/src/qbzd_discovery.rs b/src-tauri/src/qbzd_discovery.rs deleted file mode 100644 index 6fcc2486d..000000000 --- a/src-tauri/src/qbzd_discovery.rs +++ /dev/null @@ -1,129 +0,0 @@ -//! mDNS discovery for qbzd daemon instances on the LAN. -//! -//! Uses the mdns-sd crate. Discovers `_qbz._tcp.local` services. - -use serde::Serialize; -use std::sync::Arc; -use tokio::sync::Mutex; - -const QBZD_SERVICE_TYPE: &str = "_qbz._tcp.local."; - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct QbzdDevice { - pub name: String, - pub host: String, - pub port: u16, - pub base_url: String, -} - -pub struct QbzdDiscoveryState { - devices: Arc>>, - daemon: Arc>>, -} - -impl QbzdDiscoveryState { - pub fn new() -> Self { - Self { - devices: Arc::new(Mutex::new(Vec::new())), - daemon: Arc::new(Mutex::new(None)), - } - } -} - -#[tauri::command] -pub async fn v2_qbzd_start_discovery( - state: tauri::State<'_, QbzdDiscoveryState>, -) -> Result<(), String> { - let mut daemon_guard = state.daemon.lock().await; - - // Stop existing discovery if running - if let Some(d) = daemon_guard.take() { - let _ = d.shutdown(); - } - - state.devices.lock().await.clear(); - - let mdns = mdns_sd::ServiceDaemon::new() - .map_err(|e| format!("Failed to create mDNS daemon: {}", e))?; - - let receiver = mdns - .browse(QBZD_SERVICE_TYPE) - .map_err(|e| format!("Failed to browse qbzd services: {}", e))?; - - let devices = state.devices.clone(); - - // Spawn background task to collect discovered devices - tokio::spawn(async move { - while let Ok(event) = receiver.recv_async().await { - match event { - mdns_sd::ServiceEvent::ServiceResolved(info) => { - let name = info - .get_fullname() - .split('.') - .next() - .unwrap_or("qbzd") - .to_string(); - let host = info - .get_addresses() - .iter() - .next() - .map(|a| a.to_string()) - .unwrap_or_else(|| info.get_hostname().trim_end_matches('.').to_string()); - let port = info.get_port(); - let base_url = format!("http://{}:{}", host, port); - - log::info!("[qbzd-discovery] Found: {} at {}", name, base_url); - - let device = QbzdDevice { - name, - host, - port, - base_url, - }; - - let mut devs = devices.lock().await; - // Deduplicate by base_url - if !devs.iter().any(|d| d.base_url == device.base_url) { - devs.push(device); - } - } - mdns_sd::ServiceEvent::ServiceRemoved(_, fullname) => { - log::info!("[qbzd-discovery] Removed: {}", fullname); - let name = fullname - .split('.') - .next() - .unwrap_or("") - .to_string(); - let mut devs = devices.lock().await; - devs.retain(|d| d.name != name); - } - _ => {} - } - } - }); - - *daemon_guard = Some(mdns); - log::info!("[qbzd-discovery] Discovery started"); - Ok(()) -} - -#[tauri::command] -pub async fn v2_qbzd_stop_discovery( - state: tauri::State<'_, QbzdDiscoveryState>, -) -> Result<(), String> { - let mut daemon_guard = state.daemon.lock().await; - if let Some(d) = daemon_guard.take() { - let _ = d.shutdown(); - log::info!("[qbzd-discovery] Discovery stopped"); - } - Ok(()) -} - -#[tauri::command] -pub async fn v2_qbzd_get_devices( - state: tauri::State<'_, QbzdDiscoveryState>, -) -> Result, String> { - let devs = state.devices.lock().await; - Ok(devs.clone()) -} diff --git a/src/lib/components/CastPicker.svelte b/src/lib/components/CastPicker.svelte index 961bd37f2..2221172dc 100644 --- a/src/lib/components/CastPicker.svelte +++ b/src/lib/components/CastPicker.svelte @@ -11,49 +11,6 @@ type CastProtocol, type CastDevice } from '$lib/stores/castStore'; - import { - connectToRemote, - disconnectFromRemote, - isRemoteMode, - playbackTarget, - } from '$lib/stores/playbackTargetStore'; - import { connectRemoteEvents, disconnectRemoteEvents } from '$lib/services/remoteEvents'; - import { pingRemote } from '$lib/services/remoteApi'; - - interface QbzdDevice { - name: string; - host: string; - port: number; - baseUrl: string; - } - - const QBZD_STORAGE_KEY = 'qbzd-saved-daemons'; - - interface SavedDaemon { - name: string; - baseUrl: string; - lastConnected: number; - } - - function loadSavedDaemons(): SavedDaemon[] { - try { - const raw = localStorage.getItem(QBZD_STORAGE_KEY); - return raw ? JSON.parse(raw) : []; - } catch { return []; } - } - - function saveDaemon(name: string, baseUrl: string) { - const saved = loadSavedDaemons().filter(d => d.baseUrl !== baseUrl); - saved.unshift({ name, baseUrl, lastConnected: Date.now() }); - // Keep max 5 recent - localStorage.setItem(QBZD_STORAGE_KEY, JSON.stringify(saved.slice(0, 5))); - } - - function removeSavedDaemon(baseUrl: string) { - const saved = loadSavedDaemons().filter(d => d.baseUrl !== baseUrl); - localStorage.setItem(QBZD_STORAGE_KEY, JSON.stringify(saved)); - savedDaemons = loadSavedDaemons(); - } interface Props { isOpen: boolean; @@ -62,17 +19,9 @@ let { isOpen, onClose }: Props = $props(); - // Feature flag: qbzd (QBZ Daemon) is not ready for release yet. Keep the - // code paths but hide the tab and skip the backend invocations so users - // don't see a broken option. Flip to `true` when qbzd ships. - const QBZD_ENABLED = false; - - let activeProtocol = $state(QBZD_ENABLED ? 'qbzd' : 'chromecast'); - let manualAddress = $state(''); - let savedDaemons = $state(loadSavedDaemons()); + let activeProtocol = $state('chromecast'); let chromecastDevices = $state([]); let dlnaDevices = $state([]); - let qbzdDevices = $state([]); let loading = $state(false); let error = $state(null); let discoveryStarted = $state(false); @@ -92,7 +41,6 @@ switch (activeProtocol) { case 'chromecast': return chromecastDevices; case 'dlna': return dlnaDevices; - case 'qbzd': return []; // qbzd uses its own list } }); @@ -136,7 +84,6 @@ await Promise.allSettled([ invoke('v2_cast_start_discovery'), invoke('v2_dlna_start_discovery'), - ...(QBZD_ENABLED ? [invoke('v2_qbzd_start_discovery')] : []), ]); // Poll for devices pollDevices(); @@ -156,7 +103,6 @@ await Promise.allSettled([ invoke('v2_cast_stop_discovery'), invoke('v2_dlna_stop_discovery'), - ...(QBZD_ENABLED ? [invoke('v2_qbzd_stop_discovery')] : []), ]); } catch (err) { console.error('Failed to stop discovery:', err); @@ -171,9 +117,8 @@ const results = await Promise.allSettled([ invoke('v2_cast_get_devices'), invoke('v2_dlna_get_devices'), - ...(QBZD_ENABLED ? [invoke('v2_qbzd_get_devices')] : []), ]); - const [chromecast, dlna, qbzd] = results; + const [chromecast, dlna] = results; if (chromecast.status === 'fulfilled') { chromecastDevices = chromecast.value as CastDevice[]; @@ -181,9 +126,6 @@ if (dlna.status === 'fulfilled') { dlnaDevices = dlna.value as CastDevice[]; } - if (QBZD_ENABLED && qbzd && qbzd.status === 'fulfilled') { - qbzdDevices = qbzd.value as QbzdDevice[]; - } } catch (err) { console.error('Failed to get devices:', err); } @@ -211,111 +153,21 @@ async function handleDisconnect() { try { - if ($isRemoteMode) { - disconnectRemoteEvents(); - disconnectFromRemote(); - } else { - await disconnect(); - } + await disconnect(); } catch (err) { error = String(err); } } - async function handleQbzdConnect(device: QbzdDevice) { - connecting = true; - error = null; - try { - const reachable = await pingRemote(device.baseUrl, ''); - if (!reachable) { - error = `Cannot reach ${device.name} at ${device.baseUrl}`; - return; - } - connectToRemote(device.baseUrl, '', device.name); - connectRemoteEvents(); - saveDaemon(device.name, device.baseUrl); - savedDaemons = loadSavedDaemons(); - onClose(); - } catch (err) { - error = String(err); - } finally { - connecting = false; - } - } - - async function handleManualConnect() { - let addr = manualAddress.trim(); - if (!addr) return; - // Add http:// if missing - if (!addr.startsWith('http://') && !addr.startsWith('https://')) { - addr = `http://${addr}`; - } - // Add default port if missing - try { - const url = new URL(addr); - if (!url.port) { - addr = `${url.protocol}//${url.hostname}:8182`; - } - } catch { - error = 'Invalid address'; - return; - } - - connecting = true; - error = null; - try { - const reachable = await pingRemote(addr, ''); - if (!reachable) { - error = `Cannot reach daemon at ${addr}`; - return; - } - // Extract name from hostname - const name = new URL(addr).hostname; - connectToRemote(addr, '', name); - connectRemoteEvents(); - saveDaemon(name, addr); - savedDaemons = loadSavedDaemons(); - manualAddress = ''; - onClose(); - } catch (err) { - error = String(err); - } finally { - connecting = false; - } - } - - async function handleSavedConnect(saved: SavedDaemon) { - connecting = true; - error = null; - try { - const reachable = await pingRemote(saved.baseUrl, ''); - if (!reachable) { - error = `Cannot reach ${saved.name} at ${saved.baseUrl}`; - return; - } - connectToRemote(saved.baseUrl, '', saved.name); - connectRemoteEvents(); - saveDaemon(saved.name, saved.baseUrl); - savedDaemons = loadSavedDaemons(); - onClose(); - } catch (err) { - error = String(err); - } finally { - connecting = false; - } - } - - function getProtocolIcon(protocol: CastProtocol | 'qbzd') { + function getProtocolIcon(protocol: CastProtocol) { switch (protocol) { - case 'qbzd': return Monitor; case 'chromecast': return Cast; case 'dlna': return Tv; } } - function getProtocolLabel(protocol: CastProtocol | 'qbzd'): string { + function getProtocolLabel(protocol: CastProtocol): string { switch (protocol) { - case 'qbzd': return 'QBZ Daemon'; case 'chromecast': return 'Chromecast'; case 'dlna': return 'DLNA'; } @@ -333,21 +185,7 @@ - {#if $isRemoteMode} -
-
- -
- {$t('player.connectedTo')} - {$playbackTarget.name} -
-
- -
- {:else if castState.isConnected && castState.device} + {#if castState.isConnected && castState.device} {@const ProtocolIcon = getProtocolIcon(castState.protocol!)}
@@ -365,19 +203,6 @@ {:else}
- {#if QBZD_ENABLED} - - {/if}
- {:else if activeProtocol === 'qbzd'} - -
- -
- e.key === 'Enter' && handleManualConnect()} - /> - -
- - - {#if savedDaemons.length > 0} - -
- {#each savedDaemons as saved} -
- - -
- {/each} -
- {/if} - - - {#if qbzdDevices.length > 0} - -
- {#each qbzdDevices as device} - - {/each} -
- {:else if loading} - -
- - {$t('toast.loadingDevices')} -
- {/if} -
{:else if devices().length === 0 && (loading || scanning)}
@@ -673,111 +436,6 @@ color: var(--text-muted); } - .qbzd-section { - display: flex; - flex-direction: column; - gap: 12px; - } - - .manual-connect { - display: flex; - gap: 8px; - padding: 0 16px; - } - - .manual-input { - flex: 1; - padding: 8px 12px; - background: var(--bg-tertiary); - border: 1px solid var(--border-subtle); - border-radius: 6px; - color: var(--text-primary); - font-size: 13px; - font-family: monospace; - } - - .manual-input::placeholder { - color: var(--text-muted); - } - - .manual-input:focus { - outline: none; - border-color: var(--accent-primary); - } - - .connect-btn { - padding: 8px 16px; - background: var(--accent-primary); - color: var(--btn-primary-text); - border: none; - border-radius: 6px; - font-size: 13px; - font-weight: 500; - cursor: pointer; - white-space: nowrap; - } - - .connect-btn:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - .connect-btn:hover:not(:disabled) { - filter: brightness(1.1); - } - - .section-label { - font-size: 11px; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.05em; - padding: 0 16px; - } - - .device-row { - display: flex; - align-items: center; - } - - .device-row .device { - flex: 1; - } - - .remove-saved { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - background: none; - border: none; - color: var(--text-muted); - cursor: pointer; - border-radius: 50%; - margin-right: 8px; - opacity: 0; - transition: opacity 150ms ease; - } - - .device-row:hover .remove-saved { - opacity: 1; - } - - .remove-saved:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - - .scanning-hint { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 16px; - font-size: 12px; - color: var(--text-muted); - } - .device :global(.cast-icon) { color: var(--text-muted); opacity: 0; diff --git a/src/lib/components/NowPlayingBar.svelte b/src/lib/components/NowPlayingBar.svelte index a215b2236..4794a66f2 100644 --- a/src/lib/components/NowPlayingBar.svelte +++ b/src/lib/components/NowPlayingBar.svelte @@ -13,7 +13,6 @@ Volume2, VolumeX, Volume1, - Monitor, Cast, MicVocal, Maximize2, @@ -22,8 +21,6 @@ MoreHorizontal } from 'lucide-svelte'; import { t } from 'svelte-i18n'; - import { isRemoteMode, playbackTarget } from '$lib/stores/playbackTargetStore'; - import { disconnectFromRemote } from '$lib/stores/playbackTargetStore'; import QualityBadge from './QualityBadge.svelte'; import AudioOutputBadges from './AudioOutputBadges.svelte'; import QconnectBadge from './QconnectBadge.svelte'; @@ -394,19 +391,12 @@ // Set CSS variable for player bar height so page layout adjusts $effect(() => { - const barHeight = $isRemoteMode ? 128 : 104; + const barHeight = 104; document.documentElement.style.setProperty('--player-bar-height', `${barHeight}px`); }); -
- {#if $isRemoteMode} -
- - {$t('player.controllingRemote', { values: { name: $playbackTarget.name || 'Remote' } })} - -
- {/if} +
{formatTime(currentTime)} @@ -934,38 +924,6 @@ flex-direction: column; } - .now-playing-bar.has-remote-banner { - height: 128px; - } - - .remote-indicator { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 4px 12px; - background: var(--accent-primary); - color: var(--btn-primary-text); - font-size: 12px; - font-weight: 500; - letter-spacing: 0.02em; - } - - .remote-disconnect { - background: rgba(255, 255, 255, 0.2); - border: none; - color: white; - font-size: 11px; - padding: 2px 8px; - border-radius: 4px; - cursor: pointer; - margin-left: 4px; - } - - .remote-disconnect:hover { - background: rgba(255, 255, 255, 0.35); - } - /* ===== Seekbar ===== */ .seekbar-container { display: flex; diff --git a/src/lib/i18n/locales/de.json b/src/lib/i18n/locales/de.json index ca6caff5a..8bdb740a9 100644 --- a/src/lib/i18n/locales/de.json +++ b/src/lib/i18n/locales/de.json @@ -1466,11 +1466,6 @@ }, "player": { "nowPlaying": "Aktuell abgespielt", - "controllingRemote": "Steuert: {name}", - "noQbzdFound": "Keine QBZ-Daemons in Ihrem Netzwerk gefunden", - "noQbzdFoundHint": "Stellen Sie sicher, dass qbzd auf dem Zielgerat lauft", - "recentDaemons": "Zuletzt verwendet", - "discoveredDaemons": "Im Netzwerk entdeckt", "queue": "Warteschlange", "upNext": "Als Nächstes", "ofTotal": "von", diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index e87985186..3643ae8b9 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -1466,11 +1466,6 @@ }, "player": { "nowPlaying": "Now Playing", - "controllingRemote": "Controlling: {name}", - "noQbzdFound": "No QBZ Daemons found on your network", - "noQbzdFoundHint": "Make sure qbzd is running on the target device", - "recentDaemons": "Recent", - "discoveredDaemons": "Discovered on network", "queue": "Queue", "upNext": "Up Next", "ofTotal": "of", diff --git a/src/lib/i18n/locales/es.json b/src/lib/i18n/locales/es.json index 72670d65c..ee362ba97 100644 --- a/src/lib/i18n/locales/es.json +++ b/src/lib/i18n/locales/es.json @@ -1466,11 +1466,6 @@ }, "player": { "nowPlaying": "Reproduciendo", - "controllingRemote": "Controlando: {name}", - "noQbzdFound": "No se encontraron daemons QBZ en tu red", - "noQbzdFoundHint": "Asegurate de que qbzd este corriendo en el dispositivo", - "recentDaemons": "Recientes", - "discoveredDaemons": "Descubiertos en la red", "queue": "Cola", "upNext": "Siguiente", "ofTotal": "de", diff --git a/src/lib/i18n/locales/fr.json b/src/lib/i18n/locales/fr.json index 7846763cb..ec51a079d 100644 --- a/src/lib/i18n/locales/fr.json +++ b/src/lib/i18n/locales/fr.json @@ -1466,11 +1466,6 @@ }, "player": { "nowPlaying": "Lecture en cours", - "controllingRemote": "Controle : {name}", - "noQbzdFound": "Aucun daemon QBZ trouve sur votre reseau", - "noQbzdFoundHint": "Assurez-vous que qbzd est en cours d'execution sur l'appareil cible", - "recentDaemons": "Recents", - "discoveredDaemons": "Decouverts sur le reseau", "queue": "File d'attente", "upNext": "À suivre", "ofTotal": "de", diff --git a/src/lib/i18n/locales/pt.json b/src/lib/i18n/locales/pt.json index bca3a0a5d..e558c733f 100644 --- a/src/lib/i18n/locales/pt.json +++ b/src/lib/i18n/locales/pt.json @@ -1466,11 +1466,6 @@ }, "player": { "nowPlaying": "Tocando Agora", - "controllingRemote": "Controlando: {name}", - "noQbzdFound": "Nenhum daemon QBZ encontrado na sua rede", - "noQbzdFoundHint": "Certifique-se de que o qbzd esta em execucao no dispositivo alvo", - "recentDaemons": "Recentes", - "discoveredDaemons": "Descobertos na rede", "queue": "Fila", "upNext": "A Seguir", "ofTotal": "de", diff --git a/src/lib/services/commandRouter.ts b/src/lib/services/commandRouter.ts index 23b377fcd..56428663d 100644 --- a/src/lib/services/commandRouter.ts +++ b/src/lib/services/commandRouter.ts @@ -1,103 +1,61 @@ /** * Command Router * - * Routes commands to either Tauri invoke() or remote HTTP API based on - * the current playback target. This is the single point where local vs - * remote bifurcation happens — stores and services call these functions - * instead of invoke() directly for playback/queue operations. + * Thin dispatch layer over Tauri invoke() for playback, queue, and audio + * commands. Stores and services call these functions instead of invoke() + * directly so the V2 command names live in one place. * - * Only playback, queue, and favorites operations route remotely. - * Everything else (settings, library, integrations) always stays local. + * This used to bifurcate between local invoke() and a remote HTTP daemon + * (qbzd). The qbzd daemon was a premature experiment and has been removed; + * the app always controls local playback now. `isRemote()` / `skipIfRemote()` + * are kept as always-false no-ops so the ~100 `if (skipIfRemote()) return` + * guards scattered across stores/services keep compiling without edits. */ import { invoke } from '@tauri-apps/api/core'; -import { getTarget } from '$lib/stores/playbackTargetStore'; -import { remotePost, remoteGet } from '$lib/services/remoteApi'; -// ==================== Remote-Aware Helpers ==================== +// ==================== Local-only no-ops (kept for call-site compatibility) ==================== -/** Check if currently targeting a remote daemon */ +/** Remote targeting was removed — the app is always local. */ export function isRemote(): boolean { - return getTarget().type === 'qbzd'; + return false; } /** - * Guard for local-only operations. Returns true if we should skip. - * Use at the start of any function that only makes sense locally - * (offline cache, window settings, visualizer, etc.) + * Guard kept for call-site compatibility. Always false now (no remote target), + * so guarded local-only operations always proceed. */ export function skipIfRemote(): boolean { - return getTarget().type === 'qbzd'; -} - -/** Fetch from remote API (GET), or null if local */ -export async function remoteGetOrNull(path: string): Promise { - if (!isRemote()) return null; - return remoteGet(path); + return false; } // ==================== Playback ==================== export async function cmdPause(): Promise { - const target = getTarget(); - if (target.type === 'qbzd') { - await remotePost('/api/playback/pause'); - } else { - await invoke('v2_pause_playback'); - } + await invoke('v2_pause_playback'); } export async function cmdResume(): Promise { - const target = getTarget(); - if (target.type === 'qbzd') { - await remotePost('/api/playback/play'); - } else { - await invoke('v2_resume_playback'); - } + await invoke('v2_resume_playback'); } export async function cmdStop(): Promise { - const target = getTarget(); - if (target.type === 'qbzd') { - await remotePost('/api/playback/stop'); - } else { - await invoke('v2_stop_playback'); - } + await invoke('v2_stop_playback'); } export async function cmdSeek(position: number): Promise { - const target = getTarget(); - if (target.type === 'qbzd') { - await remotePost('/api/playback/seek', { position_secs: Math.floor(position) }); - } else { - await invoke('v2_seek', { position: Math.floor(position) }); - } + await invoke('v2_seek', { position: Math.floor(position) }); } export async function cmdSetVolume(volume: number): Promise { - const target = getTarget(); - if (target.type === 'qbzd') { - await remotePost('/api/playback/volume', { volume }); - } else { - await invoke('v2_set_volume', { volume }); - } + await invoke('v2_set_volume', { volume }); } export async function cmdNext(): Promise { - const target = getTarget(); - if (target.type === 'qbzd') { - return remotePost('/api/playback/next'); - } else { - return invoke('v2_next_track'); - } + return invoke('v2_next_track'); } export async function cmdPrevious(): Promise { - const target = getTarget(); - if (target.type === 'qbzd') { - return remotePost('/api/playback/previous'); - } else { - return invoke('v2_previous_track'); - } + return invoke('v2_previous_track'); } export async function cmdPlayTrack( @@ -107,142 +65,74 @@ export async function cmdPlayTrack( forceLowestQuality?: boolean | null, startPositionSecs?: number | null, ): Promise { - const target = getTarget(); - if (target.type === 'qbzd') { - return remotePost('/api/playback/play-track', { track_id: trackId, quality }); - } else { - // Parameter names MUST match Tauri's camelCase mapping of Rust - // v2_play_track(track_id, quality, force_lowest_quality, duration_secs, - // start_position_secs). duration_secs is required on the streaming - // path — without it the backend stores duration=0 and - // current_position() clamps to 0, freezing the seekbar (seen on - // session-restore first play). start_position_secs is still accepted - // by the backend but the frontend no longer uses it: session resume - // now seeks to the saved offset only on a cache hit, where the audio - // is fully in memory and Seek lands (see playerStore togglePlay). - return invoke('v2_play_track', { - trackId, - quality: quality ?? null, - forceLowestQuality: forceLowestQuality ?? null, - durationSecs: durationSecs ?? null, - startPositionSecs: startPositionSecs ?? null, - }); - } + // Parameter names MUST match Tauri's camelCase mapping of Rust + // v2_play_track(track_id, quality, force_lowest_quality, duration_secs, + // start_position_secs). duration_secs is required on the streaming + // path — without it the backend stores duration=0 and + // current_position() clamps to 0, freezing the seekbar (seen on + // session-restore first play). start_position_secs is still accepted + // by the backend but the frontend no longer uses it: session resume + // now seeks to the saved offset only on a cache hit, where the audio + // is fully in memory and Seek lands (see playerStore togglePlay). + return invoke('v2_play_track', { + trackId, + quality: quality ?? null, + forceLowestQuality: forceLowestQuality ?? null, + durationSecs: durationSecs ?? null, + startPositionSecs: startPositionSecs ?? null, + }); } // ==================== Queue ==================== export async function cmdSetQueue(tracks: unknown[], startIndex: number): Promise { - const target = getTarget(); - if (target.type === 'qbzd') { - await remotePost('/api/queue/set', { tracks, start_index: startIndex }); - } else { - await invoke('v2_set_queue', { tracks, startIndex }); - } + await invoke('v2_set_queue', { tracks, startIndex }); } export async function cmdAddToQueue(track: unknown): Promise { - const target = getTarget(); - if (target.type === 'qbzd') { - await remotePost('/api/queue/add', { tracks: [track] }); - } else { - await invoke('v2_add_to_queue', { track }); - } + await invoke('v2_add_to_queue', { track }); } export async function cmdAddToQueueNext(track: unknown): Promise { - const target = getTarget(); - if (target.type === 'qbzd') { - await remotePost('/api/queue/add-next', { tracks: [track] }); - } else { - await invoke('v2_add_to_queue_next', { track }); - } + await invoke('v2_add_to_queue_next', { track }); } export async function cmdAddTracksToQueue(tracks: unknown[]): Promise { - const target = getTarget(); - if (target.type === 'qbzd') { - await remotePost('/api/queue/add', { tracks }); - } else { - await invoke('v2_add_tracks_to_queue', { tracks }); - } + await invoke('v2_add_tracks_to_queue', { tracks }); } export async function cmdAddTracksToQueueNext(tracks: unknown[]): Promise { - const target = getTarget(); - if (target.type === 'qbzd') { - await remotePost('/api/queue/add-next', { tracks }); - } else { - await invoke('v2_add_tracks_to_queue_next', { tracks }); - } + await invoke('v2_add_tracks_to_queue_next', { tracks }); } export async function cmdClearQueue(opts?: { includeCurrent?: boolean }): Promise { - const target = getTarget(); - if (target.type === 'qbzd') { - await remotePost('/api/queue/clear'); - } else { - await invoke('v2_clear_queue', { includeCurrent: opts?.includeCurrent ?? false }); - } + await invoke('v2_clear_queue', { includeCurrent: opts?.includeCurrent ?? false }); } export async function cmdToggleShuffle(): Promise { - const target = getTarget(); - if (target.type === 'qbzd') { - // Remote: get current state and toggle - const queue = await remoteGet<{ shuffle: boolean }>('/api/queue'); - await remotePost('/api/queue/shuffle', { enabled: !queue.shuffle }); - } else { - await invoke('v2_toggle_shuffle'); - } + await invoke('v2_toggle_shuffle'); } export async function cmdSetRepeatMode(mode: string): Promise { - const target = getTarget(); - if (target.type === 'qbzd') { - await remotePost('/api/queue/repeat', { mode }); - } else { - // Map frontend mode names to V2 command format - const v2Mode = mode === 'one' ? 'One' : mode === 'all' ? 'All' : 'Off'; - await invoke('v2_set_repeat_mode', { mode: v2Mode }); - } + // Map frontend mode names to V2 command format + const v2Mode = mode === 'one' ? 'One' : mode === 'all' ? 'All' : 'Off'; + await invoke('v2_set_repeat_mode', { mode: v2Mode }); } // ==================== Audio Settings ==================== export async function cmdGetAudioSettings(): Promise { - const target = getTarget(); - if (target.type === 'qbzd') { - return remoteGet('/api/audio/settings'); - } else { - return invoke('v2_get_audio_settings'); - } + return invoke('v2_get_audio_settings'); } export async function cmdGetAvailableBackends(): Promise { - const target = getTarget(); - if (target.type === 'qbzd') { - return remoteGet('/api/audio/backends'); - } else { - return invoke('v2_get_available_backends'); - } + return invoke('v2_get_available_backends'); } export async function cmdGetDevicesForBackend(backendType: string): Promise { - const target = getTarget(); - if (target.type === 'qbzd') { - return remoteGet(`/api/audio/devices?backend=${encodeURIComponent(backendType)}`); - } else { - return invoke('v2_get_devices_for_backend', { backendType }); - } + return invoke('v2_get_devices_for_backend', { backendType }); } export async function cmdGetHardwareAudioStatus(): Promise { - const target = getTarget(); - if (target.type === 'qbzd') { - return remoteGet('/api/audio/hardware-status'); - } else { - return invoke('v2_get_hardware_audio_status'); - } + return invoke('v2_get_hardware_audio_status'); } - diff --git a/src/lib/services/playbackService.ts b/src/lib/services/playbackService.ts index a03192b0b..ecbaccc30 100644 --- a/src/lib/services/playbackService.ts +++ b/src/lib/services/playbackService.ts @@ -14,8 +14,6 @@ import { get } from 'svelte/store'; import { t } from '$lib/i18n'; import { formatTrackTitle } from '$lib/utils/trackTitle'; import { cmdStop, cmdPlayTrack, skipIfRemote } from '$lib/services/commandRouter'; -import { getTarget } from '$lib/stores/playbackTargetStore'; -import { remotePost } from '$lib/services/remoteApi'; import { getUserItem, setUserItem } from '$lib/utils/userStorage'; import { isPlaybackSourceLocal, @@ -242,18 +240,6 @@ export async function playTrack( try { let handledRemotely = false; - const target = getTarget(); - - // When controlling a qbzd daemon, send play directly to daemon. - // No local stop, no QConnect, no metadata — daemon handles everything. - if (target.type === 'qbzd') { - await remotePost('/api/playback/play-track', { - track_id: track.id, - quality: effectiveQuality(track), - }); - setIsPlaying(true); - return true; - } if (!gaplessTransition && !isLocal && source !== 'plex') { handledRemotely = await handoffPlayTrackToRemoteRenderer(track.id); @@ -332,28 +318,19 @@ export async function playTrack( } else if (isLocal) { await invoke('v2_library_play_track', { trackId: track.id }); } else { - // Route to daemon or local based on playback target - const target = getTarget(); - if (target.type === 'qbzd') { - await remotePost('/api/playback/play-track', { - track_id: track.id, - quality: effectiveQuality(track), - }); - } else { - const result = await invoke('v2_play_track', { - trackId: track.id, - quality: effectiveQuality(track), - durationSecs: track.duration ? Math.round(track.duration) : null, - forceLowestQuality: forceLowestQuality || null - }); + const result = await invoke('v2_play_track', { + trackId: track.id, + quality: effectiveQuality(track), + durationSecs: track.duration ? Math.round(track.duration) : null, + forceLowestQuality: forceLowestQuality || null + }); - // Update track format based on actual stream format_id from Qobuz - const actualFormat = formatIdToString(result.format_id); - if (actualFormat) { - track.format = actualFormat; - // Re-set current track to update the UI with actual format - setCurrentTrack(track); - } + // Update track format based on actual stream format_id from Qobuz + const actualFormat = formatIdToString(result.format_id); + if (actualFormat) { + track.format = actualFormat; + // Re-set current track to update the UI with actual format + setCurrentTrack(track); } } } diff --git a/src/lib/services/remoteApi.ts b/src/lib/services/remoteApi.ts deleted file mode 100644 index 90a31d843..000000000 --- a/src/lib/services/remoteApi.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Remote API Service - * - * HTTP client for communicating with a remote qbzd daemon. - * Used when playbackTarget.type === 'qbzd'. - * - * All methods throw if not in remote mode — callers must check first. - */ -import { getTarget, type PlaybackTarget } from '$lib/stores/playbackTargetStore'; - -export class RemoteApiError extends Error { - constructor( - public status: number, - public code: string, - message: string, - ) { - super(message); - this.name = 'RemoteApiError'; - } -} - -function requireRemote(): PlaybackTarget & { type: 'qbzd'; baseUrl: string } { - const target = getTarget(); - if (target.type !== 'qbzd' || !target.baseUrl) { - throw new Error('Not in remote mode'); - } - return target as PlaybackTarget & { type: 'qbzd'; baseUrl: string }; -} - -/** Make a GET request to the remote daemon */ -export async function remoteGet(path: string): Promise { - const target = requireRemote(); - const headers: Record = {}; - if (target.token) headers['X-API-Key'] = target.token; - - const response = await fetch(`${target.baseUrl}${path}`, { headers }); - if (!response.ok) { - const body = await response.text().catch(() => ''); - throw new RemoteApiError(response.status, 'request_failed', body || response.statusText); - } - return response.json(); -} - -/** Make a POST request to the remote daemon */ -export async function remotePost(path: string, body?: unknown): Promise { - const target = requireRemote(); - const headers: Record = { 'Content-Type': 'application/json' }; - if (target.token) headers['X-API-Key'] = target.token; - - const response = await fetch(`${target.baseUrl}${path}`, { - method: 'POST', - headers, - body: body ? JSON.stringify(body) : undefined, - }); - if (!response.ok) { - const text = await response.text().catch(() => ''); - throw new RemoteApiError(response.status, 'request_failed', text || response.statusText); - } - // Some endpoints return plain text ("ok"), not JSON - const text = await response.text(); - try { - return JSON.parse(text); - } catch { - return text as unknown as T; - } -} - -/** Check if a remote daemon is reachable */ -export async function pingRemote(baseUrl: string, token?: string): Promise { - try { - const headers: Record = {}; - if (token) headers['X-API-Key'] = token; - const response = await fetch(`${baseUrl}/api/ping`, { - headers, - signal: AbortSignal.timeout(5000), - }); - return response.ok; - } catch { - return false; - } -} - -/** Get daemon info (name, version, capabilities) */ -export async function getRemoteInfo( - baseUrl: string, - token?: string, -): Promise<{ name: string; version: string; cache: Record }> { - const headers: Record = {}; - if (token) headers['X-API-Key'] = token; - const response = await fetch(`${baseUrl}/api/info`, { - headers, - signal: AbortSignal.timeout(5000), - }); - if (!response.ok) throw new Error('Failed to get daemon info'); - return response.json(); -} diff --git a/src/lib/services/remoteEvents.ts b/src/lib/services/remoteEvents.ts deleted file mode 100644 index 8c031a026..000000000 --- a/src/lib/services/remoteEvents.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Remote Events Service - * - * SSE client that connects to a remote qbzd daemon's /api/events endpoint. - * When in remote mode, this replaces Tauri's event system for playback - * and queue state updates. - */ -import { getTarget } from '$lib/stores/playbackTargetStore'; - -type EventHandler = (data: unknown) => void; - -let eventSource: EventSource | null = null; -let reconnectTimer: ReturnType | null = null; -const handlers: Map = new Map(); - -/** Connect to the remote daemon's SSE stream */ -export function connectRemoteEvents() { - disconnectRemoteEvents(); - - const target = getTarget(); - if (target.type !== 'qbzd' || !target.baseUrl || !target.token) return; - - const url = `${target.baseUrl}/api/events?token=${encodeURIComponent(target.token)}`; - console.log('[RemoteEvents] Connecting to SSE:', target.baseUrl); - - eventSource = new EventSource(url); - - eventSource.onopen = () => { - console.log('[RemoteEvents] SSE connected'); - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - }; - - eventSource.onerror = () => { - console.warn('[RemoteEvents] SSE error, will reconnect...'); - disconnectRemoteEvents(); - reconnectTimer = setTimeout(connectRemoteEvents, 3000); - }; - - // Register handlers for each event type - const eventTypes = [ - 'playback', - 'queue', - 'track-started', - 'track-ended', - 'favorites-updated', - 'playlist-created', - 'playlist-updated', - 'error', - 'runtime', - 'logged-in', - 'logged-out', - ]; - - for (const type of eventTypes) { - eventSource.addEventListener(type, (e: MessageEvent) => { - try { - const data = JSON.parse(e.data); - const typeHandlers = handlers.get(type); - if (typeHandlers) { - for (const handler of typeHandlers) { - handler(data); - } - } - } catch (err) { - console.warn(`[RemoteEvents] Failed to parse ${type} event:`, err); - } - }); - } -} - -/** Disconnect from the remote SSE stream */ -export function disconnectRemoteEvents() { - if (eventSource) { - eventSource.close(); - eventSource = null; - } - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } -} - -/** Register a handler for a specific event type */ -export function onRemoteEvent(type: string, handler: EventHandler): () => void { - const existing = handlers.get(type) || []; - existing.push(handler); - handlers.set(type, existing); - - // Return unsubscribe function - return () => { - const list = handlers.get(type); - if (list) { - const idx = list.indexOf(handler); - if (idx !== -1) list.splice(idx, 1); - } - }; -} - -/** Check if SSE is currently connected */ -export function isRemoteEventsConnected(): boolean { - return eventSource !== null && eventSource.readyState === EventSource.OPEN; -} diff --git a/src/lib/stores/playbackTargetStore.ts b/src/lib/stores/playbackTargetStore.ts deleted file mode 100644 index 2ad1b09dc..000000000 --- a/src/lib/stores/playbackTargetStore.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Playback Target Store - * - * Tracks whether the app is controlling local playback or a remote qbzd daemon. - * When targeting a remote daemon, playback/queue/search commands route to - * the HTTP API instead of Tauri invoke(). - */ -import { writable, get, derived } from 'svelte/store'; - -export interface PlaybackTarget { - type: 'local' | 'qbzd'; - /** HTTP base URL of the daemon (e.g., "http://192.168.1.50:8182") */ - baseUrl?: string; - /** API token for authentication */ - token?: string; - /** Human-readable name (e.g., "raspberrypi", "living-room") */ - name?: string; -} - -/** Current playback target — local by default */ -export const playbackTarget = writable({ type: 'local' }); - -/** Whether we're currently controlling a remote daemon */ -export const isRemoteMode = derived(playbackTarget, ($t) => $t.type === 'qbzd'); - -/** Connect to a remote qbzd daemon */ -export function connectToRemote(baseUrl: string, token: string, name?: string) { - // Strip trailing slashes without a regex (regex `\/+$` was flagged as a - // potential ReDoS risk; the linear loop below is trivially bounded). - let normalizedUrl = baseUrl; - while (normalizedUrl.endsWith('/')) { - normalizedUrl = normalizedUrl.slice(0, -1); - } - playbackTarget.set({ - type: 'qbzd', - baseUrl: normalizedUrl, - token, - name: name || new URL(baseUrl).hostname, - }); - console.log(`[PlaybackTarget] Connected to remote: ${name || baseUrl}`); -} - -/** Disconnect from remote, return to local playback */ -export function disconnectFromRemote() { - playbackTarget.set({ type: 'local' }); - console.log('[PlaybackTarget] Switched to local playback'); -} - -/** Get the current target (non-reactive, for use in async functions) */ -export function getTarget(): PlaybackTarget { - return get(playbackTarget); -}