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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "alsa"
|
||||
version = "0.9.1"
|
||||
@@ -586,6 +592,21 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "cc"
|
||||
version = "1.2.46"
|
||||
@@ -700,6 +721,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -909,6 +943,31 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
@@ -1532,6 +1591,8 @@ version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
@@ -2223,6 +2284,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
@@ -2331,6 +2401,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "mio"
|
||||
version = "1.1.0"
|
||||
@@ -3163,14 +3245,26 @@ name = "pmocontrol"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"axum 0.8.7",
|
||||
"chrono",
|
||||
"crossbeam-channel",
|
||||
"crossterm",
|
||||
"pmodidl",
|
||||
"pmoserver",
|
||||
"pmoupnp",
|
||||
"quick-xml 0.38.4",
|
||||
"ratatui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ureq",
|
||||
"utoipa",
|
||||
"xmltree 0.11.0",
|
||||
]
|
||||
|
||||
@@ -3742,6 +3836,26 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rav1e"
|
||||
version = "0.7.1"
|
||||
@@ -4234,6 +4348,27 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-mio"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio 0.8.11",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.6"
|
||||
@@ -4336,12 +4471,50 @@ dependencies = [
|
||||
"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]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -4786,7 +4959,7 @@ checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"mio 1.1.0",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
@@ -5117,6 +5290,29 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
@@ -5618,6 +5814,15 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
@@ -5669,6 +5874,21 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
@@ -5708,6 +5928,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -5726,6 +5952,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -5744,6 +5976,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -5774,6 +6012,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -5792,6 +6036,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -5810,6 +6060,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -5828,6 +6084,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
|
||||
@@ -14,3 +14,22 @@ tracing-subscriber = "0.3"
|
||||
anyhow = "1.0"
|
||||
xmltree = "0.11.0"
|
||||
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
|
||||
//! 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::process;
|
||||
use std::thread;
|
||||
@@ -77,9 +77,7 @@ fn main() -> Result<()> {
|
||||
.unwrap_or(false);
|
||||
println!(
|
||||
"Renderer \"{}\": AVTransport present = {}, SetNextAVTransportURI supported = {}",
|
||||
renderer.friendly_name,
|
||||
renderer.capabilities.has_avtransport,
|
||||
supports_set_next
|
||||
renderer.friendly_name, renderer.capabilities.has_avtransport, supports_set_next
|
||||
);
|
||||
|
||||
let timeout = Duration::from_secs(config.timeout_secs);
|
||||
@@ -93,7 +91,10 @@ fn main() -> Result<()> {
|
||||
let live_playlist_container = match live_playlist_container {
|
||||
Some(container) => container,
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -112,7 +113,10 @@ fn main() -> Result<()> {
|
||||
.context("Failed to collect playable items from Live Playlist container")?;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -204,13 +208,16 @@ fn main() -> Result<()> {
|
||||
bound_container
|
||||
);
|
||||
// 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!(
|
||||
" → Queue length after refresh: {} items",
|
||||
fresh_snapshot.len()
|
||||
);
|
||||
if !fresh_snapshot.is_empty() {
|
||||
println!(" → First item: {}",
|
||||
println!(
|
||||
" → First item: {}",
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -392,7 +402,10 @@ fn find_live_playlist_container(server: &MusicServer) -> Result<Option<MediaEntr
|
||||
.browse_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)
|
||||
let mut queue: VecDeque<(MediaEntry, usize)> = VecDeque::new();
|
||||
@@ -412,7 +425,10 @@ fn find_live_playlist_container(server: &MusicServer) -> Result<Option<MediaEntr
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -482,14 +501,19 @@ fn collect_playable_items_from_container(
|
||||
// Fallback: try with minimal number of items
|
||||
server
|
||||
.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 {
|
||||
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();
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,11 @@ fn main() -> Result<()> {
|
||||
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 {
|
||||
MediaServerEvent::GlobalUpdated {
|
||||
server_id,
|
||||
|
||||
@@ -80,9 +80,7 @@ fn main() -> Result<()> {
|
||||
.unwrap_or(false);
|
||||
println!(
|
||||
"Renderer \"{}\": AVTransport present = {}, SetNextAVTransportURI supported = {}",
|
||||
renderer.friendly_name,
|
||||
renderer.capabilities.has_avtransport,
|
||||
supports_set_next
|
||||
renderer.friendly_name, renderer.capabilities.has_avtransport, supports_set_next
|
||||
);
|
||||
|
||||
let timeout = Duration::from_secs(config.timeout_secs);
|
||||
|
||||
@@ -14,13 +14,13 @@ use crate::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
||||
VolumeControl,
|
||||
};
|
||||
use crate::model::TrackMetadata;
|
||||
use crate::discovery::DiscoveryManager;
|
||||
use crate::events::{MediaServerEventBus, RendererEventBus};
|
||||
use crate::media_server::{
|
||||
MediaBrowser, MediaEntry, MediaResource, MediaServerInfo, MusicServer, ServerId,
|
||||
};
|
||||
use crate::media_server_events::spawn_media_server_event_runtime;
|
||||
use crate::model::TrackMetadata;
|
||||
use crate::model::{MediaServerEvent, RendererEvent, RendererId, RendererProtocol};
|
||||
use crate::music_renderer::op_not_supported;
|
||||
use crate::playback_queue::{PlaybackItem, PlaybackQueue};
|
||||
@@ -138,7 +138,9 @@ impl ControlPoint {
|
||||
};
|
||||
let renderers = infos
|
||||
.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<_>>();
|
||||
|
||||
for renderer in renderers {
|
||||
@@ -264,6 +266,7 @@ impl ControlPoint {
|
||||
let registry_for_media_worker = Arc::clone(®istry);
|
||||
let runtime_for_media_worker = Arc::clone(&runtime);
|
||||
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();
|
||||
|
||||
thread::Builder::new()
|
||||
@@ -322,6 +325,7 @@ impl ControlPoint {
|
||||
&runtime_for_media_worker,
|
||||
&bindings_for_media_worker,
|
||||
&renderer_id,
|
||||
&event_bus_for_media_worker,
|
||||
) {
|
||||
warn!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
@@ -341,6 +345,7 @@ impl ControlPoint {
|
||||
let registry_for_periodic = Arc::clone(®istry);
|
||||
let runtime_for_periodic = Arc::clone(&runtime);
|
||||
let bindings_for_periodic = Arc::clone(&playlist_bindings);
|
||||
let event_bus_for_periodic = event_bus.clone();
|
||||
|
||||
thread::Builder::new()
|
||||
.name("cp-playlist-periodic-refresh".into())
|
||||
@@ -374,6 +379,7 @@ impl ControlPoint {
|
||||
&runtime_for_periodic,
|
||||
&bindings_for_periodic,
|
||||
&renderer_id,
|
||||
&event_bus_for_periodic,
|
||||
) {
|
||||
warn!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
@@ -511,6 +517,13 @@ impl ControlPoint {
|
||||
queue_len = 0,
|
||||
"Cleared playback queue"
|
||||
);
|
||||
|
||||
// Emit QueueUpdated event
|
||||
self.emit_renderer_event(RendererEvent::QueueUpdated {
|
||||
id: renderer_id.clone(),
|
||||
queue_length: 0,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -546,6 +559,13 @@ impl ControlPoint {
|
||||
queue_len = new_len,
|
||||
"Enqueued playback items"
|
||||
);
|
||||
|
||||
// Emit QueueUpdated event
|
||||
self.emit_renderer_event(RendererEvent::QueueUpdated {
|
||||
id: renderer_id.clone(),
|
||||
queue_length: new_len,
|
||||
});
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -945,6 +971,7 @@ fn refresh_attached_queue_for(
|
||||
runtime: &Arc<RuntimeState>,
|
||||
bindings: &Arc<Mutex<HashMap<RendererId, PlaylistBinding>>>,
|
||||
renderer_id: &RendererId,
|
||||
event_bus: &RendererEventBus,
|
||||
) -> anyhow::Result<()> {
|
||||
// Step 1: Check binding and mark refresh as in-progress
|
||||
let (server_id, container_id) = {
|
||||
@@ -1040,17 +1067,25 @@ fn refresh_attached_queue_for(
|
||||
"Refreshed playlist is empty, clearing queue"
|
||||
);
|
||||
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(());
|
||||
}
|
||||
|
||||
// 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| {
|
||||
new_items.iter().position(|new_item| {
|
||||
// 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;
|
||||
}
|
||||
// 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();
|
||||
|
||||
if let Some(idx) = item_found_at {
|
||||
@@ -1074,6 +1109,7 @@ fn refresh_attached_queue_for(
|
||||
current_preserved = true,
|
||||
"Refreshed queue from playlist container"
|
||||
);
|
||||
new_items.len() - idx
|
||||
} else {
|
||||
// Current item not found: replace with full new list
|
||||
for item in new_items.iter() {
|
||||
@@ -1087,7 +1123,14 @@ fn refresh_attached_queue_for(
|
||||
current_preserved = false,
|
||||
"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(())
|
||||
|
||||
@@ -18,6 +18,14 @@ pub mod rendering_control_client;
|
||||
pub mod soap_client;
|
||||
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 avtransport_client::{AvTransportClient, PositionInfo, TransportInfo};
|
||||
pub use capabilities::{
|
||||
@@ -38,7 +46,8 @@ pub use upnp_renderer::UpnpRenderer;
|
||||
|
||||
pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager};
|
||||
pub use model::{
|
||||
MediaServerEvent, RendererCapabilities, RendererEvent, RendererId, RendererInfo, RendererProtocol,
|
||||
MediaServerEvent, RendererCapabilities, RendererEvent, RendererId, RendererInfo,
|
||||
RendererProtocol,
|
||||
};
|
||||
pub use provider::HttpXmlDescriptionProvider;
|
||||
pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};
|
||||
|
||||
@@ -34,10 +34,7 @@ pub(crate) fn spawn_media_server_event_runtime(
|
||||
.context("Failed to read listener address")
|
||||
.map_err(io_from_anyhow)?;
|
||||
|
||||
info!(
|
||||
"MediaServer event listener bound on {}",
|
||||
listener_addr
|
||||
);
|
||||
info!("MediaServer event listener bound on {}", listener_addr);
|
||||
|
||||
let (notify_tx, notify_rx) = unbounded::<IncomingNotify>();
|
||||
thread::Builder::new()
|
||||
@@ -85,10 +82,7 @@ fn run_http_listener(listener: TcpListener, notify_tx: Sender<IncomingNotify>) {
|
||||
|
||||
let notify = IncomingNotify {
|
||||
path: request.path,
|
||||
sid: request
|
||||
.headers
|
||||
.get("sid")
|
||||
.cloned(),
|
||||
sid: request.headers.get("sid").cloned(),
|
||||
body: request.body,
|
||||
};
|
||||
|
||||
@@ -151,10 +145,7 @@ fn read_http_request(stream: &mut TcpStream) -> io::Result<HttpRequest> {
|
||||
break;
|
||||
}
|
||||
if let Some((name, value)) = trimmed.split_once(':') {
|
||||
headers.insert(
|
||||
name.trim().to_ascii_lowercase(),
|
||||
value.trim().to_string(),
|
||||
);
|
||||
headers.insert(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)
|
||||
.context("Cannot determine local IP for callback")?;
|
||||
|
||||
let callback_url =
|
||||
format!("http://{}:{}{}", format_ip(&local_ip), listener_port, entry.callback_path);
|
||||
let callback_url = format!(
|
||||
"http://{}:{}{}",
|
||||
format_ip(&local_ip),
|
||||
listener_port,
|
||||
entry.callback_path
|
||||
);
|
||||
|
||||
debug!(
|
||||
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 container_ids: Vec<String> = Vec::new();
|
||||
|
||||
for property in root
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|node| match node {
|
||||
for property in root.children.iter().filter_map(|node| match node {
|
||||
XMLNode::Element(elem) => Some(elem),
|
||||
_ => None,
|
||||
})
|
||||
{
|
||||
for child in property
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|node| match node {
|
||||
}) {
|
||||
for child in property.children.iter().filter_map(|node| match node {
|
||||
XMLNode::Element(elem) => Some(elem),
|
||||
_ => None,
|
||||
})
|
||||
{
|
||||
}) {
|
||||
if child.name == "SystemUpdateID" {
|
||||
if let Some(text) = child.get_text() {
|
||||
let trimmed = text.trim();
|
||||
@@ -694,9 +681,14 @@ fn parse_container_update_ids(raw: &str) -> Vec<String> {
|
||||
.collect()
|
||||
} else {
|
||||
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 {
|
||||
let Some(id) = tokens.next() else { break; };
|
||||
let Some(id) = tokens.next() else {
|
||||
break;
|
||||
};
|
||||
ids.push(id.to_string());
|
||||
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> {
|
||||
let is_ipv6 = remote_host.contains(':') && !remote_host.contains('.');
|
||||
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 {
|
||||
format!("{}:{}", remote_host, remote_port)
|
||||
};
|
||||
|
||||
@@ -95,6 +95,10 @@ pub enum RendererEvent {
|
||||
id: RendererId,
|
||||
metadata: TrackMetadata,
|
||||
},
|
||||
QueueUpdated {
|
||||
id: RendererId,
|
||||
queue_length: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[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 pmoupnp::{services::ServiceInstance, state_variables::StateVarInstance, variable_types::StateValue};
|
||||
use pmoupnp::{
|
||||
services::ServiceInstance, state_variables::StateVarInstance, variable_types::StateValue,
|
||||
};
|
||||
use std::sync::{
|
||||
Arc, Mutex, Weak,
|
||||
atomic::{AtomicU32, Ordering},
|
||||
|
||||
@@ -6,22 +6,22 @@
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::Body,
|
||||
extract::{Path, State},
|
||||
http::{
|
||||
header::{ACCEPT_RANGES, CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
|
||||
StatusCode,
|
||||
header::{ACCEPT_RANGES, CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
|
||||
},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use pmoaudiocache::{get_audio_cache, register_audio_cache, AudioCacheExt, Cache as AudioCache};
|
||||
use pmocovers::{get_cover_cache, register_cover_cache, Cache as CoverCache, CoverCacheExt};
|
||||
use pmoaudiocache::{AudioCacheExt, Cache as AudioCache, get_audio_cache, register_audio_cache};
|
||||
use pmocovers::{Cache as CoverCache, CoverCacheExt, get_cover_cache, register_cover_cache};
|
||||
use pmoparadise::{
|
||||
channels::{ChannelDescriptor, ALL_CHANNELS},
|
||||
stream_channel::register_global_channel_manager,
|
||||
ParadiseChannelManager, ParadiseHistoryBuilder,
|
||||
channels::{ALL_CHANNELS, ChannelDescriptor},
|
||||
stream_channel::register_global_channel_manager,
|
||||
};
|
||||
use pmoplaylist::register_audio_cache as register_playlist_audio_cache;
|
||||
use pmoplaylist::{self, PlaylistEventKind};
|
||||
|
||||
@@ -6,7 +6,8 @@ use axum::{
|
||||
};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, RwLock}, time::Duration,
|
||||
sync::{Arc, RwLock},
|
||||
time::Duration,
|
||||
};
|
||||
use tracing::info;
|
||||
use xmltree::{Element, EmitterConfig, XMLNode};
|
||||
|
||||
@@ -125,7 +125,6 @@ pub struct ServiceInstance {
|
||||
seqid: Arc<Mutex<HashMap<String, u32>>>,
|
||||
}
|
||||
|
||||
|
||||
impl std::fmt::Debug for ServiceInstance {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
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())
|
||||
}
|
||||
|
||||
|
||||
/// Construit une réponse SOAP UPnP
|
||||
///
|
||||
/// # Arguments
|
||||
|
||||
@@ -53,7 +53,7 @@ mod envelope;
|
||||
mod fault;
|
||||
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 fault::{SoapFault, build_soap_fault};
|
||||
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() {
|
||||
trace!(
|
||||
"SSDP message from {} could not be parsed:\n{}",
|
||||
from,
|
||||
data
|
||||
);
|
||||
trace!("SSDP message from {} could not be parsed:\n{}", from, data);
|
||||
}
|
||||
|
||||
result
|
||||
@@ -247,7 +243,10 @@ fn handle_search_response(
|
||||
let st = match headers.get("ST") {
|
||||
Some(s) => s.to_string(),
|
||||
None => {
|
||||
trace!("M-SEARCH response from {} missing ST header, ignoring", from);
|
||||
trace!(
|
||||
"M-SEARCH response from {} missing ST header, ignoring",
|
||||
from
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
@@ -350,8 +349,7 @@ fn parse_max_age(value: Option<&String>) -> u32 {
|
||||
}
|
||||
trace!(
|
||||
"Could not parse max-age from CACHE-CONTROL: '{}', using default {}",
|
||||
v,
|
||||
MAX_AGE
|
||||
v, MAX_AGE
|
||||
);
|
||||
}
|
||||
MAX_AGE
|
||||
|
||||
Reference in New Issue
Block a user