diff --git a/Cargo.lock b/Cargo.lock index 0d2d8419f..f72f17420 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,7 +84,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", - "anstyle-parse", + "anstyle-parse 0.2.7", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse 1.0.0", "anstyle-query", "anstyle-wincon", "colorchoice", @@ -107,24 +122,33 @@ dependencies = [ "utf8parse", ] +[[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.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.10" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -199,9 +223,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.0" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" @@ -226,15 +250,15 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "byteorder" @@ -244,15 +268,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.45" +version = "1.2.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" dependencies = [ "find-msvc-tools", "shlex", @@ -266,9 +290,9 @@ checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" [[package]] name = "cfg-expr" -version = "0.20.4" +version = "0.20.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9acd0bdbbf4b2612d09f52ba61da432140cb10930354079d0d53fafc12968726" +checksum = "78cef5b5a1a6827c7322ae2a636368a573006b27cfa76c7ebd53e834daeaab6a" dependencies = [ "smallvec", "target-lexicon", @@ -288,9 +312,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "js-sys", @@ -310,6 +334,56 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap-verbosity-flag" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d92b1fab272fe943881b77cc6e920d6543e5b1bfadbd5ed81c7c5a755742394" +dependencies = [ + "clap", + "log", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream 1.0.0", + "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.4" @@ -434,9 +508,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -494,9 +568,9 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "data-encoding" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "der" @@ -609,9 +683,20 @@ dependencies = [ [[package]] name = "endi" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enum-assoc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "enumflags2" @@ -649,7 +734,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" dependencies = [ - "anstream", + "anstream 0.6.21", "anstyle", "env_filter", "jiff", @@ -701,15 +786,15 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -723,9 +808,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" -version = "0.1.5" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "foreign-types" @@ -861,28 +946,19 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.9" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", ] -[[package]] -name = "getopts" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" -dependencies = [ - "unicode-width", -] - [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -907,9 +983,9 @@ dependencies = [ [[package]] name = "gio-sys" -version = "0.21.2" +version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171ed2f6dd927abbe108cfd9eebff2052c335013f5879d55bab0dc1dee19b706" +checksum = "0071fe88dba8e40086c8ff9bbb62622999f49628344b1d1bf490a48a29d80f22" dependencies = [ "glib-sys", "gobject-sys", @@ -920,9 +996,9 @@ dependencies = [ [[package]] name = "glib" -version = "0.21.4" +version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b9dbecb1c33e483a98be4acfea2ab369e1c28f517c6eadb674537409c25c4b2" +checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b" dependencies = [ "bitflags 2.10.0", "futures-channel", @@ -941,9 +1017,9 @@ dependencies = [ [[package]] name = "glib-macros" -version = "0.21.4" +version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "880e524e0085f3546cfb38532b2c202c0d64741d9977a6e4aa24704bfc9f19fb" +checksum = "cf59b675301228a696fe01c3073974643365080a76cc3ed5bc2cbc466ad87f17" dependencies = [ "heck", "proc-macro-crate", @@ -954,9 +1030,9 @@ dependencies = [ [[package]] name = "glib-sys" -version = "0.21.2" +version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d09d3d0fddf7239521674e57b0465dfbd844632fec54f059f7f56112e3f927e1" +checksum = "2d95e1a3a19ae464a7286e14af9a90683c64d70c02532d88d87ce95056af3e6c" dependencies = [ "libc", "system-deps", @@ -964,9 +1040,9 @@ dependencies = [ [[package]] name = "gobject-sys" -version = "0.21.2" +version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "538e41d8776173ec107e7b0f2aceced60abc368d7e1d81c1f0e2ecd35f59080d" +checksum = "2dca35da0d19a18f4575f3cb99fe1c9e029a2941af5662f326f738a21edaf294" dependencies = [ "glib-sys", "libc", @@ -975,15 +1051,15 @@ dependencies = [ [[package]] name = "governor" -version = "0.10.1" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "444405bbb1a762387aa22dd569429533b54a1d8759d35d3b64cb39b0293eaa19" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" dependencies = [ "cfg-if", "futures-sink", "futures-timer", "futures-util", - "hashbrown 0.15.5", + "hashbrown", "nonzero_ext", "parking_lot", "portable-atomic", @@ -994,9 +1070,9 @@ dependencies = [ [[package]] name = "gstreamer" -version = "0.24.3" +version = "0.24.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69ac2f12970a2f85a681d2ceaa40c32fe86cc202ead315e0dfa2223a1217cd24" +checksum = "0bed73742c5d54cb48533be608b67d89f96e1ebbba280be7823f1ef995e3a9d7" dependencies = [ "cfg-if", "futures-channel", @@ -1014,14 +1090,14 @@ dependencies = [ "pastey", "pin-project-lite", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "gstreamer-app" -version = "0.24.2" +version = "0.24.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0af5d403738faf03494dfd502d223444b4b44feb997ba28ab3f118ee6d40a0b2" +checksum = "895753fb0f976693f321e6b9d68f746ef9095f1a5b8277c11d85d807a949fbfc" dependencies = [ "futures-core", "futures-sink", @@ -1034,9 +1110,9 @@ dependencies = [ [[package]] name = "gstreamer-app-sys" -version = "0.24.0" +version = "0.24.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaf1a3af017f9493c34ccc8439cbce5c48f6ddff6ec0514c23996b374ff25f9a" +checksum = "f7719cee28afda1a48ab1ee93769628bd0653d3c5be1923bce9a8a4550fcc980" dependencies = [ "glib-sys", "gstreamer-base-sys", @@ -1047,9 +1123,9 @@ dependencies = [ [[package]] name = "gstreamer-audio" -version = "0.24.2" +version = "0.24.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68e540174d060cd0d7ee2c2356f152f05d8262bf102b40a5869ff799377269d8" +checksum = "92829dbca7c59ed4bf0c9154dd8c0cf3185d6bf9dad821b058b801d9671fa763" dependencies = [ "cfg-if", "glib", @@ -1062,9 +1138,9 @@ dependencies = [ [[package]] name = "gstreamer-audio-sys" -version = "0.24.0" +version = "0.24.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626cd3130bc155a8b6d4ac48cfddc15774b5a6cc76fcb191aab09a2655bad8f5" +checksum = "6acd80847b78122c45983597f74a29071d63273c1eded14be5f7381301711475" dependencies = [ "glib-sys", "gobject-sys", @@ -1076,9 +1152,9 @@ dependencies = [ [[package]] name = "gstreamer-base" -version = "0.24.2" +version = "0.24.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71ff9b0bbc8041f0c6c8a53b206a6542f86c7d9fa8a7dff3f27d9c374d9f39b4" +checksum = "4dd15c7e37d306573766834a5cbdd8ee711265f217b060f40a9a8eda45298488" dependencies = [ "atomic_refcell", "cfg-if", @@ -1090,9 +1166,9 @@ dependencies = [ [[package]] name = "gstreamer-base-sys" -version = "0.24.2" +version = "0.24.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed78852b92db1459b8f4288f86e6530274073c20be2f94ba642cddaca08b00e" +checksum = "27a2eda2c61e13c11883bf19b290d07ea6b53d04fd8bfeb7af64b6006c6c9ee6" dependencies = [ "glib-sys", "gobject-sys", @@ -1103,9 +1179,9 @@ dependencies = [ [[package]] name = "gstreamer-sys" -version = "0.24.2" +version = "0.24.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a24ae2930e683665832a19ef02466094b09d1f2da5673f001515ed5486aa9377" +checksum = "5d88630697e757c319e7bcec7b13919ba80492532dd3238481c1c4eee05d4904" dependencies = [ "cfg-if", "glib-sys", @@ -1116,9 +1192,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", @@ -1135,21 +1211,15 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", "foldhash", ] -[[package]] -name = "hashbrown" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" - [[package]] name = "headers" version = "0.4.1" @@ -1206,23 +1276,22 @@ dependencies = [ [[package]] name = "hostname" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" dependencies = [ "cfg-if", "libc", - "windows-link 0.1.3", + "windows-link 0.2.1", ] [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -1263,9 +1332,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.7.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", @@ -1338,13 +1407,13 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls 0.23.35", - "rustls-native-certs 0.8.2", + "rustls 0.23.36", + "rustls-native-certs 0.8.3", "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", "tower-service", - "webpki-roots 1.0.4", + "webpki-roots 1.0.6", ] [[package]] @@ -1365,14 +1434,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -1391,9 +1459,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1461,9 +1529,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -1475,9 +1543,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -1533,12 +1601,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown", ] [[package]] @@ -1558,9 +1626,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ "memchr", "serde", @@ -1602,15 +1670,15 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jack" -version = "0.13.3" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f70ca699f44c04a32d419fc9ed699aaea89657fc09014bf3fa238e91d13041b9" +checksum = "f7811b07bcac5dafabf814ab52c4b0ca9b7948aa1e279f572f03aa6544d47d27" dependencies = [ "bitflags 2.10.0", "jack-sys", @@ -1635,9 +1703,9 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.16" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" dependencies = [ "jiff-static", "log", @@ -1648,9 +1716,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.16" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" dependencies = [ "proc-macro2", "quote", @@ -1681,9 +1749,9 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -1709,9 +1777,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.177" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libloading" @@ -1725,9 +1793,9 @@ dependencies = [ [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libmdns" @@ -1743,7 +1811,7 @@ dependencies = [ "multimap", "rand 0.9.2", "socket2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", ] @@ -1799,10 +1867,12 @@ dependencies = [ name = "librespot" version = "0.8.0" dependencies = [ + "cfg_aliases", + "clap", + "clap-verbosity-flag", "data-encoding", "env_logger", "futures-util", - "getopts", "librespot-audio", "librespot-connect", "librespot-core", @@ -1812,9 +1882,10 @@ dependencies = [ "librespot-playback", "librespot-protocol", "log", + "serde", "sha1", "sysinfo", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "url", ] @@ -1833,7 +1904,7 @@ dependencies = [ "librespot-core", "log", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", ] @@ -1849,7 +1920,7 @@ dependencies = [ "protobuf", "rand 0.9.2", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-stream", "uuid", @@ -1863,6 +1934,7 @@ dependencies = [ "base64", "byteorder", "bytes", + "clap", "data-encoding", "flate2", "form_urlencoded", @@ -1900,7 +1972,7 @@ dependencies = [ "sha1", "shannon", "sysinfo", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tokio", "tokio-stream", @@ -1919,8 +1991,11 @@ dependencies = [ "aes", "base64", "bytes", + "cfg_aliases", + "clap", "ctr", "dns-sd", + "enum-assoc", "form_urlencoded", "futures", "futures-core", @@ -1938,7 +2013,7 @@ dependencies = [ "serde_json", "serde_repr", "sha1", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "zbus", ] @@ -1955,7 +2030,7 @@ dependencies = [ "protobuf", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "uuid", ] @@ -1968,7 +2043,7 @@ dependencies = [ "oauth2", "open", "reqwest", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "url", ] @@ -1978,7 +2053,10 @@ name = "librespot-playback" version = "0.8.0" dependencies = [ "alsa 0.10.0", + "clap", "cpal", + "derive_builder", + "enum-assoc", "form_urlencoded", "futures-util", "gstreamer", @@ -1998,9 +2076,10 @@ dependencies = [ "rand_distr", "rodio", "sdl2", + "serde", "shell-words", "symphonia", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "zerocopy", ] @@ -2042,9 +2121,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru-slab" @@ -2094,9 +2173,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", @@ -2124,7 +2203,7 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe", + "openssl-probe 0.1.6", "openssl-sys", "schannel", "security-framework 2.11.1", @@ -2161,19 +2240,6 @@ dependencies = [ "jni-sys", ] -[[package]] -name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - [[package]] name = "nonzero_ext" version = "0.3.0" @@ -2182,9 +2248,9 @@ checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" [[package]] name = "ntapi" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081" dependencies = [ "winapi", ] @@ -2312,7 +2378,7 @@ checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ "base64", "chrono", - "getrandom 0.2.16", + "getrandom 0.2.17", "http", "rand 0.8.5", "reqwest", @@ -2429,9 +2495,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "open" -version = "5.3.2" +version = "5.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" dependencies = [ "is-wsl", "libc", @@ -2470,6 +2536,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "openssl-sys" version = "0.9.111" @@ -2484,9 +2556,9 @@ dependencies = [ [[package]] name = "option-operations" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b31ce827892359f23d3cd1cc4c75a6c241772bbd2db17a92dcf27cbefdf52689" +checksum = "aca39cf52b03268400c16eeb9b56382ea3c3353409309b63f5c8f0b1faf42754" dependencies = [ "pastey", ] @@ -2532,9 +2604,9 @@ dependencies = [ [[package]] name = "pastey" -version = "0.1.1" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" [[package]] name = "pathdiff" @@ -2608,15 +2680,15 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" dependencies = [ "portable-atomic", ] @@ -2688,9 +2760,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -2759,9 +2831,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.38.3" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" dependencies = [ "memchr", "serde", @@ -2779,9 +2851,9 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.35", + "rustls 0.23.36", "socket2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -2799,10 +2871,10 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash", - "rustls 0.23.35", + "rustls 0.23.36", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -2824,9 +2896,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.42" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -2855,7 +2927,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -2875,7 +2947,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -2884,14 +2956,14 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -2917,9 +2989,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -2929,9 +3001,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -2940,15 +3012,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", @@ -2968,8 +3040,8 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.35", - "rustls-native-certs 0.8.2", + "rustls 0.23.36", + "rustls-native-certs 0.8.3", "rustls-pki-types", "serde", "serde_json", @@ -2985,7 +3057,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.4", + "webpki-roots 1.0.6", ] [[package]] @@ -2996,7 +3068,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -3015,9 +3087,9 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.8" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ "const-oid", "digest", @@ -3054,9 +3126,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags 2.10.0", "errno", @@ -3081,14 +3153,14 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.8", + "rustls-webpki 0.103.9", "subtle", "zeroize", ] @@ -3099,7 +3171,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" dependencies = [ - "openssl-probe", + "openssl-probe 0.1.6", "rustls-pemfile", "rustls-pki-types", "schannel", @@ -3108,11 +3180,11 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", "security-framework 3.5.1", @@ -3129,9 +3201,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", "zeroize", @@ -3150,9 +3222,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "ring", "rustls-pki-types", @@ -3167,9 +3239,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "same-file" @@ -3286,15 +3358,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -3321,9 +3393,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" dependencies = [ "serde_core", ] @@ -3373,9 +3445,9 @@ dependencies = [ [[package]] name = "shell-words" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] name = "shlex" @@ -3385,10 +3457,11 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -3404,15 +3477,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -3422,9 +3495,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", "windows-sys 0.60.2", @@ -3578,9 +3651,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.109" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f17c7e013e88258aa9543dcbe81aca68a667a9ac37cd69c9fbc07858bfe0e2f" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -3623,9 +3696,9 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags 2.10.0", "core-foundation 0.9.4", @@ -3663,14 +3736,14 @@ checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" [[package]] name = "tempfile" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix 1.1.2", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -3685,11 +3758,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -3705,9 +3778,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -3716,9 +3789,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", "itoa", @@ -3726,22 +3799,22 @@ dependencies = [ "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" dependencies = [ "num-conv", "time-core", @@ -3774,9 +3847,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", @@ -3827,15 +3900,15 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.35", + "rustls 0.23.36", "tokio", ] [[package]] name = "tokio-stream" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -3851,8 +3924,8 @@ dependencies = [ "futures-util", "log", "native-tls", - "rustls 0.23.35", - "rustls-native-certs 0.8.2", + "rustls 0.23.36", + "rustls-native-certs 0.8.3", "rustls-pki-types", "tokio", "tokio-native-tls", @@ -3863,9 +3936,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -3876,9 +3949,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.8" +version = "0.9.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" dependencies = [ "indexmap", "serde_core", @@ -3891,18 +3964,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap", "toml_datetime", @@ -3912,24 +3985,24 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -3942,9 +4015,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.10.0", "bytes", @@ -3972,9 +4045,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -3983,9 +4056,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -3994,9 +4067,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] @@ -4020,10 +4093,10 @@ dependencies = [ "log", "native-tls", "rand 0.9.2", - "rustls 0.23.35", + "rustls 0.23.36", "rustls-pki-types", "sha1", - "thiserror 2.0.17", + "thiserror 2.0.18", "utf-8", ] @@ -4050,12 +4123,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - [[package]] name = "untrusted" version = "0.9.0" @@ -4064,14 +4131,15 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -4094,13 +4162,13 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.18.1" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" dependencies = [ "getrandom 0.3.4", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -4202,9 +4270,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -4215,11 +4283,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.55" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -4228,9 +4297,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4238,9 +4307,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", @@ -4251,18 +4320,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -4294,14 +4363,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.4", + "webpki-roots 1.0.6", ] [[package]] name = "webpki-roots" -version = "1.0.4" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ "rustls-pki-types", ] @@ -4474,13 +4543,13 @@ dependencies = [ [[package]] name = "windows-registry" -version = "0.5.3" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -4770,9 +4839,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.13" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] @@ -4814,9 +4883,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.12.0" +version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" dependencies = [ "async-broadcast", "async-recursion", @@ -4826,8 +4895,9 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix", + "libc", "ordered-stream", + "rustix 1.1.3", "serde", "serde_repr", "tokio", @@ -4843,9 +4913,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.12.0" +version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -4858,30 +4928,29 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.2.0" +version = "4.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "static_assertions", "winnow", "zvariant", ] [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "57cf3aa6855b23711ee9852dfc97dfaa51c45feaba5b645d0c777414d494a961" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "8a616990af1a287837c4fe6596ad77ef57948f787e46ce28e166facc0cc1cb75" dependencies = [ "proc-macro2", "quote", @@ -4948,11 +5017,17 @@ dependencies = [ "syn", ] +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" + [[package]] name = "zvariant" -version = "5.8.0" +version = "5.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" dependencies = [ "endi", "enumflags2", @@ -4964,9 +5039,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.8.0" +version = "5.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -4977,9 +5052,9 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 71df187bd..0860ae5f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -168,7 +168,6 @@ env_logger = { version = "0.11.2", default-features = false, features = [ "auto-color", ] } futures-util = { version = "0.3", default-features = false } -getopts = "0.2" log = "0.4" sha1 = "0.10" sysinfo = { version = "0.36", default-features = false, features = ["system"] } @@ -181,6 +180,12 @@ tokio = { version = "1", features = [ "process", ] } url = "2.2" +clap = { version = "4.5.54", features = ["derive"] } +clap-verbosity-flag = "3.0.4" +serde = { version = "1.0.228", features = ["derive"]} + +[build-dependencies] +cfg_aliases = "0.2.1" [package.metadata.deb] maintainer = "Librespot Organization " diff --git a/build.rs b/build.rs new file mode 100644 index 000000000..05b9c0185 --- /dev/null +++ b/build.rs @@ -0,0 +1,7 @@ +use cfg_aliases::cfg_aliases; + +fn main() { + cfg_aliases! { + discovery: { any(feature = "with-libmdns", feature = "with-dns-sd", feature = "with-avahi") } + } +} diff --git a/connect/src/state.rs b/connect/src/state.rs index f810d8518..11082b05e 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -97,17 +97,26 @@ pub struct ConnectConfig { impl Default for ConnectConfig { fn default() -> Self { Self { - name: "librespot".to_string(), - device_type: DeviceType::Speaker, + name: ConnectConfig::DEFAULT_NAME.to_string(), + device_type: DeviceType::default(), is_group: false, - initial_volume: u16::MAX / 2, + initial_volume: ConnectConfig::DEFAULT_INITIAL_VOLUME, disable_volume: false, - volume_steps: 64, + volume_steps: ConnectConfig::DEFAULT_VOLUME_STEPS, emit_set_queue_events: false, } } } +impl ConnectConfig { + /// Default name + pub const DEFAULT_NAME: &str = "librespot"; + /// Default initial_volume + pub const DEFAULT_INITIAL_VOLUME: u16 = u16::MAX / 2; + /// Default volume_steps + pub const DEFAULT_VOLUME_STEPS: u16 = 64; +} + #[derive(Default, Debug)] pub(super) struct ConnectState { /// the entire state that is updated to the remote server diff --git a/core/Cargo.toml b/core/Cargo.toml index db963caf3..38c920b3a 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -108,6 +108,7 @@ tokio-tungstenite = { version = "0.28", default-features = false } tokio-util = { version = "0.7", default-features = false } url = "2" uuid = { version = "1", default-features = false, features = ["v4"] } +clap = { version = "4.5.54", default-features = false } [build-dependencies] rand = { version = "0.9", default-features = false, features = ["thread_rng"] } diff --git a/core/src/config.rs b/core/src/config.rs index 1b81123c3..86a781596 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -1,6 +1,8 @@ -use std::{fmt, path::PathBuf, str::FromStr}; +use std::path::PathBuf; +use clap::ValueEnum; use librespot_protocol::devices::DeviceType as ProtoDeviceType; +use serde::{Deserialize, Serialize}; use url::Url; pub(crate) const KEYMASTER_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; @@ -31,15 +33,21 @@ pub struct SessionConfig { pub autoplay: Option, } +impl Default for SessionConfig { + fn default() -> Self { + Self::default_for_os(OS) + } +} + impl SessionConfig { pub(crate) fn default_for_os(os: &str) -> Self { - let device_id = uuid::Uuid::new_v4().as_hyphenated().to_string(); let client_id = match os { "android" => ANDROID_CLIENT_ID, "ios" => IOS_CLIENT_ID, _ => KEYMASTER_CLIENT_ID, } .to_owned(); + let device_id = uuid::Uuid::new_v4().as_hyphenated().to_string(); Self { client_id, @@ -52,13 +60,21 @@ impl SessionConfig { } } -impl Default for SessionConfig { - fn default() -> Self { - Self::default_for_os(OS) - } -} - -#[derive(Clone, Copy, Debug, Hash, PartialOrd, Ord, PartialEq, Eq, Default)] +#[derive( + Clone, + Copy, + Debug, + Hash, + PartialOrd, + Ord, + PartialEq, + Eq, + Default, + ValueEnum, + Serialize, + Deserialize, +)] +#[clap(rename_all = "lower")] pub enum DeviceType { Unknown = 0, Computer = 1, @@ -81,67 +97,9 @@ pub enum DeviceType { Observer = 102, } -impl FromStr for DeviceType { - type Err = (); - fn from_str(s: &str) -> Result { - use self::DeviceType::*; - match s.to_lowercase().as_ref() { - "computer" => Ok(Computer), - "tablet" => Ok(Tablet), - "smartphone" => Ok(Smartphone), - "speaker" => Ok(Speaker), - "tv" => Ok(Tv), - "avr" => Ok(Avr), - "stb" => Ok(Stb), - "audiodongle" => Ok(AudioDongle), - "gameconsole" => Ok(GameConsole), - "castaudio" => Ok(CastAudio), - "castvideo" => Ok(CastVideo), - "automobile" => Ok(Automobile), - "smartwatch" => Ok(Smartwatch), - "chromebook" => Ok(Chromebook), - "carthing" => Ok(CarThing), - _ => Err(()), - } - } -} - -impl From<&DeviceType> for &str { - fn from(d: &DeviceType) -> &'static str { - use self::DeviceType::*; - match d { - Unknown => "Unknown", - Computer => "Computer", - Tablet => "Tablet", - Smartphone => "Smartphone", - Speaker => "Speaker", - Tv => "TV", - Avr => "AVR", - Stb => "STB", - AudioDongle => "AudioDongle", - GameConsole => "GameConsole", - CastAudio => "CastAudio", - CastVideo => "CastVideo", - Automobile => "Automobile", - Smartwatch => "Smartwatch", - Chromebook => "Chromebook", - UnknownSpotify => "UnknownSpotify", - CarThing => "CarThing", - Observer => "Observer", - } - } -} - -impl From for &str { - fn from(d: DeviceType) -> &'static str { - (&d).into() - } -} - -impl fmt::Display for DeviceType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let str: &str = self.into(); - f.write_str(str) +impl From for String { + fn from(value: DeviceType) -> Self { + format!("{value:?}") } } diff --git a/core/src/session.rs b/core/src/session.rs index 333678fd2..8c535bc9e 100644 --- a/core/src/session.rs +++ b/core/src/session.rs @@ -98,7 +98,7 @@ struct SessionData { } struct SessionInternal { - config: SessionConfig, + config: Arc, data: RwLock, http_client: HttpClient, @@ -117,20 +117,14 @@ struct SessionInternal { handle: tokio::runtime::Handle, } -/// A shared reference to a Spotify session. -/// -/// After instantiating, you need to login via [Session::connect]. -/// You can either implement the whole playback logic yourself by using -/// this structs interface directly or hand it to a -/// `Player`. -/// -/// *Note*: [Session] instances cannot yet be reused once invalidated. After -/// an unexpectedly closed connection, you'll need to create a new [Session]. -#[derive(Clone)] -pub struct Session(Arc); +impl Drop for SessionInternal { + fn drop(&mut self) { + debug!("drop Session"); + } +} -impl Session { - pub fn new(config: SessionConfig, cache: Option) -> Self { +impl SessionInternal { + pub fn new(config: Arc, cache: Option>) -> Self { let http_client = HttpClient::new(config.proxy.as_ref()); debug!("new Session"); @@ -142,12 +136,12 @@ impl Session { ..SessionData::default() }; - Self(Arc::new(SessionInternal { + Self { config, data: RwLock::new(session_data), http_client, tx_connection: OnceLock::new(), - cache: cache.map(Arc::new), + cache, apresolver: OnceLock::new(), audio_key: OnceLock::new(), channel: OnceLock::new(), @@ -157,7 +151,35 @@ impl Session { token_provider: OnceLock::new(), login5: OnceLock::new(), handle: tokio::runtime::Handle::current(), - })) + } + } +} + +/// A shared reference to a Spotify session. +/// +/// After instantiating, you need to login via [Session::connect]. +/// You can either implement the whole playback logic yourself by using +/// this structs interface directly or hand it to a +/// `Player`. +/// +/// *Note*: [Session] instances cannot yet be reused once invalidated. After +/// an unexpectedly closed connection, you'll need to create a new [Session]. +#[derive(Clone)] +pub struct Session(Arc); + +impl Session { + pub fn new(config: SessionConfig, cache: Option) -> Self { + Self(Arc::new(SessionInternal::new( + Arc::new(config), + cache.map(Arc::new), + ))) + } + + pub fn renew(&self) -> Session { + Self(Arc::new(SessionInternal::new( + self.0.config.clone(), + self.0.cache.clone(), + ))) } async fn connect_inner( @@ -661,12 +683,6 @@ impl SessionWeak { } } -impl Drop for SessionInternal { - fn drop(&mut self) { - debug!("drop Session"); - } -} - #[derive(Clone, Copy, Default, Debug, PartialEq)] enum KeepAliveState { #[default] diff --git a/core/src/spclient.rs b/core/src/spclient.rs index e9ca99317..da72d2e1d 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -176,7 +176,7 @@ impl SpClient { let client_data = request.mut_client_data(); - client_data.client_version = spotify_semantic_version(); + client_data.client_version = spotify_semantic_version().to_string(); // Current state of affairs: keymaster ID works on all tested platforms, but may be phased out, // so it seems a good idea to mimick the real clients. `self.session().client_id()` returns the diff --git a/core/src/version.rs b/core/src/version.rs index 0e48a47ad..5efac20c1 100644 --- a/core/src/version.rs +++ b/core/src/version.rs @@ -45,9 +45,20 @@ pub fn spotify_version() -> String { } } -pub fn spotify_semantic_version() -> String { +pub fn spotify_semantic_version() -> &'static str { match crate::config::OS { - "android" | "ios" => SPOTIFY_MOBILE_VERSION.to_owned(), - _ => SPOTIFY_SEMANTIC_VERSION.to_string(), + "android" | "ios" => SPOTIFY_MOBILE_VERSION, + _ => SPOTIFY_SEMANTIC_VERSION, } } + +pub fn libresport_version() -> String { + #[cfg(debug_assertions)] + const BUILD_PROFILE: &str = "debug"; + #[cfg(not(debug_assertions))] + const BUILD_PROFILE: &str = "release"; + + format!( + "librespot {SEMVER} {SHA_SHORT} (Built on {BUILD_DATE}, Build ID: {BUILD_ID}, Profile: {BUILD_PROFILE})" + ) +} diff --git a/discovery/Cargo.toml b/discovery/Cargo.toml index c7cf4f99f..2ce6e64a3 100644 --- a/discovery/Cargo.toml +++ b/discovery/Cargo.toml @@ -8,12 +8,20 @@ description = "The discovery logic for librespot" repository.workspace = true edition.workspace = true +[[example]] +name = "discovery_group" +required-features = ["default"] + +[[example]] +name = "discovery" +required-features = ["default"] + [features] # Refer to the workspace Cargo.toml for the list of features default = ["with-libmdns", "native-tls"] # Discovery backends -with-avahi = ["dep:serde", "dep:zbus", "futures-util/async-await-macro"] +with-avahi = ["dep:zbus", "futures-util/async-await-macro"] with-dns-sd = ["dep:dns-sd"] with-libmdns = ["dep:libmdns"] @@ -46,7 +54,7 @@ log = "0.4" rand = { version = "0.9", default-features = false, features = ["thread_rng"] } serde = { version = "1", default-features = false, features = [ "derive", -], optional = true } +]} serde_repr = "0.1" serde_json = "1.0" sha1 = "0.10" @@ -55,11 +63,16 @@ tokio = { version = "1", features = ["sync", "rt"] } zbus = { version = "5", default-features = false, features = [ "tokio", ], optional = true } +clap = { version = "4.5.54", default-features = false } +enum-assoc = "1.2.4" [dev-dependencies] futures = "0.3" hex = "0.4" tokio = { version = "1", features = ["macros", "rt"] } +[build-dependencies] +cfg_aliases = "0.2.1" + [lints] workspace = true diff --git a/discovery/build.rs b/discovery/build.rs new file mode 100644 index 000000000..05b9c0185 --- /dev/null +++ b/discovery/build.rs @@ -0,0 +1,7 @@ +use cfg_aliases::cfg_aliases; + +fn main() { + cfg_aliases! { + discovery: { any(feature = "with-libmdns", feature = "with-dns-sd", feature = "with-avahi") } + } +} diff --git a/discovery/examples/discovery.rs b/discovery/examples/discovery.rs index 6d41563e0..27a857cb1 100644 --- a/discovery/examples/discovery.rs +++ b/discovery/examples/discovery.rs @@ -8,12 +8,15 @@ async fn main() { let name = "Librespot"; let device_id = hex::encode(Sha1::digest(name.as_bytes())); - let mut server = - librespot_discovery::Discovery::builder(device_id, SessionConfig::default().client_id) - .name(name) - .device_type(DeviceType::Computer) - .launch() - .unwrap(); + let mut server = librespot_discovery::Discovery::builder( + String::from(name), + device_id, + SessionConfig::default().client_id, + None, + ) + .device_type(DeviceType::Computer) + .build() + .unwrap(); while let Some(x) = server.next().await { println!("Received {x:?}"); diff --git a/discovery/examples/discovery_group.rs b/discovery/examples/discovery_group.rs index 3022781af..49edd945b 100644 --- a/discovery/examples/discovery_group.rs +++ b/discovery/examples/discovery_group.rs @@ -8,13 +8,16 @@ async fn main() { let name = "Librespot Group"; let device_id = hex::encode(Sha1::digest(name.as_bytes())); - let mut server = - librespot_discovery::Discovery::builder(device_id, SessionConfig::default().client_id) - .name(name) - .device_type(DeviceType::Speaker) - .is_group(true) - .launch() - .unwrap(); + let mut server = librespot_discovery::Discovery::builder( + String::from(name), + device_id, + SessionConfig::default().client_id, + None, + ) + .device_type(DeviceType::Speaker) + .is_group(true) + .build() + .unwrap(); while let Some(x) = server.next().await { println!("Received {x:?}"); diff --git a/discovery/src/lib.rs b/discovery/src/lib.rs index 0eaca82e2..b5c8aed37 100644 --- a/discovery/src/lib.rs +++ b/discovery/src/lib.rs @@ -1,3 +1,5 @@ +#![cfg(discovery)] + //! Advertises this device to Spotify clients in the local network. //! //! This device will show up in the list of "available devices". @@ -10,20 +12,20 @@ mod avahi; mod server; +use futures_core::Stream; use std::{ - borrow::Cow, error::Error as StdError, pin::Pin, task::{Context, Poll}, }; - -use futures_core::Stream; use thiserror::Error; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::mpsc; use self::server::DiscoveryServer; +use std::borrow::Cow; +use tokio::sync::oneshot; -pub use crate::core::Error; +use crate::core::Error; use librespot_core as core; /// Credentials to be used in [`librespot`](`librespot_core`). @@ -32,6 +34,12 @@ pub use crate::core::authentication::Credentials; /// Determining the icon in the list of available devices. pub use crate::core::config::DeviceType; +use clap::{Args, ValueEnum, value_parser}; +use enum_assoc::Assoc; +use serde::{Deserialize, Serialize}; + +use std::net::{AddrParseError, IpAddr}; + pub enum DiscoveryEvent { Credentials(Credentials), ServerError(DiscoveryError), @@ -63,77 +71,6 @@ impl DnsSdHandle { } } -pub type DnsSdServiceBuilder = fn( - Cow<'static, str>, - Vec, - u16, - mpsc::UnboundedSender, -) -> Result; - -// Default goes first: This matches the behaviour when feature flags were exlusive, i.e. when there -// was only `feature = "with-dns-sd"` or `not(feature = "with-dns-sd")` -pub const BACKENDS: &[( - &str, - // If None, the backend is known but wasn't compiled. - Option, -)] = &[ - #[cfg(feature = "with-avahi")] - ("avahi", Some(launch_avahi)), - #[cfg(not(feature = "with-avahi"))] - ("avahi", None), - #[cfg(feature = "with-dns-sd")] - ("dns-sd", Some(launch_dns_sd)), - #[cfg(not(feature = "with-dns-sd"))] - ("dns-sd", None), - #[cfg(feature = "with-libmdns")] - ("libmdns", Some(launch_libmdns)), - #[cfg(not(feature = "with-libmdns"))] - ("libmdns", None), -]; - -pub fn find(name: Option<&str>) -> Result { - if let Some(ref name) = name { - match BACKENDS.iter().find(|(id, _)| name == id) { - Some((_id, Some(launch_svc))) => Ok(*launch_svc), - Some((_id, None)) => Err(Error::unavailable(format!( - "librespot built without '{name}' support" - ))), - None => Err(Error::not_found(format!( - "unknown zeroconf backend '{name}'" - ))), - } - } else { - BACKENDS - .iter() - .find_map(|(_, launch_svc)| *launch_svc) - .ok_or(Error::unavailable( - "librespot built without zeroconf backends", - )) - } -} - -/// Makes this device visible to Spotify clients in the local network. -/// -/// `Discovery` implements the [`Stream`] trait. Every time this device -/// is selected in the list of available devices, it yields [`Credentials`]. -pub struct Discovery { - server: DiscoveryServer, - - /// An opaque handle to the DNS-SD service. Dropping this will unregister the service. - #[allow(unused)] - svc: DnsSdHandle, - - event_rx: mpsc::UnboundedReceiver, -} - -/// A builder for [`Discovery`]. -pub struct Builder { - server_config: server::Config, - port: u16, - zeroconf_ip: Vec, - zeroconf_backend: Option, -} - /// Errors that can occur while setting up a [`Discovery`] instance. #[derive(Debug, Error)] pub enum DiscoveryError { @@ -151,6 +88,9 @@ pub enum DiscoveryError { #[error("Missing params for key {0}")] ParamsError(&'static str), + + #[error("librespot compiled without zeroconf backend")] + NotCompiled, } #[cfg(feature = "with-avahi")] @@ -168,13 +108,37 @@ impl From for Error { DiscoveryError::HmacError(_) => Error::invalid_argument(err), DiscoveryError::HttpServerError(_) => Error::unavailable(err), DiscoveryError::ParamsError(_) => Error::invalid_argument(err), + DiscoveryError::NotCompiled => Error::do_not_use(err), } } } -#[allow(unused)] +#[derive(Clone, Copy, Debug, Default, ValueEnum, Assoc, Serialize, Deserialize)] +#[ + func(pub fn launch(&self, + name: Cow<'static, str>, + zeroconf_ip: Vec, + port: u16, + status_tx: mpsc::UnboundedSender + ) -> Result + ) +] +pub enum ZeroconfBackend { + #[cfg(feature = "with-avahi")] + #[default] + #[assoc(launch = launch_avahi(name, zeroconf_ip, port, status_tx))] + Avahi, + #[cfg(feature = "with-dns-sd")] + #[cfg_attr(not(feature = "with-avahi"), default)] + #[assoc(launch = launch_dns_sd(name, zeroconf_ip, port, status_tx))] + DnsSd, + #[cfg(feature = "with-libmdns")] + #[cfg_attr(not(any(feature = "with-dns-sd", feature = "with-avahi")), default)] + #[assoc(launch = launch_libmdns(name, zeroconf_ip, port, status_tx))] + Libmdns, +} + const DNS_SD_SERVICE_NAME: &str = "_spotify-connect._tcp"; -#[allow(unused)] const TXT_RECORD: [&str; 2] = ["VERSION=1.0", "CPath=/"]; #[cfg(feature = "with-avahi")] @@ -427,12 +391,49 @@ fn launch_libmdns( }) } -impl Builder { +pub fn zeroconf_inteface_parser(value: &str) -> Result { + value.trim().parse::() +} + +#[derive(Serialize, Deserialize, Args)] +pub struct DiscoveryConfig { + /// The port the internal server advertises over zeroconf 1 - 65535. + /// Ports bellow 1025 may require root privileges. + /// Value 0 means any port + #[arg(long, short='z', verbatim_doc_comment, value_parser=value_parser!(u16), default_value_t=0, conflicts_with("disable_discovery"))] + pub zeroconf_port: u16, + + /// Comma-separated interface IP addresses on which zeroconf will bind. + /// Defaults to all interfaces. + /// Ignored by DNS-SD. + #[arg(long, short = 'i', verbatim_doc_comment, value_delimiter(','), value_parser=zeroconf_inteface_parser, conflicts_with("disable_discovery"))] + pub zeroconf_interface: Vec, + + /// Zeroconf (MDNS/DNS-SD) backend to use. + #[arg( + long, + verbatim_doc_comment, + value_enum, + default_value_t, + conflicts_with("disable_discovery") + )] + pub zeroconf_backend: ZeroconfBackend, +} + +/// A builder for [`Discovery`]. +pub struct DiscoveryBuilder { + server_config: server::Config, + port: u16, + zeroconf_ip: Vec, + zeroconf_backend: Option, +} + +impl DiscoveryBuilder { /// Starts a new builder using the provided device and client IDs. - pub fn new>(device_id: T, client_id: T) -> Self { + pub fn new>(name: T, device_id: T, client_id: T) -> Self { Self { server_config: server::Config { - name: "Librespot".into(), + name: Cow::Owned(name.into()), device_type: DeviceType::default(), is_group: false, device_id: device_id.into(), @@ -445,11 +446,11 @@ impl Builder { } } - /// Sets the name to be displayed. Default is `"Librespot"`. - pub fn name(mut self, name: impl Into>) -> Self { - self.server_config.name = name.into(); - self - } + // /// Sets the name to be displayed. Default is `"Librespot"`. + // pub fn name(mut self, name: impl Into>) -> Self { + // self.server_config.name = name.into(); + // self + // } /// Sets the device type which is visible as icon in other Spotify clients. Default is `Speaker`. pub fn device_type(mut self, device_type: DeviceType) -> Self { @@ -485,7 +486,7 @@ impl Builder { } /// Set the zeroconf (MDNS and DNS-SD) implementation to use. - pub fn zeroconf_backend(mut self, zeroconf_backend: DnsSdServiceBuilder) -> Self { + pub fn zeroconf_backend(mut self, zeroconf_backend: ZeroconfBackend) -> Self { self.zeroconf_backend = Some(zeroconf_backend); self } @@ -501,17 +502,20 @@ impl Builder { /// /// # Errors /// If setting up the mdns service or creating the server fails, this function returns an error. - pub fn launch(self) -> Result { + pub fn build(self) -> Result { let name = self.server_config.name.clone(); - let zeroconf_ip = self.zeroconf_ip; + let zeroconf_ip = self.zeroconf_ip.clone(); let (event_tx, event_rx) = mpsc::unbounded_channel(); let mut port = self.port; + let server = DiscoveryServer::new(self.server_config, &mut port, event_tx.clone())?; - let launch_svc = self.zeroconf_backend.unwrap_or(find(None)?); - let svc = launch_svc(name, zeroconf_ip, port, event_tx)?; + let svc = + self.zeroconf_backend + .unwrap_or_default() + .launch(name, zeroconf_ip, port, event_tx)?; Ok(Discovery { server, svc, @@ -520,19 +524,44 @@ impl Builder { } } +/// Makes this device visible to Spotify clients in the local network. +/// +/// `Discovery` implements the [`Stream`] trait. Every time this device +/// is selected in the list of available devices, it yields [`Credentials`]. +pub struct Discovery { + server: DiscoveryServer, + + /// An opaque handle to the DNS-SD service. Dropping this will unregister the service. + svc: DnsSdHandle, + + event_rx: mpsc::UnboundedReceiver, +} + impl Discovery { /// Starts a [`Builder`] with the provided device id. - pub fn builder>(device_id: T, client_id: T) -> Builder { - Builder::new(device_id, client_id) + pub fn builder>( + name: T, + device_id: T, + client_id: T, + config: Option<&DiscoveryConfig>, + ) -> DiscoveryBuilder { + let mut builder = DiscoveryBuilder::new(name, device_id, client_id); + if let Some(discovery_config) = config { + builder = builder + .port(discovery_config.zeroconf_port) + .zeroconf_ip(discovery_config.zeroconf_interface.clone()) + .zeroconf_backend(discovery_config.zeroconf_backend) + }; + builder } /// Create a new instance with the specified device id and default paramaters. - pub fn new>(device_id: T, client_id: T) -> Result { - Self::builder(device_id, client_id).launch() + pub fn new>(name: T, device_id: T, client_id: T) -> Result { + Self::builder(name, device_id, client_id, None).build() } pub async fn shutdown(self) { - tokio::join!(self.server.shutdown(), self.svc.shutdown(),); + tokio::join!(self.server.shutdown(), self.svc.shutdown()); } } diff --git a/discovery/src/server.rs b/discovery/src/server.rs index f4ce6152e..4f8aec18b 100644 --- a/discovery/src/server.rs +++ b/discovery/src/server.rs @@ -74,7 +74,7 @@ impl RequestHandler { fn handle_get_info(&self) -> Response> { let public_key = BASE64.encode(self.keys.public_key()); - let device_type: &str = self.config.device_type.into(); + let device_type: String = self.config.device_type.into(); let active_user = self.active_user(); // options based on zeroconf guide, search for `groupStatus` on page diff --git a/examples/play.rs b/examples/play.rs index 32a860699..8a2c8c581 100644 --- a/examples/play.rs +++ b/examples/play.rs @@ -30,7 +30,7 @@ async fn main() { id: SpotifyId::from_base62(&args[2]).unwrap(), }; - let backend = audio_backend::find(None).unwrap(); + let backend_builder = audio_backend::AudioBackendBuilder::default(); println!("Connecting..."); let session = Session::new(session_config, None); @@ -40,7 +40,7 @@ async fn main() { } let player = Player::new(player_config, session, Box::new(NoOpVolume), move || { - backend(None, audio_format) + backend_builder.build(None, audio_format) }); player.load(track, true, 0); diff --git a/examples/play_connect.rs b/examples/play_connect.rs index 1be6345ba..537e3c57e 100644 --- a/examples/play_connect.rs +++ b/examples/play_connect.rs @@ -30,8 +30,8 @@ async fn main() -> Result<(), Error> { let mixer_config = MixerConfig::default(); let request_options = LoadRequestOptions::default(); - let sink_builder = audio_backend::find(None).unwrap(); - let mixer_builder = mixer::find(None).unwrap(); + let sink_builder = audio_backend::AudioBackendBuilder::default(); + let mixer_builder = mixer::MixerBuilder::default(); let cache = Cache::new(Some(CACHE), Some(CACHE), Some(CACHE_FILES), None)?; let credentials = cache @@ -50,13 +50,13 @@ async fn main() -> Result<(), Error> { })?; let session = Session::new(session_config, Some(cache)); - let mixer = mixer_builder(mixer_config)?; + let mixer = mixer_builder.build(mixer_config)?; let player = Player::new( player_config, session.clone(), mixer.get_soft_volume(), - move || sink_builder(None, audio_format), + move || sink_builder.build(None, audio_format), ); let (spirc, spirc_task) = diff --git a/playback/Cargo.toml b/playback/Cargo.toml index dd916b55e..3775a9ef9 100644 --- a/playback/Cargo.toml +++ b/playback/Cargo.toml @@ -58,6 +58,10 @@ shell-words = "1.1" thiserror = "2" tokio = { version = "1", features = ["rt-multi-thread", "sync"] } zerocopy = { version = "0.8", features = ["derive"] } +clap = { version = "4.5.54", default-features = false } +serde = { version = "1.0.228", features = ["derive"]} +enum-assoc = "1.2.4" +derive_builder = "0.20.2" # Backends alsa = { version = "0.10", optional = true } diff --git a/playback/src/audio_backend/alsa.rs b/playback/src/audio_backend/alsa.rs index bd2b4bf5c..424e16c0e 100644 --- a/playback/src/audio_backend/alsa.rs +++ b/playback/src/audio_backend/alsa.rs @@ -1,11 +1,15 @@ -use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult}; -use crate::config::AudioFormat; -use crate::convert::Converter; -use crate::decoder::AudioPacket; -use crate::{NUM_CHANNELS, SAMPLE_RATE}; -use alsa::device_name::HintIter; -use alsa::pcm::{Access, Format, Frames, HwParams, PCM}; -use alsa::{Direction, ValueOr}; +use crate::{ + NUM_CHANNELS, SAMPLE_RATE, + audio_backend::{Open, Sink, SinkAsBytes, SinkError, SinkResult}, + config::AudioFormat, + convert::Converter, + decoder::AudioPacket, +}; +use alsa::{ + Direction, ValueOr, + device_name::HintIter, + pcm::{Access, Format, Frames, HwParams, PCM}, +}; use std::process::exit; use thiserror::Error; @@ -386,30 +390,31 @@ fn open_device(dev_name: &str, format: AudioFormat) -> SinkResult<(PCM, usize)> } impl Open for AlsaSink { - fn open(device: Option, format: AudioFormat) -> Self { - let name = match device.as_deref() { - Some("?") => match list_compatible_devices() { - Ok(_) => { - exit(0); - } - Err(e) => { - error!("{e}"); - exit(1); - } - }, - Some(device) => device, - None => "default", + fn device_options() -> ! { + if let Err(e) = list_compatible_devices() { + error!("{e}"); + exit(1) + } else { + exit(0) + } + } + + fn open(device: Option, format: AudioFormat) -> Box { + let name = if let Some(device) = device.as_deref() { + device + } else { + "default" } .to_string(); info!("Using AlsaSink with format: {format:?}"); - Self { + Box::new(Self { pcm: None, format, device: name, period_buffer: vec![], - } + }) } } @@ -481,8 +486,6 @@ impl SinkAsBytes for AlsaSink { } impl AlsaSink { - pub const NAME: &'static str = "alsa"; - fn write_buf(&mut self) -> SinkResult<()> { if self.pcm.is_some() { let write_result = { diff --git a/playback/src/audio_backend/gstreamer.rs b/playback/src/audio_backend/gstreamer.rs index f41d43339..9d8945c76 100644 --- a/playback/src/audio_backend/gstreamer.rs +++ b/playback/src/audio_backend/gstreamer.rs @@ -1,23 +1,21 @@ -use std::sync::{Arc, Mutex}; - +use crate::{ + NUM_CHANNELS, SAMPLE_RATE, + audio_backend::{Open, Sink, SinkAsBytes, SinkError, SinkResult}, + config::AudioFormat, + convert::Converter, + decoder::AudioPacket, +}; use gstreamer::{ - State, + self as gst, State, event::{FlushStart, FlushStop}, prelude::*, }; - -use gstreamer as gst; use gstreamer_app as gst_app; use gstreamer_audio as gst_audio; +use std::sync::{Arc, Mutex}; const GSTREAMER_ASYNC_ERROR_POISON_MSG: &str = "gstreamer async error mutex should not be poisoned"; -use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult}; - -use crate::{ - NUM_CHANNELS, SAMPLE_RATE, config::AudioFormat, convert::Converter, decoder::AudioPacket, -}; - pub struct GstreamerSink { appsrc: gst_app::AppSrc, bufferpool: gst::BufferPool, @@ -27,7 +25,7 @@ pub struct GstreamerSink { } impl Open for GstreamerSink { - fn open(device: Option, format: AudioFormat) -> Self { + fn open(device: Option, format: AudioFormat) -> Box { info!("Using GStreamer sink with format: {format:?}"); gst::init().expect("failed to init GStreamer!"); @@ -131,13 +129,13 @@ impl Open for GstreamerSink { .set_state(State::Ready) .expect("unable to set the pipeline to the `Ready` state"); - Self { + Box::new(Self { appsrc, bufferpool, pipeline, format, async_error, - } + }) } } @@ -210,7 +208,3 @@ impl SinkAsBytes for GstreamerSink { Ok(()) } } - -impl GstreamerSink { - pub const NAME: &'static str = "gstreamer"; -} diff --git a/playback/src/audio_backend/jackaudio.rs b/playback/src/audio_backend/jackaudio.rs index 84b13b6f0..921678512 100644 --- a/playback/src/audio_backend/jackaudio.rs +++ b/playback/src/audio_backend/jackaudio.rs @@ -1,8 +1,10 @@ -use super::{Open, Sink, SinkError, SinkResult}; -use crate::NUM_CHANNELS; -use crate::config::AudioFormat; -use crate::convert::Converter; -use crate::decoder::AudioPacket; +use crate::{ + NUM_CHANNELS, + audio_backend::{Open, Sink, SinkError, SinkResult}, + config::AudioFormat, + convert::Converter, + decoder::AudioPacket, +}; use jack::{ AsyncClient, AudioOut, Client, ClientOptions, Control, Port, ProcessHandler, ProcessScope, }; @@ -38,7 +40,7 @@ impl ProcessHandler for JackData { } impl Open for JackSink { - fn open(client_name: Option, format: AudioFormat) -> Self { + fn open(client_name: Option, format: AudioFormat) -> Box { if format != AudioFormat::F32 { warn!("JACK currently does not support {format:?} output"); } @@ -58,10 +60,10 @@ impl Open for JackSink { }; let active_client = AsyncClient::new(client, (), jack_data).unwrap(); - Self { + Box::new(Self { send: tx, active_client, - } + }) } } @@ -81,7 +83,3 @@ impl Sink for JackSink { Ok(()) } } - -impl JackSink { - pub const NAME: &'static str = "jackaudio"; -} diff --git a/playback/src/audio_backend/mod.rs b/playback/src/audio_backend/mod.rs index f8f43e3fa..9ea159ba8 100644 --- a/playback/src/audio_backend/mod.rs +++ b/playback/src/audio_backend/mod.rs @@ -1,6 +1,11 @@ +use std::process::exit; + use crate::config::AudioFormat; use crate::convert::Converter; use crate::decoder::AudioPacket; +use clap::ValueEnum; +use enum_assoc::Assoc; +use serde::{Deserialize, Serialize}; use thiserror::Error; #[derive(Debug, Error)] @@ -20,7 +25,11 @@ pub enum SinkError { pub type SinkResult = Result; pub trait Open { - fn open(_: Option, format: AudioFormat) -> Self; + fn device_options() -> ! { + println!("No device options available!"); + exit(0) + } + fn open(_: Option, format: AudioFormat) -> Box; } pub trait Sink { @@ -33,16 +42,10 @@ pub trait Sink { fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> SinkResult<()>; } -pub type SinkBuilder = fn(Option, AudioFormat) -> Box; - pub trait SinkAsBytes { fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()>; } -fn mk_sink(device: Option, format: AudioFormat) -> Box { - Box::new(S::open(device, format)) -} - // reuse code for various backends macro_rules! sink_as_bytes { () => { @@ -82,38 +85,24 @@ macro_rules! sink_as_bytes { #[cfg(feature = "alsa-backend")] mod alsa; -#[cfg(feature = "alsa-backend")] -use self::alsa::AlsaSink; #[cfg(feature = "portaudio-backend")] mod portaudio; -#[cfg(feature = "portaudio-backend")] -use self::portaudio::PortAudioSink; #[cfg(feature = "pulseaudio-backend")] mod pulseaudio; -#[cfg(feature = "pulseaudio-backend")] -use self::pulseaudio::PulseAudioSink; #[cfg(feature = "jackaudio-backend")] mod jackaudio; -#[cfg(feature = "jackaudio-backend")] -use self::jackaudio::JackSink; #[cfg(feature = "gstreamer-backend")] mod gstreamer; -#[cfg(feature = "gstreamer-backend")] -use self::gstreamer::GstreamerSink; #[cfg(any(feature = "rodio-backend", feature = "rodiojack-backend"))] mod rodio; -#[cfg(feature = "rodio-backend")] -use self::rodio::RodioSink; #[cfg(feature = "sdl-backend")] mod sdl; -#[cfg(feature = "sdl-backend")] -use self::sdl::SdlSink; mod pipe; use self::pipe::StdoutSink; @@ -121,34 +110,112 @@ use self::pipe::StdoutSink; mod subprocess; use self::subprocess::SubprocessSink; -pub const BACKENDS: &[(&str, SinkBuilder)] = &[ +#[derive(Default, Clone, Copy, Debug, ValueEnum, Assoc, Serialize, Deserialize)] +#[func(pub fn build(&self, device: Option, format: AudioFormat) -> Box)] +#[func(pub fn device_options(&self) -> !)] +pub enum AudioBackendBuilder { #[cfg(feature = "rodio-backend")] - (RodioSink::NAME, rodio::mk_rodio), // default goes first + #[default] + #[assoc(build = rodio::open_rodio(device, format))] + #[assoc(device_options = rodio::rodio_device_options())] + Rodio, #[cfg(feature = "alsa-backend")] - (AlsaSink::NAME, mk_sink::), + #[cfg_attr(not(feature = "rodio-backend"), default)] + #[assoc(build = alsa::AlsaSink::open(device, format))] + #[assoc(device_options = alsa::AlsaSink::device_options())] + Alsa, #[cfg(feature = "portaudio-backend")] - (PortAudioSink::NAME, mk_sink::>), + #[cfg_attr(not(any(feature = "rodio-backend", feature = "alsa-backend")), default)] + #[assoc(build = portaudio::PortAudioSink::<'_>::open(device, format))] + #[assoc(device_options = portaudio::PortAudioSink::<'_>::device_options())] + Portaudio, #[cfg(feature = "pulseaudio-backend")] - (PulseAudioSink::NAME, mk_sink::), + #[cfg_attr( + not(any( + feature = "rodio-backend", + feature = "alsa-backend", + feature = "portaudio-backend" + )), + default + )] + #[assoc(build = pulseaudio::PulseAudioSink::open(device, format))] + #[assoc(device_options = pulseaudio::PulseAudioSink::device_options())] + Pulseaudio, #[cfg(feature = "jackaudio-backend")] - (JackSink::NAME, mk_sink::), + #[cfg_attr( + not(any( + feature = "rodio-backend", + feature = "alsa-backend", + feature = "portaudio-backend", + feature = "pulseaudio-backend" + )), + default + )] + #[assoc(build = jackaudio::JackSink::open(device, format))] + #[assoc(device_options = jackaudio::JackSink::device_options())] + Jackaudio, #[cfg(feature = "gstreamer-backend")] - (GstreamerSink::NAME, mk_sink::), + #[cfg_attr( + not(any( + feature = "rodio-backend", + feature = "alsa-backend", + feature = "portaudio-backend", + feature = "pulseaudio-backend", + feature = "jackaudio-backend" + )), + default + )] + #[assoc(build = gstreamer::GstreamerSink::open(device, format))] + #[assoc(device_options = gstreamer::GstreamerSink::device_options())] + Gstreamer, #[cfg(feature = "rodiojack-backend")] - ("rodiojack", rodio::mk_rodiojack), + #[cfg_attr( + not(any( + feature = "rodio-backend", + feature = "alsa-backend", + feature = "portaudio-backend", + feature = "pulseaudio-backend", + feature = "jackaudio-backend", + feature = "gstreamer-backend" + )), + default + )] + #[assoc(build = rodio::open_rodiojack(device, format))] + #[assoc(device_options = rodio::rodiojack_device_options())] + Rodiojack, #[cfg(feature = "sdl-backend")] - (SdlSink::NAME, mk_sink::), - (StdoutSink::NAME, mk_sink::), - (SubprocessSink::NAME, mk_sink::), -]; - -pub fn find(name: Option) -> Option { - if let Some(name) = name { - BACKENDS - .iter() - .find(|backend| name == backend.0) - .map(|backend| backend.1) - } else { - BACKENDS.first().map(|backend| backend.1) - } + #[cfg_attr( + not(any( + feature = "rodio-backend", + feature = "alsa-backend", + feature = "portaudio-backend", + feature = "pulseaudio-backend", + feature = "jackaudio-backend", + feature = "gstreamer-backend", + feature = "rodiojack-backend" + )), + default + )] + #[assoc(build = sdl::SdlSink::open(device, format))] + #[assoc(device_options = sdl::SdlSink::device_options())] + Sdl, + #[cfg_attr( + not(any( + feature = "rodio-backend", + feature = "alsa-backend", + feature = "portaudio-backend", + feature = "pulseaudio-backend", + feature = "jackaudio-backend", + feature = "gstreamer-backend", + feature = "rodiojack-backend", + feature = "sdl-backend" + )), + default + )] + #[assoc(build = StdoutSink::open(device, format))] + #[assoc(device_options = StdoutSink::device_options())] + Pipe, + #[assoc(build = SubprocessSink::open(device, format))] + #[assoc(device_options = SubprocessSink::device_options())] + Subprocess, } diff --git a/playback/src/audio_backend/pipe.rs b/playback/src/audio_backend/pipe.rs index 8dfd21ea4..43b65c021 100644 --- a/playback/src/audio_backend/pipe.rs +++ b/playback/src/audio_backend/pipe.rs @@ -1,11 +1,14 @@ -use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult}; -use crate::config::AudioFormat; -use crate::convert::Converter; -use crate::decoder::AudioPacket; - -use std::fs::OpenOptions; -use std::io::{self, Write}; -use std::process::exit; +use crate::{ + audio_backend::{Open, Sink, SinkAsBytes, SinkError, SinkResult}, + config::AudioFormat, + convert::Converter, + decoder::AudioPacket, +}; +use std::{ + fs::OpenOptions, + io::{self, Write}, + process::exit, +}; use thiserror::Error; #[derive(Debug, Error)] @@ -42,21 +45,20 @@ pub struct StdoutSink { } impl Open for StdoutSink { - fn open(file: Option, format: AudioFormat) -> Self { - if let Some("?") = file.as_deref() { - println!( - "\nUsage:\n\nOutput to stdout:\n\n\t--backend pipe\n\nOutput to file:\n\n\t--backend pipe --device {{filename}}\n" - ); - exit(0); - } - + fn device_options() -> ! { + println!( + "\nUsage:\n\nOutput to stdout:\n\n\t--backend pipe\n\nOutput to file:\n\n\t--backend pipe --device {{filename}}\n" + ); + exit(0) + } + fn open(file: Option, format: AudioFormat) -> Box { info!("Using StdoutSink (pipe) with format: {format:?}"); - Self { + Box::new(Self { output: None, file, format, - } + }) } } @@ -107,7 +109,3 @@ impl SinkAsBytes for StdoutSink { Ok(()) } } - -impl StdoutSink { - pub const NAME: &'static str = "pipe"; -} diff --git a/playback/src/audio_backend/portaudio.rs b/playback/src/audio_backend/portaudio.rs index f8b284f29..ebf64bcc2 100644 --- a/playback/src/audio_backend/portaudio.rs +++ b/playback/src/audio_backend/portaudio.rs @@ -1,10 +1,14 @@ -use super::{Open, Sink, SinkError, SinkResult}; -use crate::config::AudioFormat; -use crate::convert::Converter; -use crate::decoder::AudioPacket; -use crate::{NUM_CHANNELS, SAMPLE_RATE}; -use portaudio_rs::device::{DeviceIndex, DeviceInfo, get_default_output_index}; -use portaudio_rs::stream::*; +use crate::{ + NUM_CHANNELS, SAMPLE_RATE, + audio_backend::{Open, Sink, SinkError, SinkResult}, + config::AudioFormat, + convert::Converter, + decoder::AudioPacket, +}; +use portaudio_rs::{ + device::{DeviceIndex, DeviceInfo, get_default_output_index}, + stream::{FRAMES_PER_BUFFER_UNSPECIFIED, Stream, StreamFlags, StreamParameters}, +}; use std::process::exit; use std::time::Duration; @@ -51,18 +55,19 @@ fn find_output(device: &str) -> Option { } impl<'a> Open for PortAudioSink<'a> { - fn open(device: Option, format: AudioFormat) -> PortAudioSink<'a> { + fn device_options() -> ! { + list_outputs(); + exit(0) + } + fn open(device: Option, format: AudioFormat) -> Box> { info!("Using PortAudio sink with format: {format:?}"); portaudio_rs::initialize().unwrap(); - let device_idx = match device.as_deref() { - Some("?") => { - list_outputs(); - exit(0) - } - Some(device) => find_output(device), - None => get_default_output_index(), + let device_idx = if let Some(device) = device.as_deref() { + find_output(device) + } else { + get_default_output_index() } .expect("could not find device"); @@ -80,7 +85,7 @@ impl<'a> Open for PortAudioSink<'a> { suggested_latency: latency, data: 0.0 as $type, }; - $sink(None, params) + Box::new($sink(None, params)) }}; } match format { @@ -180,7 +185,3 @@ impl Drop for PortAudioSink<'_> { portaudio_rs::terminate().unwrap(); } } - -impl PortAudioSink<'_> { - pub const NAME: &'static str = "portaudio"; -} diff --git a/playback/src/audio_backend/pulseaudio.rs b/playback/src/audio_backend/pulseaudio.rs index 0cc0850a8..131de0947 100644 --- a/playback/src/audio_backend/pulseaudio.rs +++ b/playback/src/audio_backend/pulseaudio.rs @@ -1,8 +1,10 @@ -use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult}; -use crate::config::AudioFormat; -use crate::convert::Converter; -use crate::decoder::AudioPacket; -use crate::{NUM_CHANNELS, SAMPLE_RATE}; +use crate::{ + NUM_CHANNELS, SAMPLE_RATE, + audio_backend::{Open, Sink, SinkAsBytes, SinkError, SinkResult}, + config::AudioFormat, + convert::Converter, + decoder::AudioPacket, +}; use libpulse_binding::{self as pulse, error::PAErr, stream::Direction}; use libpulse_simple_binding::Simple; use std::env; @@ -55,7 +57,7 @@ pub struct PulseAudioSink { } impl Open for PulseAudioSink { - fn open(device: Option, format: AudioFormat) -> Self { + fn open(device: Option, format: AudioFormat) -> Box { let app_name = env::var("PULSE_PROP_application.name").unwrap_or_default(); let stream_desc = env::var("PULSE_PROP_stream.description").unwrap_or_default(); @@ -68,13 +70,13 @@ impl Open for PulseAudioSink { info!("Using PulseAudioSink with format: {actual_format:?}"); - Self { + Box::new(Self { sink: None, device, app_name, stream_desc, format: actual_format, - } + }) } } @@ -146,7 +148,3 @@ impl SinkAsBytes for PulseAudioSink { Ok(()) } } - -impl PulseAudioSink { - pub const NAME: &'static str = "pulseaudio"; -} diff --git a/playback/src/audio_backend/rodio.rs b/playback/src/audio_backend/rodio.rs index b6cd34617..36574e803 100644 --- a/playback/src/audio_backend/rodio.rs +++ b/playback/src/audio_backend/rodio.rs @@ -1,16 +1,14 @@ -use std::process::exit; -use std::thread; -use std::time::Duration; - +use crate::{ + NUM_CHANNELS, SAMPLE_RATE, + audio_backend::{Sink, SinkError, SinkResult}, + config::AudioFormat, + convert::Converter, + decoder::AudioPacket, +}; use cpal::traits::{DeviceTrait, HostTrait}; +use std::{process::exit, thread, time::Duration}; use thiserror::Error; -use super::{Sink, SinkError, SinkResult}; -use crate::config::AudioFormat; -use crate::convert::Converter; -use crate::decoder::AudioPacket; -use crate::{NUM_CHANNELS, SAMPLE_RATE}; - #[cfg(all( feature = "rodiojack-backend", not(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd")) @@ -18,12 +16,17 @@ use crate::{NUM_CHANNELS, SAMPLE_RATE}; compile_error!("Rodio JACK backend is currently only supported on linux."); #[cfg(feature = "rodio-backend")] -pub fn mk_rodio(device: Option, format: AudioFormat) -> Box { +pub fn open_rodio(device: Option, format: AudioFormat) -> Box { Box::new(open(cpal::default_host(), device, format)) } +#[cfg(feature = "rodio-backend")] +pub fn rodio_device_options() -> ! { + list_options(&cpal::default_host()); +} + #[cfg(feature = "rodiojack-backend")] -pub fn mk_rodiojack(device: Option, format: AudioFormat) -> Box { +pub fn open_rodiojack(device: Option, format: AudioFormat) -> Box { Box::new(open( cpal::host_from_id(cpal::HostId::Jack).unwrap(), device, @@ -31,6 +34,11 @@ pub fn mk_rodiojack(device: Option, format: AudioFormat) -> Box ! { + list_options(&cpal::host_from_id(cpal::HostId::Jack).unwrap()); +} + #[derive(Debug, Error)] pub enum RodioError { #[error(" No Device Available")] @@ -142,28 +150,29 @@ fn list_outputs(host: &cpal::Host) -> Result<(), cpal::DevicesError> { Ok(()) } +fn list_options(host: &cpal::Host) -> ! { + match list_outputs(host) { + Ok(()) => exit(0), + Err(e) => { + error!("{e}"); + exit(1); + } + } +} + fn create_sink( host: &cpal::Host, device: Option, format: AudioFormat, ) -> Result<(rodio::Sink, rodio::OutputStream), RodioError> { - let cpal_device = match device.as_deref() { - Some("?") => match list_outputs(host) { - Ok(()) => exit(0), - Err(e) => { - error!("{e}"); - exit(1); - } - }, - Some(device_name) => { - // Ignore devices for which getting name fails, or format doesn't match - host.output_devices()? - .find(|d| d.name().ok().is_some_and(|name| name == device_name)) // Ignore devices for which getting name fails - .ok_or_else(|| RodioError::DeviceNotAvailable(device_name.to_string()))? - } - None => host - .default_output_device() - .ok_or(RodioError::NoDeviceAvailable)?, + let cpal_device = if let Some(device_name) = device.as_deref() { + // Ignore devices for which getting name fails, or format doesn't match + host.output_devices()? + .find(|d| d.name().ok().is_some_and(|name| name == device_name)) // Ignore devices for which getting name fails + .ok_or_else(|| RodioError::DeviceNotAvailable(device_name.to_string()))? + } else { + host.default_output_device() + .ok_or(RodioError::NoDeviceAvailable)? }; let name = cpal_device.name().ok(); @@ -262,8 +271,3 @@ impl Sink for RodioSink { Ok(()) } } - -impl RodioSink { - #[allow(dead_code)] - pub const NAME: &'static str = "rodio"; -} diff --git a/playback/src/audio_backend/sdl.rs b/playback/src/audio_backend/sdl.rs index 0d2209282..48460f013 100644 --- a/playback/src/audio_backend/sdl.rs +++ b/playback/src/audio_backend/sdl.rs @@ -1,11 +1,12 @@ -use super::{Open, Sink, SinkError, SinkResult}; -use crate::config::AudioFormat; -use crate::convert::Converter; -use crate::decoder::AudioPacket; -use crate::{NUM_CHANNELS, SAMPLE_RATE}; +use crate::{ + NUM_CHANNELS, SAMPLE_RATE, + audio_backend::{Open, Sink, SinkError, SinkResult}, + config::AudioFormat, + convert::Converter, + decoder::AudioPacket, +}; use sdl2::audio::{AudioQueue, AudioSpecDesired}; -use std::thread; -use std::time::Duration; +use std::{process::exit, thread, time::Duration}; pub enum SdlSink { F32(AudioQueue), @@ -14,7 +15,11 @@ pub enum SdlSink { } impl Open for SdlSink { - fn open(device: Option, format: AudioFormat) -> Self { + fn device_options() -> ! { + println!("SDL sink does not support specifying a device name"); + exit(0) + } + fn open(device: Option, format: AudioFormat) -> Box { info!("Using SDL sink with format: {:?}", format); if device.is_some() { @@ -37,7 +42,7 @@ impl Open for SdlSink { let queue: AudioQueue<$type> = audio .open_queue(None, &desired_spec) .expect("could not open SDL audio device"); - $sink(queue) + Box::new($sink(queue)) }}; } match format { @@ -115,7 +120,3 @@ impl Sink for SdlSink { result.map_err(SinkError::OnWrite) } } - -impl SdlSink { - pub const NAME: &'static str = "sdl"; -} diff --git a/playback/src/audio_backend/subprocess.rs b/playback/src/audio_backend/subprocess.rs index c624718b4..169b69cb2 100644 --- a/playback/src/audio_backend/subprocess.rs +++ b/playback/src/audio_backend/subprocess.rs @@ -1,11 +1,14 @@ -use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult}; -use crate::config::AudioFormat; -use crate::convert::Converter; -use crate::decoder::AudioPacket; +use crate::{ + audio_backend::{Open, Sink, SinkAsBytes, SinkError, SinkResult}, + config::AudioFormat, + convert::Converter, + decoder::AudioPacket, +}; use shell_words::split; - -use std::io::{ErrorKind, Write}; -use std::process::{Child, Command, Stdio, exit}; +use std::{ + io::{ErrorKind, Write}, + process::{Child, Command, Stdio, exit}, +}; use thiserror::Error; #[derive(Debug, Error)] @@ -66,21 +69,20 @@ pub struct SubprocessSink { } impl Open for SubprocessSink { - fn open(shell_command: Option, format: AudioFormat) -> Self { - if let Some("?") = shell_command.as_deref() { - println!( - "\nUsage:\n\nOutput to a Subprocess:\n\n\t--backend subprocess --device {{shell_command}}\n" - ); - exit(0); - } - + fn device_options() -> ! { + println!( + "\nUsage:\n\nOutput to a Subprocess:\n\n\t--backend subprocess --device {{shell_command}}\n" + ); + exit(0) + } + fn open(shell_command: Option, format: AudioFormat) -> Box { info!("Using SubprocessSink with format: {format:?}"); - Self { + Box::new(Self { shell_command, child: None, format, - } + }) } } @@ -192,8 +194,6 @@ impl SinkAsBytes for SubprocessSink { } impl SubprocessSink { - pub const NAME: &'static str = "subprocess"; - fn try_restart(&mut self, e: SubprocessError, restarted: &mut bool) -> SinkResult<()> { // If the restart fails throw the original error back. if !*restarted && self.stop().is_ok() && self.start().is_ok() { diff --git a/playback/src/config.rs b/playback/src/config.rs index 95d487130..6880008ff 100644 --- a/playback/src/config.rs +++ b/playback/src/config.rs @@ -1,29 +1,49 @@ -use std::{mem, path::PathBuf, str::FromStr, time::Duration}; - -pub use crate::dither::{DithererBuilder, TriangularDitherer, mk_ditherer}; -use crate::{convert::i24, player::duration_to_coefficient}; - -#[derive(Clone, Copy, Debug, Hash, PartialOrd, Ord, PartialEq, Eq, Default)] +use clap::ValueEnum; +use serde::{Deserialize, Serialize}; +use std::{path::PathBuf, time::Duration}; + +use crate::dither::DithererBuilder; +use crate::player::duration_to_coefficient; + +#[derive( + Clone, + Copy, + Debug, + Hash, + PartialOrd, + Ord, + PartialEq, + Eq, + Default, + ValueEnum, + Deserialize, + Serialize, +)] pub enum Bitrate { + #[clap(name = "96")] Bitrate96, #[default] + #[clap(name = "160")] Bitrate160, + #[clap(name = "320")] Bitrate320, } -impl FromStr for Bitrate { - type Err = (); - fn from_str(s: &str) -> Result { - match s { - "96" => Ok(Self::Bitrate96), - "160" => Ok(Self::Bitrate160), - "320" => Ok(Self::Bitrate320), - _ => Err(()), - } - } -} - -#[derive(Clone, Copy, Debug, Hash, PartialOrd, Ord, PartialEq, Eq, Default)] +#[derive( + Clone, + Copy, + Debug, + Hash, + PartialOrd, + Ord, + PartialEq, + Eq, + Default, + ValueEnum, + Deserialize, + Serialize, +)] +#[clap(rename_all = "verbatim")] pub enum AudioFormat { F64, F32, @@ -34,36 +54,38 @@ pub enum AudioFormat { S16, } -impl FromStr for AudioFormat { - type Err = (); - fn from_str(s: &str) -> Result { - match s.to_uppercase().as_ref() { - "F64" => Ok(Self::F64), - "F32" => Ok(Self::F32), - "S32" => Ok(Self::S32), - "S24" => Ok(Self::S24), - "S24_3" => Ok(Self::S24_3), - "S16" => Ok(Self::S16), - _ => Err(()), - } - } -} - +#[cfg(any( + feature = "gstreamer-backend", + feature = "jackaudio-backend", + feature = "sdl-backend" +))] +use std::mem; + +#[cfg(any( + feature = "gstreamer-backend", + feature = "jackaudio-backend", + feature = "sdl-backend" +))] +use crate::convert::i24; + +#[cfg(any( + feature = "gstreamer-backend", + feature = "jackaudio-backend", + feature = "sdl-backend" +))] impl AudioFormat { - // not used by all backends - #[allow(dead_code)] pub fn size(&self) -> usize { match self { Self::F64 => mem::size_of::(), Self::F32 => mem::size_of::(), + Self::S32 | Self::S24 => mem::size_of::(), Self::S24_3 => mem::size_of::(), Self::S16 => mem::size_of::(), - _ => mem::size_of::(), // S32 and S24 are both stored in i32 } } } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, ValueEnum, Serialize, Deserialize)] pub enum NormalisationType { Album, Track, @@ -71,36 +93,13 @@ pub enum NormalisationType { Auto, } -impl FromStr for NormalisationType { - type Err = (); - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_ref() { - "album" => Ok(Self::Album), - "track" => Ok(Self::Track), - "auto" => Ok(Self::Auto), - _ => Err(()), - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, ValueEnum, Serialize, Deserialize)] pub enum NormalisationMethod { Basic, #[default] Dynamic, } -impl FromStr for NormalisationMethod { - type Err = (); - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_ref() { - "basic" => Ok(Self::Basic), - "dynamic" => Ok(Self::Dynamic), - _ => Err(()), - } - } -} - #[derive(Clone)] pub struct PlayerConfig { pub bitrate: Bitrate, @@ -120,7 +119,7 @@ pub struct PlayerConfig { // pass function pointers so they can be lazily instantiated *after* spawning a thread // (thereby circumventing Send bounds that they might not satisfy) - pub ditherer: Option, + pub ditherer_builder: DithererBuilder, /// Setting this will enable periodically sending events during playback informing about the playback position /// To consume the PlayerEvent::PositionChanged event, listen to events via `Player::get_player_event_channel()`` pub position_update_interval: Option, @@ -134,55 +133,27 @@ impl Default for PlayerConfig { normalisation: false, normalisation_type: NormalisationType::default(), normalisation_method: NormalisationMethod::default(), - normalisation_pregain_db: 0.0, - normalisation_threshold_dbfs: -2.0, - normalisation_attack_cf: duration_to_coefficient(Duration::from_millis(5)), - normalisation_release_cf: duration_to_coefficient(Duration::from_millis(100)), - normalisation_knee_db: 5.0, + normalisation_pregain_db: PlayerConfig::DEFAULT_PREGAIN, + normalisation_threshold_dbfs: PlayerConfig::DEFAULT_THRESHOLD, + normalisation_attack_cf: duration_to_coefficient(Duration::from_millis( + PlayerConfig::DEFAULT_ATTACK, + )), + normalisation_release_cf: duration_to_coefficient(Duration::from_millis( + PlayerConfig::DEFAULT_RELEASE, + )), + normalisation_knee_db: PlayerConfig::DEFAULT_KNEE, passthrough: false, - ditherer: Some(mk_ditherer::), + ditherer_builder: DithererBuilder::default(), position_update_interval: None, local_file_directories: Vec::new(), } } } -// fields are intended for volume control range in dB -#[derive(Clone, Copy, Debug)] -pub enum VolumeCtrl { - Cubic(f64), - Fixed, - Linear, - Log(f64), -} - -impl FromStr for VolumeCtrl { - type Err = (); - fn from_str(s: &str) -> Result { - Self::from_str_with_range(s, Self::DEFAULT_DB_RANGE) - } -} - -impl Default for VolumeCtrl { - fn default() -> VolumeCtrl { - VolumeCtrl::Log(Self::DEFAULT_DB_RANGE) - } -} - -impl VolumeCtrl { - pub const MAX_VOLUME: u16 = u16::MAX; - - // Taken from: https://www.dr-lex.be/info-stuff/volumecontrols.html - pub const DEFAULT_DB_RANGE: f64 = 60.0; - - pub fn from_str_with_range(s: &str, db_range: f64) -> Result::Err> { - use self::VolumeCtrl::*; - match s.to_lowercase().as_ref() { - "cubic" => Ok(Cubic(db_range)), - "fixed" => Ok(Fixed), - "linear" => Ok(Linear), - "log" => Ok(Log(db_range)), - _ => Err(()), - } - } +impl PlayerConfig { + pub const DEFAULT_PREGAIN: f64 = 0.0; + pub const DEFAULT_THRESHOLD: f64 = -2.0; + pub const DEFAULT_ATTACK: u64 = 5; + pub const DEFAULT_RELEASE: u64 = 100; + pub const DEFAULT_KNEE: f64 = 5.0; } diff --git a/playback/src/convert.rs b/playback/src/convert.rs index 31e710da8..40e45e7d9 100644 --- a/playback/src/convert.rs +++ b/playback/src/convert.rs @@ -8,12 +8,11 @@ pub struct i24([u8; 3]); impl i24 { fn from_s24(sample: i32) -> Self { // trim the padding in the most significant byte - #[allow(unused_variables)] - let [a, b, c, d] = sample.to_ne_bytes(); #[cfg(target_endian = "little")] - return Self([a, b, c]); + let [a, b, c, _] = sample.to_ne_bytes(); #[cfg(target_endian = "big")] - return Self([b, c, d]); + let [_, a, b, c] = sample.to_ne_bytes(); + Self([a, b, c]) } } @@ -22,16 +21,10 @@ pub struct Converter { } impl Converter { - pub fn new(dither_config: Option) -> Self { - match dither_config { - Some(ditherer_builder) => { - let ditherer = (ditherer_builder)(); - info!("Converting with ditherer: {}", ditherer.name()); - Self { - ditherer: Some(ditherer), - } - } - None => Self { ditherer: None }, + pub fn new(ditherer_builder: DithererBuilder) -> Self { + info!("Converting with ditherer: {:?}", ditherer_builder); + Self { + ditherer: ditherer_builder.build(), } } diff --git a/playback/src/dither.rs b/playback/src/dither.rs index d08255874..a1e3d17d6 100644 --- a/playback/src/dither.rs +++ b/playback/src/dither.rs @@ -1,7 +1,10 @@ use rand::SeedableRng; use rand::rngs::SmallRng; use rand_distr::{Distribution, Normal, Triangular, Uniform}; -use std::fmt; + +use clap::ValueEnum; +use enum_assoc::Assoc; +use serde::{Deserialize, Serialize}; use crate::NUM_CHANNELS; @@ -11,7 +14,7 @@ use crate::NUM_CHANNELS; // // Guidance: // -// * On S24, S24_3 and S24, the default is to use triangular dithering. +// * On S16, S24 and S24_3, the default is to use triangular dithering. // Depending on personal preference you may use Gaussian dithering instead; // it's not as good objectively, but it may be preferred subjectively if // you are looking for a more "analog" sound akin to tape hiss. @@ -28,18 +31,26 @@ use crate::NUM_CHANNELS; // on S32 the noise level is so far down that it is simply inaudible even // after volume normalisation and control. // -pub trait Ditherer { + +#[derive(Default, Debug, Clone, Copy, Assoc, ValueEnum, Serialize, Deserialize)] +#[func(pub fn build(&self) -> Option>)] +pub enum DithererBuilder { + #[default] + #[assoc(build = Box::new(TriangularDitherer::new()))] + Tpdf, + #[assoc(build = Box::new(GaussianDitherer::new()))] + Gpdf, + #[assoc(build = Box::new(HighPassDitherer::new()))] + TpdfHp, + None, +} + +pub trait Ditherer: Send + Sync { fn new() -> Self where Self: Sized; - fn name(&self) -> &'static str; - fn noise(&mut self) -> f64; -} -impl fmt::Display for dyn Ditherer { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.name()) - } + fn noise(&mut self) -> f64; } fn create_rng() -> SmallRng { @@ -60,20 +71,12 @@ impl Ditherer for TriangularDitherer { } } - fn name(&self) -> &'static str { - Self::NAME - } - #[inline] fn noise(&mut self) -> f64 { self.distribution.sample(&mut self.cached_rng) } } -impl TriangularDitherer { - pub const NAME: &'static str = "tpdf"; -} - pub struct GaussianDitherer { cached_rng: SmallRng, distribution: Normal, @@ -95,20 +98,12 @@ impl Ditherer for GaussianDitherer { } } - fn name(&self) -> &'static str { - Self::NAME - } - #[inline] fn noise(&mut self) -> f64 { self.distribution.sample(&mut self.cached_rng) } } -impl GaussianDitherer { - pub const NAME: &'static str = "gpdf"; -} - pub struct HighPassDitherer { active_channel: usize, previous_noises: [f64; NUM_CHANNELS as usize], @@ -128,9 +123,9 @@ impl Ditherer for HighPassDitherer { } } - fn name(&self) -> &'static str { - Self::NAME - } + // fn name(&self) -> &'static str { + // Self::NAME + // } #[inline] fn noise(&mut self) -> f64 { @@ -141,22 +136,3 @@ impl Ditherer for HighPassDitherer { high_passed_noise } } - -impl HighPassDitherer { - pub const NAME: &'static str = "tpdf_hp"; -} - -pub fn mk_ditherer() -> Box { - Box::new(D::new()) -} - -pub type DithererBuilder = fn() -> Box; - -pub fn find_ditherer(name: Option) -> Option { - match name.as_deref() { - Some(TriangularDitherer::NAME) => Some(mk_ditherer::), - Some(GaussianDitherer::NAME) => Some(mk_ditherer::), - Some(HighPassDitherer::NAME) => Some(mk_ditherer::), - _ => None, - } -} diff --git a/playback/src/mixer/alsamixer.rs b/playback/src/mixer/alsamixer.rs index 90daaf17e..5998f003f 100644 --- a/playback/src/mixer/alsamixer.rs +++ b/playback/src/mixer/alsamixer.rs @@ -292,8 +292,6 @@ impl Mixer for AlsaMixer { } impl AlsaMixer { - pub const NAME: &'static str = "alsa"; - fn switched_off(&self) -> bool { if !self.has_switch { return false; diff --git a/playback/src/mixer/mod.rs b/playback/src/mixer/mod.rs index 89d03235b..5b63ce26e 100644 --- a/playback/src/mixer/mod.rs +++ b/playback/src/mixer/mod.rs @@ -1,25 +1,51 @@ -use crate::config::VolumeCtrl; use librespot_core::Error; use std::sync::Arc; pub mod mappings; use self::mappings::MappedCtrl; +use clap::ValueEnum; +use enum_assoc::Assoc; +use serde::{Deserialize, Serialize}; + pub struct NoOpVolume; -pub trait Mixer: Send + Sync { - fn open(config: MixerConfig) -> Result - where - Self: Sized; +/// Fields are intended for volume control range in dB +#[derive(Default, Clone, Copy, Debug, ValueEnum, Assoc, Serialize, Deserialize)] +#[func(pub fn build(&self, db_range: f64) -> VolumeCtrl)] +pub enum VolumeCtrlBuilder { + #[assoc(build = VolumeCtrl::Fixed)] + Fixed, + #[assoc(build = VolumeCtrl::Linear)] + Linear, + #[assoc(build = VolumeCtrl::Cubic(db_range))] + Cubic, + #[default] + #[assoc(build = VolumeCtrl::Log(db_range))] + Log, +} - fn volume(&self) -> u16; - fn set_volume(&self, volume: u16); +#[derive(Clone, Copy, Debug)] +pub enum VolumeCtrl { + Fixed, + Linear, + Cubic(f64), + Log(f64), +} - fn get_soft_volume(&self) -> Box { - Box::new(NoOpVolume) +impl Default for VolumeCtrl { + fn default() -> VolumeCtrl { + VolumeCtrl::Log(Self::DEFAULT_DB_RANGE) } } +impl VolumeCtrl { + pub const MAX_VOLUME: u16 = u16::MAX; + + // Taken from: https://www.dr-lex.be/info-stuff/volumecontrols.html + pub const DEFAULT_DB_RANGE: f64 = 60.0; +} + pub trait VolumeGetter { fn attenuation_factor(&self) -> f64; } @@ -31,15 +57,8 @@ impl VolumeGetter for NoOpVolume { } } -pub mod softmixer; -use self::softmixer::SoftMixer; - -#[cfg(feature = "alsa-backend")] -pub mod alsamixer; -#[cfg(feature = "alsa-backend")] -use self::alsamixer::AlsaMixer; - -#[derive(Debug, Clone)] +#[derive(Debug, Clone, derive_builder::Builder)] +#[builder(default)] pub struct MixerConfig { pub device: String, pub control: String, @@ -58,25 +77,41 @@ impl Default for MixerConfig { } } -pub type MixerFn = fn(MixerConfig) -> Result, Error>; +pub trait Mixer: Send + Sync { + fn open(config: MixerConfig) -> Result + where + Self: Sized; + + fn volume(&self) -> u16; + fn set_volume(&self, volume: u16); + + fn get_soft_volume(&self) -> Box { + Box::new(NoOpVolume) + } +} fn mk_sink(config: MixerConfig) -> Result, Error> { Ok(Arc::new(M::open(config)?)) } -pub const MIXERS: &[(&str, MixerFn)] = &[ - (SoftMixer::NAME, mk_sink::), // default goes first +pub mod softmixer; +use self::softmixer::SoftMixer; + +#[cfg(feature = "alsa-backend")] +pub mod alsamixer; +#[cfg(feature = "alsa-backend")] +use self::alsamixer::AlsaMixer; + +#[derive(Clone, Copy, Debug, Default, ValueEnum, Assoc, Serialize, Deserialize)] +#[func(pub fn build(&self, config: MixerConfig) -> Result, Error>)] +#[func(pub fn is_alsa(&self) -> bool)] +pub enum MixerBuilder { + #[assoc(build=mk_sink::(config))] + #[assoc(is_alsa = false)] + #[default] + Softvol, #[cfg(feature = "alsa-backend")] - (AlsaMixer::NAME, mk_sink::), -]; - -pub fn find(name: Option<&str>) -> Option { - if let Some(name) = name { - MIXERS - .iter() - .find(|mixer| name == mixer.0) - .map(|mixer| mixer.1) - } else { - MIXERS.first().map(|mixer| mixer.1) - } + #[assoc(build=mk_sink::(config))] + #[assoc(is_alsa = true)] + Alsa, } diff --git a/playback/src/mixer/softmixer.rs b/playback/src/mixer/softmixer.rs index 189fc1512..d9d0b99a2 100644 --- a/playback/src/mixer/softmixer.rs +++ b/playback/src/mixer/softmixer.rs @@ -41,10 +41,6 @@ impl Mixer for SoftMixer { } } -impl SoftMixer { - pub const NAME: &'static str = "softvol"; -} - struct SoftVolume(Arc); impl VolumeGetter for SoftVolume { diff --git a/playback/src/player.rs b/playback/src/player.rs index f3d803d51..5073a1056 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -43,8 +43,8 @@ use tokio::sync::{mpsc, oneshot}; use crate::SAMPLES_PER_SECOND; const PRELOAD_NEXT_TRACK_BEFORE_END_DURATION_MS: u32 = 30000; -pub const DB_VOLTAGE_RATIO: f64 = 20.0; -pub const PCM_AT_0DBFS: f64 = 1.0; +const DB_VOLTAGE_RATIO: f64 = 20.0; +const PCM_AT_0DBFS: f64 = 1.0; // Spotify inserts a custom Ogg packet at the start with custom metadata values, that you would // otherwise expect in Vorbis comments. This packet isn't well-formed and players may balk at it. @@ -494,7 +494,7 @@ impl Player { let player_id = PLAYER_COUNTER.fetch_add(1, Ordering::AcqRel); debug!("new Player [{player_id}]"); - let converter = Converter::new(config.ditherer); + let converter = Converter::new(config.ditherer_builder); let normalisation_knee_factor = 1.0 / (8.0 * config.normalisation_knee_db); // TODO: it would be neat if we could watch for added or modified files in the diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 000000000..fcb180a79 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,950 @@ +mod parsers; + +use clap::{ + Args, CommandFactory, Parser, ValueEnum, builder::NonEmptyStringValueParser, value_parser, +}; +use clap_verbosity_flag::{InfoLevel, Verbosity}; +use data_encoding::HEXLOWER; +#[cfg(discovery)] +use librespot::discovery::{Discovery, DiscoveryConfig}; +use librespot::{ + connect::{ConnectConfig, Spirc}, + core::{ + Session, SessionConfig, authentication::Credentials, cache::Cache, config::DeviceType, + version, + }, + oauth::OAuthClientBuilder, + playback::{ + audio_backend::AudioBackendBuilder, + config::{AudioFormat, Bitrate, NormalisationMethod, NormalisationType, PlayerConfig}, + dither::DithererBuilder, + mixer::{ + Mixer, MixerBuilder, MixerConfig, MixerConfigBuilder, VolumeCtrl, VolumeCtrlBuilder, + }, + player::{Player, duration_to_coefficient}, + }, +}; +use log::{error, info, trace, warn}; +use serde::{Deserialize, Serialize}; +use sha1::{Digest, Sha1}; +use std::{ + env, ffi::OsStr, fs::create_dir_all, ops::RangeInclusive, path::PathBuf, pin::Pin, + process::exit, sync::Arc, time::Duration, +}; +use tokio::sync::Semaphore; +use url::Url; + +use parsers::range_parser_factory; + +use crate::player_event_handler::{EventHandler, run_program_on_sink_events}; + +/// Spotify's Desktop app uses these. Some of these are only available when requested with Spotify's client IDs. +const OAUTH_SCOPES: &[&str] = &[ + "app-remote-control", + "playlist-modify", + "playlist-modify-private", + "playlist-modify-public", + "playlist-read", + "playlist-read-collaborative", + "playlist-read-private", + "streaming", + "ugc-image-upload", + "user-follow-modify", + "user-follow-read", + "user-library-modify", + "user-library-read", + "user-modify", + "user-modify-playback-state", + "user-modify-private", + "user-personalized", + "user-read-birthdate", + "user-read-currently-playing", + "user-read-email", + "user-read-play-history", + "user-read-playback-position", + "user-read-playback-state", + "user-read-private", + "user-read-recently-played", + "user-top-read", +]; + +const VALID_INITIAL_VOLUME_RANGE: RangeInclusive = 0..=100; +const VALID_VOLUME_RANGE: RangeInclusive = 0.0..=100.0; +const VALID_NORMALISATION_PREGAIN_RANGE: RangeInclusive = -10.0..=10.0; +const VALID_NORMALISATION_THRESHOLD_RANGE: RangeInclusive = -10.0..=0.0; +const VALID_NORMALISATION_ATTACK_RANGE: RangeInclusive = 1..=500; +const VALID_NORMALISATION_RELEASE_RANGE: RangeInclusive = 1..=1000; +const VALID_NORMALISATION_KNEE_RANGE: RangeInclusive = 0.0..=10.0; + +// Initialize a static semaphore with only one permit, which is used to +// prevent setting environment variables from running in parallel. +static PERMIT: Semaphore = Semaphore::const_new(1); +pub async fn set_env_var, V: AsRef>(key: K, value: V) { + let permit = PERMIT + .acquire() + .await + .expect("Failed to acquire semaphore permit"); + + // SAFETY: This is safe because setting the environment variable will wait if the permit is + // already acquired by other callers. + unsafe { env::set_var(key, value) } + + // Drop the permit manually, so the compiler doesn't optimize it away as unused variable. + drop(permit); +} + +#[cfg(feature = "pulseaudio-backend")] +async fn set_pulse_audio_env_vars(name: &str) { + use std::borrow::Cow; + + if env::var("PULSE_PROP_application.name").is_err() { + let pulseaudio_name: Cow<'_, str> = if name != ConnectConfig::DEFAULT_NAME { + Cow::Owned(format!("{} - {name}", ConnectConfig::DEFAULT_NAME)) + } else { + Cow::Borrowed(name) + }; + + set_env_var("PULSE_PROP_application.name", pulseaudio_name.as_ref()).await; + } + + if env::var("PULSE_PROP_application.version").is_err() { + set_env_var("PULSE_PROP_application.version", version::SEMVER).await; + } + + if env::var("PULSE_PROP_application.icon_name").is_err() { + set_env_var("PULSE_PROP_application.icon_name", "audio-x-generic").await; + } + + if env::var("PULSE_PROP_application.process.binary").is_err() { + set_env_var("PULSE_PROP_application.process.binary", "librespot").await; + } + + if env::var("PULSE_PROP_stream.description").is_err() { + set_env_var("PULSE_PROP_stream.description", "Spotify Connect endpoint").await; + } + + if env::var("PULSE_PROP_media.software").is_err() { + set_env_var("PULSE_PROP_media.software", "Spotify").await; + } + + if env::var("PULSE_PROP_media.role").is_err() { + set_env_var("PULSE_PROP_media.role", "music").await; + } +} + +fn about() -> String { + let version = version::libresport_version(); + let desc = env!("CARGO_PKG_DESCRIPTION"); + let repo_home = env!("CARGO_PKG_REPOSITORY"); + format!("{version}\n\n{desc}\n\n{repo_home}") +} + +#[derive(Parser, Serialize, Deserialize)] +#[command(version, author, about=about())] +pub struct Config { + #[serde(skip_serializing, skip_deserializing)] + #[command(flatten)] + pub verbosity: Verbosity, + + #[cfg(discovery)] + /// Disable zeroconf discovery mode. + #[arg(long, short = 'O', verbatim_doc_comment)] + disable_discovery: bool, + + #[cfg(discovery)] + #[command(flatten)] + discovery_config: DiscoveryConfig, + + /// Perform interactive OAuth sign in. + #[arg(long, short = 'j', verbatim_doc_comment)] + enable_oauth: bool, + + /// The port the oauth redirect server uses 1 - 65535. + /// Ports bellow 1025 may require root privileges. + #[arg(long, short='K', verbatim_doc_comment, value_parser=value_parser!(u16).range(1..), default_value_t=5588, requires("enable_oauth"))] + oauth_port: u16, + + /// Username used to sign in with. + #[arg(long, short = 'u', verbatim_doc_comment, value_parser=NonEmptyStringValueParser::new())] + username: Option, + + // /// Password used to sign in with. + // #[arg(long, short = 'p', verbatim_doc_comment)] + // password: Option, + /// Spotify access token to sign in with. + #[arg(long, short = 'k', verbatim_doc_comment, value_parser=NonEmptyStringValueParser::new())] + access_token: Option, + + #[command(flatten)] + connect_config: ConnectConfigClap, + + #[command(flatten)] + session_config: SessionConfigClap, + + #[command(flatten)] + backend_config: BackendConfig, + + /// Mixer to use. + #[arg(long, short = 'm', verbatim_doc_comment, value_enum, default_value_t)] + mixer: MixerBuilder, + + #[cfg(feature = "alsa-backend")] + #[command(flatten)] + alsa_mixer: AlsaMixerConfig, + + #[command(flatten)] + volume_ctrl_config: VolumeCtrlConfig, + + #[command(flatten)] + player_config: PlayerConfigClap, + + /// Run PROGRAM set by `--onevent` + /// before the sink is opened and after it is closed. + #[arg(long, short = 'Q', verbatim_doc_comment)] + emit_sink_events: bool, + + /// Run PROGRAM when a playback event occurs. + #[arg(long, short = 'o', verbatim_doc_comment)] + onevent: Option, + + #[command(flatten)] + cache_config: CacheConfig, +} + +#[derive(Args, Serialize, Deserialize)] +struct ConnectConfigClap { + /// Device name. + #[arg(long, short = 'n', verbatim_doc_comment, default_value=ConnectConfig::DEFAULT_NAME)] + name: String, + + /// Displayed device type. + #[arg(long, short = 'F', verbatim_doc_comment, value_enum, default_value_t)] + device_type: DeviceType, + + /// Whether the device represents a group. + #[arg(long, verbatim_doc_comment)] + group: bool, + + /// Initial volume in % from 0 to 100. Defaults to 50% if not cached. For the alsa mixer: the current volume. + #[arg(long, short='R', verbatim_doc_comment, value_parser=range_parser_factory(VALID_INITIAL_VOLUME_RANGE))] + initial_volume: Option, + + /// Number of incremental steps when responding to volume control updates. + #[arg(long, verbatim_doc_comment, value_parser=value_parser!(u16).range(1..), default_value_t=ConnectConfig::DEFAULT_VOLUME_STEPS)] + volume_steps: u16, +} + +#[derive(Args, Serialize, Deserialize)] +struct SessionConfigClap { + /// Http proxy to use when connecting. + #[arg(long, short = 'x', verbatim_doc_comment, value_parser=parsers::proxy_parser)] + proxy: Option, + + /// Connect to an AP with a specified port 1 - 65535. + /// Available ports are usually 80, 443 and 4070. + /// Ports bellow 1025 may require root privileges. + #[arg(long, short='a', verbatim_doc_comment, value_parser=value_parser!(u16).range(1..))] + ap_port: Option, + + /// Path to a directory where files will be temporarily stored while downloading. + #[arg(long, short = 't', verbatim_doc_comment)] + temp: Option, + + /// Explicitly set autoplay. + /// Defaults to following the client setting. + #[arg(long, short = 'A', verbatim_doc_comment, value_enum)] + autoplay: Option, +} + +#[derive(ValueEnum, Clone, Copy, Serialize, Deserialize)] +enum Autoplay { + On, + Off, +} + +#[derive(Args, Serialize, Deserialize)] +struct BackendConfig { + /// Audio backend to use. + #[arg(long, short = 'B', verbatim_doc_comment, value_enum, default_value_t)] + backend: AudioBackendBuilder, + + /// Audio device to use. + /// Use ? to list options. + /// Defaults to the backend's default. + #[arg(long, short = 'd', verbatim_doc_comment, value_parser=NonEmptyStringValueParser::new())] + device: Option, + + /// Output audio format. + #[arg(long, short = 'f', verbatim_doc_comment, value_enum, default_value_t)] + format: AudioFormat, +} + +#[cfg(feature = "alsa-backend")] +#[derive(Args, Serialize, Deserialize)] +struct AlsaMixerConfig { + /// Alsa index of the cards mixer. Defaults to 0. + #[arg(long, short = 's', verbatim_doc_comment)] + alsa_mixer_index: Option, + + /// Alsa mixer device, e.g hw:0 or similar from `aplay -l`. Defaults to `--device` if specified, default otherwise. + #[arg(long, short = 'S', verbatim_doc_comment, value_parser=NonEmptyStringValueParser::new())] + alsa_mixer_device: Option, + + /// Alsa mixer control, e.g. PCM, Master or similar. Defaults to PCM. + #[arg(long, short = 'T', verbatim_doc_comment, value_parser=NonEmptyStringValueParser::new())] + alsa_mixer_control: Option, +} + +#[cfg(feature = "alsa-backend")] +impl AlsaMixerConfig { + fn get_index(&self, device: Option<&String>) -> Option { + self.alsa_mixer_index.or_else(|| match device { + // Look for the dev index portion of --device. + // Specifically when --device is :CARD=,DEV= + // or :,. + + // If --device does not contain a ',' it does not contain a dev index. + // In the case that the dev index is omitted it is assumed to be 0 (mixer_default_config.index). + // Malformed --device values will also fallback to mixer_default_config.index. + Some(ref device_name) if device_name.contains(',') => { + // Turn :CARD=,DEV= or :, + // into DEV= or . + let dev = &device_name[device_name.find(',').unwrap_or_default()..] + .trim_start_matches(','); + + // Turn DEV= into (noop if it's already ) + // and then parse . + // Malformed --device values will fail the parse and fallback to mixer_default_config.index. + dev[dev.find('=').unwrap_or_default()..] + .trim_start_matches('=') + .parse::() + .ok() + } + _ => None, + }) + } + + fn get_device(&self, device: Option<&String>) -> Option { + self.alsa_mixer_device.clone().or_else(|| { + if let Some(device_name) = device { + // Look for the card name or card index portion of --device. + // Specifically when --device is :CARD=,DEV= + // or card index when --device is :,. + // --device values like `pulse`, `default`, `jack` may be valid but there is no way to + // infer automatically what the mixer should be so they fail auto fallback + // so --alsa-mixer-device must be manually specified in those situations. + let start_index = device_name.find(':').unwrap_or_default(); + + let end_index = match device_name.find(',') { + Some(index) if index > start_index => index, + _ => device_name.len(), + }; + + let card = &device_name[start_index..end_index]; + + if card.starts_with(':') { + // mixers are assumed to be hw:CARD= or hw:. + return Some("hw".to_owned() + card); + } + }; + None + }) + } +} + +#[derive(Args, Serialize, Deserialize)] +struct VolumeCtrlConfig { + /// Volume control scale type. + #[arg(long, short = 'E', verbatim_doc_comment, value_enum, default_value_t)] + volume_ctrl: VolumeCtrlBuilder, + + /// Range of the volume control (dB) from 0.0 to 100.0. + // #[cfg(not(feature = "alsa-backend"))] + // TODO: set to 0.0 if using alsa mixer + // TODO: warn volume range has no effect if volume_ctrl is Fixed or Linear + #[arg(long, short='e', verbatim_doc_comment, value_parser=range_parser_factory(VALID_VOLUME_RANGE), default_value_t=VolumeCtrl::DEFAULT_DB_RANGE)] + volume_range: f64, + // /// Range of the volume control (dB) from 0.0 to 100.0. Default for softvol: 60.0. For the alsa mixer: what the control supports."; + // #[cfg(feature = "alsa-backend")] + // #[arg(long, short='e', verbatim_doc_comment, value_parser=&range_parser_factory(VALID_VOLUME_RANGE))] + // range: f64, +} + +impl VolumeCtrlConfig { + fn build(&self) -> VolumeCtrl { + self.volume_ctrl.build(self.volume_range) + } +} + +#[derive(Args, Serialize, Deserialize)] +struct PlayerConfigClap { + /// Bitrate (kbps). + #[arg(long, short = 'b', verbatim_doc_comment, value_enum, default_value_t)] + bitrate: Bitrate, + + /// Disable gapless playback. + #[arg(long, short = 'g', verbatim_doc_comment)] + disable_gapless: bool, + + /// Pass a raw stream to the output. Only works with the pipe and subprocess backends. + #[cfg(feature = "passthrough-decoder")] + #[arg(long, short = 'P', verbatim_doc_comment)] + passthrough: bool, + + /// Play all tracks at approximately the same apparent volume. + #[arg(long, short = 'N', verbatim_doc_comment)] + enable_volume_normalisation: bool, + + #[command(flatten)] + normalization_config: NormalizationConfig, + + /// Directory to search for local file playback. + /// Can be specified multiple times to add multiple search directories. + #[arg(long, short = 'l', verbatim_doc_comment)] + local_file_dir: Vec, + + /// Specify the dither algorithm to use. + #[arg( + long, + short='D', + verbatim_doc_comment, + value_enum, + default_value_t = DithererBuilder::default(), + default_value_ifs([("format", "S32", "none"), ("format", "F32", "none"), ("format", "F64", "none")]) + )] + dither: DithererBuilder, +} + +#[derive(Args, Serialize, Deserialize)] +struct NormalizationConfig { + /// Specify the normalisation gain type to use. + #[arg( + long, + short = 'W', + verbatim_doc_comment, + value_enum, + default_value_t, + requires("enable_volume_normalisation") + )] + normalisation_gain_type: NormalisationType, + + /// Specify the normalisation method to use. + #[arg( + long, + short = 'X', + verbatim_doc_comment, + value_enum, + default_value_t, + requires("enable_volume_normalisation") + )] + normalisation_method: NormalisationMethod, + + /// Pregain (dB) applied by volume normalisation from -10.0 to 10.0. + #[arg(long, short='Y', verbatim_doc_comment, value_parser=range_parser_factory(VALID_NORMALISATION_PREGAIN_RANGE), default_value_t=PlayerConfig::DEFAULT_PREGAIN, requires("enable_volume_normalisation"))] + normalisation_pregain: f64, + + /// Threshold (dBFS) at which point the dynamic limiter engages to prevent clipping from 0.0 to -10.0. + #[arg(long, short='Z', verbatim_doc_comment, value_parser=range_parser_factory(VALID_NORMALISATION_THRESHOLD_RANGE), default_value_t=PlayerConfig::DEFAULT_THRESHOLD, requires("enable_volume_normalisation"))] + normalisation_threshold: f64, + + /// Attack time (ms) in which the dynamic limiter reduces gain from 1 to 500. + #[arg(long, short='U', verbatim_doc_comment, value_parser=range_parser_factory(VALID_NORMALISATION_ATTACK_RANGE), default_value_t=PlayerConfig::DEFAULT_ATTACK, requires("enable_volume_normalisation"))] + normalisation_attack: u64, + + /// Release or decay time (ms) in which the dynamic limiter restores gain from 1 to 1000. + #[arg(long, short='y', verbatim_doc_comment, value_parser=range_parser_factory(VALID_NORMALISATION_RELEASE_RANGE), default_value_t=PlayerConfig::DEFAULT_RELEASE, requires("enable_volume_normalisation"))] + normalisation_release: u64, + + /// Knee width (dB) of the dynamic limiter from 0.0 to 10.0. + #[arg(long, short='w', verbatim_doc_comment, value_parser=range_parser_factory(VALID_NORMALISATION_KNEE_RANGE), default_value_t=PlayerConfig::DEFAULT_KNEE, requires("enable_volume_normalisation"))] + normalisation_knee: f64, +} + +#[derive(Args, Serialize, Deserialize)] +struct CacheConfig { + /// Disable caching of the audio data. + #[arg(long, short = 'G', verbatim_doc_comment)] + disable_audio_cache: bool, + + /// Disable caching of credentials. + #[arg(long, short = 'H', verbatim_doc_comment)] + disable_credential_cache: bool, + + /// Path to a directory where files will be cached after downloading. + #[arg(long, short = 'c', verbatim_doc_comment)] + cache: Option, + + /// Path to a directory where system files (credentials, volume) will be cached. + /// May be different from the `--cache` option value. + #[arg(long, short = 'C', verbatim_doc_comment)] + system_cache: Option, + + /// Limits the size of the cache for audio files. + /// It's possible to use suffixes like K, M or G, e.g. 16G for example. + #[arg(long, short='M', verbatim_doc_comment, value_parser=parsers::parse_file_size)] + cache_size_limit: Option, +} + +impl Config { + /// Parse configuration and instantiate [Setup]. + pub async fn setup() -> Setup { + let conf = Config::parse(); // TODO: add env var parsing, config file. + + let mut conf_cmd = Config::command(); + + let mut env_logger_builder = env_logger::Builder::new(); + match env::var("RUST_LOG") { + Ok(config) => { + if conf.verbosity.is_present() { + warn!("Config verbosity flag overidden by `RUST_LOG` environment variable"); + }; + env_logger_builder.parse_filters(&config) + } + Err(_) => env_logger_builder.filter_level(conf.verbosity.log_level_filter()), + } + .init(); + + info!("{}", version::libresport_version()); + + let enable_oauth = conf.enable_oauth; + + let cache = { + let audio_dir = if conf.cache_config.disable_audio_cache { + None + } else { + conf.cache_config.cache.as_ref().map(|p| p.join("files")) + }; + + let volume_dir = conf.cache_config.system_cache.or(conf.cache_config.cache); + + let cred_dir = if conf.cache_config.disable_credential_cache { + None + } else { + volume_dir.as_ref() + }; + + let limit = if audio_dir.is_some() { + conf.cache_config.cache_size_limit + } else { + None + }; + + // if audio_dir.is_none() && limit.is_some() { + // warn!( + // "Without a `--{CACHE}` / `-{CACHE_SHORT}` path, and/or if the `--{DISABLE_AUDIO_CACHE}` / `-{DISABLE_AUDIO_CACHE_SHORT}` flag is set, `--{CACHE_SIZE_LIMIT}` / `-{CACHE_SIZE_LIMIT_SHORT}` has no effect." + // ); + // } + + let cache = match Cache::new(cred_dir, volume_dir.as_ref(), audio_dir.as_ref(), limit) { + Ok(cache) => Some(cache), + Err(e) => { + warn!("Cannot create cache: {e}"); + None + } + }; + + if enable_oauth && (cache.is_none() || cred_dir.is_none()) { + warn!("Credential caching is unavailable, but advisable when using OAuth login."); + } + + cache + }; + + let credentials = { + let cached_creds = cache.as_ref().and_then(Cache::credentials).map(Arc::new); + if let Some(access_token) = conf.access_token { + Some(Arc::new(Credentials::with_access_token(access_token))) + } else if let Some(username) = conf.username { + match cached_creds { + Some(creds) if Some(username) == creds.username => { + trace!("Using cached credentials for specified username."); + Some(creds) + } + _ => { + trace!("No cached credentials for specified username."); + None + } + } + } else { + if cached_creds.is_some() { + trace!("Using cached credentials."); + } + cached_creds + } + }; + + #[cfg(discovery)] + let discovery_config = if !conf.disable_discovery { + Some(conf.discovery_config) + } else { + None + }; + + #[cfg(discovery)] + if credentials.is_none() && discovery_config.is_none() && !enable_oauth { + conf_cmd + .error( + clap::error::ErrorKind::MissingRequiredArgument, + "Access token is required if discovery and oauth login are disabled.\nEither remove disable discovery or add enable oauth flags", + ) + .exit(); + } + + #[cfg(not(discovery))] + if credentials.is_none() && !enable_oauth { + conf_cmd + .error( + clap::error::ErrorKind::MissingRequiredArgument, + "Access token is required if oauth login is disabled.\nPlease use --enable-oauth flag", // TODO: flag names? + ) + .exit(); + } + + let backend_config = conf.backend_config; + // device help + if matches!(backend_config.device.as_deref(), Some("?")) { + backend_config.backend.device_options() + } + + let player_config = { + let bitrate = conf.player_config.bitrate; + + let gapless = !conf.player_config.disable_gapless; + + #[cfg(feature = "passthrough-decoder")] + let passthrough = conf.passthrough; + #[cfg(not(feature = "passthrough-decoder"))] + let passthrough = false; + + let normalisation = conf.player_config.enable_volume_normalisation; + + let normalisation_method = conf.player_config.normalization_config.normalisation_method; + let normalisation_type = conf + .player_config + .normalization_config + .normalisation_gain_type; + let normalisation_pregain_db = conf + .player_config + .normalization_config + .normalisation_pregain; + let normalisation_threshold_dbfs = conf + .player_config + .normalization_config + .normalisation_threshold; + let normalisation_attack_cf = duration_to_coefficient(Duration::from_millis( + conf.player_config.normalization_config.normalisation_attack, + )); + let normalisation_release_cf = duration_to_coefficient(Duration::from_millis( + conf.player_config + .normalization_config + .normalisation_release, + )); + let normalisation_knee_db = conf.player_config.normalization_config.normalisation_knee; + + let ditherer_builder = conf.player_config.dither; + let format = backend_config.format; + let ditherer_builder = if matches!(format, AudioFormat::F64 | AudioFormat::F32) + && !matches!(ditherer_builder, DithererBuilder::None) + { + conf_cmd + .error( + clap::error::ErrorKind::InvalidValue, + format!("Dithering is not available with format: {format:?}."), + ) + .exit(); + } else { + ditherer_builder + }; + + let local_file_directories = conf.player_config.local_file_dir; + + PlayerConfig { + bitrate, + gapless, + passthrough, + normalisation, + normalisation_type, + normalisation_method, + normalisation_pregain_db, + normalisation_threshold_dbfs, + normalisation_attack_cf, + normalisation_release_cf, + normalisation_knee_db, + ditherer_builder, + position_update_interval: None, + local_file_directories, + } + }; + + let mixer_config = { + let mut mixer_config = MixerConfigBuilder::default(); + + #[cfg(feature = "alsa-backend")] + { + // TODO: warn alsa mixer options will not have any effect if not using alsa mixer + if matches!(conf.mixer, MixerBuilder::Alsa) { + let device = backend_config.device.as_ref(); + + if let Some(mixer_device) = conf.alsa_mixer.get_device(device) { + mixer_config.device(mixer_device); + } + + if let Some(control) = conf.alsa_mixer.alsa_mixer_control { + mixer_config.control(control); + } + + if let Some(index) = conf.alsa_mixer.get_index(device) { + mixer_config.index(index); + } + } + } + mixer_config.volume_ctrl(conf.volume_ctrl_config.build()); + + // Should never fail as defaults to all fields are provided. + mixer_config.build().unwrap() + }; + + let connect_config = { + let name = conf.connect_config.name; + + #[cfg(feature = "pulseaudio-backend")] + set_pulse_audio_env_vars(&name).await; + + let initial_volume = if let Some(initial_volume) = + conf.connect_config.initial_volume.map(|initial_volume| { + (initial_volume as f32 / 100.0 * VolumeCtrl::MAX_VOLUME as f32) as u16 + }) { + initial_volume + } else if !conf.mixer.is_alsa() + && let Some(initial_volume) = cache.as_ref().and_then(Cache::volume) + { + initial_volume + } else { + ConnectConfig::DEFAULT_INITIAL_VOLUME + }; + + ConnectConfig { + name, + device_type: conf.connect_config.device_type, + is_group: conf.connect_config.group, + initial_volume, + disable_volume: matches!(mixer_config.volume_ctrl, VolumeCtrl::Fixed), + volume_steps: conf.connect_config.volume_steps, + emit_set_queue_events: false, + } + }; + + let session_config = { + let mut session_config = SessionConfig { + device_id: HEXLOWER.encode(&Sha1::digest(connect_config.name.as_bytes())), + proxy: conf.session_config.proxy, + ap_port: conf.session_config.ap_port, + ..Default::default() + }; + + if let Some(temp_dir) = conf.session_config.temp.inspect(|tmp_dir| { + if let Err(e) = create_dir_all(tmp_dir) { + conf_cmd + .error( + clap::error::ErrorKind::Io, + format!("Could not create or access specified tmp directory: {e}"), + ) + .exit(); + } + }) { + session_config.tmp_dir = temp_dir + }; + + // #1046: not all connections are supplied an `autoplay` user attribute to run statelessly. + // This knob allows for a manual override. + if let Some(autoplay_value) = conf.session_config.autoplay { + match autoplay_value { + Autoplay::On => session_config.autoplay = Some(true), + Autoplay::Off => session_config.autoplay = Some(false), + }; + }; + session_config + }; + + Setup { + backend_config, + mixer: conf.mixer, + mixer_config, + enable_oauth, + oauth_port: conf.oauth_port, + credentials, + #[cfg(discovery)] + discovery_config, + connect_config, + session: Session::new(session_config, cache), + player_config, + emit_sink_events: conf.emit_sink_events, + player_event_program: conf.onevent, + } + } +} + +pub struct Setup { + backend_config: BackendConfig, + mixer: MixerBuilder, + mixer_config: MixerConfig, + enable_oauth: bool, + oauth_port: u16, + credentials: Option>, + #[cfg(discovery)] + discovery_config: Option, + connect_config: ConnectConfig, + session: Session, + player_config: PlayerConfig, + emit_sink_events: bool, + player_event_program: Option, +} + +impl Setup { + pub fn session(&self) -> Session { + self.session.clone() + } + + #[cfg(discovery)] + pub async fn get_discovery(&self) -> Option { + if let Some(discovery_config) = &self.discovery_config { + use log::debug; + use sysinfo::System; + const DISCOVERY_RETRY_TIMEOUT: Duration = Duration::from_secs(10); + let mut sys = System::new(); + + // When started at boot as a service discovery may fail due to it + // trying to bind to interfaces before the network is actually up. + // This could be prevented in systemd by starting the service after + // network-online.target but it requires that a wait-online.service is + // also enabled which is not always the case since a wait-online.service + // can potentially hang the boot process until it times out in certain situations. + // This allows for discovery to retry every 10 secs in the 1st min of uptime + // before giving up thus papering over the issue and not holding up the boot process. + loop { + let device_id = self.session.device_id().to_string(); + let client_id = self.session.client_id(); + + match librespot::discovery::Discovery::builder( + self.connect_config.name.clone(), + device_id, + client_id, + Some(discovery_config), + ) + .device_type(self.connect_config.device_type) + .is_group(self.connect_config.is_group) + .build() + { + Ok(d) => return Some(d), + Err(e) => { + use sysinfo::ProcessesToUpdate; + + sys.refresh_processes(ProcessesToUpdate::All, true); + + if System::uptime() <= 1 { + use log::debug; + + debug!("Retrying to initialise discovery: {e}"); + tokio::time::sleep(DISCOVERY_RETRY_TIMEOUT).await; + } else { + debug!("System uptime > 1 min, not retrying to initialise discovery"); + warn!("Could not initialise discovery: {e}"); + return None; + } + } + } + } + } else { + None + } + } + + pub fn get_credentials(&self, connecting: &mut bool) -> Option> { + if let Some(credentials) = self.credentials.clone() { + *connecting = true; + Some(credentials) + } else if self.enable_oauth { + let client = OAuthClientBuilder::new( + self.session.client_id().as_str(), + &format!("http://127.0.0.1:{}/login", self.oauth_port), + OAUTH_SCOPES.to_vec(), + ) + .open_in_browser() + .build() + .unwrap_or_else(|e| { + error!("Failed to create OAuth client: {e}"); + exit(1); + }); + let oauth_token = client.get_access_token().unwrap_or_else(|e| { + error!("Failed to get Spotify access token: {e}"); + exit(1); + }); + *connecting = true; + Some(Arc::new(Credentials::with_access_token( + oauth_token.access_token, + ))) + } else { + None + } + } + + pub fn get_mixer(&self) -> Arc { + match self.mixer.build(self.mixer_config.clone()) { + Ok(mixer) => mixer, + Err(why) => { + error!("{why}"); + exit(1) + } + } + } + + pub fn get_player(&self, mixer: Arc, session: Session) -> Arc { + let player_config = self.player_config.clone(); + let soft_volume = mixer.get_soft_volume(); + let format = self.backend_config.format; + let backend = self.backend_config.backend; + let device = self.backend_config.device.clone(); + Player::new(player_config, session, soft_volume, move || { + backend.build(device, format) + }) + } + + pub fn get_player_event_handler(&self, player: Arc) -> Option { + if let Some(player_event_program) = self.player_event_program.clone() { + let handler = Some(EventHandler::new( + player.get_player_event_channel(), + &player_event_program, + )); + if self.emit_sink_events { + player.set_sink_event_callback(Some(Box::new(move |sink_status| { + run_program_on_sink_events(sink_status, &player_event_program) + }))); + } + handler + } else { + None + } + } + + pub async fn get_spirc( + &self, + session: Session, + credentials: Credentials, + player: Arc, + mixer: Arc, + ) -> ( + Option, + Option + 'static>>>, + ) { + let connect_config = self.connect_config.clone(); + let (spirc_, spirc_task_) = + match Spirc::new(connect_config, session, credentials, player, mixer).await { + Ok((spirc_, spirc_task_)) => (spirc_, spirc_task_), + Err(e) => { + error!("could not initialize spirc: {e}"); + exit(1); + } + }; + (Some(spirc_), Some(Box::pin(spirc_task_))) + } +} diff --git a/src/config/parsers.rs b/src/config/parsers.rs new file mode 100644 index 000000000..3f7d425ca --- /dev/null +++ b/src/config/parsers.rs @@ -0,0 +1,107 @@ +use std::{fmt::Debug, ops::RangeInclusive, str::FromStr}; +use thiserror::Error; +use url::Url; + +#[derive(Debug, Error)] +pub enum ParseFileSizeError { + #[error("empty argument")] + EmptyInput, + #[error("invalid suffix")] + InvalidSuffix, + #[error("invalid number: {0}")] + InvalidNumber(#[from] std::num::ParseFloatError), + #[error("non-finite number specified")] + NotFinite(f64), +} + +pub fn parse_file_size(input: &str) -> Result { + use ParseFileSizeError::*; + + let mut iter = input.chars(); + let mut suffix = iter.next_back().ok_or(EmptyInput)?; + let mut suffix_len = 0; + + let iec = matches!(suffix, 'i' | 'I'); + + if iec { + suffix_len += 1; + suffix = iter.next_back().ok_or(InvalidSuffix)?; + } + + let base: u64 = if iec { 1024 } else { 1000 }; + + suffix_len += 1; + let exponent = match suffix.to_ascii_uppercase() { + '0'..='9' if !iec => { + suffix_len -= 1; + 0 + } + 'K' => 1, + 'M' => 2, + 'G' => 3, + 'T' => 4, + 'P' => 5, + 'E' => 6, + 'Z' => 7, + 'Y' => 8, + _ => return Err(InvalidSuffix), + }; + + let num = { + let mut iter = input.chars(); + + for _ in (&mut iter).rev().take(suffix_len) {} + + iter.as_str().parse::()? + }; + + if !num.is_finite() { + return Err(NotFinite(num)); + } + + Ok((num * base.pow(exponent) as f64) as u64) +} + +#[derive(Debug, Error)] +pub enum ParserFactoryError { + #[error("invalid number: {0}")] + InvalidFloat(#[from] std::num::ParseFloatError), + #[error("invalid number: {0}")] + InvalidInt(#[from] std::num::ParseIntError), + #[error("Value must lay between {0} and {1}")] + NotInRange(T, T), +} + +pub fn range_parser_factory( + range: RangeInclusive, +) -> impl Fn(&str) -> Result> + Clone + Send + Sync + 'static +where + T: FromStr + PartialOrd + Copy + Send + Sync + 'static, + ParserFactoryError: From<::Err>, +{ + move |value: &str| { + let num_value = value.parse::()?; + if !range.contains(&num_value) { + Err(ParserFactoryError::NotInRange(*range.start(), *range.end())) + } else { + Ok(num_value) + } + } +} + +#[derive(Debug, Error)] +pub enum ProxyParserError { + #[error("invalid number: {0}")] + InvalidUrl(#[from] url::ParseError), + #[error("Invalid proxy url, only URLs on the format \"http(s)://host:port\" are allowed")] + InvalidProxyUrl, +} + +pub fn proxy_parser(value: &str) -> Result { + let url = Url::parse(value)?; + if url.host().is_none() || url.port_or_known_default().is_none() { + Err(ProxyParserError::InvalidProxyUrl) + } else { + Ok(url) + } +} diff --git a/src/lib.rs b/src/lib.rs index f6a176548..c45c23e81 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub use librespot_audio as audio; pub use librespot_connect as connect; pub use librespot_core as core; +#[cfg(discovery)] pub use librespot_discovery as discovery; pub use librespot_metadata as metadata; pub use librespot_oauth as oauth; diff --git a/src/main.rs b/src/main.rs index 16ba1946f..8ddc0f5ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,2069 +1,119 @@ -use data_encoding::HEXLOWER; -use futures_util::StreamExt; -#[cfg(feature = "alsa-backend")] -use librespot::playback::mixer::alsamixer::AlsaMixer; -use librespot::{ - connect::{ConnectConfig, Spirc}, - core::{ - Session, SessionConfig, authentication::Credentials, cache::Cache, config::DeviceType, - version, - }, - discovery::DnsSdServiceBuilder, - playback::{ - audio_backend::{self, BACKENDS, SinkBuilder}, - config::{ - AudioFormat, Bitrate, NormalisationMethod, NormalisationType, PlayerConfig, VolumeCtrl, - }, - dither, - mixer::{self, MixerConfig, MixerFn}, - player::{Player, coefficient_to_duration, duration_to_coefficient}, - }, -}; -use librespot_oauth::OAuthClientBuilder; -use log::{debug, error, info, trace, warn}; -use sha1::{Digest, Sha1}; +use librespot::connect::Spirc; +use log::{error, info, warn}; use std::{ env, - ffi::OsStr, - fs::create_dir_all, - ops::RangeInclusive, - path::{Path, PathBuf}, - pin::Pin, process::exit, - str::FromStr, time::{Duration, Instant}, }; -use sysinfo::{ProcessesToUpdate, System}; -use thiserror::Error; -use tokio::sync::Semaphore; -use url::Url; - -mod player_event_handler; -use player_event_handler::{EventHandler, run_program_on_sink_events}; - -fn device_id(name: &str) -> String { - HEXLOWER.encode(&Sha1::digest(name.as_bytes())) -} - -fn usage(program: &str, opts: &getopts::Options) -> String { - let repo_home = env!("CARGO_PKG_REPOSITORY"); - let desc = env!("CARGO_PKG_DESCRIPTION"); - let version = get_version_string(); - let brief = format!("{version}\n\n{desc}\n\n{repo_home}\n\nUsage: {program} []"); - opts.usage(&brief) -} - -fn setup_logging(quiet: bool, verbose: bool) { - let mut builder = env_logger::Builder::new(); - match env::var("RUST_LOG") { - Ok(config) => { - builder.parse_filters(&config); - builder.init(); - - if verbose { - warn!("`--verbose` flag overidden by `RUST_LOG` environment variable"); - } else if quiet { - warn!("`--quiet` flag overidden by `RUST_LOG` environment variable"); - } - } - Err(_) => { - if verbose { - builder.parse_filters("libmdns=info,librespot=trace"); - } else if quiet { - builder.parse_filters("libmdns=warn,librespot=warn"); - } else { - builder.parse_filters("libmdns=info,librespot=info"); - } - builder.init(); - - if verbose && quiet { - warn!( - "`--verbose` and `--quiet` are mutually exclusive. Logging can not be both verbose and quiet. Using verbose mode." - ); - } - } - } -} - -fn list_backends() { - println!("Available backends: "); - for (&(name, _), idx) in BACKENDS.iter().zip(0..) { - if idx == 0 { - println!("- {name} (default)"); - } else { - println!("- {name}"); - } - } -} - -#[derive(Debug, Error)] -pub enum ParseFileSizeError { - #[error("empty argument")] - EmptyInput, - #[error("invalid suffix")] - InvalidSuffix, - #[error("invalid number: {0}")] - InvalidNumber(#[from] std::num::ParseFloatError), - #[error("non-finite number specified")] - NotFinite(f64), -} - -pub fn parse_file_size(input: &str) -> Result { - use ParseFileSizeError::*; - - let mut iter = input.chars(); - let mut suffix = iter.next_back().ok_or(EmptyInput)?; - let mut suffix_len = 0; - - let iec = matches!(suffix, 'i' | 'I'); - - if iec { - suffix_len += 1; - suffix = iter.next_back().ok_or(InvalidSuffix)?; - } - - let base: u64 = if iec { 1024 } else { 1000 }; - - suffix_len += 1; - let exponent = match suffix.to_ascii_uppercase() { - '0'..='9' if !iec => { - suffix_len -= 1; - 0 - } - 'K' => 1, - 'M' => 2, - 'G' => 3, - 'T' => 4, - 'P' => 5, - 'E' => 6, - 'Z' => 7, - 'Y' => 8, - _ => return Err(InvalidSuffix), - }; - - let num = { - let mut iter = input.chars(); - - for _ in (&mut iter).rev().take(suffix_len) {} - - iter.as_str().parse::()? - }; - - if !num.is_finite() { - return Err(NotFinite(num)); - } - - Ok((num * base.pow(exponent) as f64) as u64) -} - -fn get_version_string() -> String { - #[cfg(debug_assertions)] - const BUILD_PROFILE: &str = "debug"; - #[cfg(not(debug_assertions))] - const BUILD_PROFILE: &str = "release"; - - format!( - "librespot {semver} {sha} (Built on {build_date}, Build ID: {build_id}, Profile: {build_profile})", - semver = version::SEMVER, - sha = version::SHA_SHORT, - build_date = version::BUILD_DATE, - build_id = version::BUILD_ID, - build_profile = BUILD_PROFILE - ) -} - -/// Spotify's Desktop app uses these. Some of these are only available when requested with Spotify's client IDs. -static OAUTH_SCOPES: &[&str] = &[ - //const OAUTH_SCOPES: Vec<&str> = vec![ - "app-remote-control", - "playlist-modify", - "playlist-modify-private", - "playlist-modify-public", - "playlist-read", - "playlist-read-collaborative", - "playlist-read-private", - "streaming", - "ugc-image-upload", - "user-follow-modify", - "user-follow-read", - "user-library-modify", - "user-library-read", - "user-modify", - "user-modify-playback-state", - "user-modify-private", - "user-personalized", - "user-read-birthdate", - "user-read-currently-playing", - "user-read-email", - "user-read-play-history", - "user-read-playback-position", - "user-read-playback-state", - "user-read-private", - "user-read-recently-played", - "user-top-read", -]; - -struct Setup { - format: AudioFormat, - backend: SinkBuilder, - device: Option, - mixer: MixerFn, - cache: Option, - player_config: PlayerConfig, - session_config: SessionConfig, - connect_config: ConnectConfig, - mixer_config: MixerConfig, - credentials: Option, - enable_oauth: bool, - oauth_port: Option, - zeroconf_port: u16, - player_event_program: Option, - emit_sink_events: bool, - zeroconf_ip: Vec, - zeroconf_backend: Option, -} - -async fn get_setup() -> Setup { - const VALID_INITIAL_VOLUME_RANGE: RangeInclusive = 0..=100; - const VALID_VOLUME_RANGE: RangeInclusive = 0.0..=100.0; - const VALID_NORMALISATION_KNEE_RANGE: RangeInclusive = 0.0..=10.0; - const VALID_NORMALISATION_PREGAIN_RANGE: RangeInclusive = -10.0..=10.0; - const VALID_NORMALISATION_THRESHOLD_RANGE: RangeInclusive = -10.0..=0.0; - const VALID_NORMALISATION_ATTACK_RANGE: RangeInclusive = 1..=500; - const VALID_NORMALISATION_RELEASE_RANGE: RangeInclusive = 1..=1000; - - const ACCESS_TOKEN: &str = "access-token"; - const AP_PORT: &str = "ap-port"; - const AUTOPLAY: &str = "autoplay"; - const BACKEND: &str = "backend"; - const BITRATE: &str = "bitrate"; - const CACHE: &str = "cache"; - const CACHE_SIZE_LIMIT: &str = "cache-size-limit"; - const DEVICE: &str = "device"; - const DEVICE_TYPE: &str = "device-type"; - const DEVICE_IS_GROUP: &str = "group"; - const DISABLE_AUDIO_CACHE: &str = "disable-audio-cache"; - const DISABLE_CREDENTIAL_CACHE: &str = "disable-credential-cache"; - const DISABLE_DISCOVERY: &str = "disable-discovery"; - const DISABLE_GAPLESS: &str = "disable-gapless"; - const DITHER: &str = "dither"; - const EMIT_SINK_EVENTS: &str = "emit-sink-events"; - const ENABLE_OAUTH: &str = "enable-oauth"; - const ENABLE_VOLUME_NORMALISATION: &str = "enable-volume-normalisation"; - const FORMAT: &str = "format"; - const HELP: &str = "help"; - const INITIAL_VOLUME: &str = "initial-volume"; - const MIXER_TYPE: &str = "mixer"; - const ALSA_MIXER_DEVICE: &str = "alsa-mixer-device"; - const ALSA_MIXER_INDEX: &str = "alsa-mixer-index"; - const ALSA_MIXER_CONTROL: &str = "alsa-mixer-control"; - const NAME: &str = "name"; - const NORMALISATION_ATTACK: &str = "normalisation-attack"; - const NORMALISATION_GAIN_TYPE: &str = "normalisation-gain-type"; - const NORMALISATION_KNEE: &str = "normalisation-knee"; - const NORMALISATION_METHOD: &str = "normalisation-method"; - const NORMALISATION_PREGAIN: &str = "normalisation-pregain"; - const NORMALISATION_RELEASE: &str = "normalisation-release"; - const NORMALISATION_THRESHOLD: &str = "normalisation-threshold"; - const OAUTH_PORT: &str = "oauth-port"; - const ONEVENT: &str = "onevent"; - #[cfg(feature = "passthrough-decoder")] - const PASSTHROUGH: &str = "passthrough"; - const PASSWORD: &str = "password"; - const PROXY: &str = "proxy"; - const QUIET: &str = "quiet"; - const SYSTEM_CACHE: &str = "system-cache"; - const TEMP_DIR: &str = "tmp"; - const USERNAME: &str = "username"; - const VERBOSE: &str = "verbose"; - const VERSION: &str = "version"; - const VOLUME_CTRL: &str = "volume-ctrl"; - const VOLUME_RANGE: &str = "volume-range"; - const VOLUME_STEPS: &str = "volume-steps"; - const ZEROCONF_PORT: &str = "zeroconf-port"; - const ZEROCONF_INTERFACE: &str = "zeroconf-interface"; - const ZEROCONF_BACKEND: &str = "zeroconf-backend"; - const LOCAL_FILE_DIR: &str = "local-file-dir"; - - // Mostly arbitrary. - const AP_PORT_SHORT: &str = "a"; - const AUTOPLAY_SHORT: &str = "A"; - const BACKEND_SHORT: &str = "B"; - const BITRATE_SHORT: &str = "b"; - const SYSTEM_CACHE_SHORT: &str = "C"; - const CACHE_SHORT: &str = "c"; - const DITHER_SHORT: &str = "D"; - const DEVICE_SHORT: &str = "d"; - const VOLUME_CTRL_SHORT: &str = "E"; - const VOLUME_RANGE_SHORT: &str = "e"; - const VOLUME_STEPS_SHORT: &str = ""; // no short flag - const DEVICE_TYPE_SHORT: &str = "F"; - const FORMAT_SHORT: &str = "f"; - const DISABLE_AUDIO_CACHE_SHORT: &str = "G"; - const DISABLE_GAPLESS_SHORT: &str = "g"; - const DISABLE_CREDENTIAL_CACHE_SHORT: &str = "H"; - const HELP_SHORT: &str = "h"; - const ZEROCONF_INTERFACE_SHORT: &str = "i"; - const ENABLE_OAUTH_SHORT: &str = "j"; - const OAUTH_PORT_SHORT: &str = "K"; - const ACCESS_TOKEN_SHORT: &str = "k"; - const CACHE_SIZE_LIMIT_SHORT: &str = "M"; - const MIXER_TYPE_SHORT: &str = "m"; - const ENABLE_VOLUME_NORMALISATION_SHORT: &str = "N"; - const NAME_SHORT: &str = "n"; - const DISABLE_DISCOVERY_SHORT: &str = "O"; - const ONEVENT_SHORT: &str = "o"; - #[cfg(feature = "passthrough-decoder")] - const PASSTHROUGH_SHORT: &str = "P"; - const PASSWORD_SHORT: &str = "p"; - const EMIT_SINK_EVENTS_SHORT: &str = "Q"; - const QUIET_SHORT: &str = "q"; - const INITIAL_VOLUME_SHORT: &str = "R"; - const ALSA_MIXER_DEVICE_SHORT: &str = "S"; - const ALSA_MIXER_INDEX_SHORT: &str = "s"; - const ALSA_MIXER_CONTROL_SHORT: &str = "T"; - const TEMP_DIR_SHORT: &str = "t"; - const NORMALISATION_ATTACK_SHORT: &str = "U"; - const USERNAME_SHORT: &str = "u"; - const VERSION_SHORT: &str = "V"; - const VERBOSE_SHORT: &str = "v"; - const NORMALISATION_GAIN_TYPE_SHORT: &str = "W"; - const NORMALISATION_KNEE_SHORT: &str = "w"; - const NORMALISATION_METHOD_SHORT: &str = "X"; - const PROXY_SHORT: &str = "x"; - const NORMALISATION_PREGAIN_SHORT: &str = "Y"; - const NORMALISATION_RELEASE_SHORT: &str = "y"; - const NORMALISATION_THRESHOLD_SHORT: &str = "Z"; - const ZEROCONF_PORT_SHORT: &str = "z"; - const ZEROCONF_BACKEND_SHORT: &str = ""; // no short flag - const LOCAL_FILE_DIR_SHORT: &str = "l"; - - // Options that have different descriptions - // depending on what backends were enabled at build time. - #[cfg(feature = "alsa-backend")] - const MIXER_TYPE_DESC: &str = "Mixer to use {alsa|softvol}. Defaults to softvol."; - #[cfg(not(feature = "alsa-backend"))] - const MIXER_TYPE_DESC: &str = "Not supported by the included audio backend(s)."; - #[cfg(any( - feature = "alsa-backend", - feature = "rodio-backend", - feature = "portaudio-backend" - ))] - const DEVICE_DESC: &str = "Audio device to use. Use ? to list options if using alsa, portaudio or rodio. Defaults to the backend's default."; - #[cfg(not(any( - feature = "alsa-backend", - feature = "rodio-backend", - feature = "portaudio-backend" - )))] - const DEVICE_DESC: &str = "Not supported by the included audio backend(s)."; - #[cfg(feature = "alsa-backend")] - const ALSA_MIXER_CONTROL_DESC: &str = - "Alsa mixer control, e.g. PCM, Master or similar. Defaults to PCM."; - #[cfg(not(feature = "alsa-backend"))] - const ALSA_MIXER_CONTROL_DESC: &str = "Not supported by the included audio backend(s)."; - #[cfg(feature = "alsa-backend")] - const ALSA_MIXER_DEVICE_DESC: &str = "Alsa mixer device, e.g hw:0 or similar from `aplay -l`. Defaults to `--device` if specified, default otherwise."; - #[cfg(not(feature = "alsa-backend"))] - const ALSA_MIXER_DEVICE_DESC: &str = "Not supported by the included audio backend(s)."; - #[cfg(feature = "alsa-backend")] - const ALSA_MIXER_INDEX_DESC: &str = "Alsa index of the cards mixer. Defaults to 0."; - #[cfg(not(feature = "alsa-backend"))] - const ALSA_MIXER_INDEX_DESC: &str = "Not supported by the included audio backend(s)."; - #[cfg(feature = "alsa-backend")] - const INITIAL_VOLUME_DESC: &str = "Initial volume in % from 0 - 100. Default for softvol: 50. For the alsa mixer: the current volume."; - #[cfg(not(feature = "alsa-backend"))] - const INITIAL_VOLUME_DESC: &str = "Initial volume in % from 0 - 100. Defaults to 50."; - #[cfg(feature = "alsa-backend")] - const VOLUME_RANGE_DESC: &str = "Range of the volume control (dB) from 0.0 to 100.0. Default for softvol: 60.0. For the alsa mixer: what the control supports."; - #[cfg(not(feature = "alsa-backend"))] - const VOLUME_RANGE_DESC: &str = - "Range of the volume control (dB) from 0.0 to 100.0. Defaults to 60.0."; - const VOLUME_STEPS_DESC: &str = - "Number of incremental steps when responding to volume control updates. Defaults to 64."; - - let mut opts = getopts::Options::new(); - opts.optflag( - HELP_SHORT, - HELP, - "Print this help menu.", - ) - .optflag( - VERSION_SHORT, - VERSION, - "Display librespot version string.", - ) - .optflag( - VERBOSE_SHORT, - VERBOSE, - "Enable verbose log output.", - ) - .optflag( - QUIET_SHORT, - QUIET, - "Only log warning and error messages.", - ) - .optflag( - DISABLE_AUDIO_CACHE_SHORT, - DISABLE_AUDIO_CACHE, - "Disable caching of the audio data.", - ) - .optflag( - DISABLE_CREDENTIAL_CACHE_SHORT, - DISABLE_CREDENTIAL_CACHE, - "Disable caching of credentials.", - ) - .optflag( - DISABLE_DISCOVERY_SHORT, - DISABLE_DISCOVERY, - "Disable zeroconf discovery mode.", - ) - .optflag( - DISABLE_GAPLESS_SHORT, - DISABLE_GAPLESS, - "Disable gapless playback.", - ) - .optflag( - EMIT_SINK_EVENTS_SHORT, - EMIT_SINK_EVENTS, - "Run PROGRAM set by `--onevent` before the sink is opened and after it is closed.", - ) - .optflag( - ENABLE_VOLUME_NORMALISATION_SHORT, - ENABLE_VOLUME_NORMALISATION, - "Play all tracks at approximately the same apparent volume.", - ) - .optflag( - ENABLE_OAUTH_SHORT, - ENABLE_OAUTH, - "Perform interactive OAuth sign in.", - ) - .optopt( - NAME_SHORT, - NAME, - "Device name. Defaults to Librespot.", - "NAME", - ) - .optopt( - BITRATE_SHORT, - BITRATE, - "Bitrate (kbps) {96|160|320}. Defaults to 160.", - "BITRATE", - ) - .optopt( - FORMAT_SHORT, - FORMAT, - "Output format {F64|F32|S32|S24|S24_3|S16}. Defaults to S16.", - "FORMAT", - ) - .optopt( - DITHER_SHORT, - DITHER, - "Specify the dither algorithm to use {none|gpdf|tpdf|tpdf_hp}. Defaults to tpdf for formats S16, S24, S24_3 and none for other formats.", - "DITHER", - ) - .optopt( - DEVICE_TYPE_SHORT, - DEVICE_TYPE, - "Displayed device type. Defaults to speaker.", - "TYPE", - ).optflag( - "", - DEVICE_IS_GROUP, - "Whether the device represents a group. Defaults to false.", - ) - .optopt( - TEMP_DIR_SHORT, - TEMP_DIR, - "Path to a directory where files will be temporarily stored while downloading.", - "PATH", - ) - .optopt( - CACHE_SHORT, - CACHE, - "Path to a directory where files will be cached after downloading.", - "PATH", - ) - .optopt( - SYSTEM_CACHE_SHORT, - SYSTEM_CACHE, - "Path to a directory where system files (credentials, volume) will be cached. May be different from the `--cache` option value.", - "PATH", - ) - .optopt( - CACHE_SIZE_LIMIT_SHORT, - CACHE_SIZE_LIMIT, - "Limits the size of the cache for audio files. It's possible to use suffixes like K, M or G, e.g. 16G for example.", - "SIZE" - ) - .optopt( - BACKEND_SHORT, - BACKEND, - "Audio backend to use. Use ? to list options.", - "NAME", - ) - .optopt( - USERNAME_SHORT, - USERNAME, - "Username used to sign in with.", - "USERNAME", - ) - .optopt( - PASSWORD_SHORT, - PASSWORD, - "Password used to sign in with.", - "PASSWORD", - ) - .optopt( - ACCESS_TOKEN_SHORT, - ACCESS_TOKEN, - "Spotify access token to sign in with.", - "TOKEN", - ) - .optopt( - OAUTH_PORT_SHORT, - OAUTH_PORT, - "The port the oauth redirect server uses 1 - 65535. Ports <= 1024 may require root privileges.", - "PORT", - ) - .optopt( - ONEVENT_SHORT, - ONEVENT, - "Run PROGRAM when a playback event occurs.", - "PROGRAM", - ) - .optopt( - ALSA_MIXER_CONTROL_SHORT, - ALSA_MIXER_CONTROL, - ALSA_MIXER_CONTROL_DESC, - "NAME", - ) - .optopt( - ALSA_MIXER_DEVICE_SHORT, - ALSA_MIXER_DEVICE, - ALSA_MIXER_DEVICE_DESC, - "DEVICE", - ) - .optopt( - ALSA_MIXER_INDEX_SHORT, - ALSA_MIXER_INDEX, - ALSA_MIXER_INDEX_DESC, - "NUMBER", - ) - .optopt( - MIXER_TYPE_SHORT, - MIXER_TYPE, - MIXER_TYPE_DESC, - "MIXER", - ) - .optopt( - DEVICE_SHORT, - DEVICE, - DEVICE_DESC, - "NAME", - ) - .optopt( - INITIAL_VOLUME_SHORT, - INITIAL_VOLUME, - INITIAL_VOLUME_DESC, - "VOLUME", - ) - .optopt( - VOLUME_CTRL_SHORT, - VOLUME_CTRL, - "Volume control scale type {cubic|fixed|linear|log}. Defaults to log.", - "VOLUME_CTRL" - ) - .optopt( - VOLUME_RANGE_SHORT, - VOLUME_RANGE, - VOLUME_RANGE_DESC, - "RANGE", - ) - .optopt( - VOLUME_STEPS_SHORT, - VOLUME_STEPS, - VOLUME_STEPS_DESC, - "STEPS", - ) - .optopt( - NORMALISATION_METHOD_SHORT, - NORMALISATION_METHOD, - "Specify the normalisation method to use {basic|dynamic}. Defaults to dynamic.", - "METHOD", - ) - .optopt( - NORMALISATION_GAIN_TYPE_SHORT, - NORMALISATION_GAIN_TYPE, - "Specify the normalisation gain type to use {track|album|auto}. Defaults to auto.", - "TYPE", - ) - .optopt( - NORMALISATION_PREGAIN_SHORT, - NORMALISATION_PREGAIN, - "Pregain (dB) applied by volume normalisation from -10.0 to 10.0. Defaults to 0.0.", - "PREGAIN", - ) - .optopt( - NORMALISATION_THRESHOLD_SHORT, - NORMALISATION_THRESHOLD, - "Threshold (dBFS) at which point the dynamic limiter engages to prevent clipping from 0.0 to -10.0. Defaults to -2.0.", - "THRESHOLD", - ) - .optopt( - NORMALISATION_ATTACK_SHORT, - NORMALISATION_ATTACK, - "Attack time (ms) in which the dynamic limiter reduces gain from 1 to 500. Defaults to 5.", - "TIME", - ) - .optopt( - NORMALISATION_RELEASE_SHORT, - NORMALISATION_RELEASE, - "Release or decay time (ms) in which the dynamic limiter restores gain from 1 to 1000. Defaults to 100.", - "TIME", - ) - .optopt( - NORMALISATION_KNEE_SHORT, - NORMALISATION_KNEE, - "Knee width (dB) of the dynamic limiter from 0.0 to 10.0. Defaults to 5.0.", - "KNEE", - ) - .optopt( - ZEROCONF_PORT_SHORT, - ZEROCONF_PORT, - "The port the internal server advertises over zeroconf 1 - 65535. Ports <= 1024 may require root privileges.", - "PORT", - ) - .optopt( - PROXY_SHORT, - PROXY, - "HTTP proxy to use when connecting.", - "URL", - ) - .optopt( - AP_PORT_SHORT, - AP_PORT, - "Connect to an AP with a specified port 1 - 65535. Available ports are usually 80, 443 and 4070.", - "PORT", - ) - .optopt( - AUTOPLAY_SHORT, - AUTOPLAY, - "Explicitly set autoplay {on|off}. Defaults to following the client setting.", - "OVERRIDE", - ) - .optopt( - ZEROCONF_INTERFACE_SHORT, - ZEROCONF_INTERFACE, - "Comma-separated interface IP addresses on which zeroconf will bind. Defaults to all interfaces. Ignored by DNS-SD.", - "IP" - ) - .optopt( - ZEROCONF_BACKEND_SHORT, - ZEROCONF_BACKEND, - "Zeroconf (MDNS/DNS-SD) backend to use. Valid values are 'avahi', 'dns-sd' and 'libmdns', if librespot is compiled with the corresponding feature flags.", - "BACKEND" - ).optmulti( - LOCAL_FILE_DIR_SHORT, - LOCAL_FILE_DIR, - "Directory to search for local file playback. Can be specified multiple times to add multiple search directories", - "DIRECTORY" - ); - - #[cfg(feature = "passthrough-decoder")] - opts.optflag( - PASSTHROUGH_SHORT, - PASSTHROUGH, - "Pass a raw stream to the output. Only works with the pipe and subprocess backends.", - ); - - let args: Vec<_> = std::env::args_os() - .filter_map(|s| match s.into_string() { - Ok(valid) => Some(valid), - Err(s) => { - eprintln!( - "Command line argument was not valid Unicode and will not be evaluated: {s:?}" - ); - None - } - }) - .collect(); - - let matches = match opts.parse(&args[1..]) { - Ok(m) => m, - Err(e) => { - eprintln!("Error parsing command line options: {e}"); - println!("\n{}", usage(&args[0], &opts)); - exit(1); - } - }; - - let stripped_env_key = |k: &str| { - k.trim_start_matches("LIBRESPOT_") - .replace('_', "-") - .to_lowercase() - }; - - let env_vars: Vec<_> = env::vars_os().filter_map(|(k, v)| match k.into_string() { - Ok(key) if key.starts_with("LIBRESPOT_") => { - let stripped_key = stripped_env_key(&key); - // We only care about long option/flag names. - if stripped_key.chars().count() > 1 && matches.opt_defined(&stripped_key) { - match v.into_string() { - Ok(value) => Some((key, value)), - Err(s) => { - eprintln!("Environment variable was not valid Unicode and will not be evaluated: {key}={s:?}"); - None - } - } - } else { - None - } - }, - _ => None - }) - .collect(); - - let opt_present = - |opt| matches.opt_present(opt) || env_vars.iter().any(|(k, _)| stripped_env_key(k) == opt); - - let opt_str = |opt| { - if matches.opt_present(opt) { - matches.opt_str(opt) - } else { - env_vars - .iter() - .find(|(k, _)| stripped_env_key(k) == opt) - .map(|(_, v)| v.to_string()) - } - }; - - if opt_present(HELP) { - println!("{}", usage(&args[0], &opts)); - exit(0); - } - - if opt_present(VERSION) { - println!("{}", get_version_string()); - exit(0); - } - - setup_logging(opt_present(QUIET), opt_present(VERBOSE)); - - info!("{}", get_version_string()); - - if !env_vars.is_empty() { - trace!("Environment variable(s):"); - - for (k, v) in &env_vars { - if matches!( - k.as_str(), - "LIBRESPOT_PASSWORD" | "LIBRESPOT_USERNAME" | "LIBRESPOT_ACCESS_TOKEN" - ) { - trace!("\t\t{k}=\"XXXXXXXX\""); - } else if v.is_empty() { - trace!("\t\t{k}="); - } else { - trace!("\t\t{k}=\"{v}\""); - } - } - } - - let args_len = args.len(); - - if args_len > 1 { - trace!("Command line argument(s):"); - - for (index, key) in args.iter().enumerate() { - let opt = { - let key = key.trim_start_matches('-'); - - if let Some((s, _)) = key.split_once('=') { - s - } else { - key - } - }; - - if index > 0 - && key.starts_with('-') - && &args[index - 1] != key - && matches.opt_defined(opt) - && matches.opt_present(opt) - { - if matches!( - opt, - PASSWORD - | PASSWORD_SHORT - | USERNAME - | USERNAME_SHORT - | ACCESS_TOKEN - | ACCESS_TOKEN_SHORT - ) { - // Don't log creds. - trace!("\t\t{opt} \"XXXXXXXX\""); - } else { - let value = matches.opt_str(opt).unwrap_or_default(); - if value.is_empty() { - trace!("\t\t{opt}"); - } else { - trace!("\t\t{opt} \"{value}\""); - } - } - } - } - } - - #[cfg(not(feature = "alsa-backend"))] - for a in &[ - MIXER_TYPE, - ALSA_MIXER_DEVICE, - ALSA_MIXER_INDEX, - ALSA_MIXER_CONTROL, - ] { - if opt_present(a) { - warn!( - "Alsa specific options have no effect if the alsa backend is not enabled at build time." - ); - break; - } - } - - let backend_name = opt_str(BACKEND); - if backend_name == Some("?".into()) { - list_backends(); - exit(0); - } - - // Can't use `-> fmt::Arguments` due to https://github.com/rust-lang/rust/issues/92698 - fn format_flag(long: &str, short: &str) -> String { - if short.is_empty() { - format!("`--{long}`") - } else { - format!("`--{long}` / `-{short}`") - } - } - - let invalid_error_msg = - |long: &str, short: &str, invalid: &str, valid_values: &str, default_value: &str| { - let flag = format_flag(long, short); - error!("Invalid {flag}: \"{invalid}\""); - - if !valid_values.is_empty() { - println!("Valid {flag} values: {valid_values}"); - } - - if !default_value.is_empty() { - println!("Default: {default_value}"); - } - }; - - let empty_string_error_msg = |long: &str, short: &str| { - error!("`--{long}` / `-{short}` can not be an empty string"); - exit(1); - }; - - let backend = audio_backend::find(backend_name).unwrap_or_else(|| { - invalid_error_msg( - BACKEND, - BACKEND_SHORT, - &opt_str(BACKEND).unwrap_or_default(), - "", - "", - ); - - list_backends(); - exit(1); - }); - - let format = opt_str(FORMAT) - .as_deref() - .map(|format| { - AudioFormat::from_str(format).unwrap_or_else(|_| { - let default_value = &format!("{:?}", AudioFormat::default()); - invalid_error_msg( - FORMAT, - FORMAT_SHORT, - format, - "F64, F32, S32, S24, S24_3, S16", - default_value, - ); - - exit(1); - }) - }) - .unwrap_or_default(); - - let device = opt_str(DEVICE); - if let Some(ref value) = device { - if value == "?" { - backend(device, format); - exit(0); - } else if value.is_empty() { - empty_string_error_msg(DEVICE, DEVICE_SHORT); - } - } - - #[cfg(feature = "alsa-backend")] - let mixer_type = opt_str(MIXER_TYPE); - #[cfg(not(feature = "alsa-backend"))] - let mixer_type: Option = None; - - let mixer = mixer::find(mixer_type.as_deref()).unwrap_or_else(|| { - invalid_error_msg( - MIXER_TYPE, - MIXER_TYPE_SHORT, - &opt_str(MIXER_TYPE).unwrap_or_default(), - "alsa, softvol", - "softvol", - ); - - exit(1); - }); - - let is_alsa_mixer = match mixer_type.as_deref() { - #[cfg(feature = "alsa-backend")] - Some(AlsaMixer::NAME) => true, - _ => false, - }; - - #[cfg(feature = "alsa-backend")] - if !is_alsa_mixer { - for a in &[ALSA_MIXER_DEVICE, ALSA_MIXER_INDEX, ALSA_MIXER_CONTROL] { - if opt_present(a) { - warn!("Alsa specific mixer options have no effect if not using the alsa mixer."); - break; - } - } - } - - let mixer_config = { - let mixer_default_config = MixerConfig::default(); - - #[cfg(feature = "alsa-backend")] - let index = if !is_alsa_mixer { - mixer_default_config.index - } else { - opt_str(ALSA_MIXER_INDEX) - .map(|index| { - index.parse::().unwrap_or_else(|_| { - invalid_error_msg( - ALSA_MIXER_INDEX, - ALSA_MIXER_INDEX_SHORT, - &index, - "", - &mixer_default_config.index.to_string(), - ); - - exit(1); - }) - }) - .unwrap_or_else(|| match device { - // Look for the dev index portion of --device. - // Specifically when --device is :CARD=,DEV= - // or :,. - - // If --device does not contain a ',' it does not contain a dev index. - // In the case that the dev index is omitted it is assumed to be 0 (mixer_default_config.index). - // Malformed --device values will also fallback to mixer_default_config.index. - Some(ref device_name) if device_name.contains(',') => { - // Turn :CARD=,DEV= or :, - // into DEV= or . - let dev = &device_name[device_name.find(',').unwrap_or_default()..] - .trim_start_matches(','); - - // Turn DEV= into (noop if it's already ) - // and then parse . - // Malformed --device values will fail the parse and fallback to mixer_default_config.index. - dev[dev.find('=').unwrap_or_default()..] - .trim_start_matches('=') - .parse::() - .unwrap_or(mixer_default_config.index) - } - _ => mixer_default_config.index, - }) - }; - - #[cfg(not(feature = "alsa-backend"))] - let index = mixer_default_config.index; - - #[cfg(feature = "alsa-backend")] - let device = if !is_alsa_mixer { - mixer_default_config.device - } else { - match opt_str(ALSA_MIXER_DEVICE) { - Some(mixer_device) => { - if mixer_device.is_empty() { - empty_string_error_msg(ALSA_MIXER_DEVICE, ALSA_MIXER_DEVICE_SHORT); - } - - mixer_device - } - None => match device { - Some(ref device_name) => { - // Look for the card name or card index portion of --device. - // Specifically when --device is :CARD=,DEV= - // or card index when --device is :,. - // --device values like `pulse`, `default`, `jack` may be valid but there is no way to - // infer automatically what the mixer should be so they fail auto fallback - // so --alsa-mixer-device must be manually specified in those situations. - let start_index = device_name.find(':').unwrap_or_default(); - - let end_index = match device_name.find(',') { - Some(index) if index > start_index => index, - _ => device_name.len(), - }; - - let card = &device_name[start_index..end_index]; - - if card.starts_with(':') { - // mixers are assumed to be hw:CARD= or hw:. - "hw".to_owned() + card - } else { - error!( - "Could not find an alsa mixer for \"{}\", it must be specified with `--{}` / `-{}`", - &device.unwrap_or_default(), - ALSA_MIXER_DEVICE, - ALSA_MIXER_DEVICE_SHORT - ); - - exit(1); - } - } - None => { - error!( - "`--{}` / `-{}` or `--{}` / `-{}` \ - must be specified when `--{}` / `-{}` is set to \"alsa\"", - DEVICE, - DEVICE_SHORT, - ALSA_MIXER_DEVICE, - ALSA_MIXER_DEVICE_SHORT, - MIXER_TYPE, - MIXER_TYPE_SHORT - ); - - exit(1); - } - }, - } - }; - - #[cfg(not(feature = "alsa-backend"))] - let device = mixer_default_config.device; - - #[cfg(feature = "alsa-backend")] - let control = opt_str(ALSA_MIXER_CONTROL).unwrap_or(mixer_default_config.control); - - #[cfg(feature = "alsa-backend")] - if control.is_empty() { - empty_string_error_msg(ALSA_MIXER_CONTROL, ALSA_MIXER_CONTROL_SHORT); - } - - #[cfg(not(feature = "alsa-backend"))] - let control = mixer_default_config.control; - - let volume_range = opt_str(VOLUME_RANGE) - .map(|range| match range.parse::() { - Ok(value) if (VALID_VOLUME_RANGE).contains(&value) => value, - _ => { - let valid_values = &format!( - "{} - {}", - VALID_VOLUME_RANGE.start(), - VALID_VOLUME_RANGE.end() - ); - - #[cfg(feature = "alsa-backend")] - let default_value = &format!( - "softvol - {}, alsa - what the control supports", - VolumeCtrl::DEFAULT_DB_RANGE - ); - - #[cfg(not(feature = "alsa-backend"))] - let default_value = &VolumeCtrl::DEFAULT_DB_RANGE.to_string(); - - invalid_error_msg( - VOLUME_RANGE, - VOLUME_RANGE_SHORT, - &range, - valid_values, - default_value, - ); - - exit(1); - } - }) - .unwrap_or_else(|| { - if is_alsa_mixer { - 0.0 - } else { - VolumeCtrl::DEFAULT_DB_RANGE - } - }); - - let volume_ctrl = opt_str(VOLUME_CTRL) - .as_deref() - .map(|volume_ctrl| { - VolumeCtrl::from_str_with_range(volume_ctrl, volume_range).unwrap_or_else(|_| { - invalid_error_msg( - VOLUME_CTRL, - VOLUME_CTRL_SHORT, - volume_ctrl, - "cubic, fixed, linear, log", - "log", - ); - - exit(1); - }) - }) - .unwrap_or_else(|| VolumeCtrl::Log(volume_range)); - - MixerConfig { - device, - control, - index, - volume_ctrl, - } - }; - - let tmp_dir = opt_str(TEMP_DIR).map_or(SessionConfig::default().tmp_dir, |p| { - let tmp_dir = PathBuf::from(p); - if let Err(e) = create_dir_all(&tmp_dir) { - error!("could not create or access specified tmp directory: {e}"); - exit(1); - } - tmp_dir - }); - - let enable_oauth = opt_present(ENABLE_OAUTH); - - let cache = { - let volume_dir = opt_str(SYSTEM_CACHE) - .or_else(|| opt_str(CACHE)) - .map(Into::into); - - let cred_dir = if opt_present(DISABLE_CREDENTIAL_CACHE) { - None - } else { - volume_dir.clone() - }; - - let audio_dir = if opt_present(DISABLE_AUDIO_CACHE) { - None - } else { - opt_str(CACHE) - .as_ref() - .map(|p| AsRef::::as_ref(p).join("files")) - }; - - let limit = if audio_dir.is_some() { - opt_str(CACHE_SIZE_LIMIT) - .as_deref() - .map(parse_file_size) - .map(|e| { - e.unwrap_or_else(|e| { - invalid_error_msg( - CACHE_SIZE_LIMIT, - CACHE_SIZE_LIMIT_SHORT, - &e.to_string(), - "", - "", - ); - - exit(1); - }) - }) - } else { - None - }; - - if audio_dir.is_none() && opt_present(CACHE_SIZE_LIMIT) { - warn!( - "Without a `--{CACHE}` / `-{CACHE_SHORT}` path, and/or if the `--{DISABLE_AUDIO_CACHE}` / `-{DISABLE_AUDIO_CACHE_SHORT}` flag is set, `--{CACHE_SIZE_LIMIT}` / `-{CACHE_SIZE_LIMIT_SHORT}` has no effect." - ); - } - - let cache = match Cache::new(cred_dir.clone(), volume_dir, audio_dir, limit) { - Ok(cache) => Some(cache), - Err(e) => { - warn!("Cannot create cache: {e}"); - None - } - }; - - if enable_oauth && (cache.is_none() || cred_dir.is_none()) { - warn!("Credential caching is unavailable, but advisable when using OAuth login."); - } - - cache - }; - - let credentials = { - let cached_creds = cache.as_ref().and_then(Cache::credentials); - if let Some(access_token) = opt_str(ACCESS_TOKEN) { - if access_token.is_empty() { - empty_string_error_msg(ACCESS_TOKEN, ACCESS_TOKEN_SHORT); - } - Some(Credentials::with_access_token(access_token)) - } else if let Some(username) = opt_str(USERNAME) { - if username.is_empty() { - empty_string_error_msg(USERNAME, USERNAME_SHORT); - } - if opt_present(PASSWORD) { - error!( - "Invalid `--{PASSWORD}` / `-{PASSWORD_SHORT}`: Password authentication no longer supported, use OAuth" - ); - exit(1); - } - match cached_creds { - Some(creds) if Some(username) == creds.username => { - trace!("Using cached credentials for specified username."); - Some(creds) - } - _ => { - trace!("No cached credentials for specified username."); - None - } - } - } else { - if cached_creds.is_some() { - trace!("Using cached credentials."); - } - cached_creds - } - }; - - let no_discovery_reason = if !cfg!(any( - feature = "with-libmdns", - feature = "with-dns-sd", - feature = "with-avahi" - )) { - Some("librespot compiled without zeroconf backend".to_owned()) - } else if opt_present(DISABLE_DISCOVERY) { - Some(format!( - "the `--{DISABLE_DISCOVERY}` / `-{DISABLE_DISCOVERY_SHORT}` flag set", - )) - } else { - None - }; - - if credentials.is_none() && no_discovery_reason.is_some() && !enable_oauth { - error!("Credentials are required if discovery and oauth login are disabled."); - exit(1); - } - - let oauth_port = if opt_present(OAUTH_PORT) { - if !enable_oauth { - warn!( - "Without the `--{ENABLE_OAUTH}` / `-{ENABLE_OAUTH_SHORT}` flag set `--{OAUTH_PORT}` / `-{OAUTH_PORT_SHORT}` has no effect." - ); - } - opt_str(OAUTH_PORT) - .map(|port| match port.parse::() { - Ok(value) => { - if value > 0 { - Some(value) - } else { - None - } - } - _ => { - let valid_values = &format!("1 - {}", u16::MAX); - invalid_error_msg(OAUTH_PORT, OAUTH_PORT_SHORT, &port, valid_values, ""); - - exit(1); - } - }) - .unwrap_or(None) - } else { - Some(5588) - }; - - if let Some(reason) = no_discovery_reason.as_deref() { - if opt_present(ZEROCONF_PORT) { - warn!("With {reason} `--{ZEROCONF_PORT}` / `-{ZEROCONF_PORT_SHORT}` has no effect."); - } - } - - let zeroconf_port = if no_discovery_reason.is_none() { - opt_str(ZEROCONF_PORT) - .map(|port| match port.parse::() { - Ok(value) if value != 0 => value, - _ => { - let valid_values = &format!("1 - {}", u16::MAX); - invalid_error_msg(ZEROCONF_PORT, ZEROCONF_PORT_SHORT, &port, valid_values, ""); - - exit(1); - } - }) - .unwrap_or(0) - } else { - 0 - }; - - // #1046: not all connections are supplied an `autoplay` user attribute to run statelessly. - // This knob allows for a manual override. - let autoplay = match opt_str(AUTOPLAY) { - Some(value) => match value.as_ref() { - "on" => Some(true), - "off" => Some(false), - _ => { - invalid_error_msg( - AUTOPLAY, - AUTOPLAY_SHORT, - &opt_str(AUTOPLAY).unwrap_or_default(), - "on, off", - "", - ); - exit(1); - } - }, - None => SessionConfig::default().autoplay, - }; - - if let Some(reason) = no_discovery_reason.as_deref() { - if opt_present(ZEROCONF_INTERFACE) { - warn!( - "With {} {} has no effect.", - reason, - format_flag(ZEROCONF_INTERFACE, ZEROCONF_INTERFACE_SHORT), - ); - } - } - - let zeroconf_ip: Vec = if opt_present(ZEROCONF_INTERFACE) { - if let Some(zeroconf_ip) = opt_str(ZEROCONF_INTERFACE) { - zeroconf_ip - .split(',') - .map(|s| { - s.trim().parse::().unwrap_or_else(|_| { - invalid_error_msg( - ZEROCONF_INTERFACE, - ZEROCONF_INTERFACE_SHORT, - s, - "IPv4 and IPv6 addresses", - "", - ); - exit(1); - }) - }) - .collect() - } else { - warn!("Unable to use zeroconf-interface option, default to all interfaces."); - vec![] - } - } else { - vec![] - }; - - if let Some(reason) = no_discovery_reason.as_deref() { - if opt_present(ZEROCONF_BACKEND) { - warn!( - "With {reason} `--{ZEROCONF_BACKEND}` / `-{ZEROCONF_BACKEND_SHORT}` has no effect." - ); - } - } - - let zeroconf_backend_name = opt_str(ZEROCONF_BACKEND); - let zeroconf_backend = no_discovery_reason.is_none().then(|| { - librespot::discovery::find(zeroconf_backend_name.as_deref()).unwrap_or_else(|_| { - let available_backends: Vec<_> = librespot::discovery::BACKENDS - .iter() - .filter_map(|(id, launch_svc)| launch_svc.map(|_| *id)) - .collect(); - let default_backend = librespot::discovery::BACKENDS - .iter() - .find_map(|(id, launch_svc)| launch_svc.map(|_| *id)) - .unwrap_or(""); - - invalid_error_msg( - ZEROCONF_BACKEND, - ZEROCONF_BACKEND_SHORT, - &zeroconf_backend_name.unwrap_or_default(), - &available_backends.join(", "), - default_backend, - ); - - exit(1); - }) - }); - - let local_file_directories = matches - .opt_strs(LOCAL_FILE_DIR) - .into_iter() - .map(PathBuf::from) - .collect::>(); - - let connect_config = { - let connect_default_config = ConnectConfig::default(); - - let name = opt_str(NAME); - if matches!(name, Some(ref name) if name.is_empty()) { - empty_string_error_msg(NAME, NAME_SHORT); - exit(1); - } - - #[cfg(feature = "pulseaudio-backend")] - { - if env::var("PULSE_PROP_application.name").is_err() { - let op_pulseaudio_name = name - .as_ref() - .map(|name| format!("{} - {}", connect_default_config.name, name)); - - let pulseaudio_name = op_pulseaudio_name - .as_deref() - .unwrap_or(&connect_default_config.name); - - set_env_var("PULSE_PROP_application.name", pulseaudio_name).await; - } - - if env::var("PULSE_PROP_application.version").is_err() { - set_env_var("PULSE_PROP_application.version", version::SEMVER).await; - } - - if env::var("PULSE_PROP_application.icon_name").is_err() { - set_env_var("PULSE_PROP_application.icon_name", "audio-x-generic").await; - } - - if env::var("PULSE_PROP_application.process.binary").is_err() { - set_env_var("PULSE_PROP_application.process.binary", "librespot").await; - } - - if env::var("PULSE_PROP_stream.description").is_err() { - set_env_var("PULSE_PROP_stream.description", "Spotify Connect endpoint").await; - } - - if env::var("PULSE_PROP_media.software").is_err() { - set_env_var("PULSE_PROP_media.software", "Spotify").await; - } - - if env::var("PULSE_PROP_media.role").is_err() { - set_env_var("PULSE_PROP_media.role", "music").await; - } - } - - let initial_volume = opt_str(INITIAL_VOLUME) - .map(|initial_volume| { - let volume = match initial_volume.parse::() { - Ok(value) if (VALID_INITIAL_VOLUME_RANGE).contains(&value) => value, - _ => { - let valid_values = &format!( - "{} - {}", - VALID_INITIAL_VOLUME_RANGE.start(), - VALID_INITIAL_VOLUME_RANGE.end() - ); - - #[cfg(feature = "alsa-backend")] - let default_value = &format!( - "{}, or the current value when the alsa mixer is used.", - connect_default_config.initial_volume - ); - - #[cfg(not(feature = "alsa-backend"))] - let default_value = &connect_default_config.initial_volume.to_string(); - - invalid_error_msg( - INITIAL_VOLUME, - INITIAL_VOLUME_SHORT, - &initial_volume, - valid_values, - default_value, - ); - - exit(1); - } - }; - - (volume as f32 / 100.0 * VolumeCtrl::MAX_VOLUME as f32) as u16 - }) - .or_else(|| { - if is_alsa_mixer { - None - } else { - cache.as_ref().and_then(Cache::volume) - } - }); - - let device_type = opt_str(DEVICE_TYPE).as_deref().map(|device_type| { - DeviceType::from_str(device_type).unwrap_or_else(|_| { - invalid_error_msg( - DEVICE_TYPE, - DEVICE_TYPE_SHORT, - device_type, - "computer, tablet, smartphone, \ - speaker, tv, avr, stb, audiodongle, \ - gameconsole, castaudio, castvideo, \ - automobile, smartwatch, chromebook, \ - carthing", - DeviceType::default().into(), - ); - - exit(1); - }) - }); - - let volume_steps = opt_str(VOLUME_STEPS).map(|steps| match steps.parse::() { - Ok(value) => value, - _ => { - let default_value = &connect_default_config.volume_steps.to_string(); - - invalid_error_msg( - VOLUME_STEPS, - VOLUME_STEPS_SHORT, - &steps, - "a positive whole number <= 65535", - default_value, - ); - - exit(1); - } - }); - - let is_group = opt_present(DEVICE_IS_GROUP); - - // use config defaults if not provided - let name = name.unwrap_or(connect_default_config.name); - let device_type = device_type.unwrap_or(connect_default_config.device_type); - let initial_volume = initial_volume.unwrap_or(connect_default_config.initial_volume); - let disable_volume = matches!(mixer_config.volume_ctrl, VolumeCtrl::Fixed); - let volume_steps = volume_steps.unwrap_or(connect_default_config.volume_steps); - - ConnectConfig { - name, - device_type, - is_group, - initial_volume, - disable_volume, - volume_steps, - emit_set_queue_events: false, - } - }; - - let session_config = SessionConfig { - device_id: device_id(&connect_config.name), - proxy: opt_str(PROXY).or_else(|| std::env::var("http_proxy").ok()).map( - |s| { - match Url::parse(&s) { - Ok(url) => { - if url.host().is_none() || url.port_or_known_default().is_none() { - error!("Invalid proxy url, only URLs on the format \"http(s)://host:port\" are allowed"); - exit(1); - } - - url - }, - Err(e) => { - error!("Invalid proxy URL: \"{e}\", only URLs in the format \"http(s)://host:port\" are allowed"); - exit(1); - } - } - }, - ), - ap_port: opt_str(AP_PORT).map(|port| match port.parse::() { - Ok(value) if value != 0 => value, - _ => { - let valid_values = &format!("1 - {}", u16::MAX); - invalid_error_msg(AP_PORT, AP_PORT_SHORT, &port, valid_values, ""); - - exit(1); - } - }), - tmp_dir, - autoplay, - ..SessionConfig::default() - }; - - let player_config = { - let player_default_config = PlayerConfig::default(); - - let bitrate = opt_str(BITRATE) - .as_deref() - .map(|bitrate| { - Bitrate::from_str(bitrate).unwrap_or_else(|_| { - invalid_error_msg(BITRATE, BITRATE_SHORT, bitrate, "96, 160, 320", "160"); - exit(1); - }) - }) - .unwrap_or(player_default_config.bitrate); - - let gapless = !opt_present(DISABLE_GAPLESS); - - let normalisation = opt_present(ENABLE_VOLUME_NORMALISATION); - - let normalisation_method; - let normalisation_type; - let normalisation_pregain_db; - let normalisation_threshold_dbfs; - let normalisation_attack_cf; - let normalisation_release_cf; - let normalisation_knee_db; - - if !normalisation { - for a in &[ - NORMALISATION_METHOD, - NORMALISATION_GAIN_TYPE, - NORMALISATION_PREGAIN, - NORMALISATION_THRESHOLD, - NORMALISATION_ATTACK, - NORMALISATION_RELEASE, - NORMALISATION_KNEE, - ] { - if opt_present(a) { - warn!( - "Without the `--{ENABLE_VOLUME_NORMALISATION}` / `-{ENABLE_VOLUME_NORMALISATION_SHORT}` flag normalisation options have no effect.", - ); - break; - } - } - - normalisation_method = player_default_config.normalisation_method; - normalisation_type = player_default_config.normalisation_type; - normalisation_pregain_db = player_default_config.normalisation_pregain_db; - normalisation_threshold_dbfs = player_default_config.normalisation_threshold_dbfs; - normalisation_attack_cf = player_default_config.normalisation_attack_cf; - normalisation_release_cf = player_default_config.normalisation_release_cf; - normalisation_knee_db = player_default_config.normalisation_knee_db; - } else { - normalisation_method = opt_str(NORMALISATION_METHOD) - .as_deref() - .map(|method| { - NormalisationMethod::from_str(method).unwrap_or_else(|_| { - invalid_error_msg( - NORMALISATION_METHOD, - NORMALISATION_METHOD_SHORT, - method, - "basic, dynamic", - &format!("{:?}", player_default_config.normalisation_method), - ); - - exit(1); - }) - }) - .unwrap_or(player_default_config.normalisation_method); - - normalisation_type = opt_str(NORMALISATION_GAIN_TYPE) - .as_deref() - .map(|gain_type| { - NormalisationType::from_str(gain_type).unwrap_or_else(|_| { - invalid_error_msg( - NORMALISATION_GAIN_TYPE, - NORMALISATION_GAIN_TYPE_SHORT, - gain_type, - "track, album, auto", - &format!("{:?}", player_default_config.normalisation_type), - ); - - exit(1); - }) - }) - .unwrap_or(player_default_config.normalisation_type); - - normalisation_pregain_db = opt_str(NORMALISATION_PREGAIN) - .map(|pregain| match pregain.parse::() { - Ok(value) if (VALID_NORMALISATION_PREGAIN_RANGE).contains(&value) => value, - _ => { - let valid_values = &format!( - "{} - {}", - VALID_NORMALISATION_PREGAIN_RANGE.start(), - VALID_NORMALISATION_PREGAIN_RANGE.end() - ); - - invalid_error_msg( - NORMALISATION_PREGAIN, - NORMALISATION_PREGAIN_SHORT, - &pregain, - valid_values, - &player_default_config.normalisation_pregain_db.to_string(), - ); - - exit(1); - } - }) - .unwrap_or(player_default_config.normalisation_pregain_db); - - normalisation_threshold_dbfs = opt_str(NORMALISATION_THRESHOLD) - .map(|threshold| match threshold.parse::() { - Ok(value) if (VALID_NORMALISATION_THRESHOLD_RANGE).contains(&value) => value, - _ => { - let valid_values = &format!( - "{} - {}", - VALID_NORMALISATION_THRESHOLD_RANGE.start(), - VALID_NORMALISATION_THRESHOLD_RANGE.end() - ); - - invalid_error_msg( - NORMALISATION_THRESHOLD, - NORMALISATION_THRESHOLD_SHORT, - &threshold, - valid_values, - &player_default_config - .normalisation_threshold_dbfs - .to_string(), - ); - - exit(1); - } - }) - .unwrap_or(player_default_config.normalisation_threshold_dbfs); - - normalisation_attack_cf = opt_str(NORMALISATION_ATTACK) - .map(|attack| match attack.parse::() { - Ok(value) if (VALID_NORMALISATION_ATTACK_RANGE).contains(&value) => { - duration_to_coefficient(Duration::from_millis(value)) - } - _ => { - let valid_values = &format!( - "{} - {}", - VALID_NORMALISATION_ATTACK_RANGE.start(), - VALID_NORMALISATION_ATTACK_RANGE.end() - ); - - invalid_error_msg( - NORMALISATION_ATTACK, - NORMALISATION_ATTACK_SHORT, - &attack, - valid_values, - &coefficient_to_duration(player_default_config.normalisation_attack_cf) - .as_millis() - .to_string(), - ); - - exit(1); - } - }) - .unwrap_or(player_default_config.normalisation_attack_cf); - - normalisation_release_cf = opt_str(NORMALISATION_RELEASE) - .map(|release| match release.parse::() { - Ok(value) if (VALID_NORMALISATION_RELEASE_RANGE).contains(&value) => { - duration_to_coefficient(Duration::from_millis(value)) - } - _ => { - let valid_values = &format!( - "{} - {}", - VALID_NORMALISATION_RELEASE_RANGE.start(), - VALID_NORMALISATION_RELEASE_RANGE.end() - ); - - invalid_error_msg( - NORMALISATION_RELEASE, - NORMALISATION_RELEASE_SHORT, - &release, - valid_values, - &coefficient_to_duration( - player_default_config.normalisation_release_cf, - ) - .as_millis() - .to_string(), - ); - - exit(1); - } - }) - .unwrap_or(player_default_config.normalisation_release_cf); - - normalisation_knee_db = opt_str(NORMALISATION_KNEE) - .map(|knee| match knee.parse::() { - Ok(value) if (VALID_NORMALISATION_KNEE_RANGE).contains(&value) => value, - _ => { - let valid_values = &format!( - "{} - {}", - VALID_NORMALISATION_KNEE_RANGE.start(), - VALID_NORMALISATION_KNEE_RANGE.end() - ); - - invalid_error_msg( - NORMALISATION_KNEE, - NORMALISATION_KNEE_SHORT, - &knee, - valid_values, - &player_default_config.normalisation_knee_db.to_string(), - ); - - exit(1); - } - }) - .unwrap_or(player_default_config.normalisation_knee_db); - } - - let ditherer_name = opt_str(DITHER); - let ditherer = match ditherer_name.as_deref() { - Some(value) => match value { - "none" => None, - _ => match format { - AudioFormat::F64 | AudioFormat::F32 => { - error!("Dithering is not available with format: {format:?}."); - exit(1); - } - _ => Some(dither::find_ditherer(ditherer_name).unwrap_or_else(|| { - invalid_error_msg( - DITHER, - DITHER_SHORT, - &opt_str(DITHER).unwrap_or_default(), - "none, gpdf, tpdf, tpdf_hp for formats S16, S24, S24_3, S32, none for formats F32, F64", - "tpdf for formats S16, S24, S24_3 and none for formats S32, F32, F64", - ); - - exit(1); - })), - }, - }, - None => match format { - AudioFormat::S16 | AudioFormat::S24 | AudioFormat::S24_3 => { - player_default_config.ditherer - } - _ => None, - }, - }; - - #[cfg(feature = "passthrough-decoder")] - let passthrough = opt_present(PASSTHROUGH); - #[cfg(not(feature = "passthrough-decoder"))] - let passthrough = false; - - PlayerConfig { - bitrate, - gapless, - passthrough, - normalisation, - normalisation_type, - normalisation_method, - normalisation_pregain_db, - normalisation_threshold_dbfs, - normalisation_attack_cf, - normalisation_release_cf, - normalisation_knee_db, - ditherer, - position_update_interval: None, - local_file_directories, - } - }; - - let player_event_program = opt_str(ONEVENT); - let emit_sink_events = opt_present(EMIT_SINK_EVENTS); - - Setup { - format, - backend, - device, - mixer, - cache, - player_config, - session_config, - connect_config, - mixer_config, - credentials, - enable_oauth, - oauth_port, - zeroconf_port, - player_event_program, - emit_sink_events, - zeroconf_ip, - zeroconf_backend, - } -} - -// Initialize a static semaphore with only one permit, which is used to -// prevent setting environment variables from running in parallel. -static PERMIT: Semaphore = Semaphore::const_new(1); -async fn set_env_var, V: AsRef>(key: K, value: V) { - let permit = PERMIT - .acquire() - .await - .expect("Failed to acquire semaphore permit"); +#[cfg(discovery)] +use futures_util::StreamExt; +#[cfg(discovery)] +use std::sync::Arc; - // SAFETY: This is safe because setting the environment variable will wait if the permit is - // already acquired by other callers. - unsafe { env::set_var(key, value) } +mod config; +use config::{Config, set_env_var}; - // Drop the permit manually, so the compiler doesn't optimize it away as unused variable. - drop(permit); -} +mod player_event_handler; #[tokio::main(flavor = "current_thread")] async fn main() { const RUST_BACKTRACE: &str = "RUST_BACKTRACE"; const RECONNECT_RATE_LIMIT_WINDOW: Duration = Duration::from_secs(600); - const DISCOVERY_RETRY_TIMEOUT: Duration = Duration::from_secs(10); const RECONNECT_RATE_LIMIT: usize = 5; if env::var(RUST_BACKTRACE).is_err() { set_env_var(RUST_BACKTRACE, "full").await; } - let setup = get_setup().await; + let setup = Config::setup().await; - let mut last_credentials = None; let mut spirc: Option = None; - let mut spirc_task: Option> = None; + let mut spirc_task: Option<_> = None; let mut auto_connect_times: Vec = vec![]; - let mut discovery = None; let mut connecting = false; - let mut _event_handler: Option = None; - let mut session = Session::new(setup.session_config.clone(), setup.cache.clone()); + let mut session = setup.session(); - let mut sys = System::new(); + #[cfg(discovery)] + let mut discovery = setup.get_discovery().await; - if let Some(zeroconf_backend) = setup.zeroconf_backend { - // When started at boot as a service discovery may fail due to it - // trying to bind to interfaces before the network is actually up. - // This could be prevented in systemd by starting the service after - // network-online.target but it requires that a wait-online.service is - // also enabled which is not always the case since a wait-online.service - // can potentially hang the boot process until it times out in certain situations. - // This allows for discovery to retry every 10 secs in the 1st min of uptime - // before giving up thus papering over the issue and not holding up the boot process. + #[allow(unused_mut)] + let mut last_credentials = setup.get_credentials(&mut connecting); - discovery = loop { - let device_id = setup.session_config.device_id.clone(); - let client_id = setup.session_config.client_id.clone(); + // if last_credentials.is_none() && discovery.is_none() { + // error!( + // "Discovery is unavailable and no credentials provided. Authentication is not possible." + // ); + // exit(1); + // } - match librespot::discovery::Discovery::builder(device_id, client_id) - .name(setup.connect_config.name.clone()) - .device_type(setup.connect_config.device_type) - .is_group(setup.connect_config.is_group) - .port(setup.zeroconf_port) - .zeroconf_ip(setup.zeroconf_ip.clone()) - .zeroconf_backend(zeroconf_backend) - .launch() - { - Ok(d) => break Some(d), - Err(e) => { - sys.refresh_processes(ProcessesToUpdate::All, true); + let mixer = setup.get_mixer(); + let player = setup.get_player(mixer.clone(), session.clone()); - if System::uptime() <= 1 { - debug!("Retrying to initialise discovery: {e}"); - tokio::time::sleep(DISCOVERY_RETRY_TIMEOUT).await; - } else { - debug!("System uptime > 1 min, not retrying to initialise discovery"); - warn!("Could not initialise discovery: {e}"); - break None; - } - } - } - }; - } + let _player_event_handler = setup.get_player_event_handler(player.clone()); - if let Some(credentials) = setup.credentials { - last_credentials = Some(credentials); - connecting = true; - } else if setup.enable_oauth { - let port_str = match setup.oauth_port { - Some(port) => format!(":{port}"), - _ => String::new(), + #[cfg(not(discovery))] + macro_rules! select_wrapper { + {$($branches:tt)*} => { + tokio::select! { + $($branches)* + } }; - let client = OAuthClientBuilder::new( - &setup.session_config.client_id, - &format!("http://127.0.0.1{port_str}/login"), - OAUTH_SCOPES.to_vec(), - ) - .open_in_browser() - .build() - .unwrap_or_else(|e| { - error!("Failed to create OAuth client: {e}"); - exit(1); - }); - let oauth_token = client.get_access_token().unwrap_or_else(|e| { - error!("Failed to get Spotify access token: {e}"); - exit(1); - }); - last_credentials = Some(Credentials::with_access_token(oauth_token.access_token)); - connecting = true; - } else if discovery.is_none() { - error!( - "Discovery is unavailable and no credentials provided. Authentication is not possible." - ); - exit(1); } - let mixer_config = setup.mixer_config.clone(); - let mixer = match (setup.mixer)(mixer_config) { - Ok(mixer) => mixer, - Err(why) => { - error!("{why}"); - exit(1) - } - }; - let player_config = setup.player_config.clone(); - - let soft_volume = mixer.get_soft_volume(); - let format = setup.format; - let backend = setup.backend; - let device = setup.device.clone(); - let player = Player::new(player_config, session.clone(), soft_volume, move || { - (backend)(device, format) - }); - - if let Some(player_event_program) = setup.player_event_program.clone() { - _event_handler = Some(EventHandler::new( - player.get_player_event_channel(), - &player_event_program, - )); - - if setup.emit_sink_events { - player.set_sink_event_callback(Some(Box::new(move |sink_status| { - run_program_on_sink_events(sink_status, &player_event_program) - }))); - } - } - - loop { - tokio::select! { - credentials = async { - match discovery.as_mut() { - Some(d) => d.next().await, - _ => None - } - }, if discovery.is_some() => { - match credentials { - Some(credentials) => { - last_credentials = Some(credentials.clone()); - auto_connect_times.clear(); + #[cfg(discovery)] + macro_rules! select_wrapper { + {$($branches:tt)*} => { + tokio::select! { + credentials = async { + match discovery.as_mut() { + Some(d) => d.next().await, + _ => None + } + }, if discovery.is_some() => { + match credentials { + Some(credentials) => { + last_credentials = Some(Arc::new(credentials)); + auto_connect_times.clear(); - if let Some(spirc) = spirc.take() { - if let Err(e) = spirc.shutdown() { + if let Some(spirc) = spirc.take() && let Err(e) = spirc.shutdown() { error!("error sending spirc shutdown message: {e}"); } - } - if let Some(spirc_task) = spirc_task.take() { - // Continue shutdown in its own task - tokio::spawn(spirc_task); - } - if !session.is_invalid() { - session.shutdown(); - } + if let Some(spirc_task) = spirc_task.take() { + // Continue shutdown in its own task + tokio::spawn(spirc_task); + } + if !session.is_invalid() { + session.shutdown(); + } - connecting = true; - }, - None => { - error!("Discovery stopped unexpectedly"); - exit(1); + connecting = true; + }, + None => { + error!("Discovery stopped unexpectedly"); + exit(1); + } } - } - }, - _ = async {}, if connecting && last_credentials.is_some() => { - if session.is_invalid() { - session = Session::new(setup.session_config.clone(), setup.cache.clone()); - player.set_session(session.clone()); - } + }, - let connect_config = setup.connect_config.clone(); + $($branches)* + } + }; + } - let (spirc_, spirc_task_) = match Spirc::new(connect_config, - session.clone(), - last_credentials.clone().unwrap_or_default(), - player.clone(), - mixer.clone()).await { - Ok((spirc_, spirc_task_)) => (spirc_, spirc_task_), - Err(e) => { - error!("could not initialize spirc: {e}"); - exit(1); + loop { + select_wrapper! { + _ = async {}, if connecting => { + if let Some(credentials) = &last_credentials { + if session.is_invalid() { + session = session.renew(); + player.set_session(session.clone()); } - }; - spirc = Some(spirc_); - spirc_task = Some(Box::pin(spirc_task_)); - connecting = false; + (spirc, spirc_task) = setup.get_spirc(session.clone(),(**credentials).clone(), player.clone(), mixer.clone()).await; + connecting = false; + } }, _ = async { if let Some(task) = spirc_task.as_mut() { @@ -2116,6 +166,7 @@ async fn main() { } } + #[cfg(discovery)] if let Some(discovery) = discovery { shutdown_tasks.spawn(discovery.shutdown()); }