From 4713b7b1215344873d7719b71f851b065d0dbb7f Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 21 Dec 2025 21:24:55 +0100 Subject: [PATCH] =?UTF-8?q?Premi=C3=A8re=20impl=C3=A9mentation=20chromecas?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 615 +++++++++++++++++++++++-- pmocontrol/Cargo.toml | 4 + pmocontrol/src/chromecast_discovery.rs | 274 +++++++++++ pmocontrol/src/chromecast_renderer.rs | 499 ++++++++++++++++++++ pmocontrol/src/control_point.rs | 51 ++ pmocontrol/src/lib.rs | 3 + pmocontrol/src/model.rs | 3 + pmocontrol/src/music_renderer.rs | 35 +- pmocontrol/src/openapi.rs | 2 + pmocontrol/src/pmoserver_ext.rs | 2 + 10 files changed, 1444 insertions(+), 44 deletions(-) create mode 100644 pmocontrol/src/chromecast_discovery.rs create mode 100644 pmocontrol/src/chromecast_renderer.rs diff --git a/Cargo.lock b/Cargo.lock index 6d69571e..71a31c0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,7 +48,7 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cipher", "cpufeatures", ] @@ -99,7 +99,7 @@ checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" dependencies = [ "alsa-sys", "bitflags 2.10.0", - "cfg-if", + "cfg-if 1.0.4", "libc", ] @@ -175,28 +175,193 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "async-attributes" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if 1.0.4", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.2", + "slab", + "windows-sys 0.61.2", +] + [[package]] name = "async-lock" version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" dependencies = [ - "event-listener", + "event-listener 5.4.1", "event-listener-strategy", "pin-project-lite", ] +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if 1.0.4", + "event-listener 5.4.1", + "futures-lite", + "rustix 1.1.2", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if 1.0.4", + "futures-core", + "futures-io", + "rustix 1.1.2", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-attributes", + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-stream" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22068c0c19514942eefcfd4daf8976ef1aad84e61539f95cd200c35202f80af5" +dependencies = [ + "async-stream-impl 0.2.1", + "futures-core", +] + [[package]] name = "async-stream" version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" dependencies = [ - "async-stream-impl", + "async-stream-impl 0.3.6", "futures-core", "pin-project-lite", ] +[[package]] +name = "async-stream-impl" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f9db3b38af870bf7e5cc649167533b493928e50744e2c30ae350230b414670" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "async-stream-impl" version = "0.3.6" @@ -208,6 +373,12 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -265,6 +436,28 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "aws-lc-rs" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.7.9" @@ -572,6 +765,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "built" version = "0.7.1" @@ -680,6 +886,12 @@ dependencies = [ "target-lexicon", ] +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + [[package]] name = "cfg-if" version = "1.0.4" @@ -774,7 +986,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" dependencies = [ "castaway", - "cfg-if", + "cfg-if 1.0.4", "itoa", "ryu", "static_assertions", @@ -946,7 +1158,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] @@ -1168,6 +1380,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9c272297e804878a2a4b707cfcfc6d2328b5bb936944613b4fdf2b9269afdfd" +[[package]] +name = "dns-parser" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4d33be9473d06f75f58220f71f7a9317aca647dc061dbd3c361b0bef505fbea" +dependencies = [ + "byteorder", + "quick-error 1.2.3", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1183,6 +1405,12 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" version = "1.15.0" @@ -1195,7 +1423,7 @@ version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] @@ -1235,6 +1463,20 @@ dependencies = [ "typeid", ] +[[package]] +name = "err-derive" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22deed3a8124cff5fa835713fa105621e43bbdc46690c3a6b68328a012d350d4" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "rustversion", + "syn 1.0.109", + "synstructure 0.12.6", +] + [[package]] name = "errno" version = "0.3.14" @@ -1245,6 +1487,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + [[package]] name = "event-listener" version = "5.4.1" @@ -1262,7 +1510,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener", + "event-listener 5.4.1", "pin-project-lite", ] @@ -1421,6 +1669,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.31" @@ -1469,6 +1723,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -1554,7 +1821,7 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", "wasi", ] @@ -1565,7 +1832,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", "r-efi", "wasip2", @@ -1606,6 +1873,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "h2" version = "0.4.12" @@ -1631,7 +1910,7 @@ version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "crunchy", "zerocopy", ] @@ -1723,6 +2002,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "htmlescape" version = "0.3.1" @@ -2032,7 +2320,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ "byteorder-lite", - "quick-error", + "quick-error 2.0.1", ] [[package]] @@ -2148,7 +2436,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" dependencies = [ "cesu8", - "cfg-if", + "cfg-if 1.0.4", "combine", "jni-sys", "log", @@ -2183,6 +2471,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2238,7 +2535,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "windows-link", ] @@ -2291,6 +2588,12 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -2349,6 +2652,9 @@ name = "log" version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +dependencies = [ + "value-bag", +] [[package]] name = "loop9" @@ -2404,7 +2710,7 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "rayon", ] @@ -2414,10 +2720,26 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "digest", ] +[[package]] +name = "mdns" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c769962ac75a6ea437f0922b27834bcccd4c013d591383a16ae5731e3ef0f3f3" +dependencies = [ + "async-std", + "async-stream 0.2.1", + "dns-parser", + "err-derive", + "futures-core", + "futures-util", + "log", + "net2", +] + [[package]] name = "memchr" version = "2.7.6" @@ -2534,7 +2856,7 @@ dependencies = [ "crossbeam-epoch", "crossbeam-utils", "equivalent", - "event-listener", + "event-listener 5.4.1", "futures-util", "parking_lot", "portable-atomic", @@ -2600,6 +2922,17 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "net2" +version = "0.2.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13b648036a2339d06de780866fbdfda0dde886de7b3af2ddeba8b14f4ee34ac" +dependencies = [ + "cfg-if 0.1.10", + "libc", + "winapi 0.3.9", +] + [[package]] name = "netlink-packet-core" version = "0.7.0" @@ -2680,7 +3013,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ "bitflags 2.10.0", - "cfg-if", + "cfg-if 1.0.4", "cfg_aliases", "libc", ] @@ -3046,7 +3379,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ "bitflags 2.10.0", - "cfg-if", + "cfg-if 1.0.4", "foreign-types", "libc", "once_cell", @@ -3137,7 +3470,7 @@ version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", "redox_syscall", "smallvec", @@ -3188,6 +3521,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -3331,18 +3675,22 @@ name = "pmocontrol" version = "0.1.0" dependencies = [ "anyhow", - "async-stream", + "async-std", + "async-stream 0.3.6", "async-trait", "axum 0.8.7", "chrono", "crossbeam-channel", "crossterm", + "futures-util", + "mdns", "percent-encoding", "pmodidl", "pmoserver", "pmoupnp", "quick-xml 0.38.4", "ratatui", + "rust_cast", "serde", "serde_json", "thiserror 2.0.17", @@ -3470,7 +3818,7 @@ name = "pmoparadise" version = "0.1.0" dependencies = [ "anyhow", - "async-stream", + "async-stream 0.3.6", "async-trait", "axum 0.8.7", "bytes", @@ -3514,7 +3862,7 @@ name = "pmoplaylist" version = "0.1.0" dependencies = [ "anyhow", - "async-stream", + "async-stream 0.3.6", "axum 0.8.7", "chrono", "once_cell", @@ -3576,7 +3924,7 @@ name = "pmoserver" version = "0.1.0" dependencies = [ "anyhow", - "async-stream", + "async-stream 0.3.6", "axum 0.8.7", "axum-embed", "axum-server", @@ -3686,13 +4034,27 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if 1.0.4", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + [[package]] name = "polyval" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures", "opaque-debug", "universal-hash", @@ -3756,6 +4118,30 @@ dependencies = [ "toml_edit 0.23.7", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -3816,6 +4202,57 @@ dependencies = [ "prost", ] +[[package]] +name = "protobuf" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58678a64de2fced2bdec6bca052a6716a0efe692d6e3f53d1bda6a1def64cfc0" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-codegen" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32777b0b3f6538d9d2e012b3fad85c7e4b9244b5958d04a6415f4333782b7a77" +dependencies = [ + "anyhow", + "once_cell", + "protobuf", + "protobuf-parse", + "regex", + "tempfile", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-parse" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96cb37955261126624a25b5e6bda40ae34cf3989d52a783087ca6091b29b5642" +dependencies = [ + "anyhow", + "indexmap 1.9.3", + "log", + "protobuf", + "protobuf-support", + "tempfile", + "thiserror 1.0.69", + "which", +] + +[[package]] +name = "protobuf-support" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1ed294a835b0f30810e13616b1cd34943c6d1e84a8f3b0dcfe466d256c3e7e7" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "psl-types" version = "2.0.11" @@ -3850,6 +4287,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -3982,7 +4425,7 @@ dependencies = [ "av1-grain", "bitstream-io", "built", - "cfg-if", + "cfg-if 1.0.4", "interpolate_name", "itertools 0.12.1", "libc", @@ -4014,7 +4457,7 @@ dependencies = [ "avif-serialize", "imgref", "loop9", - "quick-error", + "quick-error 2.0.1", "rav1e", "rayon", "rgb", @@ -4148,7 +4591,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", - "cfg-if", + "cfg-if 1.0.4", "getrandom 0.2.16", "libc", "untrusted", @@ -4203,6 +4646,23 @@ dependencies = [ "walkdir", ] +[[package]] +name = "rust_cast" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "132d5179ff5b592ad1286a4e9ed50e63d5ea789a956a8183914df0b74741f24c" +dependencies = [ + "byteorder", + "log", + "protobuf", + "protobuf-codegen", + "rustls", + "rustls-native-certs", + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -4218,6 +4678,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.2" @@ -4227,7 +4700,7 @@ dependencies = [ "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] @@ -4237,6 +4710,7 @@ version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -4246,6 +4720,28 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.13.0" @@ -4261,6 +4757,7 @@ version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4431,7 +4928,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures", "digest", ] @@ -4442,7 +4939,7 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures", "digest", ] @@ -4861,6 +5358,18 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-xid", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -4878,7 +5387,7 @@ version = "0.30.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a5b4ddaee55fb2bea2bf0e5000747e5f5c0de765e5a5ff87f4cd106439f4bb3" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "core-foundation-sys", "libc", "ntapi", @@ -4942,7 +5451,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix", + "rustix 1.1.2", "windows-sys 0.61.2", ] @@ -4992,7 +5501,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] @@ -5004,7 +5513,7 @@ dependencies = [ "fax", "flate2", "half", - "quick-error", + "quick-error 2.0.1", "weezl", "zune-jpeg", ] @@ -5132,7 +5641,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" dependencies = [ - "async-stream", + "async-stream 0.3.6", "bytes", "futures-core", "tokio", @@ -5222,7 +5731,7 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" dependencies = [ - "async-stream", + "async-stream 0.3.6", "async-trait", "axum 0.7.9", "base64 0.22.1", @@ -5438,6 +5947,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "universal-hash" version = "0.5.1" @@ -5601,6 +6116,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + [[package]] name = "variadics_please" version = "1.1.0" @@ -5670,7 +6191,7 @@ version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -5683,7 +6204,7 @@ version = "0.4.55" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "js-sys", "once_cell", "wasm-bindgen", @@ -5785,6 +6306,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "winapi" version = "0.2.8" @@ -6325,7 +6858,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.110", - "synstructure", + "synstructure 0.13.2", ] [[package]] @@ -6366,7 +6899,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.110", - "synstructure", + "synstructure 0.13.2", ] [[package]] diff --git a/pmocontrol/Cargo.toml b/pmocontrol/Cargo.toml index 8db3929e..98100d54 100644 --- a/pmocontrol/Cargo.toml +++ b/pmocontrol/Cargo.toml @@ -17,6 +17,10 @@ xmltree = "0.11.0" crossbeam-channel = "0.5" ratatui = { version = "0.26", default-features = false, features = ["crossterm"] } crossterm = "0.27" +rust_cast = "0.19" +mdns = "3.0" +async-std = "1.12" +futures-util = "0.3" # pmoserver extension support (optional) pmoserver = { path = "../pmoserver", optional = true } diff --git a/pmocontrol/src/chromecast_discovery.rs b/pmocontrol/src/chromecast_discovery.rs new file mode 100644 index 00000000..bc308020 --- /dev/null +++ b/pmocontrol/src/chromecast_discovery.rs @@ -0,0 +1,274 @@ +//! Chromecast device discovery via mDNS. +//! +//! Chromecast devices advertise themselves using mDNS (Multicast DNS) on the +//! `_googlecast._tcp.local` service, unlike UPnP devices which use SSDP. +//! This module handles the discovery of Chromecast devices and converts them +//! into `DeviceUpdate` events that can be processed by the `DeviceRegistry`. + +use std::collections::HashMap; +use std::net::IpAddr; +use std::time::SystemTime; + +use crate::registry::DeviceUpdate; +use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol, RendererId}; +use tracing::{debug, warn}; + +/// Information about a discovered Chromecast device from mDNS. +#[derive(Clone, Debug)] +pub struct DiscoveredChromecast { + pub friendly_name: String, + pub host: String, + pub port: u16, + pub model: Option, + pub uuid: String, + pub manufacturer: Option, + pub last_seen: SystemTime, +} + +/// Manages the discovery and tracking of Chromecast devices via mDNS. +pub struct ChromecastDiscoveryManager { + discovered_devices: HashMap, +} + +impl ChromecastDiscoveryManager { + pub fn new() -> Self { + Self { + discovered_devices: HashMap::new(), + } + } + + /// Adds or updates a discovered Chromecast device. + pub fn update_device(&mut self, device: DiscoveredChromecast) { + let uuid = device.uuid.clone(); + self.discovered_devices.insert(uuid, device); + } + + /// Retrieves a discovered device by UUID. + pub fn get_device(&self, uuid: &str) -> Option<&DiscoveredChromecast> { + self.discovered_devices.get(uuid) + } + + /// Lists all discovered devices. + pub fn list_devices(&self) -> Vec<&DiscoveredChromecast> { + self.discovered_devices.values().collect() + } +} + +/// Processes an mDNS response and converts it into a `DeviceUpdate` event. +/// +/// This function parses mDNS service discovery responses for Chromecast +/// devices and creates the appropriate update event for the device registry. +pub fn process_mdns_response(response: mdns::Response) -> Option { + // Extract basic information from the mDNS response + let service_name = response.records().filter_map(|r| { + if let mdns::RecordKind::PTR(ref name) = r.kind { + Some(name.clone()) + } else { + None + } + }).next()?; + + debug!("Processing mDNS response for service: {}", service_name); + + // Extract the friendly name from the service instance name + // Format is typically "Friendly Name._googlecast._tcp.local" + let friendly_name = service_name + .split("._googlecast._tcp.local") + .next() + .unwrap_or("Unknown Chromecast") + .to_string(); + + // Extract IP addresses + let addresses: Vec = response + .records() + .filter_map(|r| match r.kind { + mdns::RecordKind::A(addr) => Some(IpAddr::V4(addr)), + mdns::RecordKind::AAAA(addr) => Some(IpAddr::V6(addr)), + _ => None, + }) + .collect(); + + if addresses.is_empty() { + warn!("No IP address found for Chromecast device: {}", friendly_name); + return None; + } + + // Prefer IPv4 addresses + let host = addresses + .iter() + .find(|addr| matches!(addr, IpAddr::V4(_))) + .or_else(|| addresses.first()) + .map(|addr| addr.to_string())?; + + // Extract port from SRV record + let port = response + .records() + .filter_map(|r| { + if let mdns::RecordKind::SRV { port, .. } = r.kind { + Some(port) + } else { + None + } + }) + .next() + .unwrap_or(8009); // Default Chromecast port + + // Extract TXT records for additional metadata + let txt_records: HashMap = response + .records() + .filter_map(|r| { + if let mdns::RecordKind::TXT(ref data) = r.kind { + Some(data.clone()) + } else { + None + } + }) + .flat_map(|data| { + // data is Vec, each string is "key=value" + data.into_iter().filter_map(|s| { + let parts: Vec<&str> = s.splitn(2, '=').collect(); + if parts.len() == 2 { + Some((parts[0].to_string(), parts[1].to_string())) + } else { + None + } + }) + }) + .collect(); + + // Extract metadata from TXT records + let model = txt_records.get("md").cloned(); + let uuid = txt_records + .get("id") + .cloned() + .unwrap_or_else(|| format!("chromecast-{}-{}", host, port)); + let manufacturer = Some("Google Inc.".to_string()); + + debug!( + "Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})", + friendly_name, host, port, uuid, model + ); + + // Create RendererInfo for the registry + let renderer_info = build_renderer_info( + &uuid, + &friendly_name, + &host, + port, + model.as_deref(), + manufacturer.as_deref(), + ); + + Some(DeviceUpdate::RendererOnline(renderer_info)) +} + +/// Builds a `RendererInfo` structure for a Chromecast device. +fn build_renderer_info( + uuid: &str, + friendly_name: &str, + host: &str, + port: u16, + model: Option<&str>, + manufacturer: Option<&str>, +) -> RendererInfo { + let udn = format!("uuid:{}", uuid); + let id = RendererId(udn.clone()); + + // Build Chromecast capabilities + let mut capabilities = RendererCapabilities::default(); + capabilities.has_chromecast = true; + + // The location URL for Chromecast is just the host:port + // (not a real HTTP endpoint like UPnP, but useful for identification) + let location = format!("chromecast://{}:{}", host, port); + + RendererInfo { + id, + udn, + friendly_name: friendly_name.to_string(), + model_name: model.unwrap_or("Chromecast").to_string(), + manufacturer: manufacturer.unwrap_or("Google Inc.").to_string(), + protocol: RendererProtocol::ChromecastOnly, + capabilities, + location, + server_header: "Chromecast".to_string(), + online: true, + last_seen: SystemTime::now(), + max_age: 1800, // 30 minutes + // All UPnP/OpenHome fields are None for Chromecast + avtransport_service_type: None, + avtransport_control_url: None, + rendering_control_service_type: None, + rendering_control_control_url: None, + connection_manager_service_type: None, + connection_manager_control_url: None, + oh_playlist_service_type: None, + oh_playlist_control_url: None, + oh_playlist_event_sub_url: None, + oh_info_service_type: None, + oh_info_control_url: None, + oh_info_event_sub_url: None, + oh_time_service_type: None, + oh_time_control_url: None, + oh_time_event_sub_url: None, + oh_volume_service_type: None, + oh_volume_control_url: None, + oh_radio_service_type: None, + oh_radio_control_url: None, + oh_product_service_type: None, + oh_product_control_url: None, + } +} + +/// Extracts the host (IP address) from a Chromecast location URL. +/// +/// The location format is `chromecast://host:port`. +pub fn extract_host_from_location(location: &str) -> Option { + if let Some(stripped) = location.strip_prefix("chromecast://") { + let host = stripped.split(':').next()?; + Some(host.to_string()) + } else { + None + } +} + +/// Extracts the port from a Chromecast location URL. +/// +/// The location format is `chromecast://host:port`. +pub fn extract_port_from_location(location: &str) -> Option { + if let Some(stripped) = location.strip_prefix("chromecast://") { + let port_str = stripped.split(':').nth(1)?; + port_str.parse().ok() + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_host_from_location() { + assert_eq!( + extract_host_from_location("chromecast://192.168.1.100:8009"), + Some("192.168.1.100".to_string()) + ); + assert_eq!( + extract_host_from_location("http://192.168.1.100:8009"), + None + ); + } + + #[test] + fn test_extract_port_from_location() { + assert_eq!( + extract_port_from_location("chromecast://192.168.1.100:8009"), + Some(8009) + ); + assert_eq!( + extract_port_from_location("chromecast://192.168.1.100"), + None + ); + } +} diff --git a/pmocontrol/src/chromecast_renderer.rs b/pmocontrol/src/chromecast_renderer.rs new file mode 100644 index 00000000..1a98b4c2 --- /dev/null +++ b/pmocontrol/src/chromecast_renderer.rs @@ -0,0 +1,499 @@ +//! Chromecast backend implementation using the rust_cast library. +//! +//! This module provides a `ChromecastRenderer` that implements the standard +//! transport and volume control traits, allowing Chromecast devices to be +//! controlled through the same interface as UPnP, OpenHome, and other backends. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use anyhow::{anyhow, Result}; +use rust_cast::channels::media::{Image, Media, Metadata, MusicTrackMediaMetadata, StreamType}; +use rust_cast::channels::receiver::CastDeviceApp; +use rust_cast::CastDevice; +use tracing::debug; + +use crate::capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, + VolumeControl, +}; +use crate::chromecast_discovery::{extract_host_from_location, extract_port_from_location}; +use crate::model::{RendererInfo, RendererId, RendererProtocol}; +use crate::openhome_client::parse_track_metadata_from_didl; + +/// Default Chromecast port. +const DEFAULT_CHROMECAST_PORT: u16 = 8009; + +/// Default timeout for Chromecast operations. +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Session state for a Chromecast connection. +/// +/// This tracks session IDs and cached status to enable efficient +/// communication with the Chromecast device. +/// Note: We don't store the connection itself to avoid lifetime issues. +#[derive(Debug)] +struct ChromecastSessionState { + /// The receiver session ID obtained when launching an app. + receiver_session_id: Option, + + /// The media session ID obtained when loading media. + media_session_id: Option, + + /// The destination transport ID (usually "web-0"). + destination_id: Option, +} + +impl ChromecastSessionState { + fn new() -> Self { + Self { + receiver_session_id: None, + media_session_id: None, + destination_id: None, + } + } + + /// Clears all session state. + fn clear(&mut self) { + self.receiver_session_id = None; + self.media_session_id = None; + self.destination_id = None; + } +} + +/// Chromecast renderer backend. +/// +/// Uses the rust_cast library to communicate with Chromecast devices +/// via the Cast protocol (Protocol Buffers over TLS). +#[derive(Clone, Debug)] +pub struct ChromecastRenderer { + pub info: RendererInfo, + host: String, + port: u16, + session_state: Arc>, + timeout: Duration, +} + +impl ChromecastRenderer { + /// Creates a new ChromecastRenderer from RendererInfo. + pub fn from_renderer_info(info: RendererInfo) -> Result { + // Extract host and port from the location URL + let host = extract_host_from_location(&info.location) + .ok_or_else(|| anyhow!("Invalid Chromecast location: {}", info.location))?; + + let port = extract_port_from_location(&info.location) + .unwrap_or(DEFAULT_CHROMECAST_PORT); + + debug!( + "Creating ChromecastRenderer for {} at {}:{}", + info.friendly_name, host, port + ); + + Ok(Self { + info, + host, + port, + session_state: Arc::new(Mutex::new(ChromecastSessionState::new())), + timeout: DEFAULT_TIMEOUT, + }) + } + + /// Returns the renderer ID. + pub fn id(&self) -> &RendererId { + &self.info.id + } + + /// Returns the friendly name. + pub fn friendly_name(&self) -> &str { + &self.info.friendly_name + } + + /// Returns the protocol. + pub fn protocol(&self) -> &RendererProtocol { + &self.info.protocol + } + + /// Returns the renderer info. + pub fn info(&self) -> &RendererInfo { + &self.info + } + + /// Creates a new connection to the Chromecast device. + /// + /// This creates a fresh connection each time to avoid lifetime issues. + fn connect(&self) -> Result> { + debug!("Connecting to Chromecast at {}:{}", self.host, self.port); + + let device = CastDevice::connect(&self.host, self.port) + .map_err(|e| anyhow!("Failed to connect to Chromecast: {}", e))?; + + debug!("Successfully connected to Chromecast"); + Ok(device) + } + + /// Ensures a receiver session exists by launching the Default Media Receiver app. + /// + /// This must be called before any media operations. + /// Returns a new connection with the session already established. + fn ensure_session(&self) -> Result> { + let device = self.connect()?; + + let mut state = self.session_state.lock() + .map_err(|e| anyhow!("Failed to acquire session state lock: {}", e))?; + + if state.receiver_session_id.is_none() { + debug!("Launching Default Media Receiver app"); + + let app = device.receiver.launch_app(&CastDeviceApp::DefaultMediaReceiver) + .map_err(|e| anyhow!("Failed to launch app: {}", e))?; + + state.receiver_session_id = Some(app.session_id.clone()); + state.destination_id = Some(app.transport_id.clone()); + + debug!( + "Launched app with session_id: {}, transport_id: {}", + app.session_id, app.transport_id + ); + } + + Ok(device) + } + + /// Converts DIDL-Lite metadata to rust_cast Media format. + fn build_media_from_didl(&self, uri: &str, didl_xml: &str) -> Result { + // Parse DIDL-Lite metadata + let metadata = parse_track_metadata_from_didl(didl_xml) + .unwrap_or_else(|| crate::model::TrackMetadata { + title: None, + artist: None, + album: None, + genre: None, + album_art_uri: None, + date: None, + track_number: None, + creator: None, + }); + + // Build music track metadata + let images = metadata.album_art_uri + .map(|uri| vec![Image { url: uri, dimensions: None }]) + .unwrap_or_default(); + + let music_metadata = MusicTrackMediaMetadata { + title: metadata.title, + artist: metadata.artist, + album_name: metadata.album, + images, + release_date: metadata.date, + ..Default::default() + }; + + // Detect content type from URI + let content_type = if uri.ends_with(".flac") { + "audio/flac" + } else if uri.ends_with(".mp3") { + "audio/mpeg" + } else if uri.ends_with(".ogg") || uri.ends_with(".oga") { + "audio/ogg" + } else if uri.ends_with(".m4a") || uri.ends_with(".aac") { + "audio/mp4" + } else { + "audio/flac" // Default to FLAC + }.to_string(); + + Ok(Media { + content_id: uri.to_string(), + content_type, + stream_type: StreamType::Buffered, + metadata: Some(Metadata::MusicTrack(music_metadata)), + duration: None, // Will be populated from status + }) + } + + /// Gets the current media status from the Chromecast. + fn get_media_status(&self) -> Result { + let device = self.connect()?; + + let state = self.session_state.lock() + .map_err(|e| anyhow!("Failed to acquire session state lock: {}", e))?; + + let destination_id = state.destination_id.as_ref() + .ok_or_else(|| anyhow!("No destination ID available"))? + .clone(); + + let media_session_id = state.media_session_id; + + drop(state); + + device.media.get_status(destination_id, media_session_id) + .map_err(|e| anyhow!("Failed to get media status: {}", e)) + } + + /// Parses a HH:MM:SS time string to seconds. + fn parse_hhmmss_to_seconds(hhmmss: &str) -> Result { + let parts: Vec<&str> = hhmmss.split(':').collect(); + match parts.len() { + 3 => { + let hours: f64 = parts[0].parse() + .map_err(|_| anyhow!("Invalid hours in time format"))?; + let minutes: f64 = parts[1].parse() + .map_err(|_| anyhow!("Invalid minutes in time format"))?; + let seconds: f64 = parts[2].parse() + .map_err(|_| anyhow!("Invalid seconds in time format"))?; + Ok(hours * 3600.0 + minutes * 60.0 + seconds) + } + _ => Err(anyhow!("Invalid time format, expected HH:MM:SS")), + } + } + + /// Formats seconds to HH:MM:SS string. + fn format_seconds_to_hhmmss(seconds: f64) -> String { + let h = (seconds / 3600.0).floor() as u32; + let m = ((seconds % 3600.0) / 60.0).floor() as u32; + let s = (seconds % 60.0).floor() as u32; + format!("{:02}:{:02}:{:02}", h, m, s) + } +} + +impl TransportControl for ChromecastRenderer { + fn play_uri(&self, uri: &str, meta: &str) -> Result<()> { + debug!("ChromecastRenderer: play_uri({})", uri); + + // Ensure we have a session and get a connection + let device = self.ensure_session()?; + + // Build media from DIDL metadata + let media = self.build_media_from_didl(uri, meta)?; + + // Get session IDs + let state = self.session_state.lock() + .map_err(|e| anyhow!("Failed to acquire session state lock: {}", e))?; + + let destination_id = state.destination_id.as_ref() + .ok_or_else(|| anyhow!("No destination ID available"))? + .clone(); + + let session_id = state.receiver_session_id.as_ref() + .ok_or_else(|| anyhow!("No receiver session ID available"))? + .clone(); + + // Drop the lock before calling device methods + drop(state); + + let status = device.media.load( + &destination_id, + &session_id, + &media, + ).map_err(|e| anyhow!("Failed to load media: {}", e))?; + + // Cache media session ID + let mut state = self.session_state.lock() + .map_err(|e| anyhow!("Failed to acquire session state lock: {}", e))?; + + if let Some(entry) = status.entries.first() { + state.media_session_id = Some(entry.media_session_id); + debug!("Media loaded with session ID: {}", entry.media_session_id); + } + + Ok(()) + } + + fn play(&self) -> Result<()> { + debug!("ChromecastRenderer: play()"); + + let device = self.connect()?; + + let state = self.session_state.lock() + .map_err(|e| anyhow!("Failed to acquire session state lock: {}", e))?; + + let destination_id = state.destination_id.as_ref() + .ok_or_else(|| anyhow!("No destination ID available"))? + .clone(); + + let media_session_id = state.media_session_id + .ok_or_else(|| anyhow!("No media session ID available"))?; + + drop(state); + + device.media.play(&destination_id, media_session_id) + .map_err(|e| anyhow!("Failed to play: {}", e))?; + + Ok(()) + } + + fn pause(&self) -> Result<()> { + debug!("ChromecastRenderer: pause()"); + + let device = self.connect()?; + + let state = self.session_state.lock() + .map_err(|e| anyhow!("Failed to acquire session state lock: {}", e))?; + + let destination_id = state.destination_id.as_ref() + .ok_or_else(|| anyhow!("No destination ID available"))? + .clone(); + + let media_session_id = state.media_session_id + .ok_or_else(|| anyhow!("No media session ID available"))?; + + drop(state); + + device.media.pause(&destination_id, media_session_id) + .map_err(|e| anyhow!("Failed to pause: {}", e))?; + + Ok(()) + } + + fn stop(&self) -> Result<()> { + debug!("ChromecastRenderer: stop()"); + + let device = self.connect()?; + + let state = self.session_state.lock() + .map_err(|e| anyhow!("Failed to acquire session state lock: {}", e))?; + + let destination_id = state.destination_id.as_ref() + .ok_or_else(|| anyhow!("No destination ID available"))? + .clone(); + + let media_session_id = state.media_session_id + .ok_or_else(|| anyhow!("No media session ID available"))?; + + drop(state); + + device.media.stop(&destination_id, media_session_id) + .map_err(|e| anyhow!("Failed to stop: {}", e))?; + + Ok(()) + } + + fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { + debug!("ChromecastRenderer: seek_rel_time({})", hhmmss); + + let seconds = Self::parse_hhmmss_to_seconds(hhmmss)? as f32; + let device = self.connect()?; + + let state = self.session_state.lock() + .map_err(|e| anyhow!("Failed to acquire session state lock: {}", e))?; + + let destination_id = state.destination_id.as_ref() + .ok_or_else(|| anyhow!("No destination ID available"))? + .clone(); + + let media_session_id = state.media_session_id + .ok_or_else(|| anyhow!("No media session ID available"))?; + + drop(state); + + device.media.seek( + &destination_id, + media_session_id, + Some(seconds), + Some(rust_cast::channels::media::ResumeState::PlaybackStart), + ).map_err(|e| anyhow!("Failed to seek: {}", e))?; + + Ok(()) + } +} + +impl VolumeControl for ChromecastRenderer { + fn volume(&self) -> Result { + let device = self.connect()?; + + let status = device.receiver.get_status() + .map_err(|e| anyhow!("Failed to get receiver status: {}", e))?; + + // Convert f32 (0.0-1.0) to u16 (0-100) + let volume = (status.volume.level.unwrap_or(0.0) * 100.0).round() as u16; + Ok(volume.min(100)) + } + + fn set_volume(&self, v: u16) -> Result<()> { + debug!("ChromecastRenderer: set_volume({})", v); + + let device = self.connect()?; + let level = (v.min(100) as f32) / 100.0; + + device.receiver.set_volume(level) + .map_err(|e| anyhow!("Failed to set volume: {}", e))?; + + Ok(()) + } + + fn mute(&self) -> Result { + let device = self.connect()?; + + let status = device.receiver.get_status() + .map_err(|e| anyhow!("Failed to get receiver status: {}", e))?; + + Ok(status.volume.muted.unwrap_or(false)) + } + + fn set_mute(&self, m: bool) -> Result<()> { + debug!("ChromecastRenderer: set_mute({})", m); + + let device = self.connect()?; + + // Use set_volume with bool (Volume implements From) + device.receiver.set_volume(m) + .map_err(|e| anyhow!("Failed to set mute: {}", e))?; + + Ok(()) + } +} + +impl PlaybackStatus for ChromecastRenderer { + fn playback_state(&self) -> Result { + let status = self.get_media_status()?; + + if let Some(entry) = status.entries.first() { + use rust_cast::channels::media::PlayerState; + + let state = match entry.player_state { + PlayerState::Playing => PlaybackState::Playing, + PlayerState::Paused => PlaybackState::Paused, + PlayerState::Idle => PlaybackState::Stopped, + PlayerState::Buffering => PlaybackState::Transitioning, + }; + + Ok(state) + } else { + Ok(PlaybackState::NoMedia) + } + } +} + +impl PlaybackPosition for ChromecastRenderer { + fn playback_position(&self) -> Result { + let status = self.get_media_status()?; + + if let Some(entry) = status.entries.first() { + let rel_time = entry.current_time.map(|t| Self::format_seconds_to_hhmmss(t as f64)); + + let track_duration = entry.media.as_ref() + .and_then(|m| m.duration) + .map(|d| Self::format_seconds_to_hhmmss(d as f64)); + + let track_uri = entry.media.as_ref() + .map(|m| m.content_id.clone()); + + Ok(PlaybackPositionInfo { + track: None, + rel_time, + abs_time: None, + track_duration, + track_metadata: None, + track_uri, + }) + } else { + Ok(PlaybackPositionInfo { + track: None, + rel_time: None, + abs_time: None, + track_duration: None, + track_metadata: None, + track_uri: None, + }) + } + } +} diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index 2a2d04e6..4d860ddc 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -166,6 +166,57 @@ impl ControlPoint { }); }); + // Thread de découverte mDNS pour Chromecast + let registry_for_mdns = Arc::clone(®istry); + thread::spawn(move || { + use crate::chromecast_discovery; + use futures_util::StreamExt; + + debug!("Starting mDNS discovery thread for Chromecast devices"); + + const SERVICE_NAME: &str = "_googlecast._tcp.local"; + + // Run async discovery in a blocking task + async_std::task::block_on(async { + // Create mDNS discovery stream with 30 second query interval + match mdns::discover::all(SERVICE_NAME, Duration::from_secs(30)) { + Ok(discovery) => { + let stream = discovery.listen(); + futures_util::pin_mut!(stream); + + debug!("mDNS discovery stream started for Chromecast devices"); + + // Listen to mDNS responses + while let Some(result) = stream.next().await { + match result { + Ok(response) => { + debug!("Received mDNS response with {} records", + response.records().count()); + + // Process the mDNS response + if let Some(update) = chromecast_discovery::process_mdns_response(response) { + debug!("Processed Chromecast device update: {:?}", update); + + // Update the registry + let mut registry = registry_for_mdns.write().unwrap(); + registry.apply_update(update); + } + } + Err(e) => { + warn!("mDNS discovery error: {}", e); + } + } + } + + warn!("mDNS discovery stream ended unexpectedly"); + } + Err(e) => { + error!("Failed to start mDNS discovery: {}", e); + } + } + }); + }); + let runtime_cp = ControlPoint { registry: Arc::clone(®istry), event_bus: event_bus.clone(), diff --git a/pmocontrol/src/lib.rs b/pmocontrol/src/lib.rs index 37c2c951..245a884e 100644 --- a/pmocontrol/src/lib.rs +++ b/pmocontrol/src/lib.rs @@ -4,6 +4,8 @@ mod media_server_events; pub mod arylic_tcp; pub mod avtransport_client; pub mod capabilities; +pub mod chromecast_discovery; +pub mod chromecast_renderer; pub mod connection_manager_client; pub mod control_point; pub mod discovery; @@ -40,6 +42,7 @@ pub use capabilities::{ PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, VolumeControl, }; +pub use chromecast_renderer::ChromecastRenderer; pub use connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo}; pub use control_point::{ControlPoint, PlaylistBinding}; pub use linkplay::LinkPlayRenderer; diff --git a/pmocontrol/src/model.rs b/pmocontrol/src/model.rs index f9ce9e15..ad315abb 100644 --- a/pmocontrol/src/model.rs +++ b/pmocontrol/src/model.rs @@ -22,6 +22,7 @@ pub enum RendererProtocol { UpnpAvOnly, OpenHomeOnly, Hybrid, + ChromecastOnly, } #[derive(Clone, Debug, Default)] @@ -41,6 +42,8 @@ pub struct RendererCapabilities { pub has_oh_info: bool, pub has_oh_time: bool, pub has_oh_radio: bool, + + pub has_chromecast: bool, } impl RendererCapabilities { diff --git a/pmocontrol/src/music_renderer.rs b/pmocontrol/src/music_renderer.rs index bbb1f276..7040e54e 100644 --- a/pmocontrol/src/music_renderer.rs +++ b/pmocontrol/src/music_renderer.rs @@ -1,7 +1,7 @@ //! Backend-agnostic music renderer façade for PMOMusic. //! //! `MusicRenderer` wraps every supported backend (UPnP AV/DLNA, OpenHome, -//! LinkPlay HTTP, Arylic TCP, and the hybrid UPnP + Arylic pairing) behind a +//! LinkPlay HTTP, Arylic TCP, Chromecast, and the hybrid UPnP + Arylic pairing) behind a //! single control surface. Higher layers in PMOMusic must only interact with //! renderers through this type so that transport, volume, and state queries //! stay backend-neutral. @@ -18,8 +18,8 @@ use crate::openhome_client::parse_track_metadata_from_didl; use crate::openhome_playlist::OpenHomePlaylistSnapshot; use crate::queue_backend::PlaybackItem; use crate::{ - ArylicTcpRenderer, DeviceRegistry, LinkPlayRenderer, OpenHomeRenderer, PlaybackPosition, - PlaybackState, TransportControl, UpnpRenderer, VolumeControl, + ArylicTcpRenderer, ChromecastRenderer, DeviceRegistry, LinkPlayRenderer, OpenHomeRenderer, + PlaybackPosition, PlaybackState, TransportControl, UpnpRenderer, VolumeControl, }; use anyhow::{Result, anyhow}; use tracing::warn; @@ -35,6 +35,8 @@ pub enum MusicRenderer { LinkPlay(LinkPlayRenderer), /// Renderer reachable through the Arylic TCP control protocol (port 8899). ArylicTcp(ArylicTcpRenderer), + /// Renderer controlled via the Google Cast protocol (Chromecast). + Chromecast(ChromecastRenderer), /// Combined backend using UPnP for transport + volume writes and Arylic TCP /// to read detailed playback information as well as live volume/mute state. HybridUpnpArylic { @@ -81,6 +83,7 @@ impl MusicRenderer { MusicRenderer::Upnp(r) => r.id(), MusicRenderer::LinkPlay(r) => r.id(), MusicRenderer::ArylicTcp(r) => r.id(), + MusicRenderer::Chromecast(r) => r.id(), } } @@ -92,6 +95,7 @@ impl MusicRenderer { MusicRenderer::Upnp(r) => r.friendly_name(), MusicRenderer::LinkPlay(r) => r.friendly_name(), MusicRenderer::ArylicTcp(r) => r.friendly_name(), + MusicRenderer::Chromecast(r) => r.friendly_name(), } } @@ -108,6 +112,7 @@ impl MusicRenderer { MusicRenderer::Upnp(r) => &r.info, MusicRenderer::LinkPlay(r) => &r.info, MusicRenderer::ArylicTcp(r) => &r.info, + MusicRenderer::Chromecast(r) => &r.info, } } @@ -148,6 +153,17 @@ impl MusicRenderer { } } + if matches!(info.protocol, RendererProtocol::ChromecastOnly) { + if let Ok(renderer) = ChromecastRenderer::from_renderer_info(info.clone()) { + return Some(MusicRenderer::Chromecast(renderer)); + } + warn!( + renderer = info.friendly_name.as_str(), + "Failed to build Chromecast renderer" + ); + return None; + } + match info.protocol { RendererProtocol::UpnpAvOnly | RendererProtocol::Hybrid => { let has_arylic = info.capabilities.has_arylic_tcp; @@ -184,6 +200,7 @@ impl MusicRenderer { ))) } RendererProtocol::OpenHomeOnly => None, + RendererProtocol::ChromecastOnly => None, } } @@ -310,6 +327,7 @@ impl MusicRenderer { MusicRenderer::OpenHome(_) => "OpenHome", MusicRenderer::LinkPlay(_) => "LinkPlay", MusicRenderer::ArylicTcp(_) => "ArylicTcp", + MusicRenderer::Chromecast(_) => "Chromecast", MusicRenderer::HybridUpnpArylic { .. } => "HybridUpnpArylic", } } @@ -370,6 +388,7 @@ impl TransportControl for MusicRenderer { MusicRenderer::OpenHome(oh) => oh.play_uri(uri, meta), MusicRenderer::LinkPlay(lp) => lp.play_uri(uri, meta), MusicRenderer::ArylicTcp(_) => Err(op_not_supported("play_uri", "ArylicTcp")), + MusicRenderer::Chromecast(cc) => cc.play_uri(uri, meta), MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta), } } @@ -380,6 +399,7 @@ impl TransportControl for MusicRenderer { MusicRenderer::OpenHome(oh) => oh.play(), MusicRenderer::LinkPlay(lp) => lp.play(), MusicRenderer::ArylicTcp(ary) => ary.play(), + MusicRenderer::Chromecast(cc) => cc.play(), MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.play(), } } @@ -390,6 +410,7 @@ impl TransportControl for MusicRenderer { MusicRenderer::OpenHome(oh) => oh.pause(), MusicRenderer::LinkPlay(lp) => lp.pause(), MusicRenderer::ArylicTcp(ary) => ary.pause(), + MusicRenderer::Chromecast(cc) => cc.pause(), MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.pause(), } } @@ -400,6 +421,7 @@ impl TransportControl for MusicRenderer { MusicRenderer::OpenHome(oh) => oh.stop(), MusicRenderer::LinkPlay(lp) => lp.stop(), MusicRenderer::ArylicTcp(ary) => ary.stop(), + MusicRenderer::Chromecast(cc) => cc.stop(), MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.stop(), } } @@ -410,6 +432,7 @@ impl TransportControl for MusicRenderer { MusicRenderer::OpenHome(oh) => oh.seek_rel_time(hhmmss), MusicRenderer::LinkPlay(lp) => lp.seek_rel_time(hhmmss), MusicRenderer::ArylicTcp(_) => Err(op_not_supported("seek_rel_time", "ArylicTcp")), + MusicRenderer::Chromecast(cc) => cc.seek_rel_time(hhmmss), MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.seek_rel_time(hhmmss), } } @@ -427,6 +450,7 @@ impl VolumeControl for MusicRenderer { MusicRenderer::OpenHome(oh) => oh.volume(), MusicRenderer::Upnp(upnp) => upnp.volume(), MusicRenderer::LinkPlay(lp) => lp.volume(), + MusicRenderer::Chromecast(cc) => cc.volume(), } } @@ -437,6 +461,7 @@ impl VolumeControl for MusicRenderer { MusicRenderer::OpenHome(oh) => oh.set_volume(vol), MusicRenderer::Upnp(upnp) => upnp.set_volume(vol), MusicRenderer::LinkPlay(lp) => lp.set_volume(vol), + MusicRenderer::Chromecast(cc) => cc.set_volume(vol), } } @@ -447,6 +472,7 @@ impl VolumeControl for MusicRenderer { MusicRenderer::Upnp(r) => r.get_master_mute(), MusicRenderer::LinkPlay(r) => r.mute(), MusicRenderer::ArylicTcp(r) => r.mute(), + MusicRenderer::Chromecast(cc) => cc.mute(), } } @@ -457,6 +483,7 @@ impl VolumeControl for MusicRenderer { MusicRenderer::Upnp(r) => r.set_master_mute(m), MusicRenderer::LinkPlay(r) => r.set_mute(m), MusicRenderer::ArylicTcp(r) => r.set_mute(m), + MusicRenderer::Chromecast(cc) => cc.set_mute(m), } } } @@ -472,6 +499,7 @@ impl PlaybackStatus for MusicRenderer { MusicRenderer::OpenHome(r) => PlaybackStatus::playback_state(r), MusicRenderer::LinkPlay(r) => r.playback_state(), MusicRenderer::ArylicTcp(r) => r.playback_state(), + MusicRenderer::Chromecast(cc) => cc.playback_state(), MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.playback_state(), } } @@ -486,6 +514,7 @@ impl PlaybackPosition for MusicRenderer { MusicRenderer::OpenHome(r) => r.playback_position(), MusicRenderer::LinkPlay(r) => r.playback_position(), MusicRenderer::ArylicTcp(r) => r.playback_position(), + MusicRenderer::Chromecast(cc) => cc.playback_position(), MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.playback_position(), } } diff --git a/pmocontrol/src/openapi.rs b/pmocontrol/src/openapi.rs index e6de0c55..2f14c895 100644 --- a/pmocontrol/src/openapi.rs +++ b/pmocontrol/src/openapi.rs @@ -38,6 +38,7 @@ pub enum RendererProtocolSummary { Upnp, Openhome, Hybrid, + Chromecast, } /// Drapeaux de capacités renderer (transport, volume, services OpenHome, etc.) @@ -55,6 +56,7 @@ pub struct RendererCapabilitiesSummary { pub has_oh_info: bool, pub has_oh_time: bool, pub has_oh_radio: bool, + pub has_chromecast: bool, } /// État détaillé d'un renderer diff --git a/pmocontrol/src/pmoserver_ext.rs b/pmocontrol/src/pmoserver_ext.rs index 137c6fe6..72d54114 100644 --- a/pmocontrol/src/pmoserver_ext.rs +++ b/pmocontrol/src/pmoserver_ext.rs @@ -1891,6 +1891,7 @@ fn protocol_summary(protocol: &RendererProtocol) -> RendererProtocolSummary { RendererProtocol::UpnpAvOnly => RendererProtocolSummary::Upnp, RendererProtocol::OpenHomeOnly => RendererProtocolSummary::Openhome, RendererProtocol::Hybrid => RendererProtocolSummary::Hybrid, + RendererProtocol::ChromecastOnly => RendererProtocolSummary::Chromecast, } } @@ -1908,6 +1909,7 @@ fn capability_summary(caps: &RendererCapabilities) -> RendererCapabilitiesSummar has_oh_info: caps.has_oh_info, has_oh_time: caps.has_oh_time, has_oh_radio: caps.has_oh_radio, + has_chromecast: caps.has_chromecast, } }