From 72bf73ef1076f751b8a335e3d19ddee3d50d719b Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Wed, 3 Dec 2025 18:59:41 +0100 Subject: [PATCH] =?UTF-8?q?Impl=C3=A9mentation=20de=20l'API=20REST=20De=20?= =?UTF-8?q?PMOControl.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 264 ++- pmocontrol/Cargo.toml | 19 + .../examples/full_control_point_demo.rs | 1843 +++++++++++------ pmocontrol/examples/live_pmomusic_demo.rs | 57 +- .../examples/media_server_events_demo.rs | 6 +- pmocontrol/examples/queue_pmomusic_demo.rs | 4 +- pmocontrol/src/control_point.rs | 65 +- pmocontrol/src/lib.rs | 11 +- pmocontrol/src/media_server_events.rs | 62 +- pmocontrol/src/model.rs | 4 + pmocontrol/src/openapi.rs | 305 +++ pmocontrol/src/pmoserver_ext.rs | 998 +++++++++ pmocontrol/src/sse.rs | 432 ++++ pmomediaserver/src/contentdirectory/state.rs | 4 +- pmomediaserver/src/paradise_streaming.rs | 12 +- pmoupnp/src/devices/device_instance.rs | 3 +- pmoupnp/src/services/service_instance.rs | 1 - pmoupnp/src/soap/builder.rs | 1 - pmoupnp/src/soap/mod.rs | 2 +- pmoupnp/src/ssdp/client.rs | 16 +- pmoupnp/src/upnp_server.rs | 2 +- 21 files changed, 3383 insertions(+), 728 deletions(-) create mode 100644 pmocontrol/src/openapi.rs create mode 100644 pmocontrol/src/pmoserver_ext.rs create mode 100644 pmocontrol/src/sse.rs diff --git a/Cargo.lock b/Cargo.lock index 22f972dd..9643d4b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/pmocontrol/Cargo.toml b/pmocontrol/Cargo.toml index 016aa53a..6551e1b5 100644 --- a/pmocontrol/Cargo.toml +++ b/pmocontrol/Cargo.toml @@ -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"] diff --git a/pmocontrol/examples/full_control_point_demo.rs b/pmocontrol/examples/full_control_point_demo.rs index cb91ca13..dd49209d 100644 --- a/pmocontrol/examples/full_control_point_demo.rs +++ b/pmocontrol/examples/full_control_point_demo.rs @@ -1,209 +1,1113 @@ -//! Full interactive control point CLI demo. +//! Full interactive control point demo with Ratatui-powered UI. //! -//! This example demonstrates all capabilities of the ControlPoint: -//! - Interactive device selection (renderer + media server) -//! - ContentDirectory navigation with back/forward support -//! - Queue construction and playlist binding -//! - Full playback control (play/pause/stop/next/volume/mute) -//! - Real-time event monitoring (renderer and media server events) -//! - Live playlist observation and auto-refresh +//! This version replaces the legacy println!-driven interface with a +//! Crossterm + Ratatui dashboard featuring menus, overlays and live updates. -use std::io::{self, BufRead, Write}; +use std::collections::HashMap; +use std::io::{self, Stdout}; use std::process; +use std::sync::mpsc::{self, Sender}; use std::sync::Arc; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; use anyhow::{anyhow, Context, Result}; +use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use pmocontrol::model::TrackMetadata; use pmocontrol::{ ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaResource, MediaServerEvent, - MediaServerInfo, MusicServer, PlaybackItem, PlaybackPosition, PlaybackStatus, RendererEvent, - RendererInfo, TransportControl, VolumeControl, + MediaServerInfo, MusicServer, PlaybackItem, PlaybackPositionInfo, RendererEvent, RendererInfo, + TransportControl, VolumeControl, }; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph}; +use ratatui::Terminal; const DEFAULT_TIMEOUT_SECS: u64 = 5; const DEFAULT_DISCOVERY_SECS: u64 = 15; +const PROGRESS_BAR_WIDTH: usize = 32; +const TICK_RATE: Duration = Duration::from_millis(200); + +#[derive(Clone)] +struct UiState { + renderer_name: String, + server_name: Option, + playback_state: Option, + position: Option, + volume: Option, + mute: Option, + metadata: Option, + last_status: Option, + current_track_uri: Option, +} + +impl UiState { + fn new(renderer_name: String) -> Self { + Self { + renderer_name, + server_name: None, + playback_state: None, + position: None, + volume: None, + mute: None, + metadata: None, + last_status: Some("Interface initialisée.".to_string()), + current_track_uri: None, + } + } + + fn placeholder() -> Self { + Self::new("".to_string()) + } + + fn set_status>(&mut self, status: S) { + self.last_status = Some(status.into()); + } +} fn main() -> Result<()> { let _ = tracing_subscriber::fmt::try_init(); - println!("=== Full Control Point Interactive Demo ==="); - println!("Starting control point with timeout={}s", DEFAULT_TIMEOUT_SECS); + println!("=== Full Control Point Ratatui Demo ==="); + println!( + "Starting control point with timeout={}s", + DEFAULT_TIMEOUT_SECS + ); - let control_point = ControlPoint::spawn(DEFAULT_TIMEOUT_SECS) - .context("Failed to start control point")?; + let control_point = + ControlPoint::spawn(DEFAULT_TIMEOUT_SECS).context("Failed to start control point")?; - println!("Discovery running for {} seconds...", DEFAULT_DISCOVERY_SECS); - thread::sleep(Duration::from_secs(DEFAULT_DISCOVERY_SECS)); + println!( + "Discovery running for {} seconds...", + DEFAULT_DISCOVERY_SECS + ); + std::thread::sleep(Duration::from_secs(DEFAULT_DISCOVERY_SECS)); - // Step 1: List and select renderer let registry = control_point.registry(); - let renderer_info = { + let renderers = { let reg = registry.read().expect("registry poisoned"); - let renderers = reg.list_renderers(); - - println!("\n=== Available Media Renderers ==="); - if renderers.is_empty() { + let list = reg.list_renderers(); + if list.is_empty() { eprintln!("No renderers discovered. Exiting."); process::exit(1); } - - print_renderers(&renderers); - select_renderer(&renderers)? + list }; - println!("\n✓ Selected renderer: {} (id={})", - renderer_info.friendly_name, renderer_info.id.0); - - // Step 2: List and select media server - let server_info = { + let servers = { let reg = registry.read().expect("registry poisoned"); - let servers: Vec = reg.list_servers() + let list: Vec = reg + .list_servers() .into_iter() .filter(|s| s.has_content_directory && s.content_directory_control_url.is_some()) .collect(); - - println!("\n=== Available Media Servers ==="); - if servers.is_empty() { + if list.is_empty() { eprintln!("No media servers with ContentDirectory discovered. Exiting."); process::exit(1); } - - print_servers(&servers); - select_server(&servers)? + list }; - println!("\n✓ Selected server: {} (id={})", - server_info.friendly_name, server_info.id.0); - - let timeout = Duration::from_secs(DEFAULT_TIMEOUT_SECS); - let server = MusicServer::from_info(&server_info, timeout) - .context("Failed to initialize MusicServer")?; - - // Step 3: Navigate ContentDirectory and select a container for queue - println!("\n=== ContentDirectory Navigation ==="); - println!("Commands: [number]=enter container, 'b'=back, 's'=select current as queue source, 'q'=quit navigation"); - - let (selected_items, selected_container_id) = navigate_and_select(&server)?; - - if selected_items.is_empty() { - println!("No playable items selected. Exiting."); - process::exit(1); + let control_point = Arc::new(control_point); + let app = App::new(control_point, renderers, servers); + if let Err(err) = run_app(app) { + eprintln!("Application exited with error: {err}"); } - println!("\n✓ Selected {} items for playback queue", selected_items.len()); - - // Step 4: Build queue - let renderer_id = renderer_info.id.clone(); - - println!("\n=== Building Playback Queue ==="); - control_point.clear_queue(&renderer_id) - .context("Failed to clear queue")?; - control_point.enqueue_items(&renderer_id, selected_items) - .context("Failed to enqueue items")?; - - println!("✓ Queue built with {} items", - control_point.get_queue_snapshot(&renderer_id)?.len()); - - // Step 5: Ask about playlist binding - if let Some(container_id) = selected_container_id { - println!("\nAttach this queue to playlist container '{}' for auto-refresh? (y/n)", container_id); - if read_yes_no()? { - control_point.attach_queue_to_playlist( - &renderer_id, - server_info.id.clone(), - container_id.clone(), - ); - println!("✓ Queue attached to playlist container '{}'", container_id); - } else { - println!("Queue will not be bound to playlist (no auto-refresh)"); - } - } - - // Step 6: Start playback - println!("\n=== Starting Playback ==="); - control_point.play_next_from_queue(&renderer_id) - .context("Failed to start playback")?; - println!("✓ Playback started"); - - // Step 7: Spawn event monitoring threads - let control_point_arc = Arc::new(control_point); - spawn_renderer_event_thread(Arc::clone(&control_point_arc), renderer_id.clone()); - spawn_media_server_event_thread(Arc::clone(&control_point_arc), renderer_id.clone()); - - // Step 8: Interactive control loop - println!("\n=== Interactive Control ==="); - print_help(); - - run_control_loop(Arc::clone(&control_point_arc), renderer_id)?; - println!("\nExiting. Goodbye!"); Ok(()) } -/// Print list of renderers with index. -fn print_renderers(renderers: &[RendererInfo]) { - for (idx, info) in renderers.iter().enumerate() { - println!(" [{}] {} | model={} | protocol={:?} | online={} | id={}", - idx, info.friendly_name, info.model_name, info.protocol, info.online, info.id.0); - } +struct App { + control_point: Arc, + renderers: Vec, + renderer_index: usize, + renderer_info: Option, + servers: Vec, + server_index: usize, + server_info: Option, + music_server: Option, + browser: Option, + mode: Mode, + ui_state: UiState, + queue_snapshot: Vec, + show_queue_overlay: bool, + pending_binding_container: Option, + status_line: String, + known_tracks: std::collections::HashMap, } -/// Print list of servers with index. -fn print_servers(servers: &[MediaServerInfo]) { - for (idx, info) in servers.iter().enumerate() { - println!(" [{}] {} | model={} | manufacturer={} | online={} | id={}", - idx, info.friendly_name, info.model_name, info.manufacturer, info.online, info.id.0); - } +enum Mode { + SelectRenderer, + SelectServer, + Browse, + BindingPrompt, + Control, } -/// Interactive renderer selection. -fn select_renderer(renderers: &[RendererInfo]) -> Result { - loop { - print!("\nSelect renderer (index 0-{}): ", renderers.len() - 1); - io::stdout().flush()?; +struct BrowserState { + nav_state: NavigationState, + entries: Vec, + selected_index: usize, +} - let mut input = String::new(); - io::stdin().read_line(&mut input)?; +enum AppEvent { + Renderer(RendererEvent), + Media(MediaServerEvent), +} - match input.trim().parse::() { - Ok(idx) if idx < renderers.len() => { - return Ok(renderers[idx].clone()); +impl App { + fn new( + control_point: Arc, + renderers: Vec, + servers: Vec, + ) -> Self { + Self { + control_point, + renderers, + renderer_index: 0, + renderer_info: None, + servers, + server_index: 0, + server_info: None, + music_server: None, + browser: None, + mode: Mode::SelectRenderer, + ui_state: UiState::placeholder(), + queue_snapshot: Vec::new(), + show_queue_overlay: false, + pending_binding_container: None, + status_line: "Sélectionne un renderer avec ↑/↓ et Entrée".to_string(), + known_tracks: HashMap::new(), + } + } + + fn renderer_id(&self) -> Option { + self.renderer_info.as_ref().map(|info| info.id.clone()) + } + + fn draw(&self, terminal: &mut ratatui::Frame<'_>) { + match self.mode { + Mode::SelectRenderer => self.draw_renderer_selection(terminal), + Mode::SelectServer => self.draw_server_selection(terminal), + Mode::Browse => self.draw_browser(terminal), + _ => self.draw_control_screen(terminal), + } + + if self.show_queue_overlay { + self.draw_queue_overlay(terminal); + } + + if matches!(self.mode, Mode::BindingPrompt) { + self.draw_binding_prompt(terminal); + } + } + + fn draw_renderer_selection(&self, f: &mut ratatui::Frame<'_>) { + let area = f.size(); + let block = Block::default() + .borders(Borders::ALL) + .title("Sélection du renderer"); + let items: Vec = self + .renderers + .iter() + .map(|info| { + let text = format!("{} | {}", info.friendly_name, info.model_name); + ListItem::new(text) + }) + .collect(); + let list = List::new(items) + .block(block) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + let mut state = ListState::default(); + state.select(Some(self.renderer_index)); + f.render_stateful_widget(list, area, &mut state); + self.draw_status_line(f); + } + + fn draw_server_selection(&self, f: &mut ratatui::Frame<'_>) { + let area = f.size(); + let block = Block::default() + .borders(Borders::ALL) + .title("Sélection du serveur"); + let items: Vec = self + .servers + .iter() + .map(|info| { + let text = format!("{} | {}", info.friendly_name, info.model_name); + ListItem::new(text) + }) + .collect(); + let list = List::new(items) + .block(block) + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + let mut state = ListState::default(); + state.select(Some(self.server_index)); + f.render_stateful_widget(list, area, &mut state); + self.draw_status_line(f); + } + + fn draw_browser(&self, f: &mut ratatui::Frame<'_>) { + let area = f.size(); + let Some(browser) = &self.browser else { + self.draw_status_line(f); + return; + }; + + let block = Block::default().borders(Borders::ALL).title(Span::styled( + format!( + "Navigation: {} (id: {})", + browser.nav_state.current_container_title, browser.nav_state.current_container_id + ), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )); + + let items: Vec = browser + .entries + .iter() + .map(|entry| { + let icon = if entry.is_container { "📁" } else { "♪" }; + let label = format!("{} {}", icon, entry.title); + ListItem::new(label) + }) + .collect(); + + let list = List::new(items) + .block(block) + .highlight_style( + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + let mut state = ListState::default(); + state.select(Some(browser.selected_index)); + f.render_stateful_widget(list, area, &mut state); + self.draw_status_line(f); + } + + fn draw_control_screen(&self, f: &mut ratatui::Frame<'_>) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), + Constraint::Min(8), + Constraint::Length(3), + ]) + .split(f.size()); + + self.draw_header(f, chunks[0]); + self.draw_playback_panel(f, chunks[1]); + self.draw_help(f, chunks[2]); + + self.draw_status_line(f); + } + + fn draw_header(&self, f: &mut ratatui::Frame<'_>, area: Rect) { + let ui = &self.ui_state; + let renderer = &ui.renderer_name; + let server = ui.server_name.as_deref().unwrap_or(""); + let state = ui + .playback_state + .as_ref() + .map(|s| format!("{:?}", s)) + .unwrap_or_else(|| "Inconnu".to_string()); + let volume = ui + .volume + .map(|v| v.to_string()) + .unwrap_or_else(|| "--".to_string()); + let mute = match ui.mute { + Some(true) => "ON", + Some(false) => "OFF", + None => "??", + }; + + let text = vec![ + Line::from(vec![Span::styled( + format!("Renderer : {renderer}"), + Style::default().fg(Color::Yellow), + )]), + Line::from(vec![Span::raw(format!("Serveur : {server}"))]), + Line::from(vec![Span::raw(format!( + "État : {state} Volume {volume} | Mute {mute}" + ))]), + ]; + + let paragraph = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL).title("Statut")) + .alignment(Alignment::Left); + f.render_widget(paragraph, area); + } + + fn draw_playback_panel(&self, f: &mut ratatui::Frame<'_>, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(6), Constraint::Length(3)]) + .split(area); + + let meta_lines = render_metadata_block(self.ui_state.metadata.as_ref()); + let paragraph = Paragraph::new(meta_lines) + .block( + Block::default() + .borders(Borders::ALL) + .title("Lecture en cours"), + ) + .wrap(ratatui::widgets::Wrap { trim: true }); + f.render_widget(paragraph, chunks[0]); + + let gauge_area = chunks[1]; + let gauge = match self.ui_state.position.as_ref() { + Some(pos) => build_progress_gauge(pos), + None => Gauge::default() + .block(Block::default().borders(Borders::ALL).title("Progression")) + .label("en attente...") + .ratio(0.0), + }; + f.render_widget(gauge, gauge_area); + } + + fn draw_help(&self, f: &mut ratatui::Frame<'_>, area: Rect) { + let lines = vec![ + Line::from("Commandes: r=Play p=Pause s=Stop n=Next +/- Volume m=Mute"), + Line::from(" i=Infos k=Queue b=Binding h=Aide q=Quit"), + Line::from("Switch : R=Renderer menu | S=Serveur menu"), + ]; + let paragraph = + Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title("Aide")); + f.render_widget(paragraph, area); + } + + fn draw_queue_overlay(&self, f: &mut ratatui::Frame<'_>) { + let area = centered_rect(80, 70, f.size()); + let mut lines = Vec::new(); + lines.push(Line::from("Queue actuelle:")); + if self.queue_snapshot.is_empty() { + lines.push(Line::from(" ")); + } else { + for (idx, item) in self.queue_snapshot.iter().enumerate() { + let title = item.title.as_deref().unwrap_or(""); + let artist = item.artist.as_deref().unwrap_or(""); + let line = if artist.is_empty() { + format!("[{idx}] {title}") + } else { + format!("[{idx}] {artist} - {title}") + }; + lines.push(Line::from(line)); } - _ => { - println!("Invalid selection. Please enter a number between 0 and {}", renderers.len() - 1); + } + let block = Block::default() + .title("Queue (fermer avec k ou Esc)") + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)); + f.render_widget(Clear, area); + f.render_widget(Paragraph::new(lines).block(block), area); + } + + fn draw_binding_prompt(&self, f: &mut ratatui::Frame<'_>) { + let area = centered_rect(60, 40, f.size()); + let lines = vec![ + Line::from("Attacher la file au conteneur pour auto-refresh ?"), + Line::from("y = oui | n = non"), + ]; + let block = Block::default() + .title("Binding playlist") + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)); + f.render_widget(Clear, area); + f.render_widget(Paragraph::new(lines).block(block), area); + } + + fn draw_status_line(&self, f: &mut ratatui::Frame<'_>) { + let area = Rect { + x: 0, + y: f.size().height.saturating_sub(1), + width: f.size().width, + height: 1, + }; + let status = self + .ui_state + .last_status + .clone() + .unwrap_or_else(|| self.status_line.clone()); + let paragraph = Paragraph::new(status).style(Style::default().fg(Color::Gray)); + f.render_widget(paragraph, area); + } + + fn handle_key(&mut self, key: KeyEvent) -> Result { + match self.mode { + Mode::SelectRenderer => self.handle_renderer_key(key), + Mode::SelectServer => self.handle_server_key(key), + Mode::Browse => self.handle_browse_key(key), + Mode::BindingPrompt => self.handle_binding_key(key), + Mode::Control => self.handle_control_key(key), + } + } + + fn handle_renderer_key(&mut self, key: KeyEvent) -> Result { + match key.code { + KeyCode::Char('q') => return Ok(true), + KeyCode::Up => { + if self.renderer_index > 0 { + self.renderer_index -= 1; + } + } + KeyCode::Down => { + if self.renderer_index + 1 < self.renderers.len() { + self.renderer_index += 1; + } + } + KeyCode::Enter => { + let info = self.renderers[self.renderer_index].clone(); + if let Some(current) = &self.renderer_info { + if current.id != info.id { + let _ = self.stop_current_renderer_playback(); + } + } + self.ui_state = UiState::new(info.friendly_name.clone()); + self.renderer_info = Some(info); + self.server_info = None; + self.music_server = None; + self.browser = None; + self.queue_snapshot.clear(); + self.known_tracks.clear(); + self.pending_binding_container = None; + self.ui_state.current_track_uri = None; + self.ui_state.server_name = None; + self.show_queue_overlay = false; + self.mode = Mode::SelectServer; + self.status_line = "Sélectionne un serveur avec ↑/↓ et Entrée".to_string(); + } + _ => {} + } + Ok(false) + } + + fn handle_server_key(&mut self, key: KeyEvent) -> Result { + match key.code { + KeyCode::Char('q') => return Ok(true), + KeyCode::Up => { + if self.server_index > 0 { + self.server_index -= 1; + } + } + KeyCode::Down => { + if self.server_index + 1 < self.servers.len() { + self.server_index += 1; + } + } + KeyCode::Enter => { + let info = self.servers[self.server_index].clone(); + match MusicServer::from_info(&info, Duration::from_secs(DEFAULT_TIMEOUT_SECS)) { + Ok(server) => { + let entries = server.browse_root()?; + let browser = BrowserState::new(entries); + self.music_server = Some(server); + self.server_info = Some(info.clone()); + self.browser = Some(browser); + self.mode = Mode::Browse; + self.ui_state.server_name = Some(info.friendly_name.clone()); + self.status_line = + "Navigue avec ↑/↓, Entrée pour ouvrir, s pour sélectionner".to_string(); + } + Err(err) => { + self.ui_state + .set_status(format!("MusicServer init failed: {err}")); + } + } + } + _ => {} + } + Ok(false) + } + + fn handle_browse_key(&mut self, key: KeyEvent) -> Result { + if key.code == KeyCode::Char('q') { + return Ok(true); + } + let Some(browser) = self.browser.as_mut() else { + return Ok(false); + }; + let Some(server) = self.music_server.as_mut() else { + return Ok(false); + }; + + match key.code { + KeyCode::Up => { + if browser.selected_index > 0 { + browser.selected_index -= 1; + } + } + KeyCode::Down => { + if browser.selected_index + 1 < browser.entries.len() { + browser.selected_index += 1; + } + } + KeyCode::Enter => { + if let Some(entry) = browser.current_entry() { + if entry.is_container { + match server.browse_children(&entry.id, 0, 100) { + Ok(children) => { + browser + .nav_state + .enter_container(entry.id.clone(), entry.title.clone()); + browser.entries = children; + browser.selected_index = 0; + } + Err(err) => { + self.ui_state + .set_status(format!("Impossible d'ouvrir: {err}")); + } + } + } + } + } + KeyCode::Char('b') => { + if browser.nav_state.go_back() { + let entries = if browser.nav_state.current_container_id == "0" { + server.browse_root()? + } else { + server.browse_children(&browser.nav_state.current_container_id, 0, 100)? + }; + browser.entries = entries; + browser.selected_index = 0; + } + } + KeyCode::Char('s') => { + self.enqueue_current_container()?; + } + _ => {} + } + Ok(false) + } + + fn handle_binding_key(&mut self, key: KeyEvent) -> Result { + match key.code { + KeyCode::Char('y') => { + self.attach_binding(true)?; + } + KeyCode::Char('n') | KeyCode::Esc => { + self.attach_binding(false)?; + } + KeyCode::Char('q') => return Ok(true), + _ => {} + } + Ok(false) + } + + fn handle_control_key(&mut self, key: KeyEvent) -> Result { + match key.code { + KeyCode::Char('q') => return Ok(true), + KeyCode::Char('R') => { + self.open_renderer_menu(); + } + KeyCode::Char('S') => { + self.open_server_menu(); + } + KeyCode::Char('h') => { + self.ui_state.set_status("Aide affichée"); + } + KeyCode::Char('p') => { + self.pause_renderer()?; + } + KeyCode::Char('r') => { + self.resume_renderer()?; + } + KeyCode::Char('s') => { + self.stop_renderer()?; + } + KeyCode::Char('n') => { + self.play_next()?; + } + KeyCode::Char('+') | KeyCode::Char('=') => { + self.adjust_volume(5)?; + } + KeyCode::Char('-') => { + self.adjust_volume(-5)?; + } + KeyCode::Char('m') => { + self.toggle_mute()?; + } + KeyCode::Char('i') => { + self.show_renderer_info()?; + } + KeyCode::Char('k') => { + self.show_queue_overlay = !self.show_queue_overlay; + } + KeyCode::Char('b') => { + self.show_binding(); + } + KeyCode::Esc => { + self.show_queue_overlay = false; + } + _ => {} + } + Ok(false) + } + + fn enqueue_current_container(&mut self) -> Result<()> { + let Some(browser) = self.browser.as_ref() else { + return Ok(()); + }; + let current_container_id = browser.nav_state.current_container_id.clone(); + let entries = browser.entries.clone(); + let Some(server) = self.music_server.as_ref() else { + return Ok(()); + }; + let renderer_id = self + .renderer_id() + .ok_or_else(|| anyhow!("Renderer not selected"))?; + + let items = collect_playable_items(server, &entries)?; + if items.is_empty() { + self.ui_state.set_status("Aucun média dans ce conteneur"); + return Ok(()); + } + + self.control_point.clear_queue(&renderer_id)?; + self.control_point.enqueue_items(&renderer_id, items)?; + self.queue_snapshot = self.control_point.get_queue_snapshot(&renderer_id)?; + self.record_queue_metadata(); + self.pending_binding_container = Some(current_container_id); + self.status_line = format!("File prête ({})", self.queue_snapshot.len()); + self.ui_state.set_status(&self.status_line); + self.mode = Mode::BindingPrompt; + Ok(()) + } + + fn attach_binding(&mut self, attach: bool) -> Result<()> { + if attach { + if let (Some(server), Some(container), Some(renderer_id)) = ( + &self.server_info, + self.pending_binding_container.clone(), + self.renderer_id(), + ) { + self.control_point.attach_queue_to_playlist( + &renderer_id, + server.id.clone(), + container.clone(), + ); + self.ui_state + .set_status(format!("File liée à '{}'", container)); + } + } else { + self.ui_state.set_status("File locale uniquement"); + } + + self.pending_binding_container = None; + self.mode = Mode::Control; + self.start_playback()?; + Ok(()) + } + + fn start_playback(&mut self) -> Result<()> { + let renderer_id = self + .renderer_id() + .ok_or_else(|| anyhow!("Renderer not selected"))?; + let next_item = self.peek_next_queue_item(&renderer_id); + self.control_point.play_next_from_queue(&renderer_id)?; + if let Some(item) = next_item { + self.apply_item_as_current(&item); + } else { + self.ui_state.set_status("File vide"); + } + self.refresh_queue_snapshot(&renderer_id); + Ok(()) + } + + fn pause_renderer(&mut self) -> Result<()> { + let renderer = self.get_renderer()?; + renderer.pause()?; + self.ui_state.set_status("Lecture en pause"); + Ok(()) + } + + fn resume_renderer(&mut self) -> Result<()> { + let renderer = self.get_renderer()?; + renderer.play()?; + self.ui_state.set_status("Lecture reprise"); + Ok(()) + } + + fn stop_renderer(&mut self) -> Result<()> { + let renderer = self.get_renderer()?; + renderer.stop()?; + self.ui_state.set_status("Lecture arrêtée"); + Ok(()) + } + + fn play_next(&mut self) -> Result<()> { + let renderer_id = self + .renderer_id() + .ok_or_else(|| anyhow!("Renderer not selected"))?; + let next_item = self.peek_next_queue_item(&renderer_id); + self.control_point.play_next_from_queue(&renderer_id)?; + if let Some(item) = next_item { + self.apply_item_as_current(&item); + } else { + self.ui_state.set_status("Piste suivante (file vide)"); + } + self.refresh_queue_snapshot(&renderer_id); + Ok(()) + } + + fn adjust_volume(&mut self, delta: i32) -> Result<()> { + let renderer = self.get_renderer()?; + let current = renderer.volume()?; + let new_volume = (current as i32 + delta).clamp(0, 100) as u16; + renderer.set_volume(new_volume)?; + self.ui_state.set_status(format!("Volume → {new_volume}")); + self.ui_state.volume = Some(new_volume); + Ok(()) + } + + fn toggle_mute(&mut self) -> Result<()> { + let renderer = self.get_renderer()?; + let current = renderer.mute()?; + renderer.set_mute(!current)?; + self.ui_state.mute = Some(!current); + self.ui_state.set_status(if !current { + "Mute activé" + } else { + "Mute désactivé" + }); + Ok(()) + } + + fn show_renderer_info(&mut self) -> Result<()> { + let renderer = self.get_renderer()?; + let info = renderer.info(); + self.ui_state.set_status(format!( + "Renderer: {} ({})", + info.friendly_name, info.model_name + )); + Ok(()) + } + + fn show_binding(&mut self) { + if let Some(renderer_id) = self.renderer_id() { + if let Some((server_id, container_id, has_seen_update)) = self + .control_point + .current_queue_playlist_binding(&renderer_id) + { + self.ui_state.set_status(format!( + "Binding: {} -> {} (maj vue: {})", + server_id.0, container_id, has_seen_update + )); + } else { + self.ui_state.set_status("Pas de binding actif"); + } + } + } + + fn stop_current_renderer_playback(&self) -> Result<()> { + if let Some(renderer_id) = self.renderer_id() { + if let Some(renderer) = self.control_point.music_renderer_by_id(&renderer_id) { + renderer.stop()?; + } + } + Ok(()) + } + + fn open_renderer_menu(&mut self) { + self.mode = Mode::SelectRenderer; + self.status_line = "Sélectionne un renderer avec ↑/↓ et Entrée".to_string(); + self.ui_state.set_status("Menu renderer ouvert"); + self.show_queue_overlay = false; + } + + fn open_server_menu(&mut self) { + if self.renderer_info.is_some() { + self.mode = Mode::SelectServer; + self.status_line = "Sélectionne un serveur avec ↑/↓ et Entrée".to_string(); + self.ui_state.set_status("Menu serveur ouvert"); + self.show_queue_overlay = false; + } else { + self.ui_state.set_status("Sélectionne d'abord un renderer"); + } + } + + fn record_queue_metadata(&mut self) { + for item in &self.queue_snapshot { + if let Some(meta) = playback_metadata_from_item(item) { + self.known_tracks.insert(item.uri.clone(), meta); + } + } + } + + fn apply_item_as_current(&mut self, item: &PlaybackItem) { + if let Some(meta) = playback_metadata_from_item(item) { + self.known_tracks.insert(item.uri.clone(), meta.clone()); + self.ui_state.metadata = Some(meta.clone()); + self.ui_state.set_status(format_track_status(&meta)); + } else { + let label = item + .title + .as_deref() + .map(|t| t.to_string()) + .unwrap_or_else(|| item.uri.clone()); + self.ui_state.metadata = None; + self.ui_state.set_status(format!("Lecture: {label}")); + } + self.ui_state.current_track_uri = Some(item.uri.clone()); + } + + fn update_metadata_from_uri(&mut self, uri: &str) { + self.ui_state.current_track_uri = Some(uri.to_string()); + if let Some(meta) = self.known_tracks.get(uri).cloned() { + self.ui_state.metadata = Some(meta.clone()); + self.ui_state.set_status(format_track_status(&meta)); + } else { + self.ui_state.metadata = None; + self.ui_state.set_status(format!("Lecture: {uri}")); + } + } + + fn refresh_queue_snapshot(&mut self, renderer_id: &pmocontrol::model::RendererId) { + if let Ok(queue) = self.control_point.get_queue_snapshot(renderer_id) { + self.queue_snapshot = queue; + self.record_queue_metadata(); + } + } + + fn peek_next_queue_item( + &self, + renderer_id: &pmocontrol::model::RendererId, + ) -> Option { + self.control_point + .get_queue_snapshot(renderer_id) + .ok() + .and_then(|queue| queue.into_iter().next()) + } + + fn get_renderer(&self) -> Result { + let renderer_id = self + .renderer_id() + .ok_or_else(|| anyhow!("Renderer not selected"))?; + self.control_point + .music_renderer_by_id(&renderer_id) + .ok_or_else(|| anyhow!("Renderer not found")) + } + + fn handle_app_event(&mut self, event: AppEvent) { + match event { + AppEvent::Renderer(ev) => self.handle_renderer_event(ev), + AppEvent::Media(ev) => self.handle_media_event(ev), + } + } + + fn handle_renderer_event(&mut self, event: RendererEvent) { + let Some(renderer_id) = self.renderer_id() else { + return; + }; + match event { + RendererEvent::StateChanged { id, state } => { + if id == renderer_id { + self.ui_state.playback_state = Some(state.clone()); + self.ui_state.set_status(format!("État: {:?}", state)); + } + } + RendererEvent::PositionChanged { id, position } => { + if id == renderer_id { + let track_changed = + position.track_uri.as_ref() != self.ui_state.current_track_uri.as_ref(); + self.ui_state.position = Some(position.clone()); + if track_changed { + if let Some(uri) = position.track_uri.as_deref() { + self.update_metadata_from_uri(uri); + self.refresh_queue_snapshot(&renderer_id); + } else { + self.ui_state.current_track_uri = None; + } + } + } + } + RendererEvent::VolumeChanged { id, volume } => { + if id == renderer_id { + self.ui_state.volume = Some(volume); + self.ui_state.set_status(format!("Volume → {volume}")); + } + } + RendererEvent::MuteChanged { id, mute } => { + if id == renderer_id { + self.ui_state.mute = Some(mute); + self.ui_state.set_status(if mute { + "Mute activé" + } else { + "Mute désactivé" + }); + } + } + RendererEvent::MetadataChanged { id, metadata } => { + if id == renderer_id { + self.ui_state.metadata = Some(metadata.clone()); + self.ui_state.set_status(format_track_status(&metadata)); + if let Some(uri) = self.ui_state.current_track_uri.clone() { + self.known_tracks.insert(uri, metadata); + } + } + } + } + } + + fn handle_media_event(&mut self, event: MediaServerEvent) { + match event { + MediaServerEvent::GlobalUpdated { + server_id, + system_update_id, + } => { + self.ui_state.set_status(format!( + "MAJ serveur {} (SystemUpdateID={})", + server_id.0, + system_update_id.unwrap_or(0) + )); + } + MediaServerEvent::ContainersUpdated { + server_id, + container_ids, + } => { + let mut status = format!( + "Conteneurs mis à jour sur {}: {:?}", + server_id.0, container_ids + ); + if let Some(renderer_id) = self.renderer_id() { + if let Some((bound_server, bound_container, _)) = self + .control_point + .current_queue_playlist_binding(&renderer_id) + { + if bound_server == server_id && container_ids.contains(&bound_container) { + status + .push_str(&format!(" | Playlist '{}' rafraîchie", bound_container)); + self.refresh_queue_snapshot(&renderer_id); + } + } + } + self.ui_state.set_status(status); } } } } -/// Interactive server selection. -fn select_server(servers: &[MediaServerInfo]) -> Result { - loop { - print!("\nSelect media server (index 0-{}): ", servers.len() - 1); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - - match input.trim().parse::() { - Ok(idx) if idx < servers.len() => { - return Ok(servers[idx].clone()); - } - _ => { - println!("Invalid selection. Please enter a number between 0 and {}", servers.len() - 1); - } +impl BrowserState { + fn new(entries: Vec) -> Self { + Self { + nav_state: NavigationState::new("0".to_string(), "Root".to_string()), + entries, + selected_index: 0, } } + + fn current_entry(&self) -> Option<&MediaEntry> { + self.entries.get(self.selected_index) + } +} + +fn run_app(mut app: App) -> Result<()> { + let mut terminal = setup_terminal()?; + let (event_tx, event_rx) = mpsc::channel(); + let mut renderer_events_started = false; + let mut last_tick = Instant::now(); + + loop { + terminal.draw(|f| app.draw(f))?; + + while let Ok(event) = event_rx.try_recv() { + app.handle_app_event(event); + } + + if !renderer_events_started { + if let Some(renderer_id) = app.renderer_id() { + start_event_threads(Arc::clone(&app.control_point), event_tx.clone()); + renderer_events_started = true; + } + } + + let timeout = TICK_RATE + .checked_sub(last_tick.elapsed()) + .unwrap_or_else(|| Duration::from_secs(0)); + + if event::poll(timeout)? { + if let Event::Key(key) = event::read()? { + if app.handle_key(key)? { + let _ = app.stop_current_renderer_playback(); + break; + } + } + } + + if last_tick.elapsed() >= TICK_RATE { + last_tick = Instant::now(); + } + } + + restore_terminal(&mut terminal)?; + Ok(()) +} + +fn setup_terminal() -> Result>> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend)?; + Ok(terminal) +} + +fn restore_terminal(terminal: &mut Terminal>) -> Result<()> { + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + Ok(()) +} + +fn start_event_threads(control_point: Arc, tx: Sender) { + let renderer_cp = Arc::clone(&control_point); + let renderer_tx = tx.clone(); + thread::spawn(move || { + let event_rx = renderer_cp.subscribe_events(); + for event in event_rx { + if renderer_tx.send(AppEvent::Renderer(event)).is_err() { + break; + } + } + }); + + thread::spawn(move || { + let media_rx = control_point.subscribe_media_server_events(); + for event in media_rx { + if tx.send(AppEvent::Media(event)).is_err() { + break; + } + } + }); } /// Navigation state for ContentDirectory browsing. struct NavigationState { - /// Stack of (container_id, container_title) for back navigation. path_stack: Vec<(String, String)>, - /// Current container ID being browsed. current_container_id: String, - /// Current container title. current_container_title: String, } @@ -236,116 +1140,29 @@ impl NavigationState { } } -/// Navigate ContentDirectory and let user select a container for queue. -fn navigate_and_select(server: &MusicServer) -> Result<(Vec, Option)> { - let root_entries = server.browse_root() - .context("Failed to browse root")?; - - let mut nav_state = NavigationState::new("0".to_string(), "Root".to_string()); - - loop { - println!("\n--- Browsing: {} (id: {}) ---", - nav_state.current_container_title, nav_state.current_container_id); - - let entries = if nav_state.current_container_id == "0" { - root_entries.clone() - } else { - server.browse_children(&nav_state.current_container_id, 0, 100) - .context("Failed to browse container")? - }; - - if entries.is_empty() { - println!("(empty container)"); - } else { - print_entries(&entries); - } - - print!("\nCommand [0-{} to enter container / b=back / s=select / q=quit]: ", - entries.len().saturating_sub(1)); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - let input = input.trim(); - - match input { - "q" => { - println!("Navigation cancelled."); - return Ok((Vec::new(), None)); - } - "b" => { - if !nav_state.go_back() { - println!("Already at root."); - } - } - "s" => { - // Select current container - println!("Collecting items from '{}'...", nav_state.current_container_title); - let items = collect_playable_items(server, &entries)?; - if items.is_empty() { - println!("No playable items found in this container."); - continue; - } - return Ok((items, Some(nav_state.current_container_id.clone()))); - } - _ => { - // Try to parse as index - match input.parse::() { - Ok(idx) if idx < entries.len() => { - let entry = &entries[idx]; - if entry.is_container { - nav_state.enter_container(entry.id.clone(), entry.title.clone()); - } else { - println!("Entry {} is an audio item, not a container. Use 's' to select the current container for playback.", idx); - } - } - _ => { - println!("Invalid command. Enter a number (0-{}), 'b' to go back, 's' to select, or 'q' to quit.", - entries.len().saturating_sub(1)); - } - } - } - } - } -} - -/// Print MediaEntry list. -fn print_entries(entries: &[MediaEntry]) { - for (idx, entry) in entries.iter().enumerate() { - if entry.is_container { - println!(" [{}] \u{1F4C1} {} (id: {}, class: {})", - idx, entry.title, entry.id, entry.class); - } else { - let has_audio = entry.resources.iter().any(is_audio_resource); - let icon = if has_audio { "\u{266B}" } else { "\u{1F4C4}" }; - println!(" [{}] {} {} (id: {}, class: {})", - idx, icon, entry.title, entry.id, entry.class); - } - } -} - /// Collect playable items from MediaEntry list (including nested containers). -fn collect_playable_items(server: &MusicServer, entries: &[MediaEntry]) -> Result> { +fn collect_playable_items( + server: &MusicServer, + entries: &[MediaEntry], +) -> Result> { let mut items = Vec::new(); - for entry in entries { if entry.is_container { - // Recursively collect from container match server.browse_children(&entry.id, 0, 100) { Ok(children) => { items.extend(collect_playable_items(server, &children)?); } Err(err) => { - eprintln!("Warning: failed to browse container '{}': {}", entry.title, err); + eprintln!( + "Warning: failed to browse container '{}': {err}", + entry.title + ); } } - } else { - if let Some(item) = playback_item_from_entry(server, entry) { - items.push(item); - } + } else if let Some(item) = playback_item_from_entry(server, entry) { + items.push(item); } } - Ok(items) } @@ -366,6 +1183,33 @@ fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option< Some(item) } +fn playback_metadata_from_item(item: &PlaybackItem) -> Option { + let metadata = TrackMetadata { + title: item.title.clone(), + artist: item.artist.clone(), + album: item.album.clone(), + genre: item.genre.clone(), + album_art_uri: item.album_art_uri.clone(), + date: item.date.clone(), + track_number: item.track_number.clone(), + creator: item.creator.clone(), + }; + + if metadata.title.is_none() + && metadata.artist.is_none() + && metadata.album.is_none() + && metadata.genre.is_none() + && metadata.album_art_uri.is_none() + && metadata.date.is_none() + && metadata.track_number.is_none() + && metadata.creator.is_none() + { + return None; + } + + Some(metadata) +} + /// Check if MediaResource is audio. fn is_audio_resource(res: &MediaResource) -> bool { let lower = res.protocol_info.to_ascii_lowercase(); @@ -379,415 +1223,130 @@ fn is_audio_resource(res: &MediaResource) -> bool { .unwrap_or(false) } -/// Read yes/no from stdin. -fn read_yes_no() -> Result { - loop { - print!("> "); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - - match input.trim().to_lowercase().as_str() { - "y" | "yes" => return Ok(true), - "n" | "no" => return Ok(false), - _ => println!("Please enter 'y' or 'n'"), +fn render_metadata_block(metadata: Option<&TrackMetadata>) -> Vec> { + let mut lines = Vec::new(); + if let Some(meta) = metadata { + let title = meta + .title + .clone() + .unwrap_or_else(|| "".to_string()); + lines.push(Line::from(format!("Titre : {title}"))); + if let Some(artist) = meta.artist.as_deref() { + lines.push(Line::from(format!("Artiste: {artist}"))); } - } -} - -/// Spawn thread to display renderer events in real-time. -fn spawn_renderer_event_thread( - control_point: Arc, - renderer_id: pmocontrol::model::RendererId, -) { - let event_rx = control_point.subscribe_events(); - - thread::spawn(move || { - loop { - match event_rx.recv() { - Ok(event) => { - match event { - RendererEvent::StateChanged { id, state } => { - if id == renderer_id { - println!("\n[EVENT] Renderer state changed: {:?}", state); - print!("> "); - io::stdout().flush().ok(); - } - } - RendererEvent::PositionChanged { id, position } => { - if id == renderer_id { - let rel = position.rel_time.as_deref().unwrap_or("-"); - let dur = position.track_duration.as_deref().unwrap_or("-"); - println!("\n[EVENT] Position: {} / {}", rel, dur); - print!("> "); - io::stdout().flush().ok(); - } - } - RendererEvent::VolumeChanged { id, volume } => { - if id == renderer_id { - println!("\n[EVENT] Volume changed: {}", volume); - print!("> "); - io::stdout().flush().ok(); - } - } - RendererEvent::MuteChanged { id, mute } => { - if id == renderer_id { - println!("\n[EVENT] Mute changed: {}", mute); - print!("> "); - io::stdout().flush().ok(); - } - } - RendererEvent::MetadataChanged { id, metadata } => { - if id == renderer_id { - println!("\n[EVENT] Metadata changed:"); - if let Some(title) = &metadata.title { - println!(" Title: {}", title); - } - if let Some(artist) = &metadata.artist { - println!(" Artist: {}", artist); - } - if let Some(album) = &metadata.album { - println!(" Album: {}", album); - } - if let Some(genre) = &metadata.genre { - println!(" Genre: {}", genre); - } - if let Some(date) = &metadata.date { - println!(" Date: {}", date); - } - if let Some(track_number) = &metadata.track_number { - println!(" Track: {}", track_number); - } - if let Some(album_art_uri) = &metadata.album_art_uri { - println!(" Album Art: {}", album_art_uri); - } - print!("> "); - io::stdout().flush().ok(); - } - } - } - } - Err(_) => { - eprintln!("\n[EVENT] Renderer event channel closed"); - break; - } - } + if let Some(album) = meta.album.as_deref() { + lines.push(Line::from(format!("Album : {album}"))); } - }); -} - -/// Spawn thread to display media server events in real-time. -fn spawn_media_server_event_thread( - control_point: Arc, - renderer_id: pmocontrol::model::RendererId, -) { - let media_event_rx = control_point.subscribe_media_server_events(); - - thread::spawn(move || { - loop { - match media_event_rx.recv() { - Ok(event) => { - match event { - MediaServerEvent::GlobalUpdated { server_id, system_update_id } => { - println!("\n[MEDIA EVENT] Server {} global update (SystemUpdateID={})", - server_id.0, system_update_id.unwrap_or(0)); - print!("> "); - io::stdout().flush().ok(); - } - MediaServerEvent::ContainersUpdated { server_id, container_ids } => { - println!("\n[MEDIA EVENT] Server {} containers updated: {:?}", - server_id.0, container_ids); - - // Check if bound container was updated - if let Some((bound_server, bound_container, _)) = - control_point.current_queue_playlist_binding(&renderer_id) - { - if bound_server == server_id && container_ids.contains(&bound_container) { - println!("[MEDIA EVENT] → Bound playlist '{}' was updated!", bound_container); - } - } - - print!("> "); - io::stdout().flush().ok(); - } - } - } - Err(_) => { - eprintln!("\n[MEDIA EVENT] Media server event channel closed"); - break; - } - } + if let Some(genre) = meta.genre.as_deref() { + lines.push(Line::from(format!("Genre : {genre}"))); } - }); -} - -/// Print help message for control commands. -fn print_help() { - println!("Available commands:"); - println!(" p - Pause"); - println!(" r - Resume/Play"); - println!(" s - Stop"); - println!(" n - Next track"); - println!(" + - Volume +5"); - println!(" - - Volume -5"); - println!(" m - Toggle mute"); - println!(" i - Show renderer info (state, position, volume)"); - println!(" k - Show current queue"); - println!(" b - Show playlist binding"); - println!(" h - Show this help"); - println!(" q - Quit gracefully"); - println!(" Q - Quit immediately"); -} - -/// Main interactive control loop. -fn run_control_loop( - control_point: Arc, - renderer_id: pmocontrol::model::RendererId, -) -> Result<()> { - let stdin = io::stdin(); - let mut reader = stdin.lock(); - - loop { - print!("> "); - io::stdout().flush()?; - - let mut line = String::new(); - reader.read_line(&mut line)?; - let cmd = line.trim(); - - if cmd.is_empty() { - continue; + if let Some(date) = meta.date.as_deref() { + lines.push(Line::from(format!("Date : {date}"))); } - - match cmd { - "q" => { - println!("Quitting..."); - break; - } - "Q" => { - println!("Quitting immediately!"); - process::exit(0); - } - "h" => { - print_help(); - } - "p" => { - if let Err(err) = pause_renderer(&control_point, &renderer_id) { - eprintln!("Pause failed: {}", err); - } else { - println!("✓ Paused"); - } - } - "r" => { - if let Err(err) = resume_renderer(&control_point, &renderer_id) { - eprintln!("Resume failed: {}", err); - } else { - println!("✓ Resumed"); - } - } - "s" => { - if let Err(err) = stop_renderer(&control_point, &renderer_id) { - eprintln!("Stop failed: {}", err); - } else { - println!("✓ Stopped"); - } - } - "n" => { - if let Err(err) = control_point.play_next_from_queue(&renderer_id) { - eprintln!("Next track failed: {}", err); - } else { - println!("✓ Playing next track"); - } - } - "+" => { - if let Err(err) = adjust_volume(&control_point, &renderer_id, 5) { - eprintln!("Volume adjustment failed: {}", err); - } else { - println!("✓ Volume +5"); - } - } - "-" => { - if let Err(err) = adjust_volume(&control_point, &renderer_id, -5) { - eprintln!("Volume adjustment failed: {}", err); - } else { - println!("✓ Volume -5"); - } - } - "m" => { - if let Err(err) = toggle_mute(&control_point, &renderer_id) { - eprintln!("Mute toggle failed: {}", err); - } else { - println!("✓ Mute toggled"); - } - } - "i" => { - if let Err(err) = show_renderer_info(&control_point, &renderer_id) { - eprintln!("Failed to get renderer info: {}", err); - } - } - "k" => { - if let Err(err) = show_queue(&control_point, &renderer_id) { - eprintln!("Failed to get queue: {}", err); - } - } - "b" => { - show_binding(&control_point, &renderer_id); - } - _ => { - println!("Unknown command '{}'. Type 'h' for help.", cmd); - } + if let Some(track) = meta.track_number.as_deref() { + lines.push(Line::from(format!("Piste : {track}"))); } - } - - Ok(()) -} - -/// Pause the renderer. -fn pause_renderer( - control_point: &ControlPoint, - renderer_id: &pmocontrol::model::RendererId, -) -> Result<()> { - let renderer = control_point.music_renderer_by_id(renderer_id) - .ok_or_else(|| anyhow!("Renderer not found"))?; - renderer.pause()?; - Ok(()) -} - -/// Resume/play the renderer. -fn resume_renderer( - control_point: &ControlPoint, - renderer_id: &pmocontrol::model::RendererId, -) -> Result<()> { - let renderer = control_point.music_renderer_by_id(renderer_id) - .ok_or_else(|| anyhow!("Renderer not found"))?; - renderer.play()?; - Ok(()) -} - -/// Stop the renderer. -fn stop_renderer( - control_point: &ControlPoint, - renderer_id: &pmocontrol::model::RendererId, -) -> Result<()> { - let renderer = control_point.music_renderer_by_id(renderer_id) - .ok_or_else(|| anyhow!("Renderer not found"))?; - renderer.stop()?; - Ok(()) -} - -/// Adjust volume by delta (-100 to +100). -fn adjust_volume( - control_point: &ControlPoint, - renderer_id: &pmocontrol::model::RendererId, - delta: i32, -) -> Result<()> { - let renderer = control_point.music_renderer_by_id(renderer_id) - .ok_or_else(|| anyhow!("Renderer not found"))?; - - let current = renderer.volume()?; - let new_volume = (current as i32 + delta).clamp(0, 100) as u16; - renderer.set_volume(new_volume)?; - - Ok(()) -} - -/// Toggle mute. -fn toggle_mute( - control_point: &ControlPoint, - renderer_id: &pmocontrol::model::RendererId, -) -> Result<()> { - let renderer = control_point.music_renderer_by_id(renderer_id) - .ok_or_else(|| anyhow!("Renderer not found"))?; - - let current_mute = renderer.mute()?; - renderer.set_mute(!current_mute)?; - - Ok(()) -} - -/// Show renderer info (state, position, volume, mute). -fn show_renderer_info( - control_point: &ControlPoint, - renderer_id: &pmocontrol::model::RendererId, -) -> Result<()> { - let renderer = control_point.music_renderer_by_id(renderer_id) - .ok_or_else(|| anyhow!("Renderer not found"))?; - - println!("\n=== Renderer Info ==="); - println!("Name: {}", renderer.info().friendly_name); - - match renderer.playback_state() { - Ok(state) => println!("State: {:?}", state), - Err(err) => println!("State: ", err), - } - - match renderer.playback_position() { - Ok(pos) => { - let rel = pos.rel_time.as_deref().unwrap_or("-"); - let dur = pos.track_duration.as_deref().unwrap_or("-"); - println!("Position: {} / {}", rel, dur); - } - Err(err) => println!("Position: ", err), - } - - match renderer.volume() { - Ok(vol) => println!("Volume: {}", vol), - Err(err) => println!("Volume: ", err), - } - - match renderer.mute() { - Ok(mute) => println!("Mute: {}", mute), - Err(err) => println!("Mute: ", err), - } - - Ok(()) -} - -/// Show current queue. -fn show_queue( - control_point: &ControlPoint, - renderer_id: &pmocontrol::model::RendererId, -) -> Result<()> { - let queue = control_point.get_queue_snapshot(renderer_id)?; - - println!("\n=== Queue ({} items) ===", queue.len()); - if queue.is_empty() { - println!(" "); } else { - for (idx, item) in queue.iter().enumerate() { - let title = item.title.as_deref().unwrap_or(""); - let artist = item.artist.as_deref().unwrap_or(""); - let album = item.album.as_deref().unwrap_or(""); - - if !artist.is_empty() && !album.is_empty() { - println!(" [{}] {} - {} ({})", idx, artist, title, album); - } else if !artist.is_empty() { - println!(" [{}] {} - {}", idx, artist, title); - } else { - println!(" [{}] {}", idx, title); - } - } + lines.push(Line::from("(En attente des métadonnées...)")); } - - Ok(()) + lines } -/// Show playlist binding info. -fn show_binding( - control_point: &ControlPoint, - renderer_id: &pmocontrol::model::RendererId, -) { - match control_point.current_queue_playlist_binding(renderer_id) { - Some((server_id, container_id, has_seen_update)) => { - println!("\n=== Playlist Binding ==="); - println!("Server ID: {}", server_id.0); - println!("Container ID: {}", container_id); - println!("Has seen update: {}", has_seen_update); - } - None => { - println!("\n=== Playlist Binding ==="); - println!(" "); - } +fn build_progress_gauge(position: &PlaybackPositionInfo) -> Gauge<'static> { + let rel_secs = position.rel_time.as_deref().and_then(parse_time_to_seconds); + let dur_secs = position + .track_duration + .as_deref() + .and_then(parse_time_to_seconds); + + let ratio = match (rel_secs, dur_secs) { + (Some(rel), Some(dur)) if dur > 0 => rel as f64 / dur as f64, + _ => 0.0, + }; + let ratio = ratio.clamp(0.0, 1.0); + + let rel_label = position + .rel_time + .clone() + .unwrap_or_else(|| "--:--".to_string()); + let dur_label = position + .track_duration + .clone() + .unwrap_or_else(|| "--:--".to_string()); + + let label = format!("{} / {}", rel_label, dur_label); + Gauge::default() + .block(Block::default().borders(Borders::ALL).title("Progression")) + .gauge_style(Style::default().fg(Color::Magenta)) + .ratio(ratio) + .label(label) +} + +fn format_track_status(meta: &TrackMetadata) -> String { + let title = meta.title.as_deref().unwrap_or(""); + let artist = meta.artist.as_deref().unwrap_or(""); + if artist.is_empty() { + format!("Lecture: {title}") + } else { + format!("Lecture: {artist} - {title}") } } + +fn parse_time_to_seconds(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed == "-" { + return None; + } + if let Ok(seconds) = trimmed.parse::() { + return Some(seconds); + } + + let parts: Vec<&str> = trimmed.split(':').collect(); + match parts.len() { + 3 => { + let hours = parts[0].parse::().ok()?; + let minutes = parts[1].parse::().ok()?; + let seconds = parse_seconds(parts[2])?; + Some(hours * 3600 + minutes * 60 + seconds) + } + 2 => { + let minutes = parts[0].parse::().ok()?; + let seconds = parse_seconds(parts[1])?; + Some(minutes * 60 + seconds) + } + _ => None, + } +} + +fn parse_seconds(fragment: &str) -> Option { + fragment + .split('.') + .next() + .and_then(|s| s.parse::().ok()) +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ] + .as_ref(), + ) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ] + .as_ref(), + ) + .split(popup_layout[1])[1] +} diff --git a/pmocontrol/examples/live_pmomusic_demo.rs b/pmocontrol/examples/live_pmomusic_demo.rs index 2568c706..f9fdafec 100644 --- a/pmocontrol/examples/live_pmomusic_demo.rs +++ b/pmocontrol/examples/live_pmomusic_demo.rs @@ -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("") ); } @@ -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 = VecDeque::new(); @@ -412,7 +425,10 @@ fn find_live_playlist_container(server: &MusicServer) -> Result= 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 Result<()> { Ok(()) } -fn print_event(cp: &ControlPoint, cache: &mut HashMap, event: &MediaServerEvent) { +fn print_event( + cp: &ControlPoint, + cache: &mut HashMap, + event: &MediaServerEvent, +) { match event { MediaServerEvent::GlobalUpdated { server_id, diff --git a/pmocontrol/examples/queue_pmomusic_demo.rs b/pmocontrol/examples/queue_pmomusic_demo.rs index acd538ef..e6bf56a0 100644 --- a/pmocontrol/examples/queue_pmomusic_demo.rs +++ b/pmocontrol/examples/queue_pmomusic_demo.rs @@ -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); diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index 25a9fb77..2eb19a7b 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -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}; @@ -90,11 +90,11 @@ impl ControlPoint { // ACTIVE DISCOVERY : envoyer quelques M-SEARCH au démarrage // pour forcer les devices à répondre rapidement. let search_targets = [ - "ssdp:all", - "urn:schemas-upnp-org:device:MediaRenderer:1", - "urn:av-openhome-org:device:MediaRenderer:1", - "urn:schemas-upnp-org:device:MediaServer:1", - "urn:schemas-wiimu-com:service:PlayQueue:1", // <-- AJOUTER + "ssdp:all", + "urn:schemas-upnp-org:device:MediaRenderer:1", + "urn:av-openhome-org:device:MediaRenderer:1", + "urn:schemas-upnp-org:device:MediaServer:1", + "urn:schemas-wiimu-com:service:PlayQueue:1", // <-- AJOUTER ]; for st in &search_targets { @@ -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::>(); 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, bindings: &Arc>>, 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(()) diff --git a/pmocontrol/src/lib.rs b/pmocontrol/src/lib.rs index 569d8ce0..ac3a205a 100644 --- a/pmocontrol/src/lib.rs +++ b/pmocontrol/src/lib.rs @@ -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}; diff --git a/pmocontrol/src/media_server_events.rs b/pmocontrol/src/media_server_events.rs index 997d9b10..8c7e9b47 100644 --- a/pmocontrol/src/media_server_events.rs +++ b/pmocontrol/src/media_server_events.rs @@ -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::(); thread::Builder::new() @@ -85,10 +82,7 @@ fn run_http_listener(listener: TcpListener, notify_tx: Sender) { 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 { 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(), @@ -392,7 +387,7 @@ impl MediaServerEventWorker { .get("TIMEOUT") .and_then(|value| value.to_str().ok()), ) - .unwrap_or(Duration::from_secs(SUBSCRIPTION_TIMEOUT_SECS)); + .unwrap_or(Duration::from_secs(SUBSCRIPTION_TIMEOUT_SECS)); entry.sid = Some(sid); entry.expires_at = Some(Instant::now() + timeout); @@ -441,7 +436,7 @@ impl MediaServerEventWorker { .get("TIMEOUT") .and_then(|value| value.to_str().ok()), ) - .unwrap_or(Duration::from_secs(SUBSCRIPTION_TIMEOUT_SECS)); + .unwrap_or(Duration::from_secs(SUBSCRIPTION_TIMEOUT_SECS)); entry.expires_at = Some(Instant::now() + timeout); debug!( server = entry.info.friendly_name.as_str(), @@ -629,22 +624,14 @@ fn parse_notify_payload(server_id: &ServerId, body: &[u8]) -> Vec = None; let mut container_ids: Vec = 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 { XMLNode::Element(elem) => Some(elem), _ => None, - }) - { - 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 { .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 { 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) }; diff --git a/pmocontrol/src/model.rs b/pmocontrol/src/model.rs index 98b362bd..bad94ea7 100644 --- a/pmocontrol/src/model.rs +++ b/pmocontrol/src/model.rs @@ -95,6 +95,10 @@ pub enum RendererEvent { id: RendererId, metadata: TrackMetadata, }, + QueueUpdated { + id: RendererId, + queue_length: usize, + }, } #[derive(Clone, Debug)] diff --git a/pmocontrol/src/openapi.rs b/pmocontrol/src/openapi.rs new file mode 100644 index 00000000..30551a97 --- /dev/null +++ b/pmocontrol/src/openapi.rs @@ -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, + /// Durée totale en millisecondes + pub duration_ms: Option, + /// Volume (0-100) + pub volume: Option, + /// Mute actif + pub mute: Option, + /// Nombre d'items dans la queue + pub queue_len: usize, + /// Playlist attachée (si applicable) + pub attached_playlist: Option, +} + +/// 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, + /// Artiste + pub artist: Option, + /// Album + pub album: Option, + /// ID du serveur source + pub server_id: Option, + /// ID de l'objet DIDL-Lite + pub object_id: Option, +} + +/// 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, +} + +// ============================================================================ +// 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, + /// Artiste (si item audio) + pub artist: Option, + /// Album (si item audio) + pub album: Option, + /// URI de la pochette d'album + pub album_art_uri: Option, +} + +/// 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, +} + +// ============================================================================ +// 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; diff --git a/pmocontrol/src/pmoserver_ext.rs b/pmocontrol/src/pmoserver_ext.rs new file mode 100644 index 00000000..7f62eec3 --- /dev/null +++ b/pmocontrol/src/pmoserver_ext.rs @@ -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, +} + +#[cfg(feature = "pmoserver")] +impl ControlPointState { + pub fn new(control_point: Arc) -> 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) + ), + tag = "control" +)] +async fn list_renderers( + State(state): State, +) -> Json> { + let renderers = state.control_point.list_music_renderers(); + + let summaries: Vec = 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, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + 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, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + 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 = 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), + (status = 404, description = "Renderer non trouvé", body = ErrorResponse) + ), + tag = "control" +)] +async fn get_renderer_binding( + State(state): State, + Path(renderer_id): Path, +) -> Result>, (StatusCode, Json)> { + 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, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + 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, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + 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, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + 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, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + 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, + Path(renderer_id): Path, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + 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, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + 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, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + 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, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + 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, + Path(renderer_id): Path, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + 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, + Path(renderer_id): Path, +) -> Json { + 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) + ), + tag = "control" +)] +async fn list_servers( + State(state): State, +) -> Json> { + let servers = state.control_point.list_media_servers(); + + let summaries: Vec = 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, + Path((server_id, container_id)): Path<(String, String)>, +) -> Result, (StatusCode, Json)> { + 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 = 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 { + 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) -> 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); +} + +#[cfg(feature = "pmoserver")] +#[async_trait] +impl ControlPointExt for pmoserver::Server { + async fn init_control_point(&mut self, control_point: Arc) { + 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; + } +} diff --git a/pmocontrol/src/sse.rs b/pmocontrol/src/sse.rs new file mode 100644 index 00000000..67b7394f --- /dev/null +++ b/pmocontrol/src/sse.rs @@ -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, + }, + PositionChanged { + renderer_id: String, + track: Option, + rel_time: Option, + track_duration: Option, + timestamp: chrono::DateTime, + }, + VolumeChanged { + renderer_id: String, + volume: u16, + timestamp: chrono::DateTime, + }, + MuteChanged { + renderer_id: String, + mute: bool, + timestamp: chrono::DateTime, + }, + MetadataChanged { + renderer_id: String, + title: Option, + artist: Option, + album: Option, + album_art_uri: Option, + timestamp: chrono::DateTime, + }, + QueueUpdated { + renderer_id: String, + queue_length: usize, + timestamp: chrono::DateTime, + }, +} + +/// 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, + timestamp: chrono::DateTime, + }, + ContainersUpdated { + server_id: String, + container_ids: Vec, + timestamp: chrono::DateTime, + }, +} + +/// 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>, +) -> 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>, +) -> 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>, +) -> 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) -> 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, + } +} diff --git a/pmomediaserver/src/contentdirectory/state.rs b/pmomediaserver/src/contentdirectory/state.rs index 7ee8227d..1b64c8ea 100644 --- a/pmomediaserver/src/contentdirectory/state.rs +++ b/pmomediaserver/src/contentdirectory/state.rs @@ -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}, diff --git a/pmomediaserver/src/paradise_streaming.rs b/pmomediaserver/src/paradise_streaming.rs index b845f74c..7d132870 100644 --- a/pmomediaserver/src/paradise_streaming.rs +++ b/pmomediaserver/src/paradise_streaming.rs @@ -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}; diff --git a/pmoupnp/src/devices/device_instance.rs b/pmoupnp/src/devices/device_instance.rs index 20314d03..5e626853 100644 --- a/pmoupnp/src/devices/device_instance.rs +++ b/pmoupnp/src/devices/device_instance.rs @@ -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}; diff --git a/pmoupnp/src/services/service_instance.rs b/pmoupnp/src/services/service_instance.rs index fdaa3c21..efc8db11 100644 --- a/pmoupnp/src/services/service_instance.rs +++ b/pmoupnp/src/services/service_instance.rs @@ -125,7 +125,6 @@ pub struct ServiceInstance { seqid: Arc>>, } - impl std::fmt::Debug for ServiceInstance { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ServiceInstance") diff --git a/pmoupnp/src/soap/builder.rs b/pmoupnp/src/soap/builder.rs index 8d952818..5eb1a62e 100644 --- a/pmoupnp/src/soap/builder.rs +++ b/pmoupnp/src/soap/builder.rs @@ -29,7 +29,6 @@ fn build_soap_envelope_with_body(body_child: Element) -> Result Option { }; 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 diff --git a/pmoupnp/src/upnp_server.rs b/pmoupnp/src/upnp_server.rs index 2813464d..2e3e0ad0 100644 --- a/pmoupnp/src/upnp_server.rs +++ b/pmoupnp/src/upnp_server.rs @@ -290,7 +290,7 @@ impl UpnpServerExt for Server { limit: usize, ) -> Result, anyhow::Error> { // Délègue à l'implémentation pmocovers (qui enregistre WebP + JPEG + API) - + let cache = pmocovers::CoverCacheExt::init_cover_cache(self, cache_dir, limit).await?; Ok(cache) }