commit 3a0d6e0240c29c704440a2e3d082cfd2a514d3f3 Author: joren Date: Tue Mar 31 20:38:54 2026 +0200 Initial commit: QobuzD - Qobuz Connect renderer for Linux Rust-based QConnect renderer with: - QConnect WebSocket protocol (hand-rolled protobuf) - Audio playback via Symphonia + cpal - Play, pause, resume, volume, skip support - Correct BufferState/PlayingState enum values per proto spec - Server-driven queue management (no local queue) - Periodic position reporting for track-end detection Co-Authored-By: Claude Opus 4.6 diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..66a2b65 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-unknown-linux-musl] +rustflags = ["-C", "target-feature=-crt-static"] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a37c97 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target/ +*.swp +*.swo diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9d86e08 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3599 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.11.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.11.0", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[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 = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[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 = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "zeroize", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.11.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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 = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-src" +version = "300.5.5+3.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "qobuzd" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "anyhow", + "base64", + "chrono", + "clap", + "cpal", + "directories", + "futures-util", + "hex", + "hmac", + "keyring", + "md-5", + "rand 0.8.5", + "rand_chacha 0.3.1", + "reqwest", + "rubato", + "serde", + "serde_json", + "sha1", + "sha2", + "symphonia", + "thiserror 1.0.69", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-appender", + "tracing-subscriber", + "uuid", + "zeroize", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "realfft" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" +dependencies = [ + "rustfft", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rubato" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5d18b486e7d29a408ef3f825bc1327d8f87af091c987ca2f5b734625940e234" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "realfft", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strength_reduce" +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 = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.0.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.10+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a75e23d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "qobuzd" +version = "0.1.0" +edition = "2021" +authors = ["QobuzD Team"] +description = "Qobuz Connect client for Linux" +license = "MIT" + +[dependencies] +reqwest = { version = "0.12", features = ["json", "rustls-tls-webpki-roots", "stream", "blocking"], default-features = false } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1", features = ["full"] } +md-5 = "0.10" +hmac = "0.12" +sha2 = "0.10" +rand = "0.8" +base64 = "0.22" +uuid = { version = "1.0", features = ["v4"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "0.2" +directories = "5.0" +thiserror = "1.0" +chrono = { version = "0.4", features = ["serde"] } +anyhow = "1.0" +clap = { version = "4.0", features = ["derive"] } +keyring = "3" +zeroize = { version = "1.5", features = ["derive"] } +aes-gcm = "0.10" +rand_chacha = "0.3" +sha1 = "0.10" +hex = "0.4" +tokio-tungstenite = { version = "0.24", features = ["native-tls", "native-tls-vendored"] } +futures-util = "0.3" +symphonia = { version = "0.5", features = ["flac", "pcm", "mp3", "aac", "ogg", "wav"] } +cpal = "0.15" +rubato = "0.15" + +[profile.release] +strip = true +lto = true +codegen-units = 1 +opt-level = "z" diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 0000000..e58aad9 --- /dev/null +++ b/PROTOCOL.md @@ -0,0 +1,174 @@ +# QConnect Protocol Reference + +Reverse-engineered from Qobuz Android app (JADX decompilation) and Burp traffic analysis. + +## Transport Layer + +WebSocket binary frames with custom framing: +- Frame = `type_byte` + `varint(body_length)` + `body` +- Frame type 1 = Auth (field 1=msg_id, field 3=jwt) +- Frame type 2 = Subscribe (field 1=msg_id, field 3=1) +- Frame type 6 = Data payload (field 1=msg_id, field 2=timestamp, field 3=1, field 5=\x02, field 7=qconnect_container) + +## QConnect Container (inside frame body field 7) + +- Field 3 = serialized QConnect Message + +## QConnect Message + +```protobuf +message Message { + MessageType message_type = 1; // field number of payload == message_type value + oneof payload { + // Renderer -> Server + Renderer.JoinSessionMessage rndr_srvr_join_session = 21; + Renderer.StateUpdatedMessage rndr_srvr_state_updated = 23; + Renderer.VolumeChangedMessage rndr_srvr_volume_changed = 25; + + // Server -> Renderer + Renderer.SetStateMessage srvr_rndr_set_state = 41; + Renderer.SetVolumeMessage srvr_rndr_set_volume = 42; + Renderer.SetActiveMessage srvr_rndr_set_active = 43; + Renderer.SetMaxAudioQualityMsg srvr_rndr_set_max_quality = 44; + Renderer.SetLoopModeMessage srvr_rndr_set_loop_mode = 45; + Renderer.SetShuffleModeMessage srvr_rndr_set_shuffle_mode = 46; + Renderer.MuteVolumeMessage srvr_rndr_mute_volume = 47; + + // Controller -> Server + Controller.JoinSessionMessage ctrl_srvr_join_session = 61; + Controller.SetPlayerStateMsg ctrl_srvr_set_player_state = 62; + Controller.QueueLoadTracksMsg ctrl_srvr_queue_load_tracks= 66; + + // Server -> Controller + // SessionStateMessage = 81; + } +} +``` + +## Message Definitions + +### Renderer.SetStateMessage (41 - SRVR_RNDR_SET_STATE) +```protobuf +message SetStateMessage { + PlayingState playing_state = 1; // enum: 0=UNKNOWN, 1=STOPPED, 2=PLAYING, 3=PAUSED + int32 current_position = 2; // playback position (seconds) + QueueVersion queue_version = 3; // sub-message (field 1 = major version) + QueueTrackWithContext current_track = 4; + QueueTrackWithContext next_track = 5; +} +``` + +### Renderer.SetVolumeMessage (42 - SRVR_RNDR_SET_VOLUME) +```protobuf +message SetVolumeMessage { + int32 volume = 1; // absolute volume (0-100) + int32 volume_delta = 2; // relative change +} +``` + +### Renderer.SetActiveMessage (43 - SRVR_RNDR_SET_ACTIVE) +```protobuf +message SetActiveMessage { + bool active = 1; +} +``` + +### Common.QueueTrackWithContext +```protobuf +message QueueTrackWithContext { + int32 queue_item_id = 1; + int32 track_id = 2; + bytes context_uuid = 3; // optional +} +``` + +### Common.PlaybackPosition +```protobuf +message PlaybackPosition { + fixed64 timestamp = 1; // epoch millis + int32 value = 2; // position in seconds +} +``` + +### RendererState (sent by renderer to report its state) +```protobuf +message RendererState { + PlayingState playing_state = 1; + BufferState buffer_state = 2; // enum: 0=UNKNOWN, 1=BUFFERING, 2=OK, 3=ERROR, 4=UNDERRUN + PlaybackPosition current_position = 3; + int32 duration = 4; // seconds + QueueVersion queue_version = 5; // sub-message (field 1 = major) + int32 current_queue_item_id = 6; + int32 next_queue_item_id = 7; +} +``` + +### Renderer.JoinSessionMessage (21 - RNDR_SRVR_JOIN_SESSION) +```protobuf +message JoinSessionMessage { + bytes session_uuid = 1; + DeviceInfo device_info = 2; + RendererState initial_state = 4; + bool is_active = 5; +} +``` + +### Renderer.StateUpdatedMessage (23 - RNDR_SRVR_STATE_UPDATED) +```protobuf +message StateUpdatedMessage { + RendererState state = 1; +} +``` + +### Renderer.VolumeChangedMessage (25 - RNDR_SRVR_VOLUME_CHANGED) +```protobuf +message VolumeChangedMessage { + int32 volume = 1; +} +``` + +### Controller.JoinSessionMessage (61 - CTRL_SRVR_JOIN_SESSION) +```protobuf +message JoinSessionMessage { + DeviceInfo device_info = 2; +} +``` + +### Common.DeviceInfo +```protobuf +message DeviceInfo { + bytes device_uuid = 1; + string friendly_name = 2; + string brand = 3; + string model = 4; + string serial_number = 5; + DeviceType type = 6; // enum: 5=COMPUTER + Capabilities capabilities = 7; + string software_version = 8; +} +``` + +### Common.Capabilities +```protobuf +message Capabilities { + int32 audio_quality = 1; + int32 field_2 = 2; // observed value: 4 + int32 field_3 = 3; // observed value: 2 +} +``` + +## PlayingState Enum +- 0 = UNKNOWN (idle sync, ignore) +- 1 = STOPPED +- 2 = PLAYING +- 3 = PAUSED + +## Connection Flow +1. Get JWT + WS endpoint via `GET /api.json/0.2/qws/getToken` +2. Open ctrl WebSocket, send Auth (frame type 1) + Subscribe (frame type 2) + CTRL_SRVR_JOIN_SESSION (61) +3. Receive SRVR_CTRL_SESSION_STATE (81) containing session_uuid +4. Open renderer WebSocket, send Auth + Subscribe + RNDR_SRVR_JOIN_SESSION (21) with session_uuid +5. Receive SET_ACTIVE (43) confirming join +6. Enter main loop: handle SET_STATE (41), SET_VOLUME (42), etc. +7. Report state changes via STATE_UPDATED (23) and VOLUME_CHANGED (25) +8. Send heartbeat state updates every 5 seconds diff --git a/decode_burp.py b/decode_burp.py new file mode 100644 index 0000000..17e445b --- /dev/null +++ b/decode_burp.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Decode QConnect WebSocket messages from Burp capture.""" +import json, sys + +def decode_varint(data, pos): + val = 0; shift = 0 + while pos < len(data): + b = data[pos]; pos += 1 + val |= (b & 0x7F) << shift + if not (b & 0x80): return val, pos + shift += 7 + return val, pos + +def parse_fields(data): + fields = []; pos = 0 + while pos < len(data): + if pos >= len(data): break + tag, pos = decode_varint(data, pos) + fnum = tag >> 3; wt = tag & 7 + if wt == 0: + val, pos = decode_varint(data, pos) + fields.append((fnum, wt, val.to_bytes(8, 'little'))) + elif wt == 1: + fields.append((fnum, wt, data[pos:pos+8])); pos += 8 + elif wt == 2: + ln, pos = decode_varint(data, pos) + fields.append((fnum, wt, data[pos:pos+ln])); pos += ln + elif wt == 5: + fields.append((fnum, wt, data[pos:pos+4])); pos += 4 + else: break + return fields + +def get_varint(fields, num): + for f, w, d in fields: + if f == num and w == 0: + return int.from_bytes(d[:8], 'little') + return None + +def get_bytes(fields, num): + for f, w, d in fields: + if f == num and w == 2: return d + return None + +def get_fixed32(fields, num): + for f, w, d in fields: + if f == num and w == 5: return int.from_bytes(d[:4], 'little') + return None + +def get_fixed64(fields, num): + for f, w, d in fields: + if f == num and w == 1: return int.from_bytes(d[:8], 'little') + return None + +MSG_NAMES = { + 1: "ERROR", 21: "RNDR_JOIN", 23: "STATE_UPDATED", 25: "VOLUME_CHANGED", + 28: "QUALITY_CHANGED", 29: "VOLUME_MUTED", 41: "SET_STATE", 42: "SET_VOLUME", + 43: "SET_ACTIVE", 44: "SET_QUALITY", 45: "SET_LOOP", 46: "SET_SHUFFLE", + 47: "MUTE_VOLUME", 61: "CTRL_JOIN", 66: "QUEUE_REQ", 67: "QUEUE_REQ2", + 75: "QUEUE_UPDATE", 76: "SESSION_SETUP", 77: "ACK_JOIN", 79: "TRACK_LIST", + 82: "SRV_STATE_ECHO", 83: "DEVICE_LIST", 86: "SRV_VOL_ECHO", + 87: "SRV_VOL_ECHO2", 90: "SRV_QUEUE_INFO", 91: "SRV_QUEUE_RESP", + 97: "SRV_ACK", 98: "SRV_ACK2", 99: "SRV_QUALITY_ECHO", + 100: "SRV_QUALITY_INFO", 103: "SRV_TRACKLIST_RESP" +} + +def decode_renderer_state(data): + """Decode RendererState proto""" + f = parse_fields(data) + ps = get_varint(f, 1) + bs = get_varint(f, 2) + pos_data = get_bytes(f, 3) + dur = get_varint(f, 4) + qi = get_varint(f, 6) + nqi = get_varint(f, 7) + + pos_ms = None; ts = None + if pos_data: + pf = parse_fields(pos_data) + ts = get_fixed64(pf, 1) + pos_ms = get_varint(pf, 2) + + return f"playing={ps} buffer={bs} pos={pos_ms}ms dur={dur}ms qi={qi} nqi={nqi}" + +def decode_set_state(data): + """Decode SET_STATE (type 41) payload""" + f = parse_fields(data) + ps = get_varint(f, 1) + pos = get_varint(f, 2) + qv = get_bytes(f, 3) + ct = get_bytes(f, 4) + nt = get_bytes(f, 5) + + qv_str = "" + if qv: + qvf = parse_fields(qv) + qv_str = f" qver={get_varint(qvf, 1)}.{get_varint(qvf, 2)}" + + ct_str = "" + if ct: + ctf = parse_fields(ct) + ct_str = f" cur_track(qi={get_varint(ctf, 1)} tid={get_fixed32(ctf, 2)})" + + nt_str = "" + if nt: + ntf = parse_fields(nt) + nt_str = f" next_track(qi={get_varint(ntf, 1)} tid={get_fixed32(ntf, 2)})" + + return f"playing_state={'None' if ps is None else ps} pos={pos}{qv_str}{ct_str}{nt_str}" + +def process_message(payload_str, direction): + data = payload_str.encode('latin-1') + if len(data) < 2: return + + # Decode frame layer + pos = 0 + while pos < len(data): + if pos >= len(data): break + ft = data[pos]; pos += 1 + flen, pos = decode_varint(data, pos) + if pos + flen > len(data): break + fbody = data[pos:pos+flen]; pos += flen + + if ft != 6: continue # only data frames + + # Parse frame body fields + ff = parse_fields(fbody) + f7 = get_bytes(ff, 7) + if not f7: continue + + # Inside f7, field 3 = QConnect message + cf = parse_fields(f7) + for cfnum, cwt, cdata in cf: + if cfnum != 3 or cwt != 2: continue + mf = parse_fields(cdata) + mt = get_varint(mf, 1) + if mt is None: continue + + name = MSG_NAMES.get(mt, f"TYPE_{mt}") + payload = get_bytes(mf, mt) + + extra = "" + if mt == 23 and payload: # STATE_UPDATED + # Unwrap field 1 (RendererState wrapper) + sf = parse_fields(payload) + state_data = get_bytes(sf, 1) + if state_data: + extra = " " + decode_renderer_state(state_data) + else: + extra = " " + decode_renderer_state(payload) + elif mt == 41 and payload: # SET_STATE + extra = " " + decode_set_state(payload) + elif mt == 82 and payload: # SRV_STATE_ECHO + sf = parse_fields(payload) + state_data = get_bytes(sf, 1) + if state_data: + extra = " " + decode_renderer_state(state_data) + + arrow = ">>>" if "CLIENT" in direction else "<<<" + print(f" {arrow} {name}({mt}){extra}") + +# Read from burp JSON +import subprocess +# Just process messages passed on stdin +for line in sys.stdin: + line = line.strip() + if not line: continue + try: + msg = json.loads(line) + d = msg.get('direction', '') + p = msg.get('payload', '') + process_message(p, d) + except: pass diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..6509108 --- /dev/null +++ b/src/api.rs @@ -0,0 +1,445 @@ +use crate::config::Config; +use crate::crypto; +use crate::error::{QobuzError, Result}; +use crate::types::*; +use reqwest::Client; +use std::time::Duration; + +#[derive(Clone)] +pub struct QobuzApi { + client: Client, + base_url: String, + app_id: String, + device_id: String, + device_name: String, + session_id: String, +} + +impl QobuzApi { + pub fn new(config: &Config) -> Self { + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .user_agent("Qobuzd/0.1.0") + .build() + .expect("Failed to create HTTP client"); + + Self { + client, + base_url: "https://www.qobuz.com".to_string(), + app_id: config.app_id.clone(), + device_id: config.device_id.clone(), + device_name: config.device_name.clone(), + session_id: config.session_id.clone(), + } + } + + pub fn get_device_id(&self) -> &str { + &self.device_id + } + + pub fn get_device_name(&self) -> &str { + &self.device_name + } + + pub fn get_session_id(&self) -> &str { + &self.session_id + } + + fn get_timestamp(&self) -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + } + + fn build_auth_headers(&self, access_token: Option<&str>) -> reqwest::header::HeaderMap { + use reqwest::header::*; + + let mut headers = HeaderMap::new(); + headers.insert("X-Device-Platform", "linux".parse().unwrap()); + headers.insert("X-Device-Model", self.device_name.parse().unwrap()); + headers.insert("X-Device-Manufacturer-Id", self.device_id.parse().unwrap()); + + if let Some(token) = access_token { + let auth_value = format!("Bearer {}", token); + headers.insert(AUTHORIZATION, auth_value.parse().unwrap()); + } + + headers + } + + pub async fn login(&self, email: &str, password: &str) -> Result { + let timestamp = self.get_timestamp(); + + let signature = crypto::generate_login_signature(email, password, &self.app_id, timestamp); + + let url = format!( + "{}/api.json/0.2/oauth2/login?username={}&password={}&app_id={}&request_ts={}&request_sig={}", + self.base_url, + urlencoding::encode(email), + urlencoding::encode(password), + self.app_id, + timestamp, + signature + ); + + let response = self.client + .get(&url) + .header("User-Agent", "Dalvik/2.1.0 (Linux; U; Android 9; Nexus 6P Build/PQ3A.190801.002) QobuzMobileAndroid/9.7.0.3-b26022717") + .header("X-App-Id", &self.app_id) + .header("X-App-Version", "9.7.0.3") + .header("X-Device-Platform", "android") + .header("X-Device-Model", "Nexus 6P") + .header("X-Device-Os-Version", "9") + .send() + .await?; + + let status = response.status(); + + if !status.is_success() { + let error: ErrorResponse = response.json().await.unwrap_or_else(|_| ErrorResponse { + message: Some("Login failed".to_string()), + code: Some(status.as_u16() as u32), + status: Some("error".to_string()), + errors: None, + }); + return Err(QobuzError::AuthError( + error.message.unwrap_or_else(|| "Unknown error".to_string()), + )); + } + + let login_response: LoginResponse = response.json().await?; + Ok(login_response) + } + + pub async fn refresh_token(&self, refresh_token: &str) -> Result { + let timestamp = self.get_timestamp(); + + let signature = crypto::generate_request_signature( + "oauth2/token", + &[ + ("refresh_token", refresh_token), + ("grant_type", "refresh_token"), + ], + timestamp, + ); + + let url = format!( + "{}/api.json/0.2/oauth2/token?refresh_token={}&grant_type=refresh_token&app_id={}&request_ts={}&request_sig={}", + self.base_url, + urlencoding::encode(refresh_token), + self.app_id, + timestamp, + signature + ); + + let response = self.client + .get(&url) + .header("User-Agent", "Dalvik/2.1.0 (Linux; U; Android 9; Nexus 6P Build/PQ3A.190801.002) QobuzMobileAndroid/9.7.0.3-b26022717") + .header("X-App-Id", &self.app_id) + .header("X-App-Version", "9.7.0.3") + .header("X-Device-Platform", "android") + .header("X-Device-Model", "Nexus 6P") + .header("X-Device-Os-Version", "9") + .send() + .await?; + + if !response.status().is_success() { + return Err(QobuzError::AuthError("Token refresh failed".to_string())); + } + + let tokens: OAuthTokens = response.json().await?; + Ok(tokens) + } + + pub async fn get_user(&self, access_token: &str) -> Result { + let url = format!( + "{}/api.json/0.2/user/get?app_id={}", + self.base_url, self.app_id + ); + + let response = self + .client + .get(&url) + .headers(self.build_auth_headers(Some(access_token))) + .send() + .await?; + + if !response.status().is_success() { + return Err(QobuzError::ApiError("Failed to get user".to_string())); + } + + let user: User = response.json().await?; + Ok(user) + } + + pub async fn get_link_token( + &self, + access_token: &str, + action: &str, + ) -> Result { + let timestamp = self.get_timestamp(); + + let signature = crypto::generate_request_signature( + "link/token", + &[ + ("link_action", action), + ("external_device_id", &self.device_id), + ], + timestamp, + ); + + let url = format!( + "{}/api.json/0.2/link/token?app_id={}&request_ts={}&request_sig={}", + self.base_url, self.app_id, timestamp, signature + ); + + let body = LinkTokenRequest { + link_action: action.to_string(), + external_device_id: self.device_id.clone(), + }; + + let response = self + .client + .post(&url) + .headers(self.build_auth_headers(Some(access_token))) + .json(&body) + .send() + .await?; + + if !response.status().is_success() { + return Err(QobuzError::LinkError( + "Failed to get link token".to_string(), + )); + } + + let link_response: LinkTokenResponse = response.json().await?; + Ok(link_response) + } + + pub async fn get_device_token( + &self, + access_token: &str, + link_token: &str, + link_device_id: &str, + ) -> Result { + let timestamp = self.get_timestamp(); + + let signature = crypto::generate_request_signature( + "link/device/token", + &[ + ("link_token", link_token), + ("link_device_id", link_device_id), + ("external_device_id", &self.device_id), + ], + timestamp, + ); + + let url = format!( + "{}/api.json/0.2/link/device/token?app_id={}&request_ts={}&request_sig={}", + self.base_url, self.app_id, timestamp, signature + ); + + let body = DeviceTokenRequest { + link_token: link_token.to_string(), + link_device_id: link_device_id.to_string(), + external_device_id: self.device_id.clone(), + }; + + let response = self + .client + .post(&url) + .headers(self.build_auth_headers(Some(access_token))) + .json(&body) + .send() + .await?; + + if !response.status().is_success() { + return Err(QobuzError::LinkError( + "Failed to get device token".to_string(), + )); + } + + let device_response: DeviceTokenResponse = response.json().await?; + Ok(device_response) + } + + pub async fn get_qws_token(&self, access_token: &str) -> Result { + let timestamp = self.get_timestamp(); + + let signature = crypto::generate_request_signature("qws/createToken", &[], timestamp); + + let url = format!( + "{}/api.json/0.2/qws/createToken?app_id={}&request_ts={}&request_sig={}", + self.base_url, self.app_id, timestamp, signature + ); + + let response = self + .client + .post(&url) + .headers(self.build_auth_headers(Some(access_token))) + .form(&[("jwt", "jwt_qws")]) + .send() + .await?; + + if !response.status().is_success() { + return Err(QobuzError::ApiError("Failed to get QWS token".to_string())); + } + + let qws_response: QwsTokenResponse = response.json().await?; + Ok(qws_response) + } + + pub async fn get_album(&self, access_token: &str, album_id: &str) -> Result { + let url = format!( + "{}/api.json/0.2/album/get?app_id={}&album_id={}", + self.base_url, self.app_id, album_id + ); + + let response = self + .client + .get(&url) + .headers(self.build_auth_headers(Some(access_token))) + .send() + .await?; + + if !response.status().is_success() { + return Err(QobuzError::ApiError("Failed to get album".to_string())); + } + + let album: Album = response.json().await?; + Ok(album) + } + + pub async fn get_track_url( + &self, + access_token: &str, + track_id: &str, + format_id: u32, + ) -> Result { + let timestamp = self.get_timestamp(); + let format_id_str = format_id.to_string(); + + let signature = crypto::generate_request_signature( + "track/getFileUrl", + &[ + ("format_id", &format_id_str), + ("intent", "stream"), + ("track_id", track_id), + ], + timestamp, + ); + + let url = format!( + "{}/api.json/0.2/track/getFileUrl?app_id={}&track_id={}&format_id={}&intent=stream&request_ts={}&request_sig={}", + self.base_url, self.app_id, track_id, format_id, timestamp, signature + ); + + let response = self + .client + .get(&url) + .headers(self.build_auth_headers(Some(access_token))) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(QobuzError::ApiError(format!( + "Failed to get track URL: {} - {}", + status, body + ))); + } + + #[derive(serde::Deserialize)] + struct TrackUrlResponse { + url: String, + } + + let url_response: TrackUrlResponse = response.json().await?; + Ok(url_response.url) + } + + pub async fn get_track(&self, access_token: &str, track_id: &str) -> Result { + let timestamp = self.get_timestamp(); + + let signature = + crypto::generate_request_signature("track/get", &[("track_id", track_id)], timestamp); + + let url = format!( + "{}/api.json/0.2/track/get?app_id={}&track_id={}&request_ts={}&request_sig={}", + self.base_url, self.app_id, track_id, timestamp, signature + ); + + let response = self + .client + .get(&url) + .headers(self.build_auth_headers(Some(access_token))) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(QobuzError::ApiError(format!( + "Failed to get track: {} - {}", + status, body + ))); + } + + let track: Track = response.json().await?; + Ok(track) + } + + pub async fn search( + &self, + access_token: &str, + query: &str, + search_type: &str, + limit: u32, + offset: u32, + ) -> Result { + let url = format!( + "{}/api.json/0.2/search?app_id={}&query={}&type={}&limit={}&offset={}", + self.base_url, + self.app_id, + urlencoding::encode(query), + search_type, + limit, + offset + ); + + let response = self + .client + .get(&url) + .headers(self.build_auth_headers(Some(access_token))) + .send() + .await?; + + if !response.status().is_success() { + return Err(QobuzError::ApiError("Search failed".to_string())); + } + + let results: serde_json::Value = response.json().await?; + Ok(results) + } +} + +mod urlencoding { + pub fn encode(s: &str) -> String { + let mut result = String::new(); + for c in s.chars() { + match c { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => { + result.push(c); + } + _ => { + for b in c.to_string().as_bytes() { + result.push_str(&format!("%{:02X}", b)); + } + } + } + } + result + } +} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..7883164 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,144 @@ +use crate::api::QobuzApi; +use crate::config::{Config, DeviceLinkCredentials}; +use crate::error::{QobuzError, Result}; +use crate::token::TokenManager; +use crate::types::{DeviceTokenResponse, LinkTokenResponse, User}; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub struct QobuzAuth { + api: QobuzApi, + token_manager: TokenManager, + access_token: Arc>>, +} + +impl QobuzAuth { + pub fn new(config: Config) -> Self { + let api = QobuzApi::new(&config); + let token_manager = TokenManager::new(config.clone()); + + Self { + api, + token_manager, + access_token: Arc::new(Mutex::new(None)), + } + } + + pub async fn login_with_credentials(&self, email: &str, password: &str) -> Result { + let response = self.api.login(email, password).await?; + + let tokens = response.oauth; + self.token_manager.store_tokens(&tokens)?; + + { + let mut token = self.access_token.lock().await; + *token = Some(tokens.access_token.clone()); + } + + Ok(response.user) + } + + pub async fn refresh_access_token(&self) -> Result<()> { + let config = + crate::config::Config::load().map_err(|e| QobuzError::ConfigError(e.to_string()))?; + + if let Some(creds) = config.credentials { + let new_tokens = self.api.refresh_token(&creds.refresh_token).await?; + self.token_manager.store_tokens(&new_tokens)?; + + let mut token = self.access_token.lock().await; + *token = Some(new_tokens.access_token); + } else { + return Err(QobuzError::TokenError( + "No refresh token available".to_string(), + )); + } + + Ok(()) + } + + pub async fn get_valid_token(&self) -> Result { + if let Some(token) = self.access_token.lock().await.clone() { + return Ok(token); + } + + if let Some(tokens) = self.token_manager.load_tokens()? { + let mut token = self.access_token.lock().await; + *token = Some(tokens.access_token.clone()); + return Ok(tokens.access_token); + } + + Err(QobuzError::TokenError( + "No valid token available".to_string(), + )) + } + + pub async fn logout(&self) -> Result<()> { + let mut token = self.access_token.lock().await; + *token = None; + + let mut config = + crate::config::Config::load().map_err(|e| QobuzError::ConfigError(e.to_string()))?; + config.clear_credentials()?; + + Ok(()) + } + + pub async fn start_device_linking(&self) -> Result { + let token = self.get_valid_token().await?; + let response = self.api.get_link_token(&token, "signIn").await?; + Ok(response) + } + + pub async fn get_device_token( + &self, + link_token: &str, + link_device_id: &str, + ) -> Result { + let token = self.get_valid_token().await?; + let response = self + .api + .get_device_token(&token, link_token, link_device_id) + .await?; + + if let Some(oauth) = &response.oauth { + let creds = DeviceLinkCredentials { + device_access_token: oauth.access_token.clone(), + device_refresh_token: oauth.refresh_token.clone(), + device_id: self.api.get_device_id().to_string(), + link_device_id: link_device_id.to_string(), + expires_at: None, + }; + self.token_manager.store_device_link_credentials(creds)?; + } + + Ok(response) + } + + pub async fn check_device_link_status( + &self, + link_token: &str, + link_device_id: &str, + ) -> Result { + self.get_device_token(link_token, link_device_id).await + } + + pub fn get_device_id(&self) -> &str { + self.api.get_device_id() + } + + pub fn get_device_name(&self) -> &str { + self.api.get_device_name() + } + + pub async fn is_linked(&self) -> bool { + self.token_manager + .load_device_link_credentials() + .map(|c| c.is_some()) + .unwrap_or(false) + } + + pub async fn unlink_device(&self) -> Result<()> { + self.token_manager.clear_device_link_credentials() + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..2d8f122 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,102 @@ +use crate::crypto; +use crate::error::{QobuzError, Result}; +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub app_id: String, + pub device_id: String, + pub session_id: String, + pub device_name: String, + pub cache_dir: PathBuf, + pub config_dir: PathBuf, + pub credentials: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredCredentials { + pub access_token: String, + pub refresh_token: String, + pub user_id: Option, + pub expires_at: Option, + pub email: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceLinkCredentials { + pub device_access_token: String, + pub device_refresh_token: String, + pub device_id: String, + pub link_device_id: String, + pub expires_at: Option, +} + +impl Config { + pub fn new(device_name: String) -> Result { + let proj_dirs = ProjectDirs::from("com", "qobuz", "qobuzd").ok_or_else(|| { + QobuzError::ConfigError("Could not determine config directory".into()) + })?; + + let config_dir = proj_dirs.config_dir().to_path_buf(); + let cache_dir = proj_dirs.cache_dir().to_path_buf(); + + std::fs::create_dir_all(&config_dir)?; + std::fs::create_dir_all(&cache_dir)?; + + let device_id = crypto::generate_device_id(); + let session_id = crypto::generate_session_id(); + + Ok(Self { + app_id: "312369995".to_string(), + device_id, + session_id, + device_name, + cache_dir, + config_dir, + credentials: None, + }) + } + + pub fn load() -> Result { + let proj_dirs = ProjectDirs::from("com", "qobuz", "qobuzd").ok_or_else(|| { + QobuzError::ConfigError("Could not determine config directory".into()) + })?; + + let config_path = proj_dirs.config_dir().join("config.json"); + + if config_path.exists() { + let content = std::fs::read_to_string(&config_path)?; + let config: Config = serde_json::from_str(&content)?; + Ok(config) + } else { + Self::new("qobuzd".to_string()) + } + } + + pub fn save(&self) -> Result<()> { + let config_path = self.config_dir.join("config.json"); + let content = serde_json::to_string_pretty(self)?; + std::fs::write(config_path, content)?; + Ok(()) + } + + pub fn store_credentials(&mut self, creds: StoredCredentials) -> Result<()> { + self.credentials = Some(creds); + self.save() + } + + pub fn clear_credentials(&mut self) -> Result<()> { + self.credentials = None; + self.save() + } + + pub fn credentials_path(&self) -> PathBuf { + self.config_dir.join("credentials.enc") + } + + pub fn device_link_credentials_path(&self) -> PathBuf { + self.config_dir.join("device_link.json") + } +} diff --git a/src/connect.proto b/src/connect.proto new file mode 100644 index 0000000..2965230 --- /dev/null +++ b/src/connect.proto @@ -0,0 +1,145 @@ +syntax = "proto3"; + +package qobuz.connect; + +message DeviceInfo { + string device_id = 1; + string device_name = 2; + string device_type = 3; + string firmware_version = 4; + string ip_address = 5; + int32 port = 6; + Capabilities capabilities = 7; +} + +message Capabilities { + bool supports_video = 1; + bool supports_audio = 2; + bool supports_image = 3; + repeated string supported_formats = 4; +} + +message ControlMessage { + string message_id = 1; + MessageType type = 2; + oneof payload { + PlayRequest play = 10; + PauseRequest pause = 11; + StopRequest stop = 12; + SeekRequest seek = 13; + VolumeRequest volume = 14; + GetStatusRequest get_status = 15; + LoadRequest load = 16; + } +} + +enum MessageType { + UNKNOWN = 0; + PLAY = 1; + PAUSE = 2; + STOP = 3; + SEEK = 4; + VOLUME = 5; + GET_STATUS = 6; + LOAD = 7; + STATUS = 100; + ERROR = 101; + CONNECTED = 102; + DISCONNECTED = 103; +} + +message PlayRequest { + string track_url = 1; + int64 position_ms = 2; +} + +message PauseRequest {} + +message StopRequest {} + +message SeekRequest { + int64 position_ms = 1; +} + +message VolumeRequest { + int32 volume = 1; // 0-100 + bool mute = 2; +} + +message GetStatusRequest {} + +message LoadRequest { + string track_id = 1; + string album_id = 2; + int32 format_id = 3; + int64 position_ms = 4; +} + +message ControlResponse { + string message_id = 1; + MessageType type = 2; + bool success = 3; + string error_message = 4; + oneof payload { + StatusResponse status = 10; + } +} + +message StatusResponse { + PlaybackState state = 1; + string track_id = 2; + string album_id = 3; + string track_url = 4; + int64 position_ms = 5; + int64 duration_ms = 6; + int32 volume = 7; + bool muted = 8; + TrackInfo track_info = 9; +} + +enum PlaybackState { + IDLE = 0; + LOADING = 1; + PLAYING = 2; + PAUSED = 3; + BUFFERING = 4; + ERROR = 5; +} + +message TrackInfo { + string title = 1; + string artist = 2; + string album = 3; + string album_artist = 4; + int32 track_number = 5; + int32 disc_number = 6; + int64 duration_ms = 7; + string artwork_url = 8; + string format = 9; + int32 bit_depth = 10; + int32 sample_rate = 11; +} + +message LinkRequest { + string device_id = 1; + string device_name = 2; + string device_type = 3; +} + +message LinkResponse { + bool success = 1; + string link_token = 2; + string error_message = 3; +} + +message StreamRequest { + string track_id = 1; + int32 format_id = 2; + int64 position_ms = 3; +} + +message StreamResponse { + bool success = 1; + string stream_url = 2; + string error_message = 3; +} diff --git a/src/crypto.rs b/src/crypto.rs new file mode 100644 index 0000000..4716228 --- /dev/null +++ b/src/crypto.rs @@ -0,0 +1,87 @@ +use md5::{Digest, Md5}; +use sha1::Sha1; + +const APP_SECRET: &str = "e79f8b9be485692b0e5f9dd895826368"; + +pub fn md5_hash(input: &str) -> String { + let mut hasher = Md5::new(); + hasher.update(input.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +pub fn sha1_hash(input: &str) -> String { + let mut hasher = Sha1::new(); + hasher.update(input.as_bytes()); + hex::encode(hasher.finalize()) +} + +pub fn generate_request_signature( + endpoint: &str, + params: &[(&str, &str)], + timestamp: i64, +) -> String { + let endpoint_clean = endpoint.replace("/", ""); + + let mut param_str = params + .iter() + .map(|(k, v)| format!("{}{}", k, v)) + .collect::>(); + param_str.sort(); + + let data = format!( + "{}{}{}{}", + endpoint_clean, + param_str.join(""), + timestamp, + APP_SECRET + ); + md5_hash(&data) +} + +pub fn generate_login_signature( + username: &str, + password: &str, + _app_id: &str, + timestamp: i64, +) -> String { + generate_request_signature( + "oauth2/login", + &[("username", username), ("password", password)], + timestamp, + ) +} + +pub fn generate_device_id() -> String { + use rand::Rng; + let mut rng = rand::thread_rng(); + let bytes: [u8; 16] = rng.gen(); + bytes + .iter() + .map(|b| format!("{:02x}", b)) + .collect::() +} + +pub fn generate_session_id() -> String { + uuid::Uuid::new_v4().to_string() +} + +pub fn generate_client_id() -> String { + uuid::Uuid::new_v4().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_md5_hash() { + let result = md5_hash("test"); + assert_eq!(result, "098f6bcd4621d373cade4e832627b4f6"); + } + + #[test] + fn test_request_signature() { + let sig = generate_request_signature("test", &[("a", "1"), ("b", "2")], 1234567890); + assert_eq!(sig.len(), 32); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..09d79d5 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,33 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum QobuzError { + #[error("Authentication failed: {0}")] + AuthError(String), + + #[error("API request failed: {0}")] + ApiError(String), + + #[error("Network error: {0}")] + NetworkError(#[from] reqwest::Error), + + #[error("JSON parse error: {0}")] + JsonError(#[from] serde_json::Error), + + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), + + #[error("Token error: {0}")] + TokenError(String), + + #[error("Configuration error: {0}")] + ConfigError(String), + + #[error("Device linking error: {0}")] + LinkError(String), + + #[error("Crypto error: {0}")] + CryptoError(String), +} + +pub type Result = std::result::Result; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..775a31a --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,9 @@ +pub mod api; +pub mod auth; +pub mod config; +pub mod crypto; +pub mod error; +pub mod player; +pub mod qconnect; +pub mod token; +pub mod types; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..57570dc --- /dev/null +++ b/src/main.rs @@ -0,0 +1,228 @@ +use anyhow::Result; +use clap::{Parser, Subcommand}; +use std::sync::Arc; +use tokio::sync::Mutex; +use tracing::{error, info, Level}; +use tracing_subscriber::FmtSubscriber; + +use qobuzd::api::QobuzApi; +use qobuzd::auth::QobuzAuth; +use qobuzd::config::Config; +use qobuzd::qconnect::QConnect; + +#[derive(Parser)] +#[command(name = "qobuzd")] +#[command(about = "Qobuz Connect client for Linux")] +struct Cli { + #[arg(short, long, default_value = "qobuzd")] + name: String, + + #[command(subcommand)] + command: Commands, + + #[arg(short, long, default_value = "info")] + log_level: String, +} + +#[derive(Subcommand)] +enum Commands { + Login { + #[arg(short, long)] + email: String, + #[arg(short, long)] + password: String, + }, + Logout, + Status, + User, + Search { + #[arg(short, long)] + query: String, + #[arg(short, long, default_value = "albums")] + search_type: String, + }, + Album { + #[arg(short, long)] + album_id: String, + }, + Stream { + #[arg(short, long)] + track_id: String, + #[arg(short, long, default_value = "5")] + format_id: u32, + }, + Serve, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + let level = match cli.log_level.as_str() { + "debug" | "trace" => Level::DEBUG, + "warn" => Level::WARN, + "error" => Level::ERROR, + _ => Level::INFO, + }; + tracing::subscriber::set_global_default( + FmtSubscriber::builder() + .with_max_level(level) + .with_target(false) + .with_thread_ids(false) + .with_file(true) + .with_line_number(true) + .finish(), + ) + .expect("failed to set subscriber"); + + let config = Config::new(cli.name.clone())?; + let auth = Arc::new(Mutex::new(QobuzAuth::new(config.clone()))); + + match cli.command { + Commands::Login { email, password } => { + println!("Logging in as {}...", email); + let auth_guard = auth.lock().await; + match auth_guard.login_with_credentials(&email, &password).await { + Ok(user) => { + println!( + "Logged in as: {} (id: {})", + user.display_name.unwrap_or_default(), + user.id + ); + } + Err(e) => { + error!("Login failed: {}", e); + std::process::exit(1); + } + } + } + + Commands::Logout => { + let guard = auth.lock().await; + guard.logout().await?; + println!("Logged out"); + } + + Commands::Status => { + let guard = auth.lock().await; + if guard.is_linked().await { + println!("Device is linked"); + } else { + println!("Device is not linked"); + } + } + + Commands::User => { + let guard = auth.lock().await; + let token = guard.get_valid_token().await?; + drop(guard); + let api = QobuzApi::new(&config); + match api.get_user(&token).await { + Ok(user) => { + println!("User: {}", user.display_name.unwrap_or_default()); + println!("Email: {}", user.email); + if let Some(sub) = &user.subscription { + println!("Subscription: {}", sub.offer); + } + } + Err(e) => { + error!("Failed: {}", e); + std::process::exit(1); + } + } + } + + Commands::Search { query, search_type } => { + let guard = auth.lock().await; + let token = guard.get_valid_token().await?; + drop(guard); + let api = QobuzApi::new(&config); + match api.search(&token, &query, &search_type, 10, 0).await { + Ok(results) => { + println!("{}", serde_json::to_string_pretty(&results)?); + } + Err(e) => { + error!("Search failed: {}", e); + std::process::exit(1); + } + } + } + + Commands::Album { album_id } => { + let guard = auth.lock().await; + let token = guard.get_valid_token().await?; + drop(guard); + let api = QobuzApi::new(&config); + match api.get_album(&token, &album_id).await { + Ok(album) => { + println!("Album: {}", album.title); + if let Some(artists) = &album.artists { + if let Some(a) = artists.first() { + println!("Artist: {}", a.name); + } + } + println!("Tracks: {}", album.track_count.unwrap_or(0)); + } + Err(e) => { + error!("Failed: {}", e); + std::process::exit(1); + } + } + } + + Commands::Stream { + track_id, + format_id, + } => { + let guard = auth.lock().await; + let token = guard.get_valid_token().await?; + drop(guard); + let api = QobuzApi::new(&config); + match api.get_track_url(&token, &track_id, format_id).await { + Ok(url) => println!("Stream URL: {}", url), + Err(e) => { + error!("Failed: {}", e); + std::process::exit(1); + } + } + } + + Commands::Serve => { + let guard = auth.lock().await; + let token = match guard.get_valid_token().await { + Ok(t) => t, + Err(e) => { + error!("Not logged in: {}", e); + println!("Run 'qobuzd login' first."); + std::process::exit(1); + } + }; + drop(guard); + + let device_id = config.device_id.clone(); + let device_name = config.device_name.clone(); + + println!("Starting QobuzD as '{}'...", device_name); + + let mut qconnect = QConnect::start(token, device_id, device_name); + + println!("QobuzD is running. Select it in the Qobuz app to play music."); + println!("Press Ctrl+C to stop."); + + // Just forward commands to stdout for visibility + tokio::spawn(async move { + loop { + if let Some(cmd) = qconnect.poll_command() { + info!("Command received: {:?}", cmd); + } + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + }); + + tokio::signal::ctrl_c().await?; + println!("\nStopped."); + } + } + + Ok(()) +} diff --git a/src/player.rs b/src/player.rs new file mode 100644 index 0000000..b054a7a --- /dev/null +++ b/src/player.rs @@ -0,0 +1,468 @@ +use std::io::{self, Read, Seek, SeekFrom}; +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU64, AtomicU8, Ordering}; +use std::sync::Arc; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use symphonia::core::audio::SampleBuffer; +use symphonia::core::codecs::DecoderOptions; +use symphonia::core::formats::FormatOptions; +use symphonia::core::io::{MediaSource, MediaSourceStream}; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; +use tokio::sync::mpsc; +use tracing::{error, info, warn}; + +#[derive(Debug)] +pub enum PlayerCommand { + Play { + url: String, + track_id: i32, + queue_item_id: i32, + duration_ms: u64, + }, + Resume, + Pause, + Stop, + SetVolume(u8), +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum PlayerState { + Stopped, + Playing, + Paused, +} + +pub struct PlayerStatus { + pub state: PlayerState, + pub position_ms: u64, + pub duration_ms: u64, + pub track_id: i32, + pub queue_item_id: i32, + pub volume: u8, +} + +struct SharedState { + playing: AtomicBool, + paused: AtomicBool, + stop_signal: AtomicBool, + generation: AtomicU64, // incremented on each Play, used to avoid old threads clobbering state + position_ms: AtomicU64, + duration_ms: AtomicU64, + volume: AtomicU8, + track_id: AtomicI32, + queue_item_id: AtomicI32, +} + +pub struct AudioPlayer { + cmd_tx: mpsc::UnboundedSender, + shared: Arc, +} + +impl AudioPlayer { + pub fn new() -> Self { + let shared = Arc::new(SharedState { + playing: AtomicBool::new(false), + paused: AtomicBool::new(false), + stop_signal: AtomicBool::new(false), + generation: AtomicU64::new(0), + position_ms: AtomicU64::new(0), + duration_ms: AtomicU64::new(0), + volume: AtomicU8::new(100), + track_id: AtomicI32::new(0), + queue_item_id: AtomicI32::new(0), + }); + + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::(); + let shared_clone = shared.clone(); + + std::thread::spawn(move || { + player_thread(cmd_rx, shared_clone); + }); + + Self { cmd_tx, shared } + } + + pub fn send(&self, cmd: PlayerCommand) { + let _ = self.cmd_tx.send(cmd); + } + + pub fn status(&self) -> PlayerStatus { + let playing = self.shared.playing.load(Ordering::Relaxed); + let paused = self.shared.paused.load(Ordering::Relaxed); + let state = if playing && !paused { + PlayerState::Playing + } else if playing && paused { + PlayerState::Paused + } else { + PlayerState::Stopped + }; + + PlayerStatus { + state, + position_ms: self.shared.position_ms.load(Ordering::Relaxed), + duration_ms: self.shared.duration_ms.load(Ordering::Relaxed), + track_id: self.shared.track_id.load(Ordering::Relaxed), + queue_item_id: self.shared.queue_item_id.load(Ordering::Relaxed), + volume: self.shared.volume.load(Ordering::Relaxed), + } + } +} + +fn player_thread(mut cmd_rx: mpsc::UnboundedReceiver, shared: Arc) { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to build tokio runtime for player"); + + loop { + let cmd = match rt.block_on(cmd_rx.recv()) { + Some(c) => c, + None => break, + }; + + match cmd { + PlayerCommand::Play { + url, + track_id, + queue_item_id, + duration_ms, + } => { + // Stop any current playback + shared.stop_signal.store(true, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(100)); + shared.stop_signal.store(false, Ordering::SeqCst); + let gen = shared.generation.fetch_add(1, Ordering::SeqCst) + 1; + shared.paused.store(false, Ordering::SeqCst); + shared.position_ms.store(0, Ordering::SeqCst); + shared.duration_ms.store(duration_ms, Ordering::SeqCst); + shared.track_id.store(track_id, Ordering::SeqCst); + shared.queue_item_id.store(queue_item_id, Ordering::SeqCst); + shared.playing.store(true, Ordering::SeqCst); + + let shared_play = shared.clone(); + std::thread::spawn(move || { + if let Err(e) = play_stream(&url, shared_play, gen) { + error!("Playback error: {}", e); + } + }); + } + PlayerCommand::Pause => { + shared.paused.store(true, Ordering::SeqCst); + } + PlayerCommand::Resume => { + shared.paused.store(false, Ordering::SeqCst); + } + PlayerCommand::Stop => { + shared.stop_signal.store(true, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(100)); + shared.stop_signal.store(false, Ordering::SeqCst); + shared.playing.store(false, Ordering::SeqCst); + shared.paused.store(false, Ordering::SeqCst); + shared.track_id.store(0, Ordering::SeqCst); + shared.queue_item_id.store(0, Ordering::SeqCst); + } + PlayerCommand::SetVolume(vol) => { + shared.volume.store(vol, Ordering::SeqCst); + } + } + } +} + +// --------------------------------------------------------------------------- +// HTTP streaming source (streams from network, buffers first 512KB for seeks) +// --------------------------------------------------------------------------- + +const HEAD_SIZE: usize = 512 * 1024; + +struct HttpStreamSource { + reader: reqwest::blocking::Response, + head: Vec, + reader_pos: u64, + pos: u64, + content_length: Option, +} + +impl HttpStreamSource { + fn new(response: reqwest::blocking::Response, content_length: Option) -> Self { + Self { + reader: response, + head: Vec::new(), + reader_pos: 0, + pos: 0, + content_length, + } + } +} + +impl Read for HttpStreamSource { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let pos = self.pos as usize; + + if pos < self.head.len() { + let avail = self.head.len() - pos; + let n = buf.len().min(avail); + buf[..n].copy_from_slice(&self.head[pos..pos + n]); + self.pos += n as u64; + return Ok(n); + } + + let n = self.reader.read(buf)?; + if n > 0 { + if self.reader_pos < HEAD_SIZE as u64 { + let capacity: usize = HEAD_SIZE.saturating_sub(self.head.len()); + let to_buf = n.min(capacity); + if to_buf > 0 { + self.head.extend_from_slice(&buf[..to_buf]); + } + } + self.reader_pos += n as u64; + self.pos += n as u64; + } + Ok(n) + } +} + +impl Seek for HttpStreamSource { + fn seek(&mut self, from: SeekFrom) -> io::Result { + let cl = self.content_length.unwrap_or(u64::MAX); + let target: u64 = match from { + SeekFrom::Start(n) => n, + SeekFrom::End(n) if n < 0 => cl.saturating_sub((-n) as u64), + SeekFrom::End(_) => cl, + SeekFrom::Current(n) if n >= 0 => self.pos.saturating_add(n as u64), + SeekFrom::Current(n) => self.pos.saturating_sub((-n) as u64), + }; + + if target == self.pos { + return Ok(self.pos); + } + + if target < self.reader_pos { + if target < self.head.len() as u64 { + self.pos = target; + return Ok(self.pos); + } + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "backward seek past head buffer", + )); + } + + // Forward seek: read and discard + let mut remaining = target - self.reader_pos; + while remaining > 0 { + let mut discard = [0u8; 8192]; + let want = (remaining as usize).min(discard.len()); + match self.reader.read(&mut discard[..want]) { + Ok(0) => break, + Ok(n) => { + if self.reader_pos < HEAD_SIZE as u64 { + let capacity: usize = HEAD_SIZE.saturating_sub(self.head.len()); + let to_buf = n.min(capacity); + if to_buf > 0 { + self.head.extend_from_slice(&discard[..to_buf]); + } + } + self.reader_pos += n as u64; + remaining -= n as u64; + } + Err(e) => return Err(e), + } + } + self.pos = self.reader_pos; + Ok(self.pos) + } +} + +impl MediaSource for HttpStreamSource { + fn is_seekable(&self) -> bool { + true + } + + fn byte_len(&self) -> Option { + self.content_length + } +} + +// --------------------------------------------------------------------------- +// Streaming playback +// --------------------------------------------------------------------------- + +fn play_stream(url: &str, shared: Arc, generation: u64) -> anyhow::Result<()> { + info!("Streaming audio..."); + let response = reqwest::blocking::get(url)?; + let content_length = response.content_length(); + let source = HttpStreamSource::new(response, content_length); + let mss = MediaSourceStream::new(Box::new(source), Default::default()); + + let hint = Hint::new(); + let probed = symphonia::default::get_probe().format( + &hint, + mss, + &FormatOptions::default(), + &MetadataOptions::default(), + )?; + + let mut format = probed.format; + let track = format + .tracks() + .iter() + .find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL) + .ok_or_else(|| anyhow::anyhow!("no audio track"))? + .clone(); + + let track_id = track.id; + let sample_rate = track.codec_params.sample_rate.unwrap_or(44100); + let channels = track.codec_params.channels.map(|c| c.count()).unwrap_or(2); + + // Update duration from codec if available (and not already set from API) + if shared.duration_ms.load(Ordering::Relaxed) == 0 { + if let Some(n_frames) = track.codec_params.n_frames { + let dur_ms = (n_frames as f64 / sample_rate as f64 * 1000.0) as u64; + shared.duration_ms.store(dur_ms, Ordering::SeqCst); + } + } + + let mut decoder = + symphonia::default::get_codecs().make(&track.codec_params, &DecoderOptions::default())?; + + // Set up cpal output + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or_else(|| anyhow::anyhow!("no audio output device"))?; + info!("Audio output: {}", device.name().unwrap_or_default()); + + let (sample_tx, sample_rx) = std::sync::mpsc::sync_channel::>(32); + + let config = cpal::StreamConfig { + channels: channels as u16, + sample_rate: cpal::SampleRate(sample_rate), + buffer_size: cpal::BufferSize::Default, + }; + + let shared_out = shared.clone(); + let mut ring_buf: Vec = Vec::new(); + let mut ring_pos = 0; + + let stream = device.build_output_stream( + &config, + move |out: &mut [f32], _: &cpal::OutputCallbackInfo| { + let vol = shared_out.volume.load(Ordering::Relaxed) as f32 / 100.0; + let paused = shared_out.paused.load(Ordering::Relaxed); + + for sample in out.iter_mut() { + if paused { + *sample = 0.0; + continue; + } + if ring_pos >= ring_buf.len() { + match sample_rx.try_recv() { + Ok(buf) => { + ring_buf = buf; + ring_pos = 0; + } + Err(_) => { + *sample = 0.0; + continue; + } + } + } + *sample = ring_buf[ring_pos] * vol; + ring_pos += 1; + } + }, + |err| error!("cpal error: {}", err), + None, + )?; + + stream.play()?; + info!("Playback started ({}Hz, {}ch)", sample_rate, channels); + + loop { + // Check if superseded by a newer Play command (generation changed) + if shared.generation.load(Ordering::SeqCst) != generation { + info!("Playback superseded by newer generation"); + break; + } + + if shared.stop_signal.load(Ordering::Relaxed) { + info!("Playback stopped by signal"); + break; + } + + while shared.paused.load(Ordering::Relaxed) { + std::thread::sleep(std::time::Duration::from_millis(50)); + if shared.stop_signal.load(Ordering::Relaxed) + || shared.generation.load(Ordering::SeqCst) != generation + { + break; + } + } + if shared.stop_signal.load(Ordering::Relaxed) + || shared.generation.load(Ordering::SeqCst) != generation + { + break; + } + + let packet = match format.next_packet() { + Ok(p) => p, + Err(symphonia::core::errors::Error::IoError(ref e)) + if e.kind() == std::io::ErrorKind::UnexpectedEof => + { + info!("Playback finished (gen={})", generation); + break; + } + Err(symphonia::core::errors::Error::ResetRequired) => { + decoder.reset(); + continue; + } + Err(e) => { + warn!("Packet error: {}", e); + break; + } + }; + + if packet.track_id() != track_id { + continue; + } + + // Update position from packet timestamp (ts is in codec timebase units = samples) + let pos_ms = (packet.ts() as f64 / sample_rate as f64 * 1000.0) as u64; + shared.position_ms.store(pos_ms, Ordering::Relaxed); + + let decoded = match decoder.decode(&packet) { + Ok(d) => d, + Err(symphonia::core::errors::Error::DecodeError(e)) => { + warn!("Decode error: {}", e); + continue; + } + Err(e) => { + warn!("Decode error: {}", e); + break; + } + }; + + let spec = *decoded.spec(); + let n_frames = decoded.frames(); + let mut sample_buf = SampleBuffer::::new(n_frames as u64, spec); + sample_buf.copy_interleaved_ref(decoded); + + if sample_tx.send(sample_buf.samples().to_vec()).is_err() { + break; + } + } + + // Let audio buffer drain + std::thread::sleep(std::time::Duration::from_millis(300)); + drop(stream); + + // Only clear playing state if we're still the current generation + // (if generation changed, a new Play command has taken over — don't clobber its state) + if shared.generation.load(Ordering::SeqCst) == generation { + shared.playing.store(false, Ordering::SeqCst); + shared.paused.store(false, Ordering::SeqCst); + } + Ok(()) +} diff --git a/src/qconnect.rs b/src/qconnect.rs new file mode 100644 index 0000000..f346fde --- /dev/null +++ b/src/qconnect.rs @@ -0,0 +1,1148 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{bail, Result}; +use futures_util::{SinkExt, StreamExt}; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::Message; +use tracing::{debug, error, info, warn}; + +use crate::api::QobuzApi; +use crate::config::Config; +use crate::player::{AudioPlayer, PlayerCommand, PlayerState}; + +// --------------------------------------------------------------------------- +// Protobuf helpers (hand-rolled, matching the qconnect.proto schema) +// --------------------------------------------------------------------------- + +fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64 +} + +fn encode_varint(mut val: u64) -> Vec { + let mut buf = Vec::with_capacity(10); + loop { + let mut byte = (val & 0x7F) as u8; + val >>= 7; + if val != 0 { + byte |= 0x80; + } + buf.push(byte); + if val == 0 { + break; + } + } + buf +} + +fn encode_field_varint(field: u32, val: u64) -> Vec { + let tag = (field as u64) << 3; + let mut out = encode_varint(tag); + out.extend(encode_varint(val)); + out +} + +fn encode_field_bytes(field: u32, data: &[u8]) -> Vec { + let tag = ((field as u64) << 3) | 2; + let mut out = encode_varint(tag); + out.extend(encode_varint(data.len() as u64)); + out.extend_from_slice(data); + out +} + +fn encode_field_string(field: u32, s: &str) -> Vec { + encode_field_bytes(field, s.as_bytes()) +} + +fn encode_field_fixed64(field: u32, val: u64) -> Vec { + let tag = ((field as u64) << 3) | 1; + let mut out = encode_varint(tag); + out.extend_from_slice(&val.to_le_bytes()); + out +} + +fn decode_varint(data: &[u8]) -> Option<(u64, usize)> { + let mut val: u64 = 0; + let mut shift = 0; + for (i, &byte) in data.iter().enumerate() { + val |= ((byte & 0x7F) as u64) << shift; + if byte & 0x80 == 0 { + return Some((val, i + 1)); + } + shift += 7; + if shift >= 64 { + return None; + } + } + None +} + +/// Parsed protobuf field: (field_number, wire_type, raw_data). +/// For varint (wire_type 0), data is the re-encoded varint bytes. +/// For length-delimited (wire_type 2), data is the payload bytes. +/// For fixed64 (wire_type 1), data is the 8 raw bytes. +fn parse_fields(data: &[u8]) -> Vec<(u32, u8, Vec)> { + let mut fields = Vec::new(); + let mut pos = 0; + while pos < data.len() { + let (tag, n) = match decode_varint(&data[pos..]) { + Some(v) => v, + None => break, + }; + pos += n; + let field_number = (tag >> 3) as u32; + let wire_type = (tag & 0x07) as u8; + + match wire_type { + 0 => { + // Varint + let (val, n) = match decode_varint(&data[pos..]) { + Some(v) => v, + None => break, + }; + pos += n; + fields.push((field_number, wire_type, val.to_le_bytes().to_vec())); + } + 1 => { + // Fixed64 + if pos + 8 > data.len() { + break; + } + fields.push((field_number, wire_type, data[pos..pos + 8].to_vec())); + pos += 8; + } + 2 => { + // Length-delimited + let (len, n) = match decode_varint(&data[pos..]) { + Some(v) => v, + None => break, + }; + pos += n; + let len = len as usize; + if pos + len > data.len() { + break; + } + fields.push((field_number, wire_type, data[pos..pos + len].to_vec())); + pos += len; + } + 5 => { + // Fixed32 + if pos + 4 > data.len() { + break; + } + fields.push((field_number, wire_type, data[pos..pos + 4].to_vec())); + pos += 4; + } + _ => break, + } + } + fields +} + +fn get_varint_field(fields: &[(u32, u8, Vec)], field_num: u32) -> Option { + for (num, wt, data) in fields { + if *num == field_num && *wt == 0 { + let mut val: u64 = 0; + for (i, &b) in data.iter().enumerate().take(8) { + val |= (b as u64) << (i * 8); + } + return Some(val); + } + } + None +} + +fn get_fixed32_field(fields: &[(u32, u8, Vec)], field_num: u32) -> Option { + for (num, wt, data) in fields { + if *num == field_num && *wt == 5 && data.len() >= 4 { + return Some(u32::from_le_bytes([data[0], data[1], data[2], data[3]])); + } + } + None +} + +fn get_bytes_field<'a>(fields: &'a [(u32, u8, Vec)], field_num: u32) -> Option<&'a [u8]> { + for (num, wt, data) in fields { + if *num == field_num && *wt == 2 { + return Some(data.as_slice()); + } + } + None +} + +// --------------------------------------------------------------------------- +// WebSocket frame layer (outer transport framing, NOT protobuf) +// --------------------------------------------------------------------------- + +fn build_frame(frame_type: u8, body: &[u8]) -> Vec { + let mut out = vec![frame_type]; + out.extend(encode_varint(body.len() as u64)); + out.extend_from_slice(body); + out +} + +fn decode_frame(data: &[u8], pos: &mut usize) -> Option<(u8, Vec)> { + if *pos >= data.len() { + return None; + } + let frame_type = data[*pos]; + *pos += 1; + let (len_val, n) = decode_varint(&data[*pos..])?; + *pos += n; + let len = len_val as usize; + if *pos + len > data.len() { + return None; + } + let payload = data[*pos..*pos + len].to_vec(); + *pos += len; + Some((frame_type, payload)) +} + +fn decode_all_frames(data: &[u8]) -> Vec<(u8, Vec)> { + let mut out = Vec::new(); + let mut pos = 0; + while pos < data.len() { + match decode_frame(data, &mut pos) { + Some(v) => out.push(v), + None => break, + } + } + out +} + +// --------------------------------------------------------------------------- +// Frame builders +// --------------------------------------------------------------------------- + +fn build_auth_frame(msg_id: u64, jwt: &str) -> Vec { + let mut body = encode_field_varint(1, msg_id); + body.extend(encode_field_string(3, jwt)); + build_frame(1, &body) +} + +fn build_subscribe_frame(msg_id: u64) -> Vec { + let mut body = encode_field_varint(1, msg_id); + body.extend(encode_field_varint(3, 1)); + build_frame(2, &body) +} + +fn build_payload_frame(msg_id: u64, qc_data: &[u8]) -> Vec { + let mut body = encode_field_varint(1, msg_id); + body.extend(encode_field_varint(2, now_millis())); + body.extend(encode_field_varint(3, 1)); + body.extend(encode_field_bytes(5, &[0x02])); + body.extend(encode_field_bytes(7, qc_data)); + build_frame(6, &body) +} + +// --------------------------------------------------------------------------- +// QConnect message builders +// --------------------------------------------------------------------------- + +/// Wraps a QConnect message (field 1 = message_type, field N = payload) +/// inside a field-3 container, as the protocol expects. +fn build_qconnect_message(message_type: u32, payload: &[u8]) -> Vec { + let mut inner = encode_field_varint(1, message_type as u64); + inner.extend(encode_field_bytes(message_type, payload)); + encode_field_bytes(3, &inner) +} + +fn uuid_to_bytes(uuid_str: &str) -> Vec { + uuid::Uuid::parse_str(uuid_str) + .map(|u| u.as_bytes().to_vec()) + .unwrap_or_else(|_| uuid_str.as_bytes().to_vec()) +} + +fn build_device_info(device_uuid: &str, device_name: &str) -> Vec { + let mut out = Vec::new(); + out.extend(encode_field_bytes(1, &uuid_to_bytes(device_uuid))); // device_uuid + out.extend(encode_field_string(2, device_name)); // friendly_name + out.extend(encode_field_string(3, "QobuzD")); // brand + out.extend(encode_field_string(4, "Linux")); // model + out.extend(encode_field_string(5, device_uuid)); // serial_number + out.extend(encode_field_varint(6, 5)); // type = COMPUTER(5) + // capabilities: field 1=min_audio_quality(MP3=1), field 2=max_audio_quality(HIRES_LEVEL3=5), field 3=volume_remote_control(ALLOWED=2) + let mut caps = encode_field_varint(1, 1); + caps.extend(encode_field_varint(2, 5)); + caps.extend(encode_field_varint(3, 2)); + out.extend(encode_field_bytes(7, &caps)); + out.extend(encode_field_string(8, "qobuzd-0.1.0")); // software_version + out +} + +/// CTRL_SRVR_JOIN_SESSION (61): controller asks server to create/join session. +fn msg_ctrl_join_session(device_uuid: &str, device_name: &str) -> Vec { + let device_info = build_device_info(device_uuid, device_name); + let payload = encode_field_bytes(2, &device_info); + build_qconnect_message(61, &payload) +} + +/// RNDR_SRVR_JOIN_SESSION (21): renderer joins an existing session. +fn msg_renderer_join_session(device_uuid: &str, device_name: &str, session_uuid: &[u8]) -> Vec { + let device_info = build_device_info(device_uuid, device_name); + let initial_state = build_renderer_state(1, 2, 0, 0, -1, -1); // stopped, buffer_state=OK(2) + let mut payload = Vec::new(); + payload.extend(encode_field_bytes(1, session_uuid)); + payload.extend(encode_field_bytes(2, &device_info)); + payload.extend(encode_field_bytes(4, &initial_state)); + payload.extend(encode_field_varint(5, 1)); // is_active = true + build_qconnect_message(21, &payload) +} + +/// Build a RendererState protobuf. +/// buffer_state: 1=BUFFERING, 2=OK (per common.proto BufferState enum) +/// Encode a signed int32 as a protobuf varint field (sign-extended to 64 bits, matching proto int32 encoding). +fn encode_field_int32(field: u32, val: i32) -> Vec { + let tag = (field as u64) << 3; + let mut out = encode_varint(tag); + // Protobuf int32 sign-extends to 64 bits: -1 becomes 0xFFFFFFFFFFFFFFFF (10-byte varint) + out.extend(encode_varint(val as i64 as u64)); + out +} + +fn build_renderer_state( + playing_state: u64, + buffer_state: u64, + position_ms: u64, + duration_ms: u64, + queue_item_id: i32, + next_queue_item_id: i32, +) -> Vec { + let mut out = Vec::new(); + out.extend(encode_field_varint(1, playing_state)); // field 1: playing_state + out.extend(encode_field_varint(2, buffer_state)); // field 2: buffer_state + // field 3: current_position (PlaybackPosition: field 1=timestamp fixed64, field 2=value ms) + let mut pos = Vec::new(); + pos.extend(encode_field_fixed64(1, now_millis())); // timestamp + if playing_state != 1 || buffer_state != 1 { + // Real app omits position_ms when STOPPED+BUFFERING(1) + pos.extend(encode_field_varint(2, position_ms)); // value (ms) + } + out.extend(encode_field_bytes(3, &pos)); + if duration_ms > 0 { + out.extend(encode_field_varint(4, duration_ms)); // field 4: duration (ms) + } + // field 5: queue_version (QueueVersion: field 1=major, field 2=minor) + // mpv reference client sends QueueVersion(major=0, minor=0) — proto3 default encodes as empty submessage + out.extend(encode_field_bytes(5, &[])); + // field 6: current_queue_item_id — real app sends -1 when no track (never omits) + out.extend(encode_field_int32(6, queue_item_id)); + // field 7: next_queue_item_id — real app sends -1 when no next track + out.extend(encode_field_int32(7, next_queue_item_id)); + out +} + +/// RNDR_SRVR_STATE_UPDATED (23): renderer reports its state. +fn msg_state_updated( + playing_state: u64, + buffer_state: u64, + position_ms: u64, + duration_ms: u64, + queue_item_id: i32, + next_queue_item_id: i32, +) -> Vec { + let state = build_renderer_state( + playing_state, + buffer_state, + position_ms, + duration_ms, + queue_item_id, + next_queue_item_id, + ); + let payload = encode_field_bytes(1, &state); + build_qconnect_message(23, &payload) +} + +/// Convert QConnect AudioQuality proto value to Qobuz API format_id. +/// Proto: 1=MP3, 2=CD, 3=HiRes96, 4=HiRes192, 5=HiRes192(max), 0/other=HiRes192 default +fn quality_to_format_id(quality: u32) -> u32 { + match quality { + 1 => 5, // MP3 320kbps + 2 => 6, // FLAC CD 16-bit/44.1kHz + 3 => 7, // FLAC Hi-Res 24-bit/96kHz + 4 | 5 => 27, // FLAC Hi-Res 24-bit/192kHz + _ => 27, // default to max quality + } +} + +/// RNDR_SRVR_VOLUME_CHANGED (25): renderer reports volume. +fn msg_volume_changed(volume: u64) -> Vec { + let payload = encode_field_varint(1, volume); + build_qconnect_message(25, &payload) +} + +/// RNDR_SRVR_MAX_AUDIO_QUALITY_CHANGED (28): renderer confirms quality setting. +/// Includes maxAudioQuality (field 1) and networkType (field 2: 1=WIFI, 2=CELLULAR, 3=UNKNOWN). +fn msg_max_audio_quality_changed(quality: u64) -> Vec { + let mut payload = encode_field_varint(1, quality); + payload.extend(encode_field_varint(2, 1)); // networkType = WIFI + build_qconnect_message(28, &payload) +} + +/// RNDR_SRVR_VOLUME_MUTED (29): renderer confirms mute state. +fn msg_volume_muted(muted: bool) -> Vec { + let payload = encode_field_varint(1, if muted { 1 } else { 0 }); + build_qconnect_message(29, &payload) +} + +// --------------------------------------------------------------------------- +// QConnect message parser — extracts messages from the frame layer +// --------------------------------------------------------------------------- + +/// Extracts the QConnect Message from a data frame's body. +/// Frame body field 7 = qconnect container, which has field 3 = Message. +/// Returns (message_type, payload_for_that_type) pairs. +fn extract_qconnect_messages(frame_body: &[u8]) -> Vec<(u32, Vec)> { + let mut result = Vec::new(); + let fields = parse_fields(frame_body); + + // Field 7 is the qconnect container + for (fnum, wt, data) in &fields { + if *fnum == 7 && *wt == 2 { + // Inside field 7, field 3 is the serialized QConnect Message + let container_fields = parse_fields(data); + for (cfnum, cwt, cdata) in &container_fields { + if *cfnum == 3 && *cwt == 2 { + // This is the QConnect Message + let msg_fields = parse_fields(cdata); + let msg_type = get_varint_field(&msg_fields, 1).unwrap_or(0) as u32; + // The payload is in the field whose number matches message_type + if let Some(payload) = get_bytes_field(&msg_fields, msg_type) { + result.push((msg_type, payload.to_vec())); + } else { + // Some messages have no sub-payload (just the type) + result.push((msg_type, Vec::new())); + } + } + } + } + } + result +} + +// --------------------------------------------------------------------------- +// Parsed incoming commands +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub enum QConnectCommand { + SetState { + playing_state: Option, // None = not set (keep current), Some(1)=stopped, Some(2)=playing, Some(3)=paused + position_ms: u32, + current_track: Option, + next_track: Option, + queue_version_major: u32, + }, + SetVolume { + volume: Option, + delta: Option, + }, + SetActive { + active: bool, + }, + SetLoopMode(u32), + SetShuffleMode(u32), + MuteVolume(bool), + SetMaxAudioQuality(u32), + Unknown(u32), +} + +#[derive(Debug, Clone)] +pub struct TrackRef { + pub queue_item_id: i32, + pub track_id: i32, +} + +fn parse_queue_track(data: &[u8]) -> TrackRef { + let fields = parse_fields(data); + let queue_item_id = get_varint_field(&fields, 1).unwrap_or(0) as i32; + // track_id is fixed32 on the wire (not varint) + let track_id = get_fixed32_field(&fields, 2).unwrap_or(0) as i32; + TrackRef { + queue_item_id, + track_id, + } +} + +fn parse_incoming_commands(data: &[u8]) -> Vec { + let mut cmds = Vec::new(); + + for (frame_type, frame_body) in decode_all_frames(data) { + if frame_type != 6 { + debug!( + "[FRAME] type={} body={} bytes", + frame_type, + frame_body.len() + ); + continue; // Only process data payload frames + } + + for (msg_type, payload) in extract_qconnect_messages(&frame_body) { + let cmd = match msg_type { + // SRVR_RNDR_SET_STATE (41) + 41 => { + let fields = parse_fields(&payload); + let playing_state = get_varint_field(&fields, 1).map(|v| v as u32); // None = not present + let position_ms = get_varint_field(&fields, 2).unwrap_or(0) as u32; + let queue_version_major = get_bytes_field(&fields, 3) + .map(|qv| { + let qvf = parse_fields(qv); + get_varint_field(&qvf, 1).unwrap_or(0) as u32 + }) + .unwrap_or(0); + let current_track = get_bytes_field(&fields, 4).map(parse_queue_track); + let next_track = get_bytes_field(&fields, 5).map(parse_queue_track); + + info!("[RECV] SET_STATE: playing_state={:?}, position_ms={}, current_track={:?}, next_track={:?}, queue_ver={}", + playing_state, position_ms, current_track, next_track, queue_version_major); + + QConnectCommand::SetState { + playing_state, + position_ms, + current_track, + next_track, + queue_version_major, + } + } + // SRVR_RNDR_SET_VOLUME (42) + 42 => { + let fields = parse_fields(&payload); + let volume = get_varint_field(&fields, 1).map(|v| v as u32); + let delta = get_varint_field(&fields, 2).map(|v| v as i32); + QConnectCommand::SetVolume { volume, delta } + } + // SRVR_RNDR_SET_ACTIVE (43) + 43 => { + let fields = parse_fields(&payload); + let active = get_varint_field(&fields, 1).unwrap_or(0) != 0; + QConnectCommand::SetActive { active } + } + // SRVR_RNDR_SET_LOOP_MODE (45) + 45 => { + let fields = parse_fields(&payload); + let mode = get_varint_field(&fields, 1).unwrap_or(0) as u32; + QConnectCommand::SetLoopMode(mode) + } + // SRVR_RNDR_SET_SHUFFLE_MODE (46) + 46 => { + let fields = parse_fields(&payload); + let mode = get_varint_field(&fields, 1).unwrap_or(0) as u32; + QConnectCommand::SetShuffleMode(mode) + } + // SRVR_RNDR_MUTE_VOLUME (47) + 47 => { + let fields = parse_fields(&payload); + let muted = get_varint_field(&fields, 1).unwrap_or(0) != 0; + QConnectCommand::MuteVolume(muted) + } + // SRVR_RNDR_SET_MAX_AUDIO_QUALITY (44) + 44 => { + let fields = parse_fields(&payload); + let quality = get_varint_field(&fields, 1).unwrap_or(0) as u32; + QConnectCommand::SetMaxAudioQuality(quality) + } + other => { + info!( + "[RECV] Unknown msg type {}: payload {} bytes = {:02x?}", + other, + payload.len(), + &payload[..payload.len().min(64)] + ); + QConnectCommand::Unknown(other) + } + }; + debug!("QConnect command: {:?}", cmd); + cmds.push(cmd); + } + } + cmds +} + +// --------------------------------------------------------------------------- +// QConnect public API +// --------------------------------------------------------------------------- + +pub struct QConnect { + cmd_rx: mpsc::Receiver, +} + +impl QConnect { + pub fn start(auth_token: String, device_uuid: String, device_name: String) -> Self { + let (cmd_tx, cmd_rx) = mpsc::channel::(64); + + tokio::spawn(async move { + qconnect_task(auth_token, device_uuid, device_name, cmd_tx).await; + }); + + Self { cmd_rx } + } + + pub fn poll_command(&mut self) -> Option { + self.cmd_rx.try_recv().ok() + } +} + +// --------------------------------------------------------------------------- +// Connection logic +// --------------------------------------------------------------------------- + +async fn qconnect_task( + auth_token: String, + device_uuid: String, + device_name: String, + cmd_tx: mpsc::Sender, +) { + let mut backoff = 5u64; + loop { + info!("QConnect: connecting..."); + match run_connection(&auth_token, &device_uuid, &device_name, &cmd_tx).await { + Ok(()) => { + info!("QConnect: disconnected cleanly"); + backoff = 5; + } + Err(e) => { + error!("QConnect: error: {}", e); + } + } + info!("QConnect: reconnecting in {}s", backoff); + tokio::time::sleep(std::time::Duration::from_secs(backoff)).await; + backoff = (backoff * 2).min(120); + } +} + +async fn get_session_uuid( + api: &QobuzApi, + auth_token: &str, + device_uuid: &str, + device_name: &str, +) -> Result> { + let token_resp = api.get_qws_token(auth_token).await?; + let jwt = token_resp.jwt_qws.jwt; + let endpoint = &token_resp.jwt_qws.endpoint; + + info!("QConnect ctrl: connecting to {}", endpoint); + let (ws, _) = tokio_tungstenite::connect_async(endpoint).await?; + let (mut tx, mut rx) = ws.split(); + + // Auth + tx.send(Message::Binary(build_auth_frame(1, &jwt).into())) + .await?; + if let Some(r) = rx.next().await { + r?; + } + + // Subscribe + tx.send(Message::Binary(build_subscribe_frame(2).into())) + .await?; + if let Some(r) = rx.next().await { + r?; + } + + // Send ctrl_join_session + let ctrl_join = build_payload_frame(3, &msg_ctrl_join_session(device_uuid, device_name)); + tx.send(Message::Binary(ctrl_join.into())).await?; + + // Wait for session UUID in response + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + let remaining = deadline + .checked_duration_since(std::time::Instant::now()) + .ok_or_else(|| anyhow::anyhow!("timeout waiting for session UUID"))?; + + match tokio::time::timeout(remaining, rx.next()).await { + Ok(Some(Ok(Message::Binary(data)))) => { + for (frame_type, frame_body) in decode_all_frames(&data) { + if frame_type != 6 { + continue; + } + // Look for session UUID in the SRVR_CTRL_SESSION_STATE (81) response + for (msg_type, payload) in extract_qconnect_messages(&frame_body) { + if msg_type == 81 { + // Session state — look for session UUID + let fields = parse_fields(&payload); + // Field 7 in session state might have device info + // Field 1 might be session UUID + if let Some(uuid_bytes) = get_bytes_field(&fields, 1) { + if uuid_bytes.len() == 16 { + info!("Got session UUID from msg type 81"); + return Ok(uuid_bytes.to_vec()); + } + } + } + } + + // Fallback: scan frame field 7 deeply for any 16-byte UUID + let frame_fields = parse_fields(&frame_body); + if let Some(f7) = get_bytes_field(&frame_fields, 7) { + if let Some(uuid) = find_session_uuid(f7) { + info!("Got session UUID from deep scan"); + return Ok(uuid); + } + } + } + } + Ok(Some(Ok(Message::Ping(data)))) => { + tx.send(Message::Pong(data)).await?; + } + Ok(Some(Ok(_))) => {} + Ok(Some(Err(e))) => bail!("ctrl connection error: {}", e), + _ => bail!("timeout waiting for session UUID"), + } + } +} + +/// Recursively search for a 16-byte blob that looks like a session UUID. +fn find_session_uuid(data: &[u8]) -> Option> { + let fields = parse_fields(data); + if let Some(candidate) = get_bytes_field(&fields, 1) { + // Prefer structures that look like SessionState payloads: + // field 1 = 16-byte UUID, field 2 = enum/int varint. + if candidate.len() == 16 && get_varint_field(&fields, 2).is_some() { + return Some(candidate.to_vec()); + } + } + + for (_, wt, field_data) in &fields { + if *wt == 2 { + if let Some(found) = find_session_uuid(field_data) { + return Some(found); + } + } + } + None +} + +async fn run_connection( + auth_token: &str, + device_uuid: &str, + device_name: &str, + cmd_tx: &mpsc::Sender, +) -> Result<()> { + let config = Config::load().map_err(|e| anyhow::anyhow!("{}", e))?; + let api = QobuzApi::new(&config); + + // 1. Get session UUID via ctrl connection + info!("QConnect: getting session UUID..."); + let session_uuid = get_session_uuid(&api, auth_token, device_uuid, device_name).await?; + info!("QConnect: got session UUID ({} bytes)", session_uuid.len()); + + // 2. Open renderer connection + let token_resp = api.get_qws_token(auth_token).await?; + let jwt = token_resp.jwt_qws.jwt; + let endpoint = &token_resp.jwt_qws.endpoint; + + info!("QConnect renderer: connecting to {}", endpoint); + let (ws, _) = tokio_tungstenite::connect_async(endpoint).await?; + let (mut ws_tx, mut ws_rx) = ws.split(); + + let mut msg_id: u64 = 1; + + // Auth + ws_tx + .send(Message::Binary(build_auth_frame(msg_id, &jwt).into())) + .await?; + msg_id += 1; + if let Some(r) = ws_rx.next().await { + r?; + } + + // Subscribe + ws_tx + .send(Message::Binary(build_subscribe_frame(msg_id).into())) + .await?; + msg_id += 1; + if let Some(r) = ws_rx.next().await { + r?; + } + + // Join session as renderer + let join_msg = msg_renderer_join_session(device_uuid, device_name, &session_uuid); + ws_tx + .send(Message::Binary( + build_payload_frame(msg_id, &join_msg).into(), + )) + .await?; + msg_id += 1; + + // Read join response + for _ in 0..5 { + match tokio::time::timeout(std::time::Duration::from_secs(5), ws_rx.next()).await { + Ok(Some(Ok(Message::Binary(data)))) => { + for (frame_type, frame_body) in decode_all_frames(&data) { + if frame_type != 6 { + continue; + } + for (mt, payload) in extract_qconnect_messages(&frame_body) { + if mt == 1 { + // Error + let fields = parse_fields(&payload); + let code = get_bytes_field(&fields, 1) + .and_then(|b| std::str::from_utf8(b).ok()) + .unwrap_or("?"); + let message = get_bytes_field(&fields, 2) + .and_then(|b| std::str::from_utf8(b).ok()) + .unwrap_or("?"); + bail!("renderer join rejected: {} — {}", code, message); + } + if mt == 43 { + info!("QConnect: renderer joined (SET_ACTIVE received)"); + } + } + } + break; + } + Ok(Some(Ok(Message::Ping(data)))) => { + ws_tx.send(Message::Pong(data)).await?; + } + Ok(Some(Ok(_))) => break, + Ok(Some(Err(e))) => bail!("WS error on join: {}", e), + _ => break, + } + } + info!("QConnect: joined session as renderer"); + + // Send initial state (stopped, buffer_state=OK) and volume + { + let state_msg = msg_state_updated(1, 2, 0, 0, -1, -1); + ws_tx + .send(Message::Binary( + build_payload_frame(msg_id, &state_msg).into(), + )) + .await?; + msg_id += 1; + + let vol_msg = msg_volume_changed(100); + ws_tx + .send(Message::Binary( + build_payload_frame(msg_id, &vol_msg).into(), + )) + .await?; + msg_id += 1; + } + + // Create audio player + let player = AudioPlayer::new(); + + // Local state tracking (optimistic — reflects what we've been told to do) + let mut current_playing_state: u64 = 1; // 1=stopped, 2=playing, 3=paused + let mut current_queue_item_id: i32 = -1; + let mut current_next_queue_item_id: i32 = -1; + let mut current_position_ms: u64 = 0; + let mut current_duration_ms: u64 = 0; + let mut current_buffer_state: u64 = 2; // 2=OK per proto + let mut volume: u8 = 100; + let mut muted = false; + let mut pre_mute_volume: u8 = 100; + let mut max_audio_quality: u32 = 4; // proto quality value 4 = Hi-Res 192 + let mut current_track_id: i32 = 0; // track_id of currently playing track + let mut last_play_command_at: std::time::Instant = std::time::Instant::now(); + let mut has_seen_position_progress = false; // true once we've seen pos > 0 after a Play + let mut track_ended = false; // true when player finishes track naturally + + // Helper macro: send a state update + macro_rules! send_state { + ($ws_tx:expr, $msg_id:expr) => {{ + debug!( + "[SEND] StateUpdated: playing={} buffer={} pos={}ms dur={}ms qi={} nqi={}", + current_playing_state, + current_buffer_state, + current_position_ms, + current_duration_ms, + current_queue_item_id, + current_next_queue_item_id + ); + let sm = msg_state_updated( + current_playing_state, + current_buffer_state, + current_position_ms, + current_duration_ms, + current_queue_item_id, + current_next_queue_item_id, + ); + $ws_tx + .send(Message::Binary(build_payload_frame($msg_id, &sm).into())) + .await?; + $msg_id += 1; + }}; + } + + info!("QConnect: entering main loop"); + let mut position_ticker = tokio::time::interval(std::time::Duration::from_millis(500)); + position_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + _ = position_ticker.tick() => { + if current_playing_state == 2 { + let status = player.status(); + let elapsed_since_play = last_play_command_at.elapsed(); + + if status.state == PlayerState::Stopped + && has_seen_position_progress + && elapsed_since_play > std::time::Duration::from_secs(3) + { + // Track ended naturally — keep reporting PLAYING with position=duration + // so the server detects end-of-track and sends next track via SET_STATE + if !track_ended { + info!("[TICK] Track ended naturally, reporting position=duration ({}ms)", current_duration_ms); + track_ended = true; + current_position_ms = current_duration_ms; + } + send_state!(ws_tx, msg_id); + } else if status.state == PlayerState::Stopped { + debug!("[TICK] Player stopped but grace period (elapsed={:?}, progress={}), ignoring", + elapsed_since_play, has_seen_position_progress); + } else { + let new_pos = status.position_ms; + if new_pos > 0 && !has_seen_position_progress { + has_seen_position_progress = true; + info!("[TICK] First position progress: {}ms", new_pos); + } + if new_pos != current_position_ms { + current_position_ms = new_pos; + send_state!(ws_tx, msg_id); + } + } + } + } + msg = ws_rx.next() => { + match msg { + Some(Ok(Message::Binary(data))) => { + let cmds = parse_incoming_commands(&data); + for cmd in cmds { + let _ = cmd_tx.try_send(cmd.clone()); + + match &cmd { + QConnectCommand::SetState { + playing_state, + position_ms, + current_track, + next_track, + .. + } => { + info!("[STATE] SET_STATE: playing_state={:?} current_track={:?} next_track={:?} pos={}", + playing_state, current_track.as_ref().map(|t| t.track_id), + next_track.as_ref().map(|t| t.track_id), position_ms); + + // 1. Store next_track metadata + if let Some(nt) = next_track { + current_next_queue_item_id = nt.queue_item_id; + } + + // 2. Load new current_track if present and different + let mut loaded_new_track = false; + if let Some(track) = current_track { + if track.track_id != current_track_id || track.queue_item_id != current_queue_item_id { + info!("[STATE] Loading new track {} (qi={})", track.track_id, track.queue_item_id); + current_track_id = track.track_id; + current_queue_item_id = track.queue_item_id; + current_playing_state = 2; + current_buffer_state = 1; // BUFFERING + current_position_ms = *position_ms as u64; + current_duration_ms = 0; + last_play_command_at = std::time::Instant::now(); + has_seen_position_progress = false; + track_ended = false; + send_state!(ws_tx, msg_id); + + let track_id_str = track.track_id.to_string(); + let format_id = quality_to_format_id(max_audio_quality); + let duration_ms = match api.get_track(auth_token, &track_id_str).await { + Ok(t) => t.duration.unwrap_or(0) as u64 * 1000, + Err(e) => { warn!("get_track failed: {}", e); 0 } + }; + current_duration_ms = duration_ms; + match api.get_track_url(auth_token, &track_id_str, format_id).await { + Ok(url) => { + info!("[STATE] Got URL, playing (duration={}ms)", duration_ms); + player.send(PlayerCommand::Play { + url, + track_id: track.track_id, + queue_item_id: track.queue_item_id, + duration_ms, + }); + current_buffer_state = 2; // OK + } + Err(e) => { + error!("[STATE] Failed to get stream URL: {}", e); + current_playing_state = 1; + current_buffer_state = 2; // OK + } + } + loaded_new_track = true; + } + } + + // 3. Apply playing_state if present (and we didn't just load a new track) + if let Some(ps) = playing_state { + if !loaded_new_track { + match ps { + 2 => { + if current_playing_state == 3 { + info!("[STATE] Resuming playback"); + player.send(PlayerCommand::Resume); + current_playing_state = 2; + track_ended = false; + } else if current_playing_state != 2 { + info!("[STATE] Play requested but no new track, state={}", current_playing_state); + current_playing_state = 2; + } + } + 3 => { + info!("[STATE] Pausing playback"); + player.send(PlayerCommand::Pause); + current_playing_state = 3; + if *position_ms > 0 { + current_position_ms = *position_ms as u64; + } else { + current_position_ms = player.status().position_ms; + } + } + 1 => { + info!("[STATE] Stopping playback"); + player.send(PlayerCommand::Stop); + current_playing_state = 1; + current_position_ms = 0; + current_queue_item_id = -1; + current_next_queue_item_id = -1; + current_track_id = 0; + track_ended = false; + } + _ => {} + } + } + } + + // 4. Apply seek position if provided and not loading new track + let is_pause = matches!(playing_state, Some(3)); + if !loaded_new_track && *position_ms > 0 && !is_pause { + current_position_ms = *position_ms as u64; + } + + // 5. Always send state update (like reference implementation) + send_state!(ws_tx, msg_id); + } + + QConnectCommand::SetVolume { volume: vol, delta } => { + let new_vol = if let Some(v) = vol { + (*v).min(100) as u8 + } else if let Some(d) = delta { + (volume as i32 + d).clamp(0, 100) as u8 + } else { + volume + }; + info!("Setting volume to {}", new_vol); + volume = new_vol; + if muted && new_vol > 0 { muted = false; } + player.send(PlayerCommand::SetVolume(new_vol)); + let resp = msg_volume_changed(new_vol as u64); + ws_tx.send(Message::Binary(build_payload_frame(msg_id, &resp).into())).await?; + msg_id += 1; + } + + QConnectCommand::SetActive { active } => { + info!("SetActive: {}", active); + if !*active { + player.send(PlayerCommand::Stop); + current_playing_state = 1; + current_buffer_state = 2; // OK + current_position_ms = 0; + current_queue_item_id = -1; + current_next_queue_item_id = -1; + current_track_id = 0; + send_state!(ws_tx, msg_id); + } + } + + QConnectCommand::MuteVolume(mute) => { + info!("MuteVolume: {}", mute); + if *mute { + pre_mute_volume = volume; + volume = 0; + muted = true; + } else { + volume = pre_mute_volume; + muted = false; + } + player.send(PlayerCommand::SetVolume(volume)); + let resp = msg_volume_muted(*mute); + ws_tx.send(Message::Binary(build_payload_frame(msg_id, &resp).into())).await?; + msg_id += 1; + } + + QConnectCommand::SetMaxAudioQuality(quality) => { + let format_id = quality_to_format_id(*quality); + info!("SetMaxAudioQuality: {} (format_id={})", quality, format_id); + max_audio_quality = *quality; + + // Confirm quality change to server + let resp = msg_max_audio_quality_changed(*quality as u64); + ws_tx.send(Message::Binary(build_payload_frame(msg_id, &resp).into())).await?; + msg_id += 1; + + // If currently playing, restart at new quality + if current_playing_state == 2 && current_track_id != 0 { + info!("Restarting track {} at new quality format_id={}", current_track_id, format_id); + current_buffer_state = 1; // BUFFERING + current_position_ms = 0; + send_state!(ws_tx, msg_id); + + let track_id_str = current_track_id.to_string(); + let duration_ms = match api.get_track(auth_token, &track_id_str).await { + Ok(t) => t.duration.unwrap_or(0) as u64 * 1000, + Err(e) => { warn!("get_track failed: {}", e); current_duration_ms } + }; + current_duration_ms = duration_ms; + match api.get_track_url(auth_token, &track_id_str, format_id).await { + Ok(url) => { + player.send(PlayerCommand::Play { + url, + track_id: current_track_id, + queue_item_id: current_queue_item_id, + duration_ms, + }); + current_buffer_state = 2; // OK(2) + info!("Restarted at format_id={}", format_id); + } + Err(e) => { + error!("Failed to get stream URL for quality change: {}", e); + current_playing_state = 1; + current_buffer_state = 2; // OK + } + } + send_state!(ws_tx, msg_id); + } + } + + QConnectCommand::SetLoopMode(mode) => { + info!("SetLoopMode: {}", mode); + let _ = mode; + // No response message — renderer stores setting, server notifies controllers directly + } + + QConnectCommand::SetShuffleMode(mode) => { + info!("SetShuffleMode: {}", mode); + let _ = mode; + // No response message — renderer stores setting, server notifies controllers directly + } + + QConnectCommand::Unknown(_) => {} + } + } + } + Some(Ok(Message::Ping(data))) => { + ws_tx.send(Message::Pong(data)).await?; + } + Some(Ok(Message::Close(_))) | None => { + bail!("WebSocket closed"); + } + Some(Err(e)) => { + bail!("WebSocket error: {}", e); + } + _ => {} + } + } + } + } +} diff --git a/src/token.rs b/src/token.rs new file mode 100644 index 0000000..7322af4 --- /dev/null +++ b/src/token.rs @@ -0,0 +1,103 @@ +use crate::config::{Config, DeviceLinkCredentials}; +use crate::error::Result; +use crate::types::OAuthTokens; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Clone)] +pub struct TokenManager { + config: Config, +} + +impl TokenManager { + pub fn new(config: Config) -> Self { + Self { config } + } + + pub fn store_device_link_credentials(&self, creds: DeviceLinkCredentials) -> Result<()> { + let path = self.config.device_link_credentials_path(); + let content = serde_json::to_string_pretty(&creds)?; + std::fs::write(path, content)?; + Ok(()) + } + + pub fn load_device_link_credentials(&self) -> Result> { + let path = self.config.device_link_credentials_path(); + if path.exists() { + let content = std::fs::read_to_string(path)?; + let creds: DeviceLinkCredentials = serde_json::from_str(&content)?; + if let Some(expires_at) = creds.expires_at { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + if expires_at > now { + return Ok(Some(creds)); + } + } + Ok(Some(creds)) + } else { + Ok(None) + } + } + + pub fn clear_device_link_credentials(&self) -> Result<()> { + let path = self.config.device_link_credentials_path(); + if path.exists() { + std::fs::remove_file(path)?; + } + Ok(()) + } + + pub fn store_tokens(&self, tokens: &OAuthTokens) -> Result<()> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let expires_at = now + tokens.expires_in as i64; + + let creds = crate::config::StoredCredentials { + access_token: tokens.access_token.clone(), + refresh_token: tokens.refresh_token.clone(), + user_id: None, + expires_at: Some(expires_at), + email: None, + }; + + let mut config = self.config.clone(); + config.store_credentials(creds)?; + + Ok(()) + } + + pub fn load_tokens(&self) -> Result> { + let config = crate::config::Config::load()?; + + if let Some(creds) = config.credentials { + if let Some(expires_at) = creds.expires_at { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + if expires_at > now { + return Ok(Some(OAuthTokens { + token_type: "bearer".to_string(), + access_token: creds.access_token, + refresh_token: creds.refresh_token, + expires_in: (expires_at - now) as u64, + })); + } + } + } + + Ok(None) + } + + pub fn is_token_expired(&self) -> Result { + if let Some(tokens) = self.load_tokens()? { + Ok(tokens.expires_in == 0) + } else { + Ok(true) + } + } +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..c0aa33e --- /dev/null +++ b/src/types.rs @@ -0,0 +1,262 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthTokens { + pub token_type: String, + pub access_token: String, + pub refresh_token: String, + pub expires_in: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + pub id: u64, + #[serde(rename = "publicId")] + pub public_id: Option, + pub email: String, + pub login: String, + #[serde(rename = "firstname")] + pub first_name: Option, + #[serde(rename = "lastname")] + pub last_name: Option, + #[serde(rename = "display_name")] + pub display_name: Option, + #[serde(rename = "country_code")] + pub country_code: Option, + #[serde(rename = "language_code")] + pub language_code: Option, + pub zone: Option, + pub store: Option, + pub country: Option, + pub avatar: Option, + pub subscription: Option, + pub credential: Option, + #[serde(rename = "store_features")] + pub store_features: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Subscription { + pub offer: String, + pub periodicity: String, + #[serde(rename = "start_date")] + pub start_date: Option, + #[serde(rename = "end_date")] + pub end_date: Option, + #[serde(rename = "is_canceled")] + pub is_canceled: bool, + #[serde(rename = "household_size_max")] + pub household_size_max: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Credential { + pub id: u64, + pub label: String, + pub description: Option, + pub parameters: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CredentialParameters { + #[serde(rename = "lossy_streaming")] + pub lossy_streaming: Option, + #[serde(rename = "lossless_streaming")] + pub lossless_streaming: Option, + #[serde(rename = "hires_streaming")] + pub hires_streaming: Option, + #[serde(rename = "hires_purchases_streaming")] + pub hires_purchases_streaming: Option, + #[serde(rename = "mobile_streaming")] + pub mobile_streaming: Option, + #[serde(rename = "offline_streaming")] + pub offline_streaming: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoreFeatures { + pub download: bool, + pub streaming: bool, + pub editorial: bool, + pub club: bool, + pub wallet: bool, + pub weeklyq: bool, + pub autoplay: bool, + #[serde(rename = "inapp_purchase_subscripton")] + pub inapp_purchase_subscription: bool, + pub opt_in: bool, + #[serde(rename = "pre_register_opt_in")] + pub pre_register_opt_in: bool, + #[serde(rename = "pre_register_zipcode")] + pub pre_register_zipcode: bool, + #[serde(rename = "music_import")] + pub music_import: bool, + pub radio: bool, + #[serde(rename = "stream_purchase")] + pub stream_purchase: bool, + pub lyrics: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoginResponse { + pub user: User, + #[serde(rename = "oauth2")] + pub oauth: OAuthTokens, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LinkTokenRequest { + #[serde(rename = "link_action")] + pub link_action: String, + #[serde(rename = "external_device_id")] + pub external_device_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LinkTokenResponse { + pub status: String, + #[serde(rename = "link_token")] + pub link_token: Option, + #[serde(rename = "link_device_id")] + pub link_device_id: Option, + pub errors: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceTokenRequest { + #[serde(rename = "link_token")] + pub link_token: String, + #[serde(rename = "link_device_id")] + pub link_device_id: String, + #[serde(rename = "external_device_id")] + pub external_device_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceTokenResponse { + pub status: String, + #[serde(rename = "oauth2")] + pub oauth: Option, + #[serde(rename = "link_action")] + pub link_action: Option, + pub errors: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QwsTokenResponse { + #[serde(rename = "jwt_qws")] + pub jwt_qws: QwsToken, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QwsToken { + pub exp: i64, + pub jwt: String, + pub endpoint: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiResponse { + pub status: Option, + pub data: Option, + pub message: Option, + pub code: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorResponse { + pub message: Option, + pub code: Option, + pub status: Option, + pub errors: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Album { + pub id: String, + pub title: String, + pub version: Option, + #[serde(rename = "track_count")] + pub track_count: Option, + pub duration: Option, + pub image: Option, + pub artists: Option>, + pub label: Option