Implémentation de l'API REST De PMOControl.
This commit is contained in:
264
Cargo.lock
generated
264
Cargo.lock
generated
@@ -49,6 +49,12 @@ dependencies = [
|
|||||||
"equator",
|
"equator",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "allocator-api2"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alsa"
|
name = "alsa"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -586,6 +592,21 @@ version = "1.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4964518bd3b4a8190e832886cdc0da9794f12e8e6c1613a9e90ff331c4c8724b"
|
checksum = "4964518bd3b4a8190e832886cdc0da9794f12e8e6c1613a9e90ff331c4c8724b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cassowary"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "castaway"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.46"
|
version = "1.2.46"
|
||||||
@@ -700,6 +721,19 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compact_str"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f"
|
||||||
|
dependencies = [
|
||||||
|
"castaway",
|
||||||
|
"cfg-if",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"static_assertions",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -909,6 +943,31 @@ version = "0.8.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm"
|
||||||
|
version = "0.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"crossterm_winapi",
|
||||||
|
"libc",
|
||||||
|
"mio 0.8.11",
|
||||||
|
"parking_lot",
|
||||||
|
"signal-hook",
|
||||||
|
"signal-hook-mio",
|
||||||
|
"winapi 0.3.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm_winapi"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||||
|
dependencies = [
|
||||||
|
"winapi 0.3.9",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crunchy"
|
name = "crunchy"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
@@ -1532,6 +1591,8 @@ version = "0.15.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"allocator-api2",
|
||||||
|
"equivalent",
|
||||||
"foldhash 0.1.5",
|
"foldhash 0.1.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2223,6 +2284,15 @@ dependencies = [
|
|||||||
"imgref",
|
"imgref",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru"
|
||||||
|
version = "0.12.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.15.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mach2"
|
name = "mach2"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -2331,6 +2401,18 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "0.8.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -3163,14 +3245,26 @@ name = "pmocontrol"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"async-stream",
|
||||||
|
"async-trait",
|
||||||
|
"axum 0.8.7",
|
||||||
|
"chrono",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
|
"crossterm",
|
||||||
"pmodidl",
|
"pmodidl",
|
||||||
|
"pmoserver",
|
||||||
"pmoupnp",
|
"pmoupnp",
|
||||||
"quick-xml 0.38.4",
|
"quick-xml 0.38.4",
|
||||||
|
"ratatui",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"ureq",
|
"ureq",
|
||||||
|
"utoipa",
|
||||||
"xmltree 0.11.0",
|
"xmltree 0.11.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3742,6 +3836,26 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ratatui"
|
||||||
|
version = "0.26.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"cassowary",
|
||||||
|
"compact_str",
|
||||||
|
"crossterm",
|
||||||
|
"itertools 0.12.1",
|
||||||
|
"lru",
|
||||||
|
"paste",
|
||||||
|
"stability",
|
||||||
|
"strum",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"unicode-truncate",
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rav1e"
|
name = "rav1e"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -4234,6 +4348,27 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook"
|
||||||
|
version = "0.3.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"signal-hook-registry",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-mio"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"mio 0.8.11",
|
||||||
|
"signal-hook",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signal-hook-registry"
|
name = "signal-hook-registry"
|
||||||
version = "1.4.6"
|
version = "1.4.6"
|
||||||
@@ -4336,12 +4471,50 @@ dependencies = [
|
|||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "stability"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.110",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "static_assertions"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum"
|
||||||
|
version = "0.26.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||||
|
dependencies = [
|
||||||
|
"strum_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum_macros"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustversion",
|
||||||
|
"syn 2.0.110",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@@ -4786,7 +4959,7 @@ checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio 1.1.0",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
@@ -5117,6 +5290,29 @@ version = "1.0.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-segmentation"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-truncate"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
||||||
|
dependencies = [
|
||||||
|
"itertools 0.13.0",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unsafe-libyaml"
|
name = "unsafe-libyaml"
|
||||||
version = "0.2.11"
|
version = "0.2.11"
|
||||||
@@ -5618,6 +5814,15 @@ dependencies = [
|
|||||||
"windows-targets 0.42.2",
|
"windows-targets 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.48.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.48.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
@@ -5669,6 +5874,21 @@ dependencies = [
|
|||||||
"windows_x86_64_msvc 0.42.2",
|
"windows_x86_64_msvc 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm 0.48.5",
|
||||||
|
"windows_aarch64_msvc 0.48.5",
|
||||||
|
"windows_i686_gnu 0.48.5",
|
||||||
|
"windows_i686_msvc 0.48.5",
|
||||||
|
"windows_x86_64_gnu 0.48.5",
|
||||||
|
"windows_x86_64_gnullvm 0.48.5",
|
||||||
|
"windows_x86_64_msvc 0.48.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5708,6 +5928,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5726,6 +5952,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5744,6 +5976,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5774,6 +6012,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5792,6 +6036,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5810,6 +6060,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5828,6 +6084,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|||||||
@@ -14,3 +14,22 @@ tracing-subscriber = "0.3"
|
|||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
xmltree = "0.11.0"
|
xmltree = "0.11.0"
|
||||||
crossbeam-channel = "0.5"
|
crossbeam-channel = "0.5"
|
||||||
|
ratatui = { version = "0.26", default-features = false, features = ["crossterm"] }
|
||||||
|
crossterm = "0.27"
|
||||||
|
|
||||||
|
# pmoserver extension support (optional)
|
||||||
|
pmoserver = { path = "../pmoserver", optional = true }
|
||||||
|
utoipa = { version = "5.4.0", optional = true }
|
||||||
|
axum = { version = "0.8.4", optional = true }
|
||||||
|
serde = { version = "1.0", features = ["derive"], optional = true }
|
||||||
|
serde_json = { version = "1.0", optional = true }
|
||||||
|
tokio = { version = "1", features = ["sync", "rt"], optional = true }
|
||||||
|
async-trait = { version = "0.1", optional = true }
|
||||||
|
tokio-stream = { version = "0.1", features = ["sync"], optional = true }
|
||||||
|
async-stream = { version = "0.3", optional = true }
|
||||||
|
chrono = { version = "0.4", features = ["serde"], optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
# Active l'API REST pmoserver
|
||||||
|
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:serde", "dep:serde_json", "dep:tokio", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:chrono"]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
//! Live PMOMusic demo: binds a renderer queue to a dynamic "Live Playlist" container
|
//! Live PMOMusic demo: binds a renderer queue to a dynamic "Live Playlist" container
|
||||||
//! and monitors ContentDirectory updates over an extended period (~30 minutes).
|
//! and monitors ContentDirectory updates over an extended period (~30 minutes).
|
||||||
|
|
||||||
use std::collections::{VecDeque, HashSet};
|
use std::collections::{HashSet, VecDeque};
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::process;
|
use std::process;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
@@ -77,9 +77,7 @@ fn main() -> Result<()> {
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
println!(
|
println!(
|
||||||
"Renderer \"{}\": AVTransport present = {}, SetNextAVTransportURI supported = {}",
|
"Renderer \"{}\": AVTransport present = {}, SetNextAVTransportURI supported = {}",
|
||||||
renderer.friendly_name,
|
renderer.friendly_name, renderer.capabilities.has_avtransport, supports_set_next
|
||||||
renderer.capabilities.has_avtransport,
|
|
||||||
supports_set_next
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let timeout = Duration::from_secs(config.timeout_secs);
|
let timeout = Duration::from_secs(config.timeout_secs);
|
||||||
@@ -93,7 +91,10 @@ fn main() -> Result<()> {
|
|||||||
let live_playlist_container = match live_playlist_container {
|
let live_playlist_container = match live_playlist_container {
|
||||||
Some(container) => container,
|
Some(container) => container,
|
||||||
None => {
|
None => {
|
||||||
println!("No Live Playlist container found on server \"{}\". Exiting.", server_info.friendly_name);
|
println!(
|
||||||
|
"No Live Playlist container found on server \"{}\". Exiting.",
|
||||||
|
server_info.friendly_name
|
||||||
|
);
|
||||||
process::exit(1);
|
process::exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -112,7 +113,10 @@ fn main() -> Result<()> {
|
|||||||
.context("Failed to collect playable items from Live Playlist container")?;
|
.context("Failed to collect playable items from Live Playlist container")?;
|
||||||
|
|
||||||
if playback_items.is_empty() {
|
if playback_items.is_empty() {
|
||||||
println!("Live Playlist container '{}' contains no playable tracks.", live_playlist_container.title);
|
println!(
|
||||||
|
"Live Playlist container '{}' contains no playable tracks.",
|
||||||
|
live_playlist_container.title
|
||||||
|
);
|
||||||
process::exit(1);
|
process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,13 +208,16 @@ fn main() -> Result<()> {
|
|||||||
bound_container
|
bound_container
|
||||||
);
|
);
|
||||||
// Take a fresh snapshot to observe changes
|
// Take a fresh snapshot to observe changes
|
||||||
if let Ok(fresh_snapshot) = control_point.get_queue_snapshot(&renderer_id) {
|
if let Ok(fresh_snapshot) =
|
||||||
|
control_point.get_queue_snapshot(&renderer_id)
|
||||||
|
{
|
||||||
println!(
|
println!(
|
||||||
" → Queue length after refresh: {} items",
|
" → Queue length after refresh: {} items",
|
||||||
fresh_snapshot.len()
|
fresh_snapshot.len()
|
||||||
);
|
);
|
||||||
if !fresh_snapshot.is_empty() {
|
if !fresh_snapshot.is_empty() {
|
||||||
println!(" → First item: {}",
|
println!(
|
||||||
|
" → First item: {}",
|
||||||
fresh_snapshot[0].title.as_deref().unwrap_or("<no title>")
|
fresh_snapshot[0].title.as_deref().unwrap_or("<no title>")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -257,7 +264,10 @@ fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("Monitoring finished ({}s elapsed), exiting.", MONITOR_DURATION_SECS);
|
println!(
|
||||||
|
"Monitoring finished ({}s elapsed), exiting.",
|
||||||
|
MONITOR_DURATION_SECS
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,7 +402,10 @@ fn find_live_playlist_container(server: &MusicServer) -> Result<Option<MediaEntr
|
|||||||
.browse_root()
|
.browse_root()
|
||||||
.context("Failed to browse ContentDirectory root")?;
|
.context("Failed to browse ContentDirectory root")?;
|
||||||
|
|
||||||
println!("Root returned {} entries, starting BFS search for Live Playlist...", root_entries.len());
|
println!(
|
||||||
|
"Root returned {} entries, starting BFS search for Live Playlist...",
|
||||||
|
root_entries.len()
|
||||||
|
);
|
||||||
|
|
||||||
// BFS queue: (entry, depth)
|
// BFS queue: (entry, depth)
|
||||||
let mut queue: VecDeque<(MediaEntry, usize)> = VecDeque::new();
|
let mut queue: VecDeque<(MediaEntry, usize)> = VecDeque::new();
|
||||||
@@ -412,7 +425,10 @@ fn find_live_playlist_container(server: &MusicServer) -> Result<Option<MediaEntr
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if containers_explored >= MAX_CONTAINERS_TO_EXPLORE {
|
if containers_explored >= MAX_CONTAINERS_TO_EXPLORE {
|
||||||
println!("Reached max containers to explore ({}), stopping search.", MAX_CONTAINERS_TO_EXPLORE);
|
println!(
|
||||||
|
"Reached max containers to explore ({}), stopping search.",
|
||||||
|
MAX_CONTAINERS_TO_EXPLORE
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,7 +469,10 @@ fn find_live_playlist_container(server: &MusicServer) -> Result<Option<MediaEntr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("No Live Playlist container found after exploring {} containers.", containers_explored);
|
println!(
|
||||||
|
"No Live Playlist container found after exploring {} containers.",
|
||||||
|
containers_explored
|
||||||
|
);
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,14 +501,19 @@ fn collect_playable_items_from_container(
|
|||||||
// Fallback: try with minimal number of items
|
// Fallback: try with minimal number of items
|
||||||
server
|
server
|
||||||
.browse_children(container_id, 0, FALLBACK_MIN_TRACKS as u32)
|
.browse_children(container_id, 0, FALLBACK_MIN_TRACKS as u32)
|
||||||
.context("Failed to browse Live Playlist container even with minimal item count")?
|
.context(
|
||||||
|
"Failed to browse Live Playlist container even with minimal item count",
|
||||||
|
)?
|
||||||
} else {
|
} else {
|
||||||
return Err(err).context("Failed to browse Live Playlist container children");
|
return Err(err).context("Failed to browse Live Playlist container children");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("Browse returned {} entries from Live Playlist", children.len());
|
println!(
|
||||||
|
"Browse returned {} entries from Live Playlist",
|
||||||
|
children.len()
|
||||||
|
);
|
||||||
|
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
for entry in &children {
|
for entry in &children {
|
||||||
@@ -503,7 +527,10 @@ fn collect_playable_items_from_container(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("Extracted {} playable items from Live Playlist", items.len());
|
println!(
|
||||||
|
"Extracted {} playable items from Live Playlist",
|
||||||
|
items.len()
|
||||||
|
);
|
||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,11 @@ fn main() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_event(cp: &ControlPoint, cache: &mut HashMap<ServerId, MediaServerInfo>, event: &MediaServerEvent) {
|
fn print_event(
|
||||||
|
cp: &ControlPoint,
|
||||||
|
cache: &mut HashMap<ServerId, MediaServerInfo>,
|
||||||
|
event: &MediaServerEvent,
|
||||||
|
) {
|
||||||
match event {
|
match event {
|
||||||
MediaServerEvent::GlobalUpdated {
|
MediaServerEvent::GlobalUpdated {
|
||||||
server_id,
|
server_id,
|
||||||
|
|||||||
@@ -80,9 +80,7 @@ fn main() -> Result<()> {
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
println!(
|
println!(
|
||||||
"Renderer \"{}\": AVTransport present = {}, SetNextAVTransportURI supported = {}",
|
"Renderer \"{}\": AVTransport present = {}, SetNextAVTransportURI supported = {}",
|
||||||
renderer.friendly_name,
|
renderer.friendly_name, renderer.capabilities.has_avtransport, supports_set_next
|
||||||
renderer.capabilities.has_avtransport,
|
|
||||||
supports_set_next
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let timeout = Duration::from_secs(config.timeout_secs);
|
let timeout = Duration::from_secs(config.timeout_secs);
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ use crate::capabilities::{
|
|||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
||||||
VolumeControl,
|
VolumeControl,
|
||||||
};
|
};
|
||||||
use crate::model::TrackMetadata;
|
|
||||||
use crate::discovery::DiscoveryManager;
|
use crate::discovery::DiscoveryManager;
|
||||||
use crate::events::{MediaServerEventBus, RendererEventBus};
|
use crate::events::{MediaServerEventBus, RendererEventBus};
|
||||||
use crate::media_server::{
|
use crate::media_server::{
|
||||||
MediaBrowser, MediaEntry, MediaResource, MediaServerInfo, MusicServer, ServerId,
|
MediaBrowser, MediaEntry, MediaResource, MediaServerInfo, MusicServer, ServerId,
|
||||||
};
|
};
|
||||||
use crate::media_server_events::spawn_media_server_event_runtime;
|
use crate::media_server_events::spawn_media_server_event_runtime;
|
||||||
|
use crate::model::TrackMetadata;
|
||||||
use crate::model::{MediaServerEvent, RendererEvent, RendererId, RendererProtocol};
|
use crate::model::{MediaServerEvent, RendererEvent, RendererId, RendererProtocol};
|
||||||
use crate::music_renderer::op_not_supported;
|
use crate::music_renderer::op_not_supported;
|
||||||
use crate::playback_queue::{PlaybackItem, PlaybackQueue};
|
use crate::playback_queue::{PlaybackItem, PlaybackQueue};
|
||||||
@@ -90,11 +90,11 @@ impl ControlPoint {
|
|||||||
// ACTIVE DISCOVERY : envoyer quelques M-SEARCH au démarrage
|
// ACTIVE DISCOVERY : envoyer quelques M-SEARCH au démarrage
|
||||||
// pour forcer les devices à répondre rapidement.
|
// pour forcer les devices à répondre rapidement.
|
||||||
let search_targets = [
|
let search_targets = [
|
||||||
"ssdp:all",
|
"ssdp:all",
|
||||||
"urn:schemas-upnp-org:device:MediaRenderer:1",
|
"urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||||
"urn:av-openhome-org:device:MediaRenderer:1",
|
"urn:av-openhome-org:device:MediaRenderer:1",
|
||||||
"urn:schemas-upnp-org:device:MediaServer:1",
|
"urn:schemas-upnp-org:device:MediaServer:1",
|
||||||
"urn:schemas-wiimu-com:service:PlayQueue:1", // <-- AJOUTER
|
"urn:schemas-wiimu-com:service:PlayQueue:1", // <-- AJOUTER
|
||||||
];
|
];
|
||||||
|
|
||||||
for st in &search_targets {
|
for st in &search_targets {
|
||||||
@@ -138,7 +138,9 @@ impl ControlPoint {
|
|||||||
};
|
};
|
||||||
let renderers = infos
|
let renderers = infos
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|info| MusicRenderer::from_registry_info(info, &runtime_cp.registry))
|
.filter_map(|info| {
|
||||||
|
MusicRenderer::from_registry_info(info, &runtime_cp.registry)
|
||||||
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
for renderer in renderers {
|
for renderer in renderers {
|
||||||
@@ -264,6 +266,7 @@ impl ControlPoint {
|
|||||||
let registry_for_media_worker = Arc::clone(®istry);
|
let registry_for_media_worker = Arc::clone(®istry);
|
||||||
let runtime_for_media_worker = Arc::clone(&runtime);
|
let runtime_for_media_worker = Arc::clone(&runtime);
|
||||||
let bindings_for_media_worker = Arc::clone(&playlist_bindings);
|
let bindings_for_media_worker = Arc::clone(&playlist_bindings);
|
||||||
|
let event_bus_for_media_worker = event_bus.clone();
|
||||||
let media_rx = media_event_bus.subscribe();
|
let media_rx = media_event_bus.subscribe();
|
||||||
|
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
@@ -322,6 +325,7 @@ impl ControlPoint {
|
|||||||
&runtime_for_media_worker,
|
&runtime_for_media_worker,
|
||||||
&bindings_for_media_worker,
|
&bindings_for_media_worker,
|
||||||
&renderer_id,
|
&renderer_id,
|
||||||
|
&event_bus_for_media_worker,
|
||||||
) {
|
) {
|
||||||
warn!(
|
warn!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
@@ -341,6 +345,7 @@ impl ControlPoint {
|
|||||||
let registry_for_periodic = Arc::clone(®istry);
|
let registry_for_periodic = Arc::clone(®istry);
|
||||||
let runtime_for_periodic = Arc::clone(&runtime);
|
let runtime_for_periodic = Arc::clone(&runtime);
|
||||||
let bindings_for_periodic = Arc::clone(&playlist_bindings);
|
let bindings_for_periodic = Arc::clone(&playlist_bindings);
|
||||||
|
let event_bus_for_periodic = event_bus.clone();
|
||||||
|
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
.name("cp-playlist-periodic-refresh".into())
|
.name("cp-playlist-periodic-refresh".into())
|
||||||
@@ -374,6 +379,7 @@ impl ControlPoint {
|
|||||||
&runtime_for_periodic,
|
&runtime_for_periodic,
|
||||||
&bindings_for_periodic,
|
&bindings_for_periodic,
|
||||||
&renderer_id,
|
&renderer_id,
|
||||||
|
&event_bus_for_periodic,
|
||||||
) {
|
) {
|
||||||
warn!(
|
warn!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
@@ -511,6 +517,13 @@ impl ControlPoint {
|
|||||||
queue_len = 0,
|
queue_len = 0,
|
||||||
"Cleared playback queue"
|
"Cleared playback queue"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Emit QueueUpdated event
|
||||||
|
self.emit_renderer_event(RendererEvent::QueueUpdated {
|
||||||
|
id: renderer_id.clone(),
|
||||||
|
queue_length: 0,
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,6 +559,13 @@ impl ControlPoint {
|
|||||||
queue_len = new_len,
|
queue_len = new_len,
|
||||||
"Enqueued playback items"
|
"Enqueued playback items"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Emit QueueUpdated event
|
||||||
|
self.emit_renderer_event(RendererEvent::QueueUpdated {
|
||||||
|
id: renderer_id.clone(),
|
||||||
|
queue_length: new_len,
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -671,6 +691,12 @@ impl ControlPoint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit QueueUpdated event
|
||||||
|
self.emit_renderer_event(RendererEvent::QueueUpdated {
|
||||||
|
id: renderer_id.clone(),
|
||||||
|
queue_length: remaining_after,
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -945,6 +971,7 @@ fn refresh_attached_queue_for(
|
|||||||
runtime: &Arc<RuntimeState>,
|
runtime: &Arc<RuntimeState>,
|
||||||
bindings: &Arc<Mutex<HashMap<RendererId, PlaylistBinding>>>,
|
bindings: &Arc<Mutex<HashMap<RendererId, PlaylistBinding>>>,
|
||||||
renderer_id: &RendererId,
|
renderer_id: &RendererId,
|
||||||
|
event_bus: &RendererEventBus,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
// Step 1: Check binding and mark refresh as in-progress
|
// Step 1: Check binding and mark refresh as in-progress
|
||||||
let (server_id, container_id) = {
|
let (server_id, container_id) = {
|
||||||
@@ -1040,17 +1067,25 @@ fn refresh_attached_queue_for(
|
|||||||
"Refreshed playlist is empty, clearing queue"
|
"Refreshed playlist is empty, clearing queue"
|
||||||
);
|
);
|
||||||
runtime.with_queue_mut(renderer_id, |queue| queue.clear());
|
runtime.with_queue_mut(renderer_id, |queue| queue.clear());
|
||||||
|
|
||||||
|
// Emit QueueUpdated event
|
||||||
|
event_bus.broadcast(RendererEvent::QueueUpdated {
|
||||||
|
id: renderer_id.clone(),
|
||||||
|
queue_length: 0,
|
||||||
|
});
|
||||||
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Intelligent refresh: try to keep current item if it's still in the new list
|
// Step 5: Intelligent refresh: try to keep current item if it's still in the new list
|
||||||
let current_item = runtime.queue_snapshot(renderer_id).and_then(|q| q.first().cloned());
|
let current_item = runtime
|
||||||
|
.queue_snapshot(renderer_id)
|
||||||
|
.and_then(|q| q.first().cloned());
|
||||||
|
|
||||||
let item_found_at = current_item.as_ref().and_then(|current| {
|
let item_found_at = current_item.as_ref().and_then(|current| {
|
||||||
new_items.iter().position(|new_item| {
|
new_items.iter().position(|new_item| {
|
||||||
// Match by object_id if both have it
|
// Match by object_id if both have it
|
||||||
if let (Some(current_obj), Some(new_obj)) = (¤t.object_id, &new_item.object_id)
|
if let (Some(current_obj), Some(new_obj)) = (¤t.object_id, &new_item.object_id) {
|
||||||
{
|
|
||||||
return current_obj == new_obj;
|
return current_obj == new_obj;
|
||||||
}
|
}
|
||||||
// Fallback: match by URI
|
// Fallback: match by URI
|
||||||
@@ -1058,7 +1093,7 @@ fn refresh_attached_queue_for(
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
runtime.with_queue_mut(renderer_id, |queue| {
|
let final_queue_len = runtime.with_queue_mut(renderer_id, |queue| {
|
||||||
queue.clear();
|
queue.clear();
|
||||||
|
|
||||||
if let Some(idx) = item_found_at {
|
if let Some(idx) = item_found_at {
|
||||||
@@ -1074,6 +1109,7 @@ fn refresh_attached_queue_for(
|
|||||||
current_preserved = true,
|
current_preserved = true,
|
||||||
"Refreshed queue from playlist container"
|
"Refreshed queue from playlist container"
|
||||||
);
|
);
|
||||||
|
new_items.len() - idx
|
||||||
} else {
|
} else {
|
||||||
// Current item not found: replace with full new list
|
// Current item not found: replace with full new list
|
||||||
for item in new_items.iter() {
|
for item in new_items.iter() {
|
||||||
@@ -1087,7 +1123,14 @@ fn refresh_attached_queue_for(
|
|||||||
current_preserved = false,
|
current_preserved = false,
|
||||||
"Refreshed queue from playlist container (current item not found)"
|
"Refreshed queue from playlist container (current item not found)"
|
||||||
);
|
);
|
||||||
|
new_items.len()
|
||||||
}
|
}
|
||||||
|
}).unwrap_or(0);
|
||||||
|
|
||||||
|
// Emit QueueUpdated event
|
||||||
|
event_bus.broadcast(RendererEvent::QueueUpdated {
|
||||||
|
id: renderer_id.clone(),
|
||||||
|
queue_length: final_queue_len,
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ pub mod rendering_control_client;
|
|||||||
pub mod soap_client;
|
pub mod soap_client;
|
||||||
pub mod upnp_renderer;
|
pub mod upnp_renderer;
|
||||||
|
|
||||||
|
// pmoserver extension (optional)
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
pub mod openapi;
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
pub mod pmoserver_ext;
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
pub mod sse;
|
||||||
|
|
||||||
pub use arylic_tcp::ArylicTcpRenderer;
|
pub use arylic_tcp::ArylicTcpRenderer;
|
||||||
pub use avtransport_client::{AvTransportClient, PositionInfo, TransportInfo};
|
pub use avtransport_client::{AvTransportClient, PositionInfo, TransportInfo};
|
||||||
pub use capabilities::{
|
pub use capabilities::{
|
||||||
@@ -38,7 +46,8 @@ pub use upnp_renderer::UpnpRenderer;
|
|||||||
|
|
||||||
pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager};
|
pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager};
|
||||||
pub use model::{
|
pub use model::{
|
||||||
MediaServerEvent, RendererCapabilities, RendererEvent, RendererId, RendererInfo, RendererProtocol,
|
MediaServerEvent, RendererCapabilities, RendererEvent, RendererId, RendererInfo,
|
||||||
|
RendererProtocol,
|
||||||
};
|
};
|
||||||
pub use provider::HttpXmlDescriptionProvider;
|
pub use provider::HttpXmlDescriptionProvider;
|
||||||
pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};
|
pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};
|
||||||
|
|||||||
@@ -34,10 +34,7 @@ pub(crate) fn spawn_media_server_event_runtime(
|
|||||||
.context("Failed to read listener address")
|
.context("Failed to read listener address")
|
||||||
.map_err(io_from_anyhow)?;
|
.map_err(io_from_anyhow)?;
|
||||||
|
|
||||||
info!(
|
info!("MediaServer event listener bound on {}", listener_addr);
|
||||||
"MediaServer event listener bound on {}",
|
|
||||||
listener_addr
|
|
||||||
);
|
|
||||||
|
|
||||||
let (notify_tx, notify_rx) = unbounded::<IncomingNotify>();
|
let (notify_tx, notify_rx) = unbounded::<IncomingNotify>();
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
@@ -85,10 +82,7 @@ fn run_http_listener(listener: TcpListener, notify_tx: Sender<IncomingNotify>) {
|
|||||||
|
|
||||||
let notify = IncomingNotify {
|
let notify = IncomingNotify {
|
||||||
path: request.path,
|
path: request.path,
|
||||||
sid: request
|
sid: request.headers.get("sid").cloned(),
|
||||||
.headers
|
|
||||||
.get("sid")
|
|
||||||
.cloned(),
|
|
||||||
body: request.body,
|
body: request.body,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -151,10 +145,7 @@ fn read_http_request(stream: &mut TcpStream) -> io::Result<HttpRequest> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if let Some((name, value)) = trimmed.split_once(':') {
|
if let Some((name, value)) = trimmed.split_once(':') {
|
||||||
headers.insert(
|
headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
|
||||||
name.trim().to_ascii_lowercase(),
|
|
||||||
value.trim().to_string(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,8 +343,12 @@ impl MediaServerEventWorker {
|
|||||||
let local_ip = determine_local_ip(&remote_host, remote_port)
|
let local_ip = determine_local_ip(&remote_host, remote_port)
|
||||||
.context("Cannot determine local IP for callback")?;
|
.context("Cannot determine local IP for callback")?;
|
||||||
|
|
||||||
let callback_url =
|
let callback_url = format!(
|
||||||
format!("http://{}:{}{}", format_ip(&local_ip), listener_port, entry.callback_path);
|
"http://{}:{}{}",
|
||||||
|
format_ip(&local_ip),
|
||||||
|
listener_port,
|
||||||
|
entry.callback_path
|
||||||
|
);
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
server = entry.info.friendly_name.as_str(),
|
server = entry.info.friendly_name.as_str(),
|
||||||
@@ -392,7 +387,7 @@ impl MediaServerEventWorker {
|
|||||||
.get("TIMEOUT")
|
.get("TIMEOUT")
|
||||||
.and_then(|value| value.to_str().ok()),
|
.and_then(|value| value.to_str().ok()),
|
||||||
)
|
)
|
||||||
.unwrap_or(Duration::from_secs(SUBSCRIPTION_TIMEOUT_SECS));
|
.unwrap_or(Duration::from_secs(SUBSCRIPTION_TIMEOUT_SECS));
|
||||||
|
|
||||||
entry.sid = Some(sid);
|
entry.sid = Some(sid);
|
||||||
entry.expires_at = Some(Instant::now() + timeout);
|
entry.expires_at = Some(Instant::now() + timeout);
|
||||||
@@ -441,7 +436,7 @@ impl MediaServerEventWorker {
|
|||||||
.get("TIMEOUT")
|
.get("TIMEOUT")
|
||||||
.and_then(|value| value.to_str().ok()),
|
.and_then(|value| value.to_str().ok()),
|
||||||
)
|
)
|
||||||
.unwrap_or(Duration::from_secs(SUBSCRIPTION_TIMEOUT_SECS));
|
.unwrap_or(Duration::from_secs(SUBSCRIPTION_TIMEOUT_SECS));
|
||||||
entry.expires_at = Some(Instant::now() + timeout);
|
entry.expires_at = Some(Instant::now() + timeout);
|
||||||
debug!(
|
debug!(
|
||||||
server = entry.info.friendly_name.as_str(),
|
server = entry.info.friendly_name.as_str(),
|
||||||
@@ -629,22 +624,14 @@ fn parse_notify_payload(server_id: &ServerId, body: &[u8]) -> Vec<MediaServerEve
|
|||||||
let mut system_update_id: Option<u32> = None;
|
let mut system_update_id: Option<u32> = None;
|
||||||
let mut container_ids: Vec<String> = Vec::new();
|
let mut container_ids: Vec<String> = Vec::new();
|
||||||
|
|
||||||
for property in root
|
for property in root.children.iter().filter_map(|node| match node {
|
||||||
.children
|
XMLNode::Element(elem) => Some(elem),
|
||||||
.iter()
|
_ => None,
|
||||||
.filter_map(|node| match node {
|
}) {
|
||||||
|
for child in property.children.iter().filter_map(|node| match node {
|
||||||
XMLNode::Element(elem) => Some(elem),
|
XMLNode::Element(elem) => Some(elem),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
}) {
|
||||||
{
|
|
||||||
for child in property
|
|
||||||
.children
|
|
||||||
.iter()
|
|
||||||
.filter_map(|node| match node {
|
|
||||||
XMLNode::Element(elem) => Some(elem),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
{
|
|
||||||
if child.name == "SystemUpdateID" {
|
if child.name == "SystemUpdateID" {
|
||||||
if let Some(text) = child.get_text() {
|
if let Some(text) = child.get_text() {
|
||||||
let trimmed = text.trim();
|
let trimmed = text.trim();
|
||||||
@@ -694,9 +681,14 @@ fn parse_container_update_ids(raw: &str) -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
let mut ids = Vec::new();
|
let mut ids = Vec::new();
|
||||||
let mut tokens = trimmed.split(',').map(|t| t.trim()).filter(|t| !t.is_empty());
|
let mut tokens = trimmed
|
||||||
|
.split(',')
|
||||||
|
.map(|t| t.trim())
|
||||||
|
.filter(|t| !t.is_empty());
|
||||||
loop {
|
loop {
|
||||||
let Some(id) = tokens.next() else { break; };
|
let Some(id) = tokens.next() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
ids.push(id.to_string());
|
ids.push(id.to_string());
|
||||||
tokens.next(); // Skip the accompanying UpdateID
|
tokens.next(); // Skip the accompanying UpdateID
|
||||||
}
|
}
|
||||||
@@ -801,7 +793,11 @@ fn parse_host_port(url: &str) -> Option<(String, u16)> {
|
|||||||
fn determine_local_ip(remote_host: &str, remote_port: u16) -> io::Result<IpAddr> {
|
fn determine_local_ip(remote_host: &str, remote_port: u16) -> io::Result<IpAddr> {
|
||||||
let is_ipv6 = remote_host.contains(':') && !remote_host.contains('.');
|
let is_ipv6 = remote_host.contains(':') && !remote_host.contains('.');
|
||||||
let target = if is_ipv6 {
|
let target = if is_ipv6 {
|
||||||
format!("[{}]:{}", remote_host.trim_matches(|c| c == '[' || c == ']'), remote_port)
|
format!(
|
||||||
|
"[{}]:{}",
|
||||||
|
remote_host.trim_matches(|c| c == '[' || c == ']'),
|
||||||
|
remote_port
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
format!("{}:{}", remote_host, remote_port)
|
format!("{}:{}", remote_host, remote_port)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ pub enum RendererEvent {
|
|||||||
id: RendererId,
|
id: RendererId,
|
||||||
metadata: TrackMetadata,
|
metadata: TrackMetadata,
|
||||||
},
|
},
|
||||||
|
QueueUpdated {
|
||||||
|
id: RendererId,
|
||||||
|
queue_length: usize,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|||||||
305
pmocontrol/src/openapi.rs
Normal file
305
pmocontrol/src/openapi.rs
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
//! Documentation OpenAPI et DTOs pour l'API ControlPoint
|
||||||
|
//!
|
||||||
|
//! Ce module fournit les types de réponse / payloads pour l'API REST du ControlPoint,
|
||||||
|
//! ainsi que la documentation OpenAPI via `utoipa`.
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use utoipa::{OpenApi, ToSchema};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RENDERERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Résumé d'un renderer découvert
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct RendererSummary {
|
||||||
|
/// ID unique du renderer
|
||||||
|
pub id: String,
|
||||||
|
/// Nom convivial
|
||||||
|
pub friendly_name: String,
|
||||||
|
/// Modèle du renderer
|
||||||
|
pub model_name: String,
|
||||||
|
/// Protocole (Upnp, Hybrid, etc.)
|
||||||
|
pub protocol: String,
|
||||||
|
/// Renderer en ligne
|
||||||
|
pub online: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// État détaillé d'un renderer
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct RendererState {
|
||||||
|
/// ID unique du renderer
|
||||||
|
pub id: String,
|
||||||
|
/// Nom convivial
|
||||||
|
pub friendly_name: String,
|
||||||
|
/// État de transport ("PLAYING", "PAUSED", "STOPPED", etc.)
|
||||||
|
pub transport_state: String,
|
||||||
|
/// Position courante en millisecondes
|
||||||
|
pub position_ms: Option<u64>,
|
||||||
|
/// Durée totale en millisecondes
|
||||||
|
pub duration_ms: Option<u64>,
|
||||||
|
/// Volume (0-100)
|
||||||
|
pub volume: Option<u8>,
|
||||||
|
/// Mute actif
|
||||||
|
pub mute: Option<bool>,
|
||||||
|
/// Nombre d'items dans la queue
|
||||||
|
pub queue_len: usize,
|
||||||
|
/// Playlist attachée (si applicable)
|
||||||
|
pub attached_playlist: Option<AttachedPlaylistInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Information sur la playlist attachée
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct AttachedPlaylistInfo {
|
||||||
|
/// ID du serveur de médias
|
||||||
|
pub server_id: String,
|
||||||
|
/// ID du container playlist
|
||||||
|
pub container_id: String,
|
||||||
|
/// True si au moins une mise à jour a été vue
|
||||||
|
pub has_seen_update: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// QUEUE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Item de la queue de lecture
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct QueueItem {
|
||||||
|
/// Index dans la queue (0-based)
|
||||||
|
pub index: usize,
|
||||||
|
/// URI de la ressource
|
||||||
|
pub uri: String,
|
||||||
|
/// Titre du morceau
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// Artiste
|
||||||
|
pub artist: Option<String>,
|
||||||
|
/// Album
|
||||||
|
pub album: Option<String>,
|
||||||
|
/// ID du serveur source
|
||||||
|
pub server_id: Option<String>,
|
||||||
|
/// ID de l'objet DIDL-Lite
|
||||||
|
pub object_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot de la queue d'un renderer
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct QueueSnapshot {
|
||||||
|
/// ID du renderer
|
||||||
|
pub renderer_id: String,
|
||||||
|
/// Items de la queue
|
||||||
|
pub items: Vec<QueueItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MEDIA SERVERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Résumé d'un serveur de médias découvert
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct MediaServerSummary {
|
||||||
|
/// ID unique du serveur
|
||||||
|
pub id: String,
|
||||||
|
/// Nom convivial
|
||||||
|
pub friendly_name: String,
|
||||||
|
/// Modèle du serveur
|
||||||
|
pub model_name: String,
|
||||||
|
/// Serveur en ligne
|
||||||
|
pub online: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entrée de navigation (container ou item)
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct ContainerEntry {
|
||||||
|
/// ID de l'objet
|
||||||
|
pub id: String,
|
||||||
|
/// Titre
|
||||||
|
pub title: String,
|
||||||
|
/// Classe UPnP (object.container.*, object.item.*, etc.)
|
||||||
|
pub class: String,
|
||||||
|
/// True si c'est un container (navigable)
|
||||||
|
pub is_container: bool,
|
||||||
|
/// Nombre d'enfants (si container)
|
||||||
|
pub child_count: Option<u32>,
|
||||||
|
/// Artiste (si item audio)
|
||||||
|
pub artist: Option<String>,
|
||||||
|
/// Album (si item audio)
|
||||||
|
pub album: Option<String>,
|
||||||
|
/// URI de la pochette d'album
|
||||||
|
pub album_art_uri: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Résultat de navigation dans un container
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct BrowseResponse {
|
||||||
|
/// ID du container browsé
|
||||||
|
pub container_id: String,
|
||||||
|
/// Entrées du container
|
||||||
|
pub entries: Vec<ContainerEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PAYLOADS DE COMMANDES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Requête pour définir le volume
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||||
|
pub struct VolumeSetRequest {
|
||||||
|
/// Nouveau volume (0-100)
|
||||||
|
pub volume: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requête pour attacher une playlist
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||||
|
pub struct AttachPlaylistRequest {
|
||||||
|
/// ID du serveur de médias
|
||||||
|
pub server_id: String,
|
||||||
|
/// ID du container playlist
|
||||||
|
pub container_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réponse générique de succès
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct SuccessResponse {
|
||||||
|
/// Message de succès
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réponse d'erreur
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
/// Message d'erreur
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DOCUMENTATION OPENAPI
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Documentation OpenAPI pour l'API ControlPoint
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
info(
|
||||||
|
title = "PMOMusic Control Point API",
|
||||||
|
version = "1.0.0",
|
||||||
|
description = r#"
|
||||||
|
# API REST pour le Control Point PMOMusic
|
||||||
|
|
||||||
|
Cette API permet de contrôler les renderers UPnP et de naviguer dans les serveurs de médias.
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
### Renderers
|
||||||
|
- **Découverte** : Liste des renderers disponibles
|
||||||
|
- **État** : Récupération de l'état détaillé d'un renderer
|
||||||
|
- **Contrôle transport** : Play, pause, stop, next
|
||||||
|
- **Contrôle volume** : Lecture et modification du volume / mute
|
||||||
|
- **Queue** : Gestion de la queue de lecture
|
||||||
|
|
||||||
|
### Playlists
|
||||||
|
- **Binding** : Attachement de la queue à un container playlist d'un serveur
|
||||||
|
- **Synchronisation automatique** : Mise à jour de la queue lors des changements côté serveur
|
||||||
|
|
||||||
|
### Serveurs de médias
|
||||||
|
- **Découverte** : Liste des serveurs disponibles
|
||||||
|
- **Navigation** : Exploration de la hiérarchie des containers
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Le Control Point PMOMusic est un point de contrôle UPnP qui :
|
||||||
|
1. Découvre automatiquement les renderers et serveurs via SSDP
|
||||||
|
2. Maintient un registre des devices actifs
|
||||||
|
3. Permet le contrôle unifié des renderers (UPnP AV, LinkPlay, Arylic TCP)
|
||||||
|
4. Gère une queue de lecture locale avec synchronisation optionnelle
|
||||||
|
|
||||||
|
## Exemples d'utilisation
|
||||||
|
|
||||||
|
### Lister les renderers
|
||||||
|
```
|
||||||
|
GET /control/renderers
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contrôler un renderer
|
||||||
|
```
|
||||||
|
POST /control/renderers/{renderer_id}/play
|
||||||
|
POST /control/renderers/{renderer_id}/pause
|
||||||
|
POST /control/renderers/{renderer_id}/volume/set
|
||||||
|
Body: {"volume": 50}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Attacher une playlist
|
||||||
|
```
|
||||||
|
POST /control/renderers/{renderer_id}/binding/attach
|
||||||
|
Body: {
|
||||||
|
"server_id": "uuid:...",
|
||||||
|
"container_id": "0$/Music/MyPlaylist"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naviguer dans un serveur
|
||||||
|
```
|
||||||
|
GET /control/servers/{server_id}/containers/{container_id}
|
||||||
|
```
|
||||||
|
"#,
|
||||||
|
contact(
|
||||||
|
name = "PMOMusic",
|
||||||
|
),
|
||||||
|
license(
|
||||||
|
name = "MIT",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
paths(
|
||||||
|
crate::pmoserver_ext::list_renderers,
|
||||||
|
crate::pmoserver_ext::get_renderer_state,
|
||||||
|
crate::pmoserver_ext::get_renderer_queue,
|
||||||
|
crate::pmoserver_ext::get_renderer_binding,
|
||||||
|
crate::pmoserver_ext::play_renderer,
|
||||||
|
crate::pmoserver_ext::pause_renderer,
|
||||||
|
crate::pmoserver_ext::stop_renderer,
|
||||||
|
crate::pmoserver_ext::next_renderer,
|
||||||
|
crate::pmoserver_ext::set_renderer_volume,
|
||||||
|
crate::pmoserver_ext::volume_up_renderer,
|
||||||
|
crate::pmoserver_ext::volume_down_renderer,
|
||||||
|
crate::pmoserver_ext::toggle_mute_renderer,
|
||||||
|
crate::pmoserver_ext::attach_playlist_binding,
|
||||||
|
crate::pmoserver_ext::detach_playlist_binding,
|
||||||
|
crate::pmoserver_ext::list_servers,
|
||||||
|
crate::pmoserver_ext::browse_container,
|
||||||
|
crate::sse::all_events_sse,
|
||||||
|
crate::sse::renderer_events_sse,
|
||||||
|
crate::sse::media_server_events_sse,
|
||||||
|
),
|
||||||
|
components(schemas(
|
||||||
|
RendererSummary,
|
||||||
|
RendererState,
|
||||||
|
AttachedPlaylistInfo,
|
||||||
|
QueueItem,
|
||||||
|
QueueSnapshot,
|
||||||
|
MediaServerSummary,
|
||||||
|
ContainerEntry,
|
||||||
|
BrowseResponse,
|
||||||
|
VolumeSetRequest,
|
||||||
|
AttachPlaylistRequest,
|
||||||
|
SuccessResponse,
|
||||||
|
ErrorResponse,
|
||||||
|
)),
|
||||||
|
tags(
|
||||||
|
(name = "control", description = "Contrôle des renderers et navigation des serveurs")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub struct ApiDoc;
|
||||||
998
pmocontrol/src/pmoserver_ext.rs
Normal file
998
pmocontrol/src/pmoserver_ext.rs
Normal file
@@ -0,0 +1,998 @@
|
|||||||
|
//! Extension pmoserver pour le Control Point
|
||||||
|
//!
|
||||||
|
//! Ce module fournit une API REST pour contrôler les renderers UPnP
|
||||||
|
//! et naviguer dans les serveurs de médias.
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use crate::control_point::ControlPoint;
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use crate::media_server::{MediaBrowser, MusicServer, ServerId};
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use crate::model::{RendererId, RendererProtocol};
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use crate::openapi::{
|
||||||
|
AttachedPlaylistInfo, AttachPlaylistRequest, BrowseResponse, ContainerEntry, ErrorResponse,
|
||||||
|
MediaServerSummary, QueueItem, QueueSnapshot, RendererState, RendererSummary, SuccessResponse,
|
||||||
|
VolumeSetRequest,
|
||||||
|
};
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use crate::{PlaybackPosition, PlaybackStatus, TransportControl, VolumeControl};
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use async_trait::async_trait;
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use std::sync::Arc;
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use std::time::Duration;
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use tracing::{debug, warn};
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
/// État partagé pour l'API ControlPoint
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ControlPointState {
|
||||||
|
control_point: Arc<ControlPoint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
impl ControlPointState {
|
||||||
|
pub fn new(control_point: Arc<ControlPoint>) -> Self {
|
||||||
|
Self { control_point }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HANDLERS - RENDERERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// GET /control/renderers - Liste tous les renderers
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/renderers",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Liste des renderers", body = Vec<RendererSummary>)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn list_renderers(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
) -> Json<Vec<RendererSummary>> {
|
||||||
|
let renderers = state.control_point.list_music_renderers();
|
||||||
|
|
||||||
|
let summaries: Vec<RendererSummary> = renderers
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
let info = r.info();
|
||||||
|
RendererSummary {
|
||||||
|
id: info.id.0.clone(),
|
||||||
|
friendly_name: info.friendly_name.clone(),
|
||||||
|
model_name: info.model_name.clone(),
|
||||||
|
protocol: protocol_to_string(&info.protocol),
|
||||||
|
online: info.online,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Json(summaries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /control/renderers/{renderer_id} - Récupère l'état d'un renderer
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/renderers/{renderer_id}",
|
||||||
|
params(
|
||||||
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "État du renderer", body = RendererState),
|
||||||
|
(status = 404, description = "Renderer non trouvé", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn get_renderer_state(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path(renderer_id): Path<String>,
|
||||||
|
) -> Result<Json<RendererState>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
|
||||||
|
let renderer = state
|
||||||
|
.control_point
|
||||||
|
.music_renderer_by_id(&rid)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Renderer {} not found", renderer_id),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let info = renderer.info();
|
||||||
|
|
||||||
|
// État de transport
|
||||||
|
let transport_state = renderer
|
||||||
|
.playback_state()
|
||||||
|
.ok()
|
||||||
|
.map(state_to_string)
|
||||||
|
.unwrap_or_else(|| "UNKNOWN".to_string());
|
||||||
|
|
||||||
|
// Position et durée
|
||||||
|
let (position_ms, duration_ms) = renderer
|
||||||
|
.playback_position()
|
||||||
|
.ok()
|
||||||
|
.and_then(|pos| {
|
||||||
|
let position = parse_hms_to_ms(pos.rel_time.as_deref());
|
||||||
|
let duration = parse_hms_to_ms(pos.track_duration.as_deref());
|
||||||
|
Some((position, duration))
|
||||||
|
})
|
||||||
|
.unwrap_or((None, None));
|
||||||
|
|
||||||
|
// Volume et mute
|
||||||
|
let volume = renderer.volume().ok().and_then(|v| u8::try_from(v).ok());
|
||||||
|
let mute = renderer.mute().ok();
|
||||||
|
|
||||||
|
// Queue
|
||||||
|
let queue_len = state
|
||||||
|
.control_point
|
||||||
|
.get_queue_snapshot(&rid)
|
||||||
|
.ok()
|
||||||
|
.map(|q| q.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Playlist binding
|
||||||
|
let attached_playlist = state
|
||||||
|
.control_point
|
||||||
|
.current_queue_playlist_binding(&rid)
|
||||||
|
.map(|(server_id, container_id, has_seen_update)| AttachedPlaylistInfo {
|
||||||
|
server_id: server_id.0,
|
||||||
|
container_id,
|
||||||
|
has_seen_update,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(RendererState {
|
||||||
|
id: info.id.0.clone(),
|
||||||
|
friendly_name: info.friendly_name.clone(),
|
||||||
|
transport_state,
|
||||||
|
position_ms,
|
||||||
|
duration_ms,
|
||||||
|
volume,
|
||||||
|
mute,
|
||||||
|
queue_len,
|
||||||
|
attached_playlist,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /control/renderers/{renderer_id}/queue - Récupère la queue d'un renderer
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/renderers/{renderer_id}/queue",
|
||||||
|
params(
|
||||||
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Queue du renderer", body = QueueSnapshot),
|
||||||
|
(status = 404, description = "Renderer non trouvé", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn get_renderer_queue(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path(renderer_id): Path<String>,
|
||||||
|
) -> Result<Json<QueueSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
|
||||||
|
let items = state
|
||||||
|
.control_point
|
||||||
|
.get_queue_snapshot(&rid)
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Failed to get queue: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let queue_items: Vec<QueueItem> = items
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, item)| QueueItem {
|
||||||
|
index,
|
||||||
|
uri: item.uri,
|
||||||
|
title: item.title,
|
||||||
|
artist: item.artist,
|
||||||
|
album: item.album,
|
||||||
|
server_id: item.server_id.map(|s| s.0),
|
||||||
|
object_id: item.object_id,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(QueueSnapshot {
|
||||||
|
renderer_id,
|
||||||
|
items: queue_items,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /control/renderers/{renderer_id}/binding - Récupère le binding playlist
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/renderers/{renderer_id}/binding",
|
||||||
|
params(
|
||||||
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Binding playlist", body = Option<AttachedPlaylistInfo>),
|
||||||
|
(status = 404, description = "Renderer non trouvé", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn get_renderer_binding(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path(renderer_id): Path<String>,
|
||||||
|
) -> Result<Json<Option<AttachedPlaylistInfo>>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let rid = RendererId(renderer_id);
|
||||||
|
|
||||||
|
let binding = state
|
||||||
|
.control_point
|
||||||
|
.current_queue_playlist_binding(&rid)
|
||||||
|
.map(|(server_id, container_id, has_seen_update)| AttachedPlaylistInfo {
|
||||||
|
server_id: server_id.0,
|
||||||
|
container_id,
|
||||||
|
has_seen_update,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(binding))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HANDLERS - CONTRÔLE TRANSPORT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// POST /control/renderers/{renderer_id}/play - Démarre la lecture
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/renderers/{renderer_id}/play",
|
||||||
|
params(
|
||||||
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Lecture démarrée", body = SuccessResponse),
|
||||||
|
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
|
||||||
|
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn play_renderer(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path(renderer_id): Path<String>,
|
||||||
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
|
||||||
|
let renderer = state
|
||||||
|
.control_point
|
||||||
|
.music_renderer_by_id(&rid)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Renderer {} not found", renderer_id),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
renderer.play().map_err(|e| {
|
||||||
|
warn!("Failed to play renderer {}: {}", renderer_id, e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Failed to play: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(SuccessResponse {
|
||||||
|
message: "Playback started".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /control/renderers/{renderer_id}/pause - Met en pause
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/renderers/{renderer_id}/pause",
|
||||||
|
params(
|
||||||
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Lecture en pause", body = SuccessResponse),
|
||||||
|
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
|
||||||
|
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn pause_renderer(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path(renderer_id): Path<String>,
|
||||||
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
|
||||||
|
let renderer = state
|
||||||
|
.control_point
|
||||||
|
.music_renderer_by_id(&rid)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Renderer {} not found", renderer_id),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
renderer.pause().map_err(|e| {
|
||||||
|
warn!("Failed to pause renderer {}: {}", renderer_id, e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Failed to pause: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(SuccessResponse {
|
||||||
|
message: "Playback paused".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /control/renderers/{renderer_id}/stop - Arrête la lecture
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/renderers/{renderer_id}/stop",
|
||||||
|
params(
|
||||||
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Lecture arrêtée", body = SuccessResponse),
|
||||||
|
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
|
||||||
|
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn stop_renderer(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path(renderer_id): Path<String>,
|
||||||
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
|
||||||
|
let renderer = state
|
||||||
|
.control_point
|
||||||
|
.music_renderer_by_id(&rid)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Renderer {} not found", renderer_id),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
renderer.stop().map_err(|e| {
|
||||||
|
warn!("Failed to stop renderer {}: {}", renderer_id, e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Failed to stop: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(SuccessResponse {
|
||||||
|
message: "Playback stopped".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /control/renderers/{renderer_id}/next - Passe au morceau suivant de la queue
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/renderers/{renderer_id}/next",
|
||||||
|
params(
|
||||||
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Passage au suivant", body = SuccessResponse),
|
||||||
|
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
|
||||||
|
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn next_renderer(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path(renderer_id): Path<String>,
|
||||||
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
|
||||||
|
state
|
||||||
|
.control_point
|
||||||
|
.play_next_from_queue(&rid)
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Failed to advance queue for renderer {}: {}", renderer_id, e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Failed to advance queue: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(SuccessResponse {
|
||||||
|
message: "Advanced to next track".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HANDLERS - VOLUME
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// POST /control/renderers/{renderer_id}/volume/set - Définit le volume
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/renderers/{renderer_id}/volume/set",
|
||||||
|
params(
|
||||||
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
|
),
|
||||||
|
request_body = VolumeSetRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Volume défini", body = SuccessResponse),
|
||||||
|
(status = 400, description = "Requête invalide", body = ErrorResponse),
|
||||||
|
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
|
||||||
|
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn set_renderer_volume(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path(renderer_id): Path<String>,
|
||||||
|
Json(req): Json<VolumeSetRequest>,
|
||||||
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
|
||||||
|
let renderer = state
|
||||||
|
.control_point
|
||||||
|
.music_renderer_by_id(&rid)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Renderer {} not found", renderer_id),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
renderer.set_volume(req.volume as u16).map_err(|e| {
|
||||||
|
warn!("Failed to set volume for renderer {}: {}", renderer_id, e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Failed to set volume: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(SuccessResponse {
|
||||||
|
message: format!("Volume set to {}", req.volume),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /control/renderers/{renderer_id}/volume/up - Augmente le volume
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/renderers/{renderer_id}/volume/up",
|
||||||
|
params(
|
||||||
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Volume augmenté", body = SuccessResponse),
|
||||||
|
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
|
||||||
|
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn volume_up_renderer(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path(renderer_id): Path<String>,
|
||||||
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
|
||||||
|
let renderer = state
|
||||||
|
.control_point
|
||||||
|
.music_renderer_by_id(&rid)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Renderer {} not found", renderer_id),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let current = renderer.volume().map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Failed to get current volume: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let new_volume = (current + 5).min(100);
|
||||||
|
renderer.set_volume(new_volume).map_err(|e| {
|
||||||
|
warn!("Failed to increase volume for renderer {}: {}", renderer_id, e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Failed to increase volume: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(SuccessResponse {
|
||||||
|
message: format!("Volume increased to {}", new_volume),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /control/renderers/{renderer_id}/volume/down - Diminue le volume
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/renderers/{renderer_id}/volume/down",
|
||||||
|
params(
|
||||||
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Volume diminué", body = SuccessResponse),
|
||||||
|
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
|
||||||
|
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn volume_down_renderer(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path(renderer_id): Path<String>,
|
||||||
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
|
||||||
|
let renderer = state
|
||||||
|
.control_point
|
||||||
|
.music_renderer_by_id(&rid)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Renderer {} not found", renderer_id),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let current = renderer.volume().map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Failed to get current volume: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let new_volume = current.saturating_sub(5);
|
||||||
|
renderer.set_volume(new_volume).map_err(|e| {
|
||||||
|
warn!("Failed to decrease volume for renderer {}: {}", renderer_id, e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Failed to decrease volume: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(SuccessResponse {
|
||||||
|
message: format!("Volume decreased to {}", new_volume),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /control/renderers/{renderer_id}/mute/toggle - Bascule le mute
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/renderers/{renderer_id}/mute/toggle",
|
||||||
|
params(
|
||||||
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Mute basculé", body = SuccessResponse),
|
||||||
|
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
|
||||||
|
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn toggle_mute_renderer(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path(renderer_id): Path<String>,
|
||||||
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
|
||||||
|
let renderer = state
|
||||||
|
.control_point
|
||||||
|
.music_renderer_by_id(&rid)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Renderer {} not found", renderer_id),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let current_mute = renderer.mute().map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Failed to get current mute state: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let new_mute = !current_mute;
|
||||||
|
renderer.set_mute(new_mute).map_err(|e| {
|
||||||
|
warn!("Failed to toggle mute for renderer {}: {}", renderer_id, e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Failed to toggle mute: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(SuccessResponse {
|
||||||
|
message: format!("Mute {}", if new_mute { "enabled" } else { "disabled" }),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HANDLERS - BINDING PLAYLIST
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// POST /control/renderers/{renderer_id}/binding/attach - Attache une playlist
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/renderers/{renderer_id}/binding/attach",
|
||||||
|
params(
|
||||||
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
|
),
|
||||||
|
request_body = AttachPlaylistRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Playlist attachée", body = SuccessResponse),
|
||||||
|
(status = 404, description = "Renderer non trouvé", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn attach_playlist_binding(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path(renderer_id): Path<String>,
|
||||||
|
Json(req): Json<AttachPlaylistRequest>,
|
||||||
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
let sid = ServerId(req.server_id.clone());
|
||||||
|
|
||||||
|
state
|
||||||
|
.control_point
|
||||||
|
.attach_queue_to_playlist(&rid, sid, req.container_id.clone());
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
renderer = renderer_id.as_str(),
|
||||||
|
server = req.server_id.as_str(),
|
||||||
|
container = req.container_id.as_str(),
|
||||||
|
"Playlist attached via HTTP API"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(SuccessResponse {
|
||||||
|
message: format!(
|
||||||
|
"Playlist {} attached to renderer",
|
||||||
|
req.container_id
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /control/renderers/{renderer_id}/binding/detach - Détache la playlist
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/renderers/{renderer_id}/binding/detach",
|
||||||
|
params(
|
||||||
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Playlist détachée", body = SuccessResponse)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn detach_playlist_binding(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path(renderer_id): Path<String>,
|
||||||
|
) -> Json<SuccessResponse> {
|
||||||
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
|
||||||
|
state.control_point.detach_queue_playlist(&rid);
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
renderer = renderer_id.as_str(),
|
||||||
|
"Playlist detached via HTTP API"
|
||||||
|
);
|
||||||
|
|
||||||
|
Json(SuccessResponse {
|
||||||
|
message: "Playlist detached".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HANDLERS - MEDIA SERVERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// GET /control/servers - Liste tous les serveurs de médias
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/servers",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Liste des serveurs de médias", body = Vec<MediaServerSummary>)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn list_servers(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
) -> Json<Vec<MediaServerSummary>> {
|
||||||
|
let servers = state.control_point.list_media_servers();
|
||||||
|
|
||||||
|
let summaries: Vec<MediaServerSummary> = servers
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| MediaServerSummary {
|
||||||
|
id: s.id.0,
|
||||||
|
friendly_name: s.friendly_name,
|
||||||
|
model_name: s.model_name,
|
||||||
|
online: s.online,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Json(summaries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /control/servers/{server_id}/containers/{container_id} - Browse un container
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/servers/{server_id}/containers/{container_id}",
|
||||||
|
params(
|
||||||
|
("server_id" = String, Path, description = "ID unique du serveur"),
|
||||||
|
("container_id" = String, Path, description = "ID du container (use '0' for root)")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Contenu du container", body = BrowseResponse),
|
||||||
|
(status = 404, description = "Serveur non trouvé", body = ErrorResponse),
|
||||||
|
(status = 500, description = "Erreur lors du browse", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn browse_container(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path((server_id, container_id)): Path<(String, String)>,
|
||||||
|
) -> Result<Json<BrowseResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let sid = ServerId(server_id.clone());
|
||||||
|
|
||||||
|
let server_info = state
|
||||||
|
.control_point
|
||||||
|
.media_server(&sid)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Server {} not found", server_id),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !server_info.online {
|
||||||
|
return Err((
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Server {} is offline", server_id),
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !server_info.has_content_directory {
|
||||||
|
return Err((
|
||||||
|
StatusCode::NOT_IMPLEMENTED,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Server {} does not support ContentDirectory", server_id),
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let music_server = MusicServer::from_info(&server_info, Duration::from_secs(10)).map_err(
|
||||||
|
|e| {
|
||||||
|
warn!("Failed to create MusicServer for {}: {}", server_id, e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Failed to initialize server: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let entries = music_server
|
||||||
|
.browse_children(&container_id, 0, 100)
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!(
|
||||||
|
"Failed to browse container {} on server {}: {}",
|
||||||
|
container_id, server_id, e
|
||||||
|
);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Failed to browse container: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let container_entries: Vec<ContainerEntry> = entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| ContainerEntry {
|
||||||
|
id: e.id,
|
||||||
|
title: e.title,
|
||||||
|
class: e.class,
|
||||||
|
is_container: e.is_container,
|
||||||
|
child_count: None, // Could be extracted from DIDL-Lite if needed
|
||||||
|
artist: e.artist,
|
||||||
|
album: e.album,
|
||||||
|
album_art_uri: e.album_art_uri,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(BrowseResponse {
|
||||||
|
container_id,
|
||||||
|
entries: container_entries,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HELPERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
fn protocol_to_string(protocol: &RendererProtocol) -> String {
|
||||||
|
match protocol {
|
||||||
|
RendererProtocol::UpnpAvOnly => "UpnpAvOnly".to_string(),
|
||||||
|
RendererProtocol::OpenHomeOnly => "OpenHomeOnly".to_string(),
|
||||||
|
RendererProtocol::Hybrid => "Hybrid".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
fn state_to_string(state: crate::PlaybackState) -> String {
|
||||||
|
use crate::PlaybackState;
|
||||||
|
match state {
|
||||||
|
PlaybackState::Stopped => "STOPPED".to_string(),
|
||||||
|
PlaybackState::Playing => "PLAYING".to_string(),
|
||||||
|
PlaybackState::Paused => "PAUSED".to_string(),
|
||||||
|
PlaybackState::Transitioning => "TRANSITIONING".to_string(),
|
||||||
|
PlaybackState::NoMedia => "NO_MEDIA".to_string(),
|
||||||
|
PlaybackState::Unknown(s) => s,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
fn parse_hms_to_ms(hms: Option<&str>) -> Option<u64> {
|
||||||
|
let hms = hms?;
|
||||||
|
let parts: Vec<&str> = hms.split(':').collect();
|
||||||
|
if parts.len() != 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hours: u64 = parts[0].parse().ok()?;
|
||||||
|
let minutes: u64 = parts[1].parse().ok()?;
|
||||||
|
let seconds: u64 = parts[2].parse().ok()?;
|
||||||
|
|
||||||
|
Some((hours * 3600 + minutes * 60 + seconds) * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ROUTER & TRAIT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Crée le router pour l'API Control Point
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoint>) -> Router {
|
||||||
|
Router::new()
|
||||||
|
// Renderers
|
||||||
|
.route("/renderers", get(list_renderers))
|
||||||
|
.route("/renderers/{renderer_id}", get(get_renderer_state))
|
||||||
|
.route("/renderers/{renderer_id}/queue", get(get_renderer_queue))
|
||||||
|
.route("/renderers/{renderer_id}/binding", get(get_renderer_binding))
|
||||||
|
// Transport control
|
||||||
|
.route("/renderers/{renderer_id}/play", post(play_renderer))
|
||||||
|
.route("/renderers/{renderer_id}/pause", post(pause_renderer))
|
||||||
|
.route("/renderers/{renderer_id}/stop", post(stop_renderer))
|
||||||
|
.route("/renderers/{renderer_id}/next", post(next_renderer))
|
||||||
|
// Volume control
|
||||||
|
.route("/renderers/{renderer_id}/volume/set", post(set_renderer_volume))
|
||||||
|
.route("/renderers/{renderer_id}/volume/up", post(volume_up_renderer))
|
||||||
|
.route("/renderers/{renderer_id}/volume/down", post(volume_down_renderer))
|
||||||
|
.route("/renderers/{renderer_id}/mute/toggle", post(toggle_mute_renderer))
|
||||||
|
// Playlist binding
|
||||||
|
.route("/renderers/{renderer_id}/binding/attach", post(attach_playlist_binding))
|
||||||
|
.route("/renderers/{renderer_id}/binding/detach", post(detach_playlist_binding))
|
||||||
|
// Servers
|
||||||
|
.route("/servers", get(list_servers))
|
||||||
|
.route("/servers/{server_id}/containers/{container_id}", get(browse_container))
|
||||||
|
.with_state(state)
|
||||||
|
// SSE events - merge the SSE router
|
||||||
|
.merge(crate::sse::create_sse_router(control_point))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trait d'extension pour pmoserver::Server
|
||||||
|
///
|
||||||
|
/// Permet d'initialiser le ControlPoint avec routes HTTP complètes
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ControlPointExt {
|
||||||
|
/// Initialise l'API Control Point
|
||||||
|
///
|
||||||
|
/// # Routes créées
|
||||||
|
///
|
||||||
|
/// - API REST: `/api/control/*`
|
||||||
|
/// - `/renderers` - Liste et état des renderers
|
||||||
|
/// - `/servers` - Liste et navigation des serveurs de médias
|
||||||
|
/// - Contrôles de transport, volume, queue, binding
|
||||||
|
/// - SSE Events: `/api/control/events/*`
|
||||||
|
/// - `/events` - Tous les événements (renderers + serveurs)
|
||||||
|
/// - `/events/renderers` - Événements renderers uniquement
|
||||||
|
/// - `/events/servers` - Événements serveurs uniquement
|
||||||
|
/// - Swagger: `/swagger-ui/control`
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `control_point` - Instance du ControlPoint
|
||||||
|
async fn init_control_point(&mut self, control_point: Arc<ControlPoint>);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[async_trait]
|
||||||
|
impl ControlPointExt for pmoserver::Server {
|
||||||
|
async fn init_control_point(&mut self, control_point: Arc<ControlPoint>) {
|
||||||
|
let state = ControlPointState::new(control_point.clone());
|
||||||
|
|
||||||
|
// Créer le router API (inclut REST et SSE)
|
||||||
|
let api_router = create_api_router(state, control_point);
|
||||||
|
|
||||||
|
// L'enregistrer avec OpenAPI
|
||||||
|
self.add_openapi(api_router, crate::openapi::ApiDoc::openapi(), "control")
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
432
pmocontrol/src/sse.rs
Normal file
432
pmocontrol/src/sse.rs
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
//! SSE endpoints pour les événements du Control Point
|
||||||
|
//!
|
||||||
|
//! Ce module fournit des endpoints Server-Sent Events pour permettre aux clients
|
||||||
|
//! web de recevoir en temps réel :
|
||||||
|
//! - Les événements des renderers (state, volume, position, queue, etc.)
|
||||||
|
//! - Les événements des serveurs de médias (global updates, container updates)
|
||||||
|
//!
|
||||||
|
//! Routes:
|
||||||
|
//! - GET /api/control/events/renderers - Événements renderers uniquement
|
||||||
|
//! - GET /api/control/events/servers - Événements serveurs uniquement
|
||||||
|
//! - GET /api/control/events - Tous les événements (agrégés)
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use crate::control_point::ControlPoint;
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use crate::model::{MediaServerEvent, RendererEvent};
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use crate::PlaybackState;
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use async_stream::stream;
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
response::sse::{Event, KeepAlive, Sse},
|
||||||
|
response::IntoResponse,
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use serde::Serialize;
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PAYLOADS SSE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Payload SSE pour un événement renderer
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum RendererEventPayload {
|
||||||
|
StateChanged {
|
||||||
|
renderer_id: String,
|
||||||
|
state: String,
|
||||||
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
},
|
||||||
|
PositionChanged {
|
||||||
|
renderer_id: String,
|
||||||
|
track: Option<u32>,
|
||||||
|
rel_time: Option<String>,
|
||||||
|
track_duration: Option<String>,
|
||||||
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
},
|
||||||
|
VolumeChanged {
|
||||||
|
renderer_id: String,
|
||||||
|
volume: u16,
|
||||||
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
},
|
||||||
|
MuteChanged {
|
||||||
|
renderer_id: String,
|
||||||
|
mute: bool,
|
||||||
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
},
|
||||||
|
MetadataChanged {
|
||||||
|
renderer_id: String,
|
||||||
|
title: Option<String>,
|
||||||
|
artist: Option<String>,
|
||||||
|
album: Option<String>,
|
||||||
|
album_art_uri: Option<String>,
|
||||||
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
},
|
||||||
|
QueueUpdated {
|
||||||
|
renderer_id: String,
|
||||||
|
queue_length: usize,
|
||||||
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Payload SSE pour un événement serveur de médias
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum MediaServerEventPayload {
|
||||||
|
GlobalUpdated {
|
||||||
|
server_id: String,
|
||||||
|
system_update_id: Option<u32>,
|
||||||
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
},
|
||||||
|
ContainersUpdated {
|
||||||
|
server_id: String,
|
||||||
|
container_ids: Vec<String>,
|
||||||
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Payload SSE unifié pour tous les événements
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "category", rename_all = "snake_case")]
|
||||||
|
pub enum UnifiedEventPayload {
|
||||||
|
Renderer(RendererEventPayload),
|
||||||
|
MediaServer(MediaServerEventPayload),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HANDLERS SSE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Handler SSE pour les événements renderers
|
||||||
|
///
|
||||||
|
/// Route: GET /api/control/events/renderers
|
||||||
|
///
|
||||||
|
/// Diffuse tous les événements liés aux renderers (state, volume, position, queue, etc.)
|
||||||
|
/// en temps réel via Server-Sent Events.
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/events/renderers",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Flux SSE des événements renderers", content_type = "text/event-stream")
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
pub async fn renderer_events_sse(
|
||||||
|
State(control_point): State<Arc<ControlPoint>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Convert crossbeam channel to tokio channel for async compatibility
|
||||||
|
let (tx, mut rx_tokio) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let rx = control_point.subscribe_events();
|
||||||
|
|
||||||
|
// Spawn blocking task to bridge crossbeam -> tokio
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
while let Ok(event) = rx.recv() {
|
||||||
|
if tx.send(event).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let stream = stream! {
|
||||||
|
while let Some(event) = rx_tokio.recv().await {
|
||||||
|
let timestamp = chrono::Utc::now();
|
||||||
|
|
||||||
|
let payload = match event {
|
||||||
|
RendererEvent::StateChanged { id, state } => {
|
||||||
|
RendererEventPayload::StateChanged {
|
||||||
|
renderer_id: id.0,
|
||||||
|
state: state_to_string(state),
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RendererEvent::PositionChanged { id, position } => {
|
||||||
|
RendererEventPayload::PositionChanged {
|
||||||
|
renderer_id: id.0,
|
||||||
|
track: position.track,
|
||||||
|
rel_time: position.rel_time,
|
||||||
|
track_duration: position.track_duration,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RendererEvent::VolumeChanged { id, volume } => {
|
||||||
|
RendererEventPayload::VolumeChanged {
|
||||||
|
renderer_id: id.0,
|
||||||
|
volume,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RendererEvent::MuteChanged { id, mute } => {
|
||||||
|
RendererEventPayload::MuteChanged {
|
||||||
|
renderer_id: id.0,
|
||||||
|
mute,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RendererEvent::MetadataChanged { id, metadata } => {
|
||||||
|
RendererEventPayload::MetadataChanged {
|
||||||
|
renderer_id: id.0,
|
||||||
|
title: metadata.title,
|
||||||
|
artist: metadata.artist,
|
||||||
|
album: metadata.album,
|
||||||
|
album_art_uri: metadata.album_art_uri,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RendererEvent::QueueUpdated { id, queue_length } => {
|
||||||
|
RendererEventPayload::QueueUpdated {
|
||||||
|
renderer_id: id.0,
|
||||||
|
queue_length,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(json) = serde_json::to_string(&payload) {
|
||||||
|
yield Ok::<_, axum::Error>(Event::default().event("renderer").data(json));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler SSE pour les événements serveurs de médias
|
||||||
|
///
|
||||||
|
/// Route: GET /api/control/events/servers
|
||||||
|
///
|
||||||
|
/// Diffuse tous les événements liés aux serveurs de médias (global updates, container updates)
|
||||||
|
/// en temps réel via Server-Sent Events.
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/events/servers",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Flux SSE des événements serveurs de médias", content_type = "text/event-stream")
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
pub async fn media_server_events_sse(
|
||||||
|
State(control_point): State<Arc<ControlPoint>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Convert crossbeam channel to tokio channel for async compatibility
|
||||||
|
let (tx, mut rx_tokio) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let rx = control_point.subscribe_media_server_events();
|
||||||
|
|
||||||
|
// Spawn blocking task to bridge crossbeam -> tokio
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
while let Ok(event) = rx.recv() {
|
||||||
|
if tx.send(event).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let stream = stream! {
|
||||||
|
while let Some(event) = rx_tokio.recv().await {
|
||||||
|
let timestamp = chrono::Utc::now();
|
||||||
|
|
||||||
|
let payload = match event {
|
||||||
|
MediaServerEvent::GlobalUpdated { server_id, system_update_id } => {
|
||||||
|
MediaServerEventPayload::GlobalUpdated {
|
||||||
|
server_id: server_id.0,
|
||||||
|
system_update_id,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MediaServerEvent::ContainersUpdated { server_id, container_ids } => {
|
||||||
|
MediaServerEventPayload::ContainersUpdated {
|
||||||
|
server_id: server_id.0,
|
||||||
|
container_ids,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(json) = serde_json::to_string(&payload) {
|
||||||
|
yield Ok::<_, axum::Error>(Event::default().event("media_server").data(json));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler SSE pour tous les événements (renderers + serveurs)
|
||||||
|
///
|
||||||
|
/// Route: GET /api/control/events
|
||||||
|
///
|
||||||
|
/// Diffuse tous les événements du control point (renderers et serveurs) en temps réel.
|
||||||
|
/// Chaque événement est catégorisé et inclut un timestamp.
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/events",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Flux SSE de tous les événements du control point", content_type = "text/event-stream")
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
pub async fn all_events_sse(
|
||||||
|
State(control_point): State<Arc<ControlPoint>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Convert crossbeam channels to tokio channels for async compatibility
|
||||||
|
let (renderer_tx, mut renderer_rx_tokio) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let (server_tx, mut server_rx_tokio) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
let renderer_rx = control_point.subscribe_events();
|
||||||
|
let server_rx = control_point.subscribe_media_server_events();
|
||||||
|
|
||||||
|
// Spawn blocking tasks to bridge crossbeam -> tokio
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
while let Ok(event) = renderer_rx.recv() {
|
||||||
|
if renderer_tx.send(event).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
while let Ok(event) = server_rx.recv() {
|
||||||
|
if server_tx.send(event).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let stream = stream! {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
Some(event) = renderer_rx_tokio.recv() => {
|
||||||
|
let timestamp = chrono::Utc::now();
|
||||||
|
|
||||||
|
let renderer_payload = match event {
|
||||||
|
RendererEvent::StateChanged { id, state } => {
|
||||||
|
RendererEventPayload::StateChanged {
|
||||||
|
renderer_id: id.0,
|
||||||
|
state: state_to_string(state),
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RendererEvent::PositionChanged { id, position } => {
|
||||||
|
RendererEventPayload::PositionChanged {
|
||||||
|
renderer_id: id.0,
|
||||||
|
track: position.track,
|
||||||
|
rel_time: position.rel_time,
|
||||||
|
track_duration: position.track_duration,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RendererEvent::VolumeChanged { id, volume } => {
|
||||||
|
RendererEventPayload::VolumeChanged {
|
||||||
|
renderer_id: id.0,
|
||||||
|
volume,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RendererEvent::MuteChanged { id, mute } => {
|
||||||
|
RendererEventPayload::MuteChanged {
|
||||||
|
renderer_id: id.0,
|
||||||
|
mute,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RendererEvent::MetadataChanged { id, metadata } => {
|
||||||
|
RendererEventPayload::MetadataChanged {
|
||||||
|
renderer_id: id.0,
|
||||||
|
title: metadata.title,
|
||||||
|
artist: metadata.artist,
|
||||||
|
album: metadata.album,
|
||||||
|
album_art_uri: metadata.album_art_uri,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RendererEvent::QueueUpdated { id, queue_length } => {
|
||||||
|
RendererEventPayload::QueueUpdated {
|
||||||
|
renderer_id: id.0,
|
||||||
|
queue_length,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = UnifiedEventPayload::Renderer(renderer_payload);
|
||||||
|
|
||||||
|
if let Ok(json) = serde_json::to_string(&payload) {
|
||||||
|
yield Ok::<_, axum::Error>(Event::default().event("control").data(json));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(event) = server_rx_tokio.recv() => {
|
||||||
|
let timestamp = chrono::Utc::now();
|
||||||
|
|
||||||
|
let server_payload = match event {
|
||||||
|
MediaServerEvent::GlobalUpdated { server_id, system_update_id } => {
|
||||||
|
MediaServerEventPayload::GlobalUpdated {
|
||||||
|
server_id: server_id.0,
|
||||||
|
system_update_id,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MediaServerEvent::ContainersUpdated { server_id, container_ids } => {
|
||||||
|
MediaServerEventPayload::ContainersUpdated {
|
||||||
|
server_id: server_id.0,
|
||||||
|
container_ids,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = UnifiedEventPayload::MediaServer(server_payload);
|
||||||
|
|
||||||
|
if let Ok(json) = serde_json::to_string(&payload) {
|
||||||
|
yield Ok::<_, axum::Error>(Event::default().event("control").data(json));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else => break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ROUTER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Crée le router SSE pour les événements du Control Point
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
pub fn create_sse_router(control_point: Arc<ControlPoint>) -> Router {
|
||||||
|
use axum::routing::get;
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.route("/events", get(all_events_sse))
|
||||||
|
.route("/events/renderers", get(renderer_events_sse))
|
||||||
|
.route("/events/servers", get(media_server_events_sse))
|
||||||
|
.with_state(control_point)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HELPERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
fn state_to_string(state: PlaybackState) -> String {
|
||||||
|
match state {
|
||||||
|
PlaybackState::Stopped => "STOPPED".to_string(),
|
||||||
|
PlaybackState::Playing => "PLAYING".to_string(),
|
||||||
|
PlaybackState::Paused => "PAUSED".to_string(),
|
||||||
|
PlaybackState::Transitioning => "TRANSITIONING".to_string(),
|
||||||
|
PlaybackState::NoMedia => "NO_MEDIA".to_string(),
|
||||||
|
PlaybackState::Unknown(s) => s,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
use once_cell::sync::OnceCell;
|
use once_cell::sync::OnceCell;
|
||||||
use pmoupnp::{services::ServiceInstance, state_variables::StateVarInstance, variable_types::StateValue};
|
use pmoupnp::{
|
||||||
|
services::ServiceInstance, state_variables::StateVarInstance, variable_types::StateValue,
|
||||||
|
};
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
Arc, Mutex, Weak,
|
Arc, Mutex, Weak,
|
||||||
atomic::{AtomicU32, Ordering},
|
atomic::{AtomicU32, Ordering},
|
||||||
|
|||||||
@@ -6,22 +6,22 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Json, Router,
|
||||||
body::Body,
|
body::Body,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::{
|
http::{
|
||||||
header::{ACCEPT_RANGES, CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
|
|
||||||
StatusCode,
|
StatusCode,
|
||||||
|
header::{ACCEPT_RANGES, CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
|
||||||
},
|
},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::get,
|
routing::get,
|
||||||
Json, Router,
|
|
||||||
};
|
};
|
||||||
use pmoaudiocache::{get_audio_cache, register_audio_cache, AudioCacheExt, Cache as AudioCache};
|
use pmoaudiocache::{AudioCacheExt, Cache as AudioCache, get_audio_cache, register_audio_cache};
|
||||||
use pmocovers::{get_cover_cache, register_cover_cache, Cache as CoverCache, CoverCacheExt};
|
use pmocovers::{Cache as CoverCache, CoverCacheExt, get_cover_cache, register_cover_cache};
|
||||||
use pmoparadise::{
|
use pmoparadise::{
|
||||||
channels::{ChannelDescriptor, ALL_CHANNELS},
|
|
||||||
stream_channel::register_global_channel_manager,
|
|
||||||
ParadiseChannelManager, ParadiseHistoryBuilder,
|
ParadiseChannelManager, ParadiseHistoryBuilder,
|
||||||
|
channels::{ALL_CHANNELS, ChannelDescriptor},
|
||||||
|
stream_channel::register_global_channel_manager,
|
||||||
};
|
};
|
||||||
use pmoplaylist::register_audio_cache as register_playlist_audio_cache;
|
use pmoplaylist::register_audio_cache as register_playlist_audio_cache;
|
||||||
use pmoplaylist::{self, PlaylistEventKind};
|
use pmoplaylist::{self, PlaylistEventKind};
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
sync::{Arc, RwLock}, time::Duration,
|
sync::{Arc, RwLock},
|
||||||
|
time::Duration,
|
||||||
};
|
};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use xmltree::{Element, EmitterConfig, XMLNode};
|
use xmltree::{Element, EmitterConfig, XMLNode};
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ pub struct ServiceInstance {
|
|||||||
seqid: Arc<Mutex<HashMap<String, u32>>>,
|
seqid: Arc<Mutex<HashMap<String, u32>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl std::fmt::Debug for ServiceInstance {
|
impl std::fmt::Debug for ServiceInstance {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.debug_struct("ServiceInstance")
|
f.debug_struct("ServiceInstance")
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ fn build_soap_envelope_with_body(body_child: Element) -> Result<String, xmltree:
|
|||||||
Ok(String::from_utf8(buf).unwrap())
|
Ok(String::from_utf8(buf).unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Construit une réponse SOAP UPnP
|
/// Construit une réponse SOAP UPnP
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ mod envelope;
|
|||||||
mod fault;
|
mod fault;
|
||||||
mod parser;
|
mod parser;
|
||||||
|
|
||||||
pub use builder::{build_soap_response,build_soap_request};
|
pub use builder::{build_soap_request, build_soap_response};
|
||||||
pub use envelope::{SoapBody, SoapEnvelope, SoapHeader};
|
pub use envelope::{SoapBody, SoapEnvelope, SoapHeader};
|
||||||
pub use fault::{SoapFault, build_soap_fault};
|
pub use fault::{SoapFault, build_soap_fault};
|
||||||
pub use parser::{SoapAction, parse_soap_action, parse_soap_envelope};
|
pub use parser::{SoapAction, parse_soap_action, parse_soap_envelope};
|
||||||
|
|||||||
@@ -184,11 +184,7 @@ fn parse_message(data: &str, from: SocketAddr) -> Option<SsdpEvent> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if result.is_none() {
|
if result.is_none() {
|
||||||
trace!(
|
trace!("SSDP message from {} could not be parsed:\n{}", from, data);
|
||||||
"SSDP message from {} could not be parsed:\n{}",
|
|
||||||
from,
|
|
||||||
data
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result
|
result
|
||||||
@@ -247,7 +243,10 @@ fn handle_search_response(
|
|||||||
let st = match headers.get("ST") {
|
let st = match headers.get("ST") {
|
||||||
Some(s) => s.to_string(),
|
Some(s) => s.to_string(),
|
||||||
None => {
|
None => {
|
||||||
trace!("M-SEARCH response from {} missing ST header, ignoring", from);
|
trace!(
|
||||||
|
"M-SEARCH response from {} missing ST header, ignoring",
|
||||||
|
from
|
||||||
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -350,8 +349,7 @@ fn parse_max_age(value: Option<&String>) -> u32 {
|
|||||||
}
|
}
|
||||||
trace!(
|
trace!(
|
||||||
"Could not parse max-age from CACHE-CONTROL: '{}', using default {}",
|
"Could not parse max-age from CACHE-CONTROL: '{}', using default {}",
|
||||||
v,
|
v, MAX_AGE
|
||||||
MAX_AGE
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
MAX_AGE
|
MAX_AGE
|
||||||
|
|||||||
Reference in New Issue
Block a user