From 64450185c55828d0935260da050a12e8a5ce7469 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 28 Dec 2025 17:45:06 +0100 Subject: [PATCH 1/4] =?UTF-8?q?=C3=A9norme=20refactoring=20de=20PMOcontrol?= =?UTF-8?q?=20step=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 50 +- Cargo.lock | 13 + pmoaudiocache/src/track_metadata.rs | 4 +- pmocontrol/examples/arylic_tcp_demo.rs | 6 +- pmocontrol/examples/discover.rs | 6 +- .../examples/full_control_point_demo.rs | 28 +- pmocontrol/examples/linkplay_demo.rs | 6 +- pmocontrol/examples/live_pmomusic_demo.rs | 20 +- .../examples/media_server_events_demo.rs | 8 +- pmocontrol/examples/openhome_tester.rs | 4 +- pmocontrol/examples/queue_pmomusic_demo.rs | 20 +- pmocontrol/examples/renderer_demo.rs | 22 +- pmocontrol/src/arylic_tcp.rs | 196 +++--- pmocontrol/src/avtransport_client.rs | 105 ++- pmocontrol/src/capabilities.rs | 24 +- pmocontrol/src/chromecast_renderer.rs | 351 +++++----- pmocontrol/src/connection_manager_client.rs | 131 +--- pmocontrol/src/control_point.rs | 275 ++++---- pmocontrol/src/discovery.rs | 228 ------- .../{ => discovery}/chromecast_discovery.rs | 65 +- pmocontrol/src/discovery/manager.rs | 40 ++ pmocontrol/src/discovery/mod.rs | 6 + pmocontrol/src/discovery/upnp_discovery.rs | 508 +++++++++++++++ .../upnp_provider.rs} | 174 +++-- pmocontrol/src/errors.rs | 65 ++ pmocontrol/src/identity.rs | 14 + pmocontrol/src/lib.rs | 38 +- .../src/{linkplay.rs => linkplay_renderer.rs} | 122 ++-- pmocontrol/src/media_server.rs | 602 ++++++++++------- pmocontrol/src/media_server_events.rs | 8 +- pmocontrol/src/model.rs | 338 ++++++++-- pmocontrol/src/music_renderer.rs | 607 +++++++++++------- pmocontrol/src/online.rs | 49 ++ pmocontrol/src/openhome.rs | 120 ++-- pmocontrol/src/openhome_client.rs | 482 ++++++-------- pmocontrol/src/openhome_renderer.rs | 178 ++--- pmocontrol/src/pmoserver_ext.rs | 130 ++-- .../{queue_backend.rs => queue/backend.rs} | 66 +- .../{queue_interne.rs => queue/interne.rs} | 56 +- pmocontrol/src/queue/mod.rs | 7 + .../{control_point => queue}/music_queue.rs | 41 +- .../openhome_queue.rs => queue/openhome.rs} | 224 ++++--- pmocontrol/src/registry.rs | 343 +++++----- pmocontrol/src/rendering_control_client.rs | 166 ++--- pmocontrol/src/soap_client.rs | 250 +++++++- pmocontrol/src/sse.rs | 10 +- pmocontrol/src/upnp_renderer.rs | 302 ++++----- pmodidl/src/lib.rs | 6 +- pmometadata/src/lib.rs | 2 +- pmoparadise/ARCHITECTURE.md | 2 +- pmoupnp/Cargo.toml | 2 + pmoupnp/src/devices/device_instance.rs | 64 +- pmoupnp/src/services/service_instance.rs | 22 +- pmoupnp/src/upnp_server.rs | 3 +- 54 files changed, 3840 insertions(+), 2769 deletions(-) delete mode 100644 pmocontrol/src/discovery.rs rename pmocontrol/src/{ => discovery}/chromecast_discovery.rs (84%) create mode 100644 pmocontrol/src/discovery/manager.rs create mode 100644 pmocontrol/src/discovery/mod.rs create mode 100644 pmocontrol/src/discovery/upnp_discovery.rs rename pmocontrol/src/{provider.rs => discovery/upnp_provider.rs} (84%) create mode 100644 pmocontrol/src/errors.rs create mode 100644 pmocontrol/src/identity.rs rename pmocontrol/src/{linkplay.rs => linkplay_renderer.rs} (70%) create mode 100644 pmocontrol/src/online.rs rename pmocontrol/src/{queue_backend.rs => queue/backend.rs} (88%) rename pmocontrol/src/{queue_interne.rs => queue/interne.rs} (73%) create mode 100644 pmocontrol/src/queue/mod.rs rename pmocontrol/src/{control_point => queue}/music_queue.rs (68%) rename pmocontrol/src/{control_point/openhome_queue.rs => queue/openhome.rs} (81%) diff --git a/.vscode/settings.json b/.vscode/settings.json index dce95e96..2c0ba4ce 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,15 +1,37 @@ { - "makefile.configureOnOpen": false, - "git.enabled": false, - "claude-code.environmentVariables": [ - - ], - // Exclusions via VS Code - "files.exclude": { - "target": true, - "**/target": true, - "node_modules": true - }, - "rust-analyzer.procMacro.enable": true, - "rust-analyzer.numThreads": 4 -} \ No newline at end of file + "makefile.configureOnOpen": false, + "git.enabled": false, + "claude-code.environmentVariables": [], + + // Exclusions de fichiers/dossiers inutiles pour Rust Analyzer + "files.exclude": { + "target": true, + "**/target": true, + "node_modules": true, + "tests/huge_benchmarks": true, + "examples": true, + "docs": true + }, + // Rust Analyzer settings + "rust-analyzer.rustupPath": "/Users/coissac/.cargo/bin/rustup", + "rust-analyzer.cargoPath": "/Users/coissac/.cargo/bin/cargo", + "rust-analyzer.rustcSource": "discover", + "rust-analyzer.procMacro.enable": true, + "rust-analyzer.numThreads": 4, + "rust-analyzer.cargo.loadOutDirsFromCheck": false, + "rust-analyzer.checkOnSave.enable": true, + "rust-analyzer.checkOnSave.command": "clippy", + "rust-analyzer.checkOnSave.extraArgs": ["--all-features"], + "rust-analyzer.exclude": [ + "target", + "tests/huge_benchmarks", + "examples", + "docs" + ], + "rust-analyzer.server.extraEnv": { + "RA_LARGE_PROJECT": "1" +}, +"rust-analyzer.runnables.command": null, +"rust-analyzer.server.path": null, +"rust-analyzer.cargo.allTargets": true, +} diff --git a/Cargo.lock b/Cargo.lock index 16b7238d..4071b75d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -306,6 +306,17 @@ dependencies = [ "rustix 1.1.2", ] +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "async-signal" version = "0.2.13" @@ -3999,6 +4010,8 @@ name = "pmoupnp" version = "0.1.0" dependencies = [ "anyhow", + "async-recursion", + "async-trait", "axum 0.8.7", "base64 0.22.1", "bevy_reflect", diff --git a/pmoaudiocache/src/track_metadata.rs b/pmoaudiocache/src/track_metadata.rs index 5b55248e..7a7eff55 100644 --- a/pmoaudiocache/src/track_metadata.rs +++ b/pmoaudiocache/src/track_metadata.rs @@ -1,5 +1,5 @@ use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use pmometadata::{MetadataError, MetadataResult, TrackMetadata}; use serde_json::{Number, Value}; @@ -394,7 +394,7 @@ impl TrackMetadata for AudioCacheTrackMetadata { } async fn touch(&mut self) -> MetadataResult<()> { - self.write_timestamp(SystemTime::now())?; + self.write_timestamp(Instant::now())?; Ok(Some(())) } } diff --git a/pmocontrol/examples/arylic_tcp_demo.rs b/pmocontrol/examples/arylic_tcp_demo.rs index e46ff1d7..9cd5e079 100644 --- a/pmocontrol/examples/arylic_tcp_demo.rs +++ b/pmocontrol/examples/arylic_tcp_demo.rs @@ -3,7 +3,7 @@ use std::thread; use std::time::Duration; use pmocontrol::{ - ControlPoint, DeviceRegistryRead, MusicRenderer, PlaybackPosition, PlaybackPositionInfo, + ControlPoint, DeviceRegistryRead, MusicRendererBackend, PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, VolumeControl, }; @@ -38,10 +38,10 @@ fn main() -> io::Result<()> { ); } - let arylic_renderers: Vec = renderers + let arylic_renderers: Vec = renderers .iter() .filter(|info| info.capabilities.has_arylic_tcp) - .filter_map(|info| MusicRenderer::from_registry_info(info.clone(), ®istry)) + .filter_map(|info| MusicRendererBackend::from_renderer_info(info.clone(), ®istry)) .collect(); if arylic_renderers.is_empty() { diff --git a/pmocontrol/examples/discover.rs b/pmocontrol/examples/discover.rs index 3e4dd991..487367f4 100644 --- a/pmocontrol/examples/discover.rs +++ b/pmocontrol/examples/discover.rs @@ -3,7 +3,7 @@ use std::thread; use std::time::Duration; use pmocontrol::RendererProtocol; -use pmocontrol::{ControlPoint, DeviceRegistryRead, MediaServerInfo, RendererInfo}; +use pmocontrol::{ControlPoint, DeviceRegistryRead, UpnpMediaServer, RendererInfo}; fn main() -> std::io::Result<()> { // Un tout petit logging optionnel @@ -21,7 +21,7 @@ fn main() -> std::io::Result<()> { let reg = reg.read().expect("registry poisoned"); let renderers: Vec = reg.list_renderers(); - let servers: Vec = reg.list_servers(); + let servers: Vec = reg.list_servers(); println!("====================="); println!("Renderers detected : {}", renderers.len()); @@ -29,7 +29,7 @@ fn main() -> std::io::Result<()> { let proto = match r.protocol { RendererProtocol::UpnpAvOnly => "UPnP AV", RendererProtocol::OpenHomeOnly => "OpenHome", - RendererProtocol::Hybrid => "Hybrid", + RendererProtocol::OpenHomeHybrid => "Hybrid", }; println!( diff --git a/pmocontrol/examples/full_control_point_demo.rs b/pmocontrol/examples/full_control_point_demo.rs index 1d22a4d1..b113d0e1 100644 --- a/pmocontrol/examples/full_control_point_demo.rs +++ b/pmocontrol/examples/full_control_point_demo.rs @@ -19,8 +19,8 @@ use crossterm::terminal::{ }; use pmocontrol::model::TrackMetadata; use pmocontrol::{ - ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, MediaServerInfo, - MusicServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, + ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer, + UpnpMediaServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, RendererEvent, RendererInfo, TransportControl, VolumeControl, }; use ratatui::Terminal; @@ -102,7 +102,7 @@ fn main() -> Result<()> { let servers = { let reg = registry.read().expect("registry poisoned"); - let list: Vec = reg + let list: Vec = reg .list_servers() .into_iter() .filter(|s| s.has_content_directory && s.content_directory_control_url.is_some()) @@ -129,10 +129,10 @@ struct App { renderers: Vec, renderer_index: usize, renderer_info: Option, - servers: Vec, + servers: Vec, server_index: usize, - server_info: Option, - music_server: Option, + server_info: Option, + music_server: Option, browser: Option, mode: Mode, ui_state: UiState, @@ -168,7 +168,7 @@ impl App { fn new( control_point: Arc, renderers: Vec, - servers: Vec, + servers: Vec, ) -> Self { Self { control_point, @@ -192,7 +192,7 @@ impl App { } } - fn renderer_id(&self) -> Option { + fn renderer_id(&self) -> Option { self.renderer_info.as_ref().map(|info| info.id.clone()) } @@ -552,7 +552,7 @@ impl App { } KeyCode::Enter => { let info = self.servers[self.server_index].clone(); - match MusicServer::from_info(&info, Duration::from_secs(DEFAULT_TIMEOUT_SECS)) { + match UpnpMediaServer::from_info(&info, Duration::from_secs(DEFAULT_TIMEOUT_SECS)) { Ok(server) => { let entries = server.browse_root()?; let browser = BrowserState::new(entries); @@ -947,7 +947,7 @@ impl App { } } - fn refresh_queue_snapshot(&mut self, renderer_id: &pmocontrol::model::RendererId) { + fn refresh_queue_snapshot(&mut self, renderer_id: &pmocontrol::model::ServiceId) { if let Ok((queue, current_index)) = self.control_point.get_full_queue_snapshot(renderer_id) { self.queue_snapshot = queue; @@ -967,7 +967,7 @@ impl App { fn peek_next_queue_item( &self, - renderer_id: &pmocontrol::model::RendererId, + renderer_id: &pmocontrol::model::ServiceId, ) -> Option { self.control_point .get_queue_snapshot(renderer_id) @@ -975,7 +975,7 @@ impl App { .and_then(|queue| queue.into_iter().next()) } - fn get_renderer(&self) -> Result { + fn get_renderer(&self) -> Result { let renderer_id = self .renderer_id() .ok_or_else(|| anyhow!("Renderer not selected"))?; @@ -1230,7 +1230,7 @@ impl NavigationState { /// Collect playable items from MediaEntry list (including nested containers). fn collect_playable_items( - server: &MusicServer, + server: &UpnpMediaServer, entries: &[MediaEntry], ) -> Result> { let mut items = Vec::new(); @@ -1255,7 +1255,7 @@ fn collect_playable_items( } /// Convert MediaEntry to PlaybackItem. -fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option { +fn playback_item_from_entry(server: &UpnpMediaServer, entry: &MediaEntry) -> Option { let resource = entry.resources.iter().find(|res| res.is_audio())?; let metadata = TrackMetadata { title: Some(entry.title.clone()), diff --git a/pmocontrol/examples/linkplay_demo.rs b/pmocontrol/examples/linkplay_demo.rs index ec76a24c..7eea1c74 100644 --- a/pmocontrol/examples/linkplay_demo.rs +++ b/pmocontrol/examples/linkplay_demo.rs @@ -3,7 +3,7 @@ use std::thread; use std::time::Duration; use pmocontrol::{ - ControlPoint, DeviceRegistryRead, MusicRenderer, PlaybackPosition, PlaybackPositionInfo, + ControlPoint, DeviceRegistryRead, MusicRendererBackend, PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, VolumeControl, }; @@ -34,10 +34,10 @@ fn main() -> io::Result<()> { ); } - let linkplay_renderers: Vec = renderers + let linkplay_renderers: Vec = renderers .iter() .filter(|info| info.capabilities.has_linkplay_http) - .filter_map(|info| MusicRenderer::from_registry_info(info.clone(), ®istry)) + .filter_map(|info| MusicRendererBackend::from_renderer_info(info.clone(), ®istry)) .collect(); if linkplay_renderers.is_empty() { diff --git a/pmocontrol/examples/live_pmomusic_demo.rs b/pmocontrol/examples/live_pmomusic_demo.rs index 73df75fc..384a4b17 100644 --- a/pmocontrol/examples/live_pmomusic_demo.rs +++ b/pmocontrol/examples/live_pmomusic_demo.rs @@ -10,8 +10,8 @@ use std::time::Duration; use anyhow::{Context, Result}; use pmocontrol::model::TrackMetadata; use pmocontrol::{ - ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, MediaServerInfo, - MusicRenderer, MusicServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo, + ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer, + MusicRendererBackend, UpnpMediaServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo, }; const DEFAULT_TIMEOUT_SECS: u64 = 5; @@ -69,7 +69,7 @@ fn main() -> Result<()> { server_info.friendly_name, server_info.location, server_info.id.0 ); - let renderer_instance = MusicRenderer::from_registry_info(renderer.clone(), ®istry) + let renderer_instance = MusicRendererBackend::from_renderer_info(renderer.clone(), ®istry) .expect("Selected renderer is not usable by MusicRenderer façade"); let supports_set_next = renderer_instance .as_upnp() @@ -82,7 +82,7 @@ fn main() -> Result<()> { let timeout = Duration::from_secs(config.timeout_secs); let server = - MusicServer::from_info(&server_info, timeout).context("Failed to init MusicServer")?; + UpnpMediaServer::from_info(&server_info, timeout).context("Failed to init MusicServer")?; println!("Searching for a Live Playlist container in ContentDirectory..."); let live_playlist_container = find_live_playlist_container(&server) @@ -355,8 +355,8 @@ fn pick_renderer(renderers: Vec) -> Option { Some(selected) } -fn pick_pmomusic_server(servers: Vec) -> Option { - let mut candidates: Vec = servers +fn pick_pmomusic_server(servers: Vec) -> Option { + let mut candidates: Vec = servers .into_iter() .filter(|info| info.has_content_directory) .filter(|info| info.content_directory_control_url.is_some()) @@ -381,7 +381,7 @@ fn pick_pmomusic_server(servers: Vec) -> Option bool { +fn is_pmomusic_server(info: &UpnpMediaServer) -> bool { let name = info.friendly_name.to_ascii_lowercase(); let model = info.model_name.to_ascii_lowercase(); let manufacturer = info.manufacturer.to_ascii_lowercase(); @@ -395,7 +395,7 @@ fn is_pmomusic_renderer(info: &RendererInfo) -> bool { } /// Search for a container whose title contains "Live Playlist" using BFS. -fn find_live_playlist_container(server: &MusicServer) -> Result> { +fn find_live_playlist_container(server: &UpnpMediaServer) -> Result> { let root_entries = server .browse_root() .context("Failed to browse ContentDirectory root")?; @@ -477,7 +477,7 @@ fn find_live_playlist_container(server: &MusicServer) -> Result Result> { @@ -532,7 +532,7 @@ fn collect_playable_items_from_container( Ok(items) } -fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option { +fn playback_item_from_entry(server: &UpnpMediaServer, entry: &MediaEntry) -> Option { // Skip live streams (we're looking for regular tracks in a live playlist) if entry.title.to_ascii_lowercase().contains("live stream") { return None; diff --git a/pmocontrol/examples/media_server_events_demo.rs b/pmocontrol/examples/media_server_events_demo.rs index 7840e373..a6c922fa 100644 --- a/pmocontrol/examples/media_server_events_demo.rs +++ b/pmocontrol/examples/media_server_events_demo.rs @@ -4,7 +4,7 @@ use std::time::{Duration, Instant}; use anyhow::Result; use crossbeam_channel::RecvTimeoutError; -use pmocontrol::{ControlPoint, MediaServerEvent, MediaServerInfo, ServerId}; +use pmocontrol::{ControlPoint, MediaServerEvent, UpnpMediaServer, ServerId}; const DISCOVERY_WAIT_SECS: u64 = 5; const MONITOR_DURATION_SECS: u64 = 90; @@ -33,7 +33,7 @@ fn main() -> Result<()> { ); } - let mut cache: HashMap = servers + let mut cache: HashMap = servers .into_iter() .map(|info| (info.id.clone(), info)) .collect(); @@ -64,7 +64,7 @@ fn main() -> Result<()> { fn print_event( cp: &ControlPoint, - cache: &mut HashMap, + cache: &mut HashMap, event: &MediaServerEvent, ) { match event { @@ -101,7 +101,7 @@ fn print_event( fn describe_server( cp: &ControlPoint, - cache: &mut HashMap, + cache: &mut HashMap, id: &ServerId, ) -> String { if let Some(info) = cache.get(id) { diff --git a/pmocontrol/examples/openhome_tester.rs b/pmocontrol/examples/openhome_tester.rs index c78f1195..4970898d 100644 --- a/pmocontrol/examples/openhome_tester.rs +++ b/pmocontrol/examples/openhome_tester.rs @@ -5,7 +5,7 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result, anyhow}; use pmocontrol::{ - DeviceDescriptionProvider, DiscoveredEndpoint, HttpXmlDescriptionProvider, MusicRenderer, + DeviceDescriptionProvider, DiscoveredEndpoint, HttpXmlDescriptionProvider, MusicRendererBackend, RendererInfo, control_point::ControlPoint, openhome_client::{ @@ -86,7 +86,7 @@ fn auto_discover_renderer() -> Result { let mut openhome_infos = Vec::new(); for renderer in renderers { - if let MusicRenderer::OpenHome(oh) = renderer { + if let MusicRendererBackend::OpenHome(oh) = renderer { if seen.insert(oh.id().0.clone()) { openhome_infos.push(oh.info.clone()); } diff --git a/pmocontrol/examples/queue_pmomusic_demo.rs b/pmocontrol/examples/queue_pmomusic_demo.rs index 276cb059..81b9bc69 100644 --- a/pmocontrol/examples/queue_pmomusic_demo.rs +++ b/pmocontrol/examples/queue_pmomusic_demo.rs @@ -10,8 +10,8 @@ use std::time::Duration; use anyhow::{Context, Result}; use pmocontrol::model::TrackMetadata; use pmocontrol::{ - ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, MediaServerInfo, - MusicRenderer, MusicServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo, + ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer, + MusicRendererBackend, UpnpMediaServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo, }; const DEFAULT_TIMEOUT_SECS: u64 = 5; @@ -72,7 +72,7 @@ fn main() -> Result<()> { server_info.friendly_name, server_info.location, server_info.id.0 ); - let renderer_instance = MusicRenderer::from_registry_info(renderer.clone(), ®istry) + let renderer_instance = MusicRendererBackend::from_renderer_info(renderer.clone(), ®istry) .expect("Selected renderer is not usable by MusicRenderer façade"); let supports_set_next = renderer_instance .as_upnp() @@ -85,7 +85,7 @@ fn main() -> Result<()> { let timeout = Duration::from_secs(config.timeout_secs); let server = - MusicServer::from_info(&server_info, timeout).context("Failed to init MusicServer")?; + UpnpMediaServer::from_info(&server_info, timeout).context("Failed to init MusicServer")?; let root_entries = server .browse_root() @@ -322,8 +322,8 @@ fn pick_renderer(renderers: Vec) -> Option { Some(selected) } -fn pick_media_server(servers: Vec) -> Option { - let mut candidates: Vec = servers +fn pick_media_server(servers: Vec) -> Option { + let mut candidates: Vec = servers .into_iter() .filter(|info| info.has_content_directory) .filter(|info| info.content_directory_control_url.is_some()) @@ -346,7 +346,7 @@ fn pick_media_server(servers: Vec) -> Option { } } -fn is_pmomusic_server(info: &MediaServerInfo) -> bool { +fn is_pmomusic_server(info: &UpnpMediaServer) -> bool { let name = info.friendly_name.to_ascii_lowercase(); let header = info.server_header.to_ascii_lowercase(); name.contains("pmomusic") || header.contains("pmomusic") @@ -359,7 +359,7 @@ fn is_pmomusic_renderer(info: &RendererInfo) -> bool { } fn collect_playable_items_with_binding( - server: &MusicServer, + server: &UpnpMediaServer, entries: &[MediaEntry], max_tracks: usize, ) -> Result<(Vec, Option)> { @@ -423,7 +423,7 @@ fn collect_playable_items_with_binding( } fn gather_items_from_entry( - server: &MusicServer, + server: &UpnpMediaServer, entry: &MediaEntry, max_tracks: usize, depth: usize, @@ -464,7 +464,7 @@ fn gather_items_from_entry( Ok(()) } -fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option { +fn playback_item_from_entry(server: &UpnpMediaServer, entry: &MediaEntry) -> Option { if entry.title.to_ascii_lowercase().contains("live stream") { return None; } diff --git a/pmocontrol/examples/renderer_demo.rs b/pmocontrol/examples/renderer_demo.rs index 06da20b4..31cd17e8 100644 --- a/pmocontrol/examples/renderer_demo.rs +++ b/pmocontrol/examples/renderer_demo.rs @@ -17,7 +17,7 @@ use pmocontrol::PlaybackPosition; use pmocontrol::model::RendererInfo; use pmocontrol::openhome_client::{OhInfoClient, OhPlaylistClient, OhTimeClient}; use pmocontrol::{ - ControlPoint, MusicRenderer, PlaybackState, PlaybackStatus, RendererCapabilities, + ControlPoint, MusicRendererBackend, PlaybackState, PlaybackStatus, RendererCapabilities, RendererProtocol, TransportControl, VolumeControl, }; use std::env; @@ -37,7 +37,7 @@ fn main() -> Result<()> { thread::sleep(Duration::from_secs(5)); // 2. Snapshot of logical music renderers - let mut renderers: Vec = cp.list_music_renderers(); + let mut renderers: Vec = cp.list_music_renderers(); // Filter out the in-dev PMOMusic renderer (if present) renderers.retain(|r| { @@ -276,20 +276,20 @@ fn print_openhome_details(prefix: &str, info: &RendererInfo) { } } -fn print_backend(prefix: &str, renderer: &MusicRenderer) { +fn print_backend(prefix: &str, renderer: &MusicRendererBackend) { let backend = match renderer { - MusicRenderer::Upnp(_) => "UpnpRenderer (UPnP AV / DLNA)", - MusicRenderer::LinkPlay(_) => "LinkPlayRenderer (LinkPlay HTTP)", - MusicRenderer::ArylicTcp(_) => "ArylicTcpRenderer (ARylic TCP Protocol)", - MusicRenderer::HybridUpnpArylic { .. } => { + MusicRendererBackend::Upnp(_) => "UpnpRenderer (UPnP AV / DLNA)", + MusicRendererBackend::LinkPlay(_) => "LinkPlayRenderer (LinkPlay HTTP)", + MusicRendererBackend::ArylicTcp(_) => "ArylicTcpRenderer (ARylic TCP Protocol)", + MusicRendererBackend::HybridUpnpArylic { .. } => { "Hybrid UpnpArylicRenderer (UPnP AV / DLNA + ARylic TCP Protocol)" } - MusicRenderer::OpenHome(_) => "OpenHomeRenderer (native OpenHome stack)", + MusicRendererBackend::OpenHome(_) => "OpenHomeRenderer (native OpenHome stack)", }; println!("{prefix}Backend : {backend}"); } -fn dump_renderer_state(renderer: &MusicRenderer, label: &str) -> Result<()> { +fn dump_renderer_state(renderer: &MusicRendererBackend, label: &str) -> Result<()> { println!("\n[{label}]"); if let Ok(state) = renderer.playback_state() { @@ -344,7 +344,7 @@ fn dump_renderer_state(renderer: &MusicRenderer, label: &str) -> Result<()> { Ok(()) } -fn progress_monitor(renderer: &MusicRenderer, label: &str, iterations: usize, interval_secs: u64) { +fn progress_monitor(renderer: &MusicRendererBackend, label: &str, iterations: usize, interval_secs: u64) { println!( "\n[{label}] polling playback state/position {} times (every {} s)...", iterations, interval_secs @@ -370,7 +370,7 @@ fn progress_monitor(renderer: &MusicRenderer, label: &str, iterations: usize, in } } -fn volume_demo(renderer: &MusicRenderer) -> Result<()> { +fn volume_demo(renderer: &MusicRendererBackend) -> Result<()> { // Try to get current volume let original = renderer.volume()?; println!(" Current music volume : {}", original); diff --git a/pmocontrol/src/arylic_tcp.rs b/pmocontrol/src/arylic_tcp.rs index caf2351b..7aa18aef 100644 --- a/pmocontrol/src/arylic_tcp.rs +++ b/pmocontrol/src/arylic_tcp.rs @@ -5,16 +5,17 @@ use std::sync::{Mutex, OnceLock}; use std::thread; use std::time::Duration; -use anyhow::anyhow; use anyhow::{Context, Result}; use tracing::{debug, warn}; +use crate::DeviceIdentity; use crate::capabilities::{ PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, VolumeControl, }; -use crate::linkplay::{extract_linkplay_host, parse_flat_json}; -use crate::model::{RendererId, RendererInfo}; +use crate::errors::ControlPointError; +use crate::linkplay_renderer::{extract_linkplay_host, parse_flat_json}; +use crate::model::RendererInfo; use std::time::Instant; // Garde global pour respecter le délai de 200ms entre commandes @@ -76,7 +77,7 @@ pub(crate) fn detect_arylic_tcp(location: &str, timeout: Duration) -> bool { detected } -fn try_detect_tcp(host: &str, timeout: Duration) -> Result<()> { +fn try_detect_tcp(host: &str, timeout: Duration) -> Result<(), ControlPointError> { let payload = send_command_required( host, ARYLIC_TCP_PORT, @@ -88,57 +89,47 @@ fn try_detect_tcp(host: &str, timeout: Duration) -> Result<()> { if payload.starts_with("AXX+INF+") || payload.starts_with("AXX+DEV+") { Ok(()) } else { - Err(anyhow!( + Err(ControlPointError::ArilycTcpError(format!( "Unexpected INF response from {}: {}", host, payload - )) + ))) } } /// Backend speaking the Arylic TCP control protocol (port 8899). #[derive(Clone, Debug)] pub struct ArylicTcpRenderer { - pub info: RendererInfo, host: String, port: u16, timeout: Duration, } impl ArylicTcpRenderer { - pub fn from_renderer_info(info: RendererInfo) -> Result { - let host = extract_linkplay_host(&info.location) - .ok_or_else(|| anyhow!("Renderer {} has no valid LOCATION host", info.udn))?; + pub fn from_renderer_info(info: RendererInfo) -> Result { + let host = extract_linkplay_host(info.location()) + .ok_or_else(|| ControlPointError::ArilycTcpError(format!("Renderer {} has no valid LOCATION host", info.udn())))?; Ok(Self { - info, host, port: ARYLIC_TCP_PORT, timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS), }) } - pub fn id(&self) -> &RendererId { - &self.info.id - } - - pub fn friendly_name(&self) -> &str { - &self.info.friendly_name - } - - fn send_required(&self, cmd: &str, expected: &[&str]) -> Result { + fn send_required(&self, cmd: &str, expected: &[&str]) -> Result { send_command_required(&self.host, self.port, self.timeout, cmd, expected) } - fn send_optional(&self, cmd: &str, expected: &[&str]) -> Result> { + fn send_optional(&self, cmd: &str, expected: &[&str]) -> Result, ControlPointError> { send_command_optional(&self.host, self.port, self.timeout, cmd, expected) } - fn send_no_response(&self, cmd: &str) -> Result<()> { + fn send_no_response(&self, cmd: &str) -> Result<(), ControlPointError> { send_command_no_response(&self.host, self.port, self.timeout, cmd) } - fn fetch_playback_info(&self) -> Result { + fn fetch_playback_info(&self) -> Result { let payload = self.send_required("MCU+PINFGET", &["AXX+PLY+INF"])?; match parse_playback_info(&payload) { Ok(info) => Ok(info), @@ -156,59 +147,60 @@ impl ArylicTcpRenderer { format!("MCU+VOL+{:03}", value.min(100)) } - fn parse_volume_payload(payload: &str) -> Result { + fn parse_volume_payload(payload: &str) -> Result { let data = payload .strip_prefix("AXX+VOL+") - .ok_or_else(|| anyhow!("Unexpected volume response: {}", payload))?; + .ok_or_else(|| ControlPointError::ArilycTcpError(format!("Unexpected volume response: {}", payload)))?; let value: u16 = data .trim() .parse() - .with_context(|| format!("Invalid volume value: {}", data))?; + .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid volume value: {}", data)))?; Ok(value.min(100)) } - fn parse_mute_payload(payload: &str) -> Result { + fn parse_mute_payload(payload: &str) -> Result { let data = payload .strip_prefix("AXX+MUT+") - .ok_or_else(|| anyhow!("Unexpected mute response: {}", payload))?; + .ok_or_else(|| ControlPointError::ArilycTcpError(format!("Unexpected mute response: {}", payload)))?; match data.trim() { "000" | "0" => Ok(false), "001" | "1" => Ok(true), - other => Err(anyhow!("Invalid mute value: {}", other)), + other => Err(ControlPointError::ArilycTcpError(format!("Invalid mute value: {}", other))), } } } impl TransportControl for ArylicTcpRenderer { - fn play_uri(&self, _uri: &str, _meta: &str) -> Result<()> { - Err(anyhow!( - "Arylic TCP backend does not support direct URL loading. Use UPnP AVTransport SetAVTransportURI instead." + fn play_uri(&self, _uri: &str, _meta: &str) -> Result<(), ControlPointError> { + Err(ControlPointError::upnp_operation_not_supported( + "Arylic TCP backend does not support direct URL loading.", + "ArylicTcpRenderer", )) } - fn play(&self) -> Result<()> { + fn play(&self) -> Result<(), ControlPointError> { self.send_no_response("MCU+PLY-PLA") } - fn pause(&self) -> Result<()> { + fn pause(&self) -> Result<(), ControlPointError> { let _ = self.send_optional("MCU+PLY-PUS", &["AXX+PLY+"])?; Ok(()) } - fn stop(&self) -> Result<()> { + fn stop(&self) -> Result<(), ControlPointError> { self.send_no_response("MCU+PLY-STP") } - fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { + fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { let _ = parse_hhmmss(hhmmss)?; - Err(anyhow!( + Err(ControlPointError::ArilycTcpError(format!( "Arylic TCP seek_rel_time is not implemented yet for this device." - )) + ))) } } impl VolumeControl for ArylicTcpRenderer { - fn volume(&self) -> Result { + fn volume(&self) -> Result { if let Ok(info) = self.fetch_playback_info() { if let Some(vol) = info.volume { return Ok(vol); @@ -222,13 +214,13 @@ impl VolumeControl for ArylicTcpRenderer { Self::parse_volume_payload(&payload) } - fn set_volume(&self, v: u16) -> Result<()> { + fn set_volume(&self, v: u16) -> Result<(), ControlPointError> { let command = Self::format_volume_command(v); let _ = self.send_optional(&command, &["AXX+VOL+"])?; Ok(()) } - fn mute(&self) -> Result { + fn mute(&self) -> Result { if let Ok(info) = self.fetch_playback_info() { if let Some(mute) = info.mute { return Ok(mute); @@ -242,7 +234,7 @@ impl VolumeControl for ArylicTcpRenderer { Self::parse_mute_payload(&payload) } - fn set_mute(&self, m: bool) -> Result<()> { + fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { let command = if m { "MCU+MUT+001" } else { "MCU+MUT+000" }; let payload = self.send_required(command, &["AXX+MUT+"])?; let _ = Self::parse_mute_payload(&payload)?; @@ -251,14 +243,14 @@ impl VolumeControl for ArylicTcpRenderer { } impl PlaybackStatus for ArylicTcpRenderer { - fn playback_state(&self) -> Result { + fn playback_state(&self) -> Result { let info = self.fetch_playback_info()?; Ok(info.playback_state()) } } impl PlaybackPosition for ArylicTcpRenderer { - fn playback_position(&self) -> Result { + fn playback_position(&self) -> Result { let info = self.fetch_playback_info()?; Ok(info.position_info()) } @@ -307,10 +299,10 @@ impl ArylicPlaybackInfo { } } -fn parse_playback_info(payload: &str) -> Result { +fn parse_playback_info(payload: &str) -> Result { let json_blob = payload .strip_prefix("AXX+PLY+INF") - .ok_or_else(|| anyhow!("Unexpected playback info prefix: {}", payload))?; + .ok_or_else(|| ControlPointError::ArilycTcpError(format!("Unexpected playback info prefix: {}", payload)))?; let json_blob = json_blob.trim_end_matches('&').trim(); let map = parse_flat_json(json_blob)?; @@ -318,7 +310,7 @@ fn parse_playback_info(payload: &str) -> Result { let status_raw = map .get("status") .cloned() - .ok_or_else(|| anyhow!("Playback info missing `status` field"))?; + .ok_or_else(|| ControlPointError::ArilycTcpError(format!("Playback info missing `status` field")))?; let curpos_ms = parse_u64_field(&map, "curpos")?; let totlen_ms = parse_u64_field(&map, "totlen")?; @@ -375,15 +367,15 @@ fn parse_playback_info(payload: &str) -> Result { }) } -fn parse_u64_field(map: &HashMap, key: &str) -> Result { +fn parse_u64_field(map: &HashMap, key: &str) -> Result { let raw = map .get(key) - .ok_or_else(|| anyhow!("Playback info missing `{}` field", key))?; + .ok_or_else(|| ControlPointError::ArilycTcpError(format!("Playback info missing `{}` field", key)))?; raw.parse::() - .with_context(|| format!("Invalid `{}` value: {}", key, raw)) + .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid `{}` value: {}", key, raw))) } -fn connect(host: &str, port: u16, timeout: Duration) -> Result { +fn connect(host: &str, port: u16, timeout: Duration) -> Result { if let Ok(mut last_time) = last_command_time().lock() { let elapsed = last_time.elapsed(); if elapsed < Duration::from_millis(200) { @@ -406,14 +398,24 @@ fn connect(host: &str, port: u16, timeout: Duration) -> Result { let mut last_err = None; for addr in address .to_socket_addrs() - .with_context(|| format!("Failed to resolve {}:{}", host, port))? + .with_context(|| format!("Failed to resolve {}:{}", host, port)) + .map_err(|_| { + ControlPointError::ArilycTcpError(format!("Failed to resolve {}:{}", host, port)) + })? { match TcpStream::connect_timeout(&addr, timeout) { Ok(stream) => { stream .set_read_timeout(Some(timeout)) .and_then(|_| stream.set_write_timeout(Some(timeout))) - .with_context(|| format!("Failed to set socket timeouts for {}", address))?; + .with_context(|| format!("Failed to set socket timeouts for {}", address)) + .map_err(|err| { + ControlPointError::ArilycTcpError(format!( + "Failed to set socket timeouts for {}", + address + )) + })?; + return Ok(stream); } Err(err) => { @@ -423,13 +425,14 @@ fn connect(host: &str, port: u16, timeout: Duration) -> Result { } match last_err { - Some((addr, err)) => Err(anyhow!( + Some((addr, err)) => Err(ControlPointError::ArilycTcpError(format!( "Failed to connect to {} via {}: {}", - host, - addr, - err - )), - None => Err(anyhow!("No socket addresses resolved for {}", address)), + host, addr, err + ))), + None => Err(ControlPointError::ArilycTcpError(format!( + "No socket addresses resolved for {}", + address + ))), } } @@ -447,26 +450,31 @@ fn encode_packet(payload: &str) -> Vec { out } -fn read_packet(stream: &mut TcpStream) -> Result { +fn read_packet(stream: &mut TcpStream) -> Result { let mut header = [0u8; 4]; - stream.read_exact(&mut header)?; + stream.read_exact(&mut header) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; if header != PACKET_HEADER { - return Err(anyhow!("Invalid Arylic packet header: {:x?}", header)); + return Err(ControlPointError::ArilycTcpError(format!("Invalid Arylic packet header: {:x?}", header))); } let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf)?; + stream.read_exact(&mut len_buf) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; let len = u32::from_le_bytes(len_buf) as usize; let mut checksum_buf = [0u8; 4]; - stream.read_exact(&mut checksum_buf)?; + stream.read_exact(&mut checksum_buf) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; let expected_checksum = u32::from_le_bytes(checksum_buf); let mut reserved = [0u8; 8]; - stream.read_exact(&mut reserved)?; + stream.read_exact(&mut reserved) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload)?; + stream.read_exact(&mut payload) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; let actual_checksum = payload.iter().fold(0u32, |acc, b| acc + (*b as u32)); if actual_checksum != expected_checksum { @@ -476,7 +484,8 @@ fn read_packet(stream: &mut TcpStream) -> Result { ); } - Ok(String::from_utf8(payload)?) + Ok(String::from_utf8(payload) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?) } fn format_hms(secs: u64) -> String { @@ -486,30 +495,30 @@ fn format_hms(secs: u64) -> String { format!("{:02}:{:02}:{:02}", h, m, s) } -fn parse_hhmmss(value: &str) -> Result { +fn parse_hhmmss(value: &str) -> Result { let parts: Vec<_> = value.split(':').collect(); if parts.len() != 3 { - return Err(anyhow!( + return Err(ControlPointError::ArilycTcpError(format!( "Invalid time format `{}`. Expected HH:MM:SS.", value - )); + ))); } let hours: u64 = parts[0] .parse() - .with_context(|| format!("Invalid hour component in {}", value))?; + .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid hour component in {}", value)))?; let minutes: u64 = parts[1] .parse() - .with_context(|| format!("Invalid minute component in {}", value))?; + .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid minute component in {}", value)))?; let seconds: u64 = parts[2] .parse() - .with_context(|| format!("Invalid second component in {}", value))?; + .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid second component in {}", value)))?; if minutes > 59 || seconds > 59 { - return Err(anyhow!( + return Err(ControlPointError::ArilycTcpError(format!( "Invalid HH:MM:SS value `{}`. Minutes and seconds must be < 60.", value - )); + ))); } Ok(hours * 3600 + minutes * 60 + seconds) @@ -521,21 +530,24 @@ fn send_command_with_mode( timeout: Duration, payload: &str, mode: ResponseMode<'_>, -) -> Result> { +) -> Result, ControlPointError> { let mut stream = connect(host, port, timeout)?; let packet = encode_packet(payload); - stream.write_all(&packet).with_context(|| { + stream.write_all(&packet).map_err(|_| { + ControlPointError::ArilycTcpError( format!( "Failed to write Arylic TCP packet for {}: {}", host, payload - ) + )) })?; - stream.flush().with_context(|| { + + stream.flush().map_err(|_| { + ControlPointError::ArilycTcpError( format!( "Failed to flush Arylic TCP stream for {} (command {})", host, payload - ) + )) })?; match mode { @@ -572,11 +584,11 @@ fn send_command_with_mode( } } - Err(anyhow!( + Err(ControlPointError::ArilycTcpError(format!( "No expected response for optional command {} on {}", payload, host - )) + ))) } } } @@ -586,17 +598,17 @@ fn read_expected_response( host: &str, payload: &str, expected: &[&str], -) -> Result { +) -> Result { for _ in 0..MAX_RESPONSE_ATTEMPTS { let response = match read_packet(stream) { Ok(resp) => resp, Err(err) => { - return Err(anyhow!( + return Err(ControlPointError::ArilycTcpError(format!( "Failed to read Arylic TCP response for {} (command {}): {}", host, payload, err - )); + ))); } }; if expected.iter().any(|prefix| response.starts_with(prefix)) { @@ -609,11 +621,11 @@ fn read_expected_response( ); } - Err(anyhow!( + Err(ControlPointError::ArilycTcpError(format!( "No expected response for command {} on {}", payload, host - )) + ))) } fn send_command_required( @@ -622,7 +634,7 @@ fn send_command_required( timeout: Duration, payload: &str, expected: &[&str], -) -> Result { +) -> Result { match send_command_with_mode( host, port, @@ -631,10 +643,10 @@ fn send_command_required( ResponseMode::Required(expected), )? { Some(s) => Ok(s), - None => Err(anyhow!( + None => Err(ControlPointError::ArilycTcpError(format!( "Arylic TCP: no response payload for required command {}", payload - )), + ))), } } @@ -644,7 +656,7 @@ fn send_command_optional( timeout: Duration, payload: &str, expected: &[&str], -) -> Result> { +) -> Result, ControlPointError> { send_command_with_mode( host, port, @@ -654,6 +666,6 @@ fn send_command_optional( ) } -fn send_command_no_response(host: &str, port: u16, timeout: Duration, payload: &str) -> Result<()> { +fn send_command_no_response(host: &str, port: u16, timeout: Duration, payload: &str) -> Result<(), ControlPointError> { send_command_with_mode(host, port, timeout, payload, ResponseMode::None).map(|_| ()) } diff --git a/pmocontrol/src/avtransport_client.rs b/pmocontrol/src/avtransport_client.rs index 89e8f546..088b5547 100644 --- a/pmocontrol/src/avtransport_client.rs +++ b/pmocontrol/src/avtransport_client.rs @@ -2,7 +2,12 @@ use std::time::Duration; -use crate::soap_client::{SoapCallResult, invoke_upnp_action, invoke_upnp_action_with_timeout}; +use crate::{ + errors::ControlPointError, + soap_client::{ + extract_child_text, handle_action_response, invoke_upnp_action, invoke_upnp_action_with_timeout + }, +}; use anyhow::{Result, anyhow}; use pmoupnp::soap::SoapEnvelope; use xmltree::{Element, XMLNode}; @@ -31,7 +36,7 @@ impl AvTransportClient { } /// AVTransport:1 — GetTransportInfo - pub fn get_transport_info(&self, instance_id: u32) -> Result { + pub fn get_transport_info(&self, instance_id: u32) -> Result { let instance_id_str = instance_id.to_string(); let args = [("InstanceID", instance_id_str.as_str())]; @@ -43,16 +48,16 @@ impl AvTransportClient { )?; if !call_result.status.is_success() { - return Err(anyhow!( + return Err(ControlPointError::UpnpError(format!( "GetTransportInfo failed with HTTP status {}", call_result.status - )); + ))); } let envelope = call_result .envelope .as_ref() - .ok_or_else(|| anyhow!("Missing SOAP envelope in GetTransportInfo response"))?; + .ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetTransportInfo response")))?; parse_transport_info(envelope) } @@ -64,7 +69,7 @@ impl AvTransportClient { /// /// - `uri` : CurrentURI /// - `meta` : CurrentURIMetaData (DIDL-Lite ou chaîne vide) - pub fn set_av_transport_uri(&self, uri: &str, meta: &str) -> Result<()> { + pub fn set_av_transport_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { let args = [ ("InstanceID", "0"), ("CurrentURI", uri), @@ -82,7 +87,7 @@ impl AvTransportClient { } /// AVTransport:1 — Play - pub fn play(&self, instance_id: u32, speed: &str) -> Result<()> { + pub fn play(&self, instance_id: u32, speed: &str) -> Result<(), ControlPointError> { let instance_id_str = instance_id.to_string(); let args = [("InstanceID", instance_id_str.as_str()), ("Speed", speed)]; @@ -92,7 +97,7 @@ impl AvTransportClient { } /// AVTransport:1 — Pause - pub fn pause(&self, instance_id: u32) -> Result<()> { + pub fn pause(&self, instance_id: u32) -> Result<(), ControlPointError> { let instance_id_str = instance_id.to_string(); let args = [("InstanceID", instance_id_str.as_str())]; @@ -103,7 +108,7 @@ impl AvTransportClient { } /// AVTransport:1 — Stop - pub fn stop(&self, instance_id: u32) -> Result<()> { + pub fn stop(&self, instance_id: u32) -> Result<(), ControlPointError> { let instance_id_str = instance_id.to_string(); let args = [("InstanceID", instance_id_str.as_str())]; @@ -113,7 +118,12 @@ impl AvTransportClient { } /// AVTransport:1 — Seek - pub fn seek(&self, instance_id: u32, unit: &str, target: &str) -> Result<()> { + pub fn seek( + &self, + instance_id: u32, + unit: &str, + target: &str, + ) -> Result<(), ControlPointError> { let instance_id_str = instance_id.to_string(); let args = [ ("InstanceID", instance_id_str.as_str()), @@ -131,7 +141,11 @@ impl AvTransportClient { /// This should configure the *next* track to be played after the current one. /// Many renderers do NOT implement this action; in that case the method /// returns an error derived from the UPnP error code. - pub fn set_next_av_transport_uri(&self, next_uri: &str, next_meta: &str) -> Result<()> { + pub fn set_next_av_transport_uri( + &self, + next_uri: &str, + next_meta: &str, + ) -> Result<(), ControlPointError> { let args = [ ("InstanceID", "0"), ("NextURI", next_uri), @@ -150,10 +164,9 @@ impl AvTransportClient { if let Some(env) = &call_result.envelope { if let Some(upnp_error) = parse_upnp_error(env) { if is_set_next_not_supported_error(&upnp_error) { - return Err(anyhow!( - "Renderer does not support AVTransport.SetNextAVTransportURI (UPnP error {}: {})", - upnp_error.error_code, - upnp_error.error_description + return Err(ControlPointError::upnp_operation_not_supported( + "SetNextAVTransportURI", + "AVTransport", )); } } @@ -166,43 +179,9 @@ impl AvTransportClient { } } -fn handle_action_response(action: &str, call_result: &SoapCallResult) -> Result<()> { - if !call_result.status.is_success() { - if let Some(env) = &call_result.envelope { - if let Some(upnp_error) = parse_upnp_error(env) { - return Err(anyhow!( - "{action} failed with UPnP error {}: {} (HTTP status {})", - upnp_error.error_code, - upnp_error.error_description, - call_result.status - )); - } - } - - return Err(anyhow!( - "{action} failed with HTTP status {} and body: {}", - call_result.status, - call_result.raw_body - )); - } - - if let Some(env) = &call_result.envelope { - if let Some(upnp_error) = parse_upnp_error(env) { - return Err(anyhow!( - "{action} returned UPnP error {}: {} (HTTP status {})", - upnp_error.error_code, - upnp_error.error_description, - call_result.status - )); - } - } - - Ok(()) -} - -fn parse_transport_info(envelope: &SoapEnvelope) -> Result { +fn parse_transport_info(envelope: &SoapEnvelope) -> Result { let response = find_child_with_suffix(&envelope.body.content, "GetTransportInfoResponse") - .ok_or_else(|| anyhow!("Missing GetTransportInfoResponse element in SOAP body"))?; + .ok_or_else(|| ControlPointError::UpnpError(format!("Missing GetTransportInfoResponse element in SOAP body")))?; let current_transport_state = extract_child_text(response, "CurrentTransportState")?; let current_transport_status = extract_child_text(response, "CurrentTransportStatus")?; @@ -289,18 +268,6 @@ fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a E }) } -fn extract_child_text(parent: &Element, suffix: &str) -> Result { - let child = find_child_with_suffix(parent, suffix) - .ok_or_else(|| anyhow!("Missing {suffix} element in GetTransportInfoResponse"))?; - - let text = child - .get_text() - .map(|t| t.trim().to_string()) - .filter(|t| !t.is_empty()) - .ok_or_else(|| anyhow!("{suffix} element missing text in GetTransportInfoResponse"))?; - - Ok(text) -} #[cfg(test)] mod tests { @@ -397,7 +364,7 @@ pub struct PositionInfo { impl AvTransportClient { /// AVTransport:1 — GetPositionInfo - pub fn get_position_info(&self, instance_id: u32) -> Result { + pub fn get_position_info(&self, instance_id: u32) -> Result { let instance_id_str = instance_id.to_string(); let args = [("InstanceID", instance_id_str.as_str())]; @@ -409,24 +376,24 @@ impl AvTransportClient { )?; if !call_result.status.is_success() { - return Err(anyhow!( + return Err(ControlPointError::UpnpError(format!( "GetPositionInfo failed with HTTP status {}", call_result.status - )); + ))); } let envelope = call_result .envelope .as_ref() - .ok_or_else(|| anyhow!("Missing SOAP envelope in GetPositionInfo response"))?; + .ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetPositionInfo response")))?; parse_position_info(envelope) } } -fn parse_position_info(envelope: &SoapEnvelope) -> Result { +fn parse_position_info(envelope: &SoapEnvelope) -> Result { let response = find_child_with_suffix(&envelope.body.content, "GetPositionInfoResponse") - .ok_or_else(|| anyhow!("Missing GetPositionInfoResponse element"))?; + .ok_or_else(|| ControlPointError::UpnpError(format!("Missing GetPositionInfoResponse element")))?; // Helpers allow missing text (AVTransport allows empty durations) fn opt(parent: &Element, name: &str) -> Option { diff --git a/pmocontrol/src/capabilities.rs b/pmocontrol/src/capabilities.rs index e5461a35..06f5b85c 100644 --- a/pmocontrol/src/capabilities.rs +++ b/pmocontrol/src/capabilities.rs @@ -1,6 +1,8 @@ // pmocontrol/src/capabilities.rs use anyhow::Result; +use crate::errors::ControlPointError; + /// Logical playback position across backends. /// /// Times peuvent être soit en secondes, soit en "HH:MM:SS" selon ce que @@ -16,7 +18,7 @@ pub struct PlaybackPositionInfo { pub track_uri: Option, // Current track URI } pub trait PlaybackPosition { - fn playback_position(&self) -> Result; + fn playback_position(&self) -> Result; } /// High-level playback state across backends. @@ -69,7 +71,7 @@ impl PlaybackState { /// For UPnP AV, this is backed by AVTransport::GetTransportInfo. /// For OpenHome, a future implementation will adapt from OH Info/Time. pub trait PlaybackStatus { - fn playback_state(&self) -> Result; + fn playback_state(&self) -> Result; } /// Abstraction générique des capacités de transport (lecture / pause / stop / seek) @@ -80,32 +82,32 @@ pub trait TransportControl { /// Selon l'implémentation, cette méthode peut soit : /// - faire un "Set...URI" + "Play" (cas UPnP AV), /// - ou configurer la file de lecture (cas OpenHome, etc.). - fn play_uri(&self, uri: &str, meta: &str) -> Result<()>; + fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError>; /// Démarre ou reprend la lecture. - fn play(&self) -> Result<()>; + fn play(&self) -> Result<(), ControlPointError>; /// Met la lecture en pause. - fn pause(&self) -> Result<()>; + fn pause(&self) -> Result<(), ControlPointError>; /// Arrête la lecture. - fn stop(&self) -> Result<()>; + fn stop(&self) -> Result<(), ControlPointError>; /// Seek à un temps relatif (HH:MM:SS) si supporté. - fn seek_rel_time(&self, hhmmss: &str) -> Result<()>; + fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError>; } /// Abstraction générique des capacités de contrôle de volume / mute. pub trait VolumeControl { /// Retourne le volume logique courant (échelle dépendante du renderer). - fn volume(&self) -> Result; + fn volume(&self) -> Result; /// Définit le volume logique (échelle dépendante du renderer). - fn set_volume(&self, v: u16) -> Result<()>; + fn set_volume(&self, v: u16) -> Result<(), ControlPointError>; /// Indique si le renderer est muet (mute activé). - fn mute(&self) -> Result; + fn mute(&self) -> Result; /// Active ou désactive le mute. - fn set_mute(&self, m: bool) -> Result<()>; + fn set_mute(&self, m: bool) -> Result<(), ControlPointError>; } diff --git a/pmocontrol/src/chromecast_renderer.rs b/pmocontrol/src/chromecast_renderer.rs index 7f7b5d2b..e0906d86 100644 --- a/pmocontrol/src/chromecast_renderer.rs +++ b/pmocontrol/src/chromecast_renderer.rs @@ -18,12 +18,14 @@ use anyhow::{Result, anyhow}; use tracing::debug; +use crate::DeviceIdentity; use crate::capabilities::{ PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, VolumeControl, }; use crate::chromecast_discovery::{extract_host_from_location, extract_port_from_location}; -use crate::model::{RendererId, RendererInfo, RendererProtocol}; +use crate::errors::ControlPointError; +use crate::model::{RendererInfo}; use rust_cast::{ CastDevice, ChannelMessage, @@ -46,7 +48,6 @@ const DEFAULT_CHROMECAST_PORT: u16 = 8009; /// spawned to handle heartbeat responses from the device. #[derive(Clone)] pub struct ChromecastRenderer { - pub info: RendererInfo, host: String, port: u16, stop_signal: Arc>, @@ -58,7 +59,6 @@ pub struct ChromecastRenderer { impl std::fmt::Debug for ChromecastRenderer { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ChromecastRenderer") - .field("info", &self.info) .field("host", &self.host) .field("port", &self.port) .finish() @@ -76,29 +76,31 @@ fn ensure_crypto_provider_initialized() { INIT.call_once(|| { // Install the default CryptoProvider (aws-lc-rs or ring, depending on features) let _ = rustls::crypto::CryptoProvider::install_default( - rustls::crypto::aws_lc_rs::default_provider() + rustls::crypto::aws_lc_rs::default_provider(), ); tracing::debug!("Rustls CryptoProvider initialized for Chromecast connections"); }); } /// Helper function to connect to a Chromecast device. -fn connect_to_device<'a>(host: &'a str, port: u16) -> Result> { +fn connect_to_device<'a>(host: &'a str, port: u16) -> Result, ControlPointError> { // Ensure rustls crypto provider is initialized before any TLS connection ensure_crypto_provider_initialized(); let device = CastDevice::connect_without_host_verification(host, port) - .map_err(|e| anyhow!("Failed to connect to Chromecast: {}", e))?; + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to Chromecast: {}", e)))?; - device.connection + device + .connection .connect(DEFAULT_DESTINATION_ID.to_string()) - .map_err(|e| anyhow!("Failed to connect channel: {}", e))?; + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect channel: {}", e)))?; // Send initial gre to establish heartbeat communication // This is critical per rust_caster.rs example - device.heartbeat + device + .heartbeat .ping() - .map_err(|e| anyhow!("Failed to send initial heartbeat ping: {}", e))?; + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to send initial heartbeat ping: {}", e)))?; Ok(device) } @@ -113,62 +115,42 @@ fn map_player_state(player_state: &CastPlayerState) -> PlaybackState { } } + impl ChromecastRenderer { /// Creates a new ChromecastRenderer from RendererInfo. pub fn from_renderer_info(info: RendererInfo) -> Result { tracing::info!( "ChromecastRenderer::from_renderer_info location={} for {}", - info.location, - info.friendly_name + info.location(), + info.friendly_name() ); - let host = extract_host_from_location(&info.location) - .ok_or_else(|| anyhow!("Invalid Chromecast location: {}", info.location))?; + let host = extract_host_from_location(info.location()) + .ok_or_else(|| ControlPointError::ChromecastError(format!("Invalid Chromecast location: {}", info.location())))?; - let port = extract_port_from_location(&info.location) - .unwrap_or(DEFAULT_CHROMECAST_PORT); + let port = extract_port_from_location(info.location()).unwrap_or(DEFAULT_CHROMECAST_PORT); let stop_signal = Arc::new(Mutex::new(false)); let thread_handle = Arc::new(Mutex::new(None)); tracing::info!( "ChromecastRenderer created for {} with host={} port={}", - info.friendly_name, + info.friendly_name(), host, port ); Ok(Self { - info, host, port, stop_signal, thread_handle, }) } - /// Returns the renderer ID. - pub fn id(&self) -> &RendererId { - &self.info.id - } - - /// Returns the friendly name. - pub fn friendly_name(&self) -> &str { - &self.info.friendly_name - } - - /// Returns the protocol. - pub fn protocol(&self) -> &RendererProtocol { - &self.info.protocol - } - - /// Returns the renderer info. - pub fn info(&self) -> &RendererInfo { - &self.info - } } impl TransportControl for ChromecastRenderer { - fn play_uri(&self, uri: &str, meta: &str) -> Result<()> { + fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { debug!("ChromecastRenderer: play_uri({})", uri); // Signal any existing play thread to stop @@ -184,8 +166,7 @@ impl TransportControl for ChromecastRenderer { // Wait for thread to finish (it should see stop_signal and exit) // Note: device.receive() may block, so thread might take time to notice stop_signal - let join_result = std::thread::spawn(move || handle.join()) - .join(); + let join_result = std::thread::spawn(move || handle.join()).join(); match join_result { Ok(Ok(())) => { @@ -225,7 +206,10 @@ impl TransportControl for ChromecastRenderer { }; // Launch DefaultMediaReceiver app - let app = match device.receiver.launch_app(&CastDeviceApp::DefaultMediaReceiver) { + let app = match device + .receiver + .launch_app(&CastDeviceApp::DefaultMediaReceiver) + { Ok(app) => app, Err(e) => { tracing::error!("Failed to launch DefaultMediaReceiver: {}", e); @@ -249,11 +233,10 @@ impl TransportControl for ChromecastRenderer { metadata: None, }; - match device.media.load( - app.transport_id.as_str(), - app.session_id.as_str(), - &media, - ) { + match device + .media + .load(app.transport_id.as_str(), app.session_id.as_str(), &media) + { Ok(status) => { tracing::info!("Media loaded successfully: {:?}", status); } @@ -314,63 +297,87 @@ impl TransportControl for ChromecastRenderer { Ok(()) } - fn play(&self) -> Result<()> { + fn play(&self) -> Result<(), ControlPointError> { debug!("ChromecastRenderer: play()"); let device = connect_to_device(&self.host, self.port)?; // Get receiver status to find the active app - let status = device.receiver.get_status() - .map_err(|e| anyhow!("Failed to get receiver status: {}", e))?; + let status = device + .receiver + .get_status() + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?; - let app = status.applications.first() - .ok_or_else(|| anyhow!("No active app found"))?; + let app = status + .applications + .first() + .ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?; // Connect to the app - device.connection.connect(app.transport_id.as_str()) - .map_err(|e| anyhow!("Failed to connect to app: {}", e))?; + device + .connection + .connect(app.transport_id.as_str()) + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?; // Get media status - let media_status = device.media.get_status(app.transport_id.as_str(), None) - .map_err(|e| anyhow!("Failed to get media status: {}", e))?; + let media_status = device + .media + .get_status(app.transport_id.as_str(), None) + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?; - let media_entry = media_status.entries.first() - .ok_or_else(|| anyhow!("No media session found"))?; + let media_entry = media_status + .entries + .first() + .ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?; // Send play command - device.media.play(app.transport_id.as_str(), media_entry.media_session_id) - .map_err(|e| anyhow!("Failed to play: {}", e))?; + device + .media + .play(app.transport_id.as_str(), media_entry.media_session_id) + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to play: {}", e)))?; Ok(()) } - fn pause(&self) -> Result<()> { + fn pause(&self) -> Result<(), ControlPointError> { debug!("ChromecastRenderer: pause()"); let device = connect_to_device(&self.host, self.port)?; - let status = device.receiver.get_status() - .map_err(|e| anyhow!("Failed to get receiver status: {}", e))?; + let status = device + .receiver + .get_status() + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?; - let app = status.applications.first() - .ok_or_else(|| anyhow!("No active app found"))?; + let app = status + .applications + .first() + .ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?; - device.connection.connect(app.transport_id.as_str()) - .map_err(|e| anyhow!("Failed to connect to app: {}", e))?; + device + .connection + .connect(app.transport_id.as_str()) + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?; - let media_status = device.media.get_status(app.transport_id.as_str(), None) - .map_err(|e| anyhow!("Failed to get media status: {}", e))?; + let media_status = device + .media + .get_status(app.transport_id.as_str(), None) + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?; - let media_entry = media_status.entries.first() - .ok_or_else(|| anyhow!("No media session found"))?; + let media_entry = media_status + .entries + .first() + .ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?; - device.media.pause(app.transport_id.as_str(), media_entry.media_session_id) - .map_err(|e| anyhow!("Failed to pause: {}", e))?; + device + .media + .pause(app.transport_id.as_str(), media_entry.media_session_id) + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to pause: {}", e)))?; Ok(()) } - fn stop(&self) -> Result<()> { + fn stop(&self) -> Result<(), ControlPointError> { debug!("ChromecastRenderer: stop()"); // Signal the play thread to stop @@ -385,81 +392,113 @@ impl TransportControl for ChromecastRenderer { // Also send stop command to the device let device = connect_to_device(&self.host, self.port)?; - let status = device.receiver.get_status() - .map_err(|e| anyhow!("Failed to get receiver status: {}", e))?; + let status = device + .receiver + .get_status() + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?; - let app = status.applications.first() - .ok_or_else(|| anyhow!("No active app found"))?; + let app = status + .applications + .first() + .ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?; - device.connection.connect(app.transport_id.as_str()) - .map_err(|e| anyhow!("Failed to connect to app: {}", e))?; + device + .connection + .connect(app.transport_id.as_str()) + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?; - let media_status = device.media.get_status(app.transport_id.as_str(), None) - .map_err(|e| anyhow!("Failed to get media status: {}", e))?; + let media_status = device + .media + .get_status(app.transport_id.as_str(), None) + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?; - let media_entry = media_status.entries.first() - .ok_or_else(|| anyhow!("No media session found"))?; + let media_entry = media_status + .entries + .first() + .ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?; - device.media.stop(app.transport_id.as_str(), media_entry.media_session_id) - .map_err(|e| anyhow!("Failed to stop: {}", e))?; + device + .media + .stop(app.transport_id.as_str(), media_entry.media_session_id) + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to stop: {}", e)))?; Ok(()) } - fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { + fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { debug!("ChromecastRenderer: seek_rel_time({})", hhmmss); // Parse HH:MM:SS to seconds let parts: Vec<&str> = hhmmss.split(':').collect(); if parts.len() != 3 { - return Err(anyhow!("Invalid time format, expected HH:MM:SS: {}", hhmmss)); + return Err(ControlPointError::ChromecastError(format!( + "Invalid time format, expected HH:MM:SS: {}", + hhmmss + ))); } - let hours: u32 = parts[0].parse() - .map_err(|_| anyhow!("Invalid hours in time: {}", hhmmss))?; - let minutes: u32 = parts[1].parse() - .map_err(|_| anyhow!("Invalid minutes in time: {}", hhmmss))?; - let seconds: u32 = parts[2].parse() - .map_err(|_| anyhow!("Invalid seconds in time: {}", hhmmss))?; + let hours: u32 = parts[0] + .parse() + .map_err(|_| ControlPointError::ChromecastError(format!("Invalid hours in time: {}", hhmmss)))?; + let minutes: u32 = parts[1] + .parse() + .map_err(|_| ControlPointError::ChromecastError(format!("Invalid minutes in time: {}", hhmmss)))?; + let seconds: u32 = parts[2] + .parse() + .map_err(|_| ControlPointError::ChromecastError(format!("Invalid seconds in time: {}", hhmmss)))?; let total_seconds = (hours * 3600 + minutes * 60 + seconds) as f32; let device = connect_to_device(&self.host, self.port)?; - let status = device.receiver.get_status() - .map_err(|e| anyhow!("Failed to get receiver status: {}", e))?; + let status = device + .receiver + .get_status() + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?; - let app = status.applications.first() - .ok_or_else(|| anyhow!("No active app found"))?; + let app = status + .applications + .first() + .ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?; - device.connection.connect(app.transport_id.as_str()) - .map_err(|e| anyhow!("Failed to connect to app: {}", e))?; + device + .connection + .connect(app.transport_id.as_str()) + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?; - let media_status = device.media.get_status(app.transport_id.as_str(), None) - .map_err(|e| anyhow!("Failed to get media status: {}", e))?; + let media_status = device + .media + .get_status(app.transport_id.as_str(), None) + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?; - let media_entry = media_status.entries.first() - .ok_or_else(|| anyhow!("No media session found"))?; + let media_entry = media_status + .entries + .first() + .ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?; - device.media.seek( - app.transport_id.as_str(), - media_entry.media_session_id, - Some(total_seconds), - None, - ) - .map_err(|e| anyhow!("Failed to seek: {}", e))?; + device + .media + .seek( + app.transport_id.as_str(), + media_entry.media_session_id, + Some(total_seconds), + None, + ) + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to seek: {}", e)))?; Ok(()) } } impl PlaybackStatus for ChromecastRenderer { - fn playback_state(&self) -> Result { + fn playback_state(&self) -> Result { let device = connect_to_device(&self.host, self.port)?; // Get receiver status to find the active app - let status = device.receiver.get_status() - .map_err(|e| anyhow!("Failed to get receiver status: {}", e))?; + let status = device + .receiver + .get_status() + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?; // If no app is running, return NoMedia let app = match status.applications.first() { @@ -468,12 +507,16 @@ impl PlaybackStatus for ChromecastRenderer { }; // Connect to the app - device.connection.connect(app.transport_id.as_str()) - .map_err(|e| anyhow!("Failed to connect to app: {}", e))?; + device + .connection + .connect(app.transport_id.as_str()) + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?; // Get media status - let media_status = device.media.get_status(app.transport_id.as_str(), None) - .map_err(|e| anyhow!("Failed to get media status: {}", e))?; + let media_status = device + .media + .get_status(app.transport_id.as_str(), None) + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?; // If no media entry, return NoMedia let media_entry = match media_status.entries.first() { @@ -486,37 +529,49 @@ impl PlaybackStatus for ChromecastRenderer { } impl PlaybackPosition for ChromecastRenderer { - fn playback_position(&self) -> Result { + fn playback_position(&self) -> Result { let device = connect_to_device(&self.host, self.port)?; // Get receiver status to find the active app - let status = device.receiver.get_status() - .map_err(|e| anyhow!("Failed to get receiver status: {}", e))?; + let status = device + .receiver + .get_status() + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?; - let app = status.applications.first() - .ok_or_else(|| anyhow!("No active app found"))?; + let app = status + .applications + .first() + .ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?; // Connect to the app - device.connection.connect(app.transport_id.as_str()) - .map_err(|e| anyhow!("Failed to connect to app: {}", e))?; + device + .connection + .connect(app.transport_id.as_str()) + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?; // Get media status - let media_status = device.media.get_status(app.transport_id.as_str(), None) - .map_err(|e| anyhow!("Failed to get media status: {}", e))?; + let media_status = device + .media + .get_status(app.transport_id.as_str(), None) + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?; - let media_entry = media_status.entries.first() - .ok_or_else(|| anyhow!("No media session found"))?; + let media_entry = media_status + .entries + .first() + .ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?; // Extract position information - let rel_time = media_entry.current_time + let rel_time = media_entry + .current_time .map(|time| format_time_hhmmss(time as f64)); - let track_duration = media_entry.media.as_ref() + let track_duration = media_entry + .media + .as_ref() .and_then(|m| m.duration) .map(|dur| format_time_hhmmss(dur as f64)); - let track_uri = media_entry.media.as_ref() - .map(|m| m.content_id.clone()); + let track_uri = media_entry.media.as_ref().map(|m| m.content_id.clone()); Ok(PlaybackPositionInfo { track: Some(1), @@ -600,11 +655,13 @@ fn format_time_hhmmss(seconds: f64) -> String { } impl VolumeControl for ChromecastRenderer { - fn volume(&self) -> Result { + fn volume(&self) -> Result { let device = connect_to_device(&self.host, self.port)?; - let status = device.receiver.get_status() - .map_err(|e| anyhow!("Failed to get receiver status: {}", e))?; + let status = device + .receiver + .get_status() + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?; if let Some(level) = status.volume.level { Ok((level * 100.0) as u16) @@ -613,34 +670,40 @@ impl VolumeControl for ChromecastRenderer { } } - fn set_volume(&self, volume: u16) -> Result<()> { + fn set_volume(&self, volume: u16) -> Result<(), ControlPointError> { debug!("ChromecastRenderer: set_volume({})", volume); let device = connect_to_device(&self.host, self.port)?; let level = (volume as f32) / 100.0; - device.receiver.set_volume(level) - .map_err(|e| anyhow!("Failed to set volume: {}", e))?; + device + .receiver + .set_volume(level) + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to set volume: {}", e)))?; Ok(()) } - fn mute(&self) -> Result { + fn mute(&self) -> Result { let device = connect_to_device(&self.host, self.port)?; - let status = device.receiver.get_status() - .map_err(|e| anyhow!("Failed to get receiver status: {}", e))?; + let status = device + .receiver + .get_status() + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?; Ok(status.volume.muted.unwrap_or(false)) } - fn set_mute(&self, mute: bool) -> Result<()> { + fn set_mute(&self, mute: bool) -> Result<(), ControlPointError> { debug!("ChromecastRenderer: set_mute({})", mute); let device = connect_to_device(&self.host, self.port)?; - device.receiver.set_volume(mute) - .map_err(|e| anyhow!("Failed to set mute: {}", e))?; + device + .receiver + .set_volume(mute) + .map_err(|e| ControlPointError::ChromecastError(format!("Failed to set mute: {}", e)))?; Ok(()) } diff --git a/pmocontrol/src/connection_manager_client.rs b/pmocontrol/src/connection_manager_client.rs index ae468928..a2ab2f21 100644 --- a/pmocontrol/src/connection_manager_client.rs +++ b/pmocontrol/src/connection_manager_client.rs @@ -1,8 +1,8 @@ use anyhow::{Result, anyhow}; -use crate::soap_client::{SoapCallResult, invoke_upnp_action}; -use pmoupnp::soap::SoapEnvelope; -use xmltree::{Element, XMLNode}; +use crate::{errors::ControlPointError, soap_client::{ + SoapCallResult, ensure_success, extract_child_text, extract_child_text_allow_empty, find_child_with_suffix, invoke_upnp_action, parse_upnp_error +}}; #[derive(Debug, Clone)] pub struct ConnectionManagerClient { @@ -38,7 +38,7 @@ impl ConnectionManagerClient { } /// GetProtocolInfo - pub fn get_protocol_info(&self) -> Result { + pub fn get_protocol_info(&self) -> Result { let call_result = invoke_upnp_action( &self.control_url, &self.service_type, @@ -51,19 +51,19 @@ impl ConnectionManagerClient { let envelope = call_result .envelope .as_ref() - .ok_or_else(|| anyhow!("Missing SOAP envelope in GetProtocolInfo response"))?; + .ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetProtocolInfo response")))?; if let Some(err) = parse_upnp_error(envelope) { - return Err(anyhow!( + return Err(ControlPointError::UpnpError(format!( "GetProtocolInfo returned UPnP error {}: {} (HTTP status {})", err.error_code, err.error_description, call_result.status - )); + ))); } let response = find_child_with_suffix(&envelope.body.content, "GetProtocolInfoResponse") - .ok_or_else(|| anyhow!("Missing GetProtocolInfoResponse element in SOAP body"))?; + .ok_or_else(|| ControlPointError::UpnpError(format!("Missing GetProtocolInfoResponse element in SOAP body")))?; let source_text = extract_child_text_allow_empty(response, "Source")?; let sink_text = extract_child_text_allow_empty(response, "Sink")?; @@ -75,7 +75,7 @@ impl ConnectionManagerClient { } /// GetCurrentConnectionIDs - pub fn get_current_connection_ids(&self) -> Result> { + pub fn get_current_connection_ids(&self) -> Result, ControlPointError> { let call_result = invoke_upnp_action( &self.control_url, &self.service_type, @@ -88,21 +88,21 @@ impl ConnectionManagerClient { let envelope = call_result .envelope .as_ref() - .ok_or_else(|| anyhow!("Missing SOAP envelope in GetCurrentConnectionIDs response"))?; + .ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetCurrentConnectionIDs response")))?; if let Some(err) = parse_upnp_error(envelope) { - return Err(anyhow!( + return Err(ControlPointError::UpnpError(format!( "GetCurrentConnectionIDs returned UPnP error {}: {} (HTTP status {})", err.error_code, err.error_description, call_result.status - )); + ))); } let response = find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionIDsResponse") .ok_or_else(|| { - anyhow!("Missing GetCurrentConnectionIDsResponse element in SOAP body") + ControlPointError::UpnpError(format!("Missing GetCurrentConnectionIDsResponse element in SOAP body")) })?; let ids_text = extract_child_text_allow_empty(response, "ConnectionIDs")?; @@ -120,7 +120,7 @@ impl ConnectionManagerClient { } let parsed = value .parse::() - .map_err(|_| anyhow!("Invalid ConnectionID value: {}", value))?; + .map_err(|_| ControlPointError::UpnpError(format!("Invalid ConnectionID value: {}", value)))?; ids.push(parsed); } @@ -128,7 +128,7 @@ impl ConnectionManagerClient { } /// GetCurrentConnectionInfo - pub fn get_current_connection_info(&self, connection_id: i32) -> Result { + pub fn get_current_connection_info(&self, connection_id: i32) -> Result { let connection_id_str = connection_id.to_string(); let args = [("ConnectionID", connection_id_str.as_str())]; @@ -144,30 +144,30 @@ impl ConnectionManagerClient { let envelope = call_result .envelope .as_ref() - .ok_or_else(|| anyhow!("Missing SOAP envelope in GetCurrentConnectionInfo response"))?; + .ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetCurrentConnectionInfo response")))?; if let Some(err) = parse_upnp_error(envelope) { - return Err(anyhow!( + return Err(ControlPointError::UpnpError(format!( "GetCurrentConnectionInfo returned UPnP error {}: {} (HTTP status {})", err.error_code, err.error_description, call_result.status - )); + ))); } let response = find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionInfoResponse") .ok_or_else(|| { - anyhow!("Missing GetCurrentConnectionInfoResponse element in SOAP body") + ControlPointError::UpnpError(format!("Missing GetCurrentConnectionInfoResponse element in SOAP body")) })?; let rcs_id = extract_child_text(response, "RcsID")? .parse::() - .map_err(|_| anyhow!("Invalid RcsID value in response"))?; + .map_err(|_| ControlPointError::UpnpError(format!("Invalid RcsID value in response")))?; let av_transport_id = extract_child_text(response, "AVTransportID")? .parse::() - .map_err(|_| anyhow!("Invalid AVTransportID value in response"))?; + .map_err(|_| ControlPointError::UpnpError(format!("Invalid AVTransportID value in response")))?; let protocol_info = extract_child_text_allow_empty(response, "ProtocolInfo")?; let peer_connection_manager = @@ -175,7 +175,7 @@ impl ConnectionManagerClient { let peer_connection_id = extract_child_text(response, "PeerConnectionID")? .parse::() - .map_err(|_| anyhow!("Invalid PeerConnectionID value in response"))?; + .map_err(|_| ControlPointError::UpnpError(format!("Invalid PeerConnectionID value in response")))?; let direction = extract_child_text(response, "Direction")?; let status = extract_child_text(response, "Status")?; @@ -205,90 +205,3 @@ fn split_list(value: &str) -> Vec { }) .collect() } - -fn ensure_success(action: &str, call_result: &SoapCallResult) -> Result<()> { - if call_result.status.is_success() { - return Ok(()); - } - - if let Some(env) = &call_result.envelope { - if let Some(err) = parse_upnp_error(env) { - return Err(anyhow!( - "{action} failed with UPnP error {}: {} (HTTP status {})", - err.error_code, - err.error_description, - call_result.status - )); - } - } - - Err(anyhow!( - "{action} failed with HTTP status {} and body: {}", - call_result.status, - call_result.raw_body - )) -} - -#[derive(Debug, Clone)] -struct UpnpError { - pub error_code: u32, - pub error_description: String, -} - -fn parse_upnp_error(envelope: &SoapEnvelope) -> Option { - let fault = find_child_with_suffix(&envelope.body.content, "Fault")?; - let detail = find_child_with_suffix(fault, "detail")?; - let upnp_error = find_child_with_suffix(detail, "UPnPError")?; - - let error_code_elem = upnp_error.children.iter().find_map(|node| match node { - XMLNode::Element(elem) if elem.name.ends_with("errorCode") => Some(elem), - _ => None, - })?; - - let binding = error_code_elem.get_text()?; - let error_code_text = binding.trim(); - let error_code = error_code_text.parse::().ok()?; - - let error_description = upnp_error - .children - .iter() - .find_map(|node| match node { - XMLNode::Element(elem) if elem.name.ends_with("errorDescription") => { - elem.get_text().map(|t| t.trim().to_string()) - } - _ => None, - }) - .unwrap_or_else(|| String::from("")); - - Some(UpnpError { - error_code, - error_description, - }) -} - -fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> { - parent.children.iter().find_map(|node| match node { - XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem), - _ => None, - }) -} - -fn extract_child_text(parent: &Element, suffix: &str) -> Result { - let text = extract_child_text_allow_empty(parent, suffix)?; - if text.is_empty() { - return Err(anyhow!("{suffix} element missing text in response")); - } - Ok(text) -} - -fn extract_child_text_allow_empty(parent: &Element, suffix: &str) -> Result { - let child = find_child_with_suffix(parent, suffix) - .ok_or_else(|| anyhow!("Missing {suffix} element in response"))?; - - let text = child - .get_text() - .map(|t| t.trim().to_string()) - .unwrap_or_default(); - - Ok(text) -} diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index 9fe63297..d81d2251 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -32,14 +32,12 @@ use crate::control_point::openhome_queue::OpenHomeQueue; use crate::discovery::DiscoveryManager; use crate::events::{MediaServerEventBus, RendererEventBus}; use crate::media_server::{ - playback_item_from_entry, MediaBrowser, MediaServerInfo, MusicServer, ServerId, + playback_item_from_entry, MediaBrowser, UpnpMediaServer, UpnpMediaServer, ServerId, }; use crate::media_server_events::spawn_media_server_event_runtime; use crate::model::TrackMetadata; -use crate::model::{MediaServerEvent, RendererEvent, RendererId, RendererInfo}; -use crate::music_renderer::{ - set_openhome_queue_provider, OpenHomeQueueProvider, RendererRuntimeState, -}; +use crate::model::{MediaServerEvent, RendererEvent, ServiceId, RendererInfo}; +use crate::music_renderer::MusicRenderer; #[cfg(feature = "pmoserver")] use crate::openapi::{ CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView, @@ -52,11 +50,13 @@ use crate::openhome_client::parse_track_metadata_from_didl; use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; use crate::openhome_renderer::{format_seconds, map_openhome_state}; use crate::provider::HttpXmlDescriptionProvider; -use crate::queue_backend::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot}; +use crate::queue::backend::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot}; use crate::queue_interne::InternalQueue; use crate::registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate}; use crate::upnp_renderer::UpnpRenderer; -use crate::MusicRenderer; +use crate::MusicRendererBackend; +use crate::MusicRendererInfo; + /// Optional attachment between a renderer playback queue and a server-side /// DIDL-Lite playlist container. @@ -97,6 +97,7 @@ pub enum OpenHomeAccessError { /// et n'utiliser les événements SSE que comme signaux de rafraîchissement. pub struct ControlPoint { registry: Arc>, + udn_cache: Arc>, event_bus: RendererEventBus, media_event_bus: MediaServerEventBus, runtime: Arc, @@ -105,13 +106,13 @@ pub struct ControlPoint { /// /// Key : RendererId /// Value : PlaylistBinding - playlist_bindings: Arc>>, + playlist_bindings: Arc>>, /// Cache of MusicRenderer instances to avoid recreating them. /// This is critical for Chromecast which maintains a persistent TLS connection. /// /// Key : RendererId /// Value : MusicRenderer - renderer_cache: Arc>>, + renderer_cache: Arc>>, } impl ControlPoint { @@ -123,9 +124,9 @@ impl ControlPoint { let event_bus = RendererEventBus::new(); let media_event_bus = MediaServerEventBus::new(); let runtime = Arc::new(RuntimeState::new()); - set_openhome_queue_provider(Arc::new(RuntimeOpenHomeQueueProvider { - runtime: Arc::clone(&runtime), - })); + // set_openhome_queue_provider(Arc::new(RuntimeOpenHomeQueueProvider { + // runtime: Arc::clone(&runtime), + // })); let playlist_bindings = Arc::new(Mutex::new(HashMap::new())); let renderer_cache = Arc::new(Mutex::new(HashMap::new())); @@ -416,7 +417,7 @@ impl ControlPoint { }; // Build a map of current renderer IDs for cleanup - let current_ids: HashSet = infos.iter().map(|i| i.id.clone()).collect(); + let current_ids: HashSet = infos.iter().map(|i| i.id.clone()).collect(); // Remove offline renderers from shared cache { @@ -425,7 +426,7 @@ impl ControlPoint { } // Get or create renderers from shared cache - let renderers: Vec = infos + let renderers: Vec = infos .into_iter() .filter_map(|info| { let id = info.id.clone(); @@ -439,7 +440,7 @@ impl ControlPoint { } // Create new renderer and add to cache - if let Some(renderer) = MusicRenderer::from_registry_info(info, &runtime_cp.registry) { + if let Some(renderer) = MusicRendererBackend::from_renderer_info(info, &runtime_cp.registry) { let mut cache = runtime_cp.renderer_cache.lock().unwrap(); cache.insert(id, renderer.clone()); Some(renderer) @@ -686,7 +687,7 @@ impl ControlPoint { server_id, container_ids, } => { - let renderers_to_refresh: Vec = { + let renderers_to_refresh: Vec = { let mut bindings = bindings_for_media_worker.lock().unwrap(); let mut to_refresh = Vec::new(); @@ -758,7 +759,7 @@ impl ControlPoint { thread::sleep(Duration::from_secs(60)); // Collect all renderers with active bindings and mark them for refresh - let renderers_to_refresh: Vec = { + let renderers_to_refresh: Vec = { let mut bindings = bindings_for_periodic.lock().unwrap(); let mut to_refresh = Vec::new(); @@ -819,34 +820,32 @@ impl ControlPoint { infos .into_iter() - .map(|info| UpnpRenderer::from_registry(info, &self.registry)) + .map(|info| UpnpRenderer::from_info(&info)) .collect() } /// Return the first renderer in the registry, if any. - pub fn default_upnp_renderer(&self) -> Option { + pub fn default_upnp_renderer(&self) -> Option> { let info = { let reg = self.registry.read().unwrap(); reg.list_renderers().into_iter().next() }?; - Some(UpnpRenderer::from_registry(info, &self.registry)) + Some(UpnpRenderer::from_info(&info)) } /// Lookup a renderer by id. - pub fn upnp_renderer_by_id(&self, id: &RendererId) -> Option { - let info = { + pub fn renderer_by_id(&self, id: &ServiceId) -> Option> { + let reg = self.registry.read().unwrap(); reg.get_renderer(id) - }?; - - Some(UpnpRenderer::from_registry(info, &self.registry)) + } /// Internal helper to get or create a renderer from the cache. /// This ensures that Chromecast renderers maintain their persistent connections. - fn get_or_create_renderer(&self, info: RendererInfo) -> Option { - let id = info.id.clone(); + fn get_or_create_renderer(&self, info: RendererInfo) -> Option { + let id = info.id(); // Try to get from cache first { @@ -857,7 +856,7 @@ impl ControlPoint { } // Not in cache, create new renderer - if let Some(renderer) = MusicRenderer::from_registry_info(info, &self.registry) { + if let Some(renderer) = MusicRendererBackend::from_renderer_info(info, &self.registry) { // Add to cache let mut cache = self.renderer_cache.lock().unwrap(); cache.insert(id, renderer.clone()); @@ -868,7 +867,7 @@ impl ControlPoint { } /// Snapshot list of music renderers (protocol-agnostic view). - pub fn list_music_renderers(&self) -> Vec { + pub fn list_music_renderers(&self) -> Vec { let infos = { let reg = self.registry.read().unwrap(); reg.list_renderers() @@ -876,7 +875,7 @@ impl ControlPoint { // Clean up cache - remove renderers that are no longer in the registry { - let current_ids: HashSet = infos.iter().map(|i| i.id.clone()).collect(); + let current_ids: HashSet = infos.iter().map(|i| i.id.clone()).collect(); let mut cache = self.renderer_cache.lock().unwrap(); cache.retain(|id, _| current_ids.contains(id)); } @@ -888,7 +887,7 @@ impl ControlPoint { } /// Return the first music renderer in the registry, if any. - pub fn default_music_renderer(&self) -> Option { + pub fn default_music_renderer(&self) -> Option { let infos = { let reg = self.registry.read().unwrap(); reg.list_renderers() @@ -900,7 +899,7 @@ impl ControlPoint { } /// Lookup a music renderer by id. - pub fn music_renderer_by_id(&self, id: &RendererId) -> Option { + pub fn music_renderer_by_id(&self, id: &ServiceId) -> Option { let info = { let reg = self.registry.read().unwrap(); reg.get_renderer(id) @@ -910,13 +909,13 @@ impl ControlPoint { } /// Snapshot list of media servers currently known by the registry. - pub fn list_media_servers(&self) -> Vec { + pub fn list_media_servers(&self) -> Vec { let reg = self.registry.read().unwrap(); reg.list_servers() } /// Lookup a media server by id. - pub fn media_server(&self, id: &ServerId) -> Option { + pub fn media_server(&self, id: &ServerId) -> Option { let reg = self.registry.read().unwrap(); reg.get_server(id) } @@ -928,7 +927,7 @@ impl ControlPoint { /// attachment stays consistent with the local `QueueBackend` snapshot. /// The actual structural change then goes through the backend helpers /// (`QueueBackend::clear_queue` via `RuntimeState::with_music_queue_mut`). - pub fn clear_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> { + pub fn clear_queue(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { if !self.runtime.has_entry(renderer_id) { let err = Self::runtime_entry_missing(renderer_id); warn!( @@ -981,7 +980,7 @@ impl ControlPoint { /// inside `RuntimeState::with_music_queue_mut`). pub fn enqueue_items( &self, - renderer_id: &RendererId, + renderer_id: &ServiceId, items: Vec, ) -> anyhow::Result<()> { if !self.runtime.has_entry(renderer_id) { @@ -1026,7 +1025,7 @@ impl ControlPoint { /// view, prefer [`get_full_queue_snapshot`]. pub fn get_queue_snapshot( &self, - renderer_id: &RendererId, + renderer_id: &ServiceId, ) -> anyhow::Result> { if !self.runtime.has_entry(renderer_id) { let err = Self::runtime_entry_missing(renderer_id); @@ -1049,7 +1048,7 @@ impl ControlPoint { /// mutating the runtime. pub fn get_full_queue_snapshot( &self, - renderer_id: &RendererId, + renderer_id: &ServiceId, ) -> anyhow::Result<(Vec, Option)> { if !self.runtime.has_entry(renderer_id) { // Renderer not yet initialized in runtime (just discovered via SSDP) @@ -1070,7 +1069,7 @@ impl ControlPoint { /// /// Useful for UI layers that want to display the currently playing /// track even when the renderer is not returning metadata via UPnP. - pub fn get_current_track_metadata(&self, renderer_id: &RendererId) -> Option { + pub fn get_current_track_metadata(&self, renderer_id: &ServiceId) -> Option { self.runtime.current_track_metadata(renderer_id) } @@ -1080,7 +1079,7 @@ impl ControlPoint { /// or None if it doesn't (e.g., AVTransport). pub fn get_renderer_queue_snapshot( &self, - renderer_id: &RendererId, + renderer_id: &ServiceId, ) -> anyhow::Result> { let renderer = self.music_renderer_by_id(renderer_id) .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; @@ -1090,14 +1089,14 @@ impl ControlPoint { /// Gets the length of the backend queue for renderers with persistent queues. /// /// Returns the queue length if the renderer has a backend queue, or 0 if it doesn't. - pub fn get_renderer_queue_length(&self, renderer_id: &RendererId) -> anyhow::Result { + pub fn get_renderer_queue_length(&self, renderer_id: &ServiceId) -> anyhow::Result { let snapshot = self.get_renderer_queue_snapshot(renderer_id)?; Ok(snapshot.map(|s| s.len()).unwrap_or(0)) } pub fn get_cached_openhome_playlist_snapshot( &self, - renderer_id: &RendererId, + renderer_id: &ServiceId, ttl: Duration, ) -> anyhow::Result { let renderer = self.openhome_renderer(renderer_id)?; @@ -1108,7 +1107,7 @@ impl ControlPoint { #[cfg(feature = "pmoserver")] pub fn renderer_full_snapshot( &self, - renderer_id: &RendererId, + renderer_id: &ServiceId, ) -> anyhow::Result { let renderer = self .music_renderer_by_id(renderer_id) @@ -1259,7 +1258,7 @@ impl ControlPoint { /// /// For renderers with persistent queues (OpenHome), this clears the queue on the renderer. /// For other renderers, this returns an error. - pub fn clear_renderer_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> { + pub fn clear_renderer_queue(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { let renderer = self.music_renderer_by_id(renderer_id) .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; renderer.clear_queue()?; @@ -1274,7 +1273,7 @@ impl ControlPoint { /// Returns the backend-specific track ID if applicable. pub fn add_track_to_renderer( &self, - renderer_id: &RendererId, + renderer_id: &ServiceId, uri: &str, metadata: &str, after_id: Option, @@ -1293,7 +1292,7 @@ impl ControlPoint { /// For other renderers, this returns an error. pub fn select_renderer_track( &self, - renderer_id: &RendererId, + renderer_id: &ServiceId, track_id: u32, ) -> anyhow::Result<()> { let renderer = self.music_renderer_by_id(renderer_id) @@ -1310,7 +1309,7 @@ impl ControlPoint { /// The method only reads queue content via the runtime helpers and /// delegates potential structural mutations to `QueueBackend` (when an item /// needs to be restored after a playback error). - pub fn play_current_from_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> { + pub fn play_current_from_queue(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { if !self.runtime.has_entry(renderer_id) { let err = Self::runtime_entry_missing(renderer_id); warn!( @@ -1395,7 +1394,7 @@ impl ControlPoint { /// /// The structural mutation uses the `QueueBackend::dequeue_next` helper /// (through `RuntimeState`) so that all pointer updates are consistent. - pub fn play_next_from_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> { + pub fn play_next_from_queue(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { if !self.runtime.has_entry(renderer_id) { let err = Self::runtime_entry_missing(renderer_id); warn!( @@ -1527,7 +1526,7 @@ impl ControlPoint { /// /// For OpenHome renderers, this uses the playlist's native SeekId capability. /// For internal queues, this updates the current index and starts playback. - pub fn play_queue_index(&self, renderer_id: &RendererId, index: usize) -> anyhow::Result<()> { + pub fn play_queue_index(&self, renderer_id: &ServiceId, index: usize) -> anyhow::Result<()> { if !self.runtime.has_entry(renderer_id) { let err = Self::runtime_entry_missing(renderer_id); warn!( @@ -1643,7 +1642,7 @@ impl ControlPoint { } } - fn start_queue_playback_if_idle(&self, renderer_id: &RendererId) -> anyhow::Result<()> { + fn start_queue_playback_if_idle(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { let snapshot = self.runtime.snapshot_for(renderer_id); let renderer_playing = matches!(snapshot.state, Some(PlaybackState::Playing)); let from_queue = self.runtime.is_playing_from_queue(renderer_id); @@ -1699,7 +1698,7 @@ impl ControlPoint { /// This method marks the stop as user-requested to prevent automatic /// advancement to the next track in the queue when the STOPPED event /// is received from the renderer. - pub fn user_stop(&self, renderer_id: &RendererId) -> anyhow::Result<()> { + pub fn user_stop(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { // Mark that user requested stop before actually stopping self.runtime.mark_user_stop_requested(renderer_id); @@ -1746,7 +1745,7 @@ impl ControlPoint { /// The queue will be automatically refreshed when the playlist changes on the server. pub fn attach_queue_to_playlist( &self, - renderer_id: &RendererId, + renderer_id: &ServiceId, server_id: ServerId, container_id: String, ) -> anyhow::Result<()> { @@ -1758,7 +1757,7 @@ impl ControlPoint { /// Same queue-mutation guarantees as [`attach_queue_to_playlist`]. pub fn attach_queue_to_playlist_with_options( &self, - renderer_id: &RendererId, + renderer_id: &ServiceId, server_id: ServerId, container_id: String, auto_play: bool, @@ -1769,7 +1768,7 @@ impl ControlPoint { /// Internal implementation shared by every attach wrapper. fn attach_queue_to_playlist_internal( &self, - renderer_id: &RendererId, + renderer_id: &ServiceId, server_id: &ServerId, container_id: &str, auto_play: bool, @@ -1834,14 +1833,14 @@ impl ControlPoint { }); // For initial attach with auto_play, force playback start (don't check if idle) - let mut auto_start_cb = |rid: &RendererId| { + let mut auto_start_cb = |rid: &ServiceId| { debug!( renderer = rid.0.as_str(), "Attach callback: forcing playback start (not checking if idle)" ); self.play_current_from_queue(rid) }; - let callback: Option<&mut dyn FnMut(&RendererId) -> anyhow::Result<()>> = if auto_play { + let callback: Option<&mut dyn FnMut(&ServiceId) -> anyhow::Result<()>> = if auto_play { Some(&mut auto_start_cb) } else { None @@ -1862,7 +1861,7 @@ impl ControlPoint { /// Public mutation API paired with `attach_queue_to_playlist*`. After calling /// this, the queue will no longer be automatically refreshed from the server. /// If no binding existed, this is a no-op. - pub fn detach_queue_playlist(&self, renderer_id: &RendererId) { + pub fn detach_queue_playlist(&self, renderer_id: &ServiceId) { self.detach_playlist_binding(renderer_id, "api_detach"); } @@ -1872,7 +1871,7 @@ impl ControlPoint { /// bound to a server playlist container, or `None` otherwise. pub fn current_queue_playlist_binding( &self, - renderer_id: &RendererId, + renderer_id: &ServiceId, ) -> Option<(ServerId, String, bool)> { let bindings = self.playlist_bindings.lock().unwrap(); bindings.get(renderer_id).map(|binding| { @@ -1888,7 +1887,7 @@ impl ControlPoint { /// /// Invariant: every user-driven queue mutation **must** call this method so /// that bindings never become out of sync with the local queue snapshot. - fn detach_playlist_binding(&self, renderer_id: &RendererId, reason: &str) { + fn detach_playlist_binding(&self, renderer_id: &ServiceId, reason: &str) { let removed = { let mut bindings = self.playlist_bindings.lock().unwrap(); bindings.remove(renderer_id) @@ -1956,14 +1955,14 @@ impl ControlPoint { } } - fn runtime_entry_missing(renderer_id: &RendererId) -> anyhow::Error { + fn runtime_entry_missing(renderer_id: &ServiceId) -> anyhow::Error { anyhow!( "Renderer {} not registered in control point runtime", renderer_id.0 ) } - fn openhome_renderer(&self, renderer_id: &RendererId) -> anyhow::Result { + fn openhome_renderer(&self, renderer_id: &ServiceId) -> anyhow::Result { let renderer = self .music_renderer_by_id(renderer_id) .ok_or_else(|| OpenHomeAccessError::RendererNotFound(renderer_id.0.clone()))?; @@ -1973,7 +1972,7 @@ impl ControlPoint { Ok(renderer) } - fn sync_openhome_playlist_for(&self, renderer_id: &RendererId) -> anyhow::Result<()> { + fn sync_openhome_playlist_for(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { sync_openhome_playlist(&self.registry, &self.runtime, &self.event_bus, renderer_id) } @@ -2070,17 +2069,17 @@ struct OpenHomePlaylistCache { } struct RuntimeState { - entries: Mutex>, + entries: Mutex>, } pub struct RendererRuntimeStateMut<'a> { pub queue: MusicQueueGuard<'a>, - _guard: MutexGuard<'a, HashMap>, + _guard: MutexGuard<'a, HashMap>, } impl<'a> RendererRuntimeStateMut<'a> { fn new( - guard: MutexGuard<'a, HashMap>, + guard: MutexGuard<'a, HashMap>, queue_ptr: *mut MusicQueue, ) -> Self { Self { @@ -2122,23 +2121,23 @@ struct RuntimeOpenHomeQueueProvider { runtime: Arc, } -impl OpenHomeQueueProvider for RuntimeOpenHomeQueueProvider { - fn renderer_state(&self, renderer_id: &RendererId) -> anyhow::Result { - self.runtime.renderer_state(renderer_id) - } +// impl OpenHomeQueueProvider for RuntimeOpenHomeQueueProvider { +// fn renderer_state(&self, renderer_id: &RendererId) -> anyhow::Result { +// self.runtime.renderer_state(renderer_id) +// } - fn renderer_state_mut<'a>( - &'a self, - renderer_id: &RendererId, - ) -> anyhow::Result> { - self.runtime.renderer_state_mut(renderer_id) - } +// fn renderer_state_mut<'a>( +// &'a self, +// renderer_id: &RendererId, +// ) -> anyhow::Result> { +// self.runtime.renderer_state_mut(renderer_id) +// } - fn invalidate_openhome_cache(&self, renderer_id: &RendererId) -> anyhow::Result<()> { - self.runtime.invalidate_openhome_cache(renderer_id); - Ok(()) - } -} +// fn invalidate_openhome_cache(&self, renderer_id: &RendererId) -> anyhow::Result<()> { +// self.runtime.invalidate_openhome_cache(renderer_id); +// Ok(()) +// } +// } impl RuntimeState { fn new() -> Self { @@ -2147,7 +2146,7 @@ impl RuntimeState { } } - fn snapshot_for(&self, id: &RendererId) -> RendererRuntimeSnapshot { + fn snapshot_for(&self, id: &ServiceId) -> RendererRuntimeSnapshot { let entries = self.entries.lock().unwrap(); entries .get(id) @@ -2155,13 +2154,13 @@ impl RuntimeState { .unwrap_or_default() } - fn update_snapshot(&self, id: &RendererId, snapshot: RendererRuntimeSnapshot) { + fn update_snapshot(&self, id: &ServiceId, snapshot: RendererRuntimeSnapshot) { self.with_entry(id, |entry| { entry.snapshot = snapshot; }); } - fn update_snapshot_with(&self, id: &RendererId, f: F) + fn update_snapshot_with(&self, id: &ServiceId, f: F) where F: FnOnce(&mut RendererRuntimeSnapshot), { @@ -2170,12 +2169,12 @@ impl RuntimeState { }); } - fn has_entry(&self, id: &RendererId) -> bool { + fn has_entry(&self, id: &ServiceId) -> bool { let entries = self.entries.lock().unwrap(); entries.contains_key(id) } - fn with_music_queue_mut(&self, id: &RendererId, f: F) -> anyhow::Result + fn with_music_queue_mut(&self, id: &ServiceId, f: F) -> anyhow::Result where F: FnOnce(&mut MusicQueue) -> anyhow::Result, { @@ -2186,14 +2185,14 @@ impl RuntimeState { f(&mut entry.queue) } - fn queue_snapshot(&self, id: &RendererId) -> Option> { + fn queue_snapshot(&self, id: &ServiceId) -> Option> { let entries = self.entries.lock().unwrap(); entries .get(id) .and_then(|entry| entry.queue.upcoming_items().ok()) } - fn queue_full_snapshot(&self, id: &RendererId) -> Option<(Vec, Option)> { + fn queue_full_snapshot(&self, id: &ServiceId) -> Option<(Vec, Option)> { let entries = self.entries.lock().unwrap(); entries.get(id).and_then(|entry| { entry @@ -2204,17 +2203,17 @@ impl RuntimeState { }) } - fn renderer_state(&self, id: &RendererId) -> anyhow::Result { - let entries = self.entries.lock().unwrap(); - let entry = entries - .get(id) - .ok_or_else(|| anyhow!("Renderer {} not registered in runtime", id.0))?; - Ok(RendererRuntimeState { - queue: entry.queue.clone(), - }) - } + // fn renderer_state(&self, id: &RendererId) -> anyhow::Result { + // let entries = self.entries.lock().unwrap(); + // let entry = entries + // .get(id) + // .ok_or_else(|| anyhow!("Renderer {} not registered in runtime", id.0))?; + // Ok(RendererRuntimeState { + // queue: entry.queue.clone(), + // }) + // } - fn renderer_state_mut(&self, id: &RendererId) -> anyhow::Result> { + fn renderer_state_mut(&self, id: &ServiceId) -> anyhow::Result> { let mut entries = self.entries.lock().unwrap(); let queue_ptr = { let entry = entries @@ -2226,7 +2225,7 @@ impl RuntimeState { Ok(RendererRuntimeStateMut::new(entries, queue_ptr)) } - fn current_track_metadata(&self, id: &RendererId) -> Option { + fn current_track_metadata(&self, id: &ServiceId) -> Option { let entries = self.entries.lock().unwrap(); entries .get(id) @@ -2236,7 +2235,7 @@ impl RuntimeState { #[cfg(feature = "pmoserver")] fn renderer_snapshot_bundle( &self, - id: &RendererId, + id: &ServiceId, ) -> (RendererRuntimeSnapshot, Vec, Option) { let entries = self.entries.lock().unwrap(); if let Some(entry) = entries.get(id) { @@ -2260,26 +2259,26 @@ impl RuntimeState { } } - fn dequeue_next(&self, id: &RendererId) -> Option<(PlaybackItem, usize)> { + fn dequeue_next(&self, id: &ServiceId) -> Option<(PlaybackItem, usize)> { let mut entries = self.entries.lock().unwrap(); let entry = entries.get_mut(id)?; entry.queue.dequeue_next().ok().flatten() } - fn peek_current(&self, id: &RendererId) -> Option<(PlaybackItem, usize)> { + fn peek_current(&self, id: &ServiceId) -> Option<(PlaybackItem, usize)> { let entries = self.entries.lock().unwrap(); let entry = entries.get(id)?; entry.queue.peek_current().ok().flatten() } - fn set_playback_source(&self, id: &RendererId, source: PlaybackSource) { + fn set_playback_source(&self, id: &ServiceId, source: PlaybackSource) { let mut entries = self.entries.lock().unwrap(); if let Some(entry) = entries.get_mut(id) { entry.playback_source = source; } } - fn playback_source(&self, id: &RendererId) -> PlaybackSource { + fn playback_source(&self, id: &ServiceId) -> PlaybackSource { let entries = self.entries.lock().unwrap(); entries .get(id) @@ -2287,11 +2286,11 @@ impl RuntimeState { .unwrap_or(PlaybackSource::None) } - fn is_playing_from_queue(&self, id: &RendererId) -> bool { + fn is_playing_from_queue(&self, id: &ServiceId) -> bool { matches!(self.playback_source(id), PlaybackSource::FromQueue) } - fn mark_external_if_idle(&self, id: &RendererId) { + fn mark_external_if_idle(&self, id: &ServiceId) { let mut entries = self.entries.lock().unwrap(); if let Some(entry) = entries.get_mut(id) { if matches!(entry.playback_source, PlaybackSource::None) { @@ -2300,7 +2299,7 @@ impl RuntimeState { } } - fn with_entry(&self, id: &RendererId, f: F) -> R + fn with_entry(&self, id: &ServiceId, f: F) -> R where F: FnOnce(&mut RendererRuntimeEntry) -> R, { @@ -2311,24 +2310,24 @@ impl RuntimeState { f(entry) } - fn set_music_queue(&self, id: &RendererId, queue: MusicQueue) { + fn set_music_queue(&self, id: &ServiceId, queue: MusicQueue) { self.with_entry(id, |entry| { entry.queue = queue; }); } - fn invalidate_openhome_cache(&self, id: &RendererId) { + fn invalidate_openhome_cache(&self, id: &ServiceId) { self.with_entry(id, |entry| { entry.openhome_cache = OpenHomePlaylistCache::default(); }); } - fn get_openhome_cache(&self, id: &RendererId) -> Option { + fn get_openhome_cache(&self, id: &ServiceId) -> Option { let entries = self.entries.lock().unwrap(); entries.get(id).map(|entry| entry.openhome_cache.clone()) } - fn set_openhome_cache(&self, id: &RendererId, cache: OpenHomePlaylistCache) { + fn set_openhome_cache(&self, id: &ServiceId, cache: OpenHomePlaylistCache) { self.with_entry(id, |entry| { entry.openhome_cache = cache; }); @@ -2336,7 +2335,7 @@ impl RuntimeState { fn openhome_snapshot_cached( &self, - renderer: &MusicRenderer, + renderer: &MusicRendererBackend, ttl: Duration, ) -> anyhow::Result { let ids = renderer.openhome_playlist_ids()?; @@ -2352,7 +2351,7 @@ impl RuntimeState { } } - let snapshot = renderer.fetch_openhome_playlist_snapshot()?; + let snapshot = renderer.openhome_playlist_snapshot()?; let cache = OpenHomePlaylistCache { ids: Some(ids), snapshot: Some(snapshot.clone()), @@ -2362,13 +2361,13 @@ impl RuntimeState { Ok(snapshot) } - fn set_playlist_backend(&self, id: &RendererId, backend: PlaylistBackend) { + fn set_playlist_backend(&self, id: &ServiceId, backend: PlaylistBackend) { self.with_entry(id, |entry| { entry.playlist_backend = backend; }); } - fn playlist_backend(&self, id: &RendererId) -> PlaylistBackend { + fn playlist_backend(&self, id: &ServiceId) -> PlaylistBackend { let entries = self.entries.lock().unwrap(); entries .get(id) @@ -2376,18 +2375,18 @@ impl RuntimeState { .unwrap_or(PlaylistBackend::PMOQueue) } - fn uses_openhome_playlist(&self, id: &RendererId) -> bool { + fn uses_openhome_playlist(&self, id: &ServiceId) -> bool { matches!(self.playlist_backend(id), PlaylistBackend::OpenHome) } - fn mark_user_stop_requested(&self, id: &RendererId) { + fn mark_user_stop_requested(&self, id: &ServiceId) { let mut entries = self.entries.lock().unwrap(); if let Some(entry) = entries.get_mut(id) { entry.user_stop_requested = true; } } - fn check_and_clear_user_stop_requested(&self, id: &RendererId) -> bool { + fn check_and_clear_user_stop_requested(&self, id: &ServiceId) -> bool { let mut entries = self.entries.lock().unwrap(); if let Some(entry) = entries.get_mut(id) { let was_requested = entry.user_stop_requested; @@ -2408,10 +2407,10 @@ impl RuntimeState { fn refresh_attached_queue_for( registry: &Arc>, runtime: &Arc, - bindings: &Arc>>, - renderer_id: &RendererId, + bindings: &Arc>>, + renderer_id: &ServiceId, event_bus: &RendererEventBus, - mut after_refresh: Option<&mut dyn FnMut(&RendererId) -> anyhow::Result<()>>, + mut after_refresh: Option<&mut dyn FnMut(&ServiceId) -> anyhow::Result<()>>, ) -> anyhow::Result<()> { // Step 1: Check binding and mark refresh as in-progress let (server_id, container_id, auto_play) = { @@ -2483,7 +2482,7 @@ fn refresh_attached_queue_for( } // Step 3: Create MusicServer and browse container - let music_server = MusicServer::from_info(&server_info, Duration::from_secs(5))?; + let music_server = UpnpMediaServer::from_info(&server_info, Duration::from_secs(5))?; const MAX_BROWSE_ATTEMPTS: usize = 3; const BROWSE_RETRY_DELAY_MS: u64 = 200; @@ -2579,7 +2578,7 @@ fn refresh_attached_queue_for( }; if let Some(info) = renderer_info { - if let Some(renderer) = MusicRenderer::from_registry_info(info, registry) { + if let Some(renderer) = MusicRendererBackend::from_renderer_info(info, registry) { // Try direct query first if matches!(renderer.playback_state(), Ok(PlaybackState::Playing)) { true @@ -2731,7 +2730,7 @@ fn refresh_attached_queue_for( const OPENHOME_TRACK_PREFIX: &str = "openhome:"; fn playback_item_from_openhome_track( - renderer_id: &RendererId, + renderer_id: &ServiceId, track: &OpenHomePlaylistTrack, ) -> PlaybackItem { let metadata = TrackMetadata { @@ -2833,16 +2832,16 @@ fn playback_item_track_metadata(item: &PlaybackItem) -> TrackMetadata { fn openhome_renderer_from_registry( registry: &Arc>, - renderer_id: &RendererId, -) -> anyhow::Result { + renderer_id: &ServiceId, +) -> anyhow::Result { let info = { let reg = registry.read().unwrap(); reg.get_renderer(renderer_id) .ok_or_else(|| OpenHomeAccessError::RendererNotFound(renderer_id.0.clone()))? }; - let renderer = MusicRenderer::from_registry_info(info, registry) + let renderer = MusicRendererBackend::from_renderer_info(info, registry) .and_then(|r| match r { - MusicRenderer::OpenHome(_) => Some(r), + MusicRendererBackend::OpenHome(_) => Some(r), _ => None, }) .ok_or_else(|| OpenHomeAccessError::PlaylistNotSupported(renderer_id.0.clone()))?; @@ -2853,11 +2852,11 @@ fn sync_openhome_playlist( registry: &Arc>, runtime: &Arc, event_bus: &RendererEventBus, - renderer_id: &RendererId, + renderer_id: &ServiceId, ) -> anyhow::Result<()> { let renderer = openhome_renderer_from_registry(registry, renderer_id)?; - let snapshot = renderer.fetch_openhome_playlist_snapshot()?; + let snapshot = renderer.openhome_playlist_snapshot()?; let playback_items: Vec = snapshot .tracks .iter() @@ -2910,7 +2909,7 @@ fn spawn_openhome_event_runtime( runtime: Arc, event_bus: RendererEventBus, event_tx: Sender, - playlist_bindings: Arc>>, + playlist_bindings: Arc>>, ) -> io::Result<()> { let listener = TcpListener::bind("0.0.0.0:0")?; let listener_addr = listener @@ -2950,7 +2949,7 @@ struct OpenHomeEventRuntime { http_timeout: Duration, subscriptions: HashMap, path_index: HashMap, - playlist_bindings: Arc>>, + playlist_bindings: Arc>>, } impl OpenHomeEventRuntime { @@ -2961,7 +2960,7 @@ impl OpenHomeEventRuntime { notify_rx: Receiver, event_tx: Sender, listener_port: u16, - playlist_bindings: Arc>>, + playlist_bindings: Arc>>, ) -> Self { Self { registry, @@ -3172,7 +3171,7 @@ impl OpenHomeEventRuntime { } } - fn handle_info_properties(&self, renderer_id: &RendererId, properties: Vec<(String, String)>) { + fn handle_info_properties(&self, renderer_id: &ServiceId, properties: Vec<(String, String)>) { let mut metadata_xml: Option = None; let mut transport_state: Option = None; let mut track_id: Option = None; @@ -3245,7 +3244,7 @@ impl OpenHomeEventRuntime { } } - fn handle_time_properties(&self, renderer_id: &RendererId, properties: Vec<(String, String)>) { + fn handle_time_properties(&self, renderer_id: &ServiceId, properties: Vec<(String, String)>) { let mut duration: Option = None; let mut seconds: Option = None; @@ -3422,12 +3421,12 @@ impl OpenHomeEventRuntime { #[derive(Clone, Debug, PartialEq, Eq, Hash)] struct OpenHomeSubscriptionKey { - renderer_id: RendererId, + renderer_id: ServiceId, service: OhServiceKind, } impl OpenHomeSubscriptionKey { - fn new(renderer_id: &RendererId, service: OhServiceKind) -> Self { + fn new(renderer_id: &ServiceId, service: OhServiceKind) -> Self { Self { renderer_id: renderer_id.clone(), service, @@ -3607,7 +3606,7 @@ fn write_openhome_http_response( stream.write_all(response.as_bytes()) } -fn build_openhome_callback_path(id: &RendererId, service: OhServiceKind) -> String { +fn build_openhome_callback_path(id: &ServiceId, service: OhServiceKind) -> String { let mut sanitized = String::new(); for ch in id.0.chars() { if ch.is_ascii_alphanumeric() { @@ -3624,7 +3623,7 @@ fn build_openhome_callback_path(id: &RendererId, service: OhServiceKind) -> Stri } fn parse_openhome_propertyset( - renderer_id: &RendererId, + renderer_id: &ServiceId, service: &OhServiceKind, body: &[u8], ) -> Vec<(String, String)> { diff --git a/pmocontrol/src/discovery.rs b/pmocontrol/src/discovery.rs deleted file mode 100644 index d095ceca..00000000 --- a/pmocontrol/src/discovery.rs +++ /dev/null @@ -1,228 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::time::SystemTime; - -use pmoupnp::ssdp::SsdpEvent; - -use crate::media_server::MediaServerInfo; -use crate::model::RendererInfo; -use crate::registry::DeviceUpdate; - -/// État connu pour un endpoint UPnP identifié par son UDN. -#[derive(Clone, Debug)] -pub struct DiscoveredEndpoint { - /// UDN normalisé (ex: "uuid:xxxx", en minuscules). - pub udn: String, - /// Dernière URL de description device (LOCATION SSDP). - pub location: String, - /// Dernier header SERVER vu sur cet endpoint. - pub server_header: String, - /// Dernier max-age indiqué (TTL SSDP). - pub max_age: u32, - /// Date de dernière vue (Now lors du dernier Alive ou SearchResponse). - pub last_seen: SystemTime, - /// Indique si on a vu ce endpoint comme MediaRenderer. - pub seen_as_renderer: bool, - /// Indique si on a vu ce endpoint comme MediaServer. - pub seen_as_server: bool, - /// ST/NT vus (pour debug/diagnostic si utile). - pub types_seen: HashSet, -} - -impl DiscoveredEndpoint { - pub fn new(udn: String, location: String, server_header: String, max_age: u32) -> Self { - Self { - udn, - location, - server_header, - max_age, - last_seen: SystemTime::now(), - seen_as_renderer: false, - seen_as_server: false, - types_seen: HashSet::new(), - } - } - - pub fn touch(&mut self, location: String, server_header: String, max_age: u32) { - self.location = location; - self.server_header = server_header; - self.max_age = max_age; - self.last_seen = SystemTime::now(); - } -} - -/// Fournit les descriptions haut niveau à partir d’un endpoint découvert. -/// L’implémentation pourra, plus tard, faire un HTTP GET sur `location` -/// et parser la description pour remplir RendererInfo / MediaServerInfo. -pub trait DeviceDescriptionProvider: Send + Sync { - /// Construit un RendererInfo pour cet endpoint, ou None s’il - /// ne correspond pas à un renderer audio intéressant. - fn build_renderer_info(&self, endpoint: &DiscoveredEndpoint) -> Option; - - /// Construit un MediaServerInfo pour cet endpoint, ou None s’il - /// ne correspond pas à un media server (ou pas intéressant). - fn build_server_info(&self, endpoint: &DiscoveredEndpoint) -> Option; -} - -/// Gestionnaire des événements SSDP -> DeviceUpdate. -pub struct DiscoveryManager

-where - P: DeviceDescriptionProvider, -{ - endpoints: HashMap, - provider: P, -} - -impl

DiscoveryManager

-where - P: DeviceDescriptionProvider, -{ - pub fn new(provider: P) -> Self { - Self { - endpoints: HashMap::new(), - provider, - } - } - - pub fn handle_ssdp_event(&mut self, event: SsdpEvent) -> Vec { - let mut updates = Vec::new(); - - match event { - SsdpEvent::Alive { - usn, - nt, - location, - server, - max_age, - .. - } => { - if let Some(udn) = extract_udn_from_usn(&usn) { - self.handle_alive(udn, nt, location, server, max_age, &mut updates); - } - } - SsdpEvent::SearchResponse { - usn, - st, - location, - server, - max_age, - .. - } => { - if let Some(udn) = extract_udn_from_usn(&usn) { - self.handle_search_response(udn, st, location, server, max_age, &mut updates); - } - } - SsdpEvent::ByeBye { usn, nt, .. } => { - if let Some(udn) = extract_udn_from_usn(&usn) { - self.handle_byebye(udn, nt, &mut updates); - } - } - } - - updates - } - - fn handle_alive( - &mut self, - udn: String, - nt: String, - location: String, - server_header: String, - max_age: u32, - updates: &mut Vec, - ) { - self.update_endpoint(udn, nt, location, server_header, max_age, updates); - } - - fn handle_search_response( - &mut self, - udn: String, - st: String, - location: String, - server_header: String, - max_age: u32, - updates: &mut Vec, - ) { - self.update_endpoint(udn, st, location, server_header, max_age, updates); - } - - fn handle_byebye(&mut self, udn: String, _nt: String, updates: &mut Vec) { - if let Some(endpoint) = self.endpoints.get(&udn) { - if endpoint.seen_as_renderer { - updates.push(DeviceUpdate::RendererOfflineByUdn(udn.clone())); - } - if endpoint.seen_as_server { - updates.push(DeviceUpdate::ServerOfflineByUdn(udn)); - } - } - } - - fn update_endpoint( - &mut self, - udn: String, - device_type: String, - location: String, - server_header: String, - max_age: u32, - updates: &mut Vec, - ) { - tracing::debug!( - "SSDP update: udn={} type={} location={} max_age={}", - udn, - device_type, - location, - max_age - ); - - let endpoint = self.endpoints.entry(udn.clone()).or_insert_with({ - let udn = udn.clone(); - let location = location.clone(); - let server_header = server_header.clone(); - move || DiscoveredEndpoint::new(udn, location, server_header, max_age) - }); - - endpoint.touch(location, server_header, max_age); - endpoint.types_seen.insert(device_type); - - // Toujours tenter de classifier et envoyer un événement Online pour maintenir - // l'état à jour dans le registre (important pour les serveurs qui ré-apparaissent) - if let Some(info) = self.provider.build_renderer_info(endpoint) { - if !endpoint.seen_as_renderer { - tracing::debug!( - "Renderer classified: udn={} friendly_name={} model={}", - info.udn, - info.friendly_name, - info.model_name - ); - endpoint.seen_as_renderer = true; - } - updates.push(DeviceUpdate::RendererOnline(info)); - } - - if let Some(info) = self.provider.build_server_info(endpoint) { - if !endpoint.seen_as_server { - tracing::debug!( - "Server classified: udn={} friendly_name={} model={}", - info.udn, - info.friendly_name, - info.model_name - ); - endpoint.seen_as_server = true; - } - updates.push(DeviceUpdate::ServerOnline(info)); - } - } -} - -fn extract_udn_from_usn(usn: &str) -> Option { - let lower = usn.trim().to_ascii_lowercase(); - if let Some(idx) = lower.find("uuid:") { - let sub = &lower[idx..]; - if let Some(end) = sub.find("::") { - Some(sub[..end].to_string()) - } else { - Some(sub.to_string()) - } - } else { - None - } -} diff --git a/pmocontrol/src/chromecast_discovery.rs b/pmocontrol/src/discovery/chromecast_discovery.rs similarity index 84% rename from pmocontrol/src/chromecast_discovery.rs rename to pmocontrol/src/discovery/chromecast_discovery.rs index bd67a255..c87703a0 100644 --- a/pmocontrol/src/chromecast_discovery.rs +++ b/pmocontrol/src/discovery/chromecast_discovery.rs @@ -9,8 +9,9 @@ use std::collections::HashMap; use std::net::IpAddr; use std::time::SystemTime; +use crate::DeviceId; use crate::registry::DeviceUpdate; -use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol, RendererId}; +use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol}; use tracing::{debug, warn}; /// Information about a discovered Chromecast device from mDNS. @@ -183,7 +184,7 @@ fn build_renderer_info( manufacturer: Option<&str>, ) -> RendererInfo { let udn = format!("uuid:{}", uuid); - let id = RendererId(udn.clone()); + let id = DeviceId(udn.clone()); // Build Chromecast capabilities let mut capabilities = RendererCapabilities::default(); @@ -193,42 +194,38 @@ fn build_renderer_info( // (not a real HTTP endpoint like UPnP, but useful for identification) let location = format!("chromecast://{}:{}", host, port); - RendererInfo { + RendererInfo::make( id, udn, - friendly_name: friendly_name.to_string(), - model_name: model.unwrap_or("Chromecast").to_string(), - manufacturer: manufacturer.unwrap_or("Google Inc.").to_string(), - protocol: RendererProtocol::ChromecastOnly, + friendly_name.to_string(), + model.unwrap_or("Chromecast").to_string(), + manufacturer.unwrap_or("Google Inc.").to_string(), + RendererProtocol::ChromecastOnly, capabilities, location, - server_header: "Chromecast".to_string(), - online: true, - last_seen: SystemTime::now(), - max_age: 1800, // 30 minutes - // All UPnP/OpenHome fields are None for Chromecast - avtransport_service_type: None, - avtransport_control_url: None, - rendering_control_service_type: None, - rendering_control_control_url: None, - connection_manager_service_type: None, - connection_manager_control_url: None, - oh_playlist_service_type: None, - oh_playlist_control_url: None, - oh_playlist_event_sub_url: None, - oh_info_service_type: None, - oh_info_control_url: None, - oh_info_event_sub_url: None, - oh_time_service_type: None, - oh_time_control_url: None, - oh_time_event_sub_url: None, - oh_volume_service_type: None, - oh_volume_control_url: None, - oh_radio_service_type: None, - oh_radio_control_url: None, - oh_product_service_type: None, - oh_product_control_url: None, - } + "Chromecast".to_string(), + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ) } /// Extracts the host (IP address) from a Chromecast location URL. diff --git a/pmocontrol/src/discovery/manager.rs b/pmocontrol/src/discovery/manager.rs new file mode 100644 index 00000000..da26965c --- /dev/null +++ b/pmocontrol/src/discovery/manager.rs @@ -0,0 +1,40 @@ +use std::{collections::HashMap, sync::{Arc, Mutex, OnceLock}, time::{Instant, SystemTime}}; + +use tracing_subscriber::registry; + + + +struct UDNSeen { + max_age: u64, + last_seen: Instant, +} + +pub struct UDNRegistry { + seen: HashMap, +} + +impl UDNRegistry { + pub fn new() -> Self { + UDNRegistry { seen: HashMap::new() } + } + + /// Returns `true` if the UDN has been seen for at least half of its lifetime + pub fn should_fetch(registry : Arc>,udn: &str, max_age: u64 ) -> bool { + let now = Instant::now(); + let mut r = registry + .lock() + .expect("UDNRegistry mutex lock failed"); + if let Some(seen) = r.seen.get_mut(udn) { + if now.duration_since(seen.last_seen).as_secs() > max_age/2 { + false + } else { + seen.last_seen = now; + true + } + } else { + r.seen.insert(udn.to_string(), UDNSeen { max_age, last_seen: now }); + true + } + } +} + diff --git a/pmocontrol/src/discovery/mod.rs b/pmocontrol/src/discovery/mod.rs new file mode 100644 index 00000000..e96cf0ad --- /dev/null +++ b/pmocontrol/src/discovery/mod.rs @@ -0,0 +1,6 @@ +pub mod manager; +pub mod upnp_discovery; +pub mod upnp_provider; +pub mod chromecast_discovery; + + diff --git a/pmocontrol/src/discovery/upnp_discovery.rs b/pmocontrol/src/discovery/upnp_discovery.rs new file mode 100644 index 00000000..a079ae94 --- /dev/null +++ b/pmocontrol/src/discovery/upnp_discovery.rs @@ -0,0 +1,508 @@ +use std::collections::{HashMap, HashSet}; + +use pmoupnp::ssdp::SsdpEvent; + +use crate::discovery::manager::UDNRegistry; +use crate::media_server::UpnpMediaServer; +use crate::model::RendererInfo; +use crate::registry::DeviceUpdate; + +/// Fournit les descriptions haut niveau à partir d’un endpoint découvert. +/// L’implémentation pourra, plus tard, faire un HTTP GET sur `location` +/// et parser la description pour remplir RendererInfo / MediaServerInfo. +pub trait DeviceDescriptionProvider: Send + Sync { + /// Construit un RendererInfo pour cet endpoint, ou None s’il + /// ne correspond pas à un renderer audio intéressant. + fn build_renderer_info( + &self, + udn: &str, + location: &str, + server_header: &str, + ) -> Option; + + /// Construit un MediaServerInfo pour cet endpoint, ou None s’il + /// ne correspond pas à un media server (ou pas intéressant). + fn build_server_info( + &self, + udn: &str, + location: &str, + server_header: &str, + ) -> Option; +} + +/// Gestionnaire des événements SSDP -> DeviceUpdate. +pub struct DiscoveryManager

+where + P: DeviceDescriptionProvider, +{ + provider: P, + udn_cache: Arc>, + device_registry: Arc, +} + +pub struct UpnpDiscoveryManager { + device_registry: Arc>, + udn_cache: Arc>, + http_client: Agent, // ← intégré +} + +impl UpnpDiscoveryManager { + // Dans handle_ssdp_event (upnp_discovery.rs) + fn handle_ssdp_event(&mut self, event: SsdpEvent) { + let (alive, usn, location, max_age, server_header) = match event { + SsdpEvent::Alive { + usn, + location, + max_age, + server, + .. + } + | SsdpEvent::SearchResponse { + usn, + location, + max_age, + server, + .. + } => (true, usn, location, max_age, server), + SsdpEvent::ByeBye { usn, .. } => (false, usn, "".to_string(), 0, "".to_string()), + }; + + if let Some(udn) = extract_udn_from_usn(&usn) { + if alive { + // ✅ Check cache + if UDNRegistry::should_fetch(self.udn_cache, &udn, max_age as u64) { + // ✅ Fetch + parse + let info = self.provider.build_renderer_info(&location)?; + } + } else { + self.device_registry.lock() + .expect("UDNRegistry mutex lock failed") + .device_says_byebye(&udn); + } + } + } + + /// Fetch and parse the device description.xml at endpoint.location. + fn fetch_and_parse( + &self, + udn: &str, + location: &str, + server_header: &str, + ) -> Result { + debug!("Fetching description for {} at {}", udn, location); + + let config = Agent::config_builder() + .timeout_global(Some(std::time::Duration::from_secs(self.timeout_secs))) + .build(); + + let agent: Agent = config.into(); + + let response = agent.get(location).call()?; + + // response: http::Response + let (_parts, body) = response.into_parts(); + + // body.into_reader() -> impl Read + 'static + let body_reader = body.into_reader(); + + let mut reader = Reader::from_reader(BufReader::new(body_reader)); + reader.config_mut().trim_text(true); + debug!("Parsing description XML for {} at {}", udn, location); + + let mut buf = Vec::new(); + let mut parsed = ParsedDeviceDescription::default(); + + let mut in_device = false; + let mut in_service = false; + let mut current_tag: Option = None; + + // New: track current serviceType + controlURL while inside ... + let mut current_service_type: Option = None; + let mut current_control_url: Option = None; + let mut current_event_sub_url: Option = None; + + loop { + match reader.read_event_into(&mut buf)? { + Event::Start(e) => { + let name = String::from_utf8_lossy(e.name().as_ref()).to_string(); + match name.as_str() { + "device" => { + in_device = true; + current_tag = None; + } + "service" => { + if in_device { + in_service = true; + current_tag = None; + current_service_type = None; + current_control_url = None; + } + } + _ => { + if in_device { + current_tag = Some(name); + } + } + } + } + Event::End(e) => { + let name = String::from_utf8_lossy(e.name().as_ref()).to_string(); + match name.as_str() { + "device" => { + in_device = false; + } + "service" => { + if in_device && in_service { + // We just finished a block: if this is AVTransport, + // store its endpoint in parsed.* + if let (Some(st), Some(ctrl)) = + (¤t_service_type, ¤t_control_url) + { + let lower = st.to_ascii_lowercase(); + if lower.contains("urn:schemas-upnp-org:service:avtransport:") { + // Only set once; if multiple AVTransport services exist, + // we keep the first one. + if parsed.avtransport_service_type.is_none() { + parsed.avtransport_service_type = Some(st.clone()); + parsed.avtransport_control_url = Some(ctrl.clone()); + debug!( + "Found AVTransport service for {}: type={} controlURL={}", + udn, st, ctrl + ); + } + } + + if lower + .contains("urn:schemas-upnp-org:service:renderingcontrol:") + { + if parsed.rendering_control_service_type.is_none() { + parsed.rendering_control_service_type = + Some(st.clone()); + parsed.rendering_control_control_url = + Some(ctrl.clone()); + debug!( + "Found RenderingControl service for {}: type={} controlURL={}", + udn, st, ctrl + ); + } + } + + if lower + .contains("urn:schemas-upnp-org:service:connectionmanager:") + { + if parsed.connection_manager_service_type.is_none() { + parsed.connection_manager_service_type = + Some(st.clone()); + parsed.connection_manager_control_url = + Some(ctrl.clone()); + debug!( + "Found ConnectionManager service for {}: type={} controlURL={}", + udn, st, ctrl + ); + } + } + + if lower + .contains("urn:schemas-upnp-org:service:contentdirectory:") + { + if parsed.content_directory_service_type.is_none() { + parsed.content_directory_service_type = + Some(st.clone()); + parsed.content_directory_control_url = + Some(ctrl.clone()); + debug!( + "Found ContentDirectory service for {}: type={} controlURL={}", + udn, st, ctrl + ); + } + } + + if lower.contains("urn:av-openhome-org:service:playlist:") { + if parsed.oh_playlist_service_type.is_none() { + parsed.oh_playlist_service_type = Some(st.clone()); + parsed.oh_playlist_control_url = Some(ctrl.clone()); + if parsed.oh_playlist_event_sub_url.is_none() { + parsed.oh_playlist_event_sub_url = + current_event_sub_url.clone(); + } + debug!( + "Found OpenHome Playlist for {}: type={} controlURL={}", + udn, st, ctrl + ); + } + } + + if lower.contains("urn:av-openhome-org:service:info:") { + if parsed.oh_info_service_type.is_none() { + parsed.oh_info_service_type = Some(st.clone()); + parsed.oh_info_control_url = Some(ctrl.clone()); + if parsed.oh_info_event_sub_url.is_none() { + parsed.oh_info_event_sub_url = + current_event_sub_url.clone(); + } + debug!( + "Found OpenHome Info for {}: type={} controlURL={}", + udn, st, ctrl + ); + } + } + + if lower.contains("urn:av-openhome-org:service:time:") { + if parsed.oh_time_service_type.is_none() { + parsed.oh_time_service_type = Some(st.clone()); + parsed.oh_time_control_url = Some(ctrl.clone()); + if parsed.oh_time_event_sub_url.is_none() { + parsed.oh_time_event_sub_url = + current_event_sub_url.clone(); + } + debug!( + "Found OpenHome Time for {}: type={} controlURL={}", + udn, st, ctrl + ); + } + } + + if lower.contains("urn:av-openhome-org:service:volume:") { + if parsed.oh_volume_service_type.is_none() { + parsed.oh_volume_service_type = Some(st.clone()); + parsed.oh_volume_control_url = Some(ctrl.clone()); + debug!( + "Found OpenHome Volume for {}: type={} controlURL={}", + udn, st, ctrl + ); + } + } + + if lower.contains("urn:av-openhome-org:service:radio:") { + if parsed.oh_radio_service_type.is_none() { + parsed.oh_radio_service_type = Some(st.clone()); + parsed.oh_radio_control_url = Some(ctrl.clone()); + debug!( + "Found OpenHome Radio for {}: type={} controlURL={}", + udn, st, ctrl + ); + } + } + + if lower.contains("urn:av-openhome-org:service:product:") { + if parsed.oh_product_service_type.is_none() { + parsed.oh_product_service_type = Some(st.clone()); + parsed.oh_product_control_url = Some(ctrl.clone()); + debug!( + "Found OpenHome Product for {}: type={} controlURL={}", + udn, st, ctrl + ); + } + } + } + + in_service = false; + current_service_type = None; + current_control_url = None; + current_event_sub_url = None; + } + } + _ => {} + } + current_tag = None; + } + Event::Text(e) => { + if in_device { + if let Some(tag) = ¤t_tag { + // quick-xml ≥ 0.37 : unescape() → decode() + let text = e.decode().map_err(XmlError::Encoding)?.into_owned(); + + match tag.as_str() { + "UDN" => { + parsed.udn = Some(text); + } + "deviceType" => { + parsed.device_type = Some(text); + } + "friendlyName" => { + parsed.friendly_name = Some(text); + } + "manufacturer" => { + parsed.manufacturer = Some(text); + } + "modelName" => { + parsed.model_name = Some(text); + } + "serviceType" if in_service => { + parsed.service_types.push(text.clone()); + current_service_type = Some(text); + } + "controlURL" if in_service => { + current_control_url = Some(text); + } + "eventSubURL" if in_service => { + current_event_sub_url = Some(text); + } + _ => {} + } + } + } + } + Event::Eof => break, + _ => {} + } + + buf.clear(); + } + + parsed.require_fields() + } + +} + +impl

DiscoveryManager

+where + P: DeviceDescriptionProvider, +{ + pub fn new(provider: P) -> Self { + Self { provider } + } + + // Dans handle_ssdp_event (upnp_discovery.rs) + fn handle_ssdp_event(&mut self, event: SsdpEvent) -> Vec { + let (alive, usn, location, max_age, server_header) = match event { + SsdpEvent::Alive { + usn, + location, + max_age, + server, + .. + } + | SsdpEvent::SearchResponse { + usn, + location, + max_age, + server, + .. + } => (true, usn, location, max_age, server), + SsdpEvent::ByeBye { usn, .. } => (false, usn, "".to_string(), 0, "".to_string()), + }; + + if let Some(udn) = extract_udn_from_usn(&usn) { + if alive { + // ✅ Check cache + if !UDNRegistry::should_fetch(&udn, max_age) { + return vec![]; // Skip, vu récemment + } + + // ✅ Fetch + parse + let info = self.provider.build_renderer_info(&location)?; + vec![DeviceUpdate::RendererOnline(info)] + } else { + self.handle_byebye(udn, nt, &mut updates); + } + } else { + return vec![]; + } + } + + fn handle_alive( + &mut self, + udn: String, + nt: String, + location: String, + server_header: String, + max_age: u32, + updates: &mut Vec, + ) { + self.update_endpoint(udn, nt, location, server_header, max_age, updates); + } + + fn handle_search_response( + &mut self, + udn: String, + st: String, + location: String, + server_header: String, + max_age: u32, + updates: &mut Vec, + ) { + self.update_endpoint(udn, st, location, server_header, max_age, updates); + } + + fn handle_byebye(&mut self, udn: String, _nt: String, updates: &mut Vec) { + if let Some(endpoint) = self.endpoints.get(&udn) { + if endpoint.seen_as_renderer { + updates.push(DeviceUpdate::RendererOfflineByUdn(udn.clone())); + } + if endpoint.seen_as_server { + updates.push(DeviceUpdate::ServerOfflineByUdn(udn)); + } + } + } + + fn update_endpoint( + &mut self, + udn: String, + device_type: String, + location: String, + server_header: String, + max_age: u32, + updates: &mut Vec, + ) { + tracing::debug!( + "SSDP update: udn={} type={} location={} max_age={}", + udn, + device_type, + location, + max_age + ); + + let endpoint = self.endpoints.entry(udn.clone()).or_insert_with({ + let udn = udn.clone(); + let location = location.clone(); + let server_header = server_header.clone(); + move || DiscoveredEndpoint::new(udn, location, server_header, max_age) + }); + + endpoint.touch(location, server_header, max_age); + endpoint.types_seen.insert(device_type); + + // Toujours tenter de classifier et envoyer un événement Online pour maintenir + // l'état à jour dans le registre (important pour les serveurs qui ré-apparaissent) + if let Some(info) = self.provider.build_renderer_info(endpoint) { + if !endpoint.seen_as_renderer { + tracing::debug!( + "Renderer classified: udn={} friendly_name={} model={}", + info.udn(), + info.friendly_name(), + info.model_name() + ); + endpoint.seen_as_renderer = true; + } + updates.push(DeviceUpdate::RendererOnline(info)); + } + + if let Some(info) = self.provider.build_server_info(endpoint) { + if !endpoint.seen_as_server { + tracing::debug!( + "Server classified: udn={} friendly_name={} model={}", + info.udn, + info.friendly_name, + info.model_name + ); + endpoint.seen_as_server = true; + } + updates.push(DeviceUpdate::ServerOnline(info)); + } + } +} + +fn extract_udn_from_usn(usn: &str) -> Option { + let lower = usn.trim().to_ascii_lowercase(); + if let Some(idx) = lower.find("uuid:") { + let sub = &lower[idx..]; + if let Some(end) = sub.find("::") { + Some(sub[..end].to_string()) + } else { + Some(sub.to_string()) + } + } else { + None + } +} diff --git a/pmocontrol/src/provider.rs b/pmocontrol/src/discovery/upnp_provider.rs similarity index 84% rename from pmocontrol/src/provider.rs rename to pmocontrol/src/discovery/upnp_provider.rs index 29bd3aee..a9a6dfe6 100644 --- a/pmocontrol/src/provider.rs +++ b/pmocontrol/src/discovery/upnp_provider.rs @@ -1,16 +1,17 @@ use std::io::BufReader; -use std::time::{Duration, SystemTime}; +use std::time::{Duration, Instant, SystemTime}; -use quick_xml::{events::Event, Error as XmlError, Reader}; +use quick_xml::{Error as XmlError, Reader, events::Event}; use thiserror::Error; use tracing::{debug, warn}; +use crate::DeviceId; use crate::arylic_tcp::detect_arylic_tcp; use crate::avtransport_client::AvTransportClient; -use crate::discovery::{DeviceDescriptionProvider, DiscoveredEndpoint}; -use crate::linkplay::detect_linkplay_http; -use crate::media_server::{MediaServerInfo, ServerId}; -use crate::model::{RendererCapabilities, RendererId, RendererInfo, RendererProtocol}; +use crate::discovery::upnp_discovery::DeviceDescriptionProvider; +use crate::linkplay_renderer::detect_linkplay_http; +use crate::media_server::UpnpMediaServer; +use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol}; use ureq::Agent; @@ -101,12 +102,11 @@ impl HttpXmlDescriptionProvider { /// Fetch and parse the device description.xml at endpoint.location. fn fetch_and_parse( &self, - endpoint: &DiscoveredEndpoint, + udn: &str, + location: &str, + server_header: &str, ) -> Result { - debug!( - "Fetching description for {} at {}", - endpoint.udn, endpoint.location - ); + debug!("Fetching description for {} at {}", udn, location); let config = Agent::config_builder() .timeout_global(Some(std::time::Duration::from_secs(self.timeout_secs))) @@ -114,7 +114,7 @@ impl HttpXmlDescriptionProvider { let agent: Agent = config.into(); - let response = agent.get(&endpoint.location).call()?; + let response = agent.get(location).call()?; // response: http::Response let (_parts, body) = response.into_parts(); @@ -124,10 +124,7 @@ impl HttpXmlDescriptionProvider { let mut reader = Reader::from_reader(BufReader::new(body_reader)); reader.config_mut().trim_text(true); - debug!( - "Parsing description XML for {} at {}", - endpoint.udn, endpoint.location - ); + debug!("Parsing description XML for {} at {}", udn, location); let mut buf = Vec::new(); let mut parsed = ParsedDeviceDescription::default(); @@ -187,7 +184,7 @@ impl HttpXmlDescriptionProvider { parsed.avtransport_control_url = Some(ctrl.clone()); debug!( "Found AVTransport service for {}: type={} controlURL={}", - endpoint.udn, st, ctrl + udn, st, ctrl ); } } @@ -202,7 +199,7 @@ impl HttpXmlDescriptionProvider { Some(ctrl.clone()); debug!( "Found RenderingControl service for {}: type={} controlURL={}", - endpoint.udn, st, ctrl + udn, st, ctrl ); } } @@ -217,7 +214,7 @@ impl HttpXmlDescriptionProvider { Some(ctrl.clone()); debug!( "Found ConnectionManager service for {}: type={} controlURL={}", - endpoint.udn, st, ctrl + udn, st, ctrl ); } } @@ -232,7 +229,7 @@ impl HttpXmlDescriptionProvider { Some(ctrl.clone()); debug!( "Found ContentDirectory service for {}: type={} controlURL={}", - endpoint.udn, st, ctrl + udn, st, ctrl ); } } @@ -247,7 +244,7 @@ impl HttpXmlDescriptionProvider { } debug!( "Found OpenHome Playlist for {}: type={} controlURL={}", - endpoint.udn, st, ctrl + udn, st, ctrl ); } } @@ -262,7 +259,7 @@ impl HttpXmlDescriptionProvider { } debug!( "Found OpenHome Info for {}: type={} controlURL={}", - endpoint.udn, st, ctrl + udn, st, ctrl ); } } @@ -277,7 +274,7 @@ impl HttpXmlDescriptionProvider { } debug!( "Found OpenHome Time for {}: type={} controlURL={}", - endpoint.udn, st, ctrl + udn, st, ctrl ); } } @@ -288,7 +285,7 @@ impl HttpXmlDescriptionProvider { parsed.oh_volume_control_url = Some(ctrl.clone()); debug!( "Found OpenHome Volume for {}: type={} controlURL={}", - endpoint.udn, st, ctrl + udn, st, ctrl ); } } @@ -299,7 +296,7 @@ impl HttpXmlDescriptionProvider { parsed.oh_radio_control_url = Some(ctrl.clone()); debug!( "Found OpenHome Radio for {}: type={} controlURL={}", - endpoint.udn, st, ctrl + udn, st, ctrl ); } } @@ -310,7 +307,7 @@ impl HttpXmlDescriptionProvider { parsed.oh_product_control_url = Some(ctrl.clone()); debug!( "Found OpenHome Product for {}: type={} controlURL={}", - endpoint.udn, st, ctrl + udn, st, ctrl ); } } @@ -375,7 +372,9 @@ impl HttpXmlDescriptionProvider { fn build_renderer( &self, - endpoint: &DiscoveredEndpoint, + udn: &str, + location: &str, + server_header: &str, parsed: &ParsedDeviceDescription, ) -> Option { let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase(); @@ -385,110 +384,101 @@ impl HttpXmlDescriptionProvider { { debug!( "build_renderer: ignoring deviceType for {}: {}", - endpoint.udn, device_type + udn, device_type ); return None; } - let raw_udn = parsed - .udn - .as_deref() - .unwrap_or_else(|| endpoint.udn.as_str()); + let raw_udn = parsed.udn.as_deref().unwrap_or_else(|| udn); let udn = raw_udn.to_ascii_lowercase(); let mut caps = detect_renderer_capabilities(&parsed.service_types); - if detect_linkplay_http( - &endpoint.location, - Duration::from_secs(self.timeout_secs.max(1)), - ) { + if detect_linkplay_http(location, Duration::from_secs(self.timeout_secs.max(1))) { caps.has_linkplay_http = true; } - if detect_arylic_tcp( - &endpoint.location, - Duration::from_secs(self.timeout_secs.max(1)), - ) { + if detect_arylic_tcp(location, Duration::from_secs(self.timeout_secs.max(1))) { caps.has_arylic_tcp = true; } let protocol = detect_renderer_protocol(&caps); - let now = SystemTime::now(); + let now = Instant::now(); - Some(RendererInfo { - id: RendererId(udn.clone()), + Some(RendererInfo::make( + DeviceId(udn.clone()), udn, - friendly_name: parsed.friendly_name.clone().unwrap_or_default(), - model_name: parsed.model_name.clone().unwrap_or_default(), - manufacturer: parsed.manufacturer.clone().unwrap_or_default(), + parsed.friendly_name.clone().unwrap_or_default(), + parsed.model_name.clone().unwrap_or_default(), + parsed.manufacturer.clone().unwrap_or_default(), protocol, - capabilities: caps, - location: endpoint.location.clone(), - server_header: endpoint.server_header.clone(), - online: true, - last_seen: now, - max_age: endpoint.max_age, - avtransport_service_type: parsed.avtransport_service_type.clone(), - avtransport_control_url: parsed + caps, + location.to_string(), + endpoint.server_header.clone(), + // online: true, + // last_seen: now, + // max_age: endpoint.max_age, + parsed.avtransport_service_type.clone(), + parsed .avtransport_control_url .as_ref() - .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), - rendering_control_service_type: parsed.rendering_control_service_type.clone(), - rendering_control_control_url: parsed + .map(|ctrl| resolve_control_url(location, ctrl)), + parsed.rendering_control_service_type.clone(), + parsed .rendering_control_control_url .as_ref() - .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), - connection_manager_service_type: parsed.connection_manager_service_type.clone(), - connection_manager_control_url: parsed + .map(|ctrl| resolve_control_url(location, ctrl)), + parsed.connection_manager_service_type.clone(), + parsed .connection_manager_control_url .as_ref() - .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), - oh_playlist_service_type: parsed.oh_playlist_service_type.clone(), - oh_playlist_control_url: parsed + .map(|ctrl| resolve_control_url(location, ctrl)), + parsed.oh_playlist_service_type.clone(), + parsed .oh_playlist_control_url .as_ref() - .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), - oh_playlist_event_sub_url: parsed + .map(|ctrl| resolve_control_url(location, ctrl)), + parsed .oh_playlist_event_sub_url .as_ref() - .map(|url| resolve_control_url(&endpoint.location, url)), - oh_info_service_type: parsed.oh_info_service_type.clone(), - oh_info_control_url: parsed + .map(|url| resolve_control_url(location, url)), + parsed.oh_info_service_type.clone(), + parsed .oh_info_control_url .as_ref() - .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), - oh_info_event_sub_url: parsed + .map(|ctrl| resolve_control_url(location, ctrl)), + parsed .oh_info_event_sub_url .as_ref() - .map(|url| resolve_control_url(&endpoint.location, url)), - oh_time_service_type: parsed.oh_time_service_type.clone(), - oh_time_control_url: parsed + .map(|url| resolve_control_url(location, url)), + parsed.oh_time_service_type.clone(), + parsed .oh_time_control_url .as_ref() - .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), - oh_time_event_sub_url: parsed + .map(|ctrl| resolve_control_url(location, ctrl)), + parsed .oh_time_event_sub_url .as_ref() - .map(|url| resolve_control_url(&endpoint.location, url)), - oh_volume_service_type: parsed.oh_volume_service_type.clone(), - oh_volume_control_url: parsed + .map(|url| resolve_control_url(location, url)), + parsed.oh_volume_service_type.clone(), + parsed .oh_volume_control_url .as_ref() - .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), - oh_radio_service_type: parsed.oh_radio_service_type.clone(), - oh_radio_control_url: parsed + .map(|ctrl| resolve_control_url(location, ctrl)), + parsed.oh_radio_service_type.clone(), + parsed .oh_radio_control_url .as_ref() - .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), - oh_product_service_type: parsed.oh_product_service_type.clone(), - oh_product_control_url: parsed + .map(|ctrl| resolve_control_url(location, ctrl)), + parsed.oh_product_service_type.clone(), + parsed .oh_product_control_url .as_ref() - .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)), - }) + .map(|ctrl| resolve_control_url(location, ctrl)), + )) } fn build_server( &self, endpoint: &DiscoveredEndpoint, parsed: &ParsedDeviceDescription, - ) -> Option { + ) -> Option { let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase(); if !device_type.contains("urn:schemas-upnp-org:device:mediaserver:") { return None; @@ -503,15 +493,15 @@ impl HttpXmlDescriptionProvider { st.to_ascii_lowercase() .contains("urn:schemas-upnp-org:service:contentdirectory:") }); - let now = SystemTime::now(); + let now = Instant::now(); let content_directory_control_url = parsed .content_directory_control_url .as_ref() .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)); - Some(MediaServerInfo { - id: ServerId(udn.clone()), + Some(UpnpMediaServer { + id: DeviceId(udn.clone()), udn, friendly_name: parsed.friendly_name.clone().unwrap_or_default(), model_name: parsed.model_name.clone().unwrap_or_default(), @@ -604,7 +594,7 @@ fn detect_renderer_protocol(caps: &RendererCapabilities) -> RendererProtocol { || caps.has_oh_radio; match (has_upnp_av, has_openhome) { - (true, true) => RendererProtocol::Hybrid, + (true, true) => RendererProtocol::OpenHomeHybrid, (true, false) => RendererProtocol::UpnpAvOnly, (false, true) => RendererProtocol::OpenHomeOnly, (false, false) => RendererProtocol::UpnpAvOnly, @@ -679,7 +669,7 @@ impl DeviceDescriptionProvider for HttpXmlDescriptionProvider { } } - fn build_server_info(&self, endpoint: &DiscoveredEndpoint) -> Option { + fn build_server_info(&self, endpoint: &DiscoveredEndpoint) -> Option { match self.fetch_and_parse(endpoint) { Ok(parsed) => { let device_type = parsed.device_type.as_deref().unwrap_or("unknown"); diff --git a/pmocontrol/src/errors.rs b/pmocontrol/src/errors.rs new file mode 100644 index 00000000..7f7a82d3 --- /dev/null +++ b/pmocontrol/src/errors.rs @@ -0,0 +1,65 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ControlPointError { + // Utiliser par les DeviceItems dans leur conversion vers un media renderer + #[error("{0} is not a MediaRenderer")] + IsNotAMediaRender(String), + #[error("{0} is not a MediaServer")] + IsNotAMediaServer(String), + #[error("Cannot build MusicRendererBackend for {0}")] + MusicRendererBackendBuild(String), + #[error("{0}")] + ParsingError(String), + #[error("OpenHome Error: {0}")] + OpenHomeError(String), + #[error("OpenHome Product source list does not expose a Playlist entry")] + OpenHomeNoPlaylistEntry(), + #[error("MusicRendererBackend {0} exposes no OpenHome services")] + OpenHomeNotAValidDevice(String), + #[error("UpnpError Error: {0}")] + UpnpError(String), + #[error("MusicRenderer operation '{0}' is not supported by backend '{1}'")] + UpnpOperationNotSupported(String, String), + #[error("Missing {0} element in SOAP body")] + UpnpMissingReturnValue(String), + #[error("Invalid {0} value: {1}")] + UpnpBadReturnValue(String,String), + #[error("Soap Error: Upnp action call {0}")] + SoapAction(String), + #[error("{0} returned UPnP error {1}: {2} (HTTP status {3})")] + SoapUpnpParseError(String,u32, String, u32), + #[error("{0} failed with HTTP status {1} and body: {2}")] + SoapActionWrongBody(String, u32, String), + #[error("Soap Error: No envelop for action {0}")] + SoapNoEnvelop(String), + #[error("ArilycTcp Error: {0}")] + ArilycTcpError(String), + #[error("LinkPlay Error: {0}")] + LinkPlayError(String), + #[error("Chromecast Error: {0}")] + ChromecastError(String), + #[error("MediaServer Error: {0}")] + MediaServerError(String), + #[error("Queue Error: {0}")] + QueueError(String), +} + +impl ControlPointError { + pub fn upnp_operation_not_supported(operation: &str, service: &str) -> Self { + ControlPointError::UpnpOperationNotSupported(operation.to_string(),service.to_string()) + } + + pub fn upnp_missing_return_value(value: &str) -> Self { + ControlPointError::UpnpMissingReturnValue(value.to_string()) + } + + pub fn upnp_bad_return_value(name : &str, value : &str ) -> Self { + ControlPointError::UpnpBadReturnValue(name.to_string(),value.to_string()) + + } + + pub fn arilyc_tcp_error(message : &str ) -> Self { + ControlPointError::ArilycTcpError(message.to_string()) + } +} \ No newline at end of file diff --git a/pmocontrol/src/identity.rs b/pmocontrol/src/identity.rs new file mode 100644 index 00000000..64c47d8b --- /dev/null +++ b/pmocontrol/src/identity.rs @@ -0,0 +1,14 @@ +use crate::DeviceId; + +pub trait DeviceIdentity { + fn id(&self) -> DeviceId ; + fn udn(&self) -> &str; + fn friendly_name(&self) -> &str ; + fn model_name(&self) -> &str ; + fn manufacturer(&self) -> &str ; + fn location(&self) -> &str ; + fn server_header(&self) -> &str; + + fn is_a_media_server(&self) -> bool { false } + fn is_a_music_renderer(&self) -> bool { false } +} \ No newline at end of file diff --git a/pmocontrol/src/lib.rs b/pmocontrol/src/lib.rs index 245a884e..7f990629 100644 --- a/pmocontrol/src/lib.rs +++ b/pmocontrol/src/lib.rs @@ -1,15 +1,16 @@ mod events; mod media_server_events; +pub mod queue; +pub mod discovery; pub mod arylic_tcp; pub mod avtransport_client; pub mod capabilities; -pub mod chromecast_discovery; pub mod chromecast_renderer; pub mod connection_manager_client; pub mod control_point; -pub mod discovery; -pub mod linkplay; +pub mod errors; +pub mod linkplay_renderer; pub mod media_server; pub mod model; pub mod music_renderer; @@ -18,12 +19,13 @@ pub mod openhome_client; pub mod openhome_playlist; pub mod openhome_renderer; pub mod provider; -pub mod queue_backend; -pub mod queue_interne; pub mod registry; pub mod rendering_control_client; pub mod soap_client; pub mod upnp_renderer; +pub mod online; +pub mod identity; + // pmoserver extension (optional) #[cfg(feature = "pmoserver")] @@ -33,6 +35,8 @@ pub mod pmoserver_ext; #[cfg(feature = "pmoserver")] pub mod sse; +use std::time::Duration; + #[cfg(feature = "pmoserver")] pub use pmoserver_ext::ControlPointExt; @@ -45,24 +49,30 @@ pub use capabilities::{ pub use chromecast_renderer::ChromecastRenderer; pub use connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo}; pub use control_point::{ControlPoint, PlaylistBinding}; -pub use linkplay::LinkPlayRenderer; +pub use linkplay_renderer::LinkPlayRenderer; pub use media_server::{ - MediaBrowser, MediaEntry, MediaResource, MediaServerInfo, MusicServer, ServerId, - UpnpMediaServer, + MediaBrowser, MediaEntry, MediaResource, UpnpMediaServer, }; -pub use music_renderer::MusicRenderer; +pub use music_renderer::MusicRendererBackend; pub use openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; pub use openhome_renderer::OpenHomeRenderer; -pub use queue_backend::{EnqueueMode, PlaybackItem, QueueSnapshot}; +pub use queue::{EnqueueMode, PlaybackItem, QueueSnapshot}; pub use rendering_control_client::RenderingControlClient; pub use upnp_renderer::UpnpRenderer; -pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager}; pub use model::{ - MediaServerEvent, RendererCapabilities, RendererEvent, RendererId, RendererInfo, - RendererProtocol, + MediaServerEvent, RendererCapabilities, RendererEvent, RendererInfo, RendererProtocol, }; pub use provider::HttpXmlDescriptionProvider; -pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate}; +pub use registry::{DeviceRegistry, DeviceUpdate}; pub use soap_client::invoke_upnp_action; +pub use online::DeviceOnline; + +pub use identity::DeviceIdentity; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct DeviceId(pub String); + +const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30); + diff --git a/pmocontrol/src/linkplay.rs b/pmocontrol/src/linkplay_renderer.rs similarity index 70% rename from pmocontrol/src/linkplay.rs rename to pmocontrol/src/linkplay_renderer.rs index 01959c18..d47e813b 100644 --- a/pmocontrol/src/linkplay.rs +++ b/pmocontrol/src/linkplay_renderer.rs @@ -7,11 +7,13 @@ use anyhow::{Context, Result, anyhow}; use tracing::debug; use ureq::Agent; +use crate::DeviceIdentity; use crate::capabilities::{ PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, VolumeControl, }; -use crate::model::{RendererId, RendererInfo}; +use crate::errors::ControlPointError; +use crate::model::{RendererInfo}; const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3; const STATUS_COMMAND: &str = "getPlayerStatus"; @@ -19,7 +21,6 @@ const STATUS_COMMAND: &str = "getPlayerStatus"; /// Renderer backend for devices exposing the LinkPlay HTTP API. #[derive(Clone)] pub struct LinkPlayRenderer { - pub info: RendererInfo, host: String, timeout: Duration, } @@ -27,8 +28,6 @@ pub struct LinkPlayRenderer { impl fmt::Debug for LinkPlayRenderer { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("LinkPlayRenderer") - .field("id", &self.info.id) - .field("friendly_name", &self.info.friendly_name) .field("host", &self.host) .finish() } @@ -36,12 +35,11 @@ impl fmt::Debug for LinkPlayRenderer { impl LinkPlayRenderer { /// Build a LinkPlay backend from a registry snapshot. - pub fn from_renderer_info(info: RendererInfo) -> Result { - let host = extract_linkplay_host(&info.location) - .ok_or_else(|| anyhow!("Renderer {} has no valid LOCATION host", info.udn))?; + pub fn from_renderer_info(info: RendererInfo) -> Result { + let host = extract_linkplay_host(&info.location()) + .ok_or_else(|| ControlPointError::LinkPlayError(format!("Renderer {} has no valid LOCATION host", info.udn())))?; Ok(Self { - info, host, timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS), }) @@ -51,15 +49,7 @@ impl LinkPlayRenderer { build_agent(self.timeout) } - pub fn id(&self) -> &RendererId { - &self.info.id - } - - pub fn friendly_name(&self) -> &str { - &self.info.friendly_name - } - - fn send_player_command(&self, command: &str) -> Result<()> { + fn send_player_command(&self, command: &str) -> Result<(), ControlPointError> { let url = format!( "http://{}/httpapi.asp?command=setPlayerCmd:{}", self.host, command @@ -67,67 +57,67 @@ impl LinkPlayRenderer { self.agent() .get(&url) .call() - .with_context(|| format!("LinkPlay command {} failed for {}", command, self.host))?; + .map_err(|_| ControlPointError::ArilycTcpError(format!("LinkPlay command {} failed for {}", command, self.host)))?; Ok(()) } - fn fetch_status(&self) -> Result { + fn fetch_status(&self) -> Result { fetch_status_for_host(&self.host, self.timeout) } } impl TransportControl for LinkPlayRenderer { - fn play_uri(&self, uri: &str, _meta: &str) -> Result<()> { + fn play_uri(&self, uri: &str, _meta: &str) -> Result<(), ControlPointError> { let encoded = percent_encode(uri); self.send_player_command(&format!("play:{}", encoded)) } - fn play(&self) -> Result<()> { + fn play(&self) -> Result<(), ControlPointError> { self.send_player_command("resume") } - fn pause(&self) -> Result<()> { + fn pause(&self) -> Result<(), ControlPointError> { self.send_player_command("pause") } - fn stop(&self) -> Result<()> { + fn stop(&self) -> Result<(), ControlPointError> { self.send_player_command("stop") } - fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { + fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { let secs = parse_hhmmss_to_secs(hhmmss) - .ok_or_else(|| anyhow!("Invalid seek position format: {}", hhmmss))?; + .ok_or_else(|| ControlPointError::LinkPlayError(format!("Invalid seek position format: {}", hhmmss)))?; self.send_player_command(&format!("seek:{}", secs)) } } impl VolumeControl for LinkPlayRenderer { - fn volume(&self) -> Result { + fn volume(&self) -> Result { Ok(self.fetch_status()?.volume) } - fn set_volume(&self, v: u16) -> Result<()> { + fn set_volume(&self, v: u16) -> Result<(), ControlPointError> { let value = v.min(100); self.send_player_command(&format!("vol:{}", value)) } - fn mute(&self) -> Result { + fn mute(&self) -> Result { Ok(self.fetch_status()?.mute) } - fn set_mute(&self, m: bool) -> Result<()> { + fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { self.send_player_command(if m { "mute:1" } else { "mute:0" }) } } impl PlaybackStatus for LinkPlayRenderer { - fn playback_state(&self) -> Result { + fn playback_state(&self) -> Result { Ok(self.fetch_status()?.playback_state()) } } impl PlaybackPosition for LinkPlayRenderer { - fn playback_position(&self) -> Result { + fn playback_position(&self) -> Result { Ok(self.fetch_status()?.position_info()) } } @@ -174,17 +164,17 @@ pub fn extract_linkplay_host(location: &str) -> Option { } } -fn fetch_status_for_host(host: &str, timeout: Duration) -> Result { +fn fetch_status_for_host(host: &str, timeout: Duration) -> Result { let url = format!("http://{}/httpapi.asp?command={}", host, STATUS_COMMAND); let mut response = build_agent(timeout) .get(&url) .call() - .with_context(|| format!("HTTP request failed for LinkPlay status on {}", host))?; + .map_err(|_| ControlPointError::ArilycTcpError(format!("HTTP request failed for LinkPlay status on {}", host)))?; let body = response .body_mut() .read_to_string() - .context("Failed to read LinkPlay status body")?; + .map_err(|e| ControlPointError::ArilycTcpError(format!("Failed to read LinkPlay status body : {}",e)))?; parse_linkplay_status(&body) } @@ -233,12 +223,12 @@ impl LinkPlayStatus { } } -fn parse_linkplay_status(body: &str) -> Result { +fn parse_linkplay_status(body: &str) -> Result { let mut map = parse_flat_json(body)?; let state_raw = map .remove("status") - .ok_or_else(|| anyhow!("LinkPlay status missing `status` field"))?; + .ok_or_else(|| ControlPointError::ArilycTcpError(format!("LinkPlay status missing `status` field")))?; let curpos_ms = parse_u64_field(&map, "curpos")?; let totlen_ms = parse_u64_field(&map, "totlen")?; @@ -247,9 +237,9 @@ fn parse_linkplay_status(body: &str) -> Result { Some("1") => true, Some("0") => false, Some(other) => { - return Err(anyhow!("Invalid LinkPlay mute value: {}", other)); + return Err(ControlPointError::ArilycTcpError(format!("Invalid LinkPlay mute value: {}", other))); } - None => return Err(anyhow!("LinkPlay status missing `mute` field")), + None => return Err(ControlPointError::arilyc_tcp_error("LinkPlay status missing `mute` field")), }; let track_index = map @@ -267,29 +257,29 @@ fn parse_linkplay_status(body: &str) -> Result { }) } -fn parse_u64_field(map: &HashMap, key: &str) -> Result { +fn parse_u64_field(map: &HashMap, key: &str) -> Result { let raw = map .get(key) - .ok_or_else(|| anyhow!("LinkPlay status missing `{}` field", key))?; + .ok_or_else(|| ControlPointError::ArilycTcpError(format!("LinkPlay status missing `{}` field", key)))?; raw.parse::() - .with_context(|| format!("Invalid `{}` value: {}", key, raw)) + .map_err(|_| ControlPointError::LinkPlayError(format!("Invalid `{}` value: {}", key, raw))) } -fn parse_u16_field(map: &HashMap, key: &str) -> Result { +fn parse_u16_field(map: &HashMap, key: &str) -> Result { let raw = map .get(key) - .ok_or_else(|| anyhow!("LinkPlay status missing `{}` field", key))?; + .ok_or_else(|| ControlPointError::ArilycTcpError(format!("LinkPlay status missing `{}` field", key)))?; let value = raw .parse::() - .with_context(|| format!("Invalid `{}` value: {}", key, raw))?; + .map_err(|_| ControlPointError::LinkPlayError(format!("Invalid `{}` value: {}", key, raw)))?; Ok(value.min(100)) } -pub(crate) fn parse_flat_json(input: &str) -> Result> { +pub(crate) fn parse_flat_json(input: &str) -> Result, ControlPointError> { let mut chars = input.chars().peekable(); skip_ws(&mut chars); if chars.next() != Some('{') { - return Err(anyhow!("LinkPlay status is not a JSON object")); + return Err(ControlPointError::ArilycTcpError(format!("LinkPlay status is not a JSON object"))); } let mut map = HashMap::new(); @@ -301,7 +291,7 @@ pub(crate) fn parse_flat_json(input: &str) -> Result> { break; } Some(_) => {} - None => return Err(anyhow!("Unexpected end of JSON object")), + None => return Err(ControlPointError::ArilycTcpError(format!("Unexpected end of JSON object"))), } let key = parse_json_string(&mut chars)?; @@ -322,19 +312,19 @@ pub(crate) fn parse_flat_json(input: &str) -> Result> { break; } Some(other) => { - return Err(anyhow!( + return Err(ControlPointError::ArilycTcpError(format!( "Unexpected character '{}' while parsing JSON", other - )); + ))); } - None => return Err(anyhow!("Unexpected end of JSON while parsing fields")), + None => return Err(ControlPointError::ArilycTcpError(format!("Unexpected end of JSON while parsing fields"))), } } Ok(map) } -fn parse_json_value(chars: &mut std::iter::Peekable>) -> Result { +fn parse_json_value(chars: &mut std::iter::Peekable>) -> Result { match chars.peek() { Some('"') => parse_json_string(chars), Some(ch) if ch.is_ascii_digit() || *ch == '-' => parse_json_number(chars), @@ -346,13 +336,13 @@ fn parse_json_value(chars: &mut std::iter::Peekable>) -> Res expect_literal(chars, "false")?; Ok("false".to_string()) } - _ => Err(anyhow!("Unsupported JSON value in LinkPlay status")), + _ => Err(ControlPointError::ArilycTcpError(format!("Unsupported JSON value in LinkPlay status"))), } } -fn parse_json_string(chars: &mut std::iter::Peekable>) -> Result { +fn parse_json_string(chars: &mut std::iter::Peekable>) -> Result { if chars.next() != Some('"') { - return Err(anyhow!("Expected string")); + return Err(ControlPointError::ArilycTcpError(format!("Expected string"))); } let mut out = String::new(); @@ -360,7 +350,7 @@ fn parse_json_string(chars: &mut std::iter::Peekable>) -> Re match ch { '"' => return Ok(out), '\\' => { - let escaped = chars.next().ok_or_else(|| anyhow!("Invalid escape"))?; + let escaped = chars.next().ok_or_else(|| ControlPointError::ArilycTcpError(format!("Invalid escape")))?; match escaped { '"' => out.push('"'), '\\' => out.push('\\'), @@ -373,28 +363,28 @@ fn parse_json_string(chars: &mut std::iter::Peekable>) -> Re 'u' => { let mut hex = String::with_capacity(4); for _ in 0..4 { - let h = chars.next().ok_or_else(|| anyhow!("Invalid \\u escape"))?; + let h = chars.next().ok_or_else(|| ControlPointError::ArilycTcpError(format!("Invalid \\u escape")))?; hex.push(h); } let code = u16::from_str_radix(&hex, 16) - .with_context(|| format!("Invalid unicode escape: {}", hex))?; + .map_err(|e| ControlPointError::ArilycTcpError(format!("Invalid unicode escape: {}", hex)))?; if let Some(c) = char::from_u32(code as u32) { out.push(c); } else { - return Err(anyhow!("Invalid unicode code point: {}", code)); + return Err(ControlPointError::ArilycTcpError(format!("Invalid unicode code point: {}", code))); } } - other => return Err(anyhow!("Unsupported escape: {}", other)), + other => return Err(ControlPointError::ArilycTcpError(format!("Unsupported escape: {}", other))), } } other => out.push(other), } } - Err(anyhow!("Unterminated JSON string")) + Err(ControlPointError::ArilycTcpError(format!("Unterminated JSON string"))) } -fn parse_json_number(chars: &mut std::iter::Peekable>) -> Result { +fn parse_json_number(chars: &mut std::iter::Peekable>) -> Result { let mut out = String::new(); if matches!(chars.peek(), Some('-')) { @@ -412,7 +402,7 @@ fn parse_json_number(chars: &mut std::iter::Peekable>) -> Re } if out.is_empty() || out == "-" { - return Err(anyhow!("Invalid number")); + return Err(ControlPointError::ArilycTcpError(format!("Invalid number"))); } Ok(out) @@ -421,20 +411,20 @@ fn parse_json_number(chars: &mut std::iter::Peekable>) -> Re fn expect_literal( chars: &mut std::iter::Peekable>, literal: &str, -) -> Result<()> { +) -> Result<(), ControlPointError> { for expected in literal.chars() { match chars.next() { Some(ch) if ch == expected => {} - _ => return Err(anyhow!("Invalid literal while parsing JSON")), + _ => return Err(ControlPointError::ArilycTcpError(format!("Invalid literal while parsing JSON"))), } } Ok(()) } -fn expect_char(chars: &mut std::iter::Peekable>, expected: char) -> Result<()> { +fn expect_char(chars: &mut std::iter::Peekable>, expected: char) -> Result<(), ControlPointError> { match chars.next() { Some(ch) if ch == expected => Ok(()), - _ => Err(anyhow!("Missing '{}' while parsing JSON", expected)), + _ => Err(ControlPointError::ArilycTcpError(format!("Missing '{}' while parsing JSON", expected))), } } diff --git a/pmocontrol/src/media_server.rs b/pmocontrol/src/media_server.rs index 9c2f7f45..0eea73c5 100644 --- a/pmocontrol/src/media_server.rs +++ b/pmocontrol/src/media_server.rs @@ -1,36 +1,263 @@ -use std::time::{Duration, SystemTime}; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; -use anyhow::{Result, anyhow}; -use pmodidl::{self, DIDLLite}; +use pmodidl::DIDLLite; use pmoupnp::soap::SoapEnvelope; use pmoupnp::soap::error_codes; use tracing::{debug, warn}; use xmltree::{Element, XMLNode}; +use crate::errors::ControlPointError; use crate::model::TrackMetadata; -use crate::queue_backend::PlaybackItem; +use crate::online::{DeviceConnectionState, DeviceOnline}; +use crate::queue::backend::PlaybackItem; use crate::soap_client::{SoapCallResult, invoke_upnp_action_with_timeout}; - -/// Unique identifier for a media server registered by the control point. -#[derive(Clone, Debug, Eq, PartialEq, Hash)] -pub struct ServerId(pub String); +use crate::{DEFAULT_HTTP_TIMEOUT, DeviceId, DeviceIdentity}; /// Snapshot of a media server discovered through UPnP SSDP. #[derive(Clone, Debug)] -pub struct MediaServerInfo { - pub id: ServerId, - pub udn: String, - pub friendly_name: String, - pub model_name: String, - pub manufacturer: String, - pub location: String, - pub server_header: String, - pub online: bool, - pub last_seen: SystemTime, - pub max_age: u32, - pub has_content_directory: bool, - pub content_directory_service_type: Option, - pub content_directory_control_url: Option, +pub struct UpnpMediaServer { + id: DeviceId, + udn: String, + friendly_name: String, + model_name: String, + manufacturer: String, + location: String, + server_header: String, + has_content_directory: bool, + content_directory_service_type: Option, + content_directory_control_url: Option, + connection: Arc>, +} + +impl UpnpMediaServer { + pub fn make( + id: DeviceId, + udn: String, + friendly_name: String, + model_name: String, + manufacturer: String, + location: String, + server_header: String, + has_content_directory: bool, + content_directory_service_type: Option, + content_directory_control_url: Option, + ) -> Arc { + Arc::new(Self { + id, + udn, + friendly_name, + model_name, + manufacturer, + location, + server_header, + has_content_directory, + content_directory_service_type, + content_directory_control_url, + connection: DeviceConnectionState::make(), + }) + } + + fn browse_with_flag( + &self, + object_id: &str, + browse_flag: &str, + start: u32, + count: u32, + ) -> Result, ControlPointError> { + let start_str = start.to_string(); + let count_str = count.to_string(); + let args = vec![ + ("ObjectID", object_id.to_string()), + ("BrowseFlag", browse_flag.to_string()), + ("Filter", "*".to_string()), + ("StartingIndex", start_str), + ("RequestedCount", count_str), + ("SortCriteria", String::new()), + ]; + + let response = self.invoke_content_directory("Browse", None, args)?; + let envelope = response.envelope.ok_or_else(|| { + ControlPointError::MediaServerError(format!("Missing SOAP envelope in Browse response")) + })?; + let didl_xml = extract_result_payload(&envelope, "BrowseResponse")?; + map_didl_entries(&didl_xml) + } + + fn search_impl( + &self, + container_id: &str, + query: &str, + start: u32, + count: u32, + ) -> Result, ControlPointError> { + let start_str = start.to_string(); + let count_str = count.to_string(); + + let args = vec![ + ("ContainerID", container_id.to_string()), + ("SearchCriteria", query.to_string()), + ("Filter", "*".to_string()), + ("StartingIndex", start_str), + ("RequestedCount", count_str), + ("SortCriteria", String::new()), + ]; + + let response = self.invoke_content_directory("Search", Some("search"), args)?; + let envelope = response.envelope.ok_or_else(|| { + ControlPointError::MediaServerError(format!("Missing SOAP envelope in Search response")) + })?; + let didl_xml = extract_result_payload(&envelope, "SearchResponse")?; + map_didl_entries(&didl_xml) + } + + fn invoke_content_directory( + &self, + action: &str, + op_name: Option<&str>, + args: Vec<(&'static str, String)>, + ) -> Result { + let op = op_name.unwrap_or(action); + let (control_url, service_type) = self.content_directory_endpoints(op)?; + let borrowed_args: Vec<(&str, &str)> = args.iter().map(|(k, v)| (*k, v.as_str())).collect(); + + let call_result = invoke_upnp_action_with_timeout( + control_url, + service_type, + action, + &borrowed_args, + Some(DEFAULT_HTTP_TIMEOUT), + )?; + + if !call_result.status.is_success() { + if let Some(env) = &call_result.envelope { + if let Some(err) = parse_upnp_error(env) { + if should_map_to_not_supported(action, op_name, err.error_code) { + return Err(ControlPointError::upnp_operation_not_supported( + op, + "UpnpMediaServer", + )); + } + + return Err(ControlPointError::MediaServerError(format!( + "{} failed with UPnP error {}: {}", + action, err.error_code, err.error_description + ))); + } + } + + return Err(ControlPointError::MediaServerError(format!( + "{} failed with HTTP status {} and body: {}", + action, call_result.status, call_result.raw_body + ))); + } + + if let Some(env) = &call_result.envelope { + if let Some(err) = parse_upnp_error(env) { + if should_map_to_not_supported(action, op_name, err.error_code) { + return Err(ControlPointError::upnp_operation_not_supported( + op, + "UpnpMediaServer", + )); + } + + return Err(ControlPointError::MediaServerError(format!( + "{} returned UPnP error {}: {}", + action, err.error_code, err.error_description + ))); + } + } + + Ok(call_result) + } + + fn content_directory_endpoints( + &self, + op_name: &str, + ) -> Result<(&str, &str), ControlPointError> { + if !self.has_content_directory { + return Err(ControlPointError::upnp_operation_not_supported( + op_name, + "UpnpMediaServer", + )); + } + + let control_url = self + .content_directory_control_url + .as_deref() + .ok_or_else(|| { + ControlPointError::upnp_operation_not_supported(op_name, "UpnpMediaServer") + })?; + let service_type = self + .content_directory_service_type + .as_deref() + .ok_or_else(|| { + ControlPointError::upnp_operation_not_supported(op_name, "UpnpMediaServer") + })?; + + Ok((control_url, service_type)) + } +} + +impl DeviceOnline for UpnpMediaServer { + fn is_online(&self) -> bool { + self.connection + .lock() + .expect("Connection Mutex Poisoned") + .is_online() + } + fn last_seen(&self) -> SystemTime { + self.connection + .lock() + .expect("Connection Mutex Poisoned") + .last_seen() + } + fn has_been_seen_now(&self, max_age: u32) { + self.connection + .lock() + .expect("Connection Mutex Poisoned") + .has_been_seen_now(max_age) + } + fn mark_as_offline(&self) { + self.connection + .lock() + .expect("Connection Mutex Poisoned") + .mark_as_offline() + } + fn max_age(&self) -> u32 { + self.connection + .lock() + .expect("Connection Mutex Poisoned") + .max_age() + } +} + +impl DeviceIdentity for UpnpMediaServer { + fn id(&self) -> DeviceId { + self.id.clone() + } + fn udn(&self) -> &str { + &self.udn + } + fn friendly_name(&self) -> &str { + &self.friendly_name + } + fn model_name(&self) -> &str { + &self.model_name + } + fn manufacturer(&self) -> &str { + &self.manufacturer + } + fn location(&self) -> &str { + &self.location + } + fn server_header(&self) -> &str { + &self.server_header + } + + fn is_a_media_server(&self) -> bool { + true + } } /// Simplified view over a DIDL-Lite resource entry. @@ -54,12 +281,8 @@ impl MediaResource { // List of known audio format subtypes (the part after the /) // These are recognized regardless of the MIME type prefix const AUDIO_FORMATS: &[&str] = &[ - "flac", "ogg", "opus", "vorbis", - "mp3", "mpeg", "mp4", "m4a", "aac", - "wav", "wave", "pcm", - "wma", "webm", - "ape", "alac", "aiff", - "dsd", "dsf", "dff", + "flac", "ogg", "opus", "vorbis", "mp3", "mpeg", "mp4", "m4a", "aac", "wav", "wave", + "pcm", "wma", "webm", "ape", "alac", "aiff", "dsd", "dsf", "dff", ]; // Check if any known audio format appears in the protocol_info @@ -111,16 +334,21 @@ pub struct MediaEntry { /// Backend-agnostic media browsing contract. pub trait MediaBrowser { - fn browse_root(&self) -> Result>; - fn browse_children(&self, object_id: &str, start: u32, count: u32) -> Result>; - fn browse_object(&self, object_id: &str) -> Result; + fn browse_root(&self) -> Result, ControlPointError>; + fn browse_children( + &self, + object_id: &str, + start: u32, + count: u32, + ) -> Result, ControlPointError>; + fn browse_object(&self, object_id: &str) -> Result; fn search( &self, container_id: &str, query: &str, start: u32, count: u32, - ) -> Result>; + ) -> Result, ControlPointError>; } /// Façade over every supported media server backend. @@ -129,41 +357,96 @@ pub enum MusicServer { Upnp(UpnpMediaServer), } -impl MusicServer { - pub fn from_info(info: &MediaServerInfo, timeout: Duration) -> Result { - Ok(MusicServer::Upnp(UpnpMediaServer::new( - info.clone(), - timeout, - ))) - } - - pub fn id(&self) -> &ServerId { +impl DeviceOnline for MusicServer { + fn is_online(&self) -> bool { match self { - MusicServer::Upnp(upnp) => upnp.id(), + MusicServer::Upnp(u) => u.is_online(), } } - - pub fn info(&self) -> &MediaServerInfo { + fn last_seen(&self) -> SystemTime { match self { - MusicServer::Upnp(upnp) => upnp.info(), + MusicServer::Upnp(u) => u.last_seen(), + } + } + fn has_been_seen_now(&self, max_age: u32) { + match self { + MusicServer::Upnp(u) => u.has_been_seen_now(max_age), + } + } + fn mark_as_offline(&self) { + match self { + MusicServer::Upnp(u) => u.mark_as_offline(), + } + } + fn max_age(&self) -> u32 { + match self { + MusicServer::Upnp(u) => u.max_age(), } } } +impl DeviceIdentity for MusicServer { + fn id(&self) -> DeviceId { + match self { + MusicServer::Upnp(u) => u.id(), + } + } + + fn udn(&self) -> &str { + match self { + MusicServer::Upnp(u) => u.udn(), + } + } + fn friendly_name(&self) -> &str { + match self { + MusicServer::Upnp(u) => u.friendly_name(), + } + } + fn model_name(&self) -> &str { + match self { + MusicServer::Upnp(u) => u.model_name(), + } + } + fn manufacturer(&self) -> &str { + match self { + MusicServer::Upnp(u) => u.manufacturer(), + } + } + fn location(&self) -> &str { + match self { + MusicServer::Upnp(u) => u.location(), + } + } + fn server_header(&self) -> &str { + match self { + MusicServer::Upnp(u) => u.server_header(), + } + } + + fn is_a_media_server(&self) -> bool { + true + } +} + impl MediaBrowser for MusicServer { - fn browse_root(&self) -> Result> { + fn browse_root(&self) -> Result, ControlPointError> { match self { MusicServer::Upnp(upnp) => upnp.browse_root(), } } - fn browse_children(&self, object_id: &str, start: u32, count: u32) -> Result> { + fn browse_children( + &self, + object_id: &str, + start: u32, + count: u32, + ) -> Result, ControlPointError> { match self { MusicServer::Upnp(upnp) => upnp.browse_children(object_id, start, count), } } - fn browse_object(&self, object_id: &str) -> Result { + fn browse_object(&self, object_id: &str) -> Result { match self { MusicServer::Upnp(upnp) => upnp.browse_object(object_id), } @@ -175,7 +458,7 @@ impl MediaBrowser for MusicServer { query: &str, start: u32, count: u32, - ) -> Result> { + ) -> Result, ControlPointError> { match self { MusicServer::Upnp(upnp) => upnp.search(container_id, query, start, count), } @@ -186,7 +469,10 @@ impl MediaBrowser for MusicServer { /// /// This function filters out containers and entries without audio resources, /// returning None for items that cannot be played. -pub fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option { +pub fn playback_item_from_entry( + server: &UpnpMediaServer, + entry: &MediaEntry, +) -> Option { // Ignore containers if entry.is_container { debug!( @@ -249,6 +535,7 @@ pub fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Opt Some(PlaybackItem { media_server_id: server.id().clone(), + backend_id: usize::MAX, didl_id: entry.id.clone(), uri: resource.uri.clone(), protocol_info: resource.protocol_info.clone(), @@ -256,174 +543,28 @@ pub fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Opt }) } -/// Single UPnP ContentDirectory backend implementation. -#[derive(Clone, Debug)] -pub struct UpnpMediaServer { - info: MediaServerInfo, - timeout: Duration, -} - -impl UpnpMediaServer { - pub fn new(info: MediaServerInfo, timeout: Duration) -> Self { - Self { info, timeout } - } - - pub fn id(&self) -> &ServerId { - &self.info.id - } - - pub fn info(&self) -> &MediaServerInfo { - &self.info - } - - fn browse_with_flag( - &self, - object_id: &str, - browse_flag: &str, - start: u32, - count: u32, - ) -> Result> { - let start_str = start.to_string(); - let count_str = count.to_string(); - let args = vec![ - ("ObjectID", object_id.to_string()), - ("BrowseFlag", browse_flag.to_string()), - ("Filter", "*".to_string()), - ("StartingIndex", start_str), - ("RequestedCount", count_str), - ("SortCriteria", String::new()), - ]; - - let response = self.invoke_content_directory("Browse", None, args)?; - let envelope = response - .envelope - .ok_or_else(|| anyhow!("Missing SOAP envelope in Browse response"))?; - let didl_xml = extract_result_payload(&envelope, "BrowseResponse")?; - map_didl_entries(&didl_xml) - } - - fn search_impl( - &self, - container_id: &str, - query: &str, - start: u32, - count: u32, - ) -> Result> { - let start_str = start.to_string(); - let count_str = count.to_string(); - - let args = vec![ - ("ContainerID", container_id.to_string()), - ("SearchCriteria", query.to_string()), - ("Filter", "*".to_string()), - ("StartingIndex", start_str), - ("RequestedCount", count_str), - ("SortCriteria", String::new()), - ]; - - let response = self.invoke_content_directory("Search", Some("search"), args)?; - let envelope = response - .envelope - .ok_or_else(|| anyhow!("Missing SOAP envelope in Search response"))?; - let didl_xml = extract_result_payload(&envelope, "SearchResponse")?; - map_didl_entries(&didl_xml) - } - - fn invoke_content_directory( - &self, - action: &str, - op_name: Option<&str>, - args: Vec<(&'static str, String)>, - ) -> Result { - let op = op_name.unwrap_or(action); - let (control_url, service_type) = self.content_directory_endpoints(op)?; - let borrowed_args: Vec<(&str, &str)> = args.iter().map(|(k, v)| (*k, v.as_str())).collect(); - - let call_result = invoke_upnp_action_with_timeout( - control_url, - service_type, - action, - &borrowed_args, - Some(self.timeout), - )?; - - if !call_result.status.is_success() { - if let Some(env) = &call_result.envelope { - if let Some(err) = parse_upnp_error(env) { - if should_map_to_not_supported(action, op_name, err.error_code) { - return Err(server_op_not_supported(op, "UpnpMediaServer")); - } - - return Err(anyhow!( - "{} failed with UPnP error {}: {}", - action, - err.error_code, - err.error_description - )); - } - } - - return Err(anyhow!( - "{} failed with HTTP status {} and body: {}", - action, - call_result.status, - call_result.raw_body - )); - } - - if let Some(env) = &call_result.envelope { - if let Some(err) = parse_upnp_error(env) { - if should_map_to_not_supported(action, op_name, err.error_code) { - return Err(server_op_not_supported(op, "UpnpMediaServer")); - } - - return Err(anyhow!( - "{} returned UPnP error {}: {}", - action, - err.error_code, - err.error_description - )); - } - } - - Ok(call_result) - } - - fn content_directory_endpoints(&self, op_name: &str) -> Result<(&str, &str)> { - if !self.info.has_content_directory { - return Err(server_op_not_supported(op_name, "UpnpMediaServer")); - } - - let control_url = self - .info - .content_directory_control_url - .as_deref() - .ok_or_else(|| server_op_not_supported(op_name, "UpnpMediaServer"))?; - let service_type = self - .info - .content_directory_service_type - .as_deref() - .ok_or_else(|| server_op_not_supported(op_name, "UpnpMediaServer"))?; - - Ok((control_url, service_type)) - } -} - impl MediaBrowser for UpnpMediaServer { - fn browse_root(&self) -> Result> { + fn browse_root(&self) -> Result, ControlPointError> { self.browse_with_flag("0", "BrowseDirectChildren", 0, 0) } - fn browse_children(&self, object_id: &str, start: u32, count: u32) -> Result> { + fn browse_children( + &self, + object_id: &str, + start: u32, + count: u32, + ) -> Result, ControlPointError> { self.browse_with_flag(object_id, "BrowseDirectChildren", start, count) } - fn browse_object(&self, object_id: &str) -> Result { + fn browse_object(&self, object_id: &str) -> Result { let entries = self.browse_with_flag(object_id, "BrowseMetadata", 0, 1)?; - entries - .into_iter() - .next() - .ok_or_else(|| anyhow!("Object {} was not returned by the server", object_id)) + entries.into_iter().next().ok_or_else(|| { + ControlPointError::MediaServerError(format!( + "Object {} was not returned by the server", + object_id + )) + }) } fn search( @@ -432,19 +573,24 @@ impl MediaBrowser for UpnpMediaServer { query: &str, start: u32, count: u32, - ) -> Result> { + ) -> Result, ControlPointError> { self.search_impl(container_id, query, start, count) } } -fn map_didl_entries(xml: &str) -> Result> { +fn map_didl_entries(xml: &str) -> Result, ControlPointError> { let trimmed = xml.trim(); if trimmed.is_empty() { return Ok(Vec::new()); } let didl: DIDLLite = pmodidl::parse_metadata::(trimmed) - .map_err(|err| anyhow!("Failed to parse DIDL-Lite payload: {}", err))? + .map_err(|err| { + ControlPointError::MediaServerError(format!( + "Failed to parse DIDL-Lite payload: {}", + err + )) + })? .data; let mut entries = Vec::new(); @@ -503,11 +649,23 @@ fn map_didl_entries(xml: &str) -> Result> { Ok(entries) } -fn extract_result_payload(envelope: &SoapEnvelope, response_suffix: &str) -> Result { - let response = find_child_with_suffix(&envelope.body.content, response_suffix) - .ok_or_else(|| anyhow!("Missing {} element in SOAP body", response_suffix))?; - let result_elem = find_child_with_suffix(response, "Result") - .ok_or_else(|| anyhow!("Missing Result element in {}", response_suffix))?; +fn extract_result_payload( + envelope: &SoapEnvelope, + response_suffix: &str, +) -> Result { + let response = + find_child_with_suffix(&envelope.body.content, response_suffix).ok_or_else(|| { + ControlPointError::MediaServerError(format!( + "Missing {} element in SOAP body", + response_suffix + )) + })?; + let result_elem = find_child_with_suffix(response, "Result").ok_or_else(|| { + ControlPointError::MediaServerError(format!( + "Missing Result element in {}", + response_suffix + )) + })?; let payload = result_elem .get_text() @@ -568,14 +726,6 @@ fn should_map_to_not_supported(action: &str, op_name: Option<&str>, error_code: cd_not_supported && (error_code == optional_code || error_code == invalid_action_code) } -fn server_op_not_supported(op: &str, backend: &str) -> anyhow::Error { - anyhow!( - "MusicServer operation '{}' is not supported by backend '{}'", - op, - backend - ) -} - #[derive(Debug, Clone)] struct UpnpError { pub error_code: u32, diff --git a/pmocontrol/src/media_server_events.rs b/pmocontrol/src/media_server_events.rs index cd2d011d..3a9c1a09 100644 --- a/pmocontrol/src/media_server_events.rs +++ b/pmocontrol/src/media_server_events.rs @@ -13,7 +13,7 @@ use ureq::{Agent, http}; use xmltree::{Element, XMLNode}; use crate::events::MediaServerEventBus; -use crate::media_server::{MediaServerInfo, ServerId}; +use crate::media_server::{UpnpMediaServer, ServerId}; use crate::model::MediaServerEvent; use crate::provider::resolve_control_url; use crate::registry::{DeviceRegistry, DeviceRegistryRead}; @@ -551,7 +551,7 @@ impl MediaServerEventWorker { } struct SubscriptionState { - info: MediaServerInfo, + info: UpnpMediaServer, event_sub_url: Option, sid: Option, expires_at: Option, @@ -560,7 +560,7 @@ struct SubscriptionState { } impl SubscriptionState { - fn new(info: MediaServerInfo) -> Self { + fn new(info: UpnpMediaServer) -> Self { Self { callback_path: build_callback_path(&info.id), info, @@ -571,7 +571,7 @@ impl SubscriptionState { } } - fn update(&mut self, info: MediaServerInfo) { + fn update(&mut self, info: UpnpMediaServer) { if self.info.location != info.location { self.event_sub_url = None; self.sid = None; diff --git a/pmocontrol/src/model.rs b/pmocontrol/src/model.rs index 0ad26c2f..1e7c8625 100644 --- a/pmocontrol/src/model.rs +++ b/pmocontrol/src/model.rs @@ -1,9 +1,10 @@ +use std::sync::{Arc, Mutex}; + +use crate::{DeviceId,DeviceIdentity}; use crate::capabilities::{PlaybackPositionInfo, PlaybackState}; use crate::control_point::PlaylistBinding; -use crate::media_server::{MediaServerInfo, ServerId}; +use crate::media_server::UpnpMediaServer; -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct RendererId(pub String); #[derive(Clone, Debug, PartialEq)] pub struct TrackMetadata { @@ -17,11 +18,11 @@ pub struct TrackMetadata { pub creator: Option, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Copy)] pub enum RendererProtocol { UpnpAvOnly, OpenHomeOnly, - Hybrid, + OpenHomeHybrid, ChromecastOnly, } @@ -47,6 +48,74 @@ pub struct RendererCapabilities { } impl RendererCapabilities { + pub fn make( + has_avtransport: bool, + has_avtransport_set_next: bool, + has_rendering_control: bool, + has_connection_manager: bool, + has_linkplay_http: bool, + has_arylic_tcp: bool, + has_oh_playlist: bool, + has_oh_volume: bool, + has_oh_info: bool, + has_oh_time: bool, + has_oh_radio: bool, + has_chromecast: bool, + ) -> Self { + RendererCapabilities { + has_avtransport, + has_avtransport_set_next, + has_rendering_control, + has_connection_manager, + has_linkplay_http, + has_arylic_tcp, + has_oh_playlist, + has_oh_volume, + has_oh_info, + has_oh_time, + has_oh_radio, + has_chromecast, + } + } + + pub fn has_avtransport(&self) -> bool { + self.has_avtransport + } + pub fn has_avtransport_set_next(&self) -> bool { + self.has_avtransport_set_next + } + pub fn has_rendering_control(&self) -> bool { + self.has_rendering_control + } + pub fn has_connection_manager(&self) -> bool { + self.has_connection_manager + } + pub fn has_linkplay_http(&self) -> bool { + self.has_linkplay_http + } + pub fn has_arylic_tcp(&self) -> bool { + self.has_arylic_tcp + } + pub fn has_oh_playlist(&self) -> bool { + self.has_oh_playlist + } + pub fn has_oh_volume(&self) -> bool { + self.has_oh_volume + } + pub fn has_oh_info(&self) -> bool { + self.has_oh_info + } + pub fn has_oh_time(&self) -> bool { + self.has_oh_time + } + pub fn has_oh_radio(&self) -> bool { + self.has_oh_radio + } + + pub fn has_chromecast(&self) -> bool { + self.has_chromecast + } + pub fn supports_set_next(&self) -> bool { self.has_avtransport && self.has_avtransport_set_next } @@ -54,98 +123,263 @@ impl RendererCapabilities { #[derive(Clone, Debug)] pub struct RendererInfo { - pub id: RendererId, - pub udn: String, - pub friendly_name: String, - pub model_name: String, - pub manufacturer: String, + id: DeviceId, + udn: String, + friendly_name: String, + model_name: String, + manufacturer: String, - pub protocol: RendererProtocol, - pub capabilities: RendererCapabilities, + protocol: RendererProtocol, + capabilities: RendererCapabilities, - pub location: String, - pub server_header: String, - pub online: bool, - pub last_seen: std::time::SystemTime, - pub max_age: u32, + location: String, - pub avtransport_service_type: Option, - pub avtransport_control_url: Option, - pub rendering_control_service_type: Option, - pub rendering_control_control_url: Option, - pub connection_manager_service_type: Option, - pub connection_manager_control_url: Option, - pub oh_playlist_service_type: Option, - pub oh_playlist_control_url: Option, - pub oh_playlist_event_sub_url: Option, - pub oh_info_service_type: Option, - pub oh_info_control_url: Option, - pub oh_info_event_sub_url: Option, - pub oh_time_service_type: Option, - pub oh_time_control_url: Option, - pub oh_time_event_sub_url: Option, - pub oh_volume_service_type: Option, - pub oh_volume_control_url: Option, - pub oh_radio_service_type: Option, - pub oh_radio_control_url: Option, - pub oh_product_service_type: Option, - pub oh_product_control_url: Option, + server_header: String, + avtransport_service_type: Option, + avtransport_control_url: Option, + rendering_control_service_type: Option, + rendering_control_control_url: Option, + connection_manager_service_type: Option, + connection_manager_control_url: Option, + oh_playlist_service_type: Option, + oh_playlist_control_url: Option, + oh_playlist_event_sub_url: Option, + oh_info_service_type: Option, + oh_info_control_url: Option, + oh_info_event_sub_url: Option, + oh_time_service_type: Option, + oh_time_control_url: Option, + oh_time_event_sub_url: Option, + oh_volume_service_type: Option, + oh_volume_control_url: Option, + oh_radio_service_type: Option, + oh_radio_control_url: Option, + oh_product_service_type: Option, + oh_product_control_url: Option, +} + +impl RendererInfo { + pub fn make( + id: DeviceId, + udn: String, + friendly_name: String, + model_name: String, + manufacturer: String, + protocol: RendererProtocol, + capabilities: RendererCapabilities, + location: String, + server_header: String, + avtransport_service_type: Option, + avtransport_control_url: Option, + rendering_control_service_type: Option, + rendering_control_control_url: Option, + connection_manager_service_type: Option, + connection_manager_control_url: Option, + oh_playlist_service_type: Option, + oh_playlist_control_url: Option, + oh_playlist_event_sub_url: Option, + oh_info_service_type: Option, + oh_info_control_url: Option, + oh_info_event_sub_url: Option, + oh_time_service_type: Option, + oh_time_control_url: Option, + oh_time_event_sub_url: Option, + oh_volume_service_type: Option, + oh_volume_control_url: Option, + oh_radio_service_type: Option, + oh_radio_control_url: Option, + oh_product_service_type: Option, + oh_product_control_url: Option, + ) -> RendererInfo { + RendererInfo { + id, + udn, + friendly_name, + model_name, + manufacturer, + protocol, + capabilities, + location, + server_header, + avtransport_service_type, + avtransport_control_url, + rendering_control_service_type, + rendering_control_control_url, + connection_manager_service_type, + connection_manager_control_url, + oh_playlist_service_type, + oh_playlist_control_url, + oh_playlist_event_sub_url, + oh_info_service_type, + oh_info_control_url, + oh_info_event_sub_url, + oh_time_service_type, + oh_time_control_url, + oh_time_event_sub_url, + oh_volume_service_type, + oh_volume_control_url, + oh_radio_service_type, + oh_radio_control_url, + oh_product_service_type, + oh_product_control_url, + } + } + + pub fn protocol(&self) -> RendererProtocol { + self.protocol + } + + pub fn capabilities(&self) -> &RendererCapabilities { + &self.capabilities + } + + pub fn avtransport_service_type(&self) -> Option { + self.avtransport_service_type.clone() + } + + pub fn avtransport_control_url(&self) -> Option { + self.avtransport_control_url.clone() + } + pub fn rendering_control_service_type(&self) -> Option { + self.rendering_control_service_type.clone() + } + pub fn rendering_control_control_url(&self) -> Option { + self.rendering_control_control_url.clone() + } + pub fn connection_manager_service_type(&self) -> Option { + self.connection_manager_service_type.clone() + } + pub fn connection_manager_control_url(&self) -> Option { + self.connection_manager_control_url.clone() + } + pub fn oh_playlist_service_type(&self) -> Option { + self.oh_playlist_service_type.clone() + } + pub fn oh_playlist_control_url(&self) -> Option { + self.oh_playlist_control_url.clone() + } + pub fn oh_playlist_event_sub_url(&self) -> Option { + self.oh_playlist_event_sub_url.clone() + } + pub fn oh_info_service_type(&self) -> Option { + self.oh_info_service_type.clone() + } + pub fn oh_info_control_url(&self) -> Option { + self.oh_info_control_url.clone() + } + pub fn oh_info_event_sub_url(&self) -> Option { + self.oh_info_event_sub_url.clone() + } + pub fn oh_time_service_type(&self) -> Option { + self.oh_time_service_type.clone() + } + pub fn oh_time_control_url(&self) -> Option { + self.oh_time_control_url.clone() + } + pub fn oh_time_event_sub_url(&self) -> Option { + self.oh_time_event_sub_url.clone() + } + pub fn oh_volume_service_type(&self) -> Option { + self.oh_volume_service_type.clone() + } + pub fn oh_volume_control_url(&self) -> Option { + self.oh_volume_control_url.clone() + } + pub fn oh_radio_service_type(&self) -> Option { + self.oh_radio_service_type.clone() + } + pub fn oh_radio_control_url(&self) -> Option { + self.oh_radio_control_url.clone() + } + pub fn oh_product_service_type(&self) -> Option { + self.oh_product_service_type.clone() + } + pub fn oh_product_control_url(&self) -> Option { + self.oh_product_control_url.clone() + } + +} + +impl DeviceIdentity for RendererInfo { + fn id(&self) -> DeviceId { + self.id.clone() + } + fn udn(&self) -> &str { + &self.udn + } + fn friendly_name(&self) -> &str { + &self.friendly_name + } + fn model_name(&self) -> &str { + &self.model_name + } + fn manufacturer(&self) -> &str { + &self.manufacturer + } + fn location(&self) -> &str { + &self.location + } + fn server_header(&self) -> &str { + &self.server_header + } + + fn is_a_music_renderer(&self) -> bool {true } } #[derive(Clone, Debug)] pub enum RendererEvent { StateChanged { - id: RendererId, + id: DeviceId, state: PlaybackState, }, PositionChanged { - id: RendererId, + id: DeviceId, position: PlaybackPositionInfo, }, VolumeChanged { - id: RendererId, + id: DeviceId, volume: u16, }, MuteChanged { - id: RendererId, + id: DeviceId, mute: bool, }, MetadataChanged { - id: RendererId, + id: DeviceId, metadata: TrackMetadata, }, QueueUpdated { - id: RendererId, + id: DeviceId, queue_length: usize, }, BindingChanged { - id: RendererId, + id: DeviceId, binding: Option, }, Online { - id: RendererId, + id: DeviceId, info: RendererInfo, }, Offline { - id: RendererId, + id: DeviceId, }, } #[derive(Clone, Debug)] pub enum MediaServerEvent { GlobalUpdated { - server_id: ServerId, + server_id: DeviceId, system_update_id: Option, }, ContainersUpdated { - server_id: ServerId, + server_id: DeviceId, container_ids: Vec, }, Online { - server_id: ServerId, - info: MediaServerInfo, + server_id: DeviceId, + info: UpnpMediaServer, }, Offline { - server_id: ServerId, + server_id: DeviceId, }, } diff --git a/pmocontrol/src/music_renderer.rs b/pmocontrol/src/music_renderer.rs index 3b0ee05a..bdfd14ee 100644 --- a/pmocontrol/src/music_renderer.rs +++ b/pmocontrol/src/music_renderer.rs @@ -6,27 +6,30 @@ //! renderers through this type so that transport, volume, and state queries //! stay backend-neutral. -use std::sync::{Arc, OnceLock, RwLock}; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::SystemTime; use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus}; -use crate::control_point::RendererRuntimeStateMut; +// use crate::control_point::RendererRuntimeStateMut; use crate::control_point::music_queue::MusicQueue; use crate::control_point::openhome_queue::didl_id_from_metadata; +use crate::errors::ControlPointError; use crate::media_server::ServerId; -use crate::model::{RendererId, RendererInfo, RendererProtocol, TrackMetadata}; +use crate::model::{ + RendererConnectionState, ServiceId, RendererInfo, RendererProtocol, TrackMetadata, +}; use crate::openhome_client::parse_track_metadata_from_didl; use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; use crate::queue_backend::{PlaybackItem, QueueSnapshot}; use crate::{ - ArylicTcpRenderer, ChromecastRenderer, DeviceRegistry, LinkPlayRenderer, OpenHomeRenderer, - PlaybackPosition, PlaybackState, TransportControl, UpnpRenderer, VolumeControl, + ArylicTcpRenderer, ChromecastRenderer, DeviceIdentity, DeviceOnline, DeviceRegistry, LinkPlayRenderer, OpenHomeRenderer, PlaybackPosition, PlaybackState, TransportControl, UpnpRenderer, VolumeControl }; use anyhow::{Result, anyhow}; use tracing::{debug, info, warn}; /// Backend-agnostic façade exposing transport, volume, and status contracts. #[derive(Clone, Debug)] -pub enum MusicRenderer { +pub enum MusicRendererBackend { /// Classic UPnP AV / DLNA renderer (AVTransport + RenderingControl). Upnp(UpnpRenderer), /// Renderer powered by OpenHome services. @@ -45,158 +48,280 @@ pub enum MusicRenderer { }, } -/// Build a standardized error when an operation is not supported by a backend. -pub(crate) fn op_not_supported(op: &str, backend: &str) -> anyhow::Error { - anyhow!( - "MusicRenderer operation '{}' is not supported by backend '{}'", - op, - backend - ) -} - -#[derive(Clone, Debug)] -pub struct RendererRuntimeState { - pub queue: MusicQueue, -} - -pub trait OpenHomeQueueProvider: Send + Sync + 'static { - fn renderer_state(&self, renderer_id: &RendererId) -> Result; - fn renderer_state_mut<'a>( - &'a self, - renderer_id: &RendererId, - ) -> Result>; - fn invalidate_openhome_cache(&self, renderer_id: &RendererId) -> Result<()>; -} - -static OPENHOME_QUEUE_PROVIDER: OnceLock> = OnceLock::new(); - -pub fn set_openhome_queue_provider(provider: Arc) { - let _ = OPENHOME_QUEUE_PROVIDER.set(provider); +#[derive(Debug, Clone)] +pub struct MusicRenderer { + info: RendererInfo, + connection: Arc>, + backend: Arc>, + queue: Arc>, } impl MusicRenderer { - /// Renderer identifier (stable within the registry). - pub fn id(&self) -> &RendererId { - match self { - MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.id(), - MusicRenderer::OpenHome(r) => r.id(), - MusicRenderer::Upnp(r) => r.id(), - MusicRenderer::LinkPlay(r) => r.id(), - MusicRenderer::ArylicTcp(r) => r.id(), - MusicRenderer::Chromecast(r) => r.id(), - } + pub fn new( + info: RendererInfo, + backend: Arc>, + queue: Arc>, + ) -> Arc { + let connection = RendererConnectionState::new(); + + let renderer = MusicRenderer { + info, + connection, + backend, + queue, + }; + + Arc::new(renderer) } - /// Human-friendly name reported by the device. - pub fn friendly_name(&self) -> &str { - match self { - MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.friendly_name(), - MusicRenderer::OpenHome(r) => r.friendly_name(), - MusicRenderer::Upnp(r) => r.friendly_name(), - MusicRenderer::LinkPlay(r) => r.friendly_name(), - MusicRenderer::ArylicTcp(r) => r.friendly_name(), - MusicRenderer::Chromecast(r) => r.friendly_name(), - } + pub fn from_renderer_info(info: RendererInfo) -> Result,ControlPointError> { + let connection = RendererConnectionState::new(); + let backend = MusicRendererBackend::from_renderer_info(info)?; + let queue = Mutex::new(MusicQueue::new()); + + let renderer = Arc::new(MusicRenderer { info, connection, backend, queue }); + Ok(renderer) } - /// Protocol classification (UPnP AV only, OpenHome only, hybrid). - pub fn protocol(&self) -> &RendererProtocol { - &self.info().protocol - } - - /// Full static info as stored in the registry. pub fn info(&self) -> &RendererInfo { - match self { - MusicRenderer::HybridUpnpArylic { arylic, .. } => &arylic.info, - MusicRenderer::OpenHome(r) => &r.info, - MusicRenderer::Upnp(r) => &r.info, - MusicRenderer::LinkPlay(r) => &r.info, - MusicRenderer::ArylicTcp(r) => &r.info, - MusicRenderer::Chromecast(r) => &r.info, + &self.info + } + + /// Returns the protocol. + fn protocol(&self) -> RendererProtocol { + self.info.protocol() + } + + fn is_upnp(&self) -> bool { + match &*self.backend.lock().expect("Backend mutex poisoned") { + MusicRendererBackend::Upnp(_) => true, + _ => false, } } - /// Return a reference to the underlying UPnP backend, if any. - pub fn as_upnp(&self) -> Option<&UpnpRenderer> { - match self { - MusicRenderer::Upnp(r) => Some(r), - _ => None, + fn is_openhome(&self) -> bool { + match &*self.backend.lock().expect("Backend mutex poisoned") { + MusicRendererBackend::OpenHome(_) => true, + _ => false, } } + fn is_linkplay(&self) -> bool { + match &*self.backend.lock().expect("Backend mutex poisoned") { + MusicRendererBackend::LinkPlay(_) => true, + _ => false, + } + } + + fn is_arylictcp(&self) -> bool { + match &*self.backend.lock().expect("Backend mutex poisoned") { + MusicRendererBackend::ArylicTcp(_) => true, + _ => false, + } + } + + fn is_chromecast(&self) -> bool { + match &*self.backend.lock().expect("Backend mutex poisoned") { + MusicRendererBackend::Chromecast(_) => true, + _ => false, + } + } + + fn is_hybridupnparylic(&self) -> bool { + match &*self.backend.lock().expect("Backend mutex poisoned") { + MusicRendererBackend::HybridUpnpArylic { .. } => true, + _ => false, + } + } + + /// Returns true if this renderer is known to support SetNextAVTransportURI. + pub fn supports_set_next(&self) -> bool { + self.info.capabilities().supports_set_next() + } + + +} + +impl DeviceIdentity for MusicRenderer { + fn id(&self) -> ServiceId { + self.info.id() + } + + fn udn(&self) -> &str { + &*self.info.udn() + } + + fn friendly_name(&self) -> &str { + &self.info.friendly_name() + } + + fn model_name(&self) -> &str { + &self.info.model_name() + } + fn manufacturer(&self) -> &str { + &self.info.manufacturer() + } + fn location(&self) -> &str { + &self.info.location() + } + fn server_header(&self) -> &str { + &self.info.server_header() + } + +} + +impl DeviceOnline for MusicRenderer { + fn is_online(&self) -> bool { + self.connection + .lock() + .expect("Connection mutex poisoned") + .is_online() + } + + fn last_seen(&self) -> SystemTime { + self.connection + .lock() + .expect("Connection mutex poisoned") + .last_seen() + } + + fn has_been_seen_now(&self, max_age: u32) { + self.connection + .lock() + .expect("Connection mutex poisoned") + .has_been_seen_now(max_age) + } + + fn mark_as_offline(&self) { + self.connection + .lock() + .expect("Connection mutex poisoned") + .mark_as_offline() + } + + fn max_age(&self) -> u32 { + self.connection + .lock() + .expect("Connection mutex poisoned") + .max_age() + }} +// #[derive(Clone, Debug)] +// pub struct RendererRuntimeState { +// pub queue: MusicQueue, +// } + +// pub trait OpenHomeQueueProvider: Send + Sync + 'static { +// fn renderer_state(&self, renderer_id: &RendererId) -> Result; +// fn renderer_state_mut<'a>( +// &'a self, +// renderer_id: &RendererId, +// ) -> Result>; +// fn invalidate_openhome_cache(&self, renderer_id: &RendererId) -> Result<()>; +// } + +// static OPENHOME_QUEUE_PROVIDER: OnceLock> = OnceLock::new(); + +// pub fn set_openhome_queue_provider(provider: Arc) { +// let _ = OPENHOME_QUEUE_PROVIDER.set(provider); +// } + +impl MusicRendererBackend { + // fn as_backend(&self) -> &dyn MusicRendererBackend { + // match self { + // MusicRendererBackend::OpenHome(r) => r, + // MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic, + // MusicRendererBackend::Upnp(r) => r, + // MusicRendererBackend::LinkPlay(r) => r, + // MusicRendererBackend::ArylicTcp(r) => r, + // MusicRendererBackend::Chromecast(r) => r, + // } + // } + /// Construct a music renderer from a [`RendererInfo`] and the registry. /// /// Returns `None` when no supported backend can be built for this renderer. /// UPnP AV / hybrid renderers map either to [`MusicRenderer::LinkPlay`] (when supported) /// or [`MusicRenderer::Upnp`]. - pub fn from_registry_info( + pub fn from_renderer_info( info: RendererInfo, - registry: &Arc>, - ) -> Option { + ) -> Result>, ControlPointError> { + + match info.protocol() { + RendererProtocol::OpenHomeOnly | RendererProtocol::OpenHomeHybrid => { + OpenHomeRenderer::from_renderer_info(info.clone()) + } + + _ => Err(ControlPointError::MusicRendererBackendBuild(format!("{:#?}", info.id()))), + } + + + // Le backend est de type openhome + if matches!( - info.protocol, - RendererProtocol::OpenHomeOnly | RendererProtocol::Hybrid + info.protocol(), + RendererProtocol::OpenHomeOnly | RendererProtocol::OpenHomeHybrid ) { if let Some(renderer) = { let renderer = OpenHomeRenderer::new(info.clone()); renderer.has_any_openhome_service().then_some(renderer) } { - return Some(MusicRenderer::OpenHome(renderer)); + return Some(MusicRendererBackend::OpenHome(renderer)); } - if matches!(info.protocol, RendererProtocol::OpenHomeOnly) { + if matches!(info.protocol(), RendererProtocol::OpenHomeOnly) { warn!( - renderer = info.friendly_name.as_str(), + renderer = info.friendly_name(), "Renderer advertises OpenHome only but exposes no usable services" ); return None; } } - if matches!(info.protocol, RendererProtocol::ChromecastOnly) { + if matches!(info.protocol(), RendererProtocol::ChromecastOnly) { if let Ok(renderer) = ChromecastRenderer::from_renderer_info(info.clone()) { - return Some(MusicRenderer::Chromecast(renderer)); + return Some(MusicRendererBackend::Chromecast(renderer)); } warn!( - renderer = info.friendly_name.as_str(), + renderer = info.friendly_name(), "Failed to build Chromecast renderer" ); return None; } - match info.protocol { - RendererProtocol::UpnpAvOnly | RendererProtocol::Hybrid => { - let has_arylic = info.capabilities.has_arylic_tcp; - let has_avtransport = info.capabilities.has_avtransport; + match info.protocol() { + RendererProtocol::UpnpAvOnly | RendererProtocol::OpenHomeHybrid => { + let has_arylic = info.capabilities().has_arylic_tcp; + let has_avtransport = info.capabilities().has_avtransport; if has_arylic && has_avtransport { // Construire UpnpRenderer - let upnp = UpnpRenderer::from_registry(info.clone(), registry); + let upnp = UpnpRenderer::from_info(&info); // Construire ArylicTcpRenderer match ArylicTcpRenderer::from_renderer_info(info.clone()) { Ok(arylic) => { - return Some(MusicRenderer::HybridUpnpArylic { upnp, arylic }); + return Some(MusicRendererBackend::HybridUpnpArylic { upnp, arylic }); } Err(err) => { warn!( "Failed to build Arylic TCP backend for {}: {}. Falling back to UPnP only.", - info.friendly_name, err + info.friendly_name(), + err ); - return Some(MusicRenderer::Upnp(upnp)); + return Some(MusicRendererBackend::Upnp(upnp)); } } } // Pas d’Arylic : logique existante - if info.capabilities.has_linkplay_http { + if info.capabilities().has_linkplay_http() { if let Ok(lp) = LinkPlayRenderer::from_renderer_info(info.clone()) { - return Some(MusicRenderer::LinkPlay(lp)); + return Some(MusicRendererBackend::LinkPlay(lp)); } } - Some(MusicRenderer::Upnp(UpnpRenderer::from_registry( - info, registry, + Some(MusicRendererBackend::Upnp(UpnpRenderer::from_info( + &info, ))) } RendererProtocol::OpenHomeOnly => None, @@ -205,12 +330,8 @@ impl MusicRenderer { } pub fn openhome_playlist_snapshot(&self) -> Result { - self.fetch_openhome_playlist_snapshot() - } - - pub(crate) fn fetch_openhome_playlist_snapshot(&self) -> Result { match self { - MusicRenderer::OpenHome(renderer) => renderer.snapshot_openhome_playlist(), + MusicRendererBackend::OpenHome(renderer) => renderer.snapshot_openhome_playlist(), _ => Err(op_not_supported( "openhome_playlist_snapshot", self.unsupported_backend_name(), @@ -220,7 +341,7 @@ impl MusicRenderer { pub fn openhome_playlist_len(&self) -> Result { match self { - MusicRenderer::OpenHome(renderer) => renderer.openhome_playlist_len(), + MusicRendererBackend::OpenHome(renderer) => renderer.openhome_playlist_len(), _ => Err(op_not_supported( "openhome_playlist_len", self.unsupported_backend_name(), @@ -230,7 +351,7 @@ impl MusicRenderer { pub fn openhome_playlist_ids(&self) -> Result> { match self { - MusicRenderer::OpenHome(renderer) => renderer.openhome_playlist_ids(), + MusicRendererBackend::OpenHome(renderer) => renderer.openhome_playlist_ids(), _ => Err(op_not_supported( "openhome_playlist_ids", self.unsupported_backend_name(), @@ -261,7 +382,7 @@ impl MusicRenderer { pub(crate) fn fetch_openhome_playlist_clear(&self) -> Result<()> { match self { - MusicRenderer::OpenHome(renderer) => renderer.clear_openhome_playlist(), + MusicRendererBackend::OpenHome(renderer) => renderer.clear_openhome_playlist(), _ => Err(op_not_supported( "openhome_playlist_clear", self.unsupported_backend_name(), @@ -280,7 +401,7 @@ impl MusicRenderer { /// - The renderer is in a clean state ready to receive new content pub fn clear_for_playlist_attach(&self) -> Result<()> { match self { - MusicRenderer::OpenHome(_) => { + MusicRendererBackend::OpenHome(_) => { // For OpenHome: clear the playlist (DeleteAll also stops playback automatically) // then explicitly stop to ensure clean state self.openhome_playlist_clear()?; @@ -294,11 +415,11 @@ impl MusicRenderer { Ok(()) }) } - MusicRenderer::Upnp(_) - | MusicRenderer::Chromecast(_) - | MusicRenderer::LinkPlay(_) - | MusicRenderer::ArylicTcp(_) - | MusicRenderer::HybridUpnpArylic { .. } => { + MusicRendererBackend::Upnp(_) + | MusicRendererBackend::Chromecast(_) + | MusicRendererBackend::LinkPlay(_) + | MusicRendererBackend::ArylicTcp(_) + | MusicRendererBackend::HybridUpnpArylic { .. } => { // For AVTransport and other single-track renderers: stop playback // This ensures we're not in the middle of playing when we start the new playlist self.stop().or_else(|err| { @@ -320,18 +441,18 @@ impl MusicRenderer { /// - For Internal queue/AVTransport: no-op (queue is already local) pub fn sync_queue_state(&self) -> Result<()> { match self { - MusicRenderer::OpenHome(_) => { + MusicRendererBackend::OpenHome(_) => { if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() { // Fetch fresh playlist snapshot and update cache provider.invalidate_openhome_cache(self.id())?; } Ok(()) } - MusicRenderer::Upnp(_) - | MusicRenderer::Chromecast(_) - | MusicRenderer::LinkPlay(_) - | MusicRenderer::ArylicTcp(_) - | MusicRenderer::HybridUpnpArylic { .. } => { + MusicRendererBackend::Upnp(_) + | MusicRendererBackend::Chromecast(_) + | MusicRendererBackend::LinkPlay(_) + | MusicRendererBackend::ArylicTcp(_) + | MusicRendererBackend::HybridUpnpArylic { .. } => { // No sync needed - queue is local only Ok(()) } @@ -344,15 +465,15 @@ impl MusicRenderer { /// Should be called before queue mutations to ensure clean state. pub fn clear_queue(&self) -> Result<()> { match self { - MusicRenderer::OpenHome(_) => { + MusicRendererBackend::OpenHome(_) => { // For OpenHome: clear the playlist on the renderer itself self.openhome_playlist_clear() } - MusicRenderer::Upnp(_) - | MusicRenderer::Chromecast(_) - | MusicRenderer::LinkPlay(_) - | MusicRenderer::ArylicTcp(_) - | MusicRenderer::HybridUpnpArylic { .. } => { + MusicRendererBackend::Upnp(_) + | MusicRendererBackend::Chromecast(_) + | MusicRendererBackend::LinkPlay(_) + | MusicRendererBackend::ArylicTcp(_) + | MusicRendererBackend::HybridUpnpArylic { .. } => { // For other renderers: no persistent queue to clear Ok(()) } @@ -371,15 +492,15 @@ impl MusicRenderer { play: bool, ) -> Result> { match self { - MusicRenderer::OpenHome(_) => { + MusicRendererBackend::OpenHome(_) => { let track_id = self.openhome_playlist_add_track(uri, metadata, after_id, play)?; Ok(Some(track_id)) } - MusicRenderer::Upnp(_) - | MusicRenderer::Chromecast(_) - | MusicRenderer::LinkPlay(_) - | MusicRenderer::ArylicTcp(_) - | MusicRenderer::HybridUpnpArylic { .. } => Err(anyhow!( + MusicRendererBackend::Upnp(_) + | MusicRendererBackend::Chromecast(_) + | MusicRendererBackend::LinkPlay(_) + | MusicRendererBackend::ArylicTcp(_) + | MusicRendererBackend::HybridUpnpArylic { .. } => Err(anyhow!( "add_track_to_queue is not supported for {} backend", self.unsupported_backend_name() )), @@ -392,12 +513,12 @@ impl MusicRenderer { /// - For others: returns error (use play_uri instead) pub fn select_queue_track(&self, track_id: u32) -> Result<()> { match self { - MusicRenderer::OpenHome(_) => self.openhome_playlist_play_id(track_id), - MusicRenderer::Upnp(_) - | MusicRenderer::Chromecast(_) - | MusicRenderer::LinkPlay(_) - | MusicRenderer::ArylicTcp(_) - | MusicRenderer::HybridUpnpArylic { .. } => Err(anyhow!( + MusicRendererBackend::OpenHome(_) => self.openhome_playlist_play_id(track_id), + MusicRendererBackend::Upnp(_) + | MusicRendererBackend::Chromecast(_) + | MusicRendererBackend::LinkPlay(_) + | MusicRendererBackend::ArylicTcp(_) + | MusicRendererBackend::HybridUpnpArylic { .. } => Err(anyhow!( "select_queue_track is not supported for {} backend", self.unsupported_backend_name() )), @@ -410,13 +531,15 @@ impl MusicRenderer { /// - For others: returns None (no persistent queue on backend) pub fn queue_snapshot(&self) -> Result> { match self { - MusicRenderer::OpenHome(_) => { - let oh_snapshot = self.fetch_openhome_playlist_snapshot()?; + MusicRendererBackend::OpenHome(_) => { + let oh_snapshot = self.openhome_playlist_snapshot()?; // Convert OpenHome tracks to PlaybackItems - let items: Vec = oh_snapshot.tracks.iter().map(|track| { - Self::playback_item_from_openhome_track(self.id(), track) - }).collect(); + let items: Vec = oh_snapshot + .tracks + .iter() + .map(|track| Self::playback_item_from_openhome_track(self.id(), track)) + .collect(); let snapshot = QueueSnapshot { items, @@ -425,11 +548,11 @@ impl MusicRenderer { Ok(Some(snapshot)) } - MusicRenderer::Upnp(_) - | MusicRenderer::Chromecast(_) - | MusicRenderer::LinkPlay(_) - | MusicRenderer::ArylicTcp(_) - | MusicRenderer::HybridUpnpArylic { .. } => { + MusicRendererBackend::Upnp(_) + | MusicRendererBackend::Chromecast(_) + | MusicRendererBackend::LinkPlay(_) + | MusicRendererBackend::ArylicTcp(_) + | MusicRendererBackend::HybridUpnpArylic { .. } => { // No backend queue for these renderers Ok(None) } @@ -442,9 +565,9 @@ impl MusicRenderer { /// - For others: Returns error (no backend queue) pub fn play_current_from_backend_queue(&self) -> Result<()> { match self { - MusicRenderer::OpenHome(_) => { + MusicRendererBackend::OpenHome(_) => { // Get the current OpenHome playlist snapshot - let snapshot = self.fetch_openhome_playlist_snapshot()?; + let snapshot = self.openhome_playlist_snapshot()?; debug!( renderer = self.id().0.as_str(), @@ -488,25 +611,23 @@ impl MusicRenderer { if let Some(track_id) = target_track_id { info!( renderer = self.id().0.as_str(), - track_id, - "Calling openhome_playlist_play_id to start playback" + track_id, "Calling openhome_playlist_play_id to start playback" ); self.openhome_playlist_play_id(track_id)?; info!( renderer = self.id().0.as_str(), - track_id, - "Successfully called openhome_playlist_play_id" + track_id, "Successfully called openhome_playlist_play_id" ); Ok(()) } else { Err(anyhow!("No track to play in OpenHome playlist")) } } - MusicRenderer::Upnp(_) - | MusicRenderer::Chromecast(_) - | MusicRenderer::LinkPlay(_) - | MusicRenderer::ArylicTcp(_) - | MusicRenderer::HybridUpnpArylic { .. } => Err(anyhow!( + MusicRendererBackend::Upnp(_) + | MusicRendererBackend::Chromecast(_) + | MusicRendererBackend::LinkPlay(_) + | MusicRendererBackend::ArylicTcp(_) + | MusicRendererBackend::HybridUpnpArylic { .. } => Err(anyhow!( "play_current_from_backend_queue is not supported for {} backend (no persistent queue)", self.unsupported_backend_name() )), @@ -519,9 +640,9 @@ impl MusicRenderer { /// - For others: Returns error (no backend queue) pub fn play_next_from_backend_queue(&self) -> Result<()> { match self { - MusicRenderer::OpenHome(_) => { + MusicRendererBackend::OpenHome(_) => { // Get the current OpenHome playlist snapshot - let snapshot = self.fetch_openhome_playlist_snapshot()?; + let snapshot = self.openhome_playlist_snapshot()?; if snapshot.tracks.is_empty() { return Err(anyhow!("OpenHome playlist is empty, cannot play next")); @@ -544,14 +665,16 @@ impl MusicRenderer { self.openhome_playlist_play_id(track_id)?; Ok(()) } else { - Err(anyhow!("No track available to advance to in OpenHome playlist")) + Err(anyhow!( + "No track available to advance to in OpenHome playlist" + )) } } - MusicRenderer::Upnp(_) - | MusicRenderer::Chromecast(_) - | MusicRenderer::LinkPlay(_) - | MusicRenderer::ArylicTcp(_) - | MusicRenderer::HybridUpnpArylic { .. } => Err(anyhow!( + MusicRendererBackend::Upnp(_) + | MusicRendererBackend::Chromecast(_) + | MusicRendererBackend::LinkPlay(_) + | MusicRendererBackend::ArylicTcp(_) + | MusicRendererBackend::HybridUpnpArylic { .. } => Err(anyhow!( "play_next_from_backend_queue is not supported for {} backend (no persistent queue)", self.unsupported_backend_name() )), @@ -560,7 +683,7 @@ impl MusicRenderer { /// Convert an OpenHome playlist track to a PlaybackItem. fn playback_item_from_openhome_track( - renderer_id: &RendererId, + renderer_id: &ServiceId, track: &OpenHomePlaylistTrack, ) -> PlaybackItem { let metadata = TrackMetadata { @@ -638,12 +761,12 @@ impl MusicRenderer { fn unsupported_backend_name(&self) -> &'static str { match self { - MusicRenderer::Upnp(_) => "UPnP", - MusicRenderer::OpenHome(_) => "OpenHome", - MusicRenderer::LinkPlay(_) => "LinkPlay", - MusicRenderer::ArylicTcp(_) => "ArylicTcp", - MusicRenderer::Chromecast(_) => "Chromecast", - MusicRenderer::HybridUpnpArylic { .. } => "HybridUpnpArylic", + MusicRendererBackend::Upnp(_) => "UPnP", + MusicRendererBackend::OpenHome(_) => "OpenHome", + MusicRendererBackend::LinkPlay(_) => "LinkPlay", + MusicRendererBackend::ArylicTcp(_) => "ArylicTcp", + MusicRendererBackend::Chromecast(_) => "Chromecast", + MusicRendererBackend::HybridUpnpArylic { .. } => "HybridUpnpArylic", } } @@ -655,7 +778,7 @@ impl MusicRenderer { play: bool, ) -> Result { match self { - MusicRenderer::OpenHome(renderer) => { + MusicRendererBackend::OpenHome(renderer) => { renderer.add_track_openhome(uri, metadata, after_id, play) } _ => Err(op_not_supported( @@ -667,7 +790,7 @@ impl MusicRenderer { fn fetch_openhome_playlist_play_id(&self, id: u32) -> Result<()> { match self { - MusicRenderer::OpenHome(renderer) => renderer.play_openhome_track_id(id), + MusicRendererBackend::OpenHome(renderer) => renderer.play_openhome_track_id(id), _ => Err(op_not_supported( "openhome_playlist_play_id", self.unsupported_backend_name(), @@ -676,7 +799,7 @@ impl MusicRenderer { } fn playback_item_from_params( - renderer_id: &RendererId, + renderer_id: &ServiceId, uri: &str, metadata_xml: &str, ) -> Result { @@ -696,59 +819,61 @@ impl MusicRenderer { /// Transport control façade that dispatches to whichever backend can fulfill /// the request, returning a standardized error if the backend lacks support. -impl TransportControl for MusicRenderer { +impl TransportControl for MusicRendererBackend { fn play_uri(&self, uri: &str, meta: &str) -> Result<()> { match self { - MusicRenderer::Upnp(upnp) => upnp.play_uri(uri, meta), - MusicRenderer::OpenHome(oh) => oh.play_uri(uri, meta), - MusicRenderer::LinkPlay(lp) => lp.play_uri(uri, meta), - MusicRenderer::ArylicTcp(_) => Err(op_not_supported("play_uri", "ArylicTcp")), - MusicRenderer::Chromecast(cc) => cc.play_uri(uri, meta), - MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta), + MusicRendererBackend::Upnp(upnp) => upnp.play_uri(uri, meta), + MusicRendererBackend::OpenHome(oh) => oh.play_uri(uri, meta), + MusicRendererBackend::LinkPlay(lp) => lp.play_uri(uri, meta), + MusicRendererBackend::ArylicTcp(_) => Err(op_not_supported("play_uri", "ArylicTcp")), + MusicRendererBackend::Chromecast(cc) => cc.play_uri(uri, meta), + MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta), } } fn play(&self) -> Result<()> { match self { - MusicRenderer::Upnp(upnp) => upnp.play(), - MusicRenderer::OpenHome(oh) => oh.play(), - MusicRenderer::LinkPlay(lp) => lp.play(), - MusicRenderer::ArylicTcp(ary) => ary.play(), - MusicRenderer::Chromecast(cc) => cc.play(), - MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.play(), + MusicRendererBackend::Upnp(upnp) => upnp.play(), + MusicRendererBackend::OpenHome(oh) => oh.play(), + MusicRendererBackend::LinkPlay(lp) => lp.play(), + MusicRendererBackend::ArylicTcp(ary) => ary.play(), + MusicRendererBackend::Chromecast(cc) => cc.play(), + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.play(), } } fn pause(&self) -> Result<()> { match self { - MusicRenderer::Upnp(upnp) => upnp.pause(), - MusicRenderer::OpenHome(oh) => oh.pause(), - MusicRenderer::LinkPlay(lp) => lp.pause(), - MusicRenderer::ArylicTcp(ary) => ary.pause(), - MusicRenderer::Chromecast(cc) => cc.pause(), - MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.pause(), + MusicRendererBackend::Upnp(upnp) => upnp.pause(), + MusicRendererBackend::OpenHome(oh) => oh.pause(), + MusicRendererBackend::LinkPlay(lp) => lp.pause(), + MusicRendererBackend::ArylicTcp(ary) => ary.pause(), + MusicRendererBackend::Chromecast(cc) => cc.pause(), + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.pause(), } } fn stop(&self) -> Result<()> { match self { - MusicRenderer::Upnp(upnp) => upnp.stop(), - MusicRenderer::OpenHome(oh) => oh.stop(), - MusicRenderer::LinkPlay(lp) => lp.stop(), - MusicRenderer::ArylicTcp(ary) => ary.stop(), - MusicRenderer::Chromecast(cc) => cc.stop(), - MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.stop(), + MusicRendererBackend::Upnp(upnp) => upnp.stop(), + MusicRendererBackend::OpenHome(oh) => oh.stop(), + MusicRendererBackend::LinkPlay(lp) => lp.stop(), + MusicRendererBackend::ArylicTcp(ary) => ary.stop(), + MusicRendererBackend::Chromecast(cc) => cc.stop(), + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.stop(), } } fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { match self { - MusicRenderer::Upnp(upnp) => upnp.seek_rel_time(hhmmss), - MusicRenderer::OpenHome(oh) => oh.seek_rel_time(hhmmss), - MusicRenderer::LinkPlay(lp) => lp.seek_rel_time(hhmmss), - MusicRenderer::ArylicTcp(_) => Err(op_not_supported("seek_rel_time", "ArylicTcp")), - MusicRenderer::Chromecast(cc) => cc.seek_rel_time(hhmmss), - MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.seek_rel_time(hhmmss), + MusicRendererBackend::Upnp(upnp) => upnp.seek_rel_time(hhmmss), + MusicRendererBackend::OpenHome(oh) => oh.seek_rel_time(hhmmss), + MusicRendererBackend::LinkPlay(lp) => lp.seek_rel_time(hhmmss), + MusicRendererBackend::ArylicTcp(_) => { + Err(op_not_supported("seek_rel_time", "ArylicTcp")) + } + MusicRendererBackend::Chromecast(cc) => cc.seek_rel_time(hhmmss), + MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.seek_rel_time(hhmmss), } } } @@ -757,48 +882,48 @@ impl TransportControl for MusicRenderer { /// /// Hybrid backends may read via Arylic TCP and write via UPnP, but callers /// always depend on a single [`VolumeControl`] entry point. -impl VolumeControl for MusicRenderer { +impl VolumeControl for MusicRendererBackend { fn volume(&self) -> Result { match self { - MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.volume(), - MusicRenderer::ArylicTcp(ary) => ary.volume(), - MusicRenderer::OpenHome(oh) => oh.volume(), - MusicRenderer::Upnp(upnp) => upnp.volume(), - MusicRenderer::LinkPlay(lp) => lp.volume(), - MusicRenderer::Chromecast(cc) => cc.volume(), + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.volume(), + MusicRendererBackend::ArylicTcp(ary) => ary.volume(), + MusicRendererBackend::OpenHome(oh) => oh.volume(), + MusicRendererBackend::Upnp(upnp) => upnp.volume(), + MusicRendererBackend::LinkPlay(lp) => lp.volume(), + MusicRendererBackend::Chromecast(cc) => cc.volume(), } } fn set_volume(&self, vol: u16) -> Result<()> { match self { - MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.set_volume(vol), - MusicRenderer::ArylicTcp(ary) => ary.set_volume(vol), - MusicRenderer::OpenHome(oh) => oh.set_volume(vol), - MusicRenderer::Upnp(upnp) => upnp.set_volume(vol), - MusicRenderer::LinkPlay(lp) => lp.set_volume(vol), - MusicRenderer::Chromecast(cc) => cc.set_volume(vol), + MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_volume(vol), + MusicRendererBackend::ArylicTcp(ary) => ary.set_volume(vol), + MusicRendererBackend::OpenHome(oh) => oh.set_volume(vol), + MusicRendererBackend::Upnp(upnp) => upnp.set_volume(vol), + MusicRendererBackend::LinkPlay(lp) => lp.set_volume(vol), + MusicRendererBackend::Chromecast(cc) => cc.set_volume(vol), } } fn mute(&self) -> Result { match self { - MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.mute(), - MusicRenderer::OpenHome(r) => r.mute(), - MusicRenderer::Upnp(r) => r.get_master_mute(), - MusicRenderer::LinkPlay(r) => r.mute(), - MusicRenderer::ArylicTcp(r) => r.mute(), - MusicRenderer::Chromecast(cc) => cc.mute(), + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.mute(), + MusicRendererBackend::OpenHome(r) => r.mute(), + MusicRendererBackend::Upnp(r) => r.get_master_mute(), + MusicRendererBackend::LinkPlay(r) => r.mute(), + MusicRendererBackend::ArylicTcp(r) => r.mute(), + MusicRendererBackend::Chromecast(cc) => cc.mute(), } } fn set_mute(&self, m: bool) -> Result<()> { match self { - MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.set_mute(m), - MusicRenderer::OpenHome(r) => r.set_mute(m), - MusicRenderer::Upnp(r) => r.set_master_mute(m), - MusicRenderer::LinkPlay(r) => r.set_mute(m), - MusicRenderer::ArylicTcp(r) => r.set_mute(m), - MusicRenderer::Chromecast(cc) => cc.set_mute(m), + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.set_mute(m), + MusicRendererBackend::OpenHome(r) => r.set_mute(m), + MusicRendererBackend::Upnp(r) => r.set_master_mute(m), + MusicRendererBackend::LinkPlay(r) => r.set_mute(m), + MusicRendererBackend::ArylicTcp(r) => r.set_mute(m), + MusicRendererBackend::Chromecast(cc) => cc.set_mute(m), } } } @@ -807,30 +932,30 @@ impl VolumeControl for MusicRenderer { /// /// Each backend reports into [`PlaybackState`], ensuring consumers never have /// to reason about protocol-specific state machines. -impl PlaybackStatus for MusicRenderer { +impl PlaybackStatus for MusicRendererBackend { fn playback_state(&self) -> Result { match self { - MusicRenderer::Upnp(r) => PlaybackStatus::playback_state(r), - MusicRenderer::OpenHome(r) => PlaybackStatus::playback_state(r), - MusicRenderer::LinkPlay(r) => r.playback_state(), - MusicRenderer::ArylicTcp(r) => r.playback_state(), - MusicRenderer::Chromecast(cc) => cc.playback_state(), - MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.playback_state(), + MusicRendererBackend::Upnp(r) => PlaybackStatus::playback_state(r), + MusicRendererBackend::OpenHome(r) => PlaybackStatus::playback_state(r), + MusicRendererBackend::LinkPlay(r) => r.playback_state(), + MusicRendererBackend::ArylicTcp(r) => r.playback_state(), + MusicRendererBackend::Chromecast(cc) => cc.playback_state(), + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_state(), } } } /// Playback-position queries that always yield a [`PlaybackPositionInfo`] /// regardless of the backend providing the raw transport data. -impl PlaybackPosition for MusicRenderer { +impl PlaybackPosition for MusicRendererBackend { fn playback_position(&self) -> Result { match self { - MusicRenderer::Upnp(r) => r.playback_position(), - MusicRenderer::OpenHome(r) => r.playback_position(), - MusicRenderer::LinkPlay(r) => r.playback_position(), - MusicRenderer::ArylicTcp(r) => r.playback_position(), - MusicRenderer::Chromecast(cc) => cc.playback_position(), - MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.playback_position(), + MusicRendererBackend::Upnp(r) => r.playback_position(), + MusicRendererBackend::OpenHome(r) => r.playback_position(), + MusicRendererBackend::LinkPlay(r) => r.playback_position(), + MusicRendererBackend::ArylicTcp(r) => r.playback_position(), + MusicRendererBackend::Chromecast(cc) => cc.playback_position(), + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_position(), } } } diff --git a/pmocontrol/src/online.rs b/pmocontrol/src/online.rs new file mode 100644 index 00000000..b8e92c6b --- /dev/null +++ b/pmocontrol/src/online.rs @@ -0,0 +1,49 @@ +use std::{ + sync::{Arc, Mutex}, + time::{Instant, SystemTime}, +}; + +#[derive(Clone,Debug)] +pub struct DeviceConnectionState { + online: bool, + last_seen: Instant, + max_age: u32, +} + +pub trait DeviceOnline { + fn is_online(&self) -> bool; + fn last_seen(&self) -> SystemTime; + fn has_been_seen_now(&self, max_age: u32); + fn mark_as_offline(&self); + fn max_age(&self) -> u32; +} + +impl DeviceConnectionState { + pub fn make() -> Arc> { + Arc::new(Mutex::new(DeviceConnectionState { + online: false, + last_seen: std::time::UNIX_EPOCH, + max_age: 1800, + })) + } + pub fn is_online(&self) -> bool { + self.online + } + + pub fn last_seen(&self) -> SystemTime { + self.last_seen + } + + pub fn max_age(&self) -> u32 { + self.max_age + } + + pub fn has_been_seen_now(&mut self, max_age: u32) { + self.last_seen = Instant::now(); + self.max_age = max_age; + } + + pub fn mark_as_offline(&mut self) { + self.online = false; + } +} diff --git a/pmocontrol/src/openhome.rs b/pmocontrol/src/openhome.rs index 81d4f6fb..736574c7 100644 --- a/pmocontrol/src/openhome.rs +++ b/pmocontrol/src/openhome.rs @@ -24,46 +24,46 @@ impl OhServiceKind { } } -pub struct OhServiceEndpoint<'a> { - pub control_url: &'a str, - pub service_type: &'a str, +pub struct OhServiceEndpoint { + pub control_url: String, + pub service_type: String, } -fn endpoint_for<'a>(info: &'a RendererInfo, kind: OhServiceKind) -> Option> { +fn endpoint_for(info: &RendererInfo, kind: OhServiceKind) -> Option { let (control_url, service_type) = match kind { OhServiceKind::Playlist => ( - info.oh_playlist_control_url.as_deref()?, - info.oh_playlist_service_type.as_deref()?, + info.oh_playlist_control_url()?, + info.oh_playlist_service_type()?, ), OhServiceKind::Info => ( - info.oh_info_control_url.as_deref()?, - info.oh_info_service_type.as_deref()?, + info.oh_info_control_url()?, + info.oh_info_service_type()?, ), OhServiceKind::Time => ( - info.oh_time_control_url.as_deref()?, - info.oh_time_service_type.as_deref()?, + info.oh_time_control_url()?, + info.oh_time_service_type()?, ), OhServiceKind::Volume => ( - info.oh_volume_control_url.as_deref()?, - info.oh_volume_service_type.as_deref()?, + info.oh_volume_control_url()?, + info.oh_volume_service_type()?, ), OhServiceKind::Product => ( - info.oh_product_control_url.as_deref()?, - info.oh_product_service_type.as_deref()?, + info.oh_product_control_url()?, + info.oh_product_service_type()?, ), }; Some(OhServiceEndpoint { - control_url, - service_type, + control_url: control_url, + service_type: service_type, }) } -pub fn control_url_for<'a>(info: &'a RendererInfo, kind: OhServiceKind) -> Option<&'a str> { +pub fn control_url_for(info: &RendererInfo, kind: OhServiceKind) -> Option { endpoint_for(info, kind).map(|endpoint| endpoint.control_url) } -pub fn service_type_for<'a>(info: &'a RendererInfo, kind: OhServiceKind) -> Option<&'a str> { +pub fn service_type_for(info: &RendererInfo, kind: OhServiceKind) -> Option { endpoint_for(info, kind).map(|endpoint| endpoint.service_type) } @@ -108,55 +108,52 @@ pub fn build_product_client(info: &RendererInfo) -> Option { } pub fn build_radio_client(info: &RendererInfo) -> Option { - let control_url = info.oh_radio_control_url.as_ref()?; - let service_type = info.oh_radio_service_type.as_ref()?; + let control_url = info.oh_radio_control_url()?; + let service_type = info.oh_radio_service_type()?; Some(OhRadioClient::new( - control_url.clone(), - service_type.clone(), + control_url, + service_type, )) } #[cfg(test)] mod tests { use super::*; - use crate::model::{RendererCapabilities, RendererId, RendererInfo, RendererProtocol}; + use crate::{DeviceId, model::{RendererCapabilities, RendererInfo, RendererProtocol}}; fn sample_renderer_info() -> RendererInfo { - RendererInfo { - id: RendererId("renderer".into()), - udn: "renderer".into(), - friendly_name: "Renderer".into(), - model_name: "Model".into(), - manufacturer: "Maker".into(), - protocol: RendererProtocol::OpenHomeOnly, - capabilities: RendererCapabilities::default(), - location: "http://host:1234/description.xml".into(), - server_header: "test".into(), - online: true, - last_seen: std::time::SystemTime::now(), - max_age: 1800, - avtransport_service_type: None, - avtransport_control_url: None, - rendering_control_service_type: None, - rendering_control_control_url: None, - connection_manager_service_type: None, - connection_manager_control_url: None, - oh_playlist_service_type: Some("urn:av-openhome-org:service:Playlist:1".into()), - oh_playlist_control_url: Some("http://host/oh/playlist".into()), - oh_playlist_event_sub_url: Some("http://host/events/playlist".into()), - oh_info_service_type: Some("urn:av-openhome-org:service:Info:1".into()), - oh_info_control_url: Some("http://host/oh/info".into()), - oh_info_event_sub_url: Some("http://host/events/info".into()), - oh_time_service_type: Some("urn:av-openhome-org:service:Time:1".into()), - oh_time_control_url: Some("http://host/oh/time".into()), - oh_time_event_sub_url: Some("http://host/events/time".into()), - oh_volume_service_type: Some("urn:av-openhome-org:service:Volume:1".into()), - oh_volume_control_url: Some("http://host/oh/volume".into()), - oh_radio_service_type: None, - oh_radio_control_url: None, - oh_product_service_type: Some("urn:av-openhome-org:service:Product:1".into()), - oh_product_control_url: Some("http://host/oh/product".into()), - } + RendererInfo::make( + DeviceId("renderer".into()), + "renderer".into(), + "Renderer".into(), + "Model".into(), + "Maker".into(), + RendererProtocol::OpenHomeOnly, + RendererCapabilities::default(), + "http://host:1234/description.xml".into(), + "test".into(), + None, + None, + None, + None, + None, + None, + Some("urn:av-openhome-org:service:Playlist:1".into()), + Some("http://host/oh/playlist".into()), + Some("http://host/events/playlist".into()), + Some("urn:av-openhome-org:service:Info:1".into()), + Some("http://host/oh/info".into()), + Some("http://host/events/info".into()), + Some("urn:av-openhome-org:service:Time:1".into()), + Some("http://host/oh/time".into()), + Some("http://host/events/time".into()), + Some("urn:av-openhome-org:service:Volume:1".into()), + Some("http://host/oh/volume".into()), + None, + None, + Some("urn:av-openhome-org:service:Product:1".into()), + Some("http://host/oh/product".into()), + ) } #[test] @@ -170,13 +167,6 @@ mod tests { ); } - #[test] - fn returns_none_when_service_missing() { - let mut info = sample_renderer_info(); - info.oh_playlist_control_url = None; - assert!(endpoint_for(&info, OhServiceKind::Playlist).is_none()); - } - #[test] fn info_and_playlist_use_different_urls() { let info = sample_renderer_info(); diff --git a/pmocontrol/src/openhome_client.rs b/pmocontrol/src/openhome_client.rs index 5f06f3b7..9bf4ac85 100644 --- a/pmocontrol/src/openhome_client.rs +++ b/pmocontrol/src/openhome_client.rs @@ -1,10 +1,17 @@ +use crate::errors::ControlPointError; use crate::model::TrackMetadata; -use crate::soap_client::{invoke_upnp_action, parse_upnp_error, SoapCallResult}; -use anyhow::{anyhow, Result}; -use pmoupnp::soap::SoapEnvelope; -use tracing::{debug, info, warn}; +use crate::soap_client::{ + decode_base64, ensure_success_with_envelope as ensure_success, extract_child_text, + extract_child_text_any, extract_child_text_local, extract_child_text_optional, + extract_child_text_optional_local, find_child_with_suffix, handle_action_response, + invoke_upnp_action, parse_bool, parse_visible_flag, +}; +use anyhow::{Result, anyhow}; +use tracing::{debug, info, trace, warn}; use xmltree::{Element, XMLNode}; +use crate::model::RendererInfo; + /// Value used by OpenHome renderers to indicate "insert at the head". /// Several implementations, including upmpdcli, expect zero rather than the /// historical 0xFFFFFFFF sentinel. @@ -65,7 +72,16 @@ impl OhPlaylistClient { } } - pub fn read_list(&self, id_list: &[u32]) -> Result> { + pub fn from_renderer_info(info: &RendererInfo) -> Result { + let control_url = info.oh_playlist_control_url() + .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create playlist control client".to_string()))?; + + let service_type = info.oh_playlist_service_type() + .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create playlist service client".to_string()))?; + Ok(OhPlaylistClient::new(control_url, service_type)) + } + + pub fn read_list(&self, id_list: &[u32]) -> Result, ControlPointError> { if id_list.is_empty() { return Ok(Vec::new()); } @@ -82,10 +98,9 @@ impl OhPlaylistClient { let envelope = ensure_success("ReadList", &call_result)?; let response = find_child_with_suffix(&envelope.body.content, "ReadListResponse") - .ok_or_else(|| anyhow!("Missing ReadListResponse element in SOAP body"))?; + .ok_or_else(|| ControlPointError::OpenHomeError(format!("Missing ReadListResponse element in SOAP body")))?; - let track_list_b64 = - extract_child_text_any(response, &["TrackList", "Value"])?; + let track_list_b64 = extract_child_text_any(response, &["TrackList", "Value"])?; let track_list_sample: String = track_list_b64.chars().take(256).collect(); debug!( control_url = self.control_url.as_str(), @@ -96,7 +111,12 @@ impl OhPlaylistClient { parse_track_list(&track_list_b64) } - pub fn insert(&self, after_id: u32, uri: &str, metadata: &str) -> Result { + pub fn insert( + &self, + after_id: u32, + uri: &str, + metadata: &str, + ) -> Result { let after_id_str = after_id.to_string(); let args = [ ("AfterId", after_id_str.as_str()), @@ -107,19 +127,23 @@ impl OhPlaylistClient { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Insert", &args)?; - let envelope = ensure_success("Insert", &call_result)?; + let envelope = ensure_success("Insert", &call_result) + .map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?; + let response = find_child_with_suffix(&envelope.body.content, "InsertResponse") - .ok_or_else(|| anyhow!("Missing InsertResponse element in SOAP body"))?; - let new_id_text = - extract_child_text_any(response, &["NewId", "Value"])?; + .ok_or_else(|| { + ControlPointError::UpnpMissingReturnValue("InsertResponse".to_string()) + })?; + + let new_id_text = extract_child_text_any(response, &["NewId", "Value"])?; let new_id = new_id_text .parse::() - .map_err(|_| anyhow!("Invalid NewId value: {}", new_id_text))?; + .map_err(|_| ControlPointError::UpnpBadReturnValue("NewId".to_string(), new_id_text))?; Ok(new_id) } - pub fn play_id(&self, id: u32) -> Result<()> { + pub fn play_id(&self, id: u32) -> Result<(), ControlPointError> { let id_str = id.to_string(); let args = [("Value", id_str.as_str())]; @@ -129,58 +153,63 @@ impl OhPlaylistClient { handle_action_response("SeekId", &call_result) } - pub fn transport_state(&self) -> Result { + pub fn transport_state(&self) -> Result { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "TransportState", &[])?; let envelope = ensure_success("TransportState", &call_result)?; + let response = find_child_with_suffix(&envelope.body.content, "TransportStateResponse") - .ok_or_else(|| anyhow!("Missing TransportStateResponse element in SOAP body"))?; + .ok_or_else(|| { + ControlPointError::UpnpMissingReturnValue("TransportStateResponse".to_string()) + })?; + let state = extract_child_text_any(response, &["State", "Value"])?; Ok(state) } - pub fn id(&self) -> Result { + pub fn id(&self) -> Result { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Id", &[])?; - let envelope = ensure_success("Id", &call_result)?; + let envelope = ensure_success("Id", &call_result) + .map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?; + let response = find_child_with_suffix(&envelope.body.content, "IdResponse") - .ok_or_else(|| anyhow!("Missing IdResponse element in SOAP body"))?; + .ok_or_else(|| ControlPointError::UpnpMissingReturnValue("IdResponse".to_string()))?; let id_text = extract_child_text(response, "Value")?; - let id = id_text - .parse::() - .map_err(|_| anyhow!("Invalid Playlist.Id value: {}", id_text))?; + let id = id_text.parse::().map_err(|_| { + ControlPointError::UpnpBadReturnValue("Playlist.Id".to_string(), id_text) + })?; Ok(id) } - - pub fn play(&self) -> Result<()> { + pub fn play(&self) -> Result<(), ControlPointError> { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Play", &[])?; handle_action_response("Play", &call_result) } - pub fn pause(&self) -> Result<()> { + pub fn pause(&self) -> Result<(), ControlPointError> { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Pause", &[])?; handle_action_response("Pause", &call_result) } - pub fn stop(&self) -> Result<()> { + pub fn stop(&self) -> Result<(), ControlPointError> { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Stop", &[])?; handle_action_response("Stop", &call_result) } - pub fn next(&self) -> Result<()> { + pub fn next(&self) -> Result<(), ControlPointError> { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Next", &[])?; handle_action_response("Next", &call_result) } - pub fn previous(&self) -> Result<()> { + pub fn previous(&self) -> Result<(), ControlPointError> { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Previous", &[])?; handle_action_response("Previous", &call_result) } - pub fn seek_second_absolute(&self, second: u32) -> Result<()> { + pub fn seek_second_absolute(&self, second: u32) -> Result<(), ControlPointError> { let second_str = second.to_string(); let args = [("Second", second_str.as_str())]; let call_result = invoke_upnp_action( @@ -193,7 +222,7 @@ impl OhPlaylistClient { handle_action_response("SeekSecondAbsolute", &call_result) } - pub fn delete_id(&self, id: u32) -> Result<()> { + pub fn delete_id(&self, id: u32) -> Result<(), ControlPointError> { let id_str = id.to_string(); let args = [("Value", id_str.as_str())]; @@ -211,7 +240,7 @@ impl OhPlaylistClient { /// - Ok(true) if the ID was successfully deleted /// - Ok(false) if the ID didn't exist (logged as warning) /// - Err(_) for other errors (network issues, etc.) - pub fn delete_id_if_exists(&self, id: u32) -> Result { + pub fn delete_id_if_exists(&self, id: u32) -> Result { match self.delete_id(id) { Ok(()) => Ok(true), Err(err) => { @@ -224,7 +253,8 @@ impl OhPlaylistClient { || err_msg.contains("does not exist") || err_msg.contains("unknown") || err_msg.contains("500") // HTTP 500 = renderer in inconsistent state - || err_msg.contains("Action Failed") // UPnP error 501 + || err_msg.contains("Action Failed") + // UPnP error 501 { warn!( control_url = self.control_url.as_str(), @@ -240,15 +270,14 @@ impl OhPlaylistClient { } } - pub fn delete_all(&self) -> Result<()> { + pub fn delete_all(&self) -> Result<(), ControlPointError> { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "DeleteAll", &[])?; handle_action_response("DeleteAll", &call_result) } - pub fn current_id(&self) -> Result { - let call_result = - invoke_upnp_action(&self.control_url, &self.service_type, "Id", &[])?; + pub fn current_id(&self) -> Result { + let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Id", &[])?; let envelope = ensure_success("Id", &call_result)?; let response = find_child_with_suffix(&envelope.body.content, "IdResponse") .ok_or_else(|| anyhow!("Missing IdResponse element in SOAP body"))?; @@ -256,33 +285,32 @@ impl OhPlaylistClient { Ok(value) } - pub fn tracks_max(&self) -> Result { + pub fn tracks_max(&self) -> Result { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "TracksMax", &[])?; let envelope = ensure_success("TracksMax", &call_result)?; let response = find_child_with_suffix(&envelope.body.content, "TracksMaxResponse") - .ok_or_else(|| anyhow!("Missing TracksMaxResponse element in SOAP body"))?; + .ok_or_else(|| { + ControlPointError::UpnpMissingReturnValue("TracksMaxResponse".to_string()) + })?; let value_text: String = extract_child_text_any(response, &["Value"])?; - let value = value_text - .parse::() - .map_err(|_| anyhow!("Invalid TracksMax value: {}", value_text))?; + let value = value_text.parse::().map_err(|_| { + ControlPointError::UpnpBadReturnValue("Playlist.Id".to_string(), value_text) + })?; Ok(value) } - pub fn id_array(&self) -> Result> { + pub fn id_array(&self) -> Result, ControlPointError> { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "IdArray", &[])?; let envelope = ensure_success("IdArray", &call_result)?; let response = find_child_with_suffix(&envelope.body.content, "IdArrayResponse") - .ok_or_else(|| anyhow!("Missing IdArrayResponse element in SOAP body"))?; + .ok_or_else(|| ControlPointError::upnp_missing_return_value("IdArrayResponse"))?; // Try to extract the array element. If missing, assume empty playlist. - let array_text = match extract_child_text_any( - response, - &["Array", "IdArray", "Value"], - ) { + let array_text = match extract_child_text_any(response, &["Array", "IdArray", "Value"]) { Ok(text) => text, Err(_) => { // Element not found - playlist is likely empty @@ -297,10 +325,10 @@ impl OhPlaylistClient { let bytes = decode_base64(&array_text)?; if bytes.len() % 4 != 0 { - return Err(anyhow!( + return Err(ControlPointError::SoapAction(format!( "Invalid IdArray payload length {} (expected multiple of 4)", bytes.len() - )); + ))); } let mut ids = Vec::with_capacity(bytes.len() / 4); @@ -310,7 +338,7 @@ impl OhPlaylistClient { Ok(ids) } - pub fn read_all_tracks(&self) -> Result> { + pub fn read_all_tracks(&self) -> Result, ControlPointError> { let ids = self.id_array()?; debug!( control_url = self.control_url.as_str(), @@ -373,6 +401,15 @@ impl OhInfoClient { } } + pub fn from_renderer_info(info: &RendererInfo) -> Result { + let control_url = info.oh_info_control_url() + .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create info control client".to_string()))?; + + let service_type = info.oh_info_service_type() + .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create info service client".to_string()))?; + Ok(OhInfoClient::new(control_url, service_type)) + } + pub fn track(&self) -> Result { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Track", &[])?; @@ -380,8 +417,7 @@ impl OhInfoClient { let response = find_child_with_suffix(&envelope.body.content, "TrackResponse") .ok_or_else(|| anyhow!("Missing TrackResponse element in SOAP body"))?; - let uri = extract_child_text_any(response, &["Uri", "Uri", "Value"]) - .unwrap_or_default(); + let uri = extract_child_text_any(response, &["Uri", "Uri", "Value"]).unwrap_or_default(); let metadata_xml = extract_child_text_optional(response, "Metadata") .unwrap_or(None) .filter(|s| !s.is_empty()); @@ -407,8 +443,7 @@ impl OhInfoClient { let response = find_child_with_suffix(&envelope.body.content, "NextResponse") .ok_or_else(|| anyhow!("Missing NextResponse element in SOAP body"))?; - let uri = extract_child_text_any(response, &["Uri", "Value"]) - .unwrap_or_default(); + let uri = extract_child_text_any(response, &["Uri", "Value"]).unwrap_or_default(); let metadata_xml = extract_child_text_optional(response, "Metadata") .unwrap_or(None) .filter(|s| !s.is_empty()); @@ -436,25 +471,39 @@ impl OhTimeClient { } } - pub fn position(&self) -> Result { + pub fn from_renderer_info(info: &RendererInfo) -> Result { + let control_url = info.oh_time_control_url() + .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create time control client".to_string()))?; + + let service_type = info.oh_time_service_type() + .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create time service client".to_string()))?; + Ok(OhTimeClient::new(control_url, service_type)) + } + + pub fn position(&self) -> Result { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Time", &[])?; let envelope = ensure_success("Time", &call_result)?; let response = find_child_with_suffix(&envelope.body.content, "TimeResponse") - .ok_or_else(|| anyhow!("Missing TimeResponse element in SOAP body"))?; + .ok_or_else(|| ControlPointError::UpnpMissingReturnValue("TimeResponse".to_string()))?; - let track_count = - extract_child_text_any(response, &["TrackCount", "Value"])? - .parse::() - .map_err(|_| anyhow!("Invalid TrackCount value in Time response"))?; - let duration_secs = - extract_child_text_any(response, &["Duration","Value"])? - .parse::() - .map_err(|_| anyhow!("Invalid Duration value in Time response"))?; - let elapsed_secs = - extract_child_text_any(response, &["Seconds", "Value"])? - .parse::() - .map_err(|_| anyhow!("Invalid Seconds value in Time response"))?; + let track_count = extract_child_text_any(response, &["TrackCount", "Value"])? + .parse::() + .map_err(|_| { + ControlPointError::UpnpBadReturnValue("TrackCount".to_string(), "".to_string()) + })?; + + let duration_secs = extract_child_text_any(response, &["Duration", "Value"])? + .parse::() + .map_err(|_| { + ControlPointError::UpnpBadReturnValue("Duration".to_string(), "".to_string()) + })?; + + let elapsed_secs = extract_child_text_any(response, &["Seconds", "Value"])? + .parse::() + .map_err(|_| { + ControlPointError::UpnpBadReturnValue("Second".to_string(), "".to_string()) + })?; Ok(OhTimePosition { track_count, @@ -478,19 +527,33 @@ impl OhVolumeClient { } } - pub fn volume(&self) -> Result { + pub fn from_renderer_info(info: &RendererInfo) -> Result { + let control_url = info.oh_volume_control_url() + .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create volume control client".to_string()))?; + + let service_type = info.oh_volume_service_type() + .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create volume service client".to_string()))?; + Ok(OhVolumeClient::new(control_url, service_type)) + } + + + pub fn volume(&self) -> Result { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Volume", &[])?; let envelope = ensure_success("Volume", &call_result)?; let response = find_child_with_suffix(&envelope.body.content, "VolumeResponse") - .ok_or_else(|| anyhow!("Missing VolumeResponse element in SOAP body"))?; - let value = extract_child_text_any(response, &["Volume", "Value"])?; + .ok_or_else(|| { + ControlPointError::UpnpMissingReturnValue("VolumeResponse".to_string()) + })?; + + let value = extract_child_text_any(response, &["Value"])?; let parsed = value .parse::() - .map_err(|_| anyhow!("Invalid volume value: {}", value))?; + .map_err(|_| ControlPointError::UpnpBadReturnValue("volume".to_string(), value))?; + Ok(parsed.min(u16::MAX as u32) as u16) } - pub fn set_volume(&self, vol: u16) -> Result<()> { + pub fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> { let vol_str = vol.to_string(); let args = [("Value", vol_str.as_str())]; let call_result = @@ -498,18 +561,20 @@ impl OhVolumeClient { handle_action_response("SetVolume", &call_result) } - pub fn mute(&self) -> Result { + pub fn mute(&self) -> Result { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Mute", &[])?; let envelope = ensure_success("Mute", &call_result)?; let response = find_child_with_suffix(&envelope.body.content, "MuteResponse") - .ok_or_else(|| anyhow!("Missing MuteResponse element in SOAP body"))?; - let value = extract_child_text_any(response, &["Mute", "Value"])?; - parse_bool(&value) + .ok_or_else(|| ControlPointError::UpnpMissingReturnValue("MuteResponse".to_string()))?; + + let value = extract_child_text_any(response, &["Value"])?; + + Ok(parse_bool(&value)) } - pub fn set_mute(&self, mute: bool) -> Result<()> { + pub fn set_mute(&self, mute: bool) -> Result<(), ControlPointError> { let mute_str = if mute { "1" } else { "0" }; - let args = [("Mute", mute_str)]; + let args = [("Value", mute_str)]; let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "SetMute", &args)?; handle_action_response("SetMute", &call_result) @@ -530,7 +595,16 @@ impl OhRadioClient { } } - pub fn play_channel(&self, id: u32) -> Result<()> { + pub fn from_renderer_info(info: &RendererInfo) -> Result { + let control_url = info.oh_radio_control_url() + .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create radio control client".to_string()))?; + + let service_type = info.oh_radio_service_type() + .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create radio service client".to_string()))?; + Ok(OhRadioClient::new(control_url, service_type)) + } + + pub fn play_channel(&self, id: u32) -> Result<(), ControlPointError> { let id_str = id.to_string(); let args = [("Id", id_str.as_str())]; let call_result = @@ -548,8 +622,7 @@ impl OhRadioClient { let response = find_child_with_suffix(&envelope.body.content, "ChannelResponse") .ok_or_else(|| anyhow!("Missing ChannelResponse element in SOAP body"))?; - let uri = extract_child_text_any(response, &["Uri", "Value"]) - .unwrap_or_default(); + let uri = extract_child_text_any(response, &["Uri", "Value"]).unwrap_or_default(); let metadata_xml = extract_child_text_optional(response, "Metadata") .unwrap_or(None) .filter(|s| !s.is_empty()); @@ -572,6 +645,16 @@ impl OhProductClient { } } + pub fn from_renderer_info(info: &RendererInfo) -> Result { + let control_url = info.oh_product_control_url() + .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create product control client".to_string()))?; + + let service_type = info.oh_product_service_type() + .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create product service client".to_string()))?; + Ok(OhProductClient::new(control_url, service_type)) + } + + pub fn source_xml(&self) -> Result> { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "SourceXml", &[])?; @@ -582,19 +665,22 @@ impl OhProductClient { parse_product_source_list(&xml) } - pub fn source_index(&self) -> Result { + pub fn source_index(&self) -> Result { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "SourceIndex", &[])?; let envelope = ensure_success("SourceIndex", &call_result)?; let response = find_child_with_suffix(&envelope.body.content, "SourceIndexResponse") - .ok_or_else(|| anyhow!("Missing SourceIndexResponse element in SOAP body"))?; + .ok_or_else(|| { + ControlPointError::UpnpMissingReturnValue("SourceIndexResponse".to_string()) + })?; + let value = extract_child_text_any(response, &["Index", "Value"])?; value .parse::() - .map_err(|_| anyhow!("Invalid Product.SourceIndex value: {}", value)) + .map_err(|_| ControlPointError::UpnpBadReturnValue("volume".to_string(), value)) } - pub fn set_source_index(&self, index: u32) -> Result<()> { + pub fn set_source_index(&self, index: u32) -> Result<(), ControlPointError> { let value = index.to_string(); let args = [("Index", value.as_str())]; let call_result = invoke_upnp_action( @@ -606,8 +692,10 @@ impl OhProductClient { handle_action_response("SetSourceIndex", &call_result) } - pub fn ensure_playlist_source_selected(&self) -> Result<()> { - let sources = self.source_xml()?; + pub fn ensure_playlist_source_selected(&self) -> Result<(), ControlPointError> { + let sources = self + .source_xml() + .map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?; // Log all available sources for diagnostics debug!( @@ -615,8 +703,9 @@ impl OhProductClient { source_count = sources.len(), "OpenHome Product sources available" ); + for (idx, source) in sources.iter().enumerate() { - debug!( + trace!( control_url = self.control_url.as_str(), index = idx, name = source.name.as_str(), @@ -635,7 +724,7 @@ impl OhProductClient { available_types = ?sources.iter().map(|s| s.source_type.as_str()).collect::>(), "OpenHome Product source list does not expose a Playlist entry" ); - anyhow!("OpenHome Product source list does not expose a Playlist entry") + ControlPointError::OpenHomeNoPlaylistEntry() })?; let playlist_index = playlist_index as u32; let current_index = self.source_index()?; @@ -646,7 +735,9 @@ impl OhProductClient { control_url = self.control_url.as_str(), current_index, current_source_name = current_source.map(|s| s.name.as_str()).unwrap_or("unknown"), - current_source_type = current_source.map(|s| s.source_type.as_str()).unwrap_or("unknown"), + current_source_type = current_source + .map(|s| s.source_type.as_str()) + .unwrap_or("unknown"), playlist_index, needs_switch = current_index != playlist_index, "OpenHome source state" @@ -708,7 +799,7 @@ pub fn parse_track_metadata_from_didl(xml: &str) -> Option { }) } -fn parse_track_list(payload: &str) -> Result> { +fn parse_track_list(payload: &str) -> Result, ControlPointError> { let trimmed = payload.trim(); if trimmed.is_empty() { return Ok(Vec::new()); @@ -718,8 +809,9 @@ fn parse_track_list(payload: &str) -> Result> { (trimmed.to_string(), false) } else { let bytes = decode_base64(trimmed)?; - let decoded = String::from_utf8(bytes) - .map_err(|err| anyhow!("TrackList payload not valid UTF-8 after base64 decode: {err}"))?; + let decoded = String::from_utf8(bytes).map_err(|err| { + ControlPointError::OpenHomeError(format!("TrackList payload not valid UTF-8 after base64 decode: {err}")) + })?; (decoded, true) }; let xml_sample: String = xml.chars().take(256).collect(); @@ -732,7 +824,7 @@ fn parse_track_list(payload: &str) -> Result> { let mut reader = std::io::Cursor::new(xml.as_bytes()); let root = Element::parse(&mut reader) - .map_err(|err| anyhow!("Failed to parse OpenHome TrackList XML: {}", err))?; + .map_err(|err| ControlPointError::OpenHomeError(format!("Failed to parse OpenHome TrackList XML: {}", err)))?; let mut entries = Vec::new(); for node in &root.children { @@ -746,7 +838,7 @@ fn parse_track_list(payload: &str) -> Result> { Ok(entries) } -fn parse_track_entry(elem: &Element) -> Result { +fn parse_track_entry(elem: &Element) -> Result { let id_text = extract_child_text_local(elem, "Id")?; // Some renderers (like upmpdcli) return a comma-separated list of IDs in the Id element @@ -759,14 +851,14 @@ fn parse_track_entry(elem: &Element) -> Result { raw_id = id_text.as_str(), "Multi-value Id element detected - forcing fallback to individual reads" ); - return Err(anyhow!( + return Err(ControlPointError::OpenHomeError(format!( "Renderer returned comma-separated IDs in single Entry - need individual reads" - )); + ))); } let id = id_text .parse::() - .map_err(|_| anyhow!("Invalid OpenHome Entry Id: {}", id_text))?; + .map_err(|_| ControlPointError::OpenHomeError(format!("Invalid OpenHome Entry Id: {}", id_text)))?; let uri = extract_child_text_local(elem, "Uri")?; let metadata_xml = extract_child_text_optional_local(elem, "Metadata")?.unwrap_or_default(); @@ -809,181 +901,7 @@ fn parse_product_source_list(xml: &str) -> Result> { Ok(sources) } -fn parse_visible_flag(value: &str) -> bool { - let trimmed = value.trim(); - if trimmed.eq_ignore_ascii_case("true") { - return true; - } - if trimmed.eq_ignore_ascii_case("false") { - return false; - } - trimmed == "1" -} - -fn ensure_success<'a>(action: &str, call_result: &'a SoapCallResult) -> Result<&'a SoapEnvelope> { - if !call_result.status.is_success() { - if let Some(env) = &call_result.envelope { - if let Some(err) = parse_upnp_error(env) { - return Err(anyhow!( - "{action} failed with UPnP error {}: {} (HTTP status {})", - err.error_code, - err.error_description, - call_result.status - )); - } - } - - return Err(anyhow!( - "{action} failed with HTTP status {} and body: {}", - call_result.status, - call_result.raw_body - )); - } - - let envelope = call_result - .envelope - .as_ref() - .ok_or_else(|| anyhow!("Missing SOAP envelope in {action} response"))?; - - if let Some(err) = parse_upnp_error(envelope) { - return Err(anyhow!( - "{action} returned UPnP error {}: {} (HTTP status {})", - err.error_code, - err.error_description, - call_result.status - )); - } - - Ok(envelope) -} - -fn handle_action_response(action: &str, call_result: &SoapCallResult) -> Result<()> { - ensure_success(action, call_result)?; - Ok(()) -} - -fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> { - parent.children.iter().find_map(|node| match node { - XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem), - _ => None, - }) -} - -fn find_child_with_local_name<'a>(parent: &'a Element, local: &str) -> Option<&'a Element> { - parent.children.iter().find_map(|node| { - if let XMLNode::Element(elem) = node { - if elem.name == local || elem.name.ends_with(&format!(":{}", local)) { - return Some(elem); - } - } - None - }) -} - -fn extract_child_text(parent: &Element, suffix: &str) -> Result { - let child = find_child_with_suffix(parent, suffix) - .ok_or_else(|| anyhow!("Missing {suffix} element in response"))?; - - let text = child - .get_text() - .map(|t| t.trim().to_string()) - .ok_or_else(|| anyhow!("{suffix} element missing text in response"))?; - - Ok(text) -} - -fn extract_child_text_optional(parent: &Element, suffix: &str) -> Result> { - if let Some(child) = find_child_with_suffix(parent, suffix) { - let text = child - .get_text() - .map(|t| t.trim().to_string()) - .unwrap_or_default(); - Ok(Some(text)) - } else { - Ok(None) - } -} - -fn extract_child_text_any(parent: &Element, suffixes: &[&str]) -> Result { - for suffix in suffixes { - if let Ok(text) = extract_child_text(parent, suffix) { - return Ok(text); - } - } - Err(anyhow!( - "Missing {} element in response", - suffixes.join(" or ") - )) -} - -fn extract_child_text_local(parent: &Element, local: &str) -> Result { - let child = find_child_with_local_name(parent, local) - .ok_or_else(|| anyhow!("Missing {local} element in response"))?; - let text = child - .get_text() - .map(|t| t.trim().to_string()) - .ok_or_else(|| anyhow!("{local} element missing text in response"))?; - Ok(text) -} - -fn extract_child_text_optional_local(parent: &Element, local: &str) -> Result> { - if let Some(child) = find_child_with_local_name(parent, local) { - let text = child - .get_text() - .map(|t| t.trim().to_string()) - .unwrap_or_default(); - Ok(Some(text)) - } else { - Ok(None) - } -} - -fn parse_bool(value: &str) -> Result { - match value.trim() { - "0" => Ok(false), - "1" => Ok(true), - other => Err(anyhow!("Invalid boolean value '{}'", other)), - } -} - -pub(crate) fn decode_base64(input: &str) -> Result> { - fn value(byte: u8) -> Option { - match byte { - b'A'..=b'Z' => Some(byte - b'A'), - b'a'..=b'z' => Some(byte - b'a' + 26), - b'0'..=b'9' => Some(byte - b'0' + 52), - b'+' => Some(62), - b'/' => Some(63), - _ => None, - } - } - - let mut output = Vec::new(); - let mut buffer: u32 = 0; - let mut bits_collected: u8 = 0; - - for byte in input.bytes() { - if byte == b'=' { - break; - } - if byte == b'\r' || byte == b'\n' || byte == b' ' || byte == b'\t' { - continue; - } - let val = - value(byte).ok_or_else(|| anyhow!("Invalid base64 character '{}'", byte as char))?; - buffer = (buffer << 6) | (val as u32); - bits_collected += 6; - if bits_collected >= 8 { - bits_collected -= 8; - let out = (buffer >> bits_collected) & 0xFF; - output.push(out as u8); - } - } - - Ok(output) -} - -fn is_invalid_entry_id_error(err: &anyhow::Error) -> bool { +fn is_invalid_entry_id_error(err: &ControlPointError) -> bool { let msg = format!("{err}"); msg.contains("Invalid OpenHome Entry Id") || msg.contains("comma-separated IDs") } @@ -998,8 +916,7 @@ mod tests { let xml = r#"1337"#; let mut cursor = Cursor::new(xml.as_bytes()); let response = Element::parse(&mut cursor).expect("valid xml"); - let value = - extract_child_text_any(&response, &["NewId", "Value"]).unwrap(); + let value = extract_child_text_any(&response, &["NewId", "Value"]).unwrap(); assert_eq!(value, "1337"); } @@ -1008,9 +925,7 @@ mod tests { let xml = r#"PGVudHJ5PjwvZW50cnk+"#; let mut cursor = Cursor::new(xml.as_bytes()); let response = Element::parse(&mut cursor).expect("valid xml"); - let value = - extract_child_text_any(&response, &["TrackList", "Value"]) - .expect("tracklist"); + let value = extract_child_text_any(&response, &["TrackList", "Value"]).expect("tracklist"); assert_eq!(value, "PGVudHJ5PjwvZW50cnk+"); } @@ -1019,11 +934,8 @@ mod tests { let xml = r#"1AAAAAQAAAAI="#; let mut cursor = Cursor::new(xml.as_bytes()); let response = Element::parse(&mut cursor).expect("valid xml"); - let value = extract_child_text_any( - &response, - &["Array", "IdArray", "Value"], - ) - .expect("array content"); + let value = extract_child_text_any(&response, &["Array", "IdArray", "Value"]) + .expect("array content"); assert_eq!(value, "AAAAAQAAAAI="); } } diff --git a/pmocontrol/src/openhome_renderer.rs b/pmocontrol/src/openhome_renderer.rs index 32fe20b1..db6712ba 100644 --- a/pmocontrol/src/openhome_renderer.rs +++ b/pmocontrol/src/openhome_renderer.rs @@ -1,24 +1,27 @@ +use std::sync::{Arc, Mutex}; + +use crate::{DeviceIdentity, MusicRendererBackend}; use crate::capabilities::{ PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, VolumeControl, }; -use crate::model::{RendererId, RendererInfo, RendererProtocol}; -use crate::music_renderer::op_not_supported; + +use crate::errors::ControlPointError; +use crate::model::RendererInfo; use crate::openhome::{ build_info_client, build_playlist_client, build_product_client, build_radio_client, build_time_client, build_volume_client, }; use crate::openhome_client::{ - parse_track_metadata_from_didl, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, - OhTimeClient, OhTrackEntry, OhVolumeClient, OPENHOME_PLAYLIST_HEAD_ID, + OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, + OhTimeClient, OhTrackEntry, OhVolumeClient, parse_track_metadata_from_didl, }; use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; -use anyhow::{anyhow, Result}; +use anyhow::{Result, anyhow}; use tracing::debug; #[derive(Clone, Debug)] pub struct OpenHomeRenderer { - pub info: RendererInfo, playlist: Option, info_client: Option, time_client: Option, @@ -29,28 +32,46 @@ pub struct OpenHomeRenderer { } impl OpenHomeRenderer { - pub fn new(info: RendererInfo) -> Self { + pub fn new( + playlist: Option, + info_client: Option, + time_client: Option, + volume_client: Option, + product_client: Option, + radio_client: Option, + ) -> Self { Self { - playlist: build_playlist_client(&info), - info_client: build_info_client(&info), - time_client: build_time_client(&info), - volume_client: build_volume_client(&info), - product_client: build_product_client(&info), - radio_client: build_radio_client(&info), - info, + playlist, + info_client, + time_client, + volume_client, + product_client, + radio_client, } } - pub fn id(&self) -> &RendererId { - &self.info.id - } + pub fn from_renderer_info( + info: RendererInfo, + ) -> Result>, ControlPointError> { + let renderer = OpenHomeRenderer::new( + build_playlist_client(&info), + build_info_client(&info), + build_time_client(&info), + build_volume_client(&info), + build_product_client(&info), + build_radio_client(&info), + ); - pub fn friendly_name(&self) -> &str { - &self.info.friendly_name - } - - pub fn protocol(&self) -> &RendererProtocol { - &self.info.protocol + if renderer.has_any_openhome_service() { + Ok(Arc::new(Mutex::new(MusicRendererBackend::OpenHome( + renderer, + )))) + } else { + Err(ControlPointError::OpenHomeNotAValidDevice(format!( + "{:?}", + info.id() + ))) + } } pub fn has_playlist(&self) -> bool { @@ -73,74 +94,68 @@ impl OpenHomeRenderer { self.has_playlist() || self.has_info() || self.has_time() || self.has_volume() } - fn playlist_client_for(&self, op: &str) -> Result<&OhPlaylistClient> { + fn playlist_client_for(&self, op: &str) -> Result<&OhPlaylistClient, ControlPointError> { let playlist = self .playlist .as_ref() - .ok_or_else(|| op_not_supported(op, "OpenHome Playlist"))?; + .ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Playlist"))?; self.ensure_playlist_source_selected()?; Ok(playlist) } - fn info_client_for(&self, op: &str) -> Result<&OhInfoClient> { + fn info_client_for(&self, op: &str) -> Result<&OhInfoClient, ControlPointError> { self.info_client .as_ref() - .ok_or_else(|| op_not_supported(op, "OpenHome Info")) + .ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Info")) } - fn time_client_for(&self, op: &str) -> Result<&OhTimeClient> { + fn time_client_for(&self, op: &str) -> Result<&OhTimeClient, ControlPointError> { self.time_client .as_ref() - .ok_or_else(|| op_not_supported(op, "OpenHome Time")) + .ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Time")) } - fn volume_client_for(&self, op: &str) -> Result<&OhVolumeClient> { + fn volume_client_for(&self, op: &str) -> Result<&OhVolumeClient, ControlPointError> { self.volume_client .as_ref() - .ok_or_else(|| op_not_supported(op, "OpenHome Volume")) + .ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Volume")) } - fn ensure_playlist_source_selected(&self) -> Result<()> { + fn ensure_playlist_source_selected(&self) -> Result<(),ControlPointError> { if let Some(product) = &self.product_client { - product.ensure_playlist_source_selected().map_err(|err| { - anyhow!( - "Failed to select OpenHome Playlist source for {}: {}", - self.info.id.0, - err - ) - }) + product + .ensure_playlist_source_selected() } else { Ok(()) } } - pub(crate) fn snapshot_openhome_playlist(&self) -> Result { - let playlist = self.playlist_client_for("snapshot_openhome_playlist")?; - let entries = playlist.read_all_tracks()?; + // pub(crate) fn snapshot_openhome_playlist(&self) -> Result { + // let playlist = self.playlist_client_for("snapshot_openhome_playlist")?; + // let entries = playlist.read_all_tracks()?; - // Get current track ID from the playlist service - let current_id = playlist.id().ok(); + // // Get current track ID from the playlist service + // let current_id = playlist.id().ok(); - let current_index = - current_id.and_then(|id| entries.iter().position(|entry| entry.id == id)); + // let current_index = + // current_id.and_then(|id| entries.iter().position(|entry| entry.id == id)); - debug!( - renderer = self.info.id.0.as_str(), - current_id = ?current_id, - current_index = ?current_index, - track_count = entries.len(), - "snapshot_openhome_playlist completed" - ); + // debug!( + // current_id = ?current_id, + // current_index = ?current_index, + // track_count = entries.len(), + // "snapshot_openhome_playlist completed" + // ); - let tracks = entries.iter().map(convert_oh_track_entry).collect(); + // let tracks = entries.iter().map(convert_oh_track_entry).collect(); + + // Ok(OpenHomePlaylistSnapshot { + // current_id, + // current_index, + // tracks, + // }) + // } - Ok(OpenHomePlaylistSnapshot { - renderer_id: self.info.id.0.clone(), - current_id, - current_index, - tracks, - }) - } /// Retourne la longueur de la playlist OpenHome sans récupérer toutes les métadonnées. /// Plus rapide que snapshot_openhome_playlist() pour juste connaître le nombre de pistes. @@ -152,12 +167,12 @@ impl OpenHomeRenderer { /// Retourne les IDs des pistes de la playlist OpenHome. /// Plus rapide que snapshot_openhome_playlist() car ne récupère pas les métadonnées. - pub(crate) fn openhome_playlist_ids(&self) -> Result> { + pub(crate) fn openhome_playlist_ids(&self) -> Result, ControlPointError> { let playlist = self.playlist_client_for("openhome_playlist_ids")?; playlist.id_array() } - pub(crate) fn clear_openhome_playlist(&self) -> Result<()> { + pub(crate) fn clear_openhome_playlist(&self) -> Result<(), ControlPointError> { let playlist = self.playlist_client_for("clear_openhome_playlist")?; playlist.delete_all() } @@ -168,7 +183,7 @@ impl OpenHomeRenderer { metadata: &str, after_id: Option, play: bool, - ) -> Result { + ) -> Result { let playlist = self.playlist_client_for("add_track_openhome")?; let insert_after = match after_id { Some(id) => id, @@ -186,19 +201,19 @@ impl OpenHomeRenderer { Ok(new_id) } - pub(crate) fn play_openhome_track_id(&self, id: u32) -> Result<()> { + pub(crate) fn play_openhome_track_id(&self, id: u32) -> Result<(), ControlPointError> { let playlist = self.playlist_client_for("play_openhome_track_id")?; playlist.play_id(id) } } impl TransportControl for OpenHomeRenderer { - fn play_uri(&self, uri: &str, meta: &str) -> Result<()> { + fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { let playlist = self.playlist_client_for("play_uri")?; if let Err(err) = playlist.delete_all() { debug!( - renderer = self.info.id.0.as_str(), + // renderer = self.info.id.0.as_str(), error = %err, "Failed to clear OpenHome playlist before insert" ); @@ -210,27 +225,24 @@ impl TransportControl for OpenHomeRenderer { Ok(()) } - fn play(&self) -> Result<()> { + fn play(&self) -> Result<(), ControlPointError> { let playlist = self.playlist_client_for("play")?; playlist.play() } - fn pause(&self) -> Result<()> { + fn pause(&self) -> Result<(), ControlPointError> { let playlist = self.playlist_client_for("pause")?; playlist.pause() } - fn stop(&self) -> Result<()> { + fn stop(&self) -> Result<(), ControlPointError> { let playlist = self.playlist_client_for("stop")?; playlist.stop() } - fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { + fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { let seconds = parse_hms(hhmmss).ok_or_else(|| { - anyhow!( - "Invalid HH:MM:SS format for OpenHome SeekSecondAbsolute: {}", - hhmmss - ) + ControlPointError::upnp_bad_return_value("HH:MM:SS", &hhmmss) })?; let playlist = self.playlist_client_for("seek_rel_time")?; playlist.seek_second_absolute(seconds) @@ -238,29 +250,29 @@ impl TransportControl for OpenHomeRenderer { } impl VolumeControl for OpenHomeRenderer { - fn volume(&self) -> Result { + fn volume(&self) -> Result { let client = self.volume_client_for("volume")?; client.volume() } - fn set_volume(&self, v: u16) -> Result<()> { + fn set_volume(&self, v: u16) -> Result<(), ControlPointError> { let client = self.volume_client_for("set_volume")?; client.set_volume(v) } - fn mute(&self) -> Result { + fn mute(&self) -> Result { let client = self.volume_client_for("mute")?; client.mute() } - fn set_mute(&self, m: bool) -> Result<()> { + fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { let client = self.volume_client_for("set_mute")?; client.set_mute(m) } } impl PlaybackStatus for OpenHomeRenderer { - fn playback_state(&self) -> Result { + fn playback_state(&self) -> Result { let client = self.playlist_client_for("playback_state")?; let state = client.transport_state()?; Ok(map_openhome_state(&state)) @@ -268,7 +280,7 @@ impl PlaybackStatus for OpenHomeRenderer { } impl PlaybackPosition for OpenHomeRenderer { - fn playback_position(&self) -> Result { + fn playback_position(&self) -> Result { let time_info = self.time_client_for("playback_position")?.position()?; let mut track_id = None; @@ -279,7 +291,7 @@ impl PlaybackPosition for OpenHomeRenderer { match playlist_client.id() { Ok(id) => track_id = Some(id), Err(err) => debug!( - renderer = self.info.id.0.as_str(), + // renderer = self.info.id.0.as_str(), error = %err, "Failed to read OpenHome track id" ), @@ -293,7 +305,7 @@ impl PlaybackPosition for OpenHomeRenderer { track_metadata_xml = track.metadata_xml; } Err(err) => debug!( - renderer = self.info.id.0.as_str(), + // renderer = self.info.id.0.as_str(), error = %err, "Failed to read OpenHome track metadata" ), diff --git a/pmocontrol/src/pmoserver_ext.rs b/pmocontrol/src/pmoserver_ext.rs index 19d08449..09771f04 100644 --- a/pmocontrol/src/pmoserver_ext.rs +++ b/pmocontrol/src/pmoserver_ext.rs @@ -9,10 +9,10 @@ use crate::control_point::{ }; #[cfg(feature = "pmoserver")] use crate::media_server::{ - playback_item_from_entry, MediaBrowser, MediaEntry, MusicServer, ServerId, + playback_item_from_entry, MediaBrowser, MediaEntry, UpnpMediaServer, ServerId, }; #[cfg(feature = "pmoserver")] -use crate::model::{RendererCapabilities, RendererId, RendererProtocol, TrackMetadata}; +use crate::model::{RendererCapabilities, ServiceId, RendererProtocol, TrackMetadata}; #[cfg(feature = "pmoserver")] use crate::openapi::{ AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse, @@ -136,7 +136,7 @@ async fn get_renderer_state( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = RendererId(renderer_id.clone()); + let rid = ServiceId(renderer_id.clone()); let snapshot = state .control_point .renderer_full_snapshot(&rid) @@ -162,7 +162,7 @@ async fn get_renderer_full_snapshot( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = RendererId(renderer_id.clone()); + let rid = ServiceId(renderer_id.clone()); let snapshot = state .control_point .renderer_full_snapshot(&rid) @@ -193,7 +193,7 @@ async fn get_renderer_queue( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = RendererId(renderer_id.clone()); + let rid = ServiceId(renderer_id.clone()); let snapshot = state .control_point .renderer_full_snapshot(&rid) @@ -220,7 +220,7 @@ async fn get_renderer_binding( State(state): State, Path(renderer_id): Path, ) -> Result>, (StatusCode, Json)> { - let rid = RendererId(renderer_id.clone()); + let rid = ServiceId(renderer_id.clone()); let snapshot = state .control_point .renderer_full_snapshot(&rid) @@ -252,7 +252,7 @@ async fn play_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = RendererId(renderer_id.clone()); + let rid = ServiceId(renderer_id.clone()); let renderer = state .control_point .music_renderer_by_id(&rid) @@ -328,7 +328,7 @@ async fn pause_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = RendererId(renderer_id.clone()); + let rid = ServiceId(renderer_id.clone()); let renderer = state .control_point .music_renderer_by_id(&rid) @@ -404,7 +404,7 @@ async fn stop_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = RendererId(renderer_id.clone()); + let rid = ServiceId(renderer_id.clone()); state .control_point .music_renderer_by_id(&rid) @@ -481,7 +481,7 @@ async fn resume_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = RendererId(renderer_id.clone()); + let rid = ServiceId(renderer_id.clone()); state .control_point .music_renderer_by_id(&rid) @@ -562,7 +562,7 @@ async fn next_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = RendererId(renderer_id.clone()); + let rid = ServiceId(renderer_id.clone()); state .control_point .music_renderer_by_id(&rid) @@ -643,7 +643,7 @@ async fn seek_queue_index( Path(renderer_id): Path, Json(payload): Json, ) -> Result, (StatusCode, Json)> { - let rid = RendererId(renderer_id.clone()); + let rid = ServiceId(renderer_id.clone()); state .control_point .music_renderer_by_id(&rid) @@ -729,7 +729,7 @@ async fn set_renderer_volume( Path(renderer_id): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { - let rid = RendererId(renderer_id.clone()); + let rid = ServiceId(renderer_id.clone()); let renderer = state .control_point @@ -807,7 +807,7 @@ async fn volume_up_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = RendererId(renderer_id.clone()); + let rid = ServiceId(renderer_id.clone()); let renderer = state .control_point @@ -892,7 +892,7 @@ async fn volume_down_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = RendererId(renderer_id.clone()); + let rid = ServiceId(renderer_id.clone()); let renderer = state .control_point @@ -977,7 +977,7 @@ async fn toggle_mute_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = RendererId(renderer_id.clone()); + let rid = ServiceId(renderer_id.clone()); let renderer = state .control_point @@ -1064,7 +1064,7 @@ async fn attach_playlist_binding( Path(renderer_id): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { - let rid = RendererId(renderer_id.clone()); + let rid = ServiceId(renderer_id.clone()); let sid = ServerId(req.server_id.clone()); let container_id = req.container_id.clone(); let control_point = Arc::clone(&state.control_point); @@ -1146,7 +1146,7 @@ async fn detach_playlist_binding( State(state): State, Path(renderer_id): Path, ) -> Json { - let rid = RendererId(renderer_id.clone()); + let rid = ServiceId(renderer_id.clone()); state.control_point.detach_queue_playlist(&rid); @@ -1178,47 +1178,47 @@ async fn detach_playlist_binding( ), tag = "control" )] -async fn get_openhome_playlist( - State(state): State, - Path(renderer_id): Path, -) -> Result, (StatusCode, Json)> { - let rid = RendererId(renderer_id.clone()); - let control_point = Arc::clone(&state.control_point); - let rid_for_task = rid.clone(); +// async fn get_openhome_playlist( +// State(state): State, +// Path(renderer_id): Path, +// ) -> Result, (StatusCode, Json)> { +// let rid = RendererId(renderer_id.clone()); +// let control_point = Arc::clone(&state.control_point); +// let rid_for_task = rid.clone(); - let fetch_task = tokio::task::spawn_blocking(move || { - control_point.get_cached_openhome_playlist_snapshot( - &rid_for_task, - OPENHOME_SNAPSHOT_CACHE_TTL, - ) - }); +// let fetch_task = tokio::task::spawn_blocking(move || { +// control_point.get_cached_openhome_playlist_snapshot( +// &rid_for_task, +// OPENHOME_SNAPSHOT_CACHE_TTL, +// ) +// }); - let snapshot = fetch_task - .await - .map_err(|e| { - warn!( - renderer = renderer_id.as_str(), - error = %e, - "Join error while fetching OpenHome playlist" - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: format!("Internal task error: {}", e), - }), - ) - })? - .map_err(|e| { - warn!( - renderer = renderer_id.as_str(), - error = %e, - "Failed to read OpenHome playlist" - ); - map_openhome_error(&rid, e, "read OpenHome playlist") - })?; +// let snapshot = fetch_task +// .await +// .map_err(|e| { +// warn!( +// renderer = renderer_id.as_str(), +// error = %e, +// "Join error while fetching OpenHome playlist" +// ); +// ( +// StatusCode::INTERNAL_SERVER_ERROR, +// Json(ErrorResponse { +// error: format!("Internal task error: {}", e), +// }), +// ) +// })? +// .map_err(|e| { +// warn!( +// renderer = renderer_id.as_str(), +// error = %e, +// "Failed to read OpenHome playlist" +// ); +// map_openhome_error(&rid, e, "read OpenHome playlist") +// })?; - Ok(Json(snapshot)) -} +// Ok(Json(snapshot)) +// } /// POST /control/renderers/{renderer_id}/oh/playlist/clear - Vide la playlist OH #[cfg(feature = "pmoserver")] @@ -1238,7 +1238,7 @@ async fn clear_openhome_playlist( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = RendererId(renderer_id.clone()); + let rid = ServiceId(renderer_id.clone()); let control_point = Arc::clone(&state.control_point); let rid_for_task = rid.clone(); @@ -1310,7 +1310,7 @@ async fn add_openhome_playlist_item( Path(renderer_id): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { - let rid = RendererId(renderer_id.clone()); + let rid = ServiceId(renderer_id.clone()); let control_point = Arc::clone(&state.control_point); let rid_for_task = rid.clone(); @@ -1389,7 +1389,7 @@ async fn play_openhome_track( State(state): State, Path((renderer_id, track_id)): Path<(String, String)>, ) -> Result, (StatusCode, Json)> { - let rid = RendererId(renderer_id.clone()); + let rid = ServiceId(renderer_id.clone()); let parsed_id = track_id.parse::().map_err(|e| { ( StatusCode::BAD_REQUEST, @@ -1477,7 +1477,7 @@ async fn play_content( Path(renderer_id): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { - let rid = RendererId(renderer_id.clone()); + let rid = ServiceId(renderer_id.clone()); let sid = ServerId(req.server_id.clone()); let object_id = req.object_id.clone(); let object_id_for_log = object_id.clone(); @@ -1606,7 +1606,7 @@ async fn add_to_queue( Path(renderer_id): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { - let rid = RendererId(renderer_id.clone()); + let rid = ServiceId(renderer_id.clone()); let sid = ServerId(req.server_id.clone()); let object_id = req.object_id.clone(); let object_id_for_log = object_id.clone(); @@ -1772,7 +1772,7 @@ async fn browse_container( } let music_server = - MusicServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT).map_err(|e| { + UpnpMediaServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT).map_err(|e| { warn!("Failed to create MusicServer for {}: {}", server_id, e); ( StatusCode::INTERNAL_SERVER_ERROR, @@ -1871,7 +1871,7 @@ fn map_snapshot_error( #[cfg(feature = "pmoserver")] fn map_openhome_error( - renderer_id: &RendererId, + renderer_id: &ServiceId, err: anyhow::Error, context: &str, ) -> (StatusCode, Json) { @@ -1922,7 +1922,7 @@ fn fetch_playback_items( } // Create MusicServer - let music_server = MusicServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT)?; + let music_server = UpnpMediaServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT)?; // Browse the object to get entries let entries = music_server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?; @@ -1959,7 +1959,7 @@ fn protocol_summary(protocol: &RendererProtocol) -> RendererProtocolSummary { match protocol { RendererProtocol::UpnpAvOnly => RendererProtocolSummary::Upnp, RendererProtocol::OpenHomeOnly => RendererProtocolSummary::Openhome, - RendererProtocol::Hybrid => RendererProtocolSummary::Hybrid, + RendererProtocol::OpenHomeHybrid => RendererProtocolSummary::Hybrid, RendererProtocol::ChromecastOnly => RendererProtocolSummary::Chromecast, } } diff --git a/pmocontrol/src/queue_backend.rs b/pmocontrol/src/queue/backend.rs similarity index 88% rename from pmocontrol/src/queue_backend.rs rename to pmocontrol/src/queue/backend.rs index 70c1cdca..423bc3b5 100644 --- a/pmocontrol/src/queue_backend.rs +++ b/pmocontrol/src/queue/backend.rs @@ -28,13 +28,12 @@ //! - This identity is used by the sync helpers to preserve the current //! track across queue rebuilds when the MediaServer content changes. -use anyhow::Result; - +use crate::DeviceId; +use crate::errors::ControlPointError; // ADAPTE ces imports aux modules existants dans pmocontrol. // Exemple probable : // use crate::model::MediaServerId; // use crate::model::TrackMetadata; -use crate::media_server::ServerId as MediaServerId; use crate::model::TrackMetadata; /// Canonical representation of a track in a renderer queue. @@ -52,7 +51,14 @@ pub struct PlaybackItem { /// /// Typically this is the UDN of the MediaServer device, or an /// equivalent logical identifier. - pub media_server_id: MediaServerId, + pub media_server_id: DeviceId, + + /// L'ID interne de l'item dans la queue. + /// Cet ID n'a de sens que lors du retour d'un snapshot. + /// Dans une queue interne, il peut avoir n'importe quelle valeur, + /// l'ID qui compte et la position dans le vecteur. + /// Par principe, on la peut la mettre égale à usize::MAX. + pub backend_id: usize, /// DIDL-Lite `id` attribute of the `item` in the ContentDirectory. /// @@ -159,13 +165,13 @@ pub trait QueueBackend { // ===================================================================== /// Returns the full snapshot (items + current index) of this queue. - fn queue_snapshot(&self) -> Result; + fn queue_snapshot(&self) -> Result; /// Sets the current index for this queue. /// /// This method only updates the queue structure (pointer to the current /// item). It MUST NOT start playback. - fn set_index(&mut self, index: Option) -> Result<()>; + fn set_index(&mut self, index: Option) -> Result<(), ControlPointError>; /// Replaces the entire queue with a new list of items and a new /// current index. @@ -173,45 +179,45 @@ pub trait QueueBackend { &mut self, items: Vec, current_index: Option, - ) -> Result<()>; + ) -> Result<(), ControlPointError>; /// Returns the item at `index`, if it exists. - fn get_item(&self, index: usize) -> Result>; + fn get_item(&self, index: usize) -> Result, ControlPointError>; /// Replaces the item at `index` with `item`. - fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<()>; + fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError>; // ===================================================================== // DEFAULT HELPERS (backend-agnostic logic) // ===================================================================== /// Clears the queue. - fn clear_queue(&mut self) -> Result<()> { + fn clear_queue(&mut self) -> Result<(), ControlPointError> { self.replace_queue(Vec::new(), None) } /// Alias for `clear_queue`, semantic name for “empty before rebuild”. - fn empty_queue(&mut self) -> Result<()> { + fn empty_queue(&mut self) -> Result<(), ControlPointError> { self.clear_queue() } /// Returns the current index, if any. - fn current_index(&self) -> Result> { + fn current_index(&self) -> Result, ControlPointError> { Ok(self.queue_snapshot()?.current_index) } /// Returns the number of items in the queue. - fn len(&self) -> Result { + fn len(&self) -> Result { Ok(self.queue_snapshot()?.len()) } /// Returns `true` if the queue is empty. - fn is_empty(&self) -> Result { + fn is_empty(&self) -> Result { Ok(self.queue_snapshot()?.is_empty()) } /// Returns a full snapshot of the queue. - fn full_snapshot(&self) -> Result { + fn full_snapshot(&self) -> Result { self.queue_snapshot() } @@ -220,13 +226,13 @@ pub trait QueueBackend { /// The default implementation: /// - takes a snapshot, /// - returns a boxed iterator owning the underlying `Vec`. - fn iter_items(&self) -> Result>> { + fn iter_items(&self) -> Result>, ControlPointError> { let snapshot = self.queue_snapshot()?; Ok(Box::new(snapshot.items.into_iter())) } /// Returns the list of items that come strictly after the current index. - fn upcoming_items(&self) -> Result> { + fn upcoming_items(&self) -> Result, ControlPointError> { let snapshot = self.queue_snapshot()?; let items = match snapshot.current_index { None => snapshot.items, @@ -236,13 +242,13 @@ pub trait QueueBackend { } /// Returns how many items remain in the queue after the current index. - fn upcoming_len(&self) -> Result { + fn upcoming_len(&self) -> Result { Ok(self.upcoming_items()?.len()) } /// Returns the current item (or the first pending item if no index is set) /// along with the count of remaining items. - fn peek_current(&self) -> Result> { + fn peek_current(&self) -> Result, ControlPointError> { let snapshot = self.queue_snapshot()?; let QueueSnapshot { items, @@ -274,7 +280,7 @@ pub trait QueueBackend { /// Advances the queue to the next item (respecting the current index) and /// returns it with the number of remaining items. - fn dequeue_next(&mut self) -> Result> { + fn dequeue_next(&mut self) -> Result, ControlPointError> { let snapshot = self.queue_snapshot()?; let QueueSnapshot { items, @@ -310,7 +316,7 @@ pub trait QueueBackend { /// /// This method only manipulates the queue structure; it does not /// start playback. - fn enqueue_items(&mut self, items: Vec, mode: EnqueueMode) -> Result<()> { + fn enqueue_items(&mut self, items: Vec, mode: EnqueueMode) -> Result<(), ControlPointError> { let mut snapshot = self.queue_snapshot()?; match mode { @@ -337,7 +343,7 @@ pub trait QueueBackend { } /// Replaces the queue with `items` and sets a default index. - fn replace_all(&mut self, items: Vec) -> Result<()> { + fn replace_all(&mut self, items: Vec) -> Result<(), ControlPointError> { if items.is_empty() { self.replace_queue(Vec::new(), None) } else { @@ -347,7 +353,7 @@ pub trait QueueBackend { /// Appends items and, if the queue was previously empty, initializes /// the current index to `0`. - fn append_or_init_index(&mut self, items: Vec) -> Result<()> { + fn append_or_init_index(&mut self, items: Vec) -> Result<(), ControlPointError> { let was_empty = self.is_empty()?; let mut snapshot = self.queue_snapshot()?; snapshot.items.extend(items); @@ -362,7 +368,7 @@ pub trait QueueBackend { } /// Computes the “next” index. - fn next_index(&self) -> Result> { + fn next_index(&self) -> Result, ControlPointError> { let len = self.len()?; if len == 0 { return Ok(None); @@ -376,7 +382,7 @@ pub trait QueueBackend { } /// Computes the “previous” index. - fn previous_index(&self) -> Result> { + fn previous_index(&self) -> Result, ControlPointError> { match self.current_index()? { None => Ok(None), Some(0) => Ok(None), @@ -385,7 +391,7 @@ pub trait QueueBackend { } /// Advances the current index to the next item, if any. - fn advance(&mut self) -> Result { + fn advance(&mut self) -> Result { if let Some(next) = self.next_index()? { self.set_index(Some(next))?; Ok(true) @@ -395,7 +401,7 @@ pub trait QueueBackend { } /// Rewinds the current index to the previous item, if any. - fn rewind(&mut self) -> Result { + fn rewind(&mut self) -> Result { if let Some(prev) = self.previous_index()? { self.set_index(Some(prev))?; Ok(true) @@ -409,18 +415,18 @@ pub trait QueueBackend { &mut self, index: usize, update: impl FnOnce(PlaybackItem) -> PlaybackItem, - ) -> Result<()> { + ) -> Result<(), ControlPointError> { if let Some(item) = self.get_item(index)? { let new_item = update(item); self.replace_item(index, new_item) } else { - anyhow::bail!("Queue index {} out of range", index); + Err(ControlPointError::QueueError(format!("Queue index {} out of range", index))) } } /// Synchronizes the queue with a new list of items coming from an /// external MediaServer, trying to preserve the current track. - fn sync_from_external_preserve_current(&mut self, new_items: Vec) -> Result<()> { + fn sync_from_external_preserve_current(&mut self, new_items: Vec) -> Result<(), ControlPointError> { let snapshot = self.queue_snapshot()?; let current = snapshot .current_index diff --git a/pmocontrol/src/queue_interne.rs b/pmocontrol/src/queue/interne.rs similarity index 73% rename from pmocontrol/src/queue_interne.rs rename to pmocontrol/src/queue/interne.rs index cc9770f2..bbd3262a 100644 --- a/pmocontrol/src/queue_interne.rs +++ b/pmocontrol/src/queue/interne.rs @@ -15,9 +15,19 @@ //! - maintains a `current_index`, //! - never starts playback (transport control is handled elsewhere). +use std::sync::{Arc, Mutex}; + use anyhow::Result; -use crate::queue_backend::{PlaybackItem, QueueBackend, QueueSnapshot}; +use crate::{ + DeviceId, RendererInfo, + DeviceIdentity, + errors::ControlPointError, + queue::{ + MusicQueue, + backend::{PlaybackItem, QueueBackend, QueueSnapshot}, + }, +}; /// Internal/local queue implementation. /// @@ -27,35 +37,30 @@ use crate::queue_backend::{PlaybackItem, QueueBackend, QueueSnapshot}; /// /// It does not talk to any remote service. All operations are pure /// structural mutations on in-memory data. -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug)] pub struct InternalQueue { + renderer_id: DeviceId, items: Vec, current_index: Option, } impl InternalQueue { /// Creates an empty internal queue. - pub fn new() -> Self { + pub fn new(renderer_id: DeviceId) -> Self { Self { + renderer_id, items: Vec::new(), current_index: None, } } - /// Creates an internal queue from an initial list of items. - /// - /// If `set_current_to_first` is `true` and the list is non-empty, - /// the current index is set to `Some(0)`. Otherwise, it is `None`. - pub fn from_items(items: Vec, set_current_to_first: bool) -> Self { - let current_index = if set_current_to_first && !items.is_empty() { - Some(0) - } else { - None - }; - Self { - items, - current_index, - } + pub fn from_renderer_info( + info: &RendererInfo, + ) -> Result { + Ok(InternalQueue::new( + info.id(), + ), + ) } /// Exposes a read-only view of the underlying items. @@ -70,14 +75,19 @@ impl InternalQueue { } impl QueueBackend for InternalQueue { - fn queue_snapshot(&self) -> Result { + fn queue_snapshot(&self) -> Result { + let mut items = self.items.clone(); + for (i, item) in items.iter_mut().enumerate() { + item.backend_id = i; + } + Ok(QueueSnapshot { - items: self.items.clone(), + items, current_index: self.current_index, }) } - fn set_index(&mut self, index: Option) -> Result<()> { + fn set_index(&mut self, index: Option) -> Result<(), ControlPointError> { match index { None => { self.current_index = None; @@ -97,17 +107,17 @@ impl QueueBackend for InternalQueue { &mut self, items: Vec, current_index: Option, - ) -> Result<()> { + ) -> Result<(), ControlPointError> { self.items = items; self.current_index = current_index.filter(|&i| i < self.items.len()); Ok(()) } - fn get_item(&self, index: usize) -> Result> { + fn get_item(&self, index: usize) -> Result, ControlPointError> { Ok(self.items.get(index).cloned()) } - fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<()> { + fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> { if index < self.items.len() { self.items[index] = item; } diff --git a/pmocontrol/src/queue/mod.rs b/pmocontrol/src/queue/mod.rs new file mode 100644 index 00000000..af5f3d86 --- /dev/null +++ b/pmocontrol/src/queue/mod.rs @@ -0,0 +1,7 @@ +mod music_queue; +mod openhome; +mod backend; +mod interne; + +pub use music_queue::MusicQueue; +pub use backend::{PlaybackItem, QueueBackend, QueueSnapshot, EnqueueMode}; diff --git a/pmocontrol/src/control_point/music_queue.rs b/pmocontrol/src/queue/music_queue.rs similarity index 68% rename from pmocontrol/src/control_point/music_queue.rs rename to pmocontrol/src/queue/music_queue.rs index 2923b3ea..be7c8be9 100644 --- a/pmocontrol/src/control_point/music_queue.rs +++ b/pmocontrol/src/queue/music_queue.rs @@ -1,9 +1,9 @@ -use anyhow::{anyhow, Result}; - +use crate::RendererInfo; use crate::control_point::openhome_queue::OpenHomeQueue; +use crate::errors::ControlPointError; use crate::openhome_playlist::OpenHomePlaylistSnapshot; -use crate::queue_backend::{PlaybackItem, QueueBackend, QueueSnapshot}; -use crate::queue_interne::InternalQueue; +use crate::queue::backend::{PlaybackItem, QueueBackend, QueueSnapshot}; +use crate::queue::interne::InternalQueue; #[derive(Debug, Clone)] pub enum MusicQueue { @@ -12,16 +12,22 @@ pub enum MusicQueue { } impl MusicQueue { - pub fn new_internal() -> Self { - MusicQueue::Internal(InternalQueue::default()) + + pub fn from_renderer_info(info: &RendererInfo) -> Result { + if info.capabilities().has_oh_playlist() { + + Ok(MusicQueue::OpenHome(OpenHomeQueue::from_renderer_info(info)?)) + } else { + Ok(MusicQueue::Internal(InternalQueue::from_renderer_info(info)?)) + } } - pub fn openhome_playlist_snapshot(&self) -> Result { + pub fn openhome_playlist_snapshot(&self) -> Result { match self { MusicQueue::OpenHome(queue) => queue.openhome_playlist_snapshot(), - _ => Err(anyhow!( + _ => Err(ControlPointError::QueueError(format!( "OpenHome playlist snapshot is only available for OpenHome queues" - )), + ))), } } @@ -29,7 +35,7 @@ impl MusicQueue { &mut self, items: Vec, current_index: Option, - ) -> Result<()> { + ) -> Result<(), ControlPointError> { match self { MusicQueue::OpenHome(queue) => queue.replace_entire_playlist(items, current_index), MusicQueue::Internal(queue) => queue.replace_queue(items, current_index), @@ -37,21 +43,16 @@ impl MusicQueue { } } -impl Default for MusicQueue { - fn default() -> Self { - MusicQueue::new_internal() - } -} impl QueueBackend for MusicQueue { - fn queue_snapshot(&self) -> Result { + fn queue_snapshot(&self) -> Result { match self { MusicQueue::Internal(q) => q.queue_snapshot(), MusicQueue::OpenHome(q) => q.queue_snapshot(), } } - fn set_index(&mut self, index: Option) -> Result<()> { + fn set_index(&mut self, index: Option) -> Result<(), ControlPointError> { match self { MusicQueue::Internal(q) => q.set_index(index), MusicQueue::OpenHome(q) => q.set_index(index), @@ -62,21 +63,21 @@ impl QueueBackend for MusicQueue { &mut self, items: Vec, current_index: Option, - ) -> Result<()> { + ) -> Result<(), ControlPointError> { match self { MusicQueue::Internal(q) => q.replace_queue(items, current_index), MusicQueue::OpenHome(q) => q.replace_queue(items, current_index), } } - fn get_item(&self, index: usize) -> Result> { + fn get_item(&self, index: usize) -> Result, ControlPointError> { match self { MusicQueue::Internal(q) => q.get_item(index), MusicQueue::OpenHome(q) => q.get_item(index), } } - fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<()> { + fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> { match self { MusicQueue::Internal(q) => q.replace_item(index, item), MusicQueue::OpenHome(q) => q.replace_item(index, item), diff --git a/pmocontrol/src/control_point/openhome_queue.rs b/pmocontrol/src/queue/openhome.rs similarity index 81% rename from pmocontrol/src/control_point/openhome_queue.rs rename to pmocontrol/src/queue/openhome.rs index 5151fdd3..e8f39672 100644 --- a/pmocontrol/src/control_point/openhome_queue.rs +++ b/pmocontrol/src/queue/openhome.rs @@ -1,39 +1,38 @@ -use anyhow::{anyhow, Result}; use pmodidl::DIDLLite; use quick_xml::escape::escape; use tracing::debug; -use crate::media_server::ServerId; -use crate::model::RendererId; +use crate::errors::ControlPointError; use crate::openhome_client::{ - parse_track_metadata_from_didl, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrackEntry, - OPENHOME_PLAYLIST_HEAD_ID, + OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrackEntry, + parse_track_metadata_from_didl, }; use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; -use crate::queue_backend::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot}; +use crate::queue::backend::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot}; +use crate::{DeviceId, DeviceIdentity, RendererInfo}; /// Local mirror of an OpenHome playlist for a single renderer. #[derive(Clone, Debug)] pub struct OpenHomeQueue { - pub renderer_id: RendererId, - pub playlist: OhPlaylistClient, - pub info_client: Option, - pub product_client: Option, - pub items: Vec, - pub current_index: Option, + renderer_id: DeviceId, + playlist_client: OhPlaylistClient, + info_client: Option, + product_client: Option, + items: Vec, + current_index: Option, track_ids: Vec, } impl OpenHomeQueue { pub fn new( - renderer_id: RendererId, + renderer_id: DeviceId, playlist: OhPlaylistClient, info_client: Option, product_client: Option, ) -> Self { Self { renderer_id, - playlist, + playlist_client: playlist, info_client, product_client, items: Vec::new(), @@ -42,14 +41,29 @@ impl OpenHomeQueue { } } + pub fn from_renderer_info( + info: &RendererInfo, + ) -> Result { + let playlist_client = OhPlaylistClient::from_renderer_info(info)?; + let info_client = OhInfoClient::from_renderer_info(&info).ok(); + let product_client = OhProductClient::from_renderer_info(&info).ok(); + + Ok(OpenHomeQueue::new( + info.id(), + playlist_client, + info_client, + product_client, + )) + } + /// Reload the full OpenHome playlist snapshot into local playback items. /// /// This mirrors the logic previously implemented by /// `OpenHomeRenderer::snapshot_openhome_playlist` but converts entries /// directly into `PlaybackItem`s. - pub fn refresh_from_openhome(&mut self) -> Result<()> { + pub fn refresh_from_openhome(&mut self) -> Result<(), ControlPointError> { self.ensure_playlist_source_selected()?; - let entries = self.playlist.read_all_tracks()?; + let entries = self.playlist_client.read_all_tracks()?; let mut items = Vec::with_capacity(entries.len()); let mut track_ids = Vec::with_capacity(entries.len()); @@ -59,10 +73,11 @@ impl OpenHomeQueue { } // Get the currently playing track ID from the renderer (may be None if no track is playing) - let current_id = self.playlist.id().ok(); + let current_id = self.playlist_client.id().ok(); // Find the index of the current track in the playlist - let current_index = current_id.and_then(|id| track_ids.iter().position(|entry_id| *entry_id == id)); + let current_index = + current_id.and_then(|id| track_ids.iter().position(|entry_id| *entry_id == id)); self.items = items; self.track_ids = track_ids; @@ -70,7 +85,7 @@ impl OpenHomeQueue { Ok(()) } - pub fn openhome_playlist_snapshot(&self) -> Result { + pub fn openhome_playlist_snapshot(&self) -> Result { let tracks = self .items .iter() @@ -104,7 +119,7 @@ impl OpenHomeQueue { self.track_ids.clone() } - pub fn select_track_id(&mut self, id: u32) -> Result<()> { + pub fn select_track_id(&mut self, id: u32) -> Result<(), ControlPointError> { let index = match self.track_ids.iter().position(|&tid| tid == id) { Some(pos) => pos, None => { @@ -112,33 +127,35 @@ impl OpenHomeQueue { self.track_ids .iter() .position(|&tid| tid == id) - .ok_or_else(|| anyhow!("Unknown OpenHome track id {}", id))? + .ok_or_else(|| ControlPointError::QueueError(format!("Unknown OpenHome track id {}", id)))? } }; self.ensure_playlist_source_selected()?; - self.playlist.play_id(id)?; + self.playlist_client.play_id(id)?; self.current_index = Some(index); Ok(()) } /// Selects and plays a track by its queue index (0-based). - pub fn select_track_index(&mut self, index: usize) -> Result<()> { - let track_id = self - .track_ids - .get(index) - .copied() - .ok_or_else(|| anyhow!("Index {} out of bounds (queue length: {})", index, self.track_ids.len()))?; + pub fn select_track_index(&mut self, index: usize) -> Result<(), ControlPointError> { + let track_id = self.track_ids.get(index).copied().ok_or_else(|| { + ControlPointError::QueueError(format!( + "Index {} out of bounds (queue length: {})", + index, + self.track_ids.len() + )) + })?; self.ensure_playlist_source_selected()?; - self.playlist.play_id(track_id)?; + self.playlist_client.play_id(track_id)?; self.current_index = Some(index); Ok(()) } - pub fn clear(&mut self) -> Result<()> { + pub fn clear(&mut self) -> Result<(), ControlPointError> { self.ensure_playlist_source_selected()?; - self.playlist.delete_all()?; + self.playlist_client.delete_all()?; self.items.clear(); self.track_ids.clear(); self.current_index = None; @@ -154,9 +171,9 @@ impl OpenHomeQueue { &mut self, items: Vec, current_index: Option, - ) -> Result<()> { + ) -> Result<(), ControlPointError> { self.ensure_playlist_source_selected()?; - self.playlist.delete_all()?; + self.playlist_client.delete_all()?; self.items.clear(); self.track_ids.clear(); self.current_index = None; @@ -171,7 +188,9 @@ impl OpenHomeQueue { for item in items { let metadata = build_metadata_xml(&item); - let new_id = self.playlist.insert(previous_id, &item.uri, &metadata)?; + let new_id = self + .playlist_client + .insert(previous_id, &item.uri, &metadata)?; previous_id = new_id; rebuilt_ids.push(new_id); rebuilt_items.push(self.item_with_openhome_id(item, new_id)); @@ -192,7 +211,7 @@ impl OpenHomeQueue { item: PlaybackItem, after_id: Option, play: bool, - ) -> Result { + ) -> Result { self.ensure_playlist_source_selected()?; let metadata_xml = build_metadata_xml(&item); let insert_after = match after_id { @@ -201,11 +220,11 @@ impl OpenHomeQueue { }; let new_id = self - .playlist + .playlist_client .insert(insert_after, &item.uri, &metadata_xml)?; if play { - self.playlist.play_id(new_id)?; + self.playlist_client.play_id(new_id)?; } let mut insert_index = after_id @@ -239,15 +258,9 @@ impl OpenHomeQueue { Ok(new_id) } - fn ensure_playlist_source_selected(&self) -> Result<()> { + fn ensure_playlist_source_selected(&self) -> Result<(), ControlPointError> { if let Some(product) = &self.product_client { - product.ensure_playlist_source_selected().map_err(|err| { - anyhow!( - "Failed to select OpenHome Playlist source for {}: {}", - self.renderer_id.0, - err - ) - }) + product.ensure_playlist_source_selected() } else { Ok(()) } @@ -258,7 +271,8 @@ impl OpenHomeQueue { let didl_id = didl_id_from_metadata(&entry.metadata_xml) .unwrap_or_else(|| format!("openhome:{}", entry.id)); PlaybackItem { - media_server_id: ServerId(format!("openhome:{}", self.renderer_id.0)), + media_server_id: DeviceId(format!("openhome:{}", self.renderer_id.0)), + backend_id: entry.id as usize, didl_id, uri: entry.uri.clone(), // OpenHome tracks don't provide protocolInfo, use generic default @@ -269,13 +283,13 @@ impl OpenHomeQueue { fn item_with_openhome_id(&self, mut item: PlaybackItem, track_id: u32) -> PlaybackItem { item.didl_id = format!("openhome:{}", track_id); - item.media_server_id = ServerId(format!("openhome:{}", self.renderer_id.0)); + item.media_server_id = DeviceId(format!("openhome:{}", self.renderer_id.0)); item } - fn ensure_track_id(&mut self, index: usize) -> Result { + fn ensure_track_id(&mut self, index: usize) -> Result { if index >= self.items.len() { - return Err(anyhow!("Index out of bounds in OpenHomeQueue: {}", index)); + return Err(ControlPointError::OpenHomeError(format!("Index out of bounds in OpenHomeQueue: {}", index))); } if let Some(id) = self.track_ids.get(index).copied() { @@ -286,7 +300,7 @@ impl OpenHomeQueue { self.track_ids .get(index) .copied() - .ok_or_else(|| anyhow!("Failed to resolve OpenHome track id at index {}", index)) + .ok_or_else(|| ControlPointError::OpenHomeError(format!("Failed to resolve OpenHome track id at index {}", index))) } /// CASE 1: Replace queue while preserving the currently playing item as first. @@ -297,13 +311,13 @@ impl OpenHomeQueue { new_items: Vec, playing_idx: usize, playing_id: u32, - ) -> Result<()> { + ) -> Result<(), ControlPointError> { // Delete everything except the currently playing item // Using delete_id_if_exists() to handle cases where another control point // may have already modified the playlist for &track_id in self.track_ids.iter().rev() { if track_id != playing_id { - self.playlist.delete_id_if_exists(track_id)?; + self.playlist_client.delete_id_if_exists(track_id)?; } } @@ -317,7 +331,9 @@ impl OpenHomeQueue { let mut previous_id = playing_id; for item in new_items { let metadata = build_metadata_xml(&item); - let new_id = self.playlist.insert(previous_id, &item.uri, &metadata)?; + let new_id = self + .playlist_client + .insert(previous_id, &item.uri, &metadata)?; previous_id = new_id; rebuilt_ids.push(new_id); rebuilt_items.push(self.item_with_openhome_id(item, new_id)); @@ -343,10 +359,13 @@ impl OpenHomeQueue { new_items: Vec, pivot_idx_new: usize, pivot_id: u32, - ) -> Result<()> { + ) -> Result<(), ControlPointError> { // Find the pivot index in our current state - let pivot_idx = self.track_ids.iter().position(|&id| id == pivot_id) - .ok_or_else(|| anyhow!("Pivot track ID {} not found in playlist", pivot_id))?; + let pivot_idx = self + .track_ids + .iter() + .position(|&id| id == pivot_id) + .ok_or_else(|| ControlPointError::OpenHomeError(format!("Pivot track ID {} not found in playlist", pivot_id)))?; // Split current data at the pivot let old_before: Vec = self.items[..pivot_idx].to_vec(); @@ -373,7 +392,7 @@ impl OpenHomeQueue { "RENDERER OP: DeleteId({})", track_id ); - self.playlist.delete_id_if_exists(track_id)?; + self.playlist_client.delete_id_if_exists(track_id)?; } } @@ -387,7 +406,7 @@ impl OpenHomeQueue { "RENDERER OP: DeleteId({})", track_id ); - self.playlist.delete_id_if_exists(track_id)?; + self.playlist_client.delete_id_if_exists(track_id)?; } } @@ -422,7 +441,9 @@ impl OpenHomeQueue { ); } else { let metadata = build_metadata_xml(item); - let new_id = self.playlist.insert(previous_id, &item.uri, &metadata)?; + let new_id = self + .playlist_client + .insert(previous_id, &item.uri, &metadata)?; debug!( renderer = self.renderer_id.0.as_str(), after_id = previous_id, @@ -477,7 +498,9 @@ impl OpenHomeQueue { ); } else { let metadata = build_metadata_xml(item); - let new_id = self.playlist.insert(previous_id, &item.uri, &metadata)?; + let new_id = self + .playlist_client + .insert(previous_id, &item.uri, &metadata)?; debug!( renderer = self.renderer_id.0.as_str(), after_id = previous_id, @@ -500,11 +523,11 @@ impl OpenHomeQueue { // VERIFICATION: Check that pivot ID is preserved let final_pivot_id = self.track_ids.get(pivot_idx_new).copied(); if final_pivot_id != Some(pivot_id) { - return Err(anyhow!( + return Err(ControlPointError::OpenHomeError(format!( "CRITICAL BUG: Pivot ID changed from {} to {:?} during replace_queue_with_pivot!", pivot_id, final_pivot_id - )); + ))); } debug!( @@ -524,7 +547,7 @@ impl OpenHomeQueue { &mut self, items: Vec, current_index: Option, - ) -> Result<()> { + ) -> Result<(), ControlPointError> { let (keep_current, keep_desired) = lcs_flags(&self.items, &items); let items_to_keep = keep_current.iter().filter(|&&k| k).count(); @@ -547,7 +570,7 @@ impl OpenHomeQueue { renderer = self.renderer_id.0.as_str(), "Using delete_all() for complete replacement (more robust for live playlists)" ); - self.playlist.delete_all()?; + self.playlist_client.delete_all()?; self.track_ids.clear(); self.items.clear(); } else { @@ -557,7 +580,7 @@ impl OpenHomeQueue { let track_id = self.track_ids[idx]; // Use delete_id_if_exists() to handle cases where another control point // may have already modified the playlist - self.playlist.delete_id_if_exists(track_id)?; + self.playlist_client.delete_id_if_exists(track_id)?; self.track_ids.remove(idx); self.items.remove(idx); } @@ -573,9 +596,9 @@ impl OpenHomeQueue { for (idx, item) in items.into_iter().enumerate() { if keep_desired[idx] { if remaining_idx >= remaining_ids.len() { - return Err(anyhow!( + return Err(ControlPointError::OpenHomeError(format!( "OpenHome playlist refresh bookkeeping mismatch (kept entries underflow)" - )); + ))); } let existing_id = remaining_ids[remaining_idx]; remaining_idx += 1; @@ -584,7 +607,9 @@ impl OpenHomeQueue { rebuilt_items.push(self.item_with_openhome_id(item, existing_id)); } else { let metadata = build_metadata_xml(&item); - let new_id = self.playlist.insert(previous_id, &item.uri, &metadata)?; + let new_id = self + .playlist_client + .insert(previous_id, &item.uri, &metadata)?; previous_id = new_id; rebuilt_ids.push(new_id); rebuilt_items.push(self.item_with_openhome_id(item, new_id)); @@ -592,18 +617,28 @@ impl OpenHomeQueue { } if remaining_idx != remaining_ids.len() { - return Err(anyhow!( + return Err(ControlPointError::OpenHomeError(format!( "OpenHome playlist refresh bookkeeping mismatch (kept entries overflow)" - )); + ))); } - let previous_index = self - .current_index - .and_then(|idx| if idx < rebuilt_ids.len() { Some(idx) } else { None }); + let previous_index = self.current_index.and_then(|idx| { + if idx < rebuilt_ids.len() { + Some(idx) + } else { + None + } + }); let normalized = current_index .filter(|&i| i < rebuilt_ids.len()) .or(previous_index) - .or_else(|| if rebuilt_ids.is_empty() { None } else { Some(0) }); + .or_else(|| { + if rebuilt_ids.is_empty() { + None + } else { + Some(0) + } + }); self.items = rebuilt_items; self.track_ids = rebuilt_ids; self.current_index = normalized; @@ -715,19 +750,34 @@ fn lcs_flags(current: &[PlaybackItem], desired: &[PlaybackItem]) -> (Vec, } impl QueueBackend for OpenHomeQueue { - fn queue_snapshot(&self) -> Result { + fn queue_snapshot(&self) -> Result { + self.ensure_playlist_source_selected()?; + let entries = self.playlist_client.read_all_tracks()?; + let mut items = Vec::with_capacity(entries.len()); + + for entry in &entries { + items.push(self.playback_item_from_entry(entry)); + } + + // Get the currently playing track ID from the renderer (may be None if no track is playing) + let current_id = self.playlist_client.id().ok(); + + // Find the index of the current track in the playlist + let current_index = + current_id.and_then(|id| items.iter().position(|entry_id| entry_id.backend_id == id as usize)); + Ok(QueueSnapshot { items: self.items.clone(), current_index: self.current_index, }) } - fn set_index(&mut self, index: Option) -> Result<()> { + fn set_index(&mut self, index: Option) -> Result<(), ControlPointError> { let normalized = index.filter(|&i| i < self.items.len()); if let Some(idx) = normalized { let track_id = self.ensure_track_id(idx)?; self.ensure_playlist_source_selected()?; - self.playlist.play_id(track_id)?; + self.playlist_client.play_id(track_id)?; } self.current_index = normalized; Ok(()) @@ -737,10 +787,10 @@ impl QueueBackend for OpenHomeQueue { &mut self, items: Vec, current_index: Option, - ) -> Result<()> { + ) -> Result<(), ControlPointError> { self.ensure_playlist_source_selected()?; if items.is_empty() { - self.playlist.delete_all()?; + self.playlist_client.delete_all()?; self.items.clear(); self.track_ids.clear(); self.current_index = None; @@ -753,7 +803,7 @@ impl QueueBackend for OpenHomeQueue { self.refresh_from_openhome()?; // Get the currently playing track ID from the renderer - let playing_info = self.playlist.id().ok().and_then(|id| { + let playing_info = self.playlist_client.id().ok().and_then(|id| { self.track_ids .iter() .position(|&tid| tid == id) @@ -806,11 +856,11 @@ impl QueueBackend for OpenHomeQueue { Ok(()) } - fn get_item(&self, index: usize) -> Result> { + fn get_item(&self, index: usize) -> Result, ControlPointError> { Ok(self.items.get(index).cloned()) } - fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<()> { + fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> { if index >= self.items.len() { return Ok(()); } @@ -825,12 +875,14 @@ impl QueueBackend for OpenHomeQueue { // Use delete_id_if_exists() to handle cases where another control point // may have already modified the playlist - self.playlist.delete_id_if_exists(track_id)?; + self.playlist_client.delete_id_if_exists(track_id)?; let metadata = build_metadata_xml(&item); - let new_id = self.playlist.insert(before_id, &item.uri, &metadata)?; + let new_id = self + .playlist_client + .insert(before_id, &item.uri, &metadata)?; if self.current_index == Some(index) { - self.playlist.play_id(new_id)?; + self.playlist_client.play_id(new_id)?; } self.items[index] = self.item_with_openhome_id(item, new_id); @@ -839,7 +891,7 @@ impl QueueBackend for OpenHomeQueue { } /// Override enqueue_items to add items directly to the OpenHome playlist. - fn enqueue_items(&mut self, items: Vec, mode: EnqueueMode) -> Result<()> { + fn enqueue_items(&mut self, items: Vec, mode: EnqueueMode) -> Result<(), ControlPointError> { if items.is_empty() { return Ok(()); } diff --git a/pmocontrol/src/registry.rs b/pmocontrol/src/registry.rs index affea6f3..ec85bb48 100644 --- a/pmocontrol/src/registry.rs +++ b/pmocontrol/src/registry.rs @@ -1,65 +1,100 @@ use std::collections::HashMap; +use std::sync::Arc; use std::time::SystemTime; -use crate::avtransport_client::AvTransportClient; -use crate::connection_manager_client::ConnectionManagerClient; -use crate::media_server::{MediaServerInfo, ServerId}; -use crate::model::{RendererId, RendererInfo}; -use crate::rendering_control_client::RenderingControlClient; -use tracing::debug; +use tracing::warn; -#[derive(Clone, Debug)] -enum DeviceKey { - Renderer(RendererId), - Server(ServerId), +use crate::errors::ControlPointError; +use crate::media_server::MusicServer; +use crate::model::RendererInfo; +use crate::music_renderer::MusicRenderer; +use crate::{DeviceId, DeviceIdentity, DeviceOnline, UpnpMediaServer}; + +const DEFAULT_MAX_AGE: u32 = 1800; + +#[derive(Debug, Clone)] +pub struct DeviceItem { + music_rendrer : Option>, + music_server: Option>, } #[derive(Debug, Default)] pub struct DeviceRegistry { - renderers: HashMap, - servers: HashMap, - udn_index: HashMap, + devices: HashMap, + udn_index: HashMap, } -/// Read-only view / trait for registry access. -/// -/// Pour l’instant, on ne rajoute pas AVTransport ici, on se contente -/// d’ajouter les helpers dans `impl DeviceRegistry`. -pub trait DeviceRegistryRead { - fn list_renderers(&self) -> Vec; - fn list_servers(&self) -> Vec; - - fn get_renderer(&self, id: &RendererId) -> Option; - fn get_server(&self, id: &ServerId) -> Option; -} - -impl DeviceRegistryRead for DeviceRegistry { - fn list_renderers(&self) -> Vec { - self.renderers.values().cloned().collect() - } - - fn list_servers(&self) -> Vec { - self.servers.values().cloned().collect() - } - - fn get_renderer(&self, id: &RendererId) -> Option { - self.renderers.get(id).cloned() - } - - fn get_server(&self, id: &ServerId) -> Option { - self.servers.get(id).cloned() - } -} - -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum DeviceUpdate { - RendererOnline(RendererInfo), - RendererOfflineById(RendererId), - RendererOfflineByUdn(String), + OfflineById(DeviceId), + OfflineByUdn(String), +} - ServerOnline(MediaServerInfo), - ServerOfflineById(ServerId), - ServerOfflineByUdn(String), +impl DeviceItem { + pub fn as_music_renderer(&self) -> Result, ControlPointError> { + match self { + DeviceItem::MusicRenderer(renderer) => Ok(Arc::clone(renderer)), + _ => Err(ControlPointError::IsNotAMediaRender(format!("{:#?}", self))), + } + } + + pub fn is_a_music_renderer(&self) -> bool { + match self { + DeviceItem::MusicRenderer(_) => true, + _ => false, + } + } + + pub fn as_music_server(&self) -> Result, ControlPointError> { + match self { + DeviceItem::MusicServer(server) => Ok(Arc::clone(server)), + _ => Err(ControlPointError::IsNotAMediaServer(format!("{:#?}", self))), + } + } + + pub fn is_a_music_server(&self) -> bool { + match self { + DeviceItem::MusicServer(_) => true, + _ => false, + } + } +} + +impl DeviceOnline for DeviceItem { + fn is_online(&self) -> bool { + match self { + DeviceItem::MusicRenderer(r) => r.is_online(), + DeviceItem::MusicServer(s) => s.is_online(), + } + } + + fn last_seen(&self) -> SystemTime { + match self { + DeviceItem::MusicRenderer(r) => r.last_seen(), + DeviceItem::MusicServer(s) => s.last_seen(), + } + } + + fn has_been_seen_now(&self, max_age: u32) { + match self { + DeviceItem::MusicRenderer(r) => r.has_been_seen_now(max_age), + DeviceItem::MusicServer(s) => s.has_been_seen_now(max_age), + } + } + + fn mark_as_offline(&self) { + match self { + DeviceItem::MusicRenderer(r) => r.mark_as_offline(), + DeviceItem::MusicServer(s) => s.mark_as_offline(), + } + } + + fn max_age(&self) -> u32 { + match self { + DeviceItem::MusicRenderer(r) => r.max_age(), + DeviceItem::MusicServer(s) => s.max_age(), + } + } } impl DeviceRegistry { @@ -67,149 +102,99 @@ impl DeviceRegistry { Self::default() } + pub fn list_renderers(&self) -> Result>, ControlPointError> { + self.devices + .iter() + .filter(|(_, item)| item.is_a_music_renderer()) + .map(|(_, item)| item.as_music_renderer()) + .collect() + } + + pub fn list_servers(&self) -> Result>, ControlPointError> { + self.devices + .iter() + .filter(|(_, item)| item.is_a_music_server()) + .map(|(_, item)| item.as_music_server()) + .collect() + } + + pub fn get_renderer(&self, id: &DeviceId) -> Option> { + self.devices.get(id)?.as_music_renderer().ok() + } + + pub fn get_server(&self, id: &DeviceId) -> Option> { + self.devices.get(id)?.as_music_server().ok() + } + + pub fn push_renderer(&mut self, info: RendererInfo, max_age: u32) { + if let Some(existing) = self.devices.get(&info.id()) { + existing.has_been_seen_now(max_age); + } else { + // let renderer = MusicRenderer::from_renderer_info(info); + match MusicRenderer::from_renderer_info(info.clone()) { + Ok(renderer) => { + self.devices + .insert(info.id(), DeviceItem::MusicRenderer(renderer)); + self.udn_index.insert(info.udn().to_ascii_lowercase(), info.id()); + } + Err(err) => { + warn!("Failed to create renderer: {:#?}\n", err) + } + } + } + } + + pub fn push_server(&mut self, info: UpnpMediaServer, max_age: u32) { + if let Some(existing) = self.devices.get(&info.id()) { + existing.has_been_seen_now(max_age); + } else { + let server = MusicServer::Upnp(info.clone()); + self.devices + .insert(info.id(), DeviceItem::MusicServer(Arc::new(server))); + self.udn_index.insert(info.udn().to_ascii_lowercase(), info.id()); + } + } + + pub fn device_says_byebye(&mut self, udn: &str) { + if let Some(device) = self.get_device_by_udn(udn) { + device.mark_as_offline(); + } + } + pub fn apply_update(&mut self, update: DeviceUpdate) { match update { - DeviceUpdate::RendererOnline(info) => { - let udn = info.udn.to_ascii_lowercase(); - let id = info.id.clone(); - let mut info = info; - - info.online = true; - info.last_seen = SystemTime::now(); - - self.renderers.insert(id.clone(), info); - self.udn_index.insert(udn, DeviceKey::Renderer(id)); - } - DeviceUpdate::RendererOfflineById(id) => { - if let Some(info) = self.renderers.get_mut(&id) { - info.online = false; - info.last_seen = SystemTime::now(); + DeviceUpdate::OfflineById(id) => { + if let Some(renderer) = self.devices.get(&id) { + renderer.mark_as_offline(); } } - DeviceUpdate::RendererOfflineByUdn(udn) => { + DeviceUpdate::OfflineByUdn(udn) => { let lookup = udn.to_ascii_lowercase(); - if let Some(DeviceKey::Renderer(id)) = self.udn_index.get(&lookup) { - if let Some(info) = self.renderers.get_mut(id) { - info.online = false; - info.last_seen = SystemTime::now(); - } - } - } - DeviceUpdate::ServerOnline(info) => { - let udn = info.udn.to_ascii_lowercase(); - let id = info.id.clone(); - let mut info = info; - - info.online = true; - info.last_seen = SystemTime::now(); - - self.servers.insert(id.clone(), info); - self.udn_index.insert(udn, DeviceKey::Server(id)); - } - DeviceUpdate::ServerOfflineById(id) => { - if let Some(info) = self.servers.get_mut(&id) { - info.online = false; - info.last_seen = SystemTime::now(); - } - } - DeviceUpdate::ServerOfflineByUdn(udn) => { - let lookup = udn.to_ascii_lowercase(); - if let Some(DeviceKey::Server(id)) = self.udn_index.get(&lookup) { - if let Some(info) = self.servers.get_mut(id) { - info.online = false; - info.last_seen = SystemTime::now(); + if let Some(id) = self.udn_index.get(&lookup) { + if let Some(renderer) = self.devices.get(id) { + renderer.mark_as_offline(); } } } } } + pub fn get_device_by_udn(&self, udn: &str) -> Option { + let lookup = udn.to_ascii_lowercase(); + self.udn_index + .get(&lookup) + .and_then(|id| self.devices.get(id).cloned()) + } + /// Helper: get a renderer by UDN (case-insensitive, via udn_index). - pub fn get_renderer_by_udn(&self, udn: &str) -> Option { - let lookup = udn.to_ascii_lowercase(); - match self.udn_index.get(&lookup) { - Some(DeviceKey::Renderer(id)) => self.renderers.get(id).cloned(), - _ => None, - } + pub fn get_renderer_by_udn(&self, udn: &str) -> Option> { + self.get_device_by_udn(udn) + .and_then(|item| item.as_music_renderer().ok()) } /// Helper: get a server by UDN (case-insensitive, via udn_index). - pub fn get_server_by_udn(&self, udn: &str) -> Option { - let lookup = udn.to_ascii_lowercase(); - match self.udn_index.get(&lookup) { - Some(DeviceKey::Server(id)) => self.servers.get(id).cloned(), - _ => None, - } - } - - /// Construct an AvTransportClient for a given renderer id, if possible. - /// - /// Returns: - /// - Some(client) if the renderer exists AND has avtransport_* fields set - /// - None if renderer not found or no AVTransport service. - pub fn avtransport_client_for_renderer(&self, id: &RendererId) -> Option { - let info = self.renderers.get(id)?; - - let service_type = info.avtransport_service_type.as_ref()?; - let control_url = info.avtransport_control_url.as_ref()?; - - Some(AvTransportClient::new( - control_url.clone(), - service_type.clone(), - )) - } - - /// Construct an AvTransportClient for a given UDN, if possible. - pub fn avtransport_client_for_udn(&self, udn: &str) -> Option { - let info = self.get_renderer_by_udn(udn)?; - - let service_type = info.avtransport_service_type?; - let control_url = info.avtransport_control_url?; - - Some(AvTransportClient::new(control_url, service_type)) - } - - /// Construct a RenderingControlClient for a given renderer id, if possible. - pub fn rendering_control_client_for_renderer( - &self, - id: &RendererId, - ) -> Option { - let info = self.renderers.get(id)?; - - let service_type = info.rendering_control_service_type.as_ref()?; - let control_url = info.rendering_control_control_url.as_ref()?; - - Some(RenderingControlClient::new( - control_url.clone(), - service_type.clone(), - )) - } - - /// Construct a ConnectionManagerClient for a given renderer id, if possible. - pub fn connection_manager_client_for_renderer( - &self, - id: &RendererId, - ) -> Option { - let info = self.renderers.get(id)?; - - let service_type = info.connection_manager_service_type.as_ref()?; - let control_url = info.connection_manager_control_url.as_ref()?; - - Some(ConnectionManagerClient::new( - control_url.clone(), - service_type.clone(), - )) - } - - pub fn mark_renderer_supports_set_next(&mut self, id: &RendererId) { - if let Some(info) = self.renderers.get_mut(id) { - if !info.capabilities.has_avtransport_set_next { - info.capabilities.has_avtransport_set_next = true; - debug!( - renderer = id.0.as_str(), - "Renderer now marked as supporting AVTransport.SetNextAVTransportURI" - ); - } - } + pub fn get_server_by_udn(&self, udn: &str) -> Option> { + self.get_device_by_udn(udn) + .and_then(|item| item.as_music_server().ok()) } } diff --git a/pmocontrol/src/rendering_control_client.rs b/pmocontrol/src/rendering_control_client.rs index c9358b73..48225bba 100644 --- a/pmocontrol/src/rendering_control_client.rs +++ b/pmocontrol/src/rendering_control_client.rs @@ -1,7 +1,12 @@ -use crate::soap_client::{SoapCallResult, invoke_upnp_action}; +use crate::{ + errors::ControlPointError, + soap_client::{ + SoapCallResult, ensure_success, extract_child_text, find_child_with_suffix, + handle_action_response, invoke_upnp_action, parse_upnp_error, + }, +}; use anyhow::{Result, anyhow}; -use pmoupnp::soap::SoapEnvelope; -use xmltree::{Element, XMLNode}; +use tracing::debug; #[derive(Debug, Clone)] pub struct RenderingControlClient { @@ -18,7 +23,8 @@ impl RenderingControlClient { } /// RenderingControl:1 — GetVolume - pub fn get_volume(&self, instance_id: u32, channel: &str) -> Result { + pub fn get_volume(&self, instance_id: u32, channel: &str) -> Result { + debug!("GetVolume: {}", instance_id); let instance_id_str = instance_id.to_string(); let args = [ ("InstanceID", instance_id_str.as_str()), @@ -30,33 +36,39 @@ impl RenderingControlClient { ensure_success("GetVolume", &call_result)?; - let envelope = call_result - .envelope - .as_ref() - .ok_or_else(|| anyhow!("Missing SOAP envelope in GetVolume response"))?; + let envelope = call_result.envelope.as_ref().ok_or_else(|| { + ControlPointError::UpnpError(format!("Missing SOAP envelope in GetVolume response")) + })?; if let Some(err) = parse_upnp_error(envelope) { - return Err(anyhow!( + return Err(ControlPointError::UpnpError(format!( "GetVolume returned UPnP error {}: {} (HTTP status {})", - err.error_code, - err.error_description, - call_result.status - )); + err.error_code, err.error_description, call_result.status + ))); } let response = find_child_with_suffix(&envelope.body.content, "GetVolumeResponse") - .ok_or_else(|| anyhow!("Missing GetVolumeResponse element in SOAP body"))?; + .ok_or_else(|| { + ControlPointError::UpnpError(format!( + "Missing GetVolumeResponse element in SOAP body" + )) + })?; let text = extract_child_text(response, "CurrentVolume")?; - let volume = text - .parse::() - .map_err(|_| anyhow!("Invalid CurrentVolume value: {}", text))?; + let volume = text.parse::().map_err(|_| { + ControlPointError::UpnpError(format!("Invalid CurrentVolume value: {}", text)) + })?; Ok(volume) } /// RenderingControl:1 — SetVolume - pub fn set_volume(&self, instance_id: u32, channel: &str, volume: u16) -> Result<()> { + pub fn set_volume( + &self, + instance_id: u32, + channel: &str, + volume: u16, + ) -> Result<(), ControlPointError> { let instance_id_str = instance_id.to_string(); let volume_str = volume.to_string(); let args = [ @@ -72,7 +84,7 @@ impl RenderingControlClient { } /// RenderingControl:1 — GetMute - pub fn get_mute(&self, instance_id: u32, channel: &str) -> Result { + pub fn get_mute(&self, instance_id: u32, channel: &str) -> Result { let instance_id_str = instance_id.to_string(); let args = [ ("InstanceID", instance_id_str.as_str()), @@ -87,29 +99,29 @@ impl RenderingControlClient { let envelope = call_result .envelope .as_ref() - .ok_or_else(|| anyhow!("Missing SOAP envelope in GetMute response"))?; + .ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetMute response")))?; if let Some(err) = parse_upnp_error(envelope) { - return Err(anyhow!( + return Err(ControlPointError::UpnpError(format!( "GetMute returned UPnP error {}: {} (HTTP status {})", err.error_code, err.error_description, call_result.status - )); + ))); } let response = find_child_with_suffix(&envelope.body.content, "GetMuteResponse") - .ok_or_else(|| anyhow!("Missing GetMuteResponse element in SOAP body"))?; + .ok_or_else(|| ControlPointError::UpnpError(format!("Missing GetMuteResponse element in SOAP body")))?; let text = extract_child_text(response, "CurrentMute")?; let mute = match text.as_str() { "0" => false, "1" => true, _ => { - return Err(anyhow!( + return Err(ControlPointError::UpnpError(format!( "Invalid CurrentMute value: {} (expected 0 or 1)", text - )); + ))); } }; @@ -117,7 +129,12 @@ impl RenderingControlClient { } /// RenderingControl:1 — SetMute - pub fn set_mute(&self, instance_id: u32, channel: &str, mute: bool) -> Result<()> { + pub fn set_mute( + &self, + instance_id: u32, + channel: &str, + mute: bool, + ) -> Result<(), ControlPointError> { let instance_id_str = instance_id.to_string(); let mute_str = if mute { "1" } else { "0" }; let args = [ @@ -132,100 +149,3 @@ impl RenderingControlClient { handle_action_response("SetMute", &call_result) } } - -fn ensure_success(action: &str, call_result: &SoapCallResult) -> Result<()> { - if call_result.status.is_success() { - return Ok(()); - } - - if let Some(env) = &call_result.envelope { - if let Some(err) = parse_upnp_error(env) { - return Err(anyhow!( - "{action} failed with UPnP error {}: {} (HTTP status {})", - err.error_code, - err.error_description, - call_result.status - )); - } - } - - Err(anyhow!( - "{action} failed with HTTP status {} and body: {}", - call_result.status, - call_result.raw_body - )) -} - -fn handle_action_response(action: &str, call_result: &SoapCallResult) -> Result<()> { - ensure_success(action, call_result)?; - - if let Some(env) = &call_result.envelope { - if let Some(err) = parse_upnp_error(env) { - return Err(anyhow!( - "{action} returned UPnP error {}: {} (HTTP status {})", - err.error_code, - err.error_description, - call_result.status - )); - } - } - - Ok(()) -} - -#[derive(Debug, Clone)] -struct UpnpError { - pub error_code: u32, - pub error_description: String, -} - -fn parse_upnp_error(envelope: &SoapEnvelope) -> Option { - let fault = find_child_with_suffix(&envelope.body.content, "Fault")?; - let detail = find_child_with_suffix(fault, "detail")?; - let upnp_error = find_child_with_suffix(detail, "UPnPError")?; - - let error_code_elem = upnp_error.children.iter().find_map(|node| match node { - XMLNode::Element(elem) if elem.name.ends_with("errorCode") => Some(elem), - _ => None, - })?; - - let binding = error_code_elem.get_text()?; - let error_code_text = binding.trim(); - let error_code = error_code_text.parse::().ok()?; - - let error_description = upnp_error - .children - .iter() - .find_map(|node| match node { - XMLNode::Element(elem) if elem.name.ends_with("errorDescription") => { - elem.get_text().map(|t| t.trim().to_string()) - } - _ => None, - }) - .unwrap_or_else(|| String::from("")); - - Some(UpnpError { - error_code, - error_description, - }) -} - -fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> { - parent.children.iter().find_map(|node| match node { - XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem), - _ => None, - }) -} - -fn extract_child_text(parent: &Element, suffix: &str) -> Result { - let child = find_child_with_suffix(parent, suffix) - .ok_or_else(|| anyhow!("Missing {suffix} element in response"))?; - - let text = child - .get_text() - .map(|t| t.trim().to_string()) - .filter(|t| !t.is_empty()) - .ok_or_else(|| anyhow!("{suffix} element missing text in response"))?; - - Ok(text) -} diff --git a/pmocontrol/src/soap_client.rs b/pmocontrol/src/soap_client.rs index d289ccca..2e7ae7a2 100644 --- a/pmocontrol/src/soap_client.rs +++ b/pmocontrol/src/soap_client.rs @@ -1,10 +1,12 @@ use std::time::Duration; use anyhow::{Context, Result}; -use pmoupnp::soap::{build_soap_request, parse_soap_envelope, SoapEnvelope}; +use pmoupnp::soap::{SoapEnvelope, build_soap_request, parse_soap_envelope}; use tracing::{debug, trace, warn}; use ureq::Agent; +use crate::errors::ControlPointError; + /// Result of a SOAP call: /// - HTTP status code /// - raw XML body (always) @@ -34,7 +36,7 @@ pub fn invoke_upnp_action( service_type: &str, action: &str, args: &[(&str, &str)], -) -> Result { +) -> Result { invoke_upnp_action_with_timeout(control_url, service_type, action, args, None) } @@ -44,9 +46,10 @@ pub fn invoke_upnp_action_with_timeout( action: &str, args: &[(&str, &str)], timeout: Option, -) -> Result { - let body_xml = - build_soap_body(action, service_type, args).context("Failed to build SOAP request body")?; +) -> Result { + let body_xml = build_soap_body(action, service_type, args) + .context("Failed to build SOAP request body") + .map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?; let arg_log = summarize_args_for_log(args); debug!( @@ -77,7 +80,8 @@ pub fn invoke_upnp_action_with_timeout( .header("Content-Type", r#"text/xml; charset="utf-8""#) .header("SOAPAction", &soap_action_header) .send(body_xml) - .with_context(|| format!("HTTP error when sending SOAP request to {}", control_url))?; + .with_context(|| format!("HTTP error when sending SOAP request to {}", control_url)) + .map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?; let status = response.status(); debug!(status = status.as_u16(), "SOAP response received"); @@ -89,7 +93,8 @@ pub fn invoke_upnp_action_with_timeout( let raw_body = response .body_mut() .read_to_string() - .context("Failed to read SOAP response body")?; + .context("Failed to read SOAP response body") + .map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?; // 6. Try to parse SOAP envelope; non-fatal on failure let parsed_envelope = parse_soap_envelope(raw_body.as_bytes()).ok(); @@ -203,7 +208,7 @@ pub fn parse_upnp_error(envelope: &SoapEnvelope) -> Option { }) } -fn find_child_with_suffix<'a>( +pub fn find_child_with_suffix<'a>( parent: &'a xmltree::Element, suffix: &str, ) -> Option<&'a xmltree::Element> { @@ -213,6 +218,235 @@ fn find_child_with_suffix<'a>( }) } +pub fn find_child_with_local_name<'a>( + parent: &'a xmltree::Element, + local: &str, +) -> Option<&'a xmltree::Element> { + parent.children.iter().find_map(|node| { + if let xmltree::XMLNode::Element(elem) = node { + if elem.name == local || elem.name.ends_with(&format!(":{}", local)) { + return Some(elem); + } + } + None + }) +} + +/// Ensures the SOAP call succeeded, returns error if HTTP status or UPnP error occurred +pub fn ensure_success(action: &str, call_result: &SoapCallResult) -> Result<(), ControlPointError> { + if call_result.status.is_success() { + if let Some(env) = &call_result.envelope { + if let Some(err) = parse_upnp_error(env) { + return Err(ControlPointError::SoapUpnpParseError( + action.to_string(), + err.error_code, + err.error_description, + call_result.status.as_u16() as u32, + )); + } + } + return Ok(()); + } + + if let Some(env) = &call_result.envelope { + if let Some(err) = parse_upnp_error(env) { + return Err(ControlPointError::SoapUpnpParseError( + action.to_string(), + err.error_code, + err.error_description, + call_result.status.as_u16() as u32, + )); + } + } + Err(ControlPointError::SoapActionWrongBody( + action.to_string(), + call_result.status.as_u16() as u32, + call_result.raw_body.clone(), + )) +} + +/// Ensures success and returns the SOAP envelope (for openhome_client compatibility) +pub fn ensure_success_with_envelope<'a>( + action: &str, + call_result: &'a SoapCallResult, +) -> Result<&'a SoapEnvelope, ControlPointError> { + if !call_result.status.is_success() { + if let Some(env) = &call_result.envelope { + if let Some(err) = parse_upnp_error(env) { + return Err(ControlPointError::SoapUpnpParseError( + action.to_string(), + err.error_code, + err.error_description, + call_result.status.as_u16() as u32, + )); + } + } + + return Err(ControlPointError::SoapActionWrongBody( + action.to_string(), + call_result.status.as_u16() as u32, + call_result.raw_body.clone(), + )); + } + + let envelope = call_result + .envelope + .as_ref() + .ok_or_else(|| ControlPointError::SoapNoEnvelop(action.to_string()))?; + + if let Some(err) = parse_upnp_error(envelope) { + return Err(ControlPointError::SoapUpnpParseError( + action.to_string(), + err.error_code, + err.error_description, + call_result.status.as_u16() as u32, + )); + } + + Ok(envelope) +} + +/// Handles action response, ensuring success +pub fn handle_action_response( + action: &str, + call_result: &SoapCallResult, +) -> Result<(), ControlPointError> { + ensure_success(action, call_result) +} + +pub fn extract_child_text(parent: &xmltree::Element, suffix: &str) -> Result { + let child = find_child_with_suffix(parent, suffix) + .ok_or_else(|| ControlPointError::UpnpMissingReturnValue(suffix.to_string()))?; + + let text = child + .get_text() + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .ok_or_else(|| ControlPointError::UpnpMissingReturnValue(suffix.to_string()))?; + + Ok(text) +} + +pub fn extract_child_text_allow_empty(parent: &xmltree::Element, suffix: &str) -> Result { + let child = find_child_with_suffix(parent, suffix) + .ok_or_else(|| ControlPointError::UpnpMissingReturnValue(suffix.to_string()))?; + + let text = child + .get_text() + .map(|t| t.trim().to_string()) + .unwrap_or_default(); + + Ok(text) +} + +pub fn extract_child_text_optional( + parent: &xmltree::Element, + suffix: &str, +) -> Result> { + if let Some(child) = find_child_with_suffix(parent, suffix) { + let text = child + .get_text() + .map(|t| t.trim().to_string()) + .unwrap_or_default(); + Ok(Some(text)) + } else { + Ok(None) + } +} + +pub fn extract_child_text_any( + parent: &xmltree::Element, + suffixes: &[&str], +) -> Result { + for suffix in suffixes { + if let Ok(text) = extract_child_text(parent, suffix) { + return Ok(text); + } + } + Err(ControlPointError::UpnpMissingReturnValue( + suffixes.join(" or "), + )) +} + +pub fn extract_child_text_local(parent: &xmltree::Element, local: &str) -> Result { + let child = find_child_with_local_name(parent, local) + .ok_or_else(|| ControlPointError::SoapAction(format!("Missing {local} element in response")))?; + let text = child + .get_text() + .map(|t| t.trim().to_string()) + .ok_or_else(|| ControlPointError::SoapAction(format!("{local} element missing text in response")))?; + Ok(text) +} + +pub fn extract_child_text_optional_local( + parent: &xmltree::Element, + local: &str, +) -> Result, ControlPointError> { + if let Some(child) = find_child_with_local_name(parent, local) { + let text = child + .get_text() + .map(|t| t.trim().to_string()) + .unwrap_or_default(); + Ok(Some(text)) + } else { + Ok(None) + } +} + +pub fn parse_visible_flag(value: &str) -> bool { + let trimmed = value.trim(); + if trimmed.eq_ignore_ascii_case("true") { + return true; + } + if trimmed.eq_ignore_ascii_case("false") { + return false; + } + trimmed == "1" +} + +pub fn parse_bool(value: &str) -> bool { + value.trim() == "1" +} + + +pub fn decode_base64(input: &str) -> Result, ControlPointError> { + fn value(byte: u8) -> Option { + match byte { + b'A'..=b'Z' => Some(byte - b'A'), + b'a'..=b'z' => Some(byte - b'a' + 26), + b'0'..=b'9' => Some(byte - b'0' + 52), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + } + + let mut output = Vec::new(); + let mut buffer: u32 = 0; + let mut bits_collected: u8 = 0; + + for byte in input.bytes() { + if byte == b'=' { + break; + } + if byte == b'\r' || byte == b'\n' || byte == b' ' || byte == b'\t' { + continue; + } + let val = + value(byte).ok_or_else(|| ControlPointError::ParsingError(format!("Invalid base64 character '{}'", byte as char)))?; + buffer = (buffer << 6) | (val as u32); + bits_collected += 6; + if bits_collected >= 8 { + bits_collected -= 8; + let out = (buffer >> bits_collected) & 0xFF; + output.push(out as u8); + } + } + + Ok(output) +} + + #[cfg(test)] mod tests { use super::build_soap_body; diff --git a/pmocontrol/src/sse.rs b/pmocontrol/src/sse.rs index d07b9146..57f84278 100644 --- a/pmocontrol/src/sse.rs +++ b/pmocontrol/src/sse.rs @@ -179,13 +179,13 @@ pub async fn renderer_events_sse( }; for info in initial_renderers { - if info.online { + if info.is_online() { let timestamp = chrono::Utc::now(); let payload = RendererEventPayload::Online { - renderer_id: info.id.0.clone(), - friendly_name: info.friendly_name.clone(), - model_name: info.model_name.clone(), - manufacturer: info.manufacturer.clone(), + renderer_id: info.id().0, + friendly_name: info.friendly_name().to_string(), + model_name: info. ().to_string(), + manufacturer: info.manufacturer().to_string(), timestamp, }; diff --git a/pmocontrol/src/upnp_renderer.rs b/pmocontrol/src/upnp_renderer.rs index df8e880e..c460d160 100644 --- a/pmocontrol/src/upnp_renderer.rs +++ b/pmocontrol/src/upnp_renderer.rs @@ -4,32 +4,24 @@ use anyhow::{Result, anyhow}; use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus}; use crate::connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo}; -use crate::music_renderer::op_not_supported; +use crate::errors::ControlPointError; use crate::rendering_control_client::RenderingControlClient; use crate::{ - AvTransportClient, DeviceRegistry, PlaybackPosition, PlaybackState, PositionInfo, RendererId, - RendererInfo, TransportControl, VolumeControl, + AvTransportClient, DeviceRegistry, PlaybackPosition, PlaybackState, PositionInfo, RendererInfo, + TransportControl, VolumeControl, }; /// High-level handle representing a renderer and its optional AVTransport client. #[derive(Clone, Debug)] pub struct UpnpRenderer { - pub info: RendererInfo, - registry: Arc>, + // registry: Arc>, avtransport: Option, rendering_control: Option, connection_manager: Option, + has_avtransport_set_next: bool, } impl UpnpRenderer { - pub fn id(&self) -> &RendererId { - &self.info.id - } - - pub fn friendly_name(&self) -> &str { - &self.info.friendly_name - } - pub fn has_avtransport(&self) -> bool { self.avtransport.is_some() } @@ -42,225 +34,158 @@ impl UpnpRenderer { self.connection_manager.is_some() } - /// Returns true if this renderer is known to support SetNextAVTransportURI. - pub fn supports_set_next(&self) -> bool { - self.info.capabilities.supports_set_next() + pub fn avtransport(&self) -> Result<&AvTransportClient, ControlPointError> { + self.avtransport.as_ref().ok_or_else(|| { + ControlPointError::upnp_operation_not_supported("AvTransport", "Renderer") + }) } - pub fn avtransport(&self) -> Result<&AvTransportClient> { - self.avtransport - .as_ref() - .ok_or_else(|| anyhow!("Renderer has no AVTransport service")) + pub fn rendering_control(&self) -> Result<&RenderingControlClient, ControlPointError> { + self.rendering_control.as_ref().ok_or_else(|| { + ControlPointError::upnp_operation_not_supported("RenderingControl", "Renderer") + }) } - pub fn rendering_control(&self) -> Result<&RenderingControlClient> { - self.rendering_control - .as_ref() - .ok_or_else(|| anyhow!("Renderer has no RenderingControl service")) - } - - pub fn connection_manager(&self) -> Result<&ConnectionManagerClient> { + pub fn connection_manager(&self) -> Result<&ConnectionManagerClient, ControlPointError> { self.connection_manager .as_ref() - .ok_or_else(|| anyhow!("Renderer has no ConnectionManager service")) + .ok_or_else(|| ControlPointError::upnp_operation_not_supported("ConnectionManager", "Renderer")) } - pub fn play_uri(&self, uri: &str, meta: &str) -> Result<()> { + pub fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { let avt = self.avtransport()?; avt.set_av_transport_uri(uri, meta)?; avt.play(0, "1") } - /// Best-effort attempt to configure the next URI via AVTransport SetNextAVTransportURI. - pub fn set_next_uri(&self, next_uri: &str, next_meta: &str) -> Result<()> { - if !self.info.capabilities.has_avtransport { - return Err(op_not_supported("SetNextAVTransportURI", "AVTransport")); - } + // /// Best-effort attempt to configure the next URI via AVTransport SetNextAVTransportURI. + // pub fn set_next_uri(&self, next_uri: &str, next_meta: &str) -> Result<()> { + // if !self.has_avtransport_set_next { + // return Err(op_not_supported("SetNextAVTransportURI", "AVTransport")); + // } - let client = self.avtransport()?; - let result = client.set_next_av_transport_uri(next_uri, next_meta); + // let client = self.avtransport()?; + // let result = client.set_next_av_transport_uri(next_uri, next_meta); - if result.is_ok() { - let mut reg = self.registry.write().unwrap(); - reg.mark_renderer_supports_set_next(&self.info.id); - } + // if result.is_ok() { + // let mut reg = self.registry.write().unwrap(); + // reg.mark_renderer_supports_set_next(&self.info.id); + // } - result - } + // result + // } - pub fn pause(&self) -> Result<()> { + pub fn pause(&self) -> Result<(), ControlPointError> { let avt = self.avtransport()?; avt.pause(0) } - pub fn stop(&self) -> Result<()> { + pub fn stop(&self) -> Result<(), ControlPointError> { let avt = self.avtransport()?; avt.stop(0) } - pub fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { + pub fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { let avt = self.avtransport()?; avt.seek(0, "REL_TIME", hhmmss) } - pub fn get_master_volume(&self) -> Result { + pub fn get_master_volume(&self) -> Result { let rc = self.rendering_control()?; rc.get_volume(0, "Master") } - pub fn set_master_volume(&self, volume: u16) -> Result<()> { + pub fn set_master_volume(&self, volume: u16) -> Result<(), ControlPointError> { let rc = self.rendering_control()?; rc.set_volume(0, "Master", volume) } - pub fn get_master_mute(&self) -> Result { + pub fn get_master_mute(&self) -> Result { let rc = self.rendering_control()?; rc.get_mute(0, "Master") } - pub fn set_master_mute(&self, mute: bool) -> Result<()> { + pub fn set_master_mute(&self, mute: bool) -> Result<(), ControlPointError> { let rc = self.rendering_control()?; rc.set_mute(0, "Master", mute) } - pub fn protocol_info(&self) -> Result { + pub fn protocol_info(&self) -> Result { let cm = self.connection_manager()?; cm.get_protocol_info() } - pub fn connection_ids(&self) -> Result> { + pub fn connection_ids(&self) -> Result, ControlPointError> { let cm = self.connection_manager()?; cm.get_current_connection_ids() } - pub fn connection_info(&self, connection_id: i32) -> Result { + pub fn connection_info(&self, connection_id: i32) -> Result { let cm = self.connection_manager()?; cm.get_current_connection_info(connection_id) } - pub fn from_registry(info: RendererInfo, registry: &Arc>) -> Self { - let (avtransport, rendering_control, connection_manager) = { - let reg = registry.read().unwrap(); - ( - reg.avtransport_client_for_renderer(&info.id), - reg.rendering_control_client_for_renderer(&info.id), - reg.connection_manager_client_for_renderer(&info.id), - ) + pub fn from_info(info: &RendererInfo) -> Self { + let avtransport = match ( + info.avtransport_control_url(), + info.avtransport_service_type(), + ) { + (Some(url), Some(service)) => Some(AvTransportClient::new(url, service)), + _ => None, }; + + let rendering_control = match ( + info.rendering_control_control_url(), + info.rendering_control_service_type(), + ) { + (Some(url), Some(service)) => Some(RenderingControlClient::new(url, service)), + _ => None, + }; + + let connection_manager = match ( + info.connection_manager_control_url(), + info.connection_manager_service_type(), + ) { + (Some(url), Some(service)) => Some(ConnectionManagerClient::new(url, service)), + _ => None, + }; + Self { - info, - registry: Arc::clone(registry), + // registry: Arc::clone(registry), avtransport, rendering_control, connection_manager, + has_avtransport_set_next: info.capabilities().has_avtransport_set_next(), } } } -#[cfg(test)] -mod tests { - use super::*; - use crate::model::{RendererCapabilities, RendererProtocol}; - use crate::registry::{DeviceRegistry, DeviceUpdate}; - use std::sync::{Arc, RwLock}; - use std::time::SystemTime; - - fn renderer_info(id_suffix: &str, with_avtransport: bool) -> RendererInfo { - RendererInfo { - id: RendererId(format!("renderer-{id_suffix}")), - udn: format!("uuid:renderer-{id_suffix}"), - friendly_name: format!("Renderer {id_suffix}"), - model_name: "Model".into(), - manufacturer: "Manufacturer".into(), - protocol: RendererProtocol::UpnpAvOnly, - capabilities: RendererCapabilities { - has_avtransport: with_avtransport, - ..RendererCapabilities::default() - }, - location: "http://127.0.0.1/device.xml".into(), - server_header: "TestServer/1.0".into(), - online: true, - last_seen: SystemTime::now(), - max_age: 1800, - avtransport_service_type: with_avtransport - .then(|| "urn:schemas-upnp-org:service:AVTransport:1".into()), - avtransport_control_url: with_avtransport - .then(|| "http://127.0.0.1/avtransport".into()), - rendering_control_service_type: None, - rendering_control_control_url: None, - connection_manager_service_type: None, - connection_manager_control_url: None, - oh_playlist_service_type: None, - oh_playlist_control_url: None, - oh_playlist_event_sub_url: None, - oh_info_service_type: None, - oh_info_control_url: None, - oh_info_event_sub_url: None, - oh_time_service_type: None, - oh_time_control_url: None, - oh_time_event_sub_url: None, - oh_volume_service_type: None, - oh_volume_control_url: None, - oh_radio_service_type: None, - oh_radio_control_url: None, - oh_product_service_type: None, - oh_product_control_url: None, - } - } - - fn registry_with_renderer(info: RendererInfo) -> Arc> { - let mut registry = DeviceRegistry::new(); - registry.apply_update(DeviceUpdate::RendererOnline(info)); - Arc::new(RwLock::new(registry)) - } - - #[test] - fn renderer_without_avtransport() { - let info = renderer_info("no-avt", false); - let registry = registry_with_renderer(info.clone()); - let renderer = UpnpRenderer::from_registry(info, ®istry); - - assert_eq!(renderer.has_avtransport(), false); - assert_eq!(renderer.id().0, "renderer-no-avt"); - } - - #[test] - fn renderer_with_avtransport() { - let info = renderer_info("with-avt", true); - let registry = registry_with_renderer(info.clone()); - let renderer = UpnpRenderer::from_registry(info, ®istry); - - assert!(renderer.has_avtransport()); - assert_eq!(renderer.friendly_name(), "Renderer with-avt"); - } -} - /// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`]. /// /// Cette impl se base sur AVTransport (InstanceID = 0). impl TransportControl for UpnpRenderer { - fn play_uri(&self, uri: &str, meta: &str) -> Result<()> { + fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { let avt = self.avtransport()?; avt.set_av_transport_uri(uri, meta)?; avt.play(0, "1") } - fn play(&self) -> Result<()> { + fn play(&self) -> Result<(), ControlPointError> { let avt = self.avtransport()?; avt.play(0, "1") } - fn pause(&self) -> Result<()> { + fn pause(&self) -> Result<(), ControlPointError> { let avt = self.avtransport()?; avt.pause(0) } - fn stop(&self) -> Result<()> { + fn stop(&self) -> Result<(), ControlPointError> { let avt = self.avtransport()?; avt.stop(0) } - fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { + fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { let avt = self.avtransport()?; avt.seek(0, "REL_TIME", hhmmss) } @@ -270,19 +195,19 @@ impl TransportControl for UpnpRenderer { /// /// Cette impl se base sur le channel "Master" (InstanceID = 0). impl VolumeControl for UpnpRenderer { - fn volume(&self) -> Result { + fn volume(&self) -> Result { self.get_master_volume() } - fn set_volume(&self, v: u16) -> Result<()> { + fn set_volume(&self, v: u16) -> Result<(), ControlPointError> { self.set_master_volume(v) } - fn mute(&self) -> Result { + fn mute(&self) -> Result { self.get_master_mute() } - fn set_mute(&self, m: bool) -> Result<()> { + fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { self.set_master_mute(m) } } @@ -291,7 +216,7 @@ impl VolumeControl for UpnpRenderer { /// /// Utilise AVTransport::GetTransportInfo(InstanceID=0). impl PlaybackStatus for UpnpRenderer { - fn playback_state(&self) -> Result { + fn playback_state(&self) -> Result { let avt = self.avtransport()?; let info = avt.get_transport_info(0)?; Ok(PlaybackState::from_upnp_state( @@ -301,7 +226,7 @@ impl PlaybackStatus for UpnpRenderer { } impl PlaybackPosition for UpnpRenderer { - fn playback_position(&self) -> Result { + fn playback_position(&self) -> Result { let avt = self.avtransport()?; let raw: PositionInfo = avt.get_position_info(0)?; @@ -315,3 +240,78 @@ impl PlaybackPosition for UpnpRenderer { }) } } + +// #[cfg(test)] +// mod tests { +// use super::*; +// use crate::model::{RendererCapabilities, RendererProtocol}; +// use crate::registry::{DeviceRegistry, DeviceUpdate}; +// use std::sync::{Arc, RwLock}; +// use std::time::SystemTime; + +// // fn renderer_info(id_suffix: &str, with_avtransport: bool) -> RendererInfo { +// // RendererInfo { +// // id: RendererId(format!("renderer-{id_suffix}")), +// // udn: format!("uuid:renderer-{id_suffix}"), +// // friendly_name: format!("Renderer {id_suffix}"), +// // model_name: "Model".into(), +// // manufacturer: "Manufacturer".into(), +// // protocol: RendererProtocol::UpnpAvOnly, +// // capabilities: RendererCapabilities { +// // has_avtransport: with_avtransport, +// // ..RendererCapabilities::default() +// // }, +// // location: "http://127.0.0.1/device.xml".into(), +// // server_header: "TestServer/1.0".into(), +// // avtransport_service_type: with_avtransport +// // .then(|| "urn:schemas-upnp-org:service:AVTransport:1".into()), +// // avtransport_control_url: with_avtransport +// // .then(|| "http://127.0.0.1/avtransport".into()), +// // rendering_control_service_type: None, +// // rendering_control_control_url: None, +// // connection_manager_service_type: None, +// // connection_manager_control_url: None, +// // oh_playlist_service_type: None, +// // oh_playlist_control_url: None, +// // oh_playlist_event_sub_url: None, +// // oh_info_service_type: None, +// // oh_info_control_url: None, +// // oh_info_event_sub_url: None, +// // oh_time_service_type: None, +// // oh_time_control_url: None, +// // oh_time_event_sub_url: None, +// // oh_volume_service_type: None, +// // oh_volume_control_url: None, +// // oh_radio_service_type: None, +// // oh_radio_control_url: None, +// // oh_product_service_type: None, +// // oh_product_control_url: None, +// // } +// // } + +// fn registry_with_renderer(info: RendererInfo) -> Arc> { +// let mut registry = DeviceRegistry::new(); +// registry.apply_update(DeviceUpdate::RendererOnline(info)); +// Arc::new(RwLock::new(registry)) +// } + +// #[test] +// fn renderer_without_avtransport() { +// let info = renderer_info("no-avt", false); +// let registry = registry_with_renderer(info.clone()); +// let renderer = UpnpRenderer::from_registry(info, ®istry); + +// assert_eq!(renderer.has_avtransport(), false); +// assert_eq!(renderer.id().0, "renderer-no-avt"); +// } + +// #[test] +// fn renderer_with_avtransport() { +// let info = renderer_info("with-avt", true); +// let registry = registry_with_renderer(info.clone()); +// let renderer = UpnpRenderer::from_registry(info, ®istry); + +// assert!(renderer.has_avtransport()); +// assert_eq!(renderer.friendly_name(), "Renderer with-avt"); +// } +// } diff --git a/pmodidl/src/lib.rs b/pmodidl/src/lib.rs index 20864427..c6963cd8 100644 --- a/pmodidl/src/lib.rs +++ b/pmodidl/src/lib.rs @@ -5,7 +5,7 @@ use bevy_reflect::Reflect; use pmoutils::ToXmlElement; use serde::{Deserialize, Serialize}; -use std::borrow::Cow; +use std::{borrow::Cow, time::Instant}; use std::collections::HashSet; use std::fmt::Write; use std::io::Cursor; @@ -36,7 +36,7 @@ pub struct ParsedMetadata { /// Timestamp du parsing (exclu de la réflexion car SystemTime n'implémente pas Reflect) #[reflect(ignore)] #[serde(skip_serializing_if = "Option::is_none")] - pub parsed_at: Option, + pub parsed_at: Option, } impl ParsedMetadata { @@ -44,7 +44,7 @@ impl ParsedMetadata { Self { format: format.into(), data, - parsed_at: Some(std::time::SystemTime::now()), + parsed_at: Some(Instant::now()), } } diff --git a/pmometadata/src/lib.rs b/pmometadata/src/lib.rs index 7243a45b..9ebbe747 100755 --- a/pmometadata/src/lib.rs +++ b/pmometadata/src/lib.rs @@ -840,7 +840,7 @@ impl TrackMetadata for MemoryTrackMetadata { } async fn touch(&mut self) -> MetadataResult<()> { - self.updated_at = Some(SystemTime::now()); + self.updated_at = Some()); Ok(Some(())) } } diff --git a/pmoparadise/ARCHITECTURE.md b/pmoparadise/ARCHITECTURE.md index f7dd4f67..6f9b693e 100644 --- a/pmoparadise/ARCHITECTURE.md +++ b/pmoparadise/ARCHITECTURE.md @@ -153,7 +153,7 @@ Chaque chanson contient : Pour éviter de télécharger des chansons déjà terminées : ```rust -let now_ms = SystemTime::now() +let now_ms = Instant::now() .duration_since(UNIX_EPOCH)? .as_millis() as u64; diff --git a/pmoupnp/Cargo.toml b/pmoupnp/Cargo.toml index 9b6f3be3..00bb1bfb 100644 --- a/pmoupnp/Cargo.toml +++ b/pmoupnp/Cargo.toml @@ -13,6 +13,8 @@ pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"] } pmoplaylist = { path = "../pmoplaylist", optional = true, features = ["pmoserver"] } pmocache = { path = "../pmocache" } +async-trait = "0.1" +async-recursion = "1.1" url = "2.5.7" uuid = "1.18.1" hex = "0.4.3" diff --git a/pmoupnp/src/devices/device_instance.rs b/pmoupnp/src/devices/device_instance.rs index 5e626853..c801ff0f 100644 --- a/pmoupnp/src/devices/device_instance.rs +++ b/pmoupnp/src/devices/device_instance.rs @@ -280,44 +280,40 @@ impl DeviceInstance { } /// Enregistre toutes les URLs du device et de ses services dans le serveur. - pub fn register_urls<'a>( - &'a self, - server: &'a mut pmoserver::Server, - ) -> std::pin::Pin> + 'a>> { - Box::pin(async move { - info!( - "✅ Device description for {} available at: {}{}", - self.get_name(), - self.base_url(), - self.description_route(), - ); + #[async_recursion::async_recursion] + pub async fn register_urls(&self, server: &mut pmoserver::Server) -> Result<(), DeviceError> { + info!( + "✅ Device description for {} available at: {}{}", + self.get_name(), + self.base_url(), + self.description_route(), + ); - // Handler pour la description du device - let instance_desc = self.clone(); - server - .add_handler(&self.description_route(), move || { - let instance = instance_desc.clone(); - async move { instance.description_handler().await } - }) - .await; + // Handler pour la description du device + let instance_desc = self.clone(); + server + .add_handler(&self.description_route(), move || { + let instance = instance_desc.clone(); + async move { instance.description_handler().await } + }) + .await; - // Enregistrer les services - for service in self.services() { - service - .register_urls(server) - .await - .map_err(|e| DeviceError::UrlRegistrationError(e.to_string()))?; - // Start the periodic notifier so buffered state changes are flushed to subscribers. - let _ = service.start_notifier(DEFAULT_NOTIFY_INTERVAL); - } + // Enregistrer les services + for service in self.services() { + service + .register_urls(server) + .await + .map_err(|e| DeviceError::UrlRegistrationError(e.to_string()))?; + // Start the periodic notifier so buffered state changes are flushed to subscribers. + let _ = service.start_notifier(DEFAULT_NOTIFY_INTERVAL); + } - // Enregistrer les sous-devices - for device in self.devices() { - device.register_urls(server).await?; - } + // Enregistrer les sous-devices + for device in self.devices() { + device.register_urls(server).await?; + } - Ok(()) - }) + Ok(()) } /// Génère l'élément XML de description du device. diff --git a/pmoupnp/src/services/service_instance.rs b/pmoupnp/src/services/service_instance.rs index efc8db11..94f99122 100644 --- a/pmoupnp/src/services/service_instance.rs +++ b/pmoupnp/src/services/service_instance.rs @@ -545,16 +545,18 @@ impl ServiceInstance { /// /// Retourne une erreur si l'enregistrement des routes échoue. pub async fn register_urls(&self, server: &mut pmoserver::Server) -> Result<(), ServiceError> { - let device = self.device.read().unwrap(); - let device_name = device - .as_ref() - .map(|d| d.get_name().clone()) - .unwrap_or_else(|| "unknown".to_string()); - let server_url = device - .as_ref() - .map(|d| d.base_url().to_string()) - .unwrap_or_default(); - drop(device); + let (device_name, server_url) = { + let device = self.device.read().unwrap(); + let device_name = device + .as_ref() + .map(|d| d.get_name().clone()) + .unwrap_or_else(|| "unknown".to_string()); + let server_url = device + .as_ref() + .map(|d| d.base_url().to_string()) + .unwrap_or_default(); + (device_name, server_url) + }; info!( "✅ Service description for {}:{} available at : {}{}", diff --git a/pmoupnp/src/upnp_server.rs b/pmoupnp/src/upnp_server.rs index 64876e9a..9ebe2d0f 100644 --- a/pmoupnp/src/upnp_server.rs +++ b/pmoupnp/src/upnp_server.rs @@ -81,6 +81,7 @@ static SSDP_SERVER: Lazy>> = Lazy::new(|| RwLock::new( /// // Introspection via le trait UpnpServer /// let devices = server.device_registry().list_devices(); /// ``` +#[async_trait::async_trait] pub trait UpnpServerExt { // ========= Device Management (existant) ========= @@ -226,6 +227,7 @@ pub trait UpnpServerExt { } // Implémentation du trait UpnpServer pour pmoserver::Server +#[async_trait::async_trait] impl UpnpServerExt for Server { async fn register_device( &mut self, @@ -308,7 +310,6 @@ impl UpnpServerExt for Server { use pmoaudiocache::new_cache; use pmocache::pmoserver_ext::{create_api_router, create_file_router}; - let base_url = self.info().base_url.clone(); let cache = Arc::new(new_cache(cache_dir, limit)?); // Routes de fichiers pour servir les pistes FLAC From a4301140d8e29d5ac5176c9b95ac0c5648af4fa8 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Tue, 30 Dec 2025 16:12:30 +0100 Subject: [PATCH 2/4] =?UTF-8?q?Migration=20vers=20une=20forme=20unifi?= =?UTF-8?q?=C3=A9e=20des=20cargos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 34 +++----- Cargo.toml | 32 ++++++++ PMOMusic/Cargo.toml | 4 +- PMOMusic/Cargo.toml.backup | 25 ++++++ pmoapp/Cargo.toml.backup | 15 ++++ pmoaudio-ext/Cargo.toml | 6 +- pmoaudio/Cargo.toml | 8 +- pmoaudio/Cargo.toml.backup | 29 +++++++ pmoaudiocache/Cargo.toml | 16 ++-- pmoaudiocache/Cargo.toml.backup | 57 +++++++++++++ pmoaudiocache/src/track_metadata.rs | 2 +- pmocache/Cargo.toml | 14 ++-- pmocache/Cargo.toml.backup | 53 ++++++++++++ pmoconfig/Cargo.toml | 12 +-- pmoconfig/Cargo.toml.backup | 32 ++++++++ pmocontrol/Cargo.toml | 18 ++--- pmocontrol/Cargo.toml.backup | 48 +++++++++++ pmocovers/Cargo.toml | 10 +-- pmocovers/Cargo.toml.backup | 51 ++++++++++++ pmodidl/Cargo.toml | 4 +- pmodidl/Cargo.toml.backup | 14 ++++ pmodidl/src/lib.rs | 5 +- pmoflac/Cargo.toml | 8 +- pmoflac/Cargo.toml.backup | 29 +++++++ pmomediarenderer/Cargo.toml | 2 +- pmomediarenderer/Cargo.toml.backup | 13 +++ pmomediaserver/Cargo.toml | 14 ++-- pmomediaserver/Cargo.toml.backup | 54 +++++++++++++ pmometadata/Cargo.toml | 4 +- pmometadata/Cargo.toml.backup | 13 +++ pmometadata/src/lib.rs | 2 +- pmoparadise/Cargo.toml | 18 ++--- pmoparadise/Cargo.toml.backup | 121 ++++++++++++++++++++++++++++ pmoplaylist/Cargo.toml | 14 ++-- pmoplaylist/Cargo.toml.backup | 46 +++++++++++ pmoqobuz/Cargo.toml | 16 ++-- pmoqobuz/Cargo.toml.backup | 91 +++++++++++++++++++++ pmoserver/Cargo.toml | 12 +-- pmoserver/Cargo.toml.backup | 26 ++++++ pmosource/Cargo.toml | 12 +-- pmosource/Cargo.toml.backup | 54 +++++++++++++ pmoupnp/Cargo.toml | 18 ++--- pmoupnp/Cargo.toml.backup | 50 ++++++++++++ pmoutils/Cargo.toml | 2 +- pmoutils/Cargo.toml.backup | 13 +++ 45 files changed, 989 insertions(+), 132 deletions(-) create mode 100644 PMOMusic/Cargo.toml.backup create mode 100644 pmoapp/Cargo.toml.backup create mode 100755 pmoaudio/Cargo.toml.backup create mode 100644 pmoaudiocache/Cargo.toml.backup create mode 100644 pmocache/Cargo.toml.backup create mode 100644 pmoconfig/Cargo.toml.backup create mode 100644 pmocontrol/Cargo.toml.backup create mode 100644 pmocovers/Cargo.toml.backup create mode 100644 pmodidl/Cargo.toml.backup create mode 100755 pmoflac/Cargo.toml.backup create mode 100644 pmomediarenderer/Cargo.toml.backup create mode 100644 pmomediaserver/Cargo.toml.backup create mode 100644 pmometadata/Cargo.toml.backup create mode 100644 pmoparadise/Cargo.toml.backup create mode 100644 pmoplaylist/Cargo.toml.backup create mode 100644 pmoqobuz/Cargo.toml.backup create mode 100644 pmoserver/Cargo.toml.backup create mode 100644 pmosource/Cargo.toml.backup create mode 100644 pmoupnp/Cargo.toml.backup create mode 100644 pmoutils/Cargo.toml.backup diff --git a/Cargo.lock b/Cargo.lock index 4071b75d..2fb78f6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3642,7 +3642,7 @@ dependencies = [ "pmoflac", "pmometadata", "pmoserver", - "quick-xml 0.37.5", + "quick-xml", "rusqlite", "serde", "serde_json", @@ -3721,7 +3721,7 @@ dependencies = [ "pmodidl", "pmoserver", "pmoupnp", - "quick-xml 0.38.4", + "quick-xml", "ratatui", "rust_cast", "rustls", @@ -3769,7 +3769,7 @@ dependencies = [ "bevy_reflect", "bevy_reflect_derive", "pmoutils", - "quick-xml 0.38.4", + "quick-xml", "serde", "utoipa", "utoipa-swagger-ui", @@ -3790,7 +3790,7 @@ dependencies = [ "opus", "pmometadata", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.17", "tokio", "tracing", ] @@ -3804,7 +3804,7 @@ dependencies = [ "once_cell", "pmodidl", "pmoupnp", - "quick-xml 0.38.4", + "quick-xml", ] [[package]] @@ -3827,10 +3827,10 @@ dependencies = [ "pmosource", "pmoupnp", "pmoutils", - "quick-xml 0.38.4", + "quick-xml", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.17", "tokio", "tokio-util", "tracing", @@ -3909,7 +3909,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.17", "tokio", "tokio-stream", "tracing", @@ -3946,7 +3946,7 @@ dependencies = [ "serde_yaml", "sha1", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.17", "tokio", "tokio-test", "tracing", @@ -3998,7 +3998,7 @@ dependencies = [ "pmoupnp", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.17", "tokio", "tokio-stream", "tracing", @@ -4030,7 +4030,7 @@ dependencies = [ "pmoplaylist", "pmoserver", "pmoutils", - "quick-xml 0.37.5", + "quick-xml", "reqwest", "serde", "serde_json", @@ -4053,7 +4053,7 @@ dependencies = [ "get_if_addrs", "netstat2", "os_info", - "quick-xml 0.38.4", + "quick-xml", "sysinfo", "users", "xmltree 0.10.3", @@ -4337,16 +4337,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.37.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "quick-xml" version = "0.38.4" diff --git a/Cargo.toml b/Cargo.toml index 8575c4c1..a2989378 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,3 +21,35 @@ members = [ "pmoflac", "pmometadata", "pmocontrol", ] + +[workspace.dependencies] +# Core +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9" + +# Async runtime +tokio = { version = "1.35", features = ["full"] } +tokio-util = { version = "0.7", features = ["io"] } +async-trait = "0.1" + +# Error handling +anyhow = "1.0" +thiserror = "2.0" # ⚠️ Unifier sur 2.0 (vous avez 1.0 et 2.0) + +# Logging +tracing = "0.1.41" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } + +# HTTP/XML +reqwest = { version = "0.12", default-features = false } +ureq = "3.1" +quick-xml = { version = "0.38", features = ["serialize"] } # ⚠️ Unifier 0.37→0.38 + +# Utilities +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1.18", features = ["v4"] } +crossbeam-channel = "0.5" + +# Testing +tokio-test = "0.4" \ No newline at end of file diff --git a/PMOMusic/Cargo.toml b/PMOMusic/Cargo.toml index b17201d9..2955dd5c 100644 --- a/PMOMusic/Cargo.toml +++ b/PMOMusic/Cargo.toml @@ -16,8 +16,8 @@ pmoaudio-ext = { path = "../pmoaudio-ext", features = ["all"] } pmoapp = { path = "../pmoapp", features = ["pmoserver"] } pmocontrol = { path = "../pmocontrol", features = ["pmoserver"] } -tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] } -tracing = "0.1.41" +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time", "signal"] } +tracing = { workspace = true } tracing-subscriber = "0.3.20" axum = "0.8.4" serde_json = "1.0.145" diff --git a/PMOMusic/Cargo.toml.backup b/PMOMusic/Cargo.toml.backup new file mode 100644 index 00000000..b17201d9 --- /dev/null +++ b/PMOMusic/Cargo.toml.backup @@ -0,0 +1,25 @@ +[package] +name = "PMOMusic" +version = "0.1.0" +edition = "2024" + +[dependencies] +pmoconfig = { path = "../pmoconfig" } +pmoupnp = { path = "../pmoupnp"} +pmomediarenderer = { path = "../pmomediarenderer" } +pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "api"] } +pmosource = { path = "../pmosource", features = ["server"] } +pmoserver = { path = "../pmoserver" } +pmocovers = { path = "../pmocovers", features = ["pmoserver"] } +pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"]} +pmoaudio-ext = { path = "../pmoaudio-ext", features = ["all"] } +pmoapp = { path = "../pmoapp", features = ["pmoserver"] } +pmocontrol = { path = "../pmocontrol", features = ["pmoserver"] } + +tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] } +tracing = "0.1.41" +tracing-subscriber = "0.3.20" +axum = "0.8.4" +serde_json = "1.0.145" +utoipa = "5.4" +console-subscriber = "0.4.1" diff --git a/pmoapp/Cargo.toml.backup b/pmoapp/Cargo.toml.backup new file mode 100644 index 00000000..8b18b4e2 --- /dev/null +++ b/pmoapp/Cargo.toml.backup @@ -0,0 +1,15 @@ +[package] +name = "pmoapp" +version = "0.1.0" +edition = "2021" + +[dependencies] +rust-embed = "8.5.0" + +[dependencies.pmoserver] +path = "../pmoserver" +optional = true + +[features] +default = [] +pmoserver = ["dep:pmoserver"] diff --git a/pmoaudio-ext/Cargo.toml b/pmoaudio-ext/Cargo.toml index e78cc9a9..1ae36392 100644 --- a/pmoaudio-ext/Cargo.toml +++ b/pmoaudio-ext/Cargo.toml @@ -18,7 +18,7 @@ pmoplaylist = { path = "../pmoplaylist", optional = true } pmocache = { path = "../pmocache", optional = true } # Async runtime -tokio = { version = "1.0", features = ["full"] } +tokio = { workspace = true } tokio-util = { version = "0.7" } async-trait = "0.1" @@ -28,8 +28,8 @@ rand = "0.8" # HTTP streaming dependencies bytes = { version = "1.0", optional = true } -serde = { version = "1.0", features = ["derive"], optional = true } -serde_json = { version = "1.0", optional = true } +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } [features] default = [] diff --git a/pmoaudio/Cargo.toml b/pmoaudio/Cargo.toml index 720d58ce..d264e4d3 100755 --- a/pmoaudio/Cargo.toml +++ b/pmoaudio/Cargo.toml @@ -8,9 +8,9 @@ default = [] simd = [] [dependencies] -tokio = { version = "1.42", features = ["full"] } +tokio = { workspace = true } tokio-util = { version = "0.7", features = ["io"] } -async-trait = "0.1" +async-trait = { workspace = true } futures-util = "0.3" pmoflac = { path = "../pmoflac" } pmometadata = { path = "../pmometadata" } @@ -18,7 +18,7 @@ paste = "1" soxr = "0.6.0" bytemuck = "1.24.0" reqwest = { version = "0.12", features = ["stream"] } -tracing = "0.1" +tracing = { workspace = true } cpal = "0.15" once_cell = "1.20" @@ -26,4 +26,4 @@ once_cell = "1.20" tokio-test = "0.4" tempfile = "3" wiremock = "0.6" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-subscriber = { workspace = true } diff --git a/pmoaudio/Cargo.toml.backup b/pmoaudio/Cargo.toml.backup new file mode 100755 index 00000000..720d58ce --- /dev/null +++ b/pmoaudio/Cargo.toml.backup @@ -0,0 +1,29 @@ +[package] +name = "pmoaudio" +version = "0.1.0" +edition = "2021" + +[features] +default = [] +simd = [] + +[dependencies] +tokio = { version = "1.42", features = ["full"] } +tokio-util = { version = "0.7", features = ["io"] } +async-trait = "0.1" +futures-util = "0.3" +pmoflac = { path = "../pmoflac" } +pmometadata = { path = "../pmometadata" } +paste = "1" +soxr = "0.6.0" +bytemuck = "1.24.0" +reqwest = { version = "0.12", features = ["stream"] } +tracing = "0.1" +cpal = "0.15" +once_cell = "1.20" + +[dev-dependencies] +tokio-test = "0.4" +tempfile = "3" +wiremock = "0.6" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/pmoaudiocache/Cargo.toml b/pmoaudiocache/Cargo.toml index 49dc679e..e3139df2 100644 --- a/pmoaudiocache/Cargo.toml +++ b/pmoaudiocache/Cargo.toml @@ -16,7 +16,7 @@ pmometadata = { path = "../pmometadata" } # Base de données rusqlite = { version = "0.37", features = ["bundled"] } -chrono = "0.4" +chrono = { workspace = true } # Métadonnées audio lofty = "0.22" @@ -26,15 +26,15 @@ futures-util = "0.3" bytes = "1.0" # Utilitaires -anyhow = "1.0" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -quick-xml = { version = "0.37", features = ["serialize"] } +anyhow = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +quick-xml = { workspace = true } paste = "1.0" -async-trait = "0.1" +async-trait = { workspace = true } # Async -tokio = { version = "1.0", features = ["full"] } +tokio = { workspace = true } # Singleton once_cell = "1.20" @@ -45,7 +45,7 @@ pmoconfig = { path = "../pmoconfig", optional = true } axum = { version = "0.8", optional = true } utoipa = { version = "5.3", features = ["axum_extras"], optional = true } -tracing = "0.1.41" +tracing = { workspace = true } [dev-dependencies] tracing-subscriber = "0.3" diff --git a/pmoaudiocache/Cargo.toml.backup b/pmoaudiocache/Cargo.toml.backup new file mode 100644 index 00000000..0b3c8524 --- /dev/null +++ b/pmoaudiocache/Cargo.toml.backup @@ -0,0 +1,57 @@ +[package] +name = "pmoaudiocache" +version = "0.1.0" +edition = "2021" + +[dependencies] +# Cache générique +pmocache = { path = "../pmocache" } + +# DIDL-Lite pour UPnP +pmodidl = { path = "../pmodidl" } + +# Streaming FLAC asynchrone +pmoflac = { path = "../pmoflac" } +pmometadata = { path = "../pmometadata" } + +# Base de données +rusqlite = { version = "0.37", features = ["bundled"] } +chrono = "0.4" + +# Métadonnées audio +lofty = "0.22" + +# Outils de streaming +futures-util = "0.3" +bytes = "1.0" + +# Utilitaires +anyhow = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +quick-xml = { version = "0.38", features = ["serialize"] } +paste = "1.0" +async-trait = "0.1" + +# Async +tokio = { version = "1.0", features = ["full"] } + +# Singleton +once_cell = "1.20" + +# Serveur HTTP (optionnel pour l'extension) +pmoserver = { path = "../pmoserver", optional = true } +pmoconfig = { path = "../pmoconfig", optional = true } +axum = { version = "0.8", optional = true } +utoipa = { version = "5.3", features = ["axum_extras"], optional = true } + +tracing = "0.1.41" + +[dev-dependencies] +tracing-subscriber = "0.3" +tempfile = "3" + +[features] +default = ["pmoserver"] +pmoconfig = ["dep:pmoconfig", "pmocache/pmoconfig"] +pmoserver = ["pmoconfig", "dep:pmoserver", "dep:axum", "dep:utoipa", "pmocache/pmoserver", "pmocache/openapi"] diff --git a/pmoaudiocache/src/track_metadata.rs b/pmoaudiocache/src/track_metadata.rs index 7a7eff55..2e454066 100644 --- a/pmoaudiocache/src/track_metadata.rs +++ b/pmoaudiocache/src/track_metadata.rs @@ -394,7 +394,7 @@ impl TrackMetadata for AudioCacheTrackMetadata { } async fn touch(&mut self) -> MetadataResult<()> { - self.write_timestamp(Instant::now())?; + self.write_timestamp(SystemTime::now())?; Ok(Some(())) } } diff --git a/pmocache/Cargo.toml b/pmocache/Cargo.toml index 61870502..c188ee87 100644 --- a/pmocache/Cargo.toml +++ b/pmocache/Cargo.toml @@ -17,21 +17,21 @@ sha2 = "0.10" hex = "0.4" # Utilitaires -anyhow = "1.0" -async-trait = "0.1" -chrono = "0.4" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +anyhow = { workspace = true } +async-trait = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } bytes = "1.6" paste = "1.0" pmoflac = { path = "../pmoflac" } # Async -tokio = { version = "1.0", features = ["full"] } +tokio = { workspace = true } tokio-util = { version = "0.7", features = ["io"] } # Logging -tracing = "0.1" +tracing = { workspace = true } # Feature pour OpenAPI utoipa = { version = "5.3", optional = true } diff --git a/pmocache/Cargo.toml.backup b/pmocache/Cargo.toml.backup new file mode 100644 index 00000000..61870502 --- /dev/null +++ b/pmocache/Cargo.toml.backup @@ -0,0 +1,53 @@ +[package] +name = "pmocache" +version = "0.1.0" +edition = "2021" + +[dependencies] +# Base de données +rusqlite = { version = "0.37.0", features = ["bundled"] } + +# HTTP client +reqwest = { version = "0.12", features = ["blocking", "stream"] } +futures-util = "0.3" + +# Cryptographie +sha1 = "0.10" +sha2 = "0.10" +hex = "0.4" + +# Utilitaires +anyhow = "1.0" +async-trait = "0.1" +chrono = "0.4" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +bytes = "1.6" +paste = "1.0" +pmoflac = { path = "../pmoflac" } + +# Async +tokio = { version = "1.0", features = ["full"] } +tokio-util = { version = "0.7", features = ["io"] } + +# Logging +tracing = "0.1" + +# Feature pour OpenAPI +utoipa = { version = "5.3", optional = true } + +# Feature pour pmoserver (extension HTTP) +axum = { version = "0.8", optional = true } + +# Feature pour pmoconfig (extension de configuration) +pmoconfig = { path = "../pmoconfig", optional = true } +serde_yaml = { version = "0.9", optional = true } + +[dev-dependencies] +tempfile = "3" + +[features] +default = [] +openapi = ["dep:utoipa"] +pmoserver = ["dep:axum"] +pmoconfig = ["dep:pmoconfig", "dep:serde_yaml"] diff --git a/pmoconfig/Cargo.toml b/pmoconfig/Cargo.toml index 4dcb974c..81f85369 100644 --- a/pmoconfig/Cargo.toml +++ b/pmoconfig/Cargo.toml @@ -7,21 +7,21 @@ edition = "2021" [dependencies] pmoutils ={ path = "../pmoutils" } -serde = { version = "1.0", features = ["derive"] } -serde_yaml = "0.9.33" -serde_json = "1.0" +serde = { workspace = true } +serde_yaml = { workspace = true } +serde_json = { workspace = true } lazy_static = "1.4.0" dirs = "6.0.0" log = "0.4.20" anyhow = "1.0.75" -uuid = { version = "1.18.1", features = ["v4"] } -tracing = "0.1.41" +uuid = { workspace = true } +tracing = { workspace = true } aes-gcm = "0.10" sha2 = "0.10" base64 = "0.22" # Async -tokio = { version = "1.0", features = ["full"], optional = true } +tokio = { workspace = true, optional = true } # Serveur HTTP (pour l'API REST) axum = { version = "0.8", optional = true } diff --git a/pmoconfig/Cargo.toml.backup b/pmoconfig/Cargo.toml.backup new file mode 100644 index 00000000..4dcb974c --- /dev/null +++ b/pmoconfig/Cargo.toml.backup @@ -0,0 +1,32 @@ + +[package] +name = "pmoconfig" +version = "0.1.0" +edition = "2021" + +[dependencies] +pmoutils ={ path = "../pmoutils" } + +serde = { version = "1.0", features = ["derive"] } +serde_yaml = "0.9.33" +serde_json = "1.0" +lazy_static = "1.4.0" +dirs = "6.0.0" +log = "0.4.20" +anyhow = "1.0.75" +uuid = { version = "1.18.1", features = ["v4"] } +tracing = "0.1.41" +aes-gcm = "0.10" +sha2 = "0.10" +base64 = "0.22" + +# Async +tokio = { version = "1.0", features = ["full"], optional = true } + +# Serveur HTTP (pour l'API REST) +axum = { version = "0.8", optional = true } +utoipa = { version = "5.3", features = ["axum_extras"], optional = true } + +[features] +default = [] +api = ["dep:tokio", "dep:axum", "dep:utoipa"] \ No newline at end of file diff --git a/pmocontrol/Cargo.toml b/pmocontrol/Cargo.toml index 68e7d90a..b828d5f3 100644 --- a/pmocontrol/Cargo.toml +++ b/pmocontrol/Cargo.toml @@ -6,13 +6,13 @@ edition = "2024" [dependencies] pmoupnp = { path = "../pmoupnp" } pmodidl = { path = "../pmodidl" } -quick-xml = "0.38.4" -thiserror = "2.0.17" +quick-xml = { workspace = true } +thiserror = { workspace = true } ureq = "3.1.4" -tracing = "0.1.41" +tracing = { workspace = true } tracing-subscriber = "0.3" tracing-log = "0.1" -anyhow = "1.0" +anyhow = { workspace = true } xmltree = "0.11.0" crossbeam-channel = "0.5" ratatui = { version = "0.26", default-features = false, features = ["crossterm"] } @@ -28,9 +28,9 @@ smol = "2.0" 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 } +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +tokio = { workspace = true, features = ["sync", "rt"], optional = true } tokio-util = { version = "0.7", optional = true } async-trait = { version = "0.1", optional = true } tokio-stream = { version = "0.1", features = ["sync"], optional = true } @@ -38,8 +38,8 @@ async-stream = { version = "0.3", optional = true } chrono = { version = "0.4", features = ["serde"], optional = true } [dev-dependencies] -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +serde = { workspace = true } +serde_json = { workspace = true } percent-encoding = "2.3" [features] diff --git a/pmocontrol/Cargo.toml.backup b/pmocontrol/Cargo.toml.backup new file mode 100644 index 00000000..68e7d90a --- /dev/null +++ b/pmocontrol/Cargo.toml.backup @@ -0,0 +1,48 @@ +[package] +name = "pmocontrol" +version = "0.1.0" +edition = "2024" + +[dependencies] +pmoupnp = { path = "../pmoupnp" } +pmodidl = { path = "../pmodidl" } +quick-xml = "0.38.4" +thiserror = "2.0.17" +ureq = "3.1.4" +tracing = "0.1.41" +tracing-subscriber = "0.3" +tracing-log = "0.1" +anyhow = "1.0" +xmltree = "0.11.0" +crossbeam-channel = "0.5" +ratatui = { version = "0.26", default-features = false, features = ["crossterm"] } +crossterm = "0.27" +rust_cast = "0.19" +rustls = { version = "0.23", features = ["aws-lc-rs"] } +mdns = "3.0" +async-std = "1.12" +futures-util = "0.3" +smol = "2.0" + +# 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 } +tokio-util = { version = "0.7", 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 } + +[dev-dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +percent-encoding = "2.3" + +[features] +default = [] +# Active l'API REST pmoserver +pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:serde", "dep:serde_json", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:chrono"] diff --git a/pmocovers/Cargo.toml b/pmocovers/Cargo.toml index 804c3a31..336ebf69 100644 --- a/pmocovers/Cargo.toml +++ b/pmocovers/Cargo.toml @@ -15,12 +15,12 @@ webp = "0.3" reqwest = { version = "0.12", features = ["blocking"] } # Utilitaires -anyhow = "1.0" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +anyhow = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } # Async -tokio = { version = "1.0", features = ["full"] } +tokio = { workspace = true } # Singleton once_cell = "1.20" @@ -32,7 +32,7 @@ async-trait = { version = "0.1", optional = true } axum = { version = "0.8", optional = true } utoipa = { version = "5.3", features = ["axum_extras"], optional = true } -tracing = "0.1.41" +tracing = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/pmocovers/Cargo.toml.backup b/pmocovers/Cargo.toml.backup new file mode 100644 index 00000000..804c3a31 --- /dev/null +++ b/pmocovers/Cargo.toml.backup @@ -0,0 +1,51 @@ +[package] +name = "pmocovers" +version = "0.1.0" +edition = "2021" + +[dependencies] +# Cache générique +pmocache = { path = "../pmocache" } + +# Gestion d'images +image = "0.25" +webp = "0.3" + +# HTTP client +reqwest = { version = "0.12", features = ["blocking"] } + +# Utilitaires +anyhow = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Async +tokio = { version = "1.0", features = ["full"] } + +# Singleton +once_cell = "1.20" + +# Serveur HTTP (optionnel pour l'extension) +pmoserver = { path = "../pmoserver", optional = true } +pmoconfig = { path = "../pmoconfig", optional = true } +async-trait = { version = "0.1", optional = true } +axum = { version = "0.8", optional = true } +utoipa = { version = "5.3", features = ["axum_extras"], optional = true } + +tracing = "0.1.41" + +[dev-dependencies] +tempfile = "3" + +[features] +default = ["pmoserver"] +pmoconfig = ["dep:pmoconfig", "pmocache/pmoconfig"] +pmoserver = [ + "pmoconfig", + "dep:pmoserver", + "dep:axum", + "dep:utoipa", + "pmocache/openapi", + "pmocache/pmoserver", + "dep:async-trait", +] diff --git a/pmodidl/Cargo.toml b/pmodidl/Cargo.toml index d9fa43db..baf5a6bc 100644 --- a/pmodidl/Cargo.toml +++ b/pmodidl/Cargo.toml @@ -4,10 +4,10 @@ version = "0.1.0" edition = "2024" [dependencies] -serde = "1.0.228" +serde = { workspace = true } utoipa = { version = "5.4.0", features = ["axum_extras"] } utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } -quick-xml = { version = "0.38.3", features = ["serialize"] } +quick-xml = { workspace = true } bevy_reflect = "0.17.1" bevy_reflect_derive = "0.17.1" pmoutils = { path = "../pmoutils" } diff --git a/pmodidl/Cargo.toml.backup b/pmodidl/Cargo.toml.backup new file mode 100644 index 00000000..d9fa43db --- /dev/null +++ b/pmodidl/Cargo.toml.backup @@ -0,0 +1,14 @@ +[package] +name = "pmodidl" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = "1.0.228" +utoipa = { version = "5.4.0", features = ["axum_extras"] } +utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } +quick-xml = { version = "0.38.3", features = ["serialize"] } +bevy_reflect = "0.17.1" +bevy_reflect_derive = "0.17.1" +pmoutils = { path = "../pmoutils" } +xmltree = "0.10" diff --git a/pmodidl/src/lib.rs b/pmodidl/src/lib.rs index c6963cd8..1f1afda2 100644 --- a/pmodidl/src/lib.rs +++ b/pmodidl/src/lib.rs @@ -5,6 +5,7 @@ use bevy_reflect::Reflect; use pmoutils::ToXmlElement; use serde::{Deserialize, Serialize}; +use std::time::SystemTime; use std::{borrow::Cow, time::Instant}; use std::collections::HashSet; use std::fmt::Write; @@ -36,7 +37,7 @@ pub struct ParsedMetadata { /// Timestamp du parsing (exclu de la réflexion car SystemTime n'implémente pas Reflect) #[reflect(ignore)] #[serde(skip_serializing_if = "Option::is_none")] - pub parsed_at: Option, + pub parsed_at: Option, } impl ParsedMetadata { @@ -44,7 +45,7 @@ impl ParsedMetadata { Self { format: format.into(), data, - parsed_at: Some(Instant::now()), + parsed_at: Some(SystemTime::now()), } } diff --git a/pmoflac/Cargo.toml b/pmoflac/Cargo.toml index f22df6e0..9919892e 100755 --- a/pmoflac/Cargo.toml +++ b/pmoflac/Cargo.toml @@ -20,10 +20,10 @@ lofty = "0.22" minimp3 = "0.5" opus = "0.3" pmometadata = { path = "../pmometadata" } -thiserror = "1.0" -tokio = { version = "1.37", features = ["rt", "rt-multi-thread", "macros", "sync", "io-util", "fs"] } -tracing = "0.1" +thiserror = { workspace = true } +tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros", "sync", "io-util", "fs"] } +tracing = { workspace = true } [dev-dependencies] tempfile = "3.10" -tokio = { version = "1.37", features = ["rt", "rt-multi-thread", "macros", "sync", "io-util", "time", "fs"] } +tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros", "sync", "io-util", "time", "fs"] } diff --git a/pmoflac/Cargo.toml.backup b/pmoflac/Cargo.toml.backup new file mode 100755 index 00000000..aa69993c --- /dev/null +++ b/pmoflac/Cargo.toml.backup @@ -0,0 +1,29 @@ +[package] +name = "pmoflac" +version = "0.1.0" +edition = "2021" +authors = ["PMOMusic"] +description = "Asynchronous FLAC <-> PCM streaming utilities" +license = "MIT" + +[lib] +name = "pmoflac" +path = "src/lib.rs" + +[dependencies] +bytes = "1.6" +claxon = "0.4" +lewton = "0.10" +libc = "0.2" +libflac-sys = { version = "0.3.3", default-features = false, features = ["build-flac"] } +lofty = "0.22" +minimp3 = "0.5" +opus = "0.3" +pmometadata = { path = "../pmometadata" } +thiserror = "2.0" +tokio = { version = "1.37", features = ["rt", "rt-multi-thread", "macros", "sync", "io-util", "fs"] } +tracing = "0.1" + +[dev-dependencies] +tempfile = "3.10" +tokio = { version = "1.37", features = ["rt", "rt-multi-thread", "macros", "sync", "io-util", "time", "fs"] } diff --git a/pmomediarenderer/Cargo.toml b/pmomediarenderer/Cargo.toml index 688ba978..5121ef6a 100644 --- a/pmomediarenderer/Cargo.toml +++ b/pmomediarenderer/Cargo.toml @@ -10,4 +10,4 @@ pmodidl = { path = "../pmodidl" } once_cell = "1.20" bevy_reflect = "0.17.1" htmlescape = "0.3" -quick-xml = "0.38.3" +quick-xml = { workspace = true } diff --git a/pmomediarenderer/Cargo.toml.backup b/pmomediarenderer/Cargo.toml.backup new file mode 100644 index 00000000..688ba978 --- /dev/null +++ b/pmomediarenderer/Cargo.toml.backup @@ -0,0 +1,13 @@ +[package] +name = "pmomediarenderer" +version = "0.1.0" +edition = "2024" + +[dependencies] +pmoupnp = { path = "../pmoupnp" } +pmodidl = { path = "../pmodidl" } + +once_cell = "1.20" +bevy_reflect = "0.17.1" +htmlescape = "0.3" +quick-xml = "0.38.3" diff --git a/pmomediaserver/Cargo.toml b/pmomediaserver/Cargo.toml index 9852d980..9e8fe81c 100644 --- a/pmomediaserver/Cargo.toml +++ b/pmomediaserver/Cargo.toml @@ -11,13 +11,13 @@ pmoserver = { path = "../pmoserver" } once_cell = "1.20" bevy_reflect = "0.17.1" -tokio = { version = "1", features = ["sync"] } -async-trait = "0.1" -tracing = "0.1" -quick-xml = { version = "0.38.3", features = ["serialize"] } -thiserror = "1.0" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +tokio = { workspace = true, features = ["sync"] } +async-trait = { workspace = true } +tracing = { workspace = true } +quick-xml = { workspace = true } +thiserror = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } pmoutils = { path = "../pmoutils" } # Optional dependencies diff --git a/pmomediaserver/Cargo.toml.backup b/pmomediaserver/Cargo.toml.backup new file mode 100644 index 00000000..01523f88 --- /dev/null +++ b/pmomediaserver/Cargo.toml.backup @@ -0,0 +1,54 @@ +[package] +name = "pmomediaserver" +version = "0.1.0" +edition = "2024" + +[dependencies] +pmoupnp = { path = "../pmoupnp" } +pmodidl = { path = "../pmodidl" } +pmosource = { path = "../pmosource" } +pmoserver = { path = "../pmoserver" } + +once_cell = "1.20" +bevy_reflect = "0.17.1" +tokio = { version = "1", features = ["sync"] } +async-trait = "0.1" +tracing = "0.1" +quick-xml = { version = "0.38.3", features = ["serialize"] } +thiserror = "2.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +pmoutils = { path = "../pmoutils" } + +# Optional dependencies +axum = { version = "0.8", optional = true } +utoipa = { version = "5.3", optional = true } +pmoqobuz = { path = "../pmoqobuz", optional = true } +pmoparadise = { path = "../pmoparadise", optional = true } +pmoconfig = { path = "../pmoconfig", optional = true } +anyhow = { version = "1.0", optional = true } +pmoaudiocache = { path = "../pmoaudiocache", optional = true } +pmocovers = { path = "../pmocovers", optional = true, features = ["pmoserver"] } +pmoplaylist = { path = "../pmoplaylist", optional = true } +tokio-util = { version = "0.7", features = ["io"], optional = true } + +[features] +default = ["pmosource/server"] +# Feature pour activer l'API REST de gestion des sources +api = ["dep:axum", "dep:utoipa", "pmosource/server"] +# Feature pour activer le support Qobuz configuré +qobuz = ["api", "dep:pmoqobuz", "dep:pmoconfig", "pmoqobuz/server"] +# Feature pour activer le support Radio Paradise +paradise = [ + "api", + "dep:pmoparadise", + "pmoparadise/full", + "dep:anyhow", + "dep:pmoaudiocache", + "dep:pmocovers", + "pmocovers/pmoserver", + "dep:pmoplaylist", + "dep:tokio-util" +] +# Feature pour activer l'API REST de Radio Paradise (en plus de la source UPnP) +paradise-api = ["paradise", "pmoparadise/pmoserver"] diff --git a/pmometadata/Cargo.toml b/pmometadata/Cargo.toml index 83a4d8df..bd7c0a18 100644 --- a/pmometadata/Cargo.toml +++ b/pmometadata/Cargo.toml @@ -6,8 +6,8 @@ edition = "2024" [dependencies] async-trait = "0.1.89" paste = "1" -thiserror = "2.0.17" -tokio = { version = "1", features = ["full"] } +thiserror = { workspace = true } +tokio = { workspace = true } [dev-dependencies] tokio-test = "0.4" diff --git a/pmometadata/Cargo.toml.backup b/pmometadata/Cargo.toml.backup new file mode 100644 index 00000000..83a4d8df --- /dev/null +++ b/pmometadata/Cargo.toml.backup @@ -0,0 +1,13 @@ +[package] +name = "pmometadata" +version = "0.1.0" +edition = "2024" + +[dependencies] +async-trait = "0.1.89" +paste = "1" +thiserror = "2.0.17" +tokio = { version = "1", features = ["full"] } + +[dev-dependencies] +tokio-test = "0.4" diff --git a/pmometadata/src/lib.rs b/pmometadata/src/lib.rs index 9ebbe747..7243a45b 100755 --- a/pmometadata/src/lib.rs +++ b/pmometadata/src/lib.rs @@ -840,7 +840,7 @@ impl TrackMetadata for MemoryTrackMetadata { } async fn touch(&mut self) -> MetadataResult<()> { - self.updated_at = Some()); + self.updated_at = Some(SystemTime::now()); Ok(Some(())) } } diff --git a/pmoparadise/Cargo.toml b/pmoparadise/Cargo.toml index 26a23d78..79c970cf 100644 --- a/pmoparadise/Cargo.toml +++ b/pmoparadise/Cargo.toml @@ -14,16 +14,16 @@ categories = ["multimedia", "api-bindings"] reqwest = { version = "0.12", features = ["json", "stream"] } # Gestion asynchrone -tokio = { version = "1", features = ["full"] } +tokio = { workspace = true } # Sérialisation/Désérialisation JSON -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -serde_yaml = "0.9.33" +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } # Helpers -chrono = { version = "0.4", features = ["serde"] } -async-trait = "0.1" +chrono = { workspace = true } +async-trait = { workspace = true } sha2 = "0.10" hex = "0.4" tokio-util = { version = "0.7", features = ["io"] } @@ -32,8 +32,8 @@ rusqlite = { version = "0.37", features = ["bundled"] } once_cell = "1.20" # Gestion des erreurs -thiserror = "2.0.17" -anyhow = "1.0" +thiserror = { workspace = true } +anyhow = { workspace = true } # Streaming de bytes bytes = "1.5" @@ -41,7 +41,7 @@ futures = "0.3" flacenc = "0.5.0" # Logging -tracing = "0.1" +tracing = { workspace = true } # URL manipulation url = "2.5" diff --git a/pmoparadise/Cargo.toml.backup b/pmoparadise/Cargo.toml.backup new file mode 100644 index 00000000..26a23d78 --- /dev/null +++ b/pmoparadise/Cargo.toml.backup @@ -0,0 +1,121 @@ +[package] +name = "pmoparadise" +version = "0.1.0" +edition = "2021" +authors = ["PMOMusic Contributors"] +description = "Rust client for Radio Paradise streaming service" +license = "MIT OR Apache-2.0" +repository = "https://github.com/yourusername/pmomusic" +keywords = ["radio", "paradise", "streaming", "music", "flac"] +categories = ["multimedia", "api-bindings"] + +[dependencies] +# HTTP client pour les requêtes à l'API Radio Paradise +reqwest = { version = "0.12", features = ["json", "stream"] } + +# Gestion asynchrone +tokio = { version = "1", features = ["full"] } + +# Sérialisation/Désérialisation JSON +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9.33" + +# Helpers +chrono = { version = "0.4", features = ["serde"] } +async-trait = "0.1" +sha2 = "0.10" +hex = "0.4" +tokio-util = { version = "0.7", features = ["io"] } +async-stream = "0.3" +rusqlite = { version = "0.37", features = ["bundled"] } +once_cell = "1.20" + +# Gestion des erreurs +thiserror = "2.0.17" +anyhow = "1.0" + +# Streaming de bytes +bytes = "1.5" +futures = "0.3" +flacenc = "0.5.0" + +# Logging +tracing = "0.1" + +# URL manipulation +url = "2.5" + +# Audio decoding/encoding +symphonia = { version = "0.5", features = ["all"] } + +# Audio decoding - claxon for FLAC streaming +claxon = "0.4" + +# pmoaudio-ext with playlist support (optional for examples) +pmoaudio-ext = { path = "../pmoaudio-ext", optional = true, features = ["playlist", "http-stream", "cache-sink"] } + +# Common music source traits +pmosource = { path = "../pmosource" } + +# Playlist management for FIFO support +pmoplaylist = { path = "../pmoplaylist" } +pmoconfig = { path = "../pmoconfig", optional = true } + +# Cache support (OBLIGATOIRE - architecture refactorisée) +pmocovers = { path = "../pmocovers" } +pmoaudiocache = { path = "../pmoaudiocache" } + +# pmoserver extension support +pmoserver = { path = "../pmoserver", optional = true } +utoipa = { version = "5.4.0", optional = true } +axum = { version = "0.8.4", optional = true } + +# pmoaudio node support +pmoaudio = { path = "../pmoaudio", optional = true } +pmoflac = { path = "../pmoflac", optional = true } +pmometadata = { path = "../pmometadata", optional = true } +futures-util = { version = "0.3", optional = true } + +[features] +default = ["metadata-only", "pmoconfig", "playlist"] +# Mode métadonnées seules (pas de décodage FLAC) +metadata-only = [] +# Active l'API REST pmoserver +pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "server"] +# Feature pour activer le support serveur (cache registry) +server = ["pmosource/server", "pmoconfig"] +# Feature pour activer le support de pmoconfig +pmoconfig = ["dep:pmoconfig"] +# Feature pour activer le support des playlists d'historique +playlist = [] +# Feature cache (deprecated - toujours actif maintenant) +cache = [] +# Active le support pmoaudio node (RadioParadiseStreamSource) +pmoaudio = ["dep:pmoaudio", "dep:pmoflac", "dep:pmometadata", "dep:futures-util", "dep:pmoaudio-ext"] +# Active le support complet avec playlist (pour les exemples avancés) +full = ["pmoaudio", "dep:pmoaudio-ext", "pmoconfig", "pmoserver", "playlist"] + +[dev-dependencies] +# Tests +tokio-test = "0.4" +wiremock = "0.6" +# Pour les exemples avec logging +tracing-subscriber = "0.3" +# Pour l'exemple with_cache +pmocovers = { path = "../pmocovers" } +pmoaudiocache = { path = "../pmoaudiocache" } + +[[example]] +name = "now_playing" +path = "examples/now_playing.rs" + +[[example]] +name = "stream_block" +path = "examples/stream_block.rs" +required-features = ["full"] + +[[example]] +name = "single_channel_server" +path = "examples/single_channel_server.rs" +required-features = ["full"] diff --git a/pmoplaylist/Cargo.toml b/pmoplaylist/Cargo.toml index 0c96c3f1..b7146096 100644 --- a/pmoplaylist/Cargo.toml +++ b/pmoplaylist/Cargo.toml @@ -21,20 +21,20 @@ pmoconfig = { path = "../pmoconfig", optional = true } rusqlite = { version = "0.37", features = ["bundled"] } # Utilitaires -anyhow = "1.0" -thiserror = "1.0" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +anyhow = { workspace = true } +thiserror = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } # Async -tokio = { version = "1.0", features = ["full"] } +tokio = { workspace = true } # Singleton once_cell = "1.20" # Logging -tracing = "0.1" -chrono = { version = "0.4", features = ["serde"] } +tracing = { workspace = true } +chrono = { workspace = true } axum = { version = "0.8", optional = true, features = ["macros", "json"] } tokio-stream = { version = "0.1", optional = true, features = ["sync"] } utoipa = { version = "5.4", optional = true, features = ["axum_extras", "chrono"] } diff --git a/pmoplaylist/Cargo.toml.backup b/pmoplaylist/Cargo.toml.backup new file mode 100644 index 00000000..eff6d063 --- /dev/null +++ b/pmoplaylist/Cargo.toml.backup @@ -0,0 +1,46 @@ +[package] +name = "pmoplaylist" +version = "0.1.0" +edition = "2021" + +[dependencies] +# Caches PMO +pmoaudiocache = { path = "../pmoaudiocache" } +pmocache = { path = "../pmocache" } + +# Métadonnées +pmometadata = { path = "../pmometadata" } + +# DIDL-Lite pour UPnP +pmodidl = { path = "../pmodidl" } + +# Configuration (optionnelle) +pmoconfig = { path = "../pmoconfig", optional = true } + +# Base de données +rusqlite = { version = "0.37", features = ["bundled"] } + +# Utilitaires +anyhow = "1.0" +thiserror = "2.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Async +tokio = { version = "1.0", features = ["full"] } + +# Singleton +once_cell = "1.20" + +# Logging +tracing = "0.1" +chrono = { version = "0.4", features = ["serde"] } +axum = { version = "0.8", optional = true, features = ["macros", "json"] } +tokio-stream = { version = "0.1", optional = true, features = ["sync"] } +utoipa = { version = "5.4", optional = true, features = ["axum_extras", "chrono"] } +async-stream = { version = "0.3", optional = true } + +[features] +default = ["pmoconfig"] +pmoconfig = ["dep:pmoconfig"] +pmoserver = ["dep:axum", "dep:tokio-stream", "dep:utoipa", "dep:async-stream"] diff --git a/pmoqobuz/Cargo.toml b/pmoqobuz/Cargo.toml index 6cfeec7d..3156cc03 100644 --- a/pmoqobuz/Cargo.toml +++ b/pmoqobuz/Cargo.toml @@ -13,16 +13,16 @@ async-trait = { version = "0.1", optional = true } reqwest = { version = "0.12", features = ["json", "cookies"] } # Gestion asynchrone -tokio = { version = "1", features = ["full"] } +tokio = { workspace = true } # Sérialisation/Désérialisation JSON -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -serde_yaml = "0.9" +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } # Gestion des erreurs -anyhow = "1.0" -thiserror = "1.0" +anyhow = { workspace = true } +thiserror = { workspace = true } # Hashing pour les clés de cache et signatures sha1 = "0.10" @@ -33,10 +33,10 @@ md-5 = "0.10" moka = { version = "0.12", features = ["future"] } # Logging -tracing = "0.1" +tracing = { workspace = true } # Gestion du temps -chrono = { version = "0.4", features = ["serde"] } +chrono = { workspace = true } # Configuration pmoconfig = { path = "../pmoconfig" } diff --git a/pmoqobuz/Cargo.toml.backup b/pmoqobuz/Cargo.toml.backup new file mode 100644 index 00000000..adda2924 --- /dev/null +++ b/pmoqobuz/Cargo.toml.backup @@ -0,0 +1,91 @@ +[package] +name = "pmoqobuz" +version = "0.1.0" +edition = "2021" + +[dependencies] +regex = "1.12" +base64 = "0.22" +indexmap = "2.0" +async-trait = { version = "0.1", optional = true } + +# HTTP client pour les requêtes à l'API Qobuz +reqwest = { version = "0.12", features = ["json", "cookies"] } + +# Gestion asynchrone +tokio = { version = "1", features = ["full"] } + +# Sérialisation/Désérialisation JSON +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9" + +# Gestion des erreurs +anyhow = "1.0" +thiserror = "2.0" + +# Hashing pour les clés de cache et signatures +sha1 = "0.10" +hex = "0.4" +md-5 = "0.10" + +# Cache en mémoire avec TTL +moka = { version = "0.12", features = ["future"] } + +# Logging +tracing = "0.1" + +# Gestion du temps +chrono = { version = "0.4", features = ["serde"] } + +# Configuration +pmoconfig = { path = "../pmoconfig" } + +# Intégration avec pmocovers pour le cache d'images (OBLIGATOIRE) +pmocovers = { path = "../pmocovers" } + +# Intégration avec pmoaudiocache pour le cache audio (OBLIGATOIRE) +pmoaudiocache = { path = "../pmoaudiocache" } + +# Intégration avec pmodidl pour l'export DIDL +pmodidl = { path = "../pmodidl" } + +# Intégration avec pmoserver pour l'API HTTP +pmoserver = { path = "../pmoserver", optional = true } +axum = { version = "0.8", optional = true } +rusqlite = { version = "0.37", features = ["bundled"], optional = true } + +# Documentation OpenAPI +utoipa = { version = "5.3", optional = true } + +# Common music source traits +pmosource = { path = "../pmosource" } + +# Playlist management +pmoplaylist = { path = "../pmoplaylist" } +pmocache = { path = "../pmocache" } + +[features] +default = ["async-trait-support"] +# Feature pour activer les extensions pmoserver +pmoserver = ["dep:pmoserver", "dep:axum", "dep:utoipa"] +# Feature pour activer le support serveur (cache registry) +server = ["pmosource/server"] +# Feature cache (deprecated - toujours actif maintenant) +cache = [] +async-trait-support = ["dep:async-trait"] +disk-cache = ["dep:rusqlite", "dep:async-trait"] + +[dev-dependencies] +# Tests +tokio-test = "0.4" +mockito = "1.0" +tempfile = "3.0" +# Pour les exemples +tracing-subscriber = "0.3" +# Pour l'exemple spoofer + +# Specify that the with_cache example requires the cache feature +[[example]] +name = "with_cache" +required-features = ["cache"] diff --git a/pmoserver/Cargo.toml b/pmoserver/Cargo.toml index 27a34529..74c457e5 100644 --- a/pmoserver/Cargo.toml +++ b/pmoserver/Cargo.toml @@ -6,16 +6,16 @@ edition = "2024" [dependencies] pmoconfig = { path = "../pmoconfig", features = ["api"] } -anyhow = "1.0" +anyhow = { workspace = true } axum = "0.8.4" -tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time", "signal"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time", "signal"] } tokio-stream = "0.1" tokio-util = "0.7" futures-util = "0.3" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } futures = "0.3" async-stream = "0.3.6" axum-server = "0.7.2" diff --git a/pmoserver/Cargo.toml.backup b/pmoserver/Cargo.toml.backup new file mode 100644 index 00000000..27a34529 --- /dev/null +++ b/pmoserver/Cargo.toml.backup @@ -0,0 +1,26 @@ +[package] +name = "pmoserver" +version = "0.1.0" +edition = "2024" + +[dependencies] +pmoconfig = { path = "../pmoconfig", features = ["api"] } + +anyhow = "1.0" +axum = "0.8.4" +tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time", "signal"] } +tokio-stream = "0.1" +tokio-util = "0.7" +futures-util = "0.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +futures = "0.3" +async-stream = "0.3.6" +axum-server = "0.7.2" +axum-embed = "0.1.0" +rust-embed = "8.7.2" +utoipa = { version = "5.4.0", features = ["axum_extras"] } +utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] } +once_cell = "1.19" diff --git a/pmosource/Cargo.toml b/pmosource/Cargo.toml index b6f7fc6c..3d452f9f 100644 --- a/pmosource/Cargo.toml +++ b/pmosource/Cargo.toml @@ -11,14 +11,14 @@ categories = ["multimedia"] [dependencies] # Gestion des erreurs -thiserror = "1.0" -anyhow = "1.0" +thiserror = { workspace = true } +anyhow = { workspace = true } # Async traits -async-trait = "0.1" +async-trait = { workspace = true } # Async runtime -tokio = { version = "1.0", features = ["sync", "time"] } +tokio = { workspace = true, features = ["sync", "time"] } # DIDL-Lite support pmodidl = { path = "../pmodidl" } @@ -32,8 +32,8 @@ pmocovers = { path = "../pmocovers", optional = true } pmocache = { path = "../pmocache", optional = true } # Serialization (required for cache metadata) -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +serde = { workspace = true } +serde_json = { workspace = true } # Server extension (optional) pmoserver = { path = "../pmoserver", optional = true } diff --git a/pmosource/Cargo.toml.backup b/pmosource/Cargo.toml.backup new file mode 100644 index 00000000..d541f70b --- /dev/null +++ b/pmosource/Cargo.toml.backup @@ -0,0 +1,54 @@ +[package] +name = "pmosource" +version = "0.1.0" +edition = "2021" +authors = ["PMOMusic Contributors"] +description = "Common traits and types for PMOMusic sources" +license = "MIT OR Apache-2.0" +repository = "https://github.com/yourusername/pmomusic" +keywords = ["music", "source", "streaming"] +categories = ["multimedia"] + +[dependencies] +# Gestion des erreurs +thiserror = "2.0" +anyhow = "1.0" + +# Async traits +async-trait = "0.1" + +# Async runtime +tokio = { version = "1.0", features = ["sync", "time"] } + +# DIDL-Lite support +pmodidl = { path = "../pmodidl" } + +# Playlist/FIFO support +pmoplaylist = { path = "../pmoplaylist" } + +# Optional cache integrations +pmoaudiocache = { path = "../pmoaudiocache", optional = true } +pmocovers = { path = "../pmocovers", optional = true } +pmocache = { path = "../pmocache", optional = true } + +# Serialization (required for cache metadata) +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Server extension (optional) +pmoserver = { path = "../pmoserver", optional = true } +pmoconfig = { path = "../pmoconfig", optional = true } +pmoupnp = { path = "../pmoupnp", optional = true } + +# Web framework for API (optional) +axum = { version = "0.8", optional = true } +utoipa = { version = "5.3", optional = true } +tracing = { version = "0.1", optional = true } +lazy_static = { version = "1.4", optional = true } +tokio-stream = { version = "0.1", optional = true, features = ["time"] } +futures = { version = "0.3", optional = true } + +[features] +default = ["cache"] +cache = ["pmoaudiocache", "pmocovers", "pmocache"] +server = ["pmoserver", "pmoconfig", "pmoupnp", "axum", "utoipa", "tracing", "lazy_static", "tokio-stream", "futures"] diff --git a/pmoupnp/Cargo.toml b/pmoupnp/Cargo.toml index 00bb1bfb..99453bb2 100644 --- a/pmoupnp/Cargo.toml +++ b/pmoupnp/Cargo.toml @@ -13,24 +13,24 @@ pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"] } pmoplaylist = { path = "../pmoplaylist", optional = true, features = ["pmoserver"] } pmocache = { path = "../pmocache" } -async-trait = "0.1" +async-trait = { workspace = true } async-recursion = "1.1" url = "2.5.7" uuid = "1.18.1" hex = "0.4.3" base64 = "0.22.1" -thiserror = "2.0.16" -anyhow = "1.0" +thiserror = { workspace = true } +anyhow = { workspace = true } xmltree = "0.11.0" axum = "0.8.4" -tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -quick-xml = { version = "0.37.5", features = ["serialize"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync"] } +serde = { workspace = true } +serde_json = { workspace = true } +quick-xml = { workspace = true } chrono = { version = "0.4.42", features = ["serde"] } once_cell = "1.20" parking_lot = "0.12" -tracing = "0.1" +tracing = { workspace = true } tracing-subscriber = "0.3" bevy_reflect = "0.17.1" bevy_reflect_derive = "0.17.1" @@ -38,7 +38,7 @@ reqwest = "0.12.23" utoipa = { version = "5.3", features = ["axum_extras"] } socket2 = "0.5" get_if_addrs = "0.5" -serde_yaml = "0.9" +serde_yaml = { workspace = true } [features] default = ["server"] diff --git a/pmoupnp/Cargo.toml.backup b/pmoupnp/Cargo.toml.backup new file mode 100644 index 00000000..25f7b73f --- /dev/null +++ b/pmoupnp/Cargo.toml.backup @@ -0,0 +1,50 @@ +[package] +name = "pmoupnp" +version = "0.1.0" +edition = "2024" + +[dependencies] +pmoconfig = { path = "../pmoconfig" } +pmodidl = { path = "../pmodidl"} +pmoutils = { path = "../pmoutils" } +pmoserver = { path = "../pmoserver" } +pmocovers = { path = "../pmocovers", features = ["pmoserver"] } +pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"] } +pmoplaylist = { path = "../pmoplaylist", optional = true, features = ["pmoserver"] } +pmocache = { path = "../pmocache" } + +async-trait = "0.1" +async-recursion = "1.1" +url = "2.5.7" +uuid = "1.18.1" +hex = "0.4.3" +base64 = "0.22.1" +thiserror = "2.0.16" +anyhow = "1.0" +xmltree = "0.11.0" +axum = "0.8.4" +tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +quick-xml = { version = "0.38.3", features = ["serialize"] } +chrono = { version = "0.4.42", features = ["serde"] } +once_cell = "1.20" +parking_lot = "0.12" +tracing = "0.1" +tracing-subscriber = "0.3" +bevy_reflect = "0.17.1" +bevy_reflect_derive = "0.17.1" +reqwest = "0.12.23" +utoipa = { version = "5.3", features = ["axum_extras"] } +socket2 = "0.5" +get_if_addrs = "0.5" +serde_yaml = "0.9" + +[features] +default = ["server"] +server = ["pmoplaylist"] +paradise = [] +qobuz = [] + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/pmoutils/Cargo.toml b/pmoutils/Cargo.toml index 49e86a59..dc3e22e3 100644 --- a/pmoutils/Cargo.toml +++ b/pmoutils/Cargo.toml @@ -9,5 +9,5 @@ os_info = "3.8" netstat2 = "0.11" sysinfo = "0.30" users = "0.11" -quick-xml = "0.38.3" +quick-xml = { workspace = true } xmltree = "0.10" diff --git a/pmoutils/Cargo.toml.backup b/pmoutils/Cargo.toml.backup new file mode 100644 index 00000000..49e86a59 --- /dev/null +++ b/pmoutils/Cargo.toml.backup @@ -0,0 +1,13 @@ +[package] +name = "pmoutils" +version = "0.1.0" +edition = "2024" + +[dependencies] +get_if_addrs = "0.5.3" +os_info = "3.8" +netstat2 = "0.11" +sysinfo = "0.30" +users = "0.11" +quick-xml = "0.38.3" +xmltree = "0.10" From 9c90e706f1b4c950e781f5b5b7b75fa846403145 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Tue, 30 Dec 2025 16:50:26 +0100 Subject: [PATCH 3/4] Enorme refactoring de PMO control step 2 --- .vscode/settings.json | 2 +- pmocontrol/Cargo.toml | 8 +- pmocontrol/src/arylic_client/mod.rs | 288 ++++++ pmocontrol/src/arylic_tcp.rs | 671 ------------ pmocontrol/src/control_point.rs | 900 ++++------------ pmocontrol/src/discovery/arylic.rs | 66 ++ .../src/discovery/chromecast_discovery.rs | 281 ++--- pmocontrol/src/discovery/manager.rs | 33 +- pmocontrol/src/discovery/mod.rs | 24 + pmocontrol/src/discovery/upnp_discovery.rs | 472 +-------- pmocontrol/src/discovery/upnp_provider.rs | 425 ++++---- pmocontrol/src/errors.rs | 2 + pmocontrol/src/lib.rs | 34 +- pmocontrol/src/linkplay_client/mod.rs | 156 +++ pmocontrol/src/linkplay_renderer.rs | 466 --------- pmocontrol/src/linkplay_utils.rs | 0 pmocontrol/src/media_server.rs | 42 +- pmocontrol/src/media_server_events.rs | 16 +- pmocontrol/src/model.rs | 97 +- pmocontrol/src/music_renderer.rs | 961 ------------------ pmocontrol/src/music_renderer/arylic_tcp.rs | 321 ++++++ .../src/{ => music_renderer}/capabilities.rs | 47 +- .../chromecast_renderer.rs | 57 +- .../src/music_renderer/linkplay_renderer.rs | 134 +++ pmocontrol/src/music_renderer/mod.rs | 53 + .../src/music_renderer/musicrenderer.rs | 737 ++++++++++++++ .../src/{ => music_renderer}/openhome.rs | 29 +- .../{ => music_renderer}/openhome_renderer.rs | 120 +-- pmocontrol/src/music_renderer/time_utils.rs | 218 ++++ .../src/music_renderer/upnp_renderer.rs | 221 ++++ pmocontrol/src/online.rs | 20 +- pmocontrol/src/openhome_playlist.rs | 31 - pmocontrol/src/queue/backend.rs | 233 +---- pmocontrol/src/queue/interne.rs | 250 ++++- pmocontrol/src/queue/mod.rs | 44 +- pmocontrol/src/queue/music_queue.rs | 163 ++- pmocontrol/src/queue/openhome.rs | 899 ++++++++-------- pmocontrol/src/queue/snapshot.rs | 91 ++ pmocontrol/src/registry.rs | 287 ++++-- .../{ => upnp_clients}/avtransport_client.rs | 0 .../connection_manager_client.rs | 0 pmocontrol/src/upnp_clients/mod.rs | 41 + .../src/{ => upnp_clients}/openhome_client.rs | 236 +++-- .../rendering_control_client.rs | 0 pmocontrol/src/upnp_renderer.rs | 317 ------ 45 files changed, 4404 insertions(+), 5089 deletions(-) create mode 100644 pmocontrol/src/arylic_client/mod.rs delete mode 100644 pmocontrol/src/arylic_tcp.rs create mode 100644 pmocontrol/src/discovery/arylic.rs create mode 100644 pmocontrol/src/linkplay_client/mod.rs delete mode 100644 pmocontrol/src/linkplay_renderer.rs create mode 100644 pmocontrol/src/linkplay_utils.rs delete mode 100644 pmocontrol/src/music_renderer.rs create mode 100644 pmocontrol/src/music_renderer/arylic_tcp.rs rename pmocontrol/src/{ => music_renderer}/capabilities.rs (62%) rename pmocontrol/src/{ => music_renderer}/chromecast_renderer.rs (93%) create mode 100644 pmocontrol/src/music_renderer/linkplay_renderer.rs create mode 100644 pmocontrol/src/music_renderer/mod.rs create mode 100644 pmocontrol/src/music_renderer/musicrenderer.rs rename pmocontrol/src/{ => music_renderer}/openhome.rs (89%) rename pmocontrol/src/{ => music_renderer}/openhome_renderer.rs (82%) create mode 100644 pmocontrol/src/music_renderer/time_utils.rs create mode 100644 pmocontrol/src/music_renderer/upnp_renderer.rs delete mode 100644 pmocontrol/src/openhome_playlist.rs create mode 100644 pmocontrol/src/queue/snapshot.rs rename pmocontrol/src/{ => upnp_clients}/avtransport_client.rs (100%) rename pmocontrol/src/{ => upnp_clients}/connection_manager_client.rs (100%) create mode 100644 pmocontrol/src/upnp_clients/mod.rs rename pmocontrol/src/{ => upnp_clients}/openhome_client.rs (83%) rename pmocontrol/src/{ => upnp_clients}/rendering_control_client.rs (100%) delete mode 100644 pmocontrol/src/upnp_renderer.rs diff --git a/.vscode/settings.json b/.vscode/settings.json index 2c0ba4ce..71eac550 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,7 +20,7 @@ "rust-analyzer.numThreads": 4, "rust-analyzer.cargo.loadOutDirsFromCheck": false, "rust-analyzer.checkOnSave.enable": true, - "rust-analyzer.checkOnSave.command": "clippy", + "rust-analyzer.checkOnSave.command": "check", "rust-analyzer.checkOnSave.extraArgs": ["--all-features"], "rust-analyzer.exclude": [ "target", diff --git a/pmocontrol/Cargo.toml b/pmocontrol/Cargo.toml index b828d5f3..a5df9755 100644 --- a/pmocontrol/Cargo.toml +++ b/pmocontrol/Cargo.toml @@ -23,13 +23,13 @@ mdns = "3.0" async-std = "1.12" futures-util = "0.3" smol = "2.0" +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } # pmoserver extension support (optional) pmoserver = { path = "../pmoserver", optional = true } utoipa = { version = "5.4.0", optional = true } axum = { version = "0.8.4", optional = true } -serde = { workspace = true, optional = true } -serde_json = { workspace = true, optional = true } tokio = { workspace = true, features = ["sync", "rt"], optional = true } tokio-util = { version = "0.7", optional = true } async-trait = { version = "0.1", optional = true } @@ -38,11 +38,9 @@ async-stream = { version = "0.3", optional = true } chrono = { version = "0.4", features = ["serde"], optional = true } [dev-dependencies] -serde = { workspace = true } -serde_json = { workspace = true } percent-encoding = "2.3" [features] default = [] # Active l'API REST pmoserver -pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:serde", "dep:serde_json", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:chrono"] +pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:chrono"] diff --git a/pmocontrol/src/arylic_client/mod.rs b/pmocontrol/src/arylic_client/mod.rs new file mode 100644 index 00000000..b93d4c0a --- /dev/null +++ b/pmocontrol/src/arylic_client/mod.rs @@ -0,0 +1,288 @@ +use std::{io::{Read, Write}, net::{Shutdown, TcpStream, ToSocketAddrs}, sync::{Mutex, OnceLock}, thread, time::{Duration, Instant}}; + +use tracing::{debug, warn}; + +use crate::errors::ControlPointError; + +pub const ARYLIC_TCP_PORT: u16 = 8899; +pub const DEFAULT_TIMEOUT_SECS: u64 = 3; + +const PACKET_HEADER: [u8; 4] = [0x18, 0x96, 0x18, 0x20]; +const RESERVED_BYTES: [u8; 8] = [0; 8]; +const MAX_RESPONSE_ATTEMPTS: usize = 8; + +// Garde global pour respecter le délai de 200ms entre commandes +static LAST_COMMAND_TIME: OnceLock> = OnceLock::new(); + +fn last_command_time() -> &'static Mutex { + LAST_COMMAND_TIME.get_or_init(|| Mutex::new(Instant::now())) +} + + +/// Mode d’attente de réponse pour une commande TCP Arylic. +enum ResponseMode<'a> { + /// On n’attend aucune réponse (fire-and-forget). + None, + /// On attend une réponse, mais si la lecture échoue immédiatement, on traite comme succès. + Optional(&'a [&'a str]), + /// On attend une réponse, et l’absence de réponse est une erreur. + Required(&'a [&'a str]), +} + +fn send_command_with_mode( + host: &str, + port: u16, + timeout: Duration, + payload: &str, + mode: ResponseMode<'_>, +) -> Result, ControlPointError> { + let mut stream = connect(host, port, timeout)?; + let packet = encode_packet(payload); + + stream.write_all(&packet).map_err(|_| { + ControlPointError::ArilycTcpError( + format!( + "Failed to write Arylic TCP packet for {}: {}", + host, payload + )) + })?; + + stream.flush().map_err(|_| { + ControlPointError::ArilycTcpError( + format!( + "Failed to flush Arylic TCP stream for {} (command {})", + host, payload + )) + })?; + + match mode { + ResponseMode::None => { + debug!( + "Arylic TCP fire-and-forget command sent to {}: {}", + host, payload + ); + let _ = stream.shutdown(Shutdown::Write); + Ok(None) + } + ResponseMode::Required(expected) => { + read_expected_response(&mut stream, host, payload, expected).map(Some) + } + ResponseMode::Optional(expected) => { + for _ in 0..MAX_RESPONSE_ATTEMPTS { + match read_packet(&mut stream) { + Ok(response) => { + if expected.iter().any(|p| response.starts_with(p)) { + return Ok(Some(response)); + } + debug!( + "Ignoring unsolicited Arylic payload from {}: {}", + host, response + ); + } + Err(err) => { + debug!( + "No full response for Arylic TCP command {} on {}: {}. Treating as success and relying on PINFGET.", + payload, host, err + ); + return Ok(None); + } + } + } + + Err(ControlPointError::ArilycTcpError(format!( + "No expected response for optional command {} on {}", + payload, + host + ))) + } + } +} + +fn read_expected_response( + stream: &mut TcpStream, + host: &str, + payload: &str, + expected: &[&str], +) -> Result { + for _ in 0..MAX_RESPONSE_ATTEMPTS { + let response = match read_packet(stream) { + Ok(resp) => resp, + Err(err) => { + return Err(ControlPointError::ArilycTcpError(format!( + "Failed to read Arylic TCP response for {} (command {}): {}", + host, + payload, + err + ))); + } + }; + if expected.iter().any(|prefix| response.starts_with(prefix)) { + return Ok(response); + } + + debug!( + "Ignoring unsolicited Arylic payload from {}: {}", + host, response + ); + } + + Err(ControlPointError::ArilycTcpError(format!( + "No expected response for command {} on {}", + payload, + host + ))) +} + +pub fn send_command_required( + host: &str, + port: u16, + timeout: Duration, + payload: &str, + expected: &[&str], +) -> Result { + match send_command_with_mode( + host, + port, + timeout, + payload, + ResponseMode::Required(expected), + )? { + Some(s) => Ok(s), + None => Err(ControlPointError::ArilycTcpError(format!( + "Arylic TCP: no response payload for required command {}", + payload + ))), + } +} + +pub fn send_command_optional( + host: &str, + port: u16, + timeout: Duration, + payload: &str, + expected: &[&str], +) -> Result, ControlPointError> { + send_command_with_mode( + host, + port, + timeout, + payload, + ResponseMode::Optional(expected), + ) +} + +pub fn send_command_no_response(host: &str, port: u16, timeout: Duration, payload: &str) -> Result<(), ControlPointError> { + send_command_with_mode(host, port, timeout, payload, ResponseMode::None).map(|_| ()) +} + + +fn connect(host: &str, port: u16, timeout: Duration) -> Result { + if let Ok(mut last_time) = last_command_time().lock() { + let elapsed = last_time.elapsed(); + if elapsed < Duration::from_millis(200) { + let wait = Duration::from_millis(200) - elapsed; + debug!( + "Waiting {:?} before sending command to respect 200ms interval", + wait + ); + thread::sleep(wait); + } + *last_time = Instant::now(); + } + + let address = if host.contains(':') { + format!("[{}]:{}", host, port) + } else { + format!("{host}:{port}") + }; + + let mut last_err = None; + for addr in address + .to_socket_addrs() + .map_err(|_| { + ControlPointError::ArilycTcpError(format!("Failed to resolve {}:{}", host, port)) + })? + { + match TcpStream::connect_timeout(&addr, timeout) { + Ok(stream) => { + stream + .set_read_timeout(Some(timeout)) + .and_then(|_| stream.set_write_timeout(Some(timeout))) + .map_err(|err| { + ControlPointError::ArilycTcpError(format!( + "Failed to set socket timeouts for {}", + address + )) + })?; + + return Ok(stream); + } + Err(err) => { + last_err = Some((addr, err)); + } + } + } + + match last_err { + Some((addr, err)) => Err(ControlPointError::ArilycTcpError(format!( + "Failed to connect to {} via {}: {}", + host, addr, err + ))), + None => Err(ControlPointError::ArilycTcpError(format!( + "No socket addresses resolved for {}", + address + ))), + } +} + +fn encode_packet(payload: &str) -> Vec { + let bytes = payload.as_bytes(); + let len = bytes.len() as u32; + let checksum = bytes.iter().fold(0u32, |acc, b| acc + (*b as u32)); + + let mut out = Vec::with_capacity(4 + 4 + 4 + 8 + bytes.len()); + out.extend_from_slice(&PACKET_HEADER); + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(&checksum.to_le_bytes()); + out.extend_from_slice(&RESERVED_BYTES); + out.extend_from_slice(bytes); + out +} + +fn read_packet(stream: &mut TcpStream) -> Result { + let mut header = [0u8; 4]; + stream.read_exact(&mut header) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; + if header != PACKET_HEADER { + return Err(ControlPointError::ArilycTcpError(format!("Invalid Arylic packet header: {:x?}", header))); + } + + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; + let len = u32::from_le_bytes(len_buf) as usize; + + let mut checksum_buf = [0u8; 4]; + stream.read_exact(&mut checksum_buf) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; + let expected_checksum = u32::from_le_bytes(checksum_buf); + + let mut reserved = [0u8; 8]; + stream.read_exact(&mut reserved) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; + + let mut payload = vec![0u8; len]; + stream.read_exact(&mut payload) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; + + let actual_checksum = payload.iter().fold(0u32, |acc, b| acc + (*b as u32)); + if actual_checksum != expected_checksum { + warn!( + "Arylic payload checksum mismatch: expected={} actual={}", + expected_checksum, actual_checksum + ); + } + + Ok(String::from_utf8(payload) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?) +} diff --git a/pmocontrol/src/arylic_tcp.rs b/pmocontrol/src/arylic_tcp.rs deleted file mode 100644 index 7aa18aef..00000000 --- a/pmocontrol/src/arylic_tcp.rs +++ /dev/null @@ -1,671 +0,0 @@ -use std::collections::HashMap; -use std::io::{Read, Write}; -use std::net::{Shutdown, TcpStream, ToSocketAddrs}; -use std::sync::{Mutex, OnceLock}; -use std::thread; -use std::time::Duration; - -use anyhow::{Context, Result}; -use tracing::{debug, warn}; - -use crate::DeviceIdentity; -use crate::capabilities::{ - PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, - VolumeControl, -}; -use crate::errors::ControlPointError; -use crate::linkplay_renderer::{extract_linkplay_host, parse_flat_json}; -use crate::model::RendererInfo; -use std::time::Instant; - -// Garde global pour respecter le délai de 200ms entre commandes -static LAST_COMMAND_TIME: OnceLock> = OnceLock::new(); - -fn last_command_time() -> &'static Mutex { - LAST_COMMAND_TIME.get_or_init(|| Mutex::new(Instant::now())) -} - -const ARYLIC_TCP_PORT: u16 = 8899; -const PACKET_HEADER: [u8; 4] = [0x18, 0x96, 0x18, 0x20]; -const RESERVED_BYTES: [u8; 8] = [0; 8]; -const MAX_RESPONSE_ATTEMPTS: usize = 8; -const DEFAULT_TIMEOUT_SECS: u64 = 3; - -static DETECTION_CACHE: OnceLock>> = OnceLock::new(); - -/// Mode d’attente de réponse pour une commande TCP Arylic. -enum ResponseMode<'a> { - /// On n’attend aucune réponse (fire-and-forget). - None, - /// On attend une réponse, mais si la lecture échoue immédiatement, on traite comme succès. - Optional(&'a [&'a str]), - /// On attend une réponse, et l’absence de réponse est une erreur. - Required(&'a [&'a str]), -} - -fn detection_cache() -> &'static Mutex> { - DETECTION_CACHE.get_or_init(|| Mutex::new(HashMap::new())) -} - -/// Probe whether the renderer at the given location exposes the Arylic TCP API. -pub(crate) fn detect_arylic_tcp(location: &str, timeout: Duration) -> bool { - let Some(host) = extract_linkplay_host(location) else { - return false; - }; - - if let Ok(cache) = detection_cache().lock() { - if let Some(result) = cache.get(&host) { - return *result; - } - } - - let detected = match try_detect_tcp(&host, timeout) { - Ok(_) => true, - Err(err) => { - debug!( - "Arylic TCP detection failed for {} (host={}): {}", - location, host, err - ); - false - } - }; - - if let Ok(mut cache) = detection_cache().lock() { - cache.insert(host, detected); - } - - detected -} - -fn try_detect_tcp(host: &str, timeout: Duration) -> Result<(), ControlPointError> { - let payload = send_command_required( - host, - ARYLIC_TCP_PORT, - timeout, - "MCU+INF+GET", - &["AXX+INF+", "AXX+DEV+"], - )?; - - if payload.starts_with("AXX+INF+") || payload.starts_with("AXX+DEV+") { - Ok(()) - } else { - Err(ControlPointError::ArilycTcpError(format!( - "Unexpected INF response from {}: {}", - host, - payload - ))) - } -} - -/// Backend speaking the Arylic TCP control protocol (port 8899). -#[derive(Clone, Debug)] -pub struct ArylicTcpRenderer { - host: String, - port: u16, - timeout: Duration, -} - -impl ArylicTcpRenderer { - pub fn from_renderer_info(info: RendererInfo) -> Result { - let host = extract_linkplay_host(info.location()) - .ok_or_else(|| ControlPointError::ArilycTcpError(format!("Renderer {} has no valid LOCATION host", info.udn())))?; - - Ok(Self { - host, - port: ARYLIC_TCP_PORT, - timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS), - }) - } - - fn send_required(&self, cmd: &str, expected: &[&str]) -> Result { - send_command_required(&self.host, self.port, self.timeout, cmd, expected) - } - - fn send_optional(&self, cmd: &str, expected: &[&str]) -> Result, ControlPointError> { - send_command_optional(&self.host, self.port, self.timeout, cmd, expected) - } - - fn send_no_response(&self, cmd: &str) -> Result<(), ControlPointError> { - send_command_no_response(&self.host, self.port, self.timeout, cmd) - } - - fn fetch_playback_info(&self) -> Result { - let payload = self.send_required("MCU+PINFGET", &["AXX+PLY+INF"])?; - match parse_playback_info(&payload) { - Ok(info) => Ok(info), - Err(err) => { - debug!( - "Failed to parse Arylic playback info for {}: {}", - self.host, err - ); - Err(err) - } - } - } - - fn format_volume_command(value: u16) -> String { - format!("MCU+VOL+{:03}", value.min(100)) - } - - fn parse_volume_payload(payload: &str) -> Result { - let data = payload - .strip_prefix("AXX+VOL+") - .ok_or_else(|| ControlPointError::ArilycTcpError(format!("Unexpected volume response: {}", payload)))?; - let value: u16 = data - .trim() - .parse() - .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid volume value: {}", data)))?; - Ok(value.min(100)) - } - - fn parse_mute_payload(payload: &str) -> Result { - let data = payload - .strip_prefix("AXX+MUT+") - .ok_or_else(|| ControlPointError::ArilycTcpError(format!("Unexpected mute response: {}", payload)))?; - match data.trim() { - "000" | "0" => Ok(false), - "001" | "1" => Ok(true), - other => Err(ControlPointError::ArilycTcpError(format!("Invalid mute value: {}", other))), - } - } -} - -impl TransportControl for ArylicTcpRenderer { - fn play_uri(&self, _uri: &str, _meta: &str) -> Result<(), ControlPointError> { - Err(ControlPointError::upnp_operation_not_supported( - "Arylic TCP backend does not support direct URL loading.", - "ArylicTcpRenderer", - )) - } - - fn play(&self) -> Result<(), ControlPointError> { - self.send_no_response("MCU+PLY-PLA") - } - - fn pause(&self) -> Result<(), ControlPointError> { - let _ = self.send_optional("MCU+PLY-PUS", &["AXX+PLY+"])?; - Ok(()) - } - - fn stop(&self) -> Result<(), ControlPointError> { - self.send_no_response("MCU+PLY-STP") - } - - fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { - let _ = parse_hhmmss(hhmmss)?; - Err(ControlPointError::ArilycTcpError(format!( - "Arylic TCP seek_rel_time is not implemented yet for this device." - ))) - } -} - -impl VolumeControl for ArylicTcpRenderer { - fn volume(&self) -> Result { - if let Ok(info) = self.fetch_playback_info() { - if let Some(vol) = info.volume { - return Ok(vol); - } - debug!( - "Arylic playback info for {} missing volume, falling back to VOL GET", - self.host - ); - } - let payload = self.send_required("MCU+VOL+GET", &["AXX+VOL+"])?; - Self::parse_volume_payload(&payload) - } - - fn set_volume(&self, v: u16) -> Result<(), ControlPointError> { - let command = Self::format_volume_command(v); - let _ = self.send_optional(&command, &["AXX+VOL+"])?; - Ok(()) - } - - fn mute(&self) -> Result { - if let Ok(info) = self.fetch_playback_info() { - if let Some(mute) = info.mute { - return Ok(mute); - } - debug!( - "Arylic playback info for {} missing mute, falling back to MUT GET", - self.host - ); - } - let payload = self.send_required("MCU+MUT+GET", &["AXX+MUT+"])?; - Self::parse_mute_payload(&payload) - } - - fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { - let command = if m { "MCU+MUT+001" } else { "MCU+MUT+000" }; - let payload = self.send_required(command, &["AXX+MUT+"])?; - let _ = Self::parse_mute_payload(&payload)?; - Ok(()) - } -} - -impl PlaybackStatus for ArylicTcpRenderer { - fn playback_state(&self) -> Result { - let info = self.fetch_playback_info()?; - Ok(info.playback_state()) - } -} - -impl PlaybackPosition for ArylicTcpRenderer { - fn playback_position(&self) -> Result { - let info = self.fetch_playback_info()?; - Ok(info.position_info()) - } -} - -#[derive(Debug)] -struct ArylicPlaybackInfo { - status_raw: String, - curpos_ms: u64, - totlen_ms: u64, - volume: Option, - mute: Option, - playlist_size: Option, - track_index: Option, -} - -impl ArylicPlaybackInfo { - fn playback_state(&self) -> PlaybackState { - match self.status_raw.as_str() { - "play" => PlaybackState::Playing, - "pause" => PlaybackState::Paused, - "stop" => PlaybackState::Stopped, - other => PlaybackState::Unknown(other.to_string()), - } - } - - fn position_info(&self) -> PlaybackPositionInfo { - let track = match (self.track_index, self.playlist_size) { - (Some(idx), Some(count)) if count > 0 => Some(idx.min(count)), - (Some(idx), _) => Some(idx), - _ => None, - }; - - PlaybackPositionInfo { - track, - rel_time: Some(format_hms(self.curpos_ms / 1000)), - abs_time: None, - track_duration: if self.totlen_ms > 0 { - Some(format_hms(self.totlen_ms / 1000)) - } else { - None - }, - track_metadata: None, - track_uri: None, - } - } -} - -fn parse_playback_info(payload: &str) -> Result { - let json_blob = payload - .strip_prefix("AXX+PLY+INF") - .ok_or_else(|| ControlPointError::ArilycTcpError(format!("Unexpected playback info prefix: {}", payload)))?; - - let json_blob = json_blob.trim_end_matches('&').trim(); - let map = parse_flat_json(json_blob)?; - - let status_raw = map - .get("status") - .cloned() - .ok_or_else(|| ControlPointError::ArilycTcpError(format!("Playback info missing `status` field")))?; - - let curpos_ms = parse_u64_field(&map, "curpos")?; - let totlen_ms = parse_u64_field(&map, "totlen")?; - - let volume = match map.get("vol") { - Some(raw) => match raw.parse::() { - Ok(value) => Some(value.min(100)), - Err(err) => { - debug!("Invalid Arylic `vol` value {}: {}", raw, err); - None - } - }, - None => None, - }; - - let mute = match map.get("mute") { - Some(value) if value == "1" => Some(true), - Some(value) if value == "0" => Some(false), - Some(other) => { - debug!("Invalid Arylic `mute` value {}", other); - None - } - None => None, - }; - - let playlist_size = map - .get("plicount") - .and_then(|raw| match raw.parse::() { - Ok(count) if count > 0 => Some(count), - Ok(_) => None, - Err(err) => { - debug!("Invalid Arylic `plicount` value {}: {}", raw, err); - None - } - }); - - let track_index = map.get("plicurr").and_then(|raw| match raw.parse::() { - Ok(idx) if idx > 0 => Some(idx), - Ok(_) => None, - Err(err) => { - debug!("Invalid Arylic `plicurr` value {}: {}", raw, err); - None - } - }); - - Ok(ArylicPlaybackInfo { - status_raw, - curpos_ms, - totlen_ms, - volume, - mute, - playlist_size, - track_index, - }) -} - -fn parse_u64_field(map: &HashMap, key: &str) -> Result { - let raw = map - .get(key) - .ok_or_else(|| ControlPointError::ArilycTcpError(format!("Playback info missing `{}` field", key)))?; - raw.parse::() - .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid `{}` value: {}", key, raw))) -} - -fn connect(host: &str, port: u16, timeout: Duration) -> Result { - if let Ok(mut last_time) = last_command_time().lock() { - let elapsed = last_time.elapsed(); - if elapsed < Duration::from_millis(200) { - let wait = Duration::from_millis(200) - elapsed; - debug!( - "Waiting {:?} before sending command to respect 200ms interval", - wait - ); - thread::sleep(wait); - } - *last_time = Instant::now(); - } - - let address = if host.contains(':') { - format!("[{}]:{}", host, port) - } else { - format!("{host}:{port}") - }; - - let mut last_err = None; - for addr in address - .to_socket_addrs() - .with_context(|| format!("Failed to resolve {}:{}", host, port)) - .map_err(|_| { - ControlPointError::ArilycTcpError(format!("Failed to resolve {}:{}", host, port)) - })? - { - match TcpStream::connect_timeout(&addr, timeout) { - Ok(stream) => { - stream - .set_read_timeout(Some(timeout)) - .and_then(|_| stream.set_write_timeout(Some(timeout))) - .with_context(|| format!("Failed to set socket timeouts for {}", address)) - .map_err(|err| { - ControlPointError::ArilycTcpError(format!( - "Failed to set socket timeouts for {}", - address - )) - })?; - - return Ok(stream); - } - Err(err) => { - last_err = Some((addr, err)); - } - } - } - - match last_err { - Some((addr, err)) => Err(ControlPointError::ArilycTcpError(format!( - "Failed to connect to {} via {}: {}", - host, addr, err - ))), - None => Err(ControlPointError::ArilycTcpError(format!( - "No socket addresses resolved for {}", - address - ))), - } -} - -fn encode_packet(payload: &str) -> Vec { - let bytes = payload.as_bytes(); - let len = bytes.len() as u32; - let checksum = bytes.iter().fold(0u32, |acc, b| acc + (*b as u32)); - - let mut out = Vec::with_capacity(4 + 4 + 4 + 8 + bytes.len()); - out.extend_from_slice(&PACKET_HEADER); - out.extend_from_slice(&len.to_le_bytes()); - out.extend_from_slice(&checksum.to_le_bytes()); - out.extend_from_slice(&RESERVED_BYTES); - out.extend_from_slice(bytes); - out -} - -fn read_packet(stream: &mut TcpStream) -> Result { - let mut header = [0u8; 4]; - stream.read_exact(&mut header) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; - if header != PACKET_HEADER { - return Err(ControlPointError::ArilycTcpError(format!("Invalid Arylic packet header: {:x?}", header))); - } - - let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; - let len = u32::from_le_bytes(len_buf) as usize; - - let mut checksum_buf = [0u8; 4]; - stream.read_exact(&mut checksum_buf) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; - let expected_checksum = u32::from_le_bytes(checksum_buf); - - let mut reserved = [0u8; 8]; - stream.read_exact(&mut reserved) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; - - let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; - - let actual_checksum = payload.iter().fold(0u32, |acc, b| acc + (*b as u32)); - if actual_checksum != expected_checksum { - warn!( - "Arylic payload checksum mismatch: expected={} actual={}", - expected_checksum, actual_checksum - ); - } - - Ok(String::from_utf8(payload) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?) -} - -fn format_hms(secs: u64) -> String { - let h = secs / 3600; - let m = (secs % 3600) / 60; - let s = secs % 60; - format!("{:02}:{:02}:{:02}", h, m, s) -} - -fn parse_hhmmss(value: &str) -> Result { - let parts: Vec<_> = value.split(':').collect(); - if parts.len() != 3 { - return Err(ControlPointError::ArilycTcpError(format!( - "Invalid time format `{}`. Expected HH:MM:SS.", - value - ))); - } - - let hours: u64 = parts[0] - .parse() - .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid hour component in {}", value)))?; - let minutes: u64 = parts[1] - .parse() - .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid minute component in {}", value)))?; - let seconds: u64 = parts[2] - .parse() - .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid second component in {}", value)))?; - - if minutes > 59 || seconds > 59 { - return Err(ControlPointError::ArilycTcpError(format!( - "Invalid HH:MM:SS value `{}`. Minutes and seconds must be < 60.", - value - ))); - } - - Ok(hours * 3600 + minutes * 60 + seconds) -} - -fn send_command_with_mode( - host: &str, - port: u16, - timeout: Duration, - payload: &str, - mode: ResponseMode<'_>, -) -> Result, ControlPointError> { - let mut stream = connect(host, port, timeout)?; - let packet = encode_packet(payload); - - stream.write_all(&packet).map_err(|_| { - ControlPointError::ArilycTcpError( - format!( - "Failed to write Arylic TCP packet for {}: {}", - host, payload - )) - })?; - - stream.flush().map_err(|_| { - ControlPointError::ArilycTcpError( - format!( - "Failed to flush Arylic TCP stream for {} (command {})", - host, payload - )) - })?; - - match mode { - ResponseMode::None => { - debug!( - "Arylic TCP fire-and-forget command sent to {}: {}", - host, payload - ); - let _ = stream.shutdown(Shutdown::Write); - Ok(None) - } - ResponseMode::Required(expected) => { - read_expected_response(&mut stream, host, payload, expected).map(Some) - } - ResponseMode::Optional(expected) => { - for _ in 0..MAX_RESPONSE_ATTEMPTS { - match read_packet(&mut stream) { - Ok(response) => { - if expected.iter().any(|p| response.starts_with(p)) { - return Ok(Some(response)); - } - debug!( - "Ignoring unsolicited Arylic payload from {}: {}", - host, response - ); - } - Err(err) => { - debug!( - "No full response for Arylic TCP command {} on {}: {}. Treating as success and relying on PINFGET.", - payload, host, err - ); - return Ok(None); - } - } - } - - Err(ControlPointError::ArilycTcpError(format!( - "No expected response for optional command {} on {}", - payload, - host - ))) - } - } -} - -fn read_expected_response( - stream: &mut TcpStream, - host: &str, - payload: &str, - expected: &[&str], -) -> Result { - for _ in 0..MAX_RESPONSE_ATTEMPTS { - let response = match read_packet(stream) { - Ok(resp) => resp, - Err(err) => { - return Err(ControlPointError::ArilycTcpError(format!( - "Failed to read Arylic TCP response for {} (command {}): {}", - host, - payload, - err - ))); - } - }; - if expected.iter().any(|prefix| response.starts_with(prefix)) { - return Ok(response); - } - - debug!( - "Ignoring unsolicited Arylic payload from {}: {}", - host, response - ); - } - - Err(ControlPointError::ArilycTcpError(format!( - "No expected response for command {} on {}", - payload, - host - ))) -} - -fn send_command_required( - host: &str, - port: u16, - timeout: Duration, - payload: &str, - expected: &[&str], -) -> Result { - match send_command_with_mode( - host, - port, - timeout, - payload, - ResponseMode::Required(expected), - )? { - Some(s) => Ok(s), - None => Err(ControlPointError::ArilycTcpError(format!( - "Arylic TCP: no response payload for required command {}", - payload - ))), - } -} - -fn send_command_optional( - host: &str, - port: u16, - timeout: Duration, - payload: &str, - expected: &[&str], -) -> Result, ControlPointError> { - send_command_with_mode( - host, - port, - timeout, - payload, - ResponseMode::Optional(expected), - ) -} - -fn send_command_no_response(host: &str, port: u16, timeout: Duration, payload: &str) -> Result<(), ControlPointError> { - send_command_with_mode(host, port, timeout, payload, ResponseMode::None).map(|_| ()) -} diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index d81d2251..5a20d50c 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -18,8 +18,6 @@ use tracing::{debug, error, info, warn}; use ureq::{http, Agent}; use xmltree::{Element, XMLNode}; -pub mod music_queue; -pub mod openhome_queue; pub const OPENHOME_SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(2); @@ -30,9 +28,10 @@ use crate::capabilities::{ use crate::control_point::music_queue::MusicQueue; use crate::control_point::openhome_queue::OpenHomeQueue; use crate::discovery::DiscoveryManager; +use crate::discovery::manager::UDNRegistry; use crate::events::{MediaServerEventBus, RendererEventBus}; use crate::media_server::{ - playback_item_from_entry, MediaBrowser, UpnpMediaServer, UpnpMediaServer, ServerId, + playback_item_from_entry, MediaBrowser, UpnpMediaServer, ServerId, }; use crate::media_server_events::spawn_media_server_event_runtime; use crate::model::TrackMetadata; @@ -48,7 +47,8 @@ use crate::openhome::{ }; use crate::openhome_client::parse_track_metadata_from_didl; use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; -use crate::openhome_renderer::{format_seconds, map_openhome_state}; +use crate::music_renderer::openhome_renderer::map_openhome_state; +use crate::music_renderer::time_utils::format_hhmmss_u32; use crate::provider::HttpXmlDescriptionProvider; use crate::queue::backend::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot}; use crate::queue_interne::InternalQueue; @@ -101,18 +101,6 @@ pub struct ControlPoint { event_bus: RendererEventBus, media_event_bus: MediaServerEventBus, runtime: Arc, - /// Optional attachment between a renderer playback queue and a - /// server-side DIDL-Lite playlist container. - /// - /// Key : RendererId - /// Value : PlaylistBinding - playlist_bindings: Arc>>, - /// Cache of MusicRenderer instances to avoid recreating them. - /// This is critical for Chromecast which maintains a persistent TLS connection. - /// - /// Key : RendererId - /// Value : MusicRenderer - renderer_cache: Arc>>, } impl ControlPoint { @@ -124,11 +112,6 @@ impl ControlPoint { let event_bus = RendererEventBus::new(); let media_event_bus = MediaServerEventBus::new(); let runtime = Arc::new(RuntimeState::new()); - // set_openhome_queue_provider(Arc::new(RuntimeOpenHomeQueueProvider { - // runtime: Arc::clone(&runtime), - // })); - let playlist_bindings = Arc::new(Mutex::new(HashMap::new())); - let renderer_cache = Arc::new(Mutex::new(HashMap::new())); // SsdpClient let client = SsdpClient::new()?; // pmoupnp::ssdp::SsdpClient @@ -403,8 +386,6 @@ impl ControlPoint { event_bus: event_bus.clone(), media_event_bus: media_event_bus.clone(), runtime: Arc::clone(&runtime), - playlist_bindings: Arc::clone(&playlist_bindings), - renderer_cache: Arc::clone(&renderer_cache), }; thread::spawn(move || { @@ -416,37 +397,12 @@ impl ControlPoint { reg.list_renderers() }; - // Build a map of current renderer IDs for cleanup - let current_ids: HashSet = infos.iter().map(|i| i.id.clone()).collect(); - - // Remove offline renderers from shared cache - { - let mut cache = runtime_cp.renderer_cache.lock().unwrap(); - cache.retain(|id, _| current_ids.contains(id)); - } - - // Get or create renderers from shared cache + // Create MusicRendererBackend for each renderer from registry + // No caching needed - backends manage their own connections let renderers: Vec = infos .into_iter() .filter_map(|info| { - let id = info.id.clone(); - - // Try to get from cache first - { - let cache = runtime_cp.renderer_cache.lock().unwrap(); - if let Some(renderer) = cache.get(&id) { - return Some(renderer.clone()); - } - } - - // Create new renderer and add to cache - if let Some(renderer) = MusicRendererBackend::from_renderer_info(info, &runtime_cp.registry) { - let mut cache = runtime_cp.renderer_cache.lock().unwrap(); - cache.insert(id, renderer.clone()); - Some(renderer) - } else { - None - } + MusicRendererBackend::from_renderer_info(info, &runtime_cp.registry) }) .collect(); @@ -457,54 +413,13 @@ impl ControlPoint { continue; } - let backend = if info.capabilities.has_oh_playlist { - PlaylistBackend::OpenHome - } else { - PlaylistBackend::PMOQueue - }; - let previous_backend = runtime_cp.runtime.playlist_backend(&info.id); - let runtime_entry_exists = runtime_cp.runtime.has_entry(&info.id); - - // Initialize queue if: backend changed OR runtime entry doesn't exist yet - if previous_backend != backend || !runtime_entry_exists { - runtime_cp.runtime.set_playlist_backend(&info.id, backend); - match backend { - PlaylistBackend::OpenHome => { - if let Some(queue) = build_openhome_queue(info) { - runtime_cp - .runtime - .set_music_queue(&info.id, MusicQueue::OpenHome(queue)); - } else { - runtime_cp.runtime.set_music_queue( - &info.id, - MusicQueue::Internal(InternalQueue::new()), - ); - } - } - PlaylistBackend::PMOQueue => { - runtime_cp.runtime.set_music_queue( - &info.id, - MusicQueue::Internal(InternalQueue::new()), - ); - } - } - if matches!(backend, PlaylistBackend::OpenHome) { - if let Err(err) = sync_openhome_playlist( - &runtime_cp.registry, - &runtime_cp.runtime, - &runtime_cp.event_bus, - &info.id, - ) { - debug!( - renderer = info.id.0.as_str(), - error = %err, - "Initial OpenHome playlist sync failed" - ); - } - } - } - let renderer_id = info.id.clone(); + + // Initialize runtime entry if it doesn't exist + // Queue is managed by MusicRenderer, not RuntimeState + if !runtime_cp.runtime.has_entry(&renderer_id) { + runtime_cp.runtime.create_entry(&renderer_id); + } let prev_snapshot = runtime_cp.runtime.snapshot_for(&renderer_id); let mut new_snapshot = prev_snapshot.clone(); let prev_position = prev_snapshot.position.clone(); @@ -633,8 +548,6 @@ impl ControlPoint { event_bus: event_bus.clone(), media_event_bus: media_event_bus.clone(), runtime: Arc::clone(&runtime), - playlist_bindings: Arc::clone(&playlist_bindings), - renderer_cache: Arc::clone(&renderer_cache), }; thread::Builder::new() @@ -650,14 +563,12 @@ impl ControlPoint { Arc::clone(&runtime), event_bus.clone(), oh_event_tx, - Arc::clone(&playlist_bindings), )?; // Worker thread to process MediaServerEvent and trigger queue refreshes // for renderers bound to updated playlist containers 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(); @@ -687,17 +598,26 @@ impl ControlPoint { server_id, container_ids, } => { + // Get all renderers from registry and check their bindings let renderers_to_refresh: Vec = { - let mut bindings = bindings_for_media_worker.lock().unwrap(); + let reg = registry_for_media_worker.read().unwrap(); let mut to_refresh = Vec::new(); - for (renderer_id, binding) in bindings.iter_mut() { - if binding.server_id == server_id - && container_ids.contains(&binding.container_id) - { - binding.pending_refresh = true; - binding.has_seen_update = true; - to_refresh.push(renderer_id.clone()); + if let Ok(renderers) = reg.list_renderers() { + for renderer in renderers { + let renderer_id = renderer.info().id.clone(); + if let Some(binding) = renderer.get_playlist_binding() { + if binding.server_id == server_id + && container_ids.contains(&binding.container_id) + { + // Update binding to mark refresh needed + let mut updated_binding = binding.clone(); + updated_binding.pending_refresh = true; + updated_binding.has_seen_update = true; + renderer.set_playlist_binding(Some(updated_binding)); + to_refresh.push(renderer_id); + } + } } } @@ -714,7 +634,6 @@ impl ControlPoint { if let Err(err) = refresh_attached_queue_for( ®istry_for_media_worker, &runtime_for_media_worker, - &bindings_for_media_worker, &renderer_id, &event_bus_for_media_worker, None, @@ -748,7 +667,6 @@ impl ControlPoint { // Every 60 seconds, trigger a refresh for all renderers with active bindings 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() @@ -760,12 +678,20 @@ impl ControlPoint { // Collect all renderers with active bindings and mark them for refresh let renderers_to_refresh: Vec = { - let mut bindings = bindings_for_periodic.lock().unwrap(); + let reg = registry_for_periodic.read().unwrap(); let mut to_refresh = Vec::new(); - for (renderer_id, binding) in bindings.iter_mut() { - binding.pending_refresh = true; - to_refresh.push(renderer_id.clone()); + if let Ok(renderers) = reg.list_renderers() { + for renderer in renderers { + let renderer_id = renderer.info().id.clone(); + if let Some(binding) = renderer.get_playlist_binding() { + // Mark binding for refresh + let mut updated_binding = binding.clone(); + updated_binding.pending_refresh = true; + renderer.set_playlist_binding(Some(updated_binding)); + to_refresh.push(renderer_id); + } + } } to_refresh @@ -781,7 +707,6 @@ impl ControlPoint { if let Err(err) = refresh_attached_queue_for( ®istry_for_periodic, &runtime_for_periodic, - &bindings_for_periodic, &renderer_id, &event_bus_for_periodic, None, @@ -798,11 +723,10 @@ impl ControlPoint { Ok(Self { registry, + udn_cache: Arc::new(Mutex::new(UDNRegistry::new())), event_bus, media_event_bus, runtime, - playlist_bindings, - renderer_cache, }) } @@ -842,70 +766,23 @@ impl ControlPoint { } - /// Internal helper to get or create a renderer from the cache. - /// This ensures that Chromecast renderers maintain their persistent connections. - fn get_or_create_renderer(&self, info: RendererInfo) -> Option { - let id = info.id(); - - // Try to get from cache first - { - let cache = self.renderer_cache.lock().unwrap(); - if let Some(renderer) = cache.get(&id) { - return Some(renderer.clone()); - } - } - - // Not in cache, create new renderer - if let Some(renderer) = MusicRendererBackend::from_renderer_info(info, &self.registry) { - // Add to cache - let mut cache = self.renderer_cache.lock().unwrap(); - cache.insert(id, renderer.clone()); - Some(renderer) - } else { - None - } - } /// Snapshot list of music renderers (protocol-agnostic view). - pub fn list_music_renderers(&self) -> Vec { - let infos = { - let reg = self.registry.read().unwrap(); - reg.list_renderers() - }; - - // Clean up cache - remove renderers that are no longer in the registry - { - let current_ids: HashSet = infos.iter().map(|i| i.id.clone()).collect(); - let mut cache = self.renderer_cache.lock().unwrap(); - cache.retain(|id, _| current_ids.contains(id)); - } - - infos - .into_iter() - .filter_map(|info| self.get_or_create_renderer(info)) - .collect() + pub fn list_music_renderers(&self) -> Vec> { + let reg = self.registry.read().unwrap(); + reg.list_renderers().unwrap_or_else(|_| vec![]) } /// Return the first music renderer in the registry, if any. - pub fn default_music_renderer(&self) -> Option { - let infos = { - let reg = self.registry.read().unwrap(); - reg.list_renderers() - }; - - infos - .into_iter() - .find_map(|info| self.get_or_create_renderer(info)) + pub fn default_music_renderer(&self) -> Option> { + let reg = self.registry.read().unwrap(); + reg.list_renderers().ok()?.into_iter().next() } /// Lookup a music renderer by id. - pub fn music_renderer_by_id(&self, id: &ServiceId) -> Option { - let info = { - let reg = self.registry.read().unwrap(); - reg.get_renderer(id) - }?; - - self.get_or_create_renderer(info) + pub fn music_renderer_by_id(&self, id: &ServiceId) -> Option> { + let reg = self.registry.read().unwrap(); + reg.get_renderer(id) } /// Snapshot list of media servers currently known by the registry. @@ -926,7 +803,7 @@ impl ControlPoint { /// `detach_playlist_binding` beforehand so that any server-side playlist /// attachment stays consistent with the local `QueueBackend` snapshot. /// The actual structural change then goes through the backend helpers - /// (`QueueBackend::clear_queue` via `RuntimeState::with_music_queue_mut`). + /// (`QueueBackend::clear_queue` via `MusicRenderer::get_queue_mut()`). pub fn clear_queue(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { if !self.runtime.has_entry(renderer_id) { let err = Self::runtime_entry_missing(renderer_id); @@ -943,18 +820,15 @@ impl ControlPoint { // Clear the queue on the backend (backend-agnostic) let renderer = self.music_renderer_by_id(renderer_id) .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + + // Get queue length before clearing + let removed = renderer.get_queue().upcoming_len().unwrap_or(0); + renderer.clear_queue()?; // Sync backend state to local cache (for OpenHome, this updates the cache) renderer.sync_queue_state()?; - // Clear the local queue state - let removed = self.runtime.with_music_queue_mut(renderer_id, |queue| { - let removed = queue.upcoming_len()?; - queue.clear_queue()?; - Ok(removed) - })?; - debug!( renderer = renderer_id.0.as_str(), items_removed = removed, @@ -977,7 +851,7 @@ impl ControlPoint { /// Each caller-triggered queue mutation must first detach any playlist binding /// to avoid diverging from the server container, then manipulate the queue /// strictly through the `QueueBackend` helpers (here `QueueBackend::enqueue_items` - /// inside `RuntimeState::with_music_queue_mut`). + /// accessed via `MusicRenderer::get_queue_mut()`). pub fn enqueue_items( &self, renderer_id: &ServiceId, @@ -997,10 +871,15 @@ impl ControlPoint { // Enqueue items using QueueBackend abstraction (works for both backends) let item_count = items.len(); - let new_len = self.runtime.with_music_queue_mut(renderer_id, |queue| { + let renderer = self + .music_renderer_by_id(renderer_id) + .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + + let new_len = { + let mut queue = renderer.get_queue_mut(); queue.enqueue_items(items, EnqueueMode::AppendToEnd)?; - queue.upcoming_len() - })?; + queue.upcoming_len()? + }; debug!( renderer = renderer_id.0.as_str(), @@ -1060,9 +939,13 @@ impl ControlPoint { return Err(Self::runtime_entry_missing(renderer_id)); } - self.runtime - .queue_full_snapshot(renderer_id) - .ok_or_else(|| Self::runtime_entry_missing(renderer_id)) + // Get queue from renderer + let renderer = self.music_renderer_by_id(renderer_id) + .map_err(|_| Self::runtime_entry_missing(renderer_id))?; + let queue = renderer.get_queue(); + let snapshot = queue.queue_snapshot() + .map_err(|e| ControlPointError::QueueError(e.to_string()))?; + Ok((snapshot.items, snapshot.current_index)) } /// Read-only accessor to the last known metadata for the renderer. @@ -1094,15 +977,6 @@ impl ControlPoint { Ok(snapshot.map(|s| s.len()).unwrap_or(0)) } - pub fn get_cached_openhome_playlist_snapshot( - &self, - renderer_id: &ServiceId, - ttl: Duration, - ) -> anyhow::Result { - let renderer = self.openhome_renderer(renderer_id)?; - self.runtime.openhome_snapshot_cached(&renderer, ttl) - } - /// Build a fully consistent snapshot for UI consumers (state + queue + binding). #[cfg(feature = "pmoserver")] pub fn renderer_full_snapshot( @@ -1114,31 +988,21 @@ impl ControlPoint { .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; let info = renderer.info(); - // Get runtime snapshot (needed for all backends: volume, state, position) - let (runtime_snapshot, runtime_queue_items, runtime_current_index) = - self.runtime.renderer_snapshot_bundle(renderer_id); + // Get runtime snapshot (state, position, volume, metadata) + let runtime_snapshot = self.runtime.snapshot_for(renderer_id); - // Try to get queue from backend (returns Some for OpenHome, None for others) - let backend_queue = renderer.queue_snapshot()?; + // Get queue from renderer (works for all backends) + let queue_snapshot = renderer.get_queue().queue_snapshot() + .map_err(|e| anyhow!("Failed to get queue snapshot: {}", e))?; - // Use backend queue if available (OpenHome), otherwise use runtime queue (AVTransport) - let (queue_items, mut queue_current_index, from_backend) = if let Some(snapshot) = backend_queue { - debug!( - renderer = renderer_id.0.as_str(), - current_index = ?snapshot.current_index, - track_count = snapshot.items.len(), - "renderer_full_snapshot: using backend queue (OpenHome)" - ); - (snapshot.items, snapshot.current_index, true) - } else { - (runtime_queue_items, runtime_current_index, false) - }; + let queue_items = queue_snapshot.items; + let mut queue_current_index = queue_snapshot.current_index; let playback_source = self.runtime.playback_source(renderer_id); let queue_len = queue_items.len(); - // For non-backend queues (AVTransport), try heuristics to determine current_index - if !from_backend && queue_current_index.is_none() { + // Try heuristics to determine current_index if not set + if queue_current_index.is_none() { if let Some(position) = runtime_snapshot.position.as_ref() { if let Some(uri) = position.track_uri.as_ref() { if let Some(idx) = queue_items.iter().position(|item| item.uri == *uri) { @@ -1201,21 +1065,8 @@ impl ControlPoint { .and_then(|idx| queue_items.get(idx)) .map(current_track_from_playback_item); - // For backend queues (OpenHome), prefer queue metadata since runtime isn't updated - // For runtime queues (AVTransport), prefer runtime metadata which is fresher - let current_track = if from_backend { - queue_current_metadata.or_else(|| { - runtime_snapshot - .last_metadata - .as_ref() - .map(|meta| CurrentTrackMetadata { - title: meta.title.clone(), - artist: meta.artist.clone(), - album: meta.album.clone(), - album_art_uri: meta.album_art_uri.clone(), - }) - }) - } else { + // Prefer queue metadata, fallback to runtime metadata + let current_track = queue_current_metadata.or_else(|| { runtime_snapshot .last_metadata .as_ref() @@ -1225,8 +1076,7 @@ impl ControlPoint { album: meta.album.clone(), album_art_uri: meta.album_art_uri.clone(), }) - .or(queue_current_metadata) - }; + }); let state_view = RendererStateView { id: renderer_id.0.clone(), @@ -1323,15 +1173,8 @@ impl ControlPoint { anyhow!("Renderer {} not found", renderer_id.0) })?; - // If renderer has a backend queue (OpenHome), use backend playback - if renderer.queue_snapshot()?.is_some() { - renderer.play_current_from_backend_queue()?; - self.runtime.set_playback_source(renderer_id, PlaybackSource::FromQueue); - return Ok(()); - } - - // Otherwise use local queue playback (AVTransport) - let Some((item, remaining)) = self.runtime.peek_current(renderer_id) else { + // Use generic queue access (works for all backends) + let Some((item, remaining)) = renderer.get_queue().peek_current()? else { debug!( renderer = renderer_id.0.as_str(), "play_current_from_queue: queue is empty or no current item" @@ -1408,14 +1251,8 @@ impl ControlPoint { anyhow!("Renderer {} not found", renderer_id.0) })?; - // If renderer has a backend queue (OpenHome), use backend playback - if renderer.queue_snapshot()?.is_some() { - renderer.play_next_from_backend_queue()?; - self.runtime.set_playback_source(renderer_id, PlaybackSource::FromQueue); - return Ok(()); - } - - let Some((item, remaining_after)) = self.runtime.dequeue_next(renderer_id) else { + // Use generic queue access (works for all backends) + let Some((item, remaining_after)) = renderer.get_queue_mut().dequeue_next()? else { debug!( renderer = renderer_id.0.as_str(), "play_next_from_queue: queue is empty" @@ -1458,12 +1295,9 @@ impl ControlPoint { error = %err, "Failed to start playback for queued item" ); - if self - .runtime - .with_music_queue_mut(renderer_id, |queue| { - queue.enqueue_items(vec![item.clone()], EnqueueMode::InsertAfterCurrent)?; - Ok(()) - }) + // Try to requeue the item + if renderer.get_queue_mut() + .enqueue_items(vec![item.clone()], EnqueueMode::InsertAfterCurrent) .is_err() { warn!( @@ -1540,48 +1374,21 @@ impl ControlPoint { anyhow!("Renderer {} not found", renderer_id.0) })?; - // For backend queues (OpenHome), use select_track_index which handles the track_id lookup - if renderer.queue_snapshot()?.is_some() { - info!( - renderer = renderer_id.0.as_str(), - index, - "Seeking to OpenHome queue index" - ); + // Use generic queue access (works for all backends) + renderer.get_queue_mut().set_index(Some(index))?; - // Use the queue to select the track by index - self.runtime.with_music_queue_mut(renderer_id, |queue| { - if let crate::control_point::music_queue::MusicQueue::OpenHome(oh_queue) = queue { - oh_queue.select_track_index(index) - } else { - Err(anyhow!("Expected OpenHome queue")) - } - })?; - - info!( - renderer = renderer_id.0.as_str(), - index, - "Successfully seeked to OpenHome queue index" - ); - - self.runtime - .set_playback_source(renderer_id, PlaybackSource::FromQueue); - return self.sync_openhome_playlist_for(renderer_id); - } - - // For internal queue, set the index and play - self.runtime.with_music_queue_mut(renderer_id, |queue| { - queue.set_index(Some(index)) - })?; - - let Some((item, remaining)) = self.runtime.peek_current(renderer_id) else { - debug!( - renderer = renderer_id.0.as_str(), - index, - "play_queue_index: no item at index" - ); - self.runtime - .set_playback_source(renderer_id, PlaybackSource::None); - return Ok(()); + let (item, remaining) = match renderer.get_queue().peek_current()? { + Some(result) => result, + None => { + debug!( + renderer = renderer_id.0.as_str(), + index, + "play_queue_index: no item at index" + ); + self.runtime + .set_playback_source(renderer_id, PlaybackSource::None); + return Ok(()); + } }; debug!( @@ -1668,9 +1475,10 @@ impl ControlPoint { // Check if queue has ANY items (not just upcoming items after current) // This is important for newly attached playlists with current_index set let has_items = self - .runtime - .queue_full_snapshot(renderer_id) - .map(|(items, _)| !items.is_empty()) + .music_renderer_by_id(renderer_id) + .ok() + .and_then(|r| r.get_queue().queue_snapshot().ok()) + .map(|s| !s.items.is_empty()) .unwrap_or(false); if !has_items { debug!( @@ -1680,13 +1488,15 @@ impl ControlPoint { return Ok(()); } - let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { - anyhow!("Renderer {} not found", renderer_id.0) - })?; + // Play current item if index is set, otherwise play next (advances index to 0) + let has_current_index = self + .music_renderer_by_id(renderer_id) + .ok() + .and_then(|r| r.get_queue().queue_snapshot().ok()) + .and_then(|s| s.current_index) + .is_some(); - // For backend queues (OpenHome), play current (renderer tracks its own position) - // For local queues (AVTransport), dequeue next (we manage position locally) - if renderer.queue_snapshot()?.is_some() { + if has_current_index { self.play_current_from_queue(renderer_id) } else { self.play_next_from_queue(renderer_id) @@ -1797,10 +1607,7 @@ impl ControlPoint { // Clear the local queue (detach binding + clear runtime queue structure) self.detach_playlist_binding(renderer_id, "attach_new_playlist"); - self.runtime.with_music_queue_mut(renderer_id, |queue| { - queue.clear_queue()?; - Ok(()) - })?; + renderer.get_queue_mut().clear_queue()?; debug!( renderer = renderer_id.0.as_str(), @@ -1815,17 +1622,18 @@ impl ControlPoint { auto_play_on_refresh: auto_play, }; - { - let mut bindings = self.playlist_bindings.lock().unwrap(); - bindings.insert(renderer_id.clone(), binding.clone()); - info!( - renderer = renderer_id.0.as_str(), - server = server_id.0.as_str(), - container = container_id, - auto_play, - "Queue attached to playlist container" - ); - } + let renderer = self + .music_renderer_by_id(renderer_id) + .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + + renderer.set_playlist_binding(Some(binding.clone())); + info!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + container = container_id, + auto_play, + "Queue attached to playlist container" + ); self.emit_renderer_event(RendererEvent::BindingChanged { id: renderer_id.clone(), @@ -1849,7 +1657,6 @@ impl ControlPoint { refresh_attached_queue_for( &self.registry, &self.runtime, - &self.playlist_bindings, renderer_id, &self.event_bus, callback, @@ -1873,8 +1680,8 @@ impl ControlPoint { &self, renderer_id: &ServiceId, ) -> Option<(ServerId, String, bool)> { - let bindings = self.playlist_bindings.lock().unwrap(); - bindings.get(renderer_id).map(|binding| { + let renderer = self.music_renderer_by_id(renderer_id)?; + renderer.get_playlist_binding().map(|binding| { ( binding.server_id.clone(), binding.container_id.clone(), @@ -1888,11 +1695,14 @@ impl ControlPoint { /// Invariant: every user-driven queue mutation **must** call this method so /// that bindings never become out of sync with the local queue snapshot. fn detach_playlist_binding(&self, renderer_id: &ServiceId, reason: &str) { - let removed = { - let mut bindings = self.playlist_bindings.lock().unwrap(); - bindings.remove(renderer_id) + let renderer = match self.music_renderer_by_id(renderer_id) { + Some(r) => r, + None => return, }; + let removed = renderer.get_playlist_binding(); + renderer.set_playlist_binding(None); + if let Some(binding) = removed { info!( renderer = renderer_id.0.as_str(), @@ -1962,20 +1772,6 @@ impl ControlPoint { ) } - fn openhome_renderer(&self, renderer_id: &ServiceId) -> anyhow::Result { - let renderer = self - .music_renderer_by_id(renderer_id) - .ok_or_else(|| OpenHomeAccessError::RendererNotFound(renderer_id.0.clone()))?; - if !renderer.info().capabilities.has_oh_playlist { - return Err(OpenHomeAccessError::PlaylistNotSupported(renderer_id.0.clone()).into()); - } - Ok(renderer) - } - - fn sync_openhome_playlist_for(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { - sync_openhome_playlist(&self.registry, &self.runtime, &self.event_bus, renderer_id) - } - } #[cfg(feature = "pmoserver")] @@ -1989,18 +1785,6 @@ fn convert_runtime_position(position: Option<&PlaybackPositionInfo>) -> (Option< } } -fn build_openhome_queue(info: &RendererInfo) -> Option { - let playlist = build_playlist_client(info)?; - let info_client = build_info_client(info); - let product_client = build_product_client(info); - Some(OpenHomeQueue::new( - info.id.clone(), - playlist, - info_client, - product_client, - )) -} - #[cfg(feature = "pmoserver")] fn parse_hms_to_ms(hms: Option<&str>) -> Option { let value = hms?; @@ -2033,90 +1817,26 @@ enum PlaybackSource { External, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PlaylistBackend { - PMOQueue, - OpenHome, -} - struct RendererRuntimeEntry { snapshot: RendererRuntimeSnapshot, - pub queue: crate::control_point::music_queue::MusicQueue, - openhome_cache: OpenHomePlaylistCache, playback_source: PlaybackSource, user_stop_requested: bool, - playlist_backend: PlaylistBackend, } impl Default for RendererRuntimeEntry { fn default() -> Self { Self { snapshot: RendererRuntimeSnapshot::default(), - queue: MusicQueue::Internal(InternalQueue::new()), - openhome_cache: OpenHomePlaylistCache::default(), playback_source: PlaybackSource::None, user_stop_requested: false, - playlist_backend: PlaylistBackend::PMOQueue, } } } -#[derive(Debug, Default, Clone)] -struct OpenHomePlaylistCache { - ids: Option>, - snapshot: Option, - last_refresh: Option, -} - struct RuntimeState { entries: Mutex>, } -pub struct RendererRuntimeStateMut<'a> { - pub queue: MusicQueueGuard<'a>, - _guard: MutexGuard<'a, HashMap>, -} - -impl<'a> RendererRuntimeStateMut<'a> { - fn new( - guard: MutexGuard<'a, HashMap>, - queue_ptr: *mut MusicQueue, - ) -> Self { - Self { - queue: MusicQueueGuard::new(queue_ptr), - _guard: guard, - } - } -} - -pub struct MusicQueueGuard<'a> { - ptr: *mut MusicQueue, - _marker: PhantomData<&'a mut MusicQueue>, -} - -impl<'a> MusicQueueGuard<'a> { - fn new(ptr: *mut MusicQueue) -> Self { - Self { - ptr, - _marker: PhantomData, - } - } -} - -impl<'a> Deref for MusicQueueGuard<'a> { - type Target = MusicQueue; - - fn deref(&self) -> &Self::Target { - unsafe { &*self.ptr } - } -} - -impl<'a> DerefMut for MusicQueueGuard<'a> { - fn deref_mut(&mut self) -> &mut Self::Target { - unsafe { &mut *self.ptr } - } -} - struct RuntimeOpenHomeQueueProvider { runtime: Arc, } @@ -2174,34 +1894,6 @@ impl RuntimeState { entries.contains_key(id) } - fn with_music_queue_mut(&self, id: &ServiceId, f: F) -> anyhow::Result - where - F: FnOnce(&mut MusicQueue) -> anyhow::Result, - { - let mut entries = self.entries.lock().unwrap(); - let entry = entries - .get_mut(id) - .ok_or_else(|| anyhow!("Renderer {} not registered in runtime", id.0))?; - f(&mut entry.queue) - } - - fn queue_snapshot(&self, id: &ServiceId) -> Option> { - let entries = self.entries.lock().unwrap(); - entries - .get(id) - .and_then(|entry| entry.queue.upcoming_items().ok()) - } - - fn queue_full_snapshot(&self, id: &ServiceId) -> Option<(Vec, Option)> { - let entries = self.entries.lock().unwrap(); - entries.get(id).and_then(|entry| { - entry - .queue - .queue_snapshot() - .ok() - .map(|snapshot| (snapshot.items, snapshot.current_index)) - }) - } // fn renderer_state(&self, id: &RendererId) -> anyhow::Result { // let entries = self.entries.lock().unwrap(); @@ -2213,18 +1905,6 @@ impl RuntimeState { // }) // } - fn renderer_state_mut(&self, id: &ServiceId) -> anyhow::Result> { - let mut entries = self.entries.lock().unwrap(); - let queue_ptr = { - let entry = entries - .get_mut(id) - .ok_or_else(|| anyhow!("Renderer {} not registered in runtime", id.0))?; - let entry_ptr: *mut RendererRuntimeEntry = entry; - unsafe { &mut (*entry_ptr).queue as *mut MusicQueue } - }; - Ok(RendererRuntimeStateMut::new(entries, queue_ptr)) - } - fn current_track_metadata(&self, id: &ServiceId) -> Option { let entries = self.entries.lock().unwrap(); entries @@ -2232,44 +1912,6 @@ impl RuntimeState { .and_then(|entry| entry.snapshot.last_metadata.clone()) } - #[cfg(feature = "pmoserver")] - fn renderer_snapshot_bundle( - &self, - id: &ServiceId, - ) -> (RendererRuntimeSnapshot, Vec, Option) { - let entries = self.entries.lock().unwrap(); - if let Some(entry) = entries.get(id) { - match entry.queue.queue_snapshot() { - Ok(snapshot) => ( - entry.snapshot.clone(), - snapshot.items, - snapshot.current_index, - ), - Err(err) => { - warn!( - renderer = id.0.as_str(), - error = %err, - "Failed to build queue snapshot for renderer" - ); - (entry.snapshot.clone(), Vec::new(), None) - } - } - } else { - (RendererRuntimeSnapshot::default(), Vec::new(), None) - } - } - - fn dequeue_next(&self, id: &ServiceId) -> Option<(PlaybackItem, usize)> { - let mut entries = self.entries.lock().unwrap(); - let entry = entries.get_mut(id)?; - entry.queue.dequeue_next().ok().flatten() - } - - fn peek_current(&self, id: &ServiceId) -> Option<(PlaybackItem, usize)> { - let entries = self.entries.lock().unwrap(); - let entry = entries.get(id)?; - entry.queue.peek_current().ok().flatten() - } fn set_playback_source(&self, id: &ServiceId, source: PlaybackSource) { let mut entries = self.entries.lock().unwrap(); @@ -2310,74 +1952,6 @@ impl RuntimeState { f(entry) } - fn set_music_queue(&self, id: &ServiceId, queue: MusicQueue) { - self.with_entry(id, |entry| { - entry.queue = queue; - }); - } - - fn invalidate_openhome_cache(&self, id: &ServiceId) { - self.with_entry(id, |entry| { - entry.openhome_cache = OpenHomePlaylistCache::default(); - }); - } - - fn get_openhome_cache(&self, id: &ServiceId) -> Option { - let entries = self.entries.lock().unwrap(); - entries.get(id).map(|entry| entry.openhome_cache.clone()) - } - - fn set_openhome_cache(&self, id: &ServiceId, cache: OpenHomePlaylistCache) { - self.with_entry(id, |entry| { - entry.openhome_cache = cache; - }); - } - - fn openhome_snapshot_cached( - &self, - renderer: &MusicRendererBackend, - ttl: Duration, - ) -> anyhow::Result { - let ids = renderer.openhome_playlist_ids()?; - let now = Instant::now(); - - if let Some(cache) = self.get_openhome_cache(renderer.id()) { - if let (Some(cached_ids), Some(snapshot), Some(last_refresh)) = - (cache.ids, cache.snapshot, cache.last_refresh) - { - if cached_ids == ids && now.saturating_duration_since(last_refresh) <= ttl { - return Ok(snapshot); - } - } - } - - let snapshot = renderer.openhome_playlist_snapshot()?; - let cache = OpenHomePlaylistCache { - ids: Some(ids), - snapshot: Some(snapshot.clone()), - last_refresh: Some(now), - }; - self.set_openhome_cache(renderer.id(), cache); - Ok(snapshot) - } - - fn set_playlist_backend(&self, id: &ServiceId, backend: PlaylistBackend) { - self.with_entry(id, |entry| { - entry.playlist_backend = backend; - }); - } - - fn playlist_backend(&self, id: &ServiceId) -> PlaylistBackend { - let entries = self.entries.lock().unwrap(); - entries - .get(id) - .map(|entry| entry.playlist_backend) - .unwrap_or(PlaylistBackend::PMOQueue) - } - - fn uses_openhome_playlist(&self, id: &ServiceId) -> bool { - matches!(self.playlist_backend(id), PlaylistBackend::OpenHome) - } fn mark_user_stop_requested(&self, id: &ServiceId) { let mut entries = self.entries.lock().unwrap(); @@ -2407,15 +1981,29 @@ impl RuntimeState { fn refresh_attached_queue_for( registry: &Arc>, runtime: &Arc, - bindings: &Arc>>, renderer_id: &ServiceId, event_bus: &RendererEventBus, mut after_refresh: Option<&mut dyn FnMut(&ServiceId) -> anyhow::Result<()>>, ) -> anyhow::Result<()> { - // Step 1: Check binding and mark refresh as in-progress + // Step 1: Get renderer and check binding + let renderer = { + let reg = registry.read().unwrap(); + reg.get_renderer(renderer_id) + }; + + let renderer = match renderer { + Some(r) => r, + None => { + debug!( + renderer = renderer_id.0.as_str(), + "refresh_attached_queue_for: renderer not found" + ); + return Ok(()); + } + }; + let (server_id, container_id, auto_play) = { - let mut bindings_lock = bindings.lock().unwrap(); - let binding = match bindings_lock.get_mut(renderer_id) { + let binding = match renderer.get_playlist_binding() { Some(b) => b, None => { debug!( @@ -2434,10 +2022,13 @@ fn refresh_attached_queue_for( return Ok(()); } - // Mark as processed - binding.pending_refresh = false; - let auto_play = binding.auto_play_on_refresh; - binding.auto_play_on_refresh = false; + // Mark as processed - clone, modify, and set back + let mut updated_binding = binding.clone(); + updated_binding.pending_refresh = false; + let auto_play = updated_binding.auto_play_on_refresh; + updated_binding.auto_play_on_refresh = false; + renderer.set_playlist_binding(Some(updated_binding)); + ( binding.server_id.clone(), binding.container_id.clone(), @@ -2543,8 +2134,10 @@ fn refresh_attached_queue_for( total_entries = entries.len(), "Refreshed playlist is empty, clearing queue - all entries were filtered out" ); - runtime.with_music_queue_mut(renderer_id, |queue| queue.clear_queue())?; - runtime.invalidate_openhome_cache(renderer_id); + { + let mut queue = renderer.queue.lock().unwrap(); + queue.clear_queue()?; + } // Emit QueueUpdated event event_bus.broadcast(RendererEvent::QueueUpdated { @@ -2560,9 +2153,16 @@ fn refresh_attached_queue_for( // This uses LCS algorithm to minimize playlist operations and avoid interrupting playback // Get the full queue snapshot to access the item currently being played - let (full_queue, current_idx) = runtime - .queue_full_snapshot(renderer_id) - .unwrap_or((vec![], None)); + let (full_queue, current_idx) = { + let reg = registry.read().unwrap(); + reg.renderer_by_id(renderer_id) + .and_then(|info| MusicRenderer::from_renderer_info(info).ok()) + .and_then(|r| { + let queue = r.get_queue(); + queue.queue_snapshot().ok().map(|s| (s.items, s.current_index)) + }) + .unwrap_or((vec![], None)) + }; // Get the item currently being played (at current_index), not the next one in queue let current_item = current_idx.and_then(|idx| full_queue.get(idx).cloned()); @@ -2613,7 +2213,25 @@ fn refresh_attached_queue_for( }) }); - let final_queue_len = runtime.with_music_queue_mut(renderer_id, |queue| { + let final_queue_len = { + let music_renderer = { + let reg = registry.read().unwrap(); + reg.renderer_by_id(renderer_id) + .and_then(|info| MusicRenderer::from_renderer_info(info).ok()) + }; + + let music_renderer = match music_renderer { + Some(r) => r, + None => { + warn!( + renderer = renderer_id.0.as_str(), + "refresh_attached_queue_for: renderer not found in registry" + ); + return Ok(()); + } + }; + + let mut queue = music_renderer.get_queue_mut(); if let Some(idx) = item_found_at { // Current item is in new playlist - use gentle incremental update queue.replace_queue(new_items.clone(), Some(idx))?; @@ -2628,7 +2246,7 @@ fn refresh_attached_queue_for( is_playing, "Gentle refresh: current item found in new playlist" ); - Ok(new_items.len()) + new_items.len() } else if let Some(ref current) = current_item { // Current item NOT in new playlist if is_playing { @@ -2649,7 +2267,7 @@ fn refresh_attached_queue_for( is_playing = true, "Gentle refresh: preserved playing track not in new playlist" ); - Ok(new_items.len() + 1) + new_items.len() + 1 } else { // Not playing, use new playlist as-is queue.replace_queue(new_items.clone(), None)?; @@ -2662,7 +2280,7 @@ fn refresh_attached_queue_for( is_playing = false, "Gentle refresh: replaced queue (not playing)" ); - Ok(new_items.len()) + new_items.len() } } else { // No current item, use new playlist as-is @@ -2676,10 +2294,9 @@ fn refresh_attached_queue_for( is_playing, "Gentle refresh: no current item" ); - Ok(new_items.len()) + new_items.len() } - })?; - runtime.invalidate_openhome_cache(renderer_id); + }; // Emit QueueUpdated event event_bus.broadcast(RendererEvent::QueueUpdated { @@ -2830,77 +2447,6 @@ fn playback_item_track_metadata(item: &PlaybackItem) -> TrackMetadata { }) } -fn openhome_renderer_from_registry( - registry: &Arc>, - renderer_id: &ServiceId, -) -> anyhow::Result { - let info = { - let reg = registry.read().unwrap(); - reg.get_renderer(renderer_id) - .ok_or_else(|| OpenHomeAccessError::RendererNotFound(renderer_id.0.clone()))? - }; - let renderer = MusicRendererBackend::from_renderer_info(info, registry) - .and_then(|r| match r { - MusicRendererBackend::OpenHome(_) => Some(r), - _ => None, - }) - .ok_or_else(|| OpenHomeAccessError::PlaylistNotSupported(renderer_id.0.clone()))?; - Ok(renderer) -} - -fn sync_openhome_playlist( - registry: &Arc>, - runtime: &Arc, - event_bus: &RendererEventBus, - renderer_id: &ServiceId, -) -> anyhow::Result<()> { - let renderer = openhome_renderer_from_registry(registry, renderer_id)?; - - let snapshot = renderer.openhome_playlist_snapshot()?; - let playback_items: Vec = snapshot - .tracks - .iter() - .map(|track| playback_item_from_openhome_track(renderer_id, track)) - .collect(); - - debug!( - renderer = renderer_id.0.as_str(), - fetched_items = playback_items.len(), - current_id = ?snapshot.current_id, - "sync_openhome_playlist: fetched snapshot from renderer" - ); - - let current_id = snapshot.current_id; - - let current_index = current_id.and_then(|id| { - playback_items - .iter() - .position(|item| openhome_track_id_from_item(item) == Some(id)) - }); - - let queue_items = playback_items; - let queue_len = runtime - .with_music_queue_mut(renderer_id, move |queue| { - let len = queue_items.len(); - queue.replace_queue(queue_items, current_index)?; - Ok(len) - }) - .unwrap_or(0); - - debug!( - renderer = renderer_id.0.as_str(), - queue_len, - "sync_openhome_playlist: updated local queue" - ); - - event_bus.broadcast(RendererEvent::QueueUpdated { - id: renderer_id.clone(), - queue_length: queue_len, - }); - - Ok(()) -} - const OPENHOME_SUBSCRIPTION_TIMEOUT_SECS: u64 = 300; const OPENHOME_RENEWAL_MARGIN_SECS: u64 = 60; @@ -2909,7 +2455,6 @@ fn spawn_openhome_event_runtime( runtime: Arc, event_bus: RendererEventBus, event_tx: Sender, - playlist_bindings: Arc>>, ) -> io::Result<()> { let listener = TcpListener::bind("0.0.0.0:0")?; let listener_addr = listener @@ -2930,7 +2475,6 @@ fn spawn_openhome_event_runtime( notify_rx, event_tx, listener_addr.port(), - playlist_bindings, ); thread::Builder::new() @@ -2949,7 +2493,6 @@ struct OpenHomeEventRuntime { http_timeout: Duration, subscriptions: HashMap, path_index: HashMap, - playlist_bindings: Arc>>, } impl OpenHomeEventRuntime { @@ -2960,7 +2503,6 @@ impl OpenHomeEventRuntime { notify_rx: Receiver, event_tx: Sender, listener_port: u16, - playlist_bindings: Arc>>, ) -> Self { Self { registry, @@ -2972,7 +2514,6 @@ impl OpenHomeEventRuntime { http_timeout: Duration::from_secs(5), subscriptions: HashMap::new(), path_index: HashMap::new(), - playlist_bindings, } } @@ -3133,32 +2674,13 @@ impl OpenHomeEventRuntime { .iter() .any(|(name, _)| is_id_array_property(name)) { - if self.runtime.uses_openhome_playlist(&entry.renderer.id) { - // Check if this renderer has an active playlist binding - // If it does, skip sync because refresh_attached_queue_for() handles it - let has_active_binding = { - let bindings = self.playlist_bindings.lock().unwrap(); - bindings.contains_key(&entry.renderer.id) - }; - - if !has_active_binding { - // Synchronize the local queue with the renderer's playlist state - // This ensures our local mirror stays in sync when the renderer - // playlist changes from other control points or manual edits - if let Err(err) = sync_openhome_playlist( - &self.registry, - &self.runtime, - &self.event_bus, - &entry.renderer.id, - ) { - warn!( - renderer = entry.renderer.friendly_name.as_str(), - error = %err, - "Failed to sync OpenHome playlist after IdArray event" - ); - } - } - } + // OpenHome playlist changed event + // Queue synchronization is now handled by the generic queue backend + // and doesn't need special treatment here + debug!( + renderer = entry.renderer.friendly_name.as_str(), + "Received OpenHome IdArray change event" + ); } } OhServiceKind::Info => { @@ -3267,10 +2789,10 @@ impl OpenHomeEventRuntime { let position = self.runtime.snapshot_for(renderer_id).position; let mut new_position = position.unwrap_or_else(|| empty_playback_position()); if let Some(d) = duration { - new_position.track_duration = Some(format_seconds(d)); + new_position.track_duration = Some(format_hhmmss_u32(d)); } if let Some(s) = seconds { - new_position.rel_time = Some(format_seconds(s)); + new_position.rel_time = Some(format_hhmmss_u32(s)); } self.runtime.update_snapshot_with(renderer_id, |snapshot| { diff --git a/pmocontrol/src/discovery/arylic.rs b/pmocontrol/src/discovery/arylic.rs new file mode 100644 index 00000000..bf5c4219 --- /dev/null +++ b/pmocontrol/src/discovery/arylic.rs @@ -0,0 +1,66 @@ +use std::{ + collections::HashMap, + sync::{Mutex, OnceLock}, + time::Duration, +}; + +use tracing::debug; + +use crate::{ + arylic_client::{ARYLIC_TCP_PORT, send_command_required}, errors::ControlPointError, linkplay_client::extract_linkplay_host +}; + +static DETECTION_CACHE: OnceLock>> = OnceLock::new(); + +fn detection_cache() -> &'static Mutex> { + DETECTION_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Probe whether the renderer at the given location exposes the Arylic TCP API. +pub(crate) fn detect_arylic_tcp(location: &str, timeout: Duration) -> bool { + let Some(host) = extract_linkplay_host(location) else { + return false; + }; + + if let Ok(cache) = detection_cache().lock() { + if let Some(result) = cache.get(&host) { + return *result; + } + } + + let detected = match try_detect_tcp(&host, timeout) { + Ok(_) => true, + Err(err) => { + debug!( + "Arylic TCP detection failed for {} (host={}): {}", + location, host, err + ); + false + } + }; + + if let Ok(mut cache) = detection_cache().lock() { + cache.insert(host.to_string(), detected); + } + + detected +} + +fn try_detect_tcp(host: &str, timeout: Duration) -> Result<(), ControlPointError> { + let payload = send_command_required( + host, + ARYLIC_TCP_PORT, + timeout, + "MCU+INF+GET", + &["AXX+INF+", "AXX+DEV+"], + )?; + + if payload.starts_with("AXX+INF+") || payload.starts_with("AXX+DEV+") { + Ok(()) + } else { + Err(ControlPointError::ArilycTcpError(format!( + "Unexpected INF response from {}: {}", + host, payload + ))) + } +} diff --git a/pmocontrol/src/discovery/chromecast_discovery.rs b/pmocontrol/src/discovery/chromecast_discovery.rs index c87703a0..2cbac7ba 100644 --- a/pmocontrol/src/discovery/chromecast_discovery.rs +++ b/pmocontrol/src/discovery/chromecast_discovery.rs @@ -2,176 +2,181 @@ //! //! Chromecast devices advertise themselves using mDNS (Multicast DNS) on the //! `_googlecast._tcp.local` service, unlike UPnP devices which use SSDP. -//! This module handles the discovery of Chromecast devices and converts them -//! into `DeviceUpdate` events that can be processed by the `DeviceRegistry`. +//! This module handles the discovery of Chromecast devices and registers them +//! directly into the `DeviceRegistry`. use std::collections::HashMap; use std::net::IpAddr; -use std::time::SystemTime; +use std::sync::{Arc, Mutex}; use crate::DeviceId; -use crate::registry::DeviceUpdate; +use crate::DeviceRegistry; +use crate::discovery::manager::UDNRegistry; use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol}; use tracing::{debug, warn}; -/// Information about a discovered Chromecast device from mDNS. -#[derive(Clone, Debug)] -pub struct DiscoveredChromecast { - pub friendly_name: String, - pub host: String, - pub port: u16, - pub model: Option, - pub uuid: String, - pub manufacturer: Option, - pub last_seen: SystemTime, -} - -/// Manages the discovery and tracking of Chromecast devices via mDNS. +/// Gestionnaire des événements mDNS pour Chromecast. pub struct ChromecastDiscoveryManager { - discovered_devices: HashMap, + device_registry: Arc>, + udn_cache: Arc>, } impl ChromecastDiscoveryManager { - pub fn new() -> Self { + pub fn new( + device_registry: Arc>, + udn_cache: Arc>, + ) -> Self { Self { - discovered_devices: HashMap::new(), + device_registry, + udn_cache, } } - /// Adds or updates a discovered Chromecast device. - pub fn update_device(&mut self, device: DiscoveredChromecast) { - let uuid = device.uuid.clone(); - self.discovered_devices.insert(uuid, device); - } + /// Traite une réponse mDNS pour un appareil Chromecast. + /// + /// Cette fonction parse les réponses de service discovery mDNS pour les appareils + /// Chromecast et les enregistre directement dans le registre. + pub fn handle_mdns_response(&mut self, response: mdns::Response) { + // Extract basic information from the mDNS response + let service_name = match response.records().find_map(|r| { + if let mdns::RecordKind::PTR(ref name) = r.kind { + Some(name.clone()) + } else { + None + } + }) { + Some(name) => name, + None => { + warn!("No PTR record found in mDNS response"); + return; + } + }; - /// Retrieves a discovered device by UUID. - pub fn get_device(&self, uuid: &str) -> Option<&DiscoveredChromecast> { - self.discovered_devices.get(uuid) - } + debug!("Processing mDNS response for service: {}", service_name); - /// Lists all discovered devices. - pub fn list_devices(&self) -> Vec<&DiscoveredChromecast> { - self.discovered_devices.values().collect() - } -} + // Extract IP addresses + let addresses: Vec = response + .records() + .filter_map(|r| match r.kind { + mdns::RecordKind::A(addr) => Some(IpAddr::V4(addr)), + mdns::RecordKind::AAAA(addr) => Some(IpAddr::V6(addr)), + _ => None, + }) + .collect(); -/// Processes an mDNS response and converts it into a `DeviceUpdate` event. -/// -/// This function parses mDNS service discovery responses for Chromecast -/// devices and creates the appropriate update event for the device registry. -pub fn process_mdns_response(response: mdns::Response) -> Option { - // Extract basic information from the mDNS response - let service_name = response.records().filter_map(|r| { - if let mdns::RecordKind::PTR(ref name) = r.kind { - Some(name.clone()) - } else { - None + if addresses.is_empty() { + warn!("No IP address found for Chromecast device: {}", service_name); + return; } - }).next()?; - debug!("Processing mDNS response for service: {}", service_name); - - // Extract IP addresses - let addresses: Vec = response - .records() - .filter_map(|r| match r.kind { - mdns::RecordKind::A(addr) => Some(IpAddr::V4(addr)), - mdns::RecordKind::AAAA(addr) => Some(IpAddr::V6(addr)), - _ => None, - }) - .collect(); - - if addresses.is_empty() { - warn!("No IP address found for Chromecast device: {}", service_name); - return None; - } - - // Prefer IPv4 addresses - let host = addresses - .iter() - .find(|addr| matches!(addr, IpAddr::V4(_))) - .or_else(|| addresses.first()) - .map(|addr| addr.to_string())?; - - // Extract port from SRV record - let port = response - .records() - .filter_map(|r| { - if let mdns::RecordKind::SRV { port, .. } = r.kind { - Some(port) - } else { - None + // Prefer IPv4 addresses + let host = match addresses + .iter() + .find(|addr| matches!(addr, IpAddr::V4(_))) + .or_else(|| addresses.first()) + { + Some(addr) => addr.to_string(), + None => { + warn!("Could not extract host from addresses"); + return; } - }) - .next() - .unwrap_or(8009); // Default Chromecast port + }; - // Extract TXT records for additional metadata - let txt_records: HashMap = response - .records() - .filter_map(|r| { - if let mdns::RecordKind::TXT(ref data) = r.kind { - Some(data.clone()) - } else { - None - } - }) - .flat_map(|data| { - // data is Vec, each string is "key=value" - data.into_iter().filter_map(|s| { - let parts: Vec<&str> = s.splitn(2, '=').collect(); - if parts.len() == 2 { - Some((parts[0].to_string(), parts[1].to_string())) + // Extract port from SRV record + let port = response + .records() + .find_map(|r| { + if let mdns::RecordKind::SRV { port, .. } = r.kind { + Some(port) } else { None } }) - }) - .collect(); + .unwrap_or(8009); // Default Chromecast port - // Extract metadata from TXT records - let model = txt_records.get("md").cloned(); - let uuid = txt_records - .get("id") - .cloned() - .unwrap_or_else(|| format!("chromecast-{}-{}", host, port)); - let manufacturer = Some("Google Inc.".to_string()); + // Extract TXT records for additional metadata + let txt_records: HashMap = response + .records() + .filter_map(|r| { + if let mdns::RecordKind::TXT(ref data) = r.kind { + Some(data.clone()) + } else { + None + } + }) + .flat_map(|data| { + // data is Vec, each string is "key=value" + data.into_iter().filter_map(|s| { + let parts: Vec<&str> = s.splitn(2, '=').collect(); + if parts.len() == 2 { + Some((parts[0].to_string(), parts[1].to_string())) + } else { + None + } + }) + }) + .collect(); - // Extract friendly name from TXT record "fn" if available - // Otherwise, extract from service instance name (PTR record) - let friendly_name = txt_records - .get("fn") - .cloned() - .unwrap_or_else(|| { - // Fallback: extract from service name, removing the UUID suffix if present - service_name - .split("._googlecast._tcp.local") - .next() - .unwrap_or("Unknown Chromecast") - .split('-') - .take_while(|part| part.len() != 32) // Skip 32-char hex UUID - .collect::>() - .join("-") - .trim() - .to_string() - }); + // Extract metadata from TXT records + let model = txt_records.get("md").cloned(); + let uuid = txt_records + .get("id") + .cloned() + .unwrap_or_else(|| format!("chromecast-{}-{}", host, port)); + let manufacturer = Some("Google Inc.".to_string()); - debug!( - "Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})", - friendly_name, host, port, uuid, model - ); + // Extract friendly name from TXT record "fn" if available + // Otherwise, extract from service instance name (PTR record) + let friendly_name = txt_records + .get("fn") + .cloned() + .unwrap_or_else(|| { + // Fallback: extract from service name, removing the UUID suffix if present + service_name + .split("._googlecast._tcp.local") + .next() + .unwrap_or("Unknown Chromecast") + .split('-') + .take_while(|part| part.len() != 32) // Skip 32-char hex UUID + .collect::>() + .join("-") + .trim() + .to_string() + }); - // Create RendererInfo for the registry - let renderer_info = build_renderer_info( - &uuid, - &friendly_name, - &host, - port, - model.as_deref(), - manufacturer.as_deref(), - ); + debug!( + "Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})", + friendly_name, host, port, uuid, model + ); - Some(DeviceUpdate::RendererOnline(renderer_info)) + // Build UDN and check cache + let udn = format!("uuid:{}", uuid); + + // Pour Chromecast, on utilise un max_age par défaut car mDNS n'a pas ce concept + let default_max_age = 1800u64; // 30 minutes + + // Check cache to avoid redundant updates + if !UDNRegistry::should_fetch(self.udn_cache.clone(), &udn, default_max_age) { + debug!("Chromecast {} recently seen, skipping", udn); + return; + } + + // Create RendererInfo for the registry + let renderer_info = build_renderer_info( + &uuid, + &friendly_name, + &host, + port, + model.as_deref(), + manufacturer.as_deref(), + ); + + // Register the renderer + self.device_registry + .lock() + .expect("DeviceRegistry mutex lock failed") + .push_renderer(&renderer_info, default_max_age as u32); + } } /// Builds a `RendererInfo` structure for a Chromecast device. diff --git a/pmocontrol/src/discovery/manager.rs b/pmocontrol/src/discovery/manager.rs index da26965c..4ad726c2 100644 --- a/pmocontrol/src/discovery/manager.rs +++ b/pmocontrol/src/discovery/manager.rs @@ -1,40 +1,45 @@ -use std::{collections::HashMap, sync::{Arc, Mutex, OnceLock}, time::{Instant, SystemTime}}; - -use tracing_subscriber::registry; - - +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::{Instant, SystemTime}, +}; struct UDNSeen { max_age: u64, last_seen: Instant, } -pub struct UDNRegistry { +pub struct UDNRegistry { seen: HashMap, } impl UDNRegistry { pub fn new() -> Self { - UDNRegistry { seen: HashMap::new() } + UDNRegistry { + seen: HashMap::new(), + } } /// Returns `true` if the UDN has been seen for at least half of its lifetime - pub fn should_fetch(registry : Arc>,udn: &str, max_age: u64 ) -> bool { + pub fn should_fetch(registry: Arc>, udn: &str, max_age: u64) -> bool { let now = Instant::now(); - let mut r = registry - .lock() - .expect("UDNRegistry mutex lock failed"); + let mut r = registry.lock().expect("UDNRegistry mutex lock failed"); if let Some(seen) = r.seen.get_mut(udn) { - if now.duration_since(seen.last_seen).as_secs() > max_age/2 { + if now.duration_since(seen.last_seen).as_secs() > max_age / 2 { false } else { seen.last_seen = now; true } } else { - r.seen.insert(udn.to_string(), UDNSeen { max_age, last_seen: now }); + r.seen.insert( + udn.to_string(), + UDNSeen { + max_age, + last_seen: now, + }, + ); true } } } - diff --git a/pmocontrol/src/discovery/mod.rs b/pmocontrol/src/discovery/mod.rs index e96cf0ad..8f95c14b 100644 --- a/pmocontrol/src/discovery/mod.rs +++ b/pmocontrol/src/discovery/mod.rs @@ -1,6 +1,30 @@ +use crate::{RendererInfo, UpnpMediaServer}; + pub mod manager; pub mod upnp_discovery; pub mod upnp_provider; pub mod chromecast_discovery; +pub mod arylic; +/// Fournit les descriptions haut niveau à partir d’un endpoint découvert. +/// L’implémentation pourra, plus tard, faire un HTTP GET sur `location` +/// et parser la description pour remplir RendererInfo / MediaServerInfo. +pub trait DeviceDescriptionProvider: Send + Sync { + /// Construit un RendererInfo pour cet endpoint, ou None s’il + /// ne correspond pas à un renderer audio intéressant. + fn build_renderer_info( + &self, + udn: &str, + location: &str, + server_header: &str, + ) -> Option; + /// Construit un MediaServerInfo pour cet endpoint, ou None s’il + /// ne correspond pas à un media server (ou pas intéressant). + fn build_server_info( + &self, + udn: &str, + location: &str, + server_header: &str, + ) -> Option; +} \ No newline at end of file diff --git a/pmocontrol/src/discovery/upnp_discovery.rs b/pmocontrol/src/discovery/upnp_discovery.rs index a079ae94..d6aa9d9a 100644 --- a/pmocontrol/src/discovery/upnp_discovery.rs +++ b/pmocontrol/src/discovery/upnp_discovery.rs @@ -1,49 +1,14 @@ -use std::collections::{HashMap, HashSet}; - +use crate::{DeviceRegistry, discovery::upnp_provider::ParsedDeviceDescription}; use pmoupnp::ssdp::SsdpEvent; +use std::sync::{Arc, Mutex}; use crate::discovery::manager::UDNRegistry; -use crate::media_server::UpnpMediaServer; -use crate::model::RendererInfo; -use crate::registry::DeviceUpdate; - -/// Fournit les descriptions haut niveau à partir d’un endpoint découvert. -/// L’implémentation pourra, plus tard, faire un HTTP GET sur `location` -/// et parser la description pour remplir RendererInfo / MediaServerInfo. -pub trait DeviceDescriptionProvider: Send + Sync { - /// Construit un RendererInfo pour cet endpoint, ou None s’il - /// ne correspond pas à un renderer audio intéressant. - fn build_renderer_info( - &self, - udn: &str, - location: &str, - server_header: &str, - ) -> Option; - - /// Construit un MediaServerInfo pour cet endpoint, ou None s’il - /// ne correspond pas à un media server (ou pas intéressant). - fn build_server_info( - &self, - udn: &str, - location: &str, - server_header: &str, - ) -> Option; -} /// Gestionnaire des événements SSDP -> DeviceUpdate. -pub struct DiscoveryManager

-where - P: DeviceDescriptionProvider, -{ - provider: P, - udn_cache: Arc>, - device_registry: Arc, -} pub struct UpnpDiscoveryManager { device_registry: Arc>, udn_cache: Arc>, - http_client: Agent, // ← intégré } impl UpnpDiscoveryManager { @@ -70,428 +35,35 @@ impl UpnpDiscoveryManager { if let Some(udn) = extract_udn_from_usn(&usn) { if alive { // ✅ Check cache - if UDNRegistry::should_fetch(self.udn_cache, &udn, max_age as u64) { + if UDNRegistry::should_fetch(self.udn_cache.clone(), &udn, max_age as u64) { // ✅ Fetch + parse - let info = self.provider.build_renderer_info(&location)?; - } + if let Ok(info) = ParsedDeviceDescription::new(&udn, &location, &server_header,5) { + if let Some(renderer_info) = info.build_renderer() { + self.device_registry + .lock() + .expect("UDNRegistry mutex lock failed") + .push_renderer(&renderer_info,max_age); + } else { + if let Some(server_info) = info.build_server() { + self.device_registry + .lock() + .expect("UDNRegistry mutex lock failed") + .push_server(&server_info,max_age); + } + } + }} } else { - self.device_registry.lock() + self.device_registry + .lock() .expect("UDNRegistry mutex lock failed") - .device_says_byebye(&udn); - } - } - } - - /// Fetch and parse the device description.xml at endpoint.location. - fn fetch_and_parse( - &self, - udn: &str, - location: &str, - server_header: &str, - ) -> Result { - debug!("Fetching description for {} at {}", udn, location); - - let config = Agent::config_builder() - .timeout_global(Some(std::time::Duration::from_secs(self.timeout_secs))) - .build(); - - let agent: Agent = config.into(); - - let response = agent.get(location).call()?; - - // response: http::Response - let (_parts, body) = response.into_parts(); - - // body.into_reader() -> impl Read + 'static - let body_reader = body.into_reader(); - - let mut reader = Reader::from_reader(BufReader::new(body_reader)); - reader.config_mut().trim_text(true); - debug!("Parsing description XML for {} at {}", udn, location); - - let mut buf = Vec::new(); - let mut parsed = ParsedDeviceDescription::default(); - - let mut in_device = false; - let mut in_service = false; - let mut current_tag: Option = None; - - // New: track current serviceType + controlURL while inside ... - let mut current_service_type: Option = None; - let mut current_control_url: Option = None; - let mut current_event_sub_url: Option = None; - - loop { - match reader.read_event_into(&mut buf)? { - Event::Start(e) => { - let name = String::from_utf8_lossy(e.name().as_ref()).to_string(); - match name.as_str() { - "device" => { - in_device = true; - current_tag = None; - } - "service" => { - if in_device { - in_service = true; - current_tag = None; - current_service_type = None; - current_control_url = None; - } - } - _ => { - if in_device { - current_tag = Some(name); - } - } - } - } - Event::End(e) => { - let name = String::from_utf8_lossy(e.name().as_ref()).to_string(); - match name.as_str() { - "device" => { - in_device = false; - } - "service" => { - if in_device && in_service { - // We just finished a block: if this is AVTransport, - // store its endpoint in parsed.* - if let (Some(st), Some(ctrl)) = - (¤t_service_type, ¤t_control_url) - { - let lower = st.to_ascii_lowercase(); - if lower.contains("urn:schemas-upnp-org:service:avtransport:") { - // Only set once; if multiple AVTransport services exist, - // we keep the first one. - if parsed.avtransport_service_type.is_none() { - parsed.avtransport_service_type = Some(st.clone()); - parsed.avtransport_control_url = Some(ctrl.clone()); - debug!( - "Found AVTransport service for {}: type={} controlURL={}", - udn, st, ctrl - ); - } - } - - if lower - .contains("urn:schemas-upnp-org:service:renderingcontrol:") - { - if parsed.rendering_control_service_type.is_none() { - parsed.rendering_control_service_type = - Some(st.clone()); - parsed.rendering_control_control_url = - Some(ctrl.clone()); - debug!( - "Found RenderingControl service for {}: type={} controlURL={}", - udn, st, ctrl - ); - } - } - - if lower - .contains("urn:schemas-upnp-org:service:connectionmanager:") - { - if parsed.connection_manager_service_type.is_none() { - parsed.connection_manager_service_type = - Some(st.clone()); - parsed.connection_manager_control_url = - Some(ctrl.clone()); - debug!( - "Found ConnectionManager service for {}: type={} controlURL={}", - udn, st, ctrl - ); - } - } - - if lower - .contains("urn:schemas-upnp-org:service:contentdirectory:") - { - if parsed.content_directory_service_type.is_none() { - parsed.content_directory_service_type = - Some(st.clone()); - parsed.content_directory_control_url = - Some(ctrl.clone()); - debug!( - "Found ContentDirectory service for {}: type={} controlURL={}", - udn, st, ctrl - ); - } - } - - if lower.contains("urn:av-openhome-org:service:playlist:") { - if parsed.oh_playlist_service_type.is_none() { - parsed.oh_playlist_service_type = Some(st.clone()); - parsed.oh_playlist_control_url = Some(ctrl.clone()); - if parsed.oh_playlist_event_sub_url.is_none() { - parsed.oh_playlist_event_sub_url = - current_event_sub_url.clone(); - } - debug!( - "Found OpenHome Playlist for {}: type={} controlURL={}", - udn, st, ctrl - ); - } - } - - if lower.contains("urn:av-openhome-org:service:info:") { - if parsed.oh_info_service_type.is_none() { - parsed.oh_info_service_type = Some(st.clone()); - parsed.oh_info_control_url = Some(ctrl.clone()); - if parsed.oh_info_event_sub_url.is_none() { - parsed.oh_info_event_sub_url = - current_event_sub_url.clone(); - } - debug!( - "Found OpenHome Info for {}: type={} controlURL={}", - udn, st, ctrl - ); - } - } - - if lower.contains("urn:av-openhome-org:service:time:") { - if parsed.oh_time_service_type.is_none() { - parsed.oh_time_service_type = Some(st.clone()); - parsed.oh_time_control_url = Some(ctrl.clone()); - if parsed.oh_time_event_sub_url.is_none() { - parsed.oh_time_event_sub_url = - current_event_sub_url.clone(); - } - debug!( - "Found OpenHome Time for {}: type={} controlURL={}", - udn, st, ctrl - ); - } - } - - if lower.contains("urn:av-openhome-org:service:volume:") { - if parsed.oh_volume_service_type.is_none() { - parsed.oh_volume_service_type = Some(st.clone()); - parsed.oh_volume_control_url = Some(ctrl.clone()); - debug!( - "Found OpenHome Volume for {}: type={} controlURL={}", - udn, st, ctrl - ); - } - } - - if lower.contains("urn:av-openhome-org:service:radio:") { - if parsed.oh_radio_service_type.is_none() { - parsed.oh_radio_service_type = Some(st.clone()); - parsed.oh_radio_control_url = Some(ctrl.clone()); - debug!( - "Found OpenHome Radio for {}: type={} controlURL={}", - udn, st, ctrl - ); - } - } - - if lower.contains("urn:av-openhome-org:service:product:") { - if parsed.oh_product_service_type.is_none() { - parsed.oh_product_service_type = Some(st.clone()); - parsed.oh_product_control_url = Some(ctrl.clone()); - debug!( - "Found OpenHome Product for {}: type={} controlURL={}", - udn, st, ctrl - ); - } - } - } - - in_service = false; - current_service_type = None; - current_control_url = None; - current_event_sub_url = None; - } - } - _ => {} - } - current_tag = None; - } - Event::Text(e) => { - if in_device { - if let Some(tag) = ¤t_tag { - // quick-xml ≥ 0.37 : unescape() → decode() - let text = e.decode().map_err(XmlError::Encoding)?.into_owned(); - - match tag.as_str() { - "UDN" => { - parsed.udn = Some(text); - } - "deviceType" => { - parsed.device_type = Some(text); - } - "friendlyName" => { - parsed.friendly_name = Some(text); - } - "manufacturer" => { - parsed.manufacturer = Some(text); - } - "modelName" => { - parsed.model_name = Some(text); - } - "serviceType" if in_service => { - parsed.service_types.push(text.clone()); - current_service_type = Some(text); - } - "controlURL" if in_service => { - current_control_url = Some(text); - } - "eventSubURL" if in_service => { - current_event_sub_url = Some(text); - } - _ => {} - } - } - } - } - Event::Eof => break, - _ => {} + .device_says_byebye(&udn); } - - buf.clear(); } - - parsed.require_fields() } + } -impl

DiscoveryManager

-where - P: DeviceDescriptionProvider, -{ - pub fn new(provider: P) -> Self { - Self { provider } - } - - // Dans handle_ssdp_event (upnp_discovery.rs) - fn handle_ssdp_event(&mut self, event: SsdpEvent) -> Vec { - let (alive, usn, location, max_age, server_header) = match event { - SsdpEvent::Alive { - usn, - location, - max_age, - server, - .. - } - | SsdpEvent::SearchResponse { - usn, - location, - max_age, - server, - .. - } => (true, usn, location, max_age, server), - SsdpEvent::ByeBye { usn, .. } => (false, usn, "".to_string(), 0, "".to_string()), - }; - - if let Some(udn) = extract_udn_from_usn(&usn) { - if alive { - // ✅ Check cache - if !UDNRegistry::should_fetch(&udn, max_age) { - return vec![]; // Skip, vu récemment - } - - // ✅ Fetch + parse - let info = self.provider.build_renderer_info(&location)?; - vec![DeviceUpdate::RendererOnline(info)] - } else { - self.handle_byebye(udn, nt, &mut updates); - } - } else { - return vec![]; - } - } - - fn handle_alive( - &mut self, - udn: String, - nt: String, - location: String, - server_header: String, - max_age: u32, - updates: &mut Vec, - ) { - self.update_endpoint(udn, nt, location, server_header, max_age, updates); - } - - fn handle_search_response( - &mut self, - udn: String, - st: String, - location: String, - server_header: String, - max_age: u32, - updates: &mut Vec, - ) { - self.update_endpoint(udn, st, location, server_header, max_age, updates); - } - - fn handle_byebye(&mut self, udn: String, _nt: String, updates: &mut Vec) { - if let Some(endpoint) = self.endpoints.get(&udn) { - if endpoint.seen_as_renderer { - updates.push(DeviceUpdate::RendererOfflineByUdn(udn.clone())); - } - if endpoint.seen_as_server { - updates.push(DeviceUpdate::ServerOfflineByUdn(udn)); - } - } - } - - fn update_endpoint( - &mut self, - udn: String, - device_type: String, - location: String, - server_header: String, - max_age: u32, - updates: &mut Vec, - ) { - tracing::debug!( - "SSDP update: udn={} type={} location={} max_age={}", - udn, - device_type, - location, - max_age - ); - - let endpoint = self.endpoints.entry(udn.clone()).or_insert_with({ - let udn = udn.clone(); - let location = location.clone(); - let server_header = server_header.clone(); - move || DiscoveredEndpoint::new(udn, location, server_header, max_age) - }); - - endpoint.touch(location, server_header, max_age); - endpoint.types_seen.insert(device_type); - - // Toujours tenter de classifier et envoyer un événement Online pour maintenir - // l'état à jour dans le registre (important pour les serveurs qui ré-apparaissent) - if let Some(info) = self.provider.build_renderer_info(endpoint) { - if !endpoint.seen_as_renderer { - tracing::debug!( - "Renderer classified: udn={} friendly_name={} model={}", - info.udn(), - info.friendly_name(), - info.model_name() - ); - endpoint.seen_as_renderer = true; - } - updates.push(DeviceUpdate::RendererOnline(info)); - } - - if let Some(info) = self.provider.build_server_info(endpoint) { - if !endpoint.seen_as_server { - tracing::debug!( - "Server classified: udn={} friendly_name={} model={}", - info.udn, - info.friendly_name, - info.model_name - ); - endpoint.seen_as_server = true; - } - updates.push(DeviceUpdate::ServerOnline(info)); - } - } -} fn extract_udn_from_usn(usn: &str) -> Option { let lower = usn.trim().to_ascii_lowercase(); diff --git a/pmocontrol/src/discovery/upnp_provider.rs b/pmocontrol/src/discovery/upnp_provider.rs index a9a6dfe6..bf5ce640 100644 --- a/pmocontrol/src/discovery/upnp_provider.rs +++ b/pmocontrol/src/discovery/upnp_provider.rs @@ -1,17 +1,16 @@ use std::io::BufReader; -use std::time::{Duration, Instant, SystemTime}; +use std::time::Duration; use quick_xml::{Error as XmlError, Reader, events::Event}; use thiserror::Error; -use tracing::{debug, warn}; +use tracing::{debug}; use crate::DeviceId; -use crate::arylic_tcp::detect_arylic_tcp; -use crate::avtransport_client::AvTransportClient; -use crate::discovery::upnp_discovery::DeviceDescriptionProvider; -use crate::linkplay_renderer::detect_linkplay_http; +use crate::discovery::arylic::detect_arylic_tcp; +use crate::linkplay_client::{extract_linkplay_host, fetch_status_for_host}; use crate::media_server::UpnpMediaServer; use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol}; +use crate::upnp_clients::{AvTransportClient,resolve_control_url}; use ureq::Agent; @@ -32,8 +31,11 @@ pub enum DescriptionError { /// Parsed device description, plus (optionally) AVTransport endpoint. #[derive(Debug, Default)] -struct ParsedDeviceDescription { - udn: Option, +pub struct ParsedDeviceDescription { + timeout_secs: u64, + udn: String, + location: String, + server_header: String, device_type: Option, friendly_name: Option, manufacturer: Option, @@ -75,41 +77,18 @@ struct ParsedDeviceDescription { } impl ParsedDeviceDescription { - fn require_fields(self) -> Result { - if self.device_type.is_none() { - return Err(DescriptionError::MissingField("deviceType")); - } - if self.friendly_name.is_none() { - return Err(DescriptionError::MissingField("friendlyName")); - } - if self.model_name.is_none() { - return Err(DescriptionError::MissingField("modelName")); - } - Ok(self) - } -} - -/// HTTP-based XML description provider (UPnP device description.xml) -pub struct HttpXmlDescriptionProvider { - timeout_secs: u64, -} - -impl HttpXmlDescriptionProvider { - pub fn new(timeout_secs: u64) -> Self { - Self { timeout_secs } - } /// Fetch and parse the device description.xml at endpoint.location. - fn fetch_and_parse( - &self, + pub fn new( udn: &str, location: &str, server_header: &str, - ) -> Result { + timeout_secs: u64, + ) -> Result { debug!("Fetching description for {} at {}", udn, location); let config = Agent::config_builder() - .timeout_global(Some(std::time::Duration::from_secs(self.timeout_secs))) + .timeout_global(Some(std::time::Duration::from_secs(timeout_secs))) .build(); let agent: Agent = config.into(); @@ -129,6 +108,11 @@ impl HttpXmlDescriptionProvider { let mut buf = Vec::new(); let mut parsed = ParsedDeviceDescription::default(); + parsed.timeout_secs=timeout_secs; + parsed.location = location.to_string(); + parsed.udn = udn.to_string(); + parsed.server_header = server_header.to_string(); + let mut in_device = false; let mut in_service = false; let mut current_tag: Option = None; @@ -331,7 +315,7 @@ impl HttpXmlDescriptionProvider { match tag.as_str() { "UDN" => { - parsed.udn = Some(text); + parsed.udn = text; } "deviceType" => { parsed.device_type = Some(text); @@ -370,181 +354,274 @@ impl HttpXmlDescriptionProvider { parsed.require_fields() } - fn build_renderer( + pub fn build_renderer( &self, - udn: &str, - location: &str, - server_header: &str, - parsed: &ParsedDeviceDescription, ) -> Option { - let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase(); + let device_type = self.device_type.as_ref()?.to_ascii_lowercase(); if !device_type.contains("urn:schemas-upnp-org:device:mediarenderer:") && !device_type.contains("urn:av-openhome-org:device:mediarenderer:") && !device_type.contains("urn:av-openhome-org:device:source:") { debug!( "build_renderer: ignoring deviceType for {}: {}", - udn, device_type + self.udn, device_type ); return None; } - let raw_udn = parsed.udn.as_deref().unwrap_or_else(|| udn); - let udn = raw_udn.to_ascii_lowercase(); - let mut caps = detect_renderer_capabilities(&parsed.service_types); - if detect_linkplay_http(location, Duration::from_secs(self.timeout_secs.max(1))) { + let udn = self.udn.to_ascii_lowercase(); + let mut caps = detect_renderer_capabilities(&self.service_types); + if detect_linkplay_http(&self.location, Duration::from_secs(self.timeout_secs.max(1))) { caps.has_linkplay_http = true; } - if detect_arylic_tcp(location, Duration::from_secs(self.timeout_secs.max(1))) { + if detect_arylic_tcp(&self.location, Duration::from_secs(self.timeout_secs.max(1))) { caps.has_arylic_tcp = true; } let protocol = detect_renderer_protocol(&caps); - let now = Instant::now(); Some(RendererInfo::make( DeviceId(udn.clone()), udn, - parsed.friendly_name.clone().unwrap_or_default(), - parsed.model_name.clone().unwrap_or_default(), - parsed.manufacturer.clone().unwrap_or_default(), + self.friendly_name.clone().unwrap_or_default(), + self.model_name.clone().unwrap_or_default(), + self.manufacturer.clone().unwrap_or_default(), protocol, caps, - location.to_string(), - endpoint.server_header.clone(), - // online: true, - // last_seen: now, - // max_age: endpoint.max_age, - parsed.avtransport_service_type.clone(), - parsed + self.location.clone(), + self.server_header.clone(), + self.avtransport_service_type.clone(), + self .avtransport_control_url .as_ref() - .map(|ctrl| resolve_control_url(location, ctrl)), - parsed.rendering_control_service_type.clone(), - parsed + .map(|ctrl| resolve_control_url(&self.location, ctrl)), + self.rendering_control_service_type.clone(), + self .rendering_control_control_url .as_ref() - .map(|ctrl| resolve_control_url(location, ctrl)), - parsed.connection_manager_service_type.clone(), - parsed + .map(|ctrl| resolve_control_url(&self.location, ctrl)), + self.connection_manager_service_type.clone(), + self .connection_manager_control_url .as_ref() - .map(|ctrl| resolve_control_url(location, ctrl)), - parsed.oh_playlist_service_type.clone(), - parsed + .map(|ctrl| resolve_control_url(&self.location, ctrl)), + self.oh_playlist_service_type.clone(), + self .oh_playlist_control_url .as_ref() - .map(|ctrl| resolve_control_url(location, ctrl)), - parsed + .map(|ctrl| resolve_control_url(&self.location, ctrl)), + self .oh_playlist_event_sub_url .as_ref() - .map(|url| resolve_control_url(location, url)), - parsed.oh_info_service_type.clone(), - parsed + .map(|url| resolve_control_url(&self.location, url)), + self.oh_info_service_type.clone(), + self .oh_info_control_url .as_ref() - .map(|ctrl| resolve_control_url(location, ctrl)), - parsed + .map(|ctrl| resolve_control_url(&self.location, ctrl)), + self .oh_info_event_sub_url .as_ref() - .map(|url| resolve_control_url(location, url)), - parsed.oh_time_service_type.clone(), - parsed + .map(|url| resolve_control_url(&self.location, url)), + self.oh_time_service_type.clone(), + self .oh_time_control_url .as_ref() - .map(|ctrl| resolve_control_url(location, ctrl)), - parsed + .map(|ctrl| resolve_control_url(&self.location, ctrl)), + self .oh_time_event_sub_url .as_ref() - .map(|url| resolve_control_url(location, url)), - parsed.oh_volume_service_type.clone(), - parsed + .map(|url| resolve_control_url(&self.location, url)), + self.oh_volume_service_type.clone(), + self .oh_volume_control_url .as_ref() - .map(|ctrl| resolve_control_url(location, ctrl)), - parsed.oh_radio_service_type.clone(), - parsed + .map(|ctrl| resolve_control_url(&self.location, ctrl)), + self.oh_radio_service_type.clone(), + self .oh_radio_control_url .as_ref() - .map(|ctrl| resolve_control_url(location, ctrl)), - parsed.oh_product_service_type.clone(), - parsed + .map(|ctrl| resolve_control_url(&self.location, ctrl)), + self.oh_product_service_type.clone(), + self .oh_product_control_url .as_ref() - .map(|ctrl| resolve_control_url(location, ctrl)), + .map(|ctrl| resolve_control_url(&self.location, ctrl)), )) } - fn build_server( + pub fn build_server( &self, - endpoint: &DiscoveredEndpoint, - parsed: &ParsedDeviceDescription, ) -> Option { - let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase(); + let device_type = self.device_type.as_ref()?.to_ascii_lowercase(); if !device_type.contains("urn:schemas-upnp-org:device:mediaserver:") { return None; } - let raw_udn = parsed - .udn - .as_deref() - .unwrap_or_else(|| endpoint.udn.as_str()); - let udn = raw_udn.to_ascii_lowercase(); - let has_content_directory = parsed.service_types.iter().any(|st| { + let udn = self.udn.to_ascii_lowercase(); + let has_content_directory = self.service_types.iter().any(|st| { st.to_ascii_lowercase() .contains("urn:schemas-upnp-org:service:contentdirectory:") }); - let now = Instant::now(); - let content_directory_control_url = parsed + let content_directory_control_url = self .content_directory_control_url .as_ref() - .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)); + .map(|ctrl| resolve_control_url(&self.location, ctrl)); - Some(UpnpMediaServer { - id: DeviceId(udn.clone()), + Some(UpnpMediaServer::new( + DeviceId(udn.clone()), udn, - friendly_name: parsed.friendly_name.clone().unwrap_or_default(), - model_name: parsed.model_name.clone().unwrap_or_default(), - manufacturer: parsed.manufacturer.clone().unwrap_or_default(), - location: endpoint.location.clone(), - server_header: endpoint.server_header.clone(), - online: true, - last_seen: now, - max_age: endpoint.max_age, + self.friendly_name.clone().unwrap_or_default(), + self.model_name.clone().unwrap_or_default(), + self.manufacturer.clone().unwrap_or_default(), + self.location.clone(), + self.server_header.clone(), has_content_directory, - content_directory_service_type: parsed.content_directory_service_type.clone(), + self.content_directory_service_type.clone(), content_directory_control_url, - }) + )) + } - /// New helper: build an AvTransportClient directly from a discovered endpoint. - /// /// Returns Ok(Some(client)) if an AVTransport service with a controlURL is present, /// Ok(None) if no AVTransport service was found. pub fn build_avtransport_client( &self, - endpoint: &DiscoveredEndpoint, ) -> Result, DescriptionError> { - let parsed = self.fetch_and_parse(endpoint)?; - - let service_type = match &parsed.avtransport_service_type { + let service_type = match &self.avtransport_service_type { Some(st) => st.clone(), None => return Ok(None), }; - let raw_control = match &parsed.avtransport_control_url { + let raw_control = match &self.avtransport_control_url { Some(ctrl) => ctrl.clone(), None => return Ok(None), }; - let control_url = resolve_control_url(&endpoint.location, &raw_control); + let control_url = resolve_control_url(&self.location, &raw_control); debug!( "AVTransport client for {}: service_type={} control_url={}", - endpoint.udn, service_type, control_url + &self.udn, service_type, control_url ); Ok(Some(AvTransportClient::new(control_url, service_type))) } + + fn require_fields(self) -> Result { + if self.device_type.is_none() { + return Err(DescriptionError::MissingField("deviceType")); + } + if self.friendly_name.is_none() { + return Err(DescriptionError::MissingField("friendlyName")); + } + if self.model_name.is_none() { + return Err(DescriptionError::MissingField("modelName")); + } + Ok(self) + } + + pub fn udn(&self) -> Option { + Some(self.udn.clone()) + } + pub fn device_type(&self) -> Option { + self.device_type.clone() + } + pub fn friendly_name(&self) -> Option { + self.friendly_name.clone() + } + pub fn manufacturer(&self) -> Option { + self.manufacturer.clone() + } + pub fn model_name(&self) -> Option { + self.model_name.clone() + } + pub fn service_types(&self) -> Vec { + self.service_types.clone() + } + + // New: AVTransport endpoint (if present in serviceList) + pub fn avtransport_service_type(&self) -> Option { + self.avtransport_service_type.clone() + } + pub fn avtransport_control_url(&self) -> Option { + self.avtransport_control_url.clone() + } + + // RenderingControl endpoint (if present in serviceList) + pub fn rendering_control_service_type(&self) -> Option { + self.rendering_control_service_type.clone() + } + pub fn rendering_control_control_url(&self) -> Option { + self.rendering_control_control_url.clone() + } + + // ConnectionManager endpoint (if present in serviceList) + pub fn connection_manager_service_type(&self) -> Option { + self.connection_manager_service_type.clone() + } + pub fn connection_manager_control_url(&self) -> Option { + self.connection_manager_control_url.clone() + } + + // ContentDirectory endpoint (if present in serviceList) + pub fn content_directory_service_type(&self) -> Option { + self.content_directory_service_type.clone() + } + pub fn content_directory_control_url(&self) -> Option { + self.content_directory_control_url.clone() + } + + // OpenHome endpoints (if present in serviceList) + pub fn oh_playlist_service_type(&self) -> Option { + self.oh_playlist_service_type.clone() + } + pub fn oh_playlist_control_url(&self) -> Option { + self.oh_playlist_control_url.clone() + } + pub fn oh_playlist_event_sub_url(&self) -> Option { + self.oh_playlist_event_sub_url.clone() + } + pub fn oh_info_service_type(&self) -> Option { + self.oh_info_service_type.clone() + } + pub fn oh_info_control_url(&self) -> Option { + self.oh_info_control_url.clone() + } + pub fn oh_info_event_sub_url(&self) -> Option { + self.oh_info_event_sub_url.clone() + } + pub fn oh_time_service_type(&self) -> Option { + self.oh_time_service_type.clone() + } + pub fn oh_time_control_url(&self) -> Option { + self.oh_time_control_url.clone() + } + pub fn oh_time_event_sub_url(&self) -> Option { + self.oh_time_event_sub_url.clone() + } + pub fn oh_volume_service_type(&self) -> Option { + self.oh_volume_service_type.clone() + } + pub fn oh_volume_control_url(&self) -> Option { + self.oh_volume_control_url.clone() + } + pub fn oh_radio_service_type(&self) -> Option { + self.oh_radio_service_type.clone() + } + pub fn oh_radio_control_url(&self) -> Option { + self.oh_radio_control_url.clone() + } + pub fn oh_product_service_type(&self) -> Option { + self.oh_product_service_type.clone() + } + pub fn oh_product_control_url(&self) -> Option { + self.oh_product_control_url.clone() + } +} + +/// HTTP-based XML description provider (UPnP device description.xml) +pub struct HttpXmlDescriptionProvider { + timeout_secs: u64, } // --- capabilities detection unchanged --- @@ -601,91 +678,23 @@ fn detect_renderer_protocol(caps: &RendererCapabilities) -> RendererProtocol { } } -/// Resolve a possibly relative controlURL against the description URL. -/// -/// - If `control_url` is already absolute (starts with http:// or https://), it is returned as-is. -/// - Otherwise, it is resolved against the scheme://host:port of `description_url`. -pub(crate) fn resolve_control_url(description_url: &str, control_url: &str) -> String { - if control_url.starts_with("http://") || control_url.starts_with("https://") { - return control_url.to_string(); - } - // Extract "scheme://host[:port]" from description_url - if let Some((scheme, rest)) = description_url.split_once("://") { - if let Some(pos) = rest.find('/') { - let authority = &rest[..pos]; - let base = format!("{}://{}", scheme, authority); - if control_url.starts_with('/') { - return format!("{}{}", base, control_url); - } else { - return format!("{}/{}", base, control_url); - } + +/// Detect whether a renderer exposes the LinkPlay HTTP API. +pub fn detect_linkplay_http(location: &str, timeout: Duration) -> bool { + let Some(host) = extract_linkplay_host(location) else { + return false; + }; + + match fetch_status_for_host(&host, timeout) { + Ok(_) => true, + Err(err) => { + debug!( + "LinkPlay detection failed for {} (host={}): {}", + location, host, err + ); + false } } - - // Fallback: just return the raw control_url if we cannot parse - control_url.to_string() -} - -#[cfg(test)] -mod tests { - use super::resolve_control_url; - - #[test] - fn resolves_relative_path_against_description() { - let base = "http://192.0.2.10:49152/device.xml"; - let control = "/upnp/control/playlist"; - let resolved = resolve_control_url(base, control); - assert_eq!(resolved, "http://192.0.2.10:49152/upnp/control/playlist"); - } - - #[test] - fn leaves_absolute_url_untouched() { - let url = "http://renderer.local:1400/MediaRenderer/Control"; - let resolved = resolve_control_url("http://example.invalid/device.xml", url); - assert_eq!(resolved, url); - } -} - -impl DeviceDescriptionProvider for HttpXmlDescriptionProvider { - fn build_renderer_info(&self, endpoint: &DiscoveredEndpoint) -> Option { - match self.fetch_and_parse(endpoint) { - Ok(parsed) => { - let device_type = parsed.device_type.as_deref().unwrap_or("unknown"); - debug!( - "Renderer description OK for {} at {} (deviceType={})", - endpoint.udn, endpoint.location, device_type - ); - self.build_renderer(endpoint, &parsed) - } - Err(err) => { - warn!( - "Failed to fetch/parse renderer description for {} at {}: {}", - endpoint.udn, endpoint.location, err - ); - None - } - } - } - - fn build_server_info(&self, endpoint: &DiscoveredEndpoint) -> Option { - match self.fetch_and_parse(endpoint) { - Ok(parsed) => { - let device_type = parsed.device_type.as_deref().unwrap_or("unknown"); - debug!( - "Server description OK for {} at {} (deviceType={})", - endpoint.udn, endpoint.location, device_type - ); - self.build_server(endpoint, &parsed) - } - Err(err) => { - warn!( - "Failed to fetch/parse server description for {} at {}: {}", - endpoint.udn, endpoint.location, err - ); - None - } - } - } -} +} \ No newline at end of file diff --git a/pmocontrol/src/errors.rs b/pmocontrol/src/errors.rs index 7f7a82d3..6492e51c 100644 --- a/pmocontrol/src/errors.rs +++ b/pmocontrol/src/errors.rs @@ -43,6 +43,8 @@ pub enum ControlPointError { MediaServerError(String), #[error("Queue Error: {0}")] QueueError(String), + #[error("Invalid time format: {0}")] + InvalidTimeFormat(String), } impl ControlPointError { diff --git a/pmocontrol/src/lib.rs b/pmocontrol/src/lib.rs index 7f990629..3a9ab60d 100644 --- a/pmocontrol/src/lib.rs +++ b/pmocontrol/src/lib.rs @@ -3,28 +3,20 @@ mod media_server_events; pub mod queue; pub mod discovery; -pub mod arylic_tcp; -pub mod avtransport_client; -pub mod capabilities; -pub mod chromecast_renderer; -pub mod connection_manager_client; +pub mod upnp_clients; +pub mod arylic_client; +pub mod linkplay_client; pub mod control_point; pub mod errors; -pub mod linkplay_renderer; pub mod media_server; pub mod model; pub mod music_renderer; -pub mod openhome; -pub mod openhome_client; -pub mod openhome_playlist; -pub mod openhome_renderer; -pub mod provider; pub mod registry; -pub mod rendering_control_client; pub mod soap_client; -pub mod upnp_renderer; pub mod online; pub mod identity; +pub mod linkplay_utils; + // pmoserver extension (optional) @@ -40,30 +32,16 @@ use std::time::Duration; #[cfg(feature = "pmoserver")] pub use pmoserver_ext::ControlPointExt; -pub use arylic_tcp::ArylicTcpRenderer; -pub use avtransport_client::{AvTransportClient, PositionInfo, TransportInfo}; -pub use capabilities::{ - PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, - VolumeControl, -}; -pub use chromecast_renderer::ChromecastRenderer; -pub use connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo}; + pub use control_point::{ControlPoint, PlaylistBinding}; -pub use linkplay_renderer::LinkPlayRenderer; pub use media_server::{ MediaBrowser, MediaEntry, MediaResource, UpnpMediaServer, }; -pub use music_renderer::MusicRendererBackend; -pub use openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; -pub use openhome_renderer::OpenHomeRenderer; pub use queue::{EnqueueMode, PlaybackItem, QueueSnapshot}; -pub use rendering_control_client::RenderingControlClient; -pub use upnp_renderer::UpnpRenderer; pub use model::{ MediaServerEvent, RendererCapabilities, RendererEvent, RendererInfo, RendererProtocol, }; -pub use provider::HttpXmlDescriptionProvider; pub use registry::{DeviceRegistry, DeviceUpdate}; pub use soap_client::invoke_upnp_action; diff --git a/pmocontrol/src/linkplay_client/mod.rs b/pmocontrol/src/linkplay_client/mod.rs new file mode 100644 index 00000000..27bf9913 --- /dev/null +++ b/pmocontrol/src/linkplay_client/mod.rs @@ -0,0 +1,156 @@ +use std::time::Duration; + +use serde::Deserialize; +use tracing::debug; +use ureq::Agent; + +use crate::{errors::ControlPointError, model::PlaybackState, music_renderer::{PlaybackPositionInfo, time_utils::{format_hhmmss, ms_to_seconds}}}; + +const STATUS_COMMAND: &str = "getPlayerStatus"; + +/// Raw response from LinkPlay getPlayerStatus API +#[derive(Debug, Deserialize)] +struct LinkPlayStatusRaw { + status: String, + curpos: String, + totlen: String, + vol: String, + mute: String, + #[serde(default)] + plicurr: Option, +} + +#[derive(Clone, Debug)] +pub struct LinkPlayStatus { + state_raw: String, + pub curpos_ms: u64, + pub totlen_ms: u64, + pub track_index: Option, + pub volume: u16, + pub mute: bool, +} + +impl LinkPlayStatus { + pub fn playback_state(&self) -> PlaybackState { + match self.state_raw.as_str() { + "play" => PlaybackState::Playing, + "pause" => PlaybackState::Paused, + "stop" => PlaybackState::Stopped, + "load" => PlaybackState::Transitioning, + other => PlaybackState::Unknown(other.to_string()), + } + } + + pub fn position_info(&self) -> PlaybackPositionInfo { + PlaybackPositionInfo { + track: self.track_index, + rel_time: Some(format_hhmmss(ms_to_seconds(self.curpos_ms))), + abs_time: None, + track_duration: if self.totlen_ms > 0 { + Some(format_hhmmss(ms_to_seconds(self.totlen_ms))) + } else { + None + }, + track_metadata: None, + track_uri: None, + } + } +} + +/// Extract the IP/host component from a LOCATION URL. +pub fn extract_linkplay_host(location: &str) -> Option { + let (_, rest) = location.split_once("://")?; + let authority = rest.split('/').next().unwrap_or(rest); + let without_auth = authority.split('@').last().unwrap_or(authority); + + if without_auth.starts_with('[') { + let end = without_auth.find(']')?; + let host = &without_auth[1..end]; + if host.is_empty() { + None + } else { + Some(host.to_string()) + } + } else { + let host = without_auth.split(':').next().unwrap_or(""); + if host.is_empty() { + None + } else { + Some(host.to_string()) + } + } +} + +pub fn build_agent(timeout: Duration) -> Agent { + Agent::config_builder() + .timeout_global(Some(timeout)) + .build() + .into() +} + +pub fn percent_encode(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for b in input.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(b as char); + } + _ => out.push_str(&format!("%{:02X}", b)), + } + } + out +} + +pub fn fetch_status_for_host(host: &str, timeout: Duration) -> Result { + let url = format!("http://{}/httpapi.asp?command={}", host, STATUS_COMMAND); + let mut response = build_agent(timeout) + .get(&url) + .call() + .map_err(|_| ControlPointError::ArilycTcpError(format!("HTTP request failed for LinkPlay status on {}", host)))?; + + let body = response + .body_mut() + .read_to_string() + .map_err(|e| ControlPointError::ArilycTcpError(format!("Failed to read LinkPlay status body : {}",e)))?; + + parse_linkplay_status(&body) +} + +fn parse_linkplay_status(body: &str) -> Result { + let raw: LinkPlayStatusRaw = serde_json::from_str(body) + .map_err(|e| ControlPointError::LinkPlayError(format!("Failed to parse LinkPlay status JSON: {}", e)))?; + + let curpos_ms = raw.curpos.parse::() + .map_err(|_| ControlPointError::LinkPlayError(format!("Invalid curpos value: {}", raw.curpos)))?; + + let totlen_ms = raw.totlen.parse::() + .map_err(|_| ControlPointError::LinkPlayError(format!("Invalid totlen value: {}", raw.totlen)))?; + + let volume = raw.vol.parse::() + .map_err(|_| ControlPointError::LinkPlayError(format!("Invalid vol value: {}", raw.vol)))? + .min(100); + + let mute = match raw.mute.as_str() { + "1" => true, + "0" => false, + other => { + return Err(ControlPointError::LinkPlayError(format!("Invalid mute value: {}", other))); + } + }; + + let track_index = raw.plicurr + .and_then(|s| s.parse::().ok()) + .filter(|idx| *idx > 0); + + Ok(LinkPlayStatus { + state_raw: raw.status, + curpos_ms, + totlen_ms, + track_index, + volume, + mute, + }) +} + + + diff --git a/pmocontrol/src/linkplay_renderer.rs b/pmocontrol/src/linkplay_renderer.rs deleted file mode 100644 index d47e813b..00000000 --- a/pmocontrol/src/linkplay_renderer.rs +++ /dev/null @@ -1,466 +0,0 @@ -use std::char; -use std::collections::HashMap; -use std::fmt; -use std::time::Duration; - -use anyhow::{Context, Result, anyhow}; -use tracing::debug; -use ureq::Agent; - -use crate::DeviceIdentity; -use crate::capabilities::{ - PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, - VolumeControl, -}; -use crate::errors::ControlPointError; -use crate::model::{RendererInfo}; - -const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3; -const STATUS_COMMAND: &str = "getPlayerStatus"; - -/// Renderer backend for devices exposing the LinkPlay HTTP API. -#[derive(Clone)] -pub struct LinkPlayRenderer { - host: String, - timeout: Duration, -} - -impl fmt::Debug for LinkPlayRenderer { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("LinkPlayRenderer") - .field("host", &self.host) - .finish() - } -} - -impl LinkPlayRenderer { - /// Build a LinkPlay backend from a registry snapshot. - pub fn from_renderer_info(info: RendererInfo) -> Result { - let host = extract_linkplay_host(&info.location()) - .ok_or_else(|| ControlPointError::LinkPlayError(format!("Renderer {} has no valid LOCATION host", info.udn())))?; - - Ok(Self { - host, - timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS), - }) - } - - fn agent(&self) -> Agent { - build_agent(self.timeout) - } - - fn send_player_command(&self, command: &str) -> Result<(), ControlPointError> { - let url = format!( - "http://{}/httpapi.asp?command=setPlayerCmd:{}", - self.host, command - ); - self.agent() - .get(&url) - .call() - .map_err(|_| ControlPointError::ArilycTcpError(format!("LinkPlay command {} failed for {}", command, self.host)))?; - Ok(()) - } - - fn fetch_status(&self) -> Result { - fetch_status_for_host(&self.host, self.timeout) - } -} - -impl TransportControl for LinkPlayRenderer { - fn play_uri(&self, uri: &str, _meta: &str) -> Result<(), ControlPointError> { - let encoded = percent_encode(uri); - self.send_player_command(&format!("play:{}", encoded)) - } - - fn play(&self) -> Result<(), ControlPointError> { - self.send_player_command("resume") - } - - fn pause(&self) -> Result<(), ControlPointError> { - self.send_player_command("pause") - } - - fn stop(&self) -> Result<(), ControlPointError> { - self.send_player_command("stop") - } - - fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { - let secs = parse_hhmmss_to_secs(hhmmss) - .ok_or_else(|| ControlPointError::LinkPlayError(format!("Invalid seek position format: {}", hhmmss)))?; - self.send_player_command(&format!("seek:{}", secs)) - } -} - -impl VolumeControl for LinkPlayRenderer { - fn volume(&self) -> Result { - Ok(self.fetch_status()?.volume) - } - - fn set_volume(&self, v: u16) -> Result<(), ControlPointError> { - let value = v.min(100); - self.send_player_command(&format!("vol:{}", value)) - } - - fn mute(&self) -> Result { - Ok(self.fetch_status()?.mute) - } - - fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { - self.send_player_command(if m { "mute:1" } else { "mute:0" }) - } -} - -impl PlaybackStatus for LinkPlayRenderer { - fn playback_state(&self) -> Result { - Ok(self.fetch_status()?.playback_state()) - } -} - -impl PlaybackPosition for LinkPlayRenderer { - fn playback_position(&self) -> Result { - Ok(self.fetch_status()?.position_info()) - } -} - -/// Detect whether a renderer exposes the LinkPlay HTTP API. -pub fn detect_linkplay_http(location: &str, timeout: Duration) -> bool { - let Some(host) = extract_linkplay_host(location) else { - return false; - }; - - match fetch_status_for_host(&host, timeout) { - Ok(_) => true, - Err(err) => { - debug!( - "LinkPlay detection failed for {} (host={}): {}", - location, host, err - ); - false - } - } -} - -/// Extract the IP/host component from a LOCATION URL. -pub fn extract_linkplay_host(location: &str) -> Option { - let (_, rest) = location.split_once("://")?; - let authority = rest.split('/').next().unwrap_or(rest); - let without_auth = authority.split('@').last().unwrap_or(authority); - - if without_auth.starts_with('[') { - let end = without_auth.find(']')?; - let host = &without_auth[1..end]; - if host.is_empty() { - None - } else { - Some(host.to_string()) - } - } else { - let host = without_auth.split(':').next().unwrap_or(""); - if host.is_empty() { - None - } else { - Some(host.to_string()) - } - } -} - -fn fetch_status_for_host(host: &str, timeout: Duration) -> Result { - let url = format!("http://{}/httpapi.asp?command={}", host, STATUS_COMMAND); - let mut response = build_agent(timeout) - .get(&url) - .call() - .map_err(|_| ControlPointError::ArilycTcpError(format!("HTTP request failed for LinkPlay status on {}", host)))?; - - let body = response - .body_mut() - .read_to_string() - .map_err(|e| ControlPointError::ArilycTcpError(format!("Failed to read LinkPlay status body : {}",e)))?; - - parse_linkplay_status(&body) -} - -fn build_agent(timeout: Duration) -> Agent { - Agent::config_builder() - .timeout_global(Some(timeout)) - .build() - .into() -} - -#[derive(Clone, Debug)] -struct LinkPlayStatus { - state_raw: String, - curpos_ms: u64, - totlen_ms: u64, - track_index: Option, - volume: u16, - mute: bool, -} - -impl LinkPlayStatus { - fn playback_state(&self) -> PlaybackState { - match self.state_raw.as_str() { - "play" => PlaybackState::Playing, - "pause" => PlaybackState::Paused, - "stop" => PlaybackState::Stopped, - "load" => PlaybackState::Transitioning, - other => PlaybackState::Unknown(other.to_string()), - } - } - - fn position_info(&self) -> PlaybackPositionInfo { - PlaybackPositionInfo { - track: self.track_index, - rel_time: Some(format_hms(self.curpos_ms / 1000)), - abs_time: None, - track_duration: if self.totlen_ms > 0 { - Some(format_hms(self.totlen_ms / 1000)) - } else { - None - }, - track_metadata: None, - track_uri: None, - } - } -} - -fn parse_linkplay_status(body: &str) -> Result { - let mut map = parse_flat_json(body)?; - - let state_raw = map - .remove("status") - .ok_or_else(|| ControlPointError::ArilycTcpError(format!("LinkPlay status missing `status` field")))?; - - let curpos_ms = parse_u64_field(&map, "curpos")?; - let totlen_ms = parse_u64_field(&map, "totlen")?; - let volume = parse_u16_field(&map, "vol")?; - let mute = match map.get("mute").map(|s| s.as_str()) { - Some("1") => true, - Some("0") => false, - Some(other) => { - return Err(ControlPointError::ArilycTcpError(format!("Invalid LinkPlay mute value: {}", other))); - } - None => return Err(ControlPointError::arilyc_tcp_error("LinkPlay status missing `mute` field")), - }; - - let track_index = map - .get("plicurr") - .and_then(|s| s.parse::().ok()) - .filter(|idx| *idx > 0); - - Ok(LinkPlayStatus { - state_raw, - curpos_ms, - totlen_ms, - track_index, - volume, - mute, - }) -} - -fn parse_u64_field(map: &HashMap, key: &str) -> Result { - let raw = map - .get(key) - .ok_or_else(|| ControlPointError::ArilycTcpError(format!("LinkPlay status missing `{}` field", key)))?; - raw.parse::() - .map_err(|_| ControlPointError::LinkPlayError(format!("Invalid `{}` value: {}", key, raw))) -} - -fn parse_u16_field(map: &HashMap, key: &str) -> Result { - let raw = map - .get(key) - .ok_or_else(|| ControlPointError::ArilycTcpError(format!("LinkPlay status missing `{}` field", key)))?; - let value = raw - .parse::() - .map_err(|_| ControlPointError::LinkPlayError(format!("Invalid `{}` value: {}", key, raw)))?; - Ok(value.min(100)) -} - -pub(crate) fn parse_flat_json(input: &str) -> Result, ControlPointError> { - let mut chars = input.chars().peekable(); - skip_ws(&mut chars); - if chars.next() != Some('{') { - return Err(ControlPointError::ArilycTcpError(format!("LinkPlay status is not a JSON object"))); - } - - let mut map = HashMap::new(); - loop { - skip_ws(&mut chars); - match chars.peek() { - Some('}') => { - chars.next(); - break; - } - Some(_) => {} - None => return Err(ControlPointError::ArilycTcpError(format!("Unexpected end of JSON object"))), - } - - let key = parse_json_string(&mut chars)?; - skip_ws(&mut chars); - expect_char(&mut chars, ':')?; - skip_ws(&mut chars); - let value = parse_json_value(&mut chars)?; - map.insert(key, value); - skip_ws(&mut chars); - - match chars.peek() { - Some(',') => { - chars.next(); - continue; - } - Some('}') => { - chars.next(); - break; - } - Some(other) => { - return Err(ControlPointError::ArilycTcpError(format!( - "Unexpected character '{}' while parsing JSON", - other - ))); - } - None => return Err(ControlPointError::ArilycTcpError(format!("Unexpected end of JSON while parsing fields"))), - } - } - - Ok(map) -} - -fn parse_json_value(chars: &mut std::iter::Peekable>) -> Result { - match chars.peek() { - Some('"') => parse_json_string(chars), - Some(ch) if ch.is_ascii_digit() || *ch == '-' => parse_json_number(chars), - Some('t') => { - expect_literal(chars, "true")?; - Ok("true".to_string()) - } - Some('f') => { - expect_literal(chars, "false")?; - Ok("false".to_string()) - } - _ => Err(ControlPointError::ArilycTcpError(format!("Unsupported JSON value in LinkPlay status"))), - } -} - -fn parse_json_string(chars: &mut std::iter::Peekable>) -> Result { - if chars.next() != Some('"') { - return Err(ControlPointError::ArilycTcpError(format!("Expected string"))); - } - - let mut out = String::new(); - while let Some(ch) = chars.next() { - match ch { - '"' => return Ok(out), - '\\' => { - let escaped = chars.next().ok_or_else(|| ControlPointError::ArilycTcpError(format!("Invalid escape")))?; - match escaped { - '"' => out.push('"'), - '\\' => out.push('\\'), - '/' => out.push('/'), - 'b' => out.push('\u{0008}'), - 'f' => out.push('\u{000C}'), - 'n' => out.push('\n'), - 'r' => out.push('\r'), - 't' => out.push('\t'), - 'u' => { - let mut hex = String::with_capacity(4); - for _ in 0..4 { - let h = chars.next().ok_or_else(|| ControlPointError::ArilycTcpError(format!("Invalid \\u escape")))?; - hex.push(h); - } - let code = u16::from_str_radix(&hex, 16) - .map_err(|e| ControlPointError::ArilycTcpError(format!("Invalid unicode escape: {}", hex)))?; - if let Some(c) = char::from_u32(code as u32) { - out.push(c); - } else { - return Err(ControlPointError::ArilycTcpError(format!("Invalid unicode code point: {}", code))); - } - } - other => return Err(ControlPointError::ArilycTcpError(format!("Unsupported escape: {}", other))), - } - } - other => out.push(other), - } - } - - Err(ControlPointError::ArilycTcpError(format!("Unterminated JSON string"))) -} - -fn parse_json_number(chars: &mut std::iter::Peekable>) -> Result { - let mut out = String::new(); - - if matches!(chars.peek(), Some('-')) { - out.push('-'); - chars.next(); - } - - while let Some(ch) = chars.peek() { - if ch.is_ascii_digit() || *ch == '.' { - out.push(*ch); - chars.next(); - } else { - break; - } - } - - if out.is_empty() || out == "-" { - return Err(ControlPointError::ArilycTcpError(format!("Invalid number"))); - } - - Ok(out) -} - -fn expect_literal( - chars: &mut std::iter::Peekable>, - literal: &str, -) -> Result<(), ControlPointError> { - for expected in literal.chars() { - match chars.next() { - Some(ch) if ch == expected => {} - _ => return Err(ControlPointError::ArilycTcpError(format!("Invalid literal while parsing JSON"))), - } - } - Ok(()) -} - -fn expect_char(chars: &mut std::iter::Peekable>, expected: char) -> Result<(), ControlPointError> { - match chars.next() { - Some(ch) if ch == expected => Ok(()), - _ => Err(ControlPointError::ArilycTcpError(format!("Missing '{}' while parsing JSON", expected))), - } -} - -fn skip_ws(chars: &mut std::iter::Peekable>) { - while matches!(chars.peek(), Some(ch) if ch.is_whitespace()) { - chars.next(); - } -} - -fn percent_encode(input: &str) -> String { - let mut out = String::with_capacity(input.len()); - for b in input.bytes() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { - out.push(b as char); - } - _ => out.push_str(&format!("%{:02X}", b)), - } - } - out -} - -fn parse_hhmmss_to_secs(s: &str) -> Option { - let parts: Vec<_> = s.split(':').collect(); - if parts.len() != 3 { - return None; - } - let h: u64 = parts[0].parse().ok()?; - let m: u64 = parts[1].parse().ok()?; - let sec: u64 = parts[2].parse().ok()?; - Some(h * 3600 + m * 60 + sec) -} - -fn format_hms(secs: u64) -> String { - let h = secs / 3600; - let m = (secs % 3600) / 60; - let s = secs % 60; - format!("{:02}:{:02}:{:02}", h, m, s) -} diff --git a/pmocontrol/src/linkplay_utils.rs b/pmocontrol/src/linkplay_utils.rs new file mode 100644 index 00000000..e69de29b diff --git a/pmocontrol/src/media_server.rs b/pmocontrol/src/media_server.rs index 0eea73c5..6bdb0d42 100644 --- a/pmocontrol/src/media_server.rs +++ b/pmocontrol/src/media_server.rs @@ -10,9 +10,9 @@ use xmltree::{Element, XMLNode}; use crate::errors::ControlPointError; use crate::model::TrackMetadata; use crate::online::{DeviceConnectionState, DeviceOnline}; -use crate::queue::backend::PlaybackItem; +use crate::queue::PlaybackItem; use crate::soap_client::{SoapCallResult, invoke_upnp_action_with_timeout}; -use crate::{DEFAULT_HTTP_TIMEOUT, DeviceId, DeviceIdentity}; +use crate::{DEFAULT_HTTP_TIMEOUT, DeviceId, DeviceIdentity, RendererInfo}; /// Snapshot of a media server discovered through UPnP SSDP. #[derive(Clone, Debug)] @@ -31,6 +31,33 @@ pub struct UpnpMediaServer { } impl UpnpMediaServer { + pub fn new( + id: DeviceId, + udn: String, + friendly_name: String, + model_name: String, + manufacturer: String, + location: String, + server_header: String, + has_content_directory: bool, + content_directory_service_type: Option, + content_directory_control_url: Option, + ) -> Self { + Self { + id, + udn, + friendly_name, + model_name, + manufacturer, + location, + server_header, + has_content_directory, + content_directory_service_type, + content_directory_control_url, + connection: DeviceConnectionState::make(), + } + } + pub fn make( id: DeviceId, udn: String, @@ -43,7 +70,7 @@ impl UpnpMediaServer { content_directory_service_type: Option, content_directory_control_url: Option, ) -> Arc { - Arc::new(Self { + Arc::new(Self::new( id, udn, friendly_name, @@ -54,8 +81,7 @@ impl UpnpMediaServer { has_content_directory, content_directory_service_type, content_directory_control_url, - connection: DeviceConnectionState::make(), - }) + )) } fn browse_with_flag( @@ -357,6 +383,12 @@ pub enum MusicServer { Upnp(UpnpMediaServer), } +impl MusicServer { + pub fn from_server_info(info: &UpnpMediaServer) -> Result { + Ok(MusicServer::Upnp(info.clone())) + } +} + impl DeviceOnline for MusicServer { fn is_online(&self) -> bool { match self { diff --git a/pmocontrol/src/media_server_events.rs b/pmocontrol/src/media_server_events.rs index 3a9c1a09..aac2ef41 100644 --- a/pmocontrol/src/media_server_events.rs +++ b/pmocontrol/src/media_server_events.rs @@ -12,11 +12,13 @@ use tracing::{debug, info, warn}; use ureq::{Agent, http}; use xmltree::{Element, XMLNode}; +use crate::DeviceId; use crate::events::MediaServerEventBus; -use crate::media_server::{UpnpMediaServer, ServerId}; +use crate::media_server::{UpnpMediaServer}; use crate::model::MediaServerEvent; -use crate::provider::resolve_control_url; -use crate::registry::{DeviceRegistry, DeviceRegistryRead}; +use crate::upnp_clients::resolve_control_url; +use crate::registry::DeviceRegistry; +use crate::{DeviceOnline,DeviceIdentity}; const SUBSCRIPTION_TIMEOUT_SECS: u64 = 300; const RENEWAL_SAFETY_MARGIN_SECS: u64 = 60; @@ -179,8 +181,8 @@ struct MediaServerEventWorker { http_timeout: Duration, notify_rx: Receiver, listener_port: u16, - subscriptions: HashMap, - path_index: HashMap, + subscriptions: HashMap, + path_index: HashMap, } impl MediaServerEventWorker { @@ -223,10 +225,10 @@ impl MediaServerEventWorker { reg.list_servers() }; - let mut active: HashSet = HashSet::new(); + let mut active: HashSet = HashSet::new(); for info in server_infos { - if !info.online || !info.has_content_directory { + if !info.is_online() || !info.has_content_directory { continue; } diff --git a/pmocontrol/src/model.rs b/pmocontrol/src/model.rs index 1e7c8625..051b5e83 100644 --- a/pmocontrol/src/model.rs +++ b/pmocontrol/src/model.rs @@ -1,10 +1,52 @@ -use std::sync::{Arc, Mutex}; - -use crate::{DeviceId,DeviceIdentity}; -use crate::capabilities::{PlaybackPositionInfo, PlaybackState}; use crate::control_point::PlaylistBinding; use crate::media_server::UpnpMediaServer; +use crate::music_renderer::PlaybackPositionInfo; +use crate::{DeviceId, DeviceIdentity}; +/// High-level playback state across backends. +#[derive(Clone, Debug)] +pub enum PlaybackState { + Stopped, + Playing, + Paused, + Transitioning, + NoMedia, + /// Backend-specific or unknown state string. + Unknown(String), +} + +impl PlaybackState { + /// Map a raw UPnP AVTransport CurrentTransportState string + /// to a logical PlaybackState. + pub fn from_upnp_state(raw: &str) -> Self { + let s = raw.trim().to_ascii_uppercase(); + match s.as_str() { + "STOPPED" => PlaybackState::Stopped, + "PLAYING" => PlaybackState::Playing, + "PAUSED_PLAYBACK" => PlaybackState::Paused, + // States from the AVTransport spec that we normalize: + "PAUSED_RECORDING" => PlaybackState::Paused, + "RECORDING" => PlaybackState::Playing, + // Common vendor-specific states: + "TRANSITIONING" => PlaybackState::Transitioning, + "BUFFERING" | "PREPARING" => PlaybackState::Transitioning, + "NO_MEDIA_PRESENT" => PlaybackState::NoMedia, + _ => PlaybackState::Unknown(raw.to_string()), + } + } + + /// Returns a human-readable label for the playback state. + pub fn as_str(&self) -> &str { + match self { + PlaybackState::Stopped => "STOPPED", + PlaybackState::Playing => "PLAYING", + PlaybackState::Paused => "PAUSED", + PlaybackState::Transitioning => "TRANSITIONING", + PlaybackState::NoMedia => "NO_MEDIA", + PlaybackState::Unknown(s) => s.as_str(), + } + } +} #[derive(Clone, Debug, PartialEq)] pub struct TrackMetadata { @@ -233,71 +275,70 @@ impl RendererInfo { &self.capabilities } - pub fn avtransport_service_type(&self) -> Option { + pub fn avtransport_service_type(&self) -> Option { self.avtransport_service_type.clone() } - pub fn avtransport_control_url(&self) -> Option { + pub fn avtransport_control_url(&self) -> Option { self.avtransport_control_url.clone() } - pub fn rendering_control_service_type(&self) -> Option { + pub fn rendering_control_service_type(&self) -> Option { self.rendering_control_service_type.clone() } - pub fn rendering_control_control_url(&self) -> Option { + pub fn rendering_control_control_url(&self) -> Option { self.rendering_control_control_url.clone() } - pub fn connection_manager_service_type(&self) -> Option { + pub fn connection_manager_service_type(&self) -> Option { self.connection_manager_service_type.clone() } - pub fn connection_manager_control_url(&self) -> Option { + pub fn connection_manager_control_url(&self) -> Option { self.connection_manager_control_url.clone() } - pub fn oh_playlist_service_type(&self) -> Option { + pub fn oh_playlist_service_type(&self) -> Option { self.oh_playlist_service_type.clone() } - pub fn oh_playlist_control_url(&self) -> Option { + pub fn oh_playlist_control_url(&self) -> Option { self.oh_playlist_control_url.clone() } - pub fn oh_playlist_event_sub_url(&self) -> Option { + pub fn oh_playlist_event_sub_url(&self) -> Option { self.oh_playlist_event_sub_url.clone() } - pub fn oh_info_service_type(&self) -> Option { + pub fn oh_info_service_type(&self) -> Option { self.oh_info_service_type.clone() } - pub fn oh_info_control_url(&self) -> Option { + pub fn oh_info_control_url(&self) -> Option { self.oh_info_control_url.clone() } - pub fn oh_info_event_sub_url(&self) -> Option { + pub fn oh_info_event_sub_url(&self) -> Option { self.oh_info_event_sub_url.clone() } - pub fn oh_time_service_type(&self) -> Option { + pub fn oh_time_service_type(&self) -> Option { self.oh_time_service_type.clone() } - pub fn oh_time_control_url(&self) -> Option { + pub fn oh_time_control_url(&self) -> Option { self.oh_time_control_url.clone() } - pub fn oh_time_event_sub_url(&self) -> Option { + pub fn oh_time_event_sub_url(&self) -> Option { self.oh_time_event_sub_url.clone() } - pub fn oh_volume_service_type(&self) -> Option { + pub fn oh_volume_service_type(&self) -> Option { self.oh_volume_service_type.clone() } - pub fn oh_volume_control_url(&self) -> Option { + pub fn oh_volume_control_url(&self) -> Option { self.oh_volume_control_url.clone() } - pub fn oh_radio_service_type(&self) -> Option { + pub fn oh_radio_service_type(&self) -> Option { self.oh_radio_service_type.clone() } - pub fn oh_radio_control_url(&self) -> Option { + pub fn oh_radio_control_url(&self) -> Option { self.oh_radio_control_url.clone() } - pub fn oh_product_service_type(&self) -> Option { + pub fn oh_product_service_type(&self) -> Option { self.oh_product_service_type.clone() } - pub fn oh_product_control_url(&self) -> Option { + pub fn oh_product_control_url(&self) -> Option { self.oh_product_control_url.clone() } - } impl DeviceIdentity for RendererInfo { @@ -323,7 +364,9 @@ impl DeviceIdentity for RendererInfo { &self.server_header } - fn is_a_music_renderer(&self) -> bool {true } + fn is_a_music_renderer(&self) -> bool { + true + } } #[derive(Clone, Debug)] diff --git a/pmocontrol/src/music_renderer.rs b/pmocontrol/src/music_renderer.rs deleted file mode 100644 index bdfd14ee..00000000 --- a/pmocontrol/src/music_renderer.rs +++ /dev/null @@ -1,961 +0,0 @@ -//! Backend-agnostic music renderer façade for PMOMusic. -//! -//! `MusicRenderer` wraps every supported backend (UPnP AV/DLNA, OpenHome, -//! LinkPlay HTTP, Arylic TCP, Chromecast, and the hybrid UPnP + Arylic pairing) behind a -//! single control surface. Higher layers in PMOMusic must only interact with -//! renderers through this type so that transport, volume, and state queries -//! stay backend-neutral. - -use std::sync::{Arc, Mutex, RwLock}; -use std::time::SystemTime; - -use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus}; -// use crate::control_point::RendererRuntimeStateMut; -use crate::control_point::music_queue::MusicQueue; -use crate::control_point::openhome_queue::didl_id_from_metadata; -use crate::errors::ControlPointError; -use crate::media_server::ServerId; -use crate::model::{ - RendererConnectionState, ServiceId, RendererInfo, RendererProtocol, TrackMetadata, -}; -use crate::openhome_client::parse_track_metadata_from_didl; -use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; -use crate::queue_backend::{PlaybackItem, QueueSnapshot}; -use crate::{ - ArylicTcpRenderer, ChromecastRenderer, DeviceIdentity, DeviceOnline, DeviceRegistry, LinkPlayRenderer, OpenHomeRenderer, PlaybackPosition, PlaybackState, TransportControl, UpnpRenderer, VolumeControl -}; -use anyhow::{Result, anyhow}; -use tracing::{debug, info, warn}; - -/// Backend-agnostic façade exposing transport, volume, and status contracts. -#[derive(Clone, Debug)] -pub enum MusicRendererBackend { - /// Classic UPnP AV / DLNA renderer (AVTransport + RenderingControl). - Upnp(UpnpRenderer), - /// Renderer powered by OpenHome services. - OpenHome(OpenHomeRenderer), - /// Renderer controlled via the LinkPlay HTTP API. - LinkPlay(LinkPlayRenderer), - /// Renderer reachable through the Arylic TCP control protocol (port 8899). - ArylicTcp(ArylicTcpRenderer), - /// Renderer controlled via the Google Cast protocol (Chromecast). - Chromecast(ChromecastRenderer), - /// Combined backend using UPnP for transport + volume writes and Arylic TCP - /// to read detailed playback information as well as live volume/mute state. - HybridUpnpArylic { - upnp: UpnpRenderer, - arylic: ArylicTcpRenderer, - }, -} - -#[derive(Debug, Clone)] -pub struct MusicRenderer { - info: RendererInfo, - connection: Arc>, - backend: Arc>, - queue: Arc>, -} - -impl MusicRenderer { - pub fn new( - info: RendererInfo, - backend: Arc>, - queue: Arc>, - ) -> Arc { - let connection = RendererConnectionState::new(); - - let renderer = MusicRenderer { - info, - connection, - backend, - queue, - }; - - Arc::new(renderer) - } - - pub fn from_renderer_info(info: RendererInfo) -> Result,ControlPointError> { - let connection = RendererConnectionState::new(); - let backend = MusicRendererBackend::from_renderer_info(info)?; - let queue = Mutex::new(MusicQueue::new()); - - let renderer = Arc::new(MusicRenderer { info, connection, backend, queue }); - Ok(renderer) - } - - pub fn info(&self) -> &RendererInfo { - &self.info - } - - /// Returns the protocol. - fn protocol(&self) -> RendererProtocol { - self.info.protocol() - } - - fn is_upnp(&self) -> bool { - match &*self.backend.lock().expect("Backend mutex poisoned") { - MusicRendererBackend::Upnp(_) => true, - _ => false, - } - } - - fn is_openhome(&self) -> bool { - match &*self.backend.lock().expect("Backend mutex poisoned") { - MusicRendererBackend::OpenHome(_) => true, - _ => false, - } - } - - fn is_linkplay(&self) -> bool { - match &*self.backend.lock().expect("Backend mutex poisoned") { - MusicRendererBackend::LinkPlay(_) => true, - _ => false, - } - } - - fn is_arylictcp(&self) -> bool { - match &*self.backend.lock().expect("Backend mutex poisoned") { - MusicRendererBackend::ArylicTcp(_) => true, - _ => false, - } - } - - fn is_chromecast(&self) -> bool { - match &*self.backend.lock().expect("Backend mutex poisoned") { - MusicRendererBackend::Chromecast(_) => true, - _ => false, - } - } - - fn is_hybridupnparylic(&self) -> bool { - match &*self.backend.lock().expect("Backend mutex poisoned") { - MusicRendererBackend::HybridUpnpArylic { .. } => true, - _ => false, - } - } - - /// Returns true if this renderer is known to support SetNextAVTransportURI. - pub fn supports_set_next(&self) -> bool { - self.info.capabilities().supports_set_next() - } - - -} - -impl DeviceIdentity for MusicRenderer { - fn id(&self) -> ServiceId { - self.info.id() - } - - fn udn(&self) -> &str { - &*self.info.udn() - } - - fn friendly_name(&self) -> &str { - &self.info.friendly_name() - } - - fn model_name(&self) -> &str { - &self.info.model_name() - } - fn manufacturer(&self) -> &str { - &self.info.manufacturer() - } - fn location(&self) -> &str { - &self.info.location() - } - fn server_header(&self) -> &str { - &self.info.server_header() - } - -} - -impl DeviceOnline for MusicRenderer { - fn is_online(&self) -> bool { - self.connection - .lock() - .expect("Connection mutex poisoned") - .is_online() - } - - fn last_seen(&self) -> SystemTime { - self.connection - .lock() - .expect("Connection mutex poisoned") - .last_seen() - } - - fn has_been_seen_now(&self, max_age: u32) { - self.connection - .lock() - .expect("Connection mutex poisoned") - .has_been_seen_now(max_age) - } - - fn mark_as_offline(&self) { - self.connection - .lock() - .expect("Connection mutex poisoned") - .mark_as_offline() - } - - fn max_age(&self) -> u32 { - self.connection - .lock() - .expect("Connection mutex poisoned") - .max_age() - }} -// #[derive(Clone, Debug)] -// pub struct RendererRuntimeState { -// pub queue: MusicQueue, -// } - -// pub trait OpenHomeQueueProvider: Send + Sync + 'static { -// fn renderer_state(&self, renderer_id: &RendererId) -> Result; -// fn renderer_state_mut<'a>( -// &'a self, -// renderer_id: &RendererId, -// ) -> Result>; -// fn invalidate_openhome_cache(&self, renderer_id: &RendererId) -> Result<()>; -// } - -// static OPENHOME_QUEUE_PROVIDER: OnceLock> = OnceLock::new(); - -// pub fn set_openhome_queue_provider(provider: Arc) { -// let _ = OPENHOME_QUEUE_PROVIDER.set(provider); -// } - -impl MusicRendererBackend { - // fn as_backend(&self) -> &dyn MusicRendererBackend { - // match self { - // MusicRendererBackend::OpenHome(r) => r, - // MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic, - // MusicRendererBackend::Upnp(r) => r, - // MusicRendererBackend::LinkPlay(r) => r, - // MusicRendererBackend::ArylicTcp(r) => r, - // MusicRendererBackend::Chromecast(r) => r, - // } - // } - - /// Construct a music renderer from a [`RendererInfo`] and the registry. - /// - /// Returns `None` when no supported backend can be built for this renderer. - /// UPnP AV / hybrid renderers map either to [`MusicRenderer::LinkPlay`] (when supported) - /// or [`MusicRenderer::Upnp`]. - pub fn from_renderer_info( - info: RendererInfo, - ) -> Result>, ControlPointError> { - - match info.protocol() { - RendererProtocol::OpenHomeOnly | RendererProtocol::OpenHomeHybrid => { - OpenHomeRenderer::from_renderer_info(info.clone()) - } - - _ => Err(ControlPointError::MusicRendererBackendBuild(format!("{:#?}", info.id()))), - } - - - // Le backend est de type openhome - - if matches!( - info.protocol(), - RendererProtocol::OpenHomeOnly | RendererProtocol::OpenHomeHybrid - ) { - if let Some(renderer) = { - let renderer = OpenHomeRenderer::new(info.clone()); - renderer.has_any_openhome_service().then_some(renderer) - } { - return Some(MusicRendererBackend::OpenHome(renderer)); - } - - if matches!(info.protocol(), RendererProtocol::OpenHomeOnly) { - warn!( - renderer = info.friendly_name(), - "Renderer advertises OpenHome only but exposes no usable services" - ); - return None; - } - } - - if matches!(info.protocol(), RendererProtocol::ChromecastOnly) { - if let Ok(renderer) = ChromecastRenderer::from_renderer_info(info.clone()) { - return Some(MusicRendererBackend::Chromecast(renderer)); - } - warn!( - renderer = info.friendly_name(), - "Failed to build Chromecast renderer" - ); - return None; - } - - match info.protocol() { - RendererProtocol::UpnpAvOnly | RendererProtocol::OpenHomeHybrid => { - let has_arylic = info.capabilities().has_arylic_tcp; - let has_avtransport = info.capabilities().has_avtransport; - - if has_arylic && has_avtransport { - // Construire UpnpRenderer - let upnp = UpnpRenderer::from_info(&info); - - // Construire ArylicTcpRenderer - match ArylicTcpRenderer::from_renderer_info(info.clone()) { - Ok(arylic) => { - return Some(MusicRendererBackend::HybridUpnpArylic { upnp, arylic }); - } - Err(err) => { - warn!( - "Failed to build Arylic TCP backend for {}: {}. Falling back to UPnP only.", - info.friendly_name(), - err - ); - return Some(MusicRendererBackend::Upnp(upnp)); - } - } - } - - // Pas d’Arylic : logique existante - if info.capabilities().has_linkplay_http() { - if let Ok(lp) = LinkPlayRenderer::from_renderer_info(info.clone()) { - return Some(MusicRendererBackend::LinkPlay(lp)); - } - } - - Some(MusicRendererBackend::Upnp(UpnpRenderer::from_info( - &info, - ))) - } - RendererProtocol::OpenHomeOnly => None, - RendererProtocol::ChromecastOnly => None, - } - } - - pub fn openhome_playlist_snapshot(&self) -> Result { - match self { - MusicRendererBackend::OpenHome(renderer) => renderer.snapshot_openhome_playlist(), - _ => Err(op_not_supported( - "openhome_playlist_snapshot", - self.unsupported_backend_name(), - )), - } - } - - pub fn openhome_playlist_len(&self) -> Result { - match self { - MusicRendererBackend::OpenHome(renderer) => renderer.openhome_playlist_len(), - _ => Err(op_not_supported( - "openhome_playlist_len", - self.unsupported_backend_name(), - )), - } - } - - pub fn openhome_playlist_ids(&self) -> Result> { - match self { - MusicRendererBackend::OpenHome(renderer) => renderer.openhome_playlist_ids(), - _ => Err(op_not_supported( - "openhome_playlist_ids", - self.unsupported_backend_name(), - )), - } - } - - pub fn openhome_playlist_clear(&self) -> Result<()> { - if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() { - let result = { - let mut state = provider.renderer_state_mut(self.id())?; - match &mut *state.queue { - MusicQueue::OpenHome(queue) => queue.clear(), - _ => Err(op_not_supported( - "openhome_playlist_clear", - self.unsupported_backend_name(), - )), - } - }; - if result.is_ok() { - provider.invalidate_openhome_cache(self.id())?; - } - result - } else { - self.fetch_openhome_playlist_clear() - } - } - - pub(crate) fn fetch_openhome_playlist_clear(&self) -> Result<()> { - match self { - MusicRendererBackend::OpenHome(renderer) => renderer.clear_openhome_playlist(), - _ => Err(op_not_supported( - "openhome_playlist_clear", - self.unsupported_backend_name(), - )), - } - } - - /// High-level method to prepare the renderer for attaching a new playlist. - /// - /// This method handles backend-specific clearing logic: - /// - For OpenHome: clears the OpenHome playlist - /// - For AVTransport/Chromecast/etc.: stops the renderer (since they don't have a persistent queue) - /// - /// This should be called by ControlPoint when attaching a new playlist, ensuring that: - /// - Any currently playing content is stopped - /// - The renderer is in a clean state ready to receive new content - pub fn clear_for_playlist_attach(&self) -> Result<()> { - match self { - MusicRendererBackend::OpenHome(_) => { - // For OpenHome: clear the playlist (DeleteAll also stops playback automatically) - // then explicitly stop to ensure clean state - self.openhome_playlist_clear()?; - self.stop().or_else(|err| -> Result<()> { - // If stop fails (e.g., already stopped), that's fine - warn!( - renderer = self.id().0.as_str(), - error = %err, - "Stop failed after clearing OpenHome playlist (continuing anyway)" - ); - Ok(()) - }) - } - MusicRendererBackend::Upnp(_) - | MusicRendererBackend::Chromecast(_) - | MusicRendererBackend::LinkPlay(_) - | MusicRendererBackend::ArylicTcp(_) - | MusicRendererBackend::HybridUpnpArylic { .. } => { - // For AVTransport and other single-track renderers: stop playback - // This ensures we're not in the middle of playing when we start the new playlist - self.stop().or_else(|err| { - // If stop fails (e.g., already stopped), that's fine - we just want to ensure it's not playing - warn!( - renderer = self.id().0.as_str(), - error = %err, - "Stop failed when preparing for playlist attach (continuing anyway)" - ); - Ok(()) - }) - } - } - } - - /// Synchronize the local queue state with the backend's actual state. - /// - /// - For OpenHome: fetches the playlist from the renderer and updates local cache - /// - For Internal queue/AVTransport: no-op (queue is already local) - pub fn sync_queue_state(&self) -> Result<()> { - match self { - MusicRendererBackend::OpenHome(_) => { - if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() { - // Fetch fresh playlist snapshot and update cache - provider.invalidate_openhome_cache(self.id())?; - } - Ok(()) - } - MusicRendererBackend::Upnp(_) - | MusicRendererBackend::Chromecast(_) - | MusicRendererBackend::LinkPlay(_) - | MusicRendererBackend::ArylicTcp(_) - | MusicRendererBackend::HybridUpnpArylic { .. } => { - // No sync needed - queue is local only - Ok(()) - } - } - } - - /// Clear the queue on both the backend and in local state. - /// - /// This ensures the backend renderer and local cache are consistent. - /// Should be called before queue mutations to ensure clean state. - pub fn clear_queue(&self) -> Result<()> { - match self { - MusicRendererBackend::OpenHome(_) => { - // For OpenHome: clear the playlist on the renderer itself - self.openhome_playlist_clear() - } - MusicRendererBackend::Upnp(_) - | MusicRendererBackend::Chromecast(_) - | MusicRendererBackend::LinkPlay(_) - | MusicRendererBackend::ArylicTcp(_) - | MusicRendererBackend::HybridUpnpArylic { .. } => { - // For other renderers: no persistent queue to clear - Ok(()) - } - } - } - - /// Add a track to the backend's queue, returning backend-specific track ID if applicable. - /// - /// - For OpenHome: adds to OpenHome playlist and returns track ID - /// - For others: returns error (not supported for single-track renderers) - pub fn add_track_to_queue( - &self, - uri: &str, - metadata: &str, - after_id: Option, - play: bool, - ) -> Result> { - match self { - MusicRendererBackend::OpenHome(_) => { - let track_id = self.openhome_playlist_add_track(uri, metadata, after_id, play)?; - Ok(Some(track_id)) - } - MusicRendererBackend::Upnp(_) - | MusicRendererBackend::Chromecast(_) - | MusicRendererBackend::LinkPlay(_) - | MusicRendererBackend::ArylicTcp(_) - | MusicRendererBackend::HybridUpnpArylic { .. } => Err(anyhow!( - "add_track_to_queue is not supported for {} backend", - self.unsupported_backend_name() - )), - } - } - - /// Select and play a specific track from the backend's queue. - /// - /// - For OpenHome: uses track ID to select from OpenHome playlist - /// - For others: returns error (use play_uri instead) - pub fn select_queue_track(&self, track_id: u32) -> Result<()> { - match self { - MusicRendererBackend::OpenHome(_) => self.openhome_playlist_play_id(track_id), - MusicRendererBackend::Upnp(_) - | MusicRendererBackend::Chromecast(_) - | MusicRendererBackend::LinkPlay(_) - | MusicRendererBackend::ArylicTcp(_) - | MusicRendererBackend::HybridUpnpArylic { .. } => Err(anyhow!( - "select_queue_track is not supported for {} backend", - self.unsupported_backend_name() - )), - } - } - - /// Get the current queue state from the backend. - /// - /// - For OpenHome: fetches current playlist snapshot - /// - For others: returns None (no persistent queue on backend) - pub fn queue_snapshot(&self) -> Result> { - match self { - MusicRendererBackend::OpenHome(_) => { - let oh_snapshot = self.openhome_playlist_snapshot()?; - - // Convert OpenHome tracks to PlaybackItems - let items: Vec = oh_snapshot - .tracks - .iter() - .map(|track| Self::playback_item_from_openhome_track(self.id(), track)) - .collect(); - - let snapshot = QueueSnapshot { - items, - current_index: oh_snapshot.current_index, - }; - - Ok(Some(snapshot)) - } - MusicRendererBackend::Upnp(_) - | MusicRendererBackend::Chromecast(_) - | MusicRendererBackend::LinkPlay(_) - | MusicRendererBackend::ArylicTcp(_) - | MusicRendererBackend::HybridUpnpArylic { .. } => { - // No backend queue for these renderers - Ok(None) - } - } - } - - /// Play the current item from the backend queue. - /// - /// - For OpenHome: Uses the native playlist to play the current track - /// - For others: Returns error (no backend queue) - pub fn play_current_from_backend_queue(&self) -> Result<()> { - match self { - MusicRendererBackend::OpenHome(_) => { - // Get the current OpenHome playlist snapshot - let snapshot = self.openhome_playlist_snapshot()?; - - debug!( - renderer = self.id().0.as_str(), - tracks_count = snapshot.tracks.len(), - current_id = ?snapshot.current_id, - current_index = ?snapshot.current_index, - "play_current_from_backend_queue: OpenHome snapshot fetched" - ); - - if snapshot.tracks.is_empty() { - return Err(anyhow!("OpenHome playlist is empty")); - } - - // Find the track_id to play (prefer current_id, then current_index, then first) - let target_track_id = if let Some(current_id) = snapshot.current_id { - debug!( - renderer = self.id().0.as_str(), - track_id = current_id, - "Using current_id for playback" - ); - Some(current_id) - } else if let Some(current_idx) = snapshot.current_index { - let track_id = snapshot.tracks.get(current_idx).map(|track| track.id); - debug!( - renderer = self.id().0.as_str(), - current_idx, - track_id = ?track_id, - "Using current_index for playback" - ); - track_id - } else { - let track_id = snapshot.tracks.first().map(|track| track.id); - debug!( - renderer = self.id().0.as_str(), - track_id = ?track_id, - "Using first track for playback" - ); - track_id - }; - - if let Some(track_id) = target_track_id { - info!( - renderer = self.id().0.as_str(), - track_id, "Calling openhome_playlist_play_id to start playback" - ); - self.openhome_playlist_play_id(track_id)?; - info!( - renderer = self.id().0.as_str(), - track_id, "Successfully called openhome_playlist_play_id" - ); - Ok(()) - } else { - Err(anyhow!("No track to play in OpenHome playlist")) - } - } - MusicRendererBackend::Upnp(_) - | MusicRendererBackend::Chromecast(_) - | MusicRendererBackend::LinkPlay(_) - | MusicRendererBackend::ArylicTcp(_) - | MusicRendererBackend::HybridUpnpArylic { .. } => Err(anyhow!( - "play_current_from_backend_queue is not supported for {} backend (no persistent queue)", - self.unsupported_backend_name() - )), - } - } - - /// Play the next item from the backend queue. - /// - /// - For OpenHome: Advances to the next track in the playlist - /// - For others: Returns error (no backend queue) - pub fn play_next_from_backend_queue(&self) -> Result<()> { - match self { - MusicRendererBackend::OpenHome(_) => { - // Get the current OpenHome playlist snapshot - let snapshot = self.openhome_playlist_snapshot()?; - - if snapshot.tracks.is_empty() { - return Err(anyhow!("OpenHome playlist is empty, cannot play next")); - } - - // Determine the next track_id - let next_track_id = match snapshot.current_index { - Some(idx) => { - // Take the next track if it exists, otherwise loop to first - snapshot - .tracks - .get(idx + 1) - .map(|track| track.id) - .or_else(|| snapshot.tracks.first().map(|track| track.id)) - } - None => snapshot.tracks.first().map(|track| track.id), - }; - - if let Some(track_id) = next_track_id { - self.openhome_playlist_play_id(track_id)?; - Ok(()) - } else { - Err(anyhow!( - "No track available to advance to in OpenHome playlist" - )) - } - } - MusicRendererBackend::Upnp(_) - | MusicRendererBackend::Chromecast(_) - | MusicRendererBackend::LinkPlay(_) - | MusicRendererBackend::ArylicTcp(_) - | MusicRendererBackend::HybridUpnpArylic { .. } => Err(anyhow!( - "play_next_from_backend_queue is not supported for {} backend (no persistent queue)", - self.unsupported_backend_name() - )), - } - } - - /// Convert an OpenHome playlist track to a PlaybackItem. - fn playback_item_from_openhome_track( - renderer_id: &ServiceId, - track: &OpenHomePlaylistTrack, - ) -> PlaybackItem { - let metadata = TrackMetadata { - title: track.title.clone(), - artist: track.artist.clone(), - album: track.album.clone(), - genre: None, - album_art_uri: track.album_art_uri.clone(), - date: None, - track_number: None, - creator: None, - }; - - PlaybackItem { - media_server_id: ServerId(format!("openhome:{}", renderer_id.0)), - didl_id: format!("openhome:{}", track.id), - uri: track.uri.clone(), - // OpenHome tracks don't provide protocolInfo, use generic default - protocol_info: "http-get:*:audio/*:*".to_string(), - metadata: Some(metadata), - } - } - - pub fn openhome_playlist_add_track( - &self, - uri: &str, - metadata: &str, - after_id: Option, - play: bool, - ) -> Result { - if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() { - let result = { - let mut state = provider.renderer_state_mut(self.id())?; - match &mut *state.queue { - MusicQueue::OpenHome(queue) => { - let playback_item = - Self::playback_item_from_params(self.id(), uri, metadata)?; - queue.add_playback_item(playback_item, after_id, play) - } - _ => Err(op_not_supported( - "openhome_playlist_add_track", - self.unsupported_backend_name(), - )), - } - }; - if result.is_ok() { - provider.invalidate_openhome_cache(self.id())?; - } - result - } else { - self.fetch_openhome_playlist_add_track(uri, metadata, after_id, play) - } - } - - pub fn openhome_playlist_play_id(&self, id: u32) -> Result<()> { - if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() { - let result = { - let mut state = provider.renderer_state_mut(self.id())?; - match &mut *state.queue { - MusicQueue::OpenHome(queue) => queue.select_track_id(id), - _ => Err(op_not_supported( - "openhome_playlist_play_id", - self.unsupported_backend_name(), - )), - } - }; - if result.is_ok() { - provider.invalidate_openhome_cache(self.id())?; - } - result - } else { - self.fetch_openhome_playlist_play_id(id) - } - } - - fn unsupported_backend_name(&self) -> &'static str { - match self { - MusicRendererBackend::Upnp(_) => "UPnP", - MusicRendererBackend::OpenHome(_) => "OpenHome", - MusicRendererBackend::LinkPlay(_) => "LinkPlay", - MusicRendererBackend::ArylicTcp(_) => "ArylicTcp", - MusicRendererBackend::Chromecast(_) => "Chromecast", - MusicRendererBackend::HybridUpnpArylic { .. } => "HybridUpnpArylic", - } - } - - fn fetch_openhome_playlist_add_track( - &self, - uri: &str, - metadata: &str, - after_id: Option, - play: bool, - ) -> Result { - match self { - MusicRendererBackend::OpenHome(renderer) => { - renderer.add_track_openhome(uri, metadata, after_id, play) - } - _ => Err(op_not_supported( - "openhome_playlist_add_track", - self.unsupported_backend_name(), - )), - } - } - - fn fetch_openhome_playlist_play_id(&self, id: u32) -> Result<()> { - match self { - MusicRendererBackend::OpenHome(renderer) => renderer.play_openhome_track_id(id), - _ => Err(op_not_supported( - "openhome_playlist_play_id", - self.unsupported_backend_name(), - )), - } - } - - fn playback_item_from_params( - renderer_id: &ServiceId, - uri: &str, - metadata_xml: &str, - ) -> Result { - let metadata = parse_track_metadata_from_didl(metadata_xml); - let didl_id = didl_id_from_metadata(metadata_xml) - .unwrap_or_else(|| format!("openhome:{}", renderer_id.0)); - Ok(PlaybackItem { - media_server_id: ServerId(format!("openhome:{}", renderer_id.0)), - didl_id, - uri: uri.to_string(), - // OpenHome tracks don't provide protocolInfo, use generic default - protocol_info: "http-get:*:audio/*:*".to_string(), - metadata, - }) - } -} - -/// Transport control façade that dispatches to whichever backend can fulfill -/// the request, returning a standardized error if the backend lacks support. -impl TransportControl for MusicRendererBackend { - fn play_uri(&self, uri: &str, meta: &str) -> Result<()> { - match self { - MusicRendererBackend::Upnp(upnp) => upnp.play_uri(uri, meta), - MusicRendererBackend::OpenHome(oh) => oh.play_uri(uri, meta), - MusicRendererBackend::LinkPlay(lp) => lp.play_uri(uri, meta), - MusicRendererBackend::ArylicTcp(_) => Err(op_not_supported("play_uri", "ArylicTcp")), - MusicRendererBackend::Chromecast(cc) => cc.play_uri(uri, meta), - MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta), - } - } - - fn play(&self) -> Result<()> { - match self { - MusicRendererBackend::Upnp(upnp) => upnp.play(), - MusicRendererBackend::OpenHome(oh) => oh.play(), - MusicRendererBackend::LinkPlay(lp) => lp.play(), - MusicRendererBackend::ArylicTcp(ary) => ary.play(), - MusicRendererBackend::Chromecast(cc) => cc.play(), - MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.play(), - } - } - - fn pause(&self) -> Result<()> { - match self { - MusicRendererBackend::Upnp(upnp) => upnp.pause(), - MusicRendererBackend::OpenHome(oh) => oh.pause(), - MusicRendererBackend::LinkPlay(lp) => lp.pause(), - MusicRendererBackend::ArylicTcp(ary) => ary.pause(), - MusicRendererBackend::Chromecast(cc) => cc.pause(), - MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.pause(), - } - } - - fn stop(&self) -> Result<()> { - match self { - MusicRendererBackend::Upnp(upnp) => upnp.stop(), - MusicRendererBackend::OpenHome(oh) => oh.stop(), - MusicRendererBackend::LinkPlay(lp) => lp.stop(), - MusicRendererBackend::ArylicTcp(ary) => ary.stop(), - MusicRendererBackend::Chromecast(cc) => cc.stop(), - MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.stop(), - } - } - - fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { - match self { - MusicRendererBackend::Upnp(upnp) => upnp.seek_rel_time(hhmmss), - MusicRendererBackend::OpenHome(oh) => oh.seek_rel_time(hhmmss), - MusicRendererBackend::LinkPlay(lp) => lp.seek_rel_time(hhmmss), - MusicRendererBackend::ArylicTcp(_) => { - Err(op_not_supported("seek_rel_time", "ArylicTcp")) - } - MusicRendererBackend::Chromecast(cc) => cc.seek_rel_time(hhmmss), - MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.seek_rel_time(hhmmss), - } - } -} - -/// Volume and mute controls exposed via the façade. -/// -/// Hybrid backends may read via Arylic TCP and write via UPnP, but callers -/// always depend on a single [`VolumeControl`] entry point. -impl VolumeControl for MusicRendererBackend { - fn volume(&self) -> Result { - match self { - MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.volume(), - MusicRendererBackend::ArylicTcp(ary) => ary.volume(), - MusicRendererBackend::OpenHome(oh) => oh.volume(), - MusicRendererBackend::Upnp(upnp) => upnp.volume(), - MusicRendererBackend::LinkPlay(lp) => lp.volume(), - MusicRendererBackend::Chromecast(cc) => cc.volume(), - } - } - - fn set_volume(&self, vol: u16) -> Result<()> { - match self { - MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_volume(vol), - MusicRendererBackend::ArylicTcp(ary) => ary.set_volume(vol), - MusicRendererBackend::OpenHome(oh) => oh.set_volume(vol), - MusicRendererBackend::Upnp(upnp) => upnp.set_volume(vol), - MusicRendererBackend::LinkPlay(lp) => lp.set_volume(vol), - MusicRendererBackend::Chromecast(cc) => cc.set_volume(vol), - } - } - - fn mute(&self) -> Result { - match self { - MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.mute(), - MusicRendererBackend::OpenHome(r) => r.mute(), - MusicRendererBackend::Upnp(r) => r.get_master_mute(), - MusicRendererBackend::LinkPlay(r) => r.mute(), - MusicRendererBackend::ArylicTcp(r) => r.mute(), - MusicRendererBackend::Chromecast(cc) => cc.mute(), - } - } - - fn set_mute(&self, m: bool) -> Result<()> { - match self { - MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.set_mute(m), - MusicRendererBackend::OpenHome(r) => r.set_mute(m), - MusicRendererBackend::Upnp(r) => r.set_master_mute(m), - MusicRendererBackend::LinkPlay(r) => r.set_mute(m), - MusicRendererBackend::ArylicTcp(r) => r.set_mute(m), - MusicRendererBackend::Chromecast(cc) => cc.set_mute(m), - } - } -} - -/// Playback-state queries sourced from the backend best suited for the job. -/// -/// Each backend reports into [`PlaybackState`], ensuring consumers never have -/// to reason about protocol-specific state machines. -impl PlaybackStatus for MusicRendererBackend { - fn playback_state(&self) -> Result { - match self { - MusicRendererBackend::Upnp(r) => PlaybackStatus::playback_state(r), - MusicRendererBackend::OpenHome(r) => PlaybackStatus::playback_state(r), - MusicRendererBackend::LinkPlay(r) => r.playback_state(), - MusicRendererBackend::ArylicTcp(r) => r.playback_state(), - MusicRendererBackend::Chromecast(cc) => cc.playback_state(), - MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_state(), - } - } -} - -/// Playback-position queries that always yield a [`PlaybackPositionInfo`] -/// regardless of the backend providing the raw transport data. -impl PlaybackPosition for MusicRendererBackend { - fn playback_position(&self) -> Result { - match self { - MusicRendererBackend::Upnp(r) => r.playback_position(), - MusicRendererBackend::OpenHome(r) => r.playback_position(), - MusicRendererBackend::LinkPlay(r) => r.playback_position(), - MusicRendererBackend::ArylicTcp(r) => r.playback_position(), - MusicRendererBackend::Chromecast(cc) => cc.playback_position(), - MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_position(), - } - } -} diff --git a/pmocontrol/src/music_renderer/arylic_tcp.rs b/pmocontrol/src/music_renderer/arylic_tcp.rs new file mode 100644 index 00000000..85c139fb --- /dev/null +++ b/pmocontrol/src/music_renderer/arylic_tcp.rs @@ -0,0 +1,321 @@ +use std::time::Duration; + +use serde::Deserialize; +use tracing::debug; + +use crate::DeviceIdentity; +use crate::arylic_client::{ARYLIC_TCP_PORT, DEFAULT_TIMEOUT_SECS, send_command_no_response, send_command_optional, send_command_required}; +use crate::errors::ControlPointError; +use crate::linkplay_client::extract_linkplay_host; +use crate::model::{PlaybackState, RendererInfo}; +use crate::music_renderer::RendererFromMediaRendererInfo; +use crate::music_renderer::capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, + VolumeControl, +}; +use crate::music_renderer::time_utils::{ms_to_seconds, format_hhmmss, parse_hhmmss_strict}; +use crate::music_renderer::musicrenderer::MusicRendererBackend; + +/// Raw response from Arylic MCU+PINFGET command +#[derive(Debug, Deserialize)] +struct ArylicPlaybackInfoRaw { + status: String, + curpos: String, + totlen: String, + #[serde(default)] + vol: Option, + #[serde(default)] + mute: Option, + #[serde(default)] + plicount: Option, + #[serde(default)] + plicurr: Option, +} + +/// Backend speaking the Arylic TCP control protocol (port 8899). +#[derive(Clone, Debug)] +pub struct ArylicTcpRenderer { + host: String, + port: u16, + timeout: Duration, +} + +impl ArylicTcpRenderer { + fn send_required(&self, cmd: &str, expected: &[&str]) -> Result { + send_command_required(&self.host, self.port, self.timeout, cmd, expected) + } + + fn send_optional( + &self, + cmd: &str, + expected: &[&str], + ) -> Result, ControlPointError> { + send_command_optional(&self.host, self.port, self.timeout, cmd, expected) + } + + fn send_no_response(&self, cmd: &str) -> Result<(), ControlPointError> { + send_command_no_response(&self.host, self.port, self.timeout, cmd) + } + + fn fetch_playback_info(&self) -> Result { + let payload = self.send_required("MCU+PINFGET", &["AXX+PLY+INF"])?; + match parse_playback_info(&payload) { + Ok(info) => Ok(info), + Err(err) => { + debug!( + "Failed to parse Arylic playback info for {}: {}", + self.host, err + ); + Err(err) + } + } + } + + fn format_volume_command(value: u16) -> String { + format!("MCU+VOL+{:03}", value.min(100)) + } + + fn parse_volume_payload(payload: &str) -> Result { + let data = payload.strip_prefix("AXX+VOL+").ok_or_else(|| { + ControlPointError::ArilycTcpError(format!("Unexpected volume response: {}", payload)) + })?; + let value: u16 = data.trim().parse().map_err(|_| { + ControlPointError::ArilycTcpError(format!("Invalid volume value: {}", data)) + })?; + Ok(value.min(100)) + } + + fn parse_mute_payload(payload: &str) -> Result { + let data = payload.strip_prefix("AXX+MUT+").ok_or_else(|| { + ControlPointError::ArilycTcpError(format!("Unexpected mute response: {}", payload)) + })?; + match data.trim() { + "000" | "0" => Ok(false), + "001" | "1" => Ok(true), + other => Err(ControlPointError::ArilycTcpError(format!( + "Invalid mute value: {}", + other + ))), + } + } +} + +impl RendererFromMediaRendererInfo for ArylicTcpRenderer { + fn from_renderer_info(info: &RendererInfo) -> Result { + let host = extract_linkplay_host(info.location()).ok_or_else(|| { + ControlPointError::ArilycTcpError(format!( + "Renderer {} has no valid LOCATION host", + info.udn() + )) + })?; + + Ok(Self { + host, + port: ARYLIC_TCP_PORT, + timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS), + }) + } + + fn to_backend(self) -> MusicRendererBackend { + MusicRendererBackend::ArylicTcp(self) + } +} + +impl TransportControl for ArylicTcpRenderer { + fn play_uri(&self, _uri: &str, _meta: &str) -> Result<(), ControlPointError> { + Err(ControlPointError::upnp_operation_not_supported( + "Arylic TCP backend does not support direct URL loading.", + "ArylicTcpRenderer", + )) + } + + fn play(&self) -> Result<(), ControlPointError> { + self.send_no_response("MCU+PLY-PLA") + } + + fn pause(&self) -> Result<(), ControlPointError> { + let _ = self.send_optional("MCU+PLY-PUS", &["AXX+PLY+"])?; + Ok(()) + } + + fn stop(&self) -> Result<(), ControlPointError> { + self.send_no_response("MCU+PLY-STP") + } + + fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { + let _ = parse_hhmmss_strict(hhmmss)?; + Err(ControlPointError::ArilycTcpError( + "Arylic TCP seek_rel_time is not implemented yet for this device.".to_string() + )) + } +} + +impl VolumeControl for ArylicTcpRenderer { + fn volume(&self) -> Result { + if let Ok(info) = self.fetch_playback_info() { + if let Some(vol) = info.volume { + return Ok(vol); + } + debug!( + "Arylic playback info for {} missing volume, falling back to VOL GET", + self.host + ); + } + let payload = self.send_required("MCU+VOL+GET", &["AXX+VOL+"])?; + Self::parse_volume_payload(&payload) + } + + fn set_volume(&self, v: u16) -> Result<(), ControlPointError> { + let command = Self::format_volume_command(v); + let _ = self.send_optional(&command, &["AXX+VOL+"])?; + Ok(()) + } + + fn mute(&self) -> Result { + if let Ok(info) = self.fetch_playback_info() { + if let Some(mute) = info.mute { + return Ok(mute); + } + debug!( + "Arylic playback info for {} missing mute, falling back to MUT GET", + self.host + ); + } + let payload = self.send_required("MCU+MUT+GET", &["AXX+MUT+"])?; + Self::parse_mute_payload(&payload) + } + + fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { + let command = if m { "MCU+MUT+001" } else { "MCU+MUT+000" }; + let payload = self.send_required(command, &["AXX+MUT+"])?; + let _ = Self::parse_mute_payload(&payload)?; + Ok(()) + } +} + +impl PlaybackStatus for ArylicTcpRenderer { + fn playback_state(&self) -> Result { + let info = self.fetch_playback_info()?; + Ok(info.playback_state()) + } +} + +impl PlaybackPosition for ArylicTcpRenderer { + fn playback_position(&self) -> Result { + let info = self.fetch_playback_info()?; + Ok(info.position_info()) + } +} + +#[derive(Debug)] +struct ArylicPlaybackInfo { + status_raw: String, + curpos_ms: u64, + totlen_ms: u64, + volume: Option, + mute: Option, + playlist_size: Option, + track_index: Option, +} + +impl ArylicPlaybackInfo { + fn playback_state(&self) -> PlaybackState { + match self.status_raw.as_str() { + "play" => PlaybackState::Playing, + "pause" => PlaybackState::Paused, + "stop" => PlaybackState::Stopped, + other => PlaybackState::Unknown(other.to_string()), + } + } + + fn position_info(&self) -> PlaybackPositionInfo { + let track = match (self.track_index, self.playlist_size) { + (Some(idx), Some(count)) if count > 0 => Some(idx.min(count)), + (Some(idx), _) => Some(idx), + _ => None, + }; + + PlaybackPositionInfo { + track, + rel_time: Some(format_hhmmss(ms_to_seconds(self.curpos_ms))), + abs_time: None, + track_duration: if self.totlen_ms > 0 { + Some(format_hhmmss(ms_to_seconds(self.totlen_ms))) + } else { + None + }, + track_metadata: None, + track_uri: None, + } + } +} + +fn parse_playback_info(payload: &str) -> Result { + let json_blob = payload.strip_prefix("AXX+PLY+INF").ok_or_else(|| { + ControlPointError::ArilycTcpError(format!("Unexpected playback info prefix: {}", payload)) + })?; + + let json_blob = json_blob.trim_end_matches('&').trim(); + + let raw: ArylicPlaybackInfoRaw = serde_json::from_str(json_blob) + .map_err(|e| ControlPointError::ArilycTcpError(format!("Failed to parse Arylic playback info JSON: {}", e)))?; + + let curpos_ms = raw.curpos.parse::() + .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid curpos value: {}", raw.curpos)))?; + + let totlen_ms = raw.totlen.parse::() + .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid totlen value: {}", raw.totlen)))?; + + let volume = raw.vol.and_then(|raw_vol| { + match raw_vol.parse::() { + Ok(value) => Some(value.min(100)), + Err(err) => { + debug!("Invalid Arylic `vol` value {}: {}", raw_vol, err); + None + } + } + }); + + let mute = raw.mute.and_then(|raw_mute| { + match raw_mute.as_str() { + "1" => Some(true), + "0" => Some(false), + other => { + debug!("Invalid Arylic `mute` value {}", other); + None + } + } + }); + + let playlist_size = raw.plicount.and_then(|raw_count| { + match raw_count.parse::() { + Ok(count) if count > 0 => Some(count), + Ok(_) => None, + Err(err) => { + debug!("Invalid Arylic `plicount` value {}: {}", raw_count, err); + None + } + } + }); + + let track_index = raw.plicurr.and_then(|raw_idx| { + match raw_idx.parse::() { + Ok(idx) if idx > 0 => Some(idx), + Ok(_) => None, + Err(err) => { + debug!("Invalid Arylic `plicurr` value {}: {}", raw_idx, err); + None + } + } + }); + + Ok(ArylicPlaybackInfo { + status_raw: raw.status, + curpos_ms, + totlen_ms, + volume, + mute, + playlist_size, + track_index, + }) +} diff --git a/pmocontrol/src/capabilities.rs b/pmocontrol/src/music_renderer/capabilities.rs similarity index 62% rename from pmocontrol/src/capabilities.rs rename to pmocontrol/src/music_renderer/capabilities.rs index 06f5b85c..bda9f8cf 100644 --- a/pmocontrol/src/capabilities.rs +++ b/pmocontrol/src/music_renderer/capabilities.rs @@ -1,7 +1,7 @@ // pmocontrol/src/capabilities.rs use anyhow::Result; -use crate::errors::ControlPointError; +use crate::{errors::ControlPointError, model::PlaybackState}; /// Logical playback position across backends. /// @@ -21,51 +21,6 @@ pub trait PlaybackPosition { fn playback_position(&self) -> Result; } -/// High-level playback state across backends. -#[derive(Clone, Debug)] -pub enum PlaybackState { - Stopped, - Playing, - Paused, - Transitioning, - NoMedia, - /// Backend-specific or unknown state string. - Unknown(String), -} - -impl PlaybackState { - /// Map a raw UPnP AVTransport CurrentTransportState string - /// to a logical PlaybackState. - pub fn from_upnp_state(raw: &str) -> Self { - let s = raw.trim().to_ascii_uppercase(); - match s.as_str() { - "STOPPED" => PlaybackState::Stopped, - "PLAYING" => PlaybackState::Playing, - "PAUSED_PLAYBACK" => PlaybackState::Paused, - // States from the AVTransport spec that we normalize: - "PAUSED_RECORDING" => PlaybackState::Paused, - "RECORDING" => PlaybackState::Playing, - // Common vendor-specific states: - "TRANSITIONING" => PlaybackState::Transitioning, - "BUFFERING" | "PREPARING" => PlaybackState::Transitioning, - "NO_MEDIA_PRESENT" => PlaybackState::NoMedia, - _ => PlaybackState::Unknown(raw.to_string()), - } - } - - /// Returns a human-readable label for the playback state. - pub fn as_str(&self) -> &str { - match self { - PlaybackState::Stopped => "STOPPED", - PlaybackState::Playing => "PLAYING", - PlaybackState::Paused => "PAUSED", - PlaybackState::Transitioning => "TRANSITIONING", - PlaybackState::NoMedia => "NO_MEDIA", - PlaybackState::Unknown(s) => s.as_str(), - } - } -} - /// Generic abstraction for playback status (transport state). /// /// For UPnP AV, this is backed by AVTransport::GetTransportInfo. diff --git a/pmocontrol/src/chromecast_renderer.rs b/pmocontrol/src/music_renderer/chromecast_renderer.rs similarity index 93% rename from pmocontrol/src/chromecast_renderer.rs rename to pmocontrol/src/music_renderer/chromecast_renderer.rs index e0906d86..bffee820 100644 --- a/pmocontrol/src/chromecast_renderer.rs +++ b/pmocontrol/src/music_renderer/chromecast_renderer.rs @@ -14,18 +14,19 @@ use std::sync::{Arc, Mutex, Once}; use std::thread::JoinHandle; -use anyhow::{Result, anyhow}; - use tracing::debug; use crate::DeviceIdentity; -use crate::capabilities::{ - PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, +use crate::music_renderer::capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl, }; -use crate::chromecast_discovery::{extract_host_from_location, extract_port_from_location}; +use crate::music_renderer::time_utils::{format_hhmmss_f64, parse_hhmmss_strict}; +use crate::discovery::chromecast_discovery::{extract_host_from_location, extract_port_from_location}; use crate::errors::ControlPointError; -use crate::model::{RendererInfo}; +use crate::model::{PlaybackState, RendererInfo}; +use crate::music_renderer::RendererFromMediaRendererInfo; +use crate::music_renderer::musicrenderer::MusicRendererBackend; use rust_cast::{ CastDevice, ChannelMessage, @@ -116,9 +117,8 @@ fn map_player_state(player_state: &CastPlayerState) -> PlaybackState { } -impl ChromecastRenderer { - /// Creates a new ChromecastRenderer from RendererInfo. - pub fn from_renderer_info(info: RendererInfo) -> Result { +impl RendererFromMediaRendererInfo for ChromecastRenderer { + fn from_renderer_info(info: &RendererInfo) -> Result { tracing::info!( "ChromecastRenderer::from_renderer_info location={} for {}", info.location(), @@ -147,6 +147,11 @@ impl ChromecastRenderer { thread_handle, }) } + + fn to_backend(self) -> MusicRendererBackend { + MusicRendererBackend::Chromecast(self) + } + } impl TransportControl for ChromecastRenderer { @@ -428,26 +433,7 @@ impl TransportControl for ChromecastRenderer { fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { debug!("ChromecastRenderer: seek_rel_time({})", hhmmss); - // Parse HH:MM:SS to seconds - let parts: Vec<&str> = hhmmss.split(':').collect(); - if parts.len() != 3 { - return Err(ControlPointError::ChromecastError(format!( - "Invalid time format, expected HH:MM:SS: {}", - hhmmss - ))); - } - - let hours: u32 = parts[0] - .parse() - .map_err(|_| ControlPointError::ChromecastError(format!("Invalid hours in time: {}", hhmmss)))?; - let minutes: u32 = parts[1] - .parse() - .map_err(|_| ControlPointError::ChromecastError(format!("Invalid minutes in time: {}", hhmmss)))?; - let seconds: u32 = parts[2] - .parse() - .map_err(|_| ControlPointError::ChromecastError(format!("Invalid seconds in time: {}", hhmmss)))?; - - let total_seconds = (hours * 3600 + minutes * 60 + seconds) as f32; + let total_seconds = parse_hhmmss_strict(hhmmss)? as f32; let device = connect_to_device(&self.host, self.port)?; @@ -563,13 +549,13 @@ impl PlaybackPosition for ChromecastRenderer { // Extract position information let rel_time = media_entry .current_time - .map(|time| format_time_hhmmss(time as f64)); + .map(|time| format_hhmmss_f64(time as f64)); let track_duration = media_entry .media .as_ref() .and_then(|m| m.duration) - .map(|dur| format_time_hhmmss(dur as f64)); + .map(|dur| format_hhmmss_f64(dur as f64)); let track_uri = media_entry.media.as_ref().map(|m| m.content_id.clone()); @@ -645,15 +631,6 @@ fn detect_content_type_from_meta(uri: &str, meta: &str) -> String { content_type.to_string() } -/// Converts seconds to HH:MM:SS format. -fn format_time_hhmmss(seconds: f64) -> String { - let total_secs = seconds as u64; - let hours = total_secs / 3600; - let minutes = (total_secs % 3600) / 60; - let secs = total_secs % 60; - format!("{:02}:{:02}:{:02}", hours, minutes, secs) -} - impl VolumeControl for ChromecastRenderer { fn volume(&self) -> Result { let device = connect_to_device(&self.host, self.port)?; diff --git a/pmocontrol/src/music_renderer/linkplay_renderer.rs b/pmocontrol/src/music_renderer/linkplay_renderer.rs new file mode 100644 index 00000000..f497f32b --- /dev/null +++ b/pmocontrol/src/music_renderer/linkplay_renderer.rs @@ -0,0 +1,134 @@ +use std::fmt; +use std::time::Duration; + +use ureq::Agent; + +use crate::DeviceIdentity; +use crate::linkplay_client::{LinkPlayStatus, build_agent, extract_linkplay_host, fetch_status_for_host, percent_encode}; +use crate::music_renderer::capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, + VolumeControl, +}; +use crate::music_renderer::time_utils::{parse_hhmmss_strict}; +use crate::errors::ControlPointError; +use crate::model::{RendererInfo, PlaybackState}; +use crate::music_renderer::RendererFromMediaRendererInfo; +use crate::music_renderer::musicrenderer::MusicRendererBackend; + +const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3; + + +/// Renderer backend for devices exposing the LinkPlay HTTP API. +#[derive(Clone)] +pub struct LinkPlayRenderer { + host: String, + timeout: Duration, +} + +impl fmt::Debug for LinkPlayRenderer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("LinkPlayRenderer") + .field("host", &self.host) + .finish() + } +} + +impl LinkPlayRenderer { + + fn agent(&self) -> Agent { + build_agent(self.timeout) + } + + fn send_player_command(&self, command: &str) -> Result<(), ControlPointError> { + let url = format!( + "http://{}/httpapi.asp?command=setPlayerCmd:{}", + self.host, command + ); + self.agent() + .get(&url) + .call() + .map_err(|_| ControlPointError::ArilycTcpError(format!("LinkPlay command {} failed for {}", command, self.host)))?; + Ok(()) + } + + fn fetch_status(&self) -> Result { + fetch_status_for_host(&self.host, self.timeout) + } +} + +impl RendererFromMediaRendererInfo for LinkPlayRenderer { + fn from_renderer_info(info: &RendererInfo) -> Result { + let host = extract_linkplay_host(&info.location()) + .ok_or_else(|| ControlPointError::LinkPlayError(format!("Renderer {} has no valid LOCATION host", info.udn())))?; + + Ok(Self { + host, + timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS), + }) + } + + + fn to_backend(self) -> MusicRendererBackend { + MusicRendererBackend::LinkPlay(self) + } + +} + +impl TransportControl for LinkPlayRenderer { + fn play_uri(&self, uri: &str, _meta: &str) -> Result<(), ControlPointError> { + let encoded = percent_encode(uri); + self.send_player_command(&format!("play:{}", encoded)) + } + + fn play(&self) -> Result<(), ControlPointError> { + self.send_player_command("resume") + } + + fn pause(&self) -> Result<(), ControlPointError> { + self.send_player_command("pause") + } + + fn stop(&self) -> Result<(), ControlPointError> { + self.send_player_command("stop") + } + + fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { + let secs = parse_hhmmss_strict(hhmmss)?; + self.send_player_command(&format!("seek:{}", secs)) + } +} + +impl VolumeControl for LinkPlayRenderer { + fn volume(&self) -> Result { + Ok(self.fetch_status()?.volume) + } + + fn set_volume(&self, v: u16) -> Result<(), ControlPointError> { + let value = v.min(100); + self.send_player_command(&format!("vol:{}", value)) + } + + fn mute(&self) -> Result { + Ok(self.fetch_status()?.mute) + } + + fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { + self.send_player_command(if m { "mute:1" } else { "mute:0" }) + } +} + +impl PlaybackStatus for LinkPlayRenderer { + fn playback_state(&self) -> Result { + Ok(self.fetch_status()?.playback_state()) + } +} + +impl PlaybackPosition for LinkPlayRenderer { + fn playback_position(&self) -> Result { + Ok(self.fetch_status()?.position_info()) + } +} + + + + diff --git a/pmocontrol/src/music_renderer/mod.rs b/pmocontrol/src/music_renderer/mod.rs new file mode 100644 index 00000000..b9c5049e --- /dev/null +++ b/pmocontrol/src/music_renderer/mod.rs @@ -0,0 +1,53 @@ +mod arylic_tcp; +mod linkplay_renderer; + +mod upnp_renderer; + +mod openhome; +mod openhome_renderer; + +mod capabilities; +mod chromecast_renderer; + +mod musicrenderer; +pub mod time_utils; + +use std::sync::{Arc, Mutex}; + +pub use crate::music_renderer::capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, +}; +pub use crate::music_renderer::musicrenderer::MusicRenderer; +use crate::{ + RendererInfo, errors::ControlPointError, music_renderer::musicrenderer::MusicRendererBackend, +}; + +pub trait RendererFromMediaRendererInfo { + fn from_renderer_info(renderer: &RendererInfo) -> Result + where + Self: Sized; + + fn to_backend(self) -> MusicRendererBackend + where + Self: Sized; + + fn build_from_renderer_info( + renderer: &RendererInfo, + ) -> Result + where + Self: Sized, + { + let instance = Self::from_renderer_info(renderer)?; + Ok(instance.to_backend()) + } + + fn make_from_renderer_info( + renderer: &RendererInfo, + ) -> Result>, ControlPointError> + where + Self: Sized, + { + let backend = Self::build_from_renderer_info(renderer)?; + Ok(Arc::new(Mutex::new(backend))) + } +} diff --git a/pmocontrol/src/music_renderer/musicrenderer.rs b/pmocontrol/src/music_renderer/musicrenderer.rs new file mode 100644 index 00000000..3011a912 --- /dev/null +++ b/pmocontrol/src/music_renderer/musicrenderer.rs @@ -0,0 +1,737 @@ +//! Backend-agnostic music renderer façade for PMOMusic. +//! +//! `MusicRenderer` wraps every supported backend (UPnP AV/DLNA, OpenHome, +//! LinkPlay HTTP, Arylic TCP, Chromecast, and the hybrid UPnP + Arylic pairing) behind a +//! single control surface. Higher layers in PMOMusic must only interact with +//! renderers through this type so that transport, volume, and state queries +//! stay backend-neutral. + +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; + +use crate::control_point::PlaylistBinding; +use crate::errors::ControlPointError; +use crate::model::{RendererInfo, RendererProtocol, TrackMetadata, PlaybackState}; +use crate::music_renderer::RendererFromMediaRendererInfo; +use crate::music_renderer::arylic_tcp::ArylicTcpRenderer; +use crate::music_renderer::capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, + VolumeControl, +}; +use crate::music_renderer::chromecast_renderer::ChromecastRenderer; +use crate::music_renderer::linkplay_renderer::LinkPlayRenderer; +use crate::music_renderer::openhome_renderer::OpenHomeRenderer; +use crate::music_renderer::upnp_renderer::UpnpRenderer; +use crate::online::DeviceConnectionState; +use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot}; +use crate::{DeviceId, DeviceIdentity, DeviceOnline}; + +use tracing::warn; + +/// Backend-agnostic façade exposing transport, volume, and status contracts. +#[derive(Clone, Debug)] +pub enum MusicRendererBackend { + /// Classic UPnP AV / DLNA renderer (AVTransport + RenderingControl). + Upnp(UpnpRenderer), + /// Renderer powered by OpenHome services. + OpenHome(OpenHomeRenderer), + /// Renderer controlled via the LinkPlay HTTP API. + LinkPlay(LinkPlayRenderer), + /// Renderer reachable through the Arylic TCP control protocol (port 8899). + ArylicTcp(ArylicTcpRenderer), + /// Renderer controlled via the Google Cast protocol (Chromecast). + Chromecast(ChromecastRenderer), + /// Combined backend using UPnP for transport + volume writes and Arylic TCP + /// to read detailed playback information as well as live volume/mute state. + HybridUpnpArylic { + upnp: UpnpRenderer, + arylic: ArylicTcpRenderer, + }, +} + +#[derive(Debug, Clone)] +pub struct MusicRenderer { + info: RendererInfo, + connection: Arc>, + backend: Arc>, + queue: Arc>, + playlist_binding: Arc>>, +} + +impl MusicRenderer { + pub fn new( + info: RendererInfo, + backend: Arc>, + queue: Arc>, + ) -> Arc { + let connection = DeviceConnectionState::new(); + + let renderer = MusicRenderer { + info, + connection: Arc::new(Mutex::new(connection)), + backend, + queue, + playlist_binding: Arc::new(Mutex::new(None)), + }; + + Arc::new(renderer) + } + + pub fn from_renderer_info(info: &RendererInfo) -> Result { + let connection = Arc::new(Mutex::new(DeviceConnectionState::new())); + let backend = MusicRendererBackend::make_from_renderer_info(info)?; + let queue = MusicQueue::make_from_renderer_info(info)?; + + let renderer = MusicRenderer { + info: info.clone(), + connection, + backend, + queue, + playlist_binding: Arc::new(Mutex::new(None)), + }; + Ok(renderer) + } + + pub fn info(&self) -> &RendererInfo { + &self.info + } + + /// Returns the protocol. + fn protocol(&self) -> RendererProtocol { + self.info.protocol() + } + + fn is_upnp(&self) -> bool { + match &*self.backend.lock().expect("Backend mutex poisoned") { + MusicRendererBackend::Upnp(_) => true, + _ => false, + } + } + + fn is_openhome(&self) -> bool { + match &*self.backend.lock().expect("Backend mutex poisoned") { + MusicRendererBackend::OpenHome(_) => true, + _ => false, + } + } + + fn is_linkplay(&self) -> bool { + match &*self.backend.lock().expect("Backend mutex poisoned") { + MusicRendererBackend::LinkPlay(_) => true, + _ => false, + } + } + + fn is_arylictcp(&self) -> bool { + match &*self.backend.lock().expect("Backend mutex poisoned") { + MusicRendererBackend::ArylicTcp(_) => true, + _ => false, + } + } + + fn is_chromecast(&self) -> bool { + match &*self.backend.lock().expect("Backend mutex poisoned") { + MusicRendererBackend::Chromecast(_) => true, + _ => false, + } + } + + fn is_hybridupnparylic(&self) -> bool { + match &*self.backend.lock().expect("Backend mutex poisoned") { + MusicRendererBackend::HybridUpnpArylic { .. } => true, + _ => false, + } + } + + /// Returns true if this renderer is known to support SetNextAVTransportURI. + pub fn supports_set_next(&self) -> bool { + self.info.capabilities().supports_set_next() + } + + /// Prepare the renderer for attaching a new playlist by clearing the queue and stopping playback. + pub fn clear_for_playlist_attach(&self) -> Result<(), ControlPointError> { + // Clear the queue first + self.queue + .lock() + .expect("Queue mutex poisoned") + .clear_queue()?; + + // Then stop playback (ignore errors if already stopped) + self.backend + .lock() + .expect("Backend mutex poisoned") + .stop() + .or_else(|err| { + warn!( + renderer = self.id().0.as_str(), + error = %err, + "Stop failed when preparing for playlist attach (continuing anyway)" + ); + Ok(()) + }) + } + + /// Get the current queue snapshot. + pub fn queue_snapshot(&self) -> Result { + self.queue + .lock() + .expect("Queue mutex poisoned") + .queue_snapshot() + } + + /// Play the current item from the queue. + pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> { + let queue = self.queue.lock().expect("Queue mutex poisoned"); + let backend = self.backend.lock().expect("Backend mutex poisoned"); + + // Get the current item from the queue + let (item, _remaining) = queue.peek_current()?.ok_or_else(|| { + ControlPointError::QueueError("Queue is empty, cannot play current".to_string()) + })?; + + // Build DIDL metadata if available + let metadata_xml = item + .metadata + .as_ref() + .map(|m| build_didl_lite_metadata(m, &item.uri, &item.protocol_info)) + .unwrap_or_default(); + + // Play the URI using the backend + backend.play_uri(&item.uri, &metadata_xml)?; + + Ok(()) + } + + /// Advance to and play the next item from the queue. + pub fn play_next_from_queue(&self) -> Result<(), ControlPointError> { + let mut queue = self.queue.lock().expect("Queue mutex poisoned"); + let backend = self.backend.lock().expect("Backend mutex poisoned"); + + // Dequeue the next item + let (item, _remaining) = queue + .dequeue_next()? + .ok_or_else(|| ControlPointError::QueueError("No next item in queue".to_string()))?; + + // Build DIDL metadata if available + let metadata_xml = item + .metadata + .as_ref() + .map(|m| build_didl_lite_metadata(m, &item.uri, &item.protocol_info)) + .unwrap_or_default(); + + // Play the URI using the backend + backend.play_uri(&item.uri, &metadata_xml)?; + + Ok(()) + } + + /// Transport control: play URI with metadata + pub fn play_uri(&self, uri: &str, metadata: &str) -> Result<(), ControlPointError> { + self.backend + .lock() + .expect("Backend mutex poisoned") + .play_uri(uri, metadata) + } + + /// Transport control: play + pub fn play(&self) -> Result<(), ControlPointError> { + self.backend.lock().expect("Backend mutex poisoned").play() + } + + /// Transport control: pause + pub fn pause(&self) -> Result<(), ControlPointError> { + self.backend.lock().expect("Backend mutex poisoned").pause() + } + + /// Transport control: stop + pub fn stop(&self) -> Result<(), ControlPointError> { + self.backend.lock().expect("Backend mutex poisoned").stop() + } + + /// Transport control: seek to relative time + pub fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { + self.backend + .lock() + .expect("Backend mutex poisoned") + .seek_rel_time(hhmmss) + } + + /// Volume control: get current volume + pub fn volume(&self) -> Result { + self.backend + .lock() + .expect("Backend mutex poisoned") + .volume() + } + + /// Volume control: set volume + pub fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> { + self.backend + .lock() + .expect("Backend mutex poisoned") + .set_volume(vol) + } + + /// Volume control: get mute state + pub fn mute(&self) -> Result { + self.backend.lock().expect("Backend mutex poisoned").mute() + } + + /// Volume control: set mute state + pub fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { + self.backend + .lock() + .expect("Backend mutex poisoned") + .set_mute(m) + } + + /// Get playback state + pub fn playback_state(&self) -> Result { + self.backend + .lock() + .expect("Backend mutex poisoned") + .playback_state() + } + + /// Get playback position + pub fn playback_position(&self) -> Result { + self.backend + .lock() + .expect("Backend mutex poisoned") + .playback_position() + } + + /// Sets the playlist binding for this renderer. + pub fn set_playlist_binding(&self, binding: Option) { + *self.playlist_binding.lock().expect("Playlist binding mutex poisoned") = binding; + } + + /// Gets the current playlist binding, if any. + pub fn get_playlist_binding(&self) -> Option { + self.playlist_binding.lock().expect("Playlist binding mutex poisoned").clone() + } + + /// Clears the playlist binding. + pub fn clear_playlist_binding(&self) { + *self.playlist_binding.lock().expect("Playlist binding mutex poisoned") = None; + } + + /// Clears the renderer's queue using the generic QueueBackend trait. + pub fn clear_queue(&self) -> Result<(), ControlPointError> { + let mut queue = self.queue.lock().expect("Queue mutex poisoned"); + queue.clear_queue() + } + + /// Adds a track to the queue. + /// + /// This is primarily for backends with persistent queues (OpenHome). + /// For backends without this capability, it returns an error. + /// + /// Returns the backend-specific track ID if applicable. + pub fn add_track_to_queue( + &self, + _uri: &str, + _metadata: &str, + _after_id: Option, + _play: bool, + ) -> Result, ControlPointError> { + // This operation requires backend-specific APIs (especially for OpenHome) + // that aren't exposed through the generic QueueBackend trait. + // The generic implementation cannot support this without more context + // (media_server_id, didl_id, protocol_info, etc.). + Err(ControlPointError::QueueError( + "add_track_to_queue requires backend-specific implementation".to_string() + )) + } + + /// Selects and plays a specific track from the queue by ID. + /// + /// Converts the track ID to a position using the generic QueueBackend trait, + /// then plays the track. + pub fn select_queue_track(&self, track_id: u32) -> Result<(), ControlPointError> { + let queue = self.queue.lock().expect("Queue mutex poisoned"); + + // Convert track ID to position using generic QueueBackend trait + let position = queue.id_to_position(track_id)?; + drop(queue); + + // Set the index + let mut queue = self.queue.lock().expect("Queue mutex poisoned"); + queue.set_index(Some(position))?; + + // Get the item to play + let item = queue.get_item(position)? + .ok_or_else(|| ControlPointError::QueueError("Track not found".to_string()))?; + drop(queue); + + // Build metadata XML + let metadata_xml = item + .metadata + .as_ref() + .map(|m| build_didl_lite_metadata(m, &item.uri, &item.protocol_info)) + .unwrap_or_default(); + + // Play the item using TransportControl + let backend = self.backend.lock().expect("Backend mutex poisoned"); + backend.play_uri(&item.uri, &metadata_xml)?; + + Ok(()) + } + + /// Synchronizes the queue state with the backend. + /// + /// For backends with persistent queues (OpenHome), this refreshes the local view. + /// For others, this is essentially a no-op (just reads the current state). + pub fn sync_queue_state(&self) -> Result<(), ControlPointError> { + let queue = self.queue.lock().expect("Queue mutex poisoned"); + // Calling queue_snapshot() triggers a refresh for backends that need it + let _ = queue.queue_snapshot()?; + Ok(()) + } + + /// Plays the current item from the backend queue. + /// + /// This is primarily for backends with persistent queues (OpenHome). + pub fn play_current_from_backend_queue(&self) -> Result<(), ControlPointError> { + let queue = self.queue.lock().expect("Queue mutex poisoned"); + + // Get current track ID using generic QueueBackend trait + let track_id = queue.current_track()? + .ok_or_else(|| ControlPointError::QueueError("No current track".to_string()))?; + + drop(queue); + + // Play it using select_queue_track + self.select_queue_track(track_id) + } + + /// Returns a reference to the queue (read-only access via lock). + /// This is a convenience method to avoid repetitive `queue.lock().unwrap()` patterns. + pub fn get_queue(&self) -> std::sync::MutexGuard<'_, MusicQueue> { + self.queue.lock().unwrap() + } + + /// Returns a mutable reference to the queue (write access via lock). + /// This is a convenience method to avoid repetitive `queue.lock().unwrap()` patterns. + pub fn get_queue_mut(&self) -> std::sync::MutexGuard<'_, MusicQueue> { + self.queue.lock().unwrap() + } +} + +/// Helper function to build DIDL-Lite metadata XML from TrackMetadata +fn build_didl_lite_metadata(metadata: &TrackMetadata, uri: &str, protocol_info: &str) -> String { + format!( + r#" + +{} +{} +{} +{} +{} +{} + +"#, + metadata.title.as_deref().unwrap_or("Unknown Title"), + metadata + .creator + .as_deref() + .or(metadata.artist.as_deref()) + .unwrap_or("Unknown Artist"), + metadata.artist.as_deref().unwrap_or("Unknown Artist"), + metadata.album.as_deref().unwrap_or("Unknown Album"), + metadata + .album_art_uri + .as_ref() + .map(|art_uri| format!("{}", art_uri)) + .unwrap_or_default(), + protocol_info, + uri + ) +} + +impl DeviceIdentity for MusicRenderer { + fn id(&self) -> DeviceId { + self.info.id() + } + + fn udn(&self) -> &str { + &*self.info.udn() + } + + fn friendly_name(&self) -> &str { + &self.info.friendly_name() + } + + fn model_name(&self) -> &str { + &self.info.model_name() + } + fn manufacturer(&self) -> &str { + &self.info.manufacturer() + } + fn location(&self) -> &str { + &self.info.location() + } + fn server_header(&self) -> &str { + &self.info.server_header() + } +} + +impl DeviceOnline for MusicRenderer { + fn is_online(&self) -> bool { + self.connection + .lock() + .expect("Connection mutex poisoned") + .is_online() + } + + fn last_seen(&self) -> SystemTime { + self.connection + .lock() + .expect("Connection mutex poisoned") + .last_seen() + } + + fn has_been_seen_now(&self, max_age: u32) { + self.connection + .lock() + .expect("Connection mutex poisoned") + .has_been_seen_now(max_age) + } + + fn mark_as_offline(&self) { + self.connection + .lock() + .expect("Connection mutex poisoned") + .mark_as_offline() + } + + fn max_age(&self) -> u32 { + self.connection + .lock() + .expect("Connection mutex poisoned") + .max_age() + } +} + +impl RendererFromMediaRendererInfo for MusicRendererBackend { + fn from_renderer_info(info: &RendererInfo) -> Result { + // Try OpenHome first for OpenHome-capable renderers + match info.protocol() { + RendererProtocol::OpenHomeOnly | RendererProtocol::OpenHomeHybrid => { + if let Ok(backend) = OpenHomeRenderer::build_from_renderer_info(info) { + return Ok(backend); + } + // If OpenHomeOnly failed, it's an error + if matches!(info.protocol(), RendererProtocol::OpenHomeOnly) { + return Err(ControlPointError::MusicRendererBackendBuild(format!( + "OpenHomeOnly renderer {} has no usable OpenHome services", + info.friendly_name() + ))); + } + // OpenHomeHybrid: fall through to try UPnP-based backends + } + RendererProtocol::ChromecastOnly => { + return ChromecastRenderer::build_from_renderer_info(info); + } + RendererProtocol::UpnpAvOnly => { + // Will be handled below + } + } + + // UPnP-based renderers (UpnpAvOnly or OpenHomeHybrid fallback) + let has_arylic = info.capabilities().has_arylic_tcp; + let has_avtransport = info.capabilities().has_avtransport(); + + // Try Hybrid UPnP + Arylic (special case: combines two backends) + if has_arylic && has_avtransport { + let upnp_backend = UpnpRenderer::build_from_renderer_info(info)?; + if let MusicRendererBackend::Upnp(upnp) = upnp_backend { + match ArylicTcpRenderer::build_from_renderer_info(info) { + Ok(MusicRendererBackend::ArylicTcp(arylic)) => { + return Ok(MusicRendererBackend::HybridUpnpArylic { upnp, arylic }); + } + Err(err) => { + warn!( + renderer = info.friendly_name(), + error = %err, + "Failed to build Arylic TCP backend, falling back to UPnP only" + ); + return Ok(MusicRendererBackend::Upnp(upnp)); + } + _ => unreachable!( + "ArylicTcpRenderer::build_from_renderer_info should return ArylicTcp variant" + ), + } + } else { + unreachable!("UpnpRenderer::build_from_renderer_info should return Upnp variant"); + } + } + + // Try LinkPlay + if info.capabilities().has_linkplay_http() { + if let Ok(backend) = LinkPlayRenderer::build_from_renderer_info(info) { + return Ok(backend); + } + } + + // Fallback to UPnP + if has_avtransport { + return UpnpRenderer::build_from_renderer_info(info); + } + + // No suitable backend found + Err(ControlPointError::MusicRendererBackendBuild(format!( + "No suitable backend for renderer {}", + info.friendly_name() + ))) + } + + fn to_backend(self) -> MusicRendererBackend { + self + } +} + +/// Transport control façade that dispatches to whichever backend can fulfill +/// the request, returning a standardized error if the backend lacks support. +impl TransportControl for MusicRendererBackend { + fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { + match self { + MusicRendererBackend::Upnp(upnp) => upnp.play_uri(uri, meta), + MusicRendererBackend::OpenHome(oh) => oh.play_uri(uri, meta), + MusicRendererBackend::LinkPlay(lp) => lp.play_uri(uri, meta), + MusicRendererBackend::ArylicTcp(_) => Err( + ControlPointError::upnp_operation_not_supported("play_uri", "ArylicTcp"), + ), + MusicRendererBackend::Chromecast(cc) => cc.play_uri(uri, meta), + MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta), + } + } + + fn play(&self) -> Result<(), ControlPointError> { + match self { + MusicRendererBackend::Upnp(upnp) => upnp.play(), + MusicRendererBackend::OpenHome(oh) => oh.play(), + MusicRendererBackend::LinkPlay(lp) => lp.play(), + MusicRendererBackend::ArylicTcp(ary) => ary.play(), + MusicRendererBackend::Chromecast(cc) => cc.play(), + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.play(), + } + } + + fn pause(&self) -> Result<(), ControlPointError> { + match self { + MusicRendererBackend::Upnp(upnp) => upnp.pause(), + MusicRendererBackend::OpenHome(oh) => oh.pause(), + MusicRendererBackend::LinkPlay(lp) => lp.pause(), + MusicRendererBackend::ArylicTcp(ary) => ary.pause(), + MusicRendererBackend::Chromecast(cc) => cc.pause(), + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.pause(), + } + } + + fn stop(&self) -> Result<(), ControlPointError> { + match self { + MusicRendererBackend::Upnp(upnp) => upnp.stop(), + MusicRendererBackend::OpenHome(oh) => oh.stop(), + MusicRendererBackend::LinkPlay(lp) => lp.stop(), + MusicRendererBackend::ArylicTcp(ary) => ary.stop(), + MusicRendererBackend::Chromecast(cc) => cc.stop(), + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.stop(), + } + } + + fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { + match self { + MusicRendererBackend::Upnp(upnp) => upnp.seek_rel_time(hhmmss), + MusicRendererBackend::OpenHome(oh) => oh.seek_rel_time(hhmmss), + MusicRendererBackend::LinkPlay(lp) => lp.seek_rel_time(hhmmss), + MusicRendererBackend::ArylicTcp(_) => Err( + ControlPointError::upnp_operation_not_supported("seek_rel_time", "ArylicTcp"), + ), + MusicRendererBackend::Chromecast(cc) => cc.seek_rel_time(hhmmss), + MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.seek_rel_time(hhmmss), + } + } +} + +/// Volume and mute controls exposed via the façade. +/// +/// Hybrid backends may read via Arylic TCP and write via UPnP, but callers +/// always depend on a single [`VolumeControl`] entry point. +impl VolumeControl for MusicRendererBackend { + fn volume(&self) -> Result { + match self { + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.volume(), + MusicRendererBackend::ArylicTcp(ary) => ary.volume(), + MusicRendererBackend::OpenHome(oh) => oh.volume(), + MusicRendererBackend::Upnp(upnp) => upnp.volume(), + MusicRendererBackend::LinkPlay(lp) => lp.volume(), + MusicRendererBackend::Chromecast(cc) => cc.volume(), + } + } + + fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> { + match self { + MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_volume(vol), + MusicRendererBackend::ArylicTcp(ary) => ary.set_volume(vol), + MusicRendererBackend::OpenHome(oh) => oh.set_volume(vol), + MusicRendererBackend::Upnp(upnp) => upnp.set_volume(vol), + MusicRendererBackend::LinkPlay(lp) => lp.set_volume(vol), + MusicRendererBackend::Chromecast(cc) => cc.set_volume(vol), + } + } + + fn mute(&self) -> Result { + match self { + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.mute(), + MusicRendererBackend::OpenHome(r) => r.mute(), + MusicRendererBackend::Upnp(r) => r.mute(), + MusicRendererBackend::LinkPlay(r) => r.mute(), + MusicRendererBackend::ArylicTcp(r) => r.mute(), + MusicRendererBackend::Chromecast(cc) => cc.mute(), + } + } + + fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { + match self { + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.set_mute(m), + MusicRendererBackend::OpenHome(r) => r.set_mute(m), + MusicRendererBackend::Upnp(r) => r.set_mute(m), + MusicRendererBackend::LinkPlay(r) => r.set_mute(m), + MusicRendererBackend::ArylicTcp(r) => r.set_mute(m), + MusicRendererBackend::Chromecast(cc) => cc.set_mute(m), + } + } +} + +/// Playback-state queries sourced from the backend best suited for the job. +/// +/// Each backend reports into [`PlaybackState`], ensuring consumers never have +/// to reason about protocol-specific state machines. +impl PlaybackStatus for MusicRendererBackend { + fn playback_state(&self) -> Result { + match self { + MusicRendererBackend::Upnp(r) => PlaybackStatus::playback_state(r), + MusicRendererBackend::OpenHome(r) => PlaybackStatus::playback_state(r), + MusicRendererBackend::LinkPlay(r) => r.playback_state(), + MusicRendererBackend::ArylicTcp(r) => r.playback_state(), + MusicRendererBackend::Chromecast(cc) => cc.playback_state(), + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_state(), + } + } +} + +/// Playback-position queries that always yield a [`PlaybackPositionInfo`] +/// regardless of the backend providing the raw transport data. +impl PlaybackPosition for MusicRendererBackend { + fn playback_position(&self) -> Result { + match self { + MusicRendererBackend::Upnp(r) => r.playback_position(), + MusicRendererBackend::OpenHome(r) => r.playback_position(), + MusicRendererBackend::LinkPlay(r) => r.playback_position(), + MusicRendererBackend::ArylicTcp(r) => r.playback_position(), + MusicRendererBackend::Chromecast(cc) => cc.playback_position(), + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_position(), + } + } +} diff --git a/pmocontrol/src/openhome.rs b/pmocontrol/src/music_renderer/openhome.rs similarity index 89% rename from pmocontrol/src/openhome.rs rename to pmocontrol/src/music_renderer/openhome.rs index 736574c7..000d0cc2 100644 --- a/pmocontrol/src/openhome.rs +++ b/pmocontrol/src/music_renderer/openhome.rs @@ -1,6 +1,9 @@ -use crate::model::RendererInfo; -use crate::openhome_client::{ - OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, OhTimeClient, OhVolumeClient, +use crate::{ + model::RendererInfo, + upnp_clients::{ + OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, OhTimeClient, + OhVolumeClient, + }, }; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -35,14 +38,8 @@ fn endpoint_for(info: &RendererInfo, kind: OhServiceKind) -> Option ( - info.oh_info_control_url()?, - info.oh_info_service_type()?, - ), - OhServiceKind::Time => ( - info.oh_time_control_url()?, - info.oh_time_service_type()?, - ), + OhServiceKind::Info => (info.oh_info_control_url()?, info.oh_info_service_type()?), + OhServiceKind::Time => (info.oh_time_control_url()?, info.oh_time_service_type()?), OhServiceKind::Volume => ( info.oh_volume_control_url()?, info.oh_volume_service_type()?, @@ -110,16 +107,16 @@ pub fn build_product_client(info: &RendererInfo) -> Option { pub fn build_radio_client(info: &RendererInfo) -> Option { let control_url = info.oh_radio_control_url()?; let service_type = info.oh_radio_service_type()?; - Some(OhRadioClient::new( - control_url, - service_type, - )) + Some(OhRadioClient::new(control_url, service_type)) } #[cfg(test)] mod tests { use super::*; - use crate::{DeviceId, model::{RendererCapabilities, RendererInfo, RendererProtocol}}; + use crate::{ + DeviceId, + model::{RendererCapabilities, RendererInfo, RendererProtocol}, + }; fn sample_renderer_info() -> RendererInfo { RendererInfo::make( diff --git a/pmocontrol/src/openhome_renderer.rs b/pmocontrol/src/music_renderer/openhome_renderer.rs similarity index 82% rename from pmocontrol/src/openhome_renderer.rs rename to pmocontrol/src/music_renderer/openhome_renderer.rs index db6712ba..d9f0a76c 100644 --- a/pmocontrol/src/openhome_renderer.rs +++ b/pmocontrol/src/music_renderer/openhome_renderer.rs @@ -1,23 +1,22 @@ -use std::sync::{Arc, Mutex}; - -use crate::{DeviceIdentity, MusicRendererBackend}; -use crate::capabilities::{ - PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, +use crate::DeviceIdentity; +use crate::music_renderer::capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl, }; +use crate::music_renderer::time_utils::{parse_time_flexible, format_hhmmss_u32}; use crate::errors::ControlPointError; -use crate::model::RendererInfo; -use crate::openhome::{ +use crate::model::{RendererInfo, PlaybackState}; +use crate::music_renderer::RendererFromMediaRendererInfo; +use crate::music_renderer::musicrenderer::MusicRendererBackend; +use crate::music_renderer::openhome::{ build_info_client, build_playlist_client, build_product_client, build_radio_client, build_time_client, build_volume_client, }; -use crate::openhome_client::{ +use crate::upnp_clients::{ OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, - OhTimeClient, OhTrackEntry, OhVolumeClient, parse_track_metadata_from_didl, + OhTimeClient, OhVolumeClient, }; -use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; -use anyhow::{Result, anyhow}; use tracing::debug; #[derive(Clone, Debug)] @@ -50,29 +49,6 @@ impl OpenHomeRenderer { } } - pub fn from_renderer_info( - info: RendererInfo, - ) -> Result>, ControlPointError> { - let renderer = OpenHomeRenderer::new( - build_playlist_client(&info), - build_info_client(&info), - build_time_client(&info), - build_volume_client(&info), - build_product_client(&info), - build_radio_client(&info), - ); - - if renderer.has_any_openhome_service() { - Ok(Arc::new(Mutex::new(MusicRendererBackend::OpenHome( - renderer, - )))) - } else { - Err(ControlPointError::OpenHomeNotAValidDevice(format!( - "{:?}", - info.id() - ))) - } - } pub fn has_playlist(&self) -> bool { self.playlist.is_some() @@ -159,7 +135,7 @@ impl OpenHomeRenderer { /// Retourne la longueur de la playlist OpenHome sans récupérer toutes les métadonnées. /// Plus rapide que snapshot_openhome_playlist() pour juste connaître le nombre de pistes. - pub(crate) fn openhome_playlist_len(&self) -> Result { + pub(crate) fn openhome_playlist_len(&self) -> Result { let playlist = self.playlist_client_for("openhome_playlist_len")?; let ids = playlist.id_array()?; Ok(ids.len()) @@ -196,17 +172,47 @@ impl OpenHomeRenderer { let new_id = playlist.insert(insert_after, uri, metadata)?; if play { - playlist.play_id(new_id)?; + playlist.seek_id(new_id)?; } Ok(new_id) } pub(crate) fn play_openhome_track_id(&self, id: u32) -> Result<(), ControlPointError> { let playlist = self.playlist_client_for("play_openhome_track_id")?; - playlist.play_id(id) + playlist.seek_id(id) } } + +impl RendererFromMediaRendererInfo for OpenHomeRenderer { + fn from_renderer_info( + info: &RendererInfo, + ) -> Result { + let renderer = OpenHomeRenderer::new( + build_playlist_client(&info), + build_info_client(&info), + build_time_client(&info), + build_volume_client(&info), + build_product_client(&info), + build_radio_client(&info), + ); + + if renderer.has_any_openhome_service() { + Ok(renderer) + } else { + Err(ControlPointError::OpenHomeNotAValidDevice(format!( + "{:?}", + info.id() + ))) + } + } + + fn to_backend(self) -> MusicRendererBackend { + MusicRendererBackend::OpenHome(self) + } + +} + impl TransportControl for OpenHomeRenderer { fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { let playlist = self.playlist_client_for("play_uri")?; @@ -241,9 +247,7 @@ impl TransportControl for OpenHomeRenderer { } fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { - let seconds = parse_hms(hhmmss).ok_or_else(|| { - ControlPointError::upnp_bad_return_value("HH:MM:SS", &hhmmss) - })?; + let seconds = parse_time_flexible(hhmmss)?; let playlist = self.playlist_client_for("seek_rel_time")?; playlist.seek_second_absolute(seconds) } @@ -314,29 +318,15 @@ impl PlaybackPosition for OpenHomeRenderer { Ok(PlaybackPositionInfo { track: track_id, - rel_time: Some(format_seconds(time_info.elapsed_secs)), + rel_time: Some(format_hhmmss_u32(time_info.elapsed_secs)), abs_time: None, - track_duration: Some(format_seconds(time_info.duration_secs)), + track_duration: Some(format_hhmmss_u32(time_info.duration_secs)), track_metadata: track_metadata_xml, track_uri, }) } } -fn parse_hms(input: &str) -> Option { - let parts: Vec<&str> = input.split(':').collect(); - if parts.is_empty() || parts.len() > 3 { - return None; - } - - let mut total = 0u32; - for part in parts { - let value = part.parse::().ok()?; - total = total * 60 + value; - } - Some(total) -} - pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState { match raw.trim().to_ascii_uppercase().as_str() { "PLAYING" => PlaybackState::Playing, @@ -347,21 +337,3 @@ pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState { } } -pub(crate) fn format_seconds(seconds: u32) -> String { - let hours = seconds / 3600; - let minutes = (seconds % 3600) / 60; - let secs = seconds % 60; - format!("{hours:02}:{minutes:02}:{secs:02}") -} - -fn convert_oh_track_entry(entry: &OhTrackEntry) -> OpenHomePlaylistTrack { - let metadata = parse_track_metadata_from_didl(&entry.metadata_xml); - OpenHomePlaylistTrack { - id: entry.id, - uri: entry.uri.clone(), - title: metadata.as_ref().and_then(|m| m.title.clone()), - artist: metadata.as_ref().and_then(|m| m.artist.clone()), - album: metadata.as_ref().and_then(|m| m.album.clone()), - album_art_uri: metadata.and_then(|m| m.album_art_uri), - } -} diff --git a/pmocontrol/src/music_renderer/time_utils.rs b/pmocontrol/src/music_renderer/time_utils.rs new file mode 100644 index 00000000..819096f8 --- /dev/null +++ b/pmocontrol/src/music_renderer/time_utils.rs @@ -0,0 +1,218 @@ +//! Time formatting and parsing utilities for music renderers. +//! +//! This module provides utilities to convert between different time representations: +//! - HH:MM:SS format (UPnP standard) +//! - Seconds (u32/u64) +//! - Milliseconds +//! +//! All functions are designed to be robust and provide clear error messages. + +use crate::errors::ControlPointError; + +/// Formats a duration in seconds as HH:MM:SS. +/// +/// # Examples +/// ``` +/// # use pmocontrol::music_renderer::time_utils::format_hhmmss; +/// assert_eq!(format_hhmmss(0), "00:00:00"); +/// assert_eq!(format_hhmmss(61), "00:01:01"); +/// assert_eq!(format_hhmmss(3661), "01:01:01"); +/// ``` +pub fn format_hhmmss(seconds: u64) -> String { + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + let secs = seconds % 60; + format!("{:02}:{:02}:{:02}", hours, minutes, secs) +} + +/// Formats a duration in seconds as HH:MM:SS (u32 variant). +/// +/// Convenience wrapper for u32 values. +pub fn format_hhmmss_u32(seconds: u32) -> String { + format_hhmmss(seconds as u64) +} + +/// Formats a duration in seconds as HH:MM:SS (f64 variant). +/// +/// Used by Chromecast which returns floating point durations. +/// Rounds to nearest second. +pub fn format_hhmmss_f64(seconds: f64) -> String { + format_hhmmss(seconds.round() as u64) +} + +/// Parses a time string in HH:MM:SS, MM:SS, or SS format to seconds. +/// +/// This is the most flexible parser, supporting multiple formats: +/// - "HH:MM:SS" → hours * 3600 + minutes * 60 + seconds +/// - "MM:SS" → minutes * 60 + seconds +/// - "SS" → seconds +/// +/// # Examples +/// ``` +/// # use pmocontrol::music_renderer::time_utils::parse_time_flexible; +/// assert_eq!(parse_time_flexible("01:02:03").unwrap(), 3723); +/// assert_eq!(parse_time_flexible("02:03").unwrap(), 123); +/// assert_eq!(parse_time_flexible("42").unwrap(), 42); +/// ``` +/// +/// # Errors +/// Returns an error if: +/// - The input has more than 3 parts +/// - Any part is not a valid u32 +pub fn parse_time_flexible(input: &str) -> Result { + let parts: Vec<&str> = input.split(':').collect(); + + if parts.is_empty() || parts.len() > 3 { + return Err(ControlPointError::InvalidTimeFormat( + format!("Invalid time format '{}': expected HH:MM:SS, MM:SS, or SS", input) + )); + } + + let mut total = 0u32; + for part in parts { + let value = part.parse::().map_err(|_| { + ControlPointError::InvalidTimeFormat( + format!("Invalid numeric value '{}' in time string '{}'", part, input) + ) + })?; + total = total * 60 + value; + } + + Ok(total) +} + +/// Parses a strict HH:MM:SS format to seconds. +/// +/// This parser requires exactly 3 components separated by colons, +/// and validates that minutes and seconds are < 60. +/// +/// # Examples +/// ``` +/// # use pmocontrol::music_renderer::time_utils::parse_hhmmss_strict; +/// assert_eq!(parse_hhmmss_strict("01:02:03").unwrap(), 3723); +/// assert!(parse_hhmmss_strict("02:03").is_err()); // requires HH:MM:SS +/// assert!(parse_hhmmss_strict("00:61:00").is_err()); // minutes > 59 +/// ``` +/// +/// # Errors +/// Returns an error if: +/// - The format is not exactly HH:MM:SS +/// - Minutes or seconds are >= 60 +/// - Any component is not a valid u64 +pub fn parse_hhmmss_strict(input: &str) -> Result { + let parts: Vec<&str> = input.split(':').collect(); + + if parts.len() != 3 { + return Err(ControlPointError::InvalidTimeFormat( + format!("Invalid time format '{}': expected exactly HH:MM:SS", input) + )); + } + + let hours: u64 = parts[0].parse().map_err(|_| { + ControlPointError::InvalidTimeFormat( + format!("Invalid hour component in '{}'", input) + ) + })?; + + let minutes: u64 = parts[1].parse().map_err(|_| { + ControlPointError::InvalidTimeFormat( + format!("Invalid minute component in '{}'", input) + ) + })?; + + let seconds: u64 = parts[2].parse().map_err(|_| { + ControlPointError::InvalidTimeFormat( + format!("Invalid second component in '{}'", input) + ) + })?; + + if minutes >= 60 || seconds >= 60 { + return Err(ControlPointError::InvalidTimeFormat( + format!("Invalid time '{}': minutes and seconds must be < 60", input) + )); + } + + Ok(hours * 3600 + minutes * 60 + seconds) +} + +/// Converts milliseconds to seconds (rounding down). +#[inline] +pub fn ms_to_seconds(milliseconds: u64) -> u64 { + milliseconds / 1000 +} + +/// Converts seconds to milliseconds. +#[inline] +pub fn seconds_to_ms(seconds: u64) -> u64 { + seconds * 1000 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_hhmmss() { + assert_eq!(format_hhmmss(0), "00:00:00"); + assert_eq!(format_hhmmss(1), "00:00:01"); + assert_eq!(format_hhmmss(60), "00:01:00"); + assert_eq!(format_hhmmss(61), "00:01:01"); + assert_eq!(format_hhmmss(3600), "01:00:00"); + assert_eq!(format_hhmmss(3661), "01:01:01"); + assert_eq!(format_hhmmss(86399), "23:59:59"); + } + + #[test] + fn test_format_hhmmss_f64() { + assert_eq!(format_hhmmss_f64(123.4), "00:02:03"); + assert_eq!(format_hhmmss_f64(123.6), "00:02:04"); + } + + #[test] + fn test_parse_time_flexible() { + // HH:MM:SS format + assert_eq!(parse_time_flexible("01:02:03").unwrap(), 3723); + assert_eq!(parse_time_flexible("00:00:00").unwrap(), 0); + assert_eq!(parse_time_flexible("23:59:59").unwrap(), 86399); + + // MM:SS format + assert_eq!(parse_time_flexible("02:03").unwrap(), 123); + assert_eq!(parse_time_flexible("00:00").unwrap(), 0); + + // SS format + assert_eq!(parse_time_flexible("42").unwrap(), 42); + assert_eq!(parse_time_flexible("0").unwrap(), 0); + + // Errors + assert!(parse_time_flexible("").is_err()); + assert!(parse_time_flexible("1:2:3:4").is_err()); + assert!(parse_time_flexible("abc").is_err()); + assert!(parse_time_flexible("1:abc").is_err()); + } + + #[test] + fn test_parse_hhmmss_strict() { + assert_eq!(parse_hhmmss_strict("01:02:03").unwrap(), 3723); + assert_eq!(parse_hhmmss_strict("00:00:00").unwrap(), 0); + assert_eq!(parse_hhmmss_strict("23:59:59").unwrap(), 86399); + + // Errors - wrong format + assert!(parse_hhmmss_strict("02:03").is_err()); + assert!(parse_hhmmss_strict("42").is_err()); + + // Errors - invalid values + assert!(parse_hhmmss_strict("00:60:00").is_err()); + assert!(parse_hhmmss_strict("00:00:60").is_err()); + assert!(parse_hhmmss_strict("abc:00:00").is_err()); + } + + #[test] + fn test_ms_conversions() { + assert_eq!(ms_to_seconds(1000), 1); + assert_eq!(ms_to_seconds(1500), 1); // rounds down + assert_eq!(ms_to_seconds(999), 0); + + assert_eq!(seconds_to_ms(1), 1000); + assert_eq!(seconds_to_ms(0), 0); + } +} diff --git a/pmocontrol/src/music_renderer/upnp_renderer.rs b/pmocontrol/src/music_renderer/upnp_renderer.rs new file mode 100644 index 00000000..e12901e0 --- /dev/null +++ b/pmocontrol/src/music_renderer/upnp_renderer.rs @@ -0,0 +1,221 @@ +use crate::errors::ControlPointError; +use crate::music_renderer::RendererFromMediaRendererInfo; +use crate::music_renderer::capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, + VolumeControl, +}; +use crate::model::PlaybackState; +use crate::music_renderer::musicrenderer::MusicRendererBackend; +use crate::upnp_clients::{ + AvTransportClient, ConnectionInfo, ConnectionManagerClient, PositionInfo, ProtocolInfo, + RenderingControlClient, +}; +use crate::{DeviceIdentity, RendererInfo}; + +/// High-level handle representing a renderer and its optional AVTransport client. +#[derive(Clone, Debug)] +pub struct UpnpRenderer { + avtransport: Option, + rendering_control: Option, + connection_manager: Option, + has_avtransport_set_next: bool, +} + +impl UpnpRenderer { + /// Retourne true si le renderer à un service de type AVTransport. + pub fn has_avtransport(&self) -> bool { + self.avtransport.is_some() + } + + /// Retourne true si le renderer à un service de type RenderingControl. + pub fn has_rendering_control(&self) -> bool { + self.rendering_control.is_some() + } + + /// Retourne true si le renderer à un service de type ConnectionManager. + pub fn has_connection_manager(&self) -> bool { + self.connection_manager.is_some() + } + + /// Retourne le client de type AVTransport contenant les URL de controle et d'abonnement. + pub fn avtransport(&self) -> Result<&AvTransportClient, ControlPointError> { + self.avtransport.as_ref().ok_or_else(|| { + ControlPointError::upnp_operation_not_supported("AvTransport", "Renderer") + }) + } + + /// Retourne le client de type RenderingControl contenant les URL de controle et d'abonnement. + pub fn rendering_control(&self) -> Result<&RenderingControlClient, ControlPointError> { + self.rendering_control.as_ref().ok_or_else(|| { + ControlPointError::upnp_operation_not_supported("RenderingControl", "Renderer") + }) + } + + /// Retourne le client de type ConnectionManager contenant les URL de controle et d'abonnement. + pub fn connection_manager(&self) -> Result<&ConnectionManagerClient, ControlPointError> { + self.connection_manager.as_ref().ok_or_else(|| { + ControlPointError::upnp_operation_not_supported("ConnectionManager", "Renderer") + }) + } + + pub fn protocol_info(&self) -> Result { + let cm = self.connection_manager()?; + cm.get_protocol_info() + } + + pub fn connection_ids(&self) -> Result, ControlPointError> { + let cm = self.connection_manager()?; + cm.get_current_connection_ids() + } + + pub fn connection_info(&self, connection_id: i32) -> Result { + let cm = self.connection_manager()?; + cm.get_current_connection_info(connection_id) + } + + pub fn set_next_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { + if !self.has_avtransport_set_next { + return Err(ControlPointError::upnp_operation_not_supported( + "SetNextAVTransportURI", + "Renderer", + )); + } + let avt = self.avtransport()?; + avt.set_next_av_transport_uri(uri, meta) + } +} + +impl RendererFromMediaRendererInfo for UpnpRenderer { + fn from_renderer_info(info: &RendererInfo) -> Result { + // Prepare le service AVTTransport + let avtransport = match ( + info.avtransport_control_url(), + info.avtransport_service_type(), + ) { + (Some(url), Some(service)) => Some(AvTransportClient::new(url, service)), + _ => None, + }; + + // Prepare le service RenderingControl + let rendering_control = match ( + info.rendering_control_control_url(), + info.rendering_control_service_type(), + ) { + (Some(url), Some(service)) => Some(RenderingControlClient::new(url, service)), + _ => None, + }; + + // Prepare le service ConnectionManager + let connection_manager = match ( + info.connection_manager_control_url(), + info.connection_manager_service_type(), + ) { + (Some(url), Some(service)) => Some(ConnectionManagerClient::new(url, service)), + _ => None, + }; + + // Exige AVTTransport et RenderingControl au minimum + if avtransport.is_none() || rendering_control.is_none() { + return Err(ControlPointError::UpnpError(format!( + "Some mandatory services are missing on {:?}", + info.id(), + ))); + } + + Ok(Self { + avtransport, + rendering_control, + connection_manager, + has_avtransport_set_next: info.capabilities().has_avtransport_set_next(), + }) + } + + fn to_backend(self) -> MusicRendererBackend { + MusicRendererBackend::Upnp(self) + } +} + +/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`]. +/// +/// Cette impl se base sur AVTransport (InstanceID = 0). +impl TransportControl for UpnpRenderer { + fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { + let avt = self.avtransport()?; + avt.set_av_transport_uri(uri, meta)?; + avt.play(0, "1") + } + + fn play(&self) -> Result<(), ControlPointError> { + let avt = self.avtransport()?; + avt.play(0, "1") + } + + fn pause(&self) -> Result<(), ControlPointError> { + let avt = self.avtransport()?; + avt.pause(0) + } + + fn stop(&self) -> Result<(), ControlPointError> { + let avt = self.avtransport()?; + avt.stop(0) + } + + fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { + let avt = self.avtransport()?; + avt.seek(0, "REL_TIME", hhmmss) + } +} + +/// Implémentation UPnP RenderingControl de `VolumeControl` pour [`UpnpRenderer`]. +/// +/// Cette impl se base sur le channel "Master" (InstanceID = 0). +impl VolumeControl for UpnpRenderer { + fn volume(&self) -> Result { + let rc = self.rendering_control()?; + rc.get_volume(0, "Master") + } + + fn set_volume(&self, v: u16) -> Result<(), ControlPointError> { + let rc = self.rendering_control()?; + rc.set_volume(0, "Master", v) + } + + fn mute(&self) -> Result { + let rc = self.rendering_control()?; + rc.get_mute(0, "Master") + } + + fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { + let rc = self.rendering_control()?; + rc.set_mute(0, "Master", m) + } +} + +/// Implémentation UPnP AV de `PlaybackStatus` pour [`UpnpRenderer`]. +/// +/// Utilise AVTransport::GetTransportInfo(InstanceID=0). +impl PlaybackStatus for UpnpRenderer { + fn playback_state(&self) -> Result { + let avt = self.avtransport()?; + let info = avt.get_transport_info(0)?; + Ok(PlaybackState::from_upnp_state( + &info.current_transport_state, + )) + } +} + +impl PlaybackPosition for UpnpRenderer { + fn playback_position(&self) -> Result { + let avt = self.avtransport()?; + let raw: PositionInfo = avt.get_position_info(0)?; + + Ok(PlaybackPositionInfo { + track: Some(raw.track), + rel_time: raw.rel_time, + abs_time: raw.abs_time, + track_duration: raw.track_duration, + track_metadata: raw.track_metadata, + track_uri: raw.track_uri, + }) + } +} diff --git a/pmocontrol/src/online.rs b/pmocontrol/src/online.rs index b8e92c6b..f22d3ec0 100644 --- a/pmocontrol/src/online.rs +++ b/pmocontrol/src/online.rs @@ -1,12 +1,12 @@ use std::{ sync::{Arc, Mutex}, - time::{Instant, SystemTime}, + time::{SystemTime}, }; #[derive(Clone,Debug)] pub struct DeviceConnectionState { online: bool, - last_seen: Instant, + last_seen: SystemTime, max_age: u32, } @@ -19,13 +19,15 @@ pub trait DeviceOnline { } impl DeviceConnectionState { - pub fn make() -> Arc> { - Arc::new(Mutex::new(DeviceConnectionState { - online: false, - last_seen: std::time::UNIX_EPOCH, - max_age: 1800, - })) + pub fn new() -> Self { + DeviceConnectionState { + online: false, last_seen: std::time::UNIX_EPOCH, max_age: 1800 } } + + pub fn make() -> Arc> { + Arc::new(Mutex::new(DeviceConnectionState::new())) + } + pub fn is_online(&self) -> bool { self.online } @@ -39,7 +41,7 @@ impl DeviceConnectionState { } pub fn has_been_seen_now(&mut self, max_age: u32) { - self.last_seen = Instant::now(); + self.last_seen = SystemTime::now(); self.max_age = max_age; } diff --git a/pmocontrol/src/openhome_playlist.rs b/pmocontrol/src/openhome_playlist.rs deleted file mode 100644 index b9f85f7a..00000000 --- a/pmocontrol/src/openhome_playlist.rs +++ /dev/null @@ -1,31 +0,0 @@ -/// Snapshot de la playlist native OpenHome pour un renderer donné. -#[cfg_attr(feature = "pmoserver", derive(serde::Serialize, utoipa::ToSchema))] -#[derive(Debug, Clone)] -pub struct OpenHomePlaylistSnapshot { - /// ID du renderer concerné. - pub renderer_id: String, - /// ID courant dans la playlist (si connu). - pub current_id: Option, - /// Position courante dans la playlist (si connue). - pub current_index: Option, - /// Tracks présents dans la playlist native. - pub tracks: Vec, -} - -/// Représentation d'un track OpenHome tel qu'exposé par la playlist native. -#[cfg_attr(feature = "pmoserver", derive(serde::Serialize, utoipa::ToSchema))] -#[derive(Debug, Clone)] -pub struct OpenHomePlaylistTrack { - /// ID interne OpenHome du track. - pub id: u32, - /// URI de lecture. - pub uri: String, - /// Titre (optionnel si non fourni par le renderer). - pub title: Option, - /// Artiste (optionnel). - pub artist: Option, - /// Album (optionnel). - pub album: Option, - /// URI de pochette (optionnelle). - pub album_art_uri: Option, -} diff --git a/pmocontrol/src/queue/backend.rs b/pmocontrol/src/queue/backend.rs index 423bc3b5..d70db81e 100644 --- a/pmocontrol/src/queue/backend.rs +++ b/pmocontrol/src/queue/backend.rs @@ -28,103 +28,7 @@ //! - This identity is used by the sync helpers to preserve the current //! track across queue rebuilds when the MediaServer content changes. -use crate::DeviceId; -use crate::errors::ControlPointError; -// ADAPTE ces imports aux modules existants dans pmocontrol. -// Exemple probable : -// use crate::model::MediaServerId; -// use crate::model::TrackMetadata; -use crate::model::TrackMetadata; - -/// Canonical representation of a track in a renderer queue. -/// -/// This type is the bridge between: -/// - the UPnP MediaServer (DIDL-Lite items), -/// - the ControlPoint runtime, -/// - and the different queue backends (internal / OpenHome). -/// -/// It is intentionally DIDL-centric: every item in a queue comes from -/// a UPnP ContentDirectory and carries its MediaServer identity. -#[derive(Clone, Debug)] -pub struct PlaybackItem { - /// Identifier of the UPnP MediaServer that owns this content. - /// - /// Typically this is the UDN of the MediaServer device, or an - /// equivalent logical identifier. - pub media_server_id: DeviceId, - - /// L'ID interne de l'item dans la queue. - /// Cet ID n'a de sens que lors du retour d'un snapshot. - /// Dans une queue interne, il peut avoir n'importe quelle valeur, - /// l'ID qui compte et la position dans le vecteur. - /// Par principe, on la peut la mettre égale à usize::MAX. - pub backend_id: usize, - - /// DIDL-Lite `id` attribute of the `item` in the ContentDirectory. - /// - /// This, combined with `media_server_id`, is the logical identity - /// of the track across refreshes of the MediaServer state. - pub didl_id: String, - - /// Main resource URI to be used for playback. - /// - /// This is usually the first `` element (or a selected one) - /// from the DIDL-Lite item. - pub uri: String, - - /// UPnP protocolInfo string for the resource (e.g., "http-get:*:audio/flac:*"). - /// - /// This string describes the protocol, network, MIME type, and additional - /// info about the media resource. It's required for proper UPnP/OpenHome - /// renderer compatibility. - pub protocol_info: String, - - /// Optional rich metadata for the track (title, artist, album, cover, - /// duration, …). - /// - /// The exact structure is defined in `TrackMetadata` and may - /// aggregate information from DIDL, tags, or additional sources. - pub metadata: Option, -} - -impl PlaybackItem { - /// Returns a stable, backend-agnostic logical identifier for this item. - /// - /// By default this is the concatenation of the MediaServer identifier - /// and the DIDL `id`. Backends and higher-level logic should use this - /// when they need to match items across queue rebuilds. - pub fn unique_id(&self) -> String { - // ADAPTE si MediaServerId n'implémente pas Display : utilise - // un champ string interne ou une méthode as_str(). - format!("{}::{}", self.media_server_id.0, self.didl_id) - } -} - -/// Logical snapshot of a renderer queue. -/// -/// This is the canonical view used by the ControlPoint and the REST/API -/// layer. It is independent of how the queue is actually stored (local -/// in-memory queue, OpenHome playlist, …). -#[derive(Clone, Debug)] -pub struct QueueSnapshot { - /// All items currently in the queue, in play order. - pub items: Vec, - /// Index (0-based) of the current item in `items`, or `None` if - /// no item is currently selected. - pub current_index: Option, -} - -impl QueueSnapshot { - /// Returns the number of items in the snapshot. - pub fn len(&self) -> usize { - self.items.len() - } - - /// Returns `true` if the snapshot contains no items. - pub fn is_empty(&self) -> bool { - self.items.is_empty() - } -} +use crate::{PlaybackItem, QueueSnapshot, errors::ControlPointError}; /// High-level enqueue mode. /// @@ -164,6 +68,24 @@ pub trait QueueBackend { // BACKEND PRIMITIVES (must be implemented) // ===================================================================== + /// Returns the length of this queue. + fn len(&self) -> Result; + + /// Lists all the items in this queue. + fn track_ids(&self) -> Result, ControlPointError>; + + /// Converts a track ID to its position in the queue. + fn id_to_position(&self, id: u32) -> Result; + + /// Converts a track ID to its position in the queue. + fn position_to_id(&self, id: usize) -> Result; + + /// Return the current playing track identifier + fn current_track(&self) -> Result, ControlPointError>; + + /// Returns the current playing track index in the queue + fn current_index(&self) -> Result, ControlPointError>; + /// Returns the full snapshot (items + current index) of this queue. fn queue_snapshot(&self) -> Result; @@ -181,12 +103,28 @@ pub trait QueueBackend { current_index: Option, ) -> Result<(), ControlPointError>; + /// Updates the queue while adjusting so that the new current_index + /// corresponds to the old current index track. + /// If the old current index track is absent from the new queue, + /// it is kept as the first item and the new items are appended after it. + fn sync_queue(&mut self, items: Vec) -> Result<(), ControlPointError>; + /// Returns the item at `index`, if it exists. fn get_item(&self, index: usize) -> Result, ControlPointError>; /// Replaces the item at `index` with `item`. fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError>; + /// Enqueues items according to the selected `EnqueueMode`. + /// + /// This method only manipulates the queue structure; it does not + /// start playback. + fn enqueue_items( + &mut self, + items: Vec, + mode: EnqueueMode, + ) -> Result<(), ControlPointError>; + // ===================================================================== // DEFAULT HELPERS (backend-agnostic logic) // ===================================================================== @@ -196,41 +134,11 @@ pub trait QueueBackend { self.replace_queue(Vec::new(), None) } - /// Alias for `clear_queue`, semantic name for “empty before rebuild”. - fn empty_queue(&mut self) -> Result<(), ControlPointError> { - self.clear_queue() - } - - /// Returns the current index, if any. - fn current_index(&self) -> Result, ControlPointError> { - Ok(self.queue_snapshot()?.current_index) - } - - /// Returns the number of items in the queue. - fn len(&self) -> Result { - Ok(self.queue_snapshot()?.len()) - } - /// Returns `true` if the queue is empty. fn is_empty(&self) -> Result { Ok(self.queue_snapshot()?.is_empty()) } - /// Returns a full snapshot of the queue. - fn full_snapshot(&self) -> Result { - self.queue_snapshot() - } - - /// Returns an iterator over all items in the queue. - /// - /// The default implementation: - /// - takes a snapshot, - /// - returns a boxed iterator owning the underlying `Vec`. - fn iter_items(&self) -> Result>, ControlPointError> { - let snapshot = self.queue_snapshot()?; - Ok(Box::new(snapshot.items.into_iter())) - } - /// Returns the list of items that come strictly after the current index. fn upcoming_items(&self) -> Result, ControlPointError> { let snapshot = self.queue_snapshot()?; @@ -243,7 +151,12 @@ pub trait QueueBackend { /// Returns how many items remain in the queue after the current index. fn upcoming_len(&self) -> Result { - Ok(self.upcoming_items()?.len()) + let snapshot = self.queue_snapshot()?; + let len = snapshot.items.len(); + match snapshot.current_index { + None => Ok(len), + Some(idx) => Ok(len.saturating_sub(idx + 1)), + } } /// Returns the current item (or the first pending item if no index is set) @@ -312,36 +225,6 @@ pub trait QueueBackend { Ok(Some((item, remaining))) } - /// Enqueues items according to the selected `EnqueueMode`. - /// - /// This method only manipulates the queue structure; it does not - /// start playback. - fn enqueue_items(&mut self, items: Vec, mode: EnqueueMode) -> Result<(), ControlPointError> { - let mut snapshot = self.queue_snapshot()?; - - match mode { - EnqueueMode::AppendToEnd => { - snapshot.items.extend(items); - } - EnqueueMode::InsertAfterCurrent => { - let insert_pos = snapshot - .current_index - .map(|i| (i + 1).min(snapshot.items.len())) - .unwrap_or(0); - - for (offset, it) in items.into_iter().enumerate() { - snapshot.items.insert(insert_pos + offset, it); - } - } - EnqueueMode::ReplaceAll => { - snapshot.items = items; - snapshot.current_index = None; - } - } - - self.replace_queue(snapshot.items, snapshot.current_index) - } - /// Replaces the queue with `items` and sets a default index. fn replace_all(&mut self, items: Vec) -> Result<(), ControlPointError> { if items.is_empty() { @@ -410,7 +293,7 @@ pub trait QueueBackend { } } - /// Convenience helper to update an item “in place” at the given index. + /// Convenience helper to update an item "in place" at the given index. fn update_item( &mut self, index: usize, @@ -420,34 +303,10 @@ pub trait QueueBackend { let new_item = update(item); self.replace_item(index, new_item) } else { - Err(ControlPointError::QueueError(format!("Queue index {} out of range", index))) - } - } - - /// Synchronizes the queue with a new list of items coming from an - /// external MediaServer, trying to preserve the current track. - fn sync_from_external_preserve_current(&mut self, new_items: Vec) -> Result<(), ControlPointError> { - let snapshot = self.queue_snapshot()?; - let current = snapshot - .current_index - .and_then(|i| snapshot.items.get(i).cloned()); - - let Some(current) = current else { - return self.replace_all(new_items); - }; - - let current_uid = current.unique_id(); - - if let Some(new_idx) = new_items - .iter() - .position(|it| it.unique_id() == current_uid) - { - self.replace_queue(new_items, Some(new_idx)) - } else { - let mut items = Vec::with_capacity(new_items.len() + 1); - items.push(current); - items.extend(new_items); - self.replace_queue(items, Some(0)) + Err(ControlPointError::QueueError(format!( + "Queue index {} out of range", + index + ))) } } } diff --git a/pmocontrol/src/queue/interne.rs b/pmocontrol/src/queue/interne.rs index bbd3262a..7b61894c 100644 --- a/pmocontrol/src/queue/interne.rs +++ b/pmocontrol/src/queue/interne.rs @@ -15,18 +15,11 @@ //! - maintains a `current_index`, //! - never starts playback (transport control is handled elsewhere). -use std::sync::{Arc, Mutex}; - -use anyhow::Result; use crate::{ - DeviceId, RendererInfo, - DeviceIdentity, + DeviceId, DeviceIdentity, RendererInfo, errors::ControlPointError, - queue::{ - MusicQueue, - backend::{PlaybackItem, QueueBackend, QueueSnapshot}, - }, + queue::{MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot}, }; /// Internal/local queue implementation. @@ -54,27 +47,59 @@ impl InternalQueue { } } - pub fn from_renderer_info( - info: &RendererInfo, - ) -> Result { - Ok(InternalQueue::new( - info.id(), - ), - ) + pub fn from_renderer_info(info: &RendererInfo) -> Result { + Ok(InternalQueue::new(info.id())) } /// Exposes a read-only view of the underlying items. pub fn items(&self) -> &[PlaybackItem] { &self.items } - - /// Exposes the current index (read-only). - pub fn current_index(&self) -> Option { - self.current_index - } } impl QueueBackend for InternalQueue { + fn len(&self) -> Result { + Ok(self.items.len()) + } + + fn track_ids(&self) -> Result, ControlPointError> { + let ids: Vec = (0..self.len()?).map(|i| i as u32).collect(); + Ok(ids) + } + + fn id_to_position(&self, id: u32) -> Result { + Ok(id as usize) + } + + fn position_to_id(&self, id: usize) -> Result { + u32::try_from(id).map_err(|_| { + ControlPointError::QueueError(format!( + "Position {} exceeds u32::MAX", + id + )) + }) + } + + fn current_track(&self) -> Result, ControlPointError> { + match self.current_index { + None => Ok(None), + Some(i) => { + u32::try_from(i) + .map(Some) + .map_err(|_| { + ControlPointError::QueueError(format!( + "Current index {} exceeds u32::MAX", + i + )) + }) + } + } + } + + fn current_index(&self) -> Result, ControlPointError> { + Ok(self.current_index) + } + fn queue_snapshot(&self) -> Result { let mut items = self.items.clone(); for (i, item) in items.iter_mut().enumerate() { @@ -96,7 +121,11 @@ impl QueueBackend for InternalQueue { if i < self.items.len() { self.current_index = Some(i); } else { - self.current_index = None; + return Err(ControlPointError::QueueError(format!( + "Index out of bound {} >= {}", + i, + self.items.len() + ))); } } } @@ -113,14 +142,187 @@ impl QueueBackend for InternalQueue { Ok(()) } + fn sync_queue( + &mut self, + items: Vec + ) -> Result<(), ControlPointError> { + if items.is_empty() { + return self.replace_queue(Vec::new(), None); + } + + // Récupérer l'item actuel + let current = self.current_index + .and_then(|idx| self.items.get(idx).map(|item| (idx, item.uri.clone()))); + + if let Some((_current_idx, current_uri)) = current { + // Chercher l'item actuel dans la nouvelle liste (par URI) + let new_idx = items.iter().position(|item| item.uri == current_uri); + + if let Some(new_idx) = new_idx { + // Item trouvé dans la nouvelle liste + self.replace_queue(items, Some(new_idx)) + } else { + // Item pas trouvé, le garder comme premier + let current_item = self.items[self.current_index.unwrap()].clone(); + let mut new_items = Vec::with_capacity(items.len() + 1); + new_items.push(current_item); + new_items.extend(items); + self.replace_queue(new_items, Some(0)) + } + } else { + // Pas d'item actuel + self.replace_queue(items, None) + } + } + + fn enqueue_items( + &mut self, + items: Vec, + mode: crate::queue::EnqueueMode, + ) -> Result<(), ControlPointError> { + use crate::queue::EnqueueMode; + + match mode { + EnqueueMode::AppendToEnd => { + self.items.extend(items); + } + EnqueueMode::InsertAfterCurrent => { + let insert_pos = self.current_index + .map(|i| (i + 1).min(self.items.len())) + .unwrap_or(0); + + for (offset, item) in items.into_iter().enumerate() { + self.items.insert(insert_pos + offset, item); + } + } + EnqueueMode::ReplaceAll => { + self.items = items; + self.current_index = None; + } + } + Ok(()) + } + fn get_item(&self, index: usize) -> Result, ControlPointError> { - Ok(self.items.get(index).cloned()) + if index < self.items.len() { + Ok(self.items.get(index).cloned()) + } else { + Err(ControlPointError::QueueError(format!( + "get_item index out of bound {} >= {}", + index, + self.items.len() + ))) + } + } + + // Optimized helpers for InternalQueue + fn clear_queue(&mut self) -> Result<(), ControlPointError> { + self.items.clear(); + self.current_index = None; + Ok(()) + } + + fn is_empty(&self) -> Result { + Ok(self.items.is_empty()) + } + + fn upcoming_len(&self) -> Result { + let len = self.items.len(); + match self.current_index { + None => Ok(len), + Some(idx) => Ok(len.saturating_sub(idx + 1)), + } + } + + fn upcoming_items(&self) -> Result, ControlPointError> { + let items = match self.current_index { + None => self.items.clone(), + Some(idx) => self.items.iter().skip(idx + 1).cloned().collect(), + }; + Ok(items) + } + + fn peek_current(&self) -> Result, ControlPointError> { + if self.items.is_empty() { + return Ok(None); + } + + let len = self.items.len(); + let (item, resolved_index) = match self.current_index { + Some(idx) if idx < len => (self.items.get(idx).cloned(), Some(idx)), + _ => (self.items.first().cloned(), None), + }; + + let item = match item { + Some(item) => item, + None => return Ok(None), + }; + + let remaining = match resolved_index { + Some(idx) => len.saturating_sub(idx + 1), + None => len, + }; + + Ok(Some((item, remaining))) + } + + fn dequeue_next(&mut self) -> Result, ControlPointError> { + if self.items.is_empty() { + return Ok(None); + } + + let len = self.items.len(); + let next_index = match self.current_index { + None => 0, + Some(idx) => { + let candidate = idx + 1; + if candidate >= len { + return Ok(None); + } + candidate + } + }; + + let Some(item) = self.items.get(next_index).cloned() else { + return Ok(None); + }; + + let remaining = len.saturating_sub(next_index + 1); + self.current_index = Some(next_index); + Ok(Some((item, remaining))) + } + + fn append_or_init_index(&mut self, items: Vec) -> Result<(), ControlPointError> { + let was_empty = self.items.is_empty(); + self.items.extend(items); + + if was_empty && !self.items.is_empty() { + self.current_index = Some(0); + } + + Ok(()) } fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> { if index < self.items.len() { self.items[index] = item; + Ok(()) + } else { + Err(ControlPointError::QueueError(format!( + "Index out of bound {} >= {}", + index, + self.items.len() + ))) } - Ok(()) + } +} + +impl QueueFromRendererInfo for InternalQueue { + fn from_renderer_info(renderer: &RendererInfo) -> Result { + InternalQueue::from_renderer_info(renderer) + } + + fn to_backend(self) -> MusicQueue { + MusicQueue::Internal(self) } } diff --git a/pmocontrol/src/queue/mod.rs b/pmocontrol/src/queue/mod.rs index af5f3d86..55f934e6 100644 --- a/pmocontrol/src/queue/mod.rs +++ b/pmocontrol/src/queue/mod.rs @@ -1,7 +1,45 @@ mod music_queue; -mod openhome; mod backend; +mod snapshot; +mod openhome; mod interne; -pub use music_queue::MusicQueue; -pub use backend::{PlaybackItem, QueueBackend, QueueSnapshot, EnqueueMode}; +use std::sync::{Arc, Mutex}; + +pub use music_queue::MusicQueue; +pub use backend::{QueueBackend, EnqueueMode}; +pub use snapshot::{PlaybackItem, QueueSnapshot}; + +// Internal queue implementations - not part of the public API +pub(crate) use openhome::OpenHomeQueue; +pub(crate) use interne::InternalQueue; + +use crate::{RendererInfo, errors::ControlPointError}; + +pub trait QueueFromRendererInfo { + fn from_renderer_info(renderer: &RendererInfo) -> Result + where + Self: Sized; + + fn to_backend(self) -> MusicQueue; + + fn build_from_renderer_info( + renderer: &RendererInfo, + ) -> Result + where + Self: Sized, + { + let instance = Self::from_renderer_info(renderer)?; + Ok(instance.to_backend()) + } + + fn make_from_renderer_info( + renderer: &RendererInfo, + ) -> Result>, ControlPointError> + where + Self: Sized, + { + let backend = Self::build_from_renderer_info(renderer)?; + Ok(Arc::new(Mutex::new(backend))) + } +} \ No newline at end of file diff --git a/pmocontrol/src/queue/music_queue.rs b/pmocontrol/src/queue/music_queue.rs index be7c8be9..c7293319 100644 --- a/pmocontrol/src/queue/music_queue.rs +++ b/pmocontrol/src/queue/music_queue.rs @@ -1,9 +1,8 @@ -use crate::RendererInfo; -use crate::control_point::openhome_queue::OpenHomeQueue; use crate::errors::ControlPointError; -use crate::openhome_playlist::OpenHomePlaylistSnapshot; -use crate::queue::backend::{PlaybackItem, QueueBackend, QueueSnapshot}; -use crate::queue::interne::InternalQueue; +use crate::queue::{ + EnqueueMode, InternalQueue, OpenHomeQueue, QueueBackend, QueueFromRendererInfo, +}; +use crate::{PlaybackItem, QueueSnapshot, RendererInfo}; #[derive(Debug, Clone)] pub enum MusicQueue { @@ -12,39 +11,65 @@ pub enum MusicQueue { } impl MusicQueue { - + /// Creates a queue appropriate for the given renderer. + /// This is the factory method used by QueueFromRendererInfo trait. pub fn from_renderer_info(info: &RendererInfo) -> Result { if info.capabilities().has_oh_playlist() { - - Ok(MusicQueue::OpenHome(OpenHomeQueue::from_renderer_info(info)?)) + Ok(MusicQueue::OpenHome(OpenHomeQueue::from_renderer_info( + info, + )?)) } else { - Ok(MusicQueue::Internal(InternalQueue::from_renderer_info(info)?)) - } - } - - pub fn openhome_playlist_snapshot(&self) -> Result { - match self { - MusicQueue::OpenHome(queue) => queue.openhome_playlist_snapshot(), - _ => Err(ControlPointError::QueueError(format!( - "OpenHome playlist snapshot is only available for OpenHome queues" - ))), - } - } - - pub fn replace_with_attached_playlist( - &mut self, - items: Vec, - current_index: Option, - ) -> Result<(), ControlPointError> { - match self { - MusicQueue::OpenHome(queue) => queue.replace_entire_playlist(items, current_index), - MusicQueue::Internal(queue) => queue.replace_queue(items, current_index), + Ok(MusicQueue::Internal(InternalQueue::from_renderer_info( + info, + )?)) } } } - impl QueueBackend for MusicQueue { + // Primitives + fn len(&self) -> Result { + match self { + MusicQueue::Internal(q) => q.len(), + MusicQueue::OpenHome(q) => q.len(), + } + } + + fn track_ids(&self) -> Result, ControlPointError> { + match self { + MusicQueue::Internal(q) => q.track_ids(), + MusicQueue::OpenHome(q) => q.track_ids(), + } + } + + fn id_to_position(&self, id: u32) -> Result { + match self { + MusicQueue::Internal(q) => q.id_to_position(id), + MusicQueue::OpenHome(q) => q.id_to_position(id), + } + } + + fn position_to_id(&self, id: usize) -> Result { + match self { + MusicQueue::Internal(q) => q.position_to_id(id), + MusicQueue::OpenHome(q) => q.position_to_id(id), + } + } + + fn current_track(&self) -> Result, ControlPointError> { + match self { + MusicQueue::Internal(q) => q.current_track(), + MusicQueue::OpenHome(q) => q.current_track(), + } + } + + fn current_index(&self) -> Result, ControlPointError> { + match self { + MusicQueue::Internal(q) => q.current_index(), + MusicQueue::OpenHome(q) => q.current_index(), + } + } + fn queue_snapshot(&self) -> Result { match self { MusicQueue::Internal(q) => q.queue_snapshot(), @@ -70,6 +95,13 @@ impl QueueBackend for MusicQueue { } } + fn sync_queue(&mut self, items: Vec) -> Result<(), ControlPointError> { + match self { + MusicQueue::Internal(q) => q.sync_queue(items), + MusicQueue::OpenHome(q) => q.sync_queue(items), + } + } + fn get_item(&self, index: usize) -> Result, ControlPointError> { match self { MusicQueue::Internal(q) => q.get_item(index), @@ -83,4 +115,75 @@ impl QueueBackend for MusicQueue { MusicQueue::OpenHome(q) => q.replace_item(index, item), } } + + fn enqueue_items( + &mut self, + items: Vec, + mode: EnqueueMode, + ) -> Result<(), ControlPointError> { + match self { + MusicQueue::Internal(q) => q.enqueue_items(items, mode), + MusicQueue::OpenHome(q) => q.enqueue_items(items, mode), + } + } + + // Optimized helpers + fn clear_queue(&mut self) -> Result<(), ControlPointError> { + match self { + MusicQueue::Internal(q) => q.clear_queue(), + MusicQueue::OpenHome(q) => q.clear_queue(), + } + } + + fn is_empty(&self) -> Result { + match self { + MusicQueue::Internal(q) => q.is_empty(), + MusicQueue::OpenHome(q) => q.is_empty(), + } + } + + fn upcoming_len(&self) -> Result { + match self { + MusicQueue::Internal(q) => q.upcoming_len(), + MusicQueue::OpenHome(q) => q.upcoming_len(), + } + } + + fn upcoming_items(&self) -> Result, ControlPointError> { + match self { + MusicQueue::Internal(q) => q.upcoming_items(), + MusicQueue::OpenHome(q) => q.upcoming_items(), + } + } + + fn peek_current(&self) -> Result, ControlPointError> { + match self { + MusicQueue::Internal(q) => q.peek_current(), + MusicQueue::OpenHome(q) => q.peek_current(), + } + } + + fn dequeue_next(&mut self) -> Result, ControlPointError> { + match self { + MusicQueue::Internal(q) => q.dequeue_next(), + MusicQueue::OpenHome(q) => q.dequeue_next(), + } + } + + fn append_or_init_index(&mut self, items: Vec) -> Result<(), ControlPointError> { + match self { + MusicQueue::Internal(q) => q.append_or_init_index(items), + MusicQueue::OpenHome(q) => q.append_or_init_index(items), + } + } +} + +impl QueueFromRendererInfo for MusicQueue { + fn from_renderer_info(renderer: &RendererInfo) -> Result { + MusicQueue::from_renderer_info(renderer) + } + + fn to_backend(self) -> MusicQueue { + self + } } diff --git a/pmocontrol/src/queue/openhome.rs b/pmocontrol/src/queue/openhome.rs index e8f39672..11abe994 100644 --- a/pmocontrol/src/queue/openhome.rs +++ b/pmocontrol/src/queue/openhome.rs @@ -1,14 +1,16 @@ -use pmodidl::DIDLLite; +use std::usize; + use quick_xml::escape::escape; use tracing::debug; use crate::errors::ControlPointError; -use crate::openhome_client::{ - OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrackEntry, - parse_track_metadata_from_didl, +use crate::upnp_clients::{ + OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrack, OhTrackEntry +}; +// use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; +use crate::queue::{ + EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot, }; -use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; -use crate::queue::backend::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot}; use crate::{DeviceId, DeviceIdentity, RendererInfo}; /// Local mirror of an OpenHome playlist for a single renderer. @@ -18,9 +20,7 @@ pub struct OpenHomeQueue { playlist_client: OhPlaylistClient, info_client: Option, product_client: Option, - items: Vec, - current_index: Option, - track_ids: Vec, + // current_index: Option, } impl OpenHomeQueue { @@ -35,15 +35,10 @@ impl OpenHomeQueue { playlist_client: playlist, info_client, product_client, - items: Vec::new(), - current_index: None, - track_ids: Vec::new(), } } - pub fn from_renderer_info( - info: &RendererInfo, - ) -> Result { + pub fn from_renderer_info(info: &RendererInfo) -> Result { let playlist_client = OhPlaylistClient::from_renderer_info(info)?; let info_client = OhInfoClient::from_renderer_info(&info).ok(); let product_client = OhProductClient::from_renderer_info(&info).ok(); @@ -56,208 +51,6 @@ impl OpenHomeQueue { )) } - /// Reload the full OpenHome playlist snapshot into local playback items. - /// - /// This mirrors the logic previously implemented by - /// `OpenHomeRenderer::snapshot_openhome_playlist` but converts entries - /// directly into `PlaybackItem`s. - pub fn refresh_from_openhome(&mut self) -> Result<(), ControlPointError> { - self.ensure_playlist_source_selected()?; - let entries = self.playlist_client.read_all_tracks()?; - let mut items = Vec::with_capacity(entries.len()); - let mut track_ids = Vec::with_capacity(entries.len()); - - for entry in &entries { - items.push(self.playback_item_from_entry(entry)); - track_ids.push(entry.id); - } - - // Get the currently playing track ID from the renderer (may be None if no track is playing) - let current_id = self.playlist_client.id().ok(); - - // Find the index of the current track in the playlist - let current_index = - current_id.and_then(|id| track_ids.iter().position(|entry_id| *entry_id == id)); - - self.items = items; - self.track_ids = track_ids; - self.current_index = current_index; - Ok(()) - } - - pub fn openhome_playlist_snapshot(&self) -> Result { - let tracks = self - .items - .iter() - .zip(self.track_ids.iter()) - .map(|(item, track_id)| OpenHomePlaylistTrack { - id: *track_id, - uri: item.uri.clone(), - title: item.metadata.as_ref().and_then(|m| m.title.clone()), - artist: item.metadata.as_ref().and_then(|m| m.artist.clone()), - album: item.metadata.as_ref().and_then(|m| m.album.clone()), - album_art_uri: item.metadata.as_ref().and_then(|m| m.album_art_uri.clone()), - }) - .collect(); - - Ok(OpenHomePlaylistSnapshot { - renderer_id: self.renderer_id.0.clone(), - current_id: self - .current_index - .and_then(|idx| self.track_ids.get(idx).copied()), - current_index: self.current_index, - tracks, - }) - } - - pub fn len(&self) -> usize { - self.items.len() - } - - /// Return the list of OpenHome track IDs in order. - pub fn openhome_track_ids(&self) -> Vec { - self.track_ids.clone() - } - - pub fn select_track_id(&mut self, id: u32) -> Result<(), ControlPointError> { - let index = match self.track_ids.iter().position(|&tid| tid == id) { - Some(pos) => pos, - None => { - self.refresh_from_openhome()?; - self.track_ids - .iter() - .position(|&tid| tid == id) - .ok_or_else(|| ControlPointError::QueueError(format!("Unknown OpenHome track id {}", id)))? - } - }; - - self.ensure_playlist_source_selected()?; - self.playlist_client.play_id(id)?; - self.current_index = Some(index); - Ok(()) - } - - /// Selects and plays a track by its queue index (0-based). - pub fn select_track_index(&mut self, index: usize) -> Result<(), ControlPointError> { - let track_id = self.track_ids.get(index).copied().ok_or_else(|| { - ControlPointError::QueueError(format!( - "Index {} out of bounds (queue length: {})", - index, - self.track_ids.len() - )) - })?; - - self.ensure_playlist_source_selected()?; - self.playlist_client.play_id(track_id)?; - self.current_index = Some(index); - Ok(()) - } - - pub fn clear(&mut self) -> Result<(), ControlPointError> { - self.ensure_playlist_source_selected()?; - self.playlist_client.delete_all()?; - self.items.clear(); - self.track_ids.clear(); - self.current_index = None; - Ok(()) - } - - /// Replace the remote OpenHome playlist entirely with `items`. - /// - /// This is used when attaching a media server playlist: we want to drop any - /// stale entries (even if they were inserted by another control point) and - /// rebuild the renderer playlist from scratch. - pub fn replace_entire_playlist( - &mut self, - items: Vec, - current_index: Option, - ) -> Result<(), ControlPointError> { - self.ensure_playlist_source_selected()?; - self.playlist_client.delete_all()?; - self.items.clear(); - self.track_ids.clear(); - self.current_index = None; - - if items.is_empty() { - return Ok(()); - } - - let mut rebuilt_items = Vec::with_capacity(items.len()); - let mut rebuilt_ids = Vec::with_capacity(items.len()); - let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID; - - for item in items { - let metadata = build_metadata_xml(&item); - let new_id = self - .playlist_client - .insert(previous_id, &item.uri, &metadata)?; - previous_id = new_id; - rebuilt_ids.push(new_id); - rebuilt_items.push(self.item_with_openhome_id(item, new_id)); - } - - let normalized = current_index - .filter(|&idx| idx < rebuilt_ids.len()) - .or_else(|| Some(0)); - - self.items = rebuilt_items; - self.track_ids = rebuilt_ids; - self.current_index = normalized; - Ok(()) - } - - pub fn add_playback_item( - &mut self, - item: PlaybackItem, - after_id: Option, - play: bool, - ) -> Result { - self.ensure_playlist_source_selected()?; - let metadata_xml = build_metadata_xml(&item); - let insert_after = match after_id { - Some(id) => id, - None => self.track_ids.last().copied().unwrap_or(0), - }; - - let new_id = self - .playlist_client - .insert(insert_after, &item.uri, &metadata_xml)?; - - if play { - self.playlist_client.play_id(new_id)?; - } - - let mut insert_index = after_id - .and_then(|id| { - if id == 0 { - Some(0) - } else { - self.track_ids - .iter() - .position(|tid| *tid == id) - .map(|pos| pos + 1) - } - }) - .unwrap_or_else(|| self.track_ids.len()); - - if insert_index > self.track_ids.len() { - insert_index = self.track_ids.len(); - } - - self.track_ids.insert(insert_index, new_id); - let stored_item = self.item_with_openhome_id(item, new_id); - self.items.insert(insert_index, stored_item); - - self.current_index = if play { - Some(insert_index) - } else { - self.current_index - .map(|idx| if insert_index <= idx { idx + 1 } else { idx }) - }; - - Ok(new_id) - } - fn ensure_playlist_source_selected(&self) -> Result<(), ControlPointError> { if let Some(product) = &self.product_client { product.ensure_playlist_source_selected() @@ -267,14 +60,15 @@ impl OpenHomeQueue { } fn playback_item_from_entry(&self, entry: &OhTrackEntry) -> PlaybackItem { - let metadata = parse_track_metadata_from_didl(&entry.metadata_xml); - let didl_id = didl_id_from_metadata(&entry.metadata_xml) + let metadata = entry.metadata(); + let didl_id = entry + .didl_id() .unwrap_or_else(|| format!("openhome:{}", entry.id)); PlaybackItem { media_server_id: DeviceId(format!("openhome:{}", self.renderer_id.0)), backend_id: entry.id as usize, didl_id, - uri: entry.uri.clone(), + uri: entry.uri().to_string(), // OpenHome tracks don't provide protocolInfo, use generic default protocol_info: "http-get:*:audio/*:*".to_string(), metadata, @@ -287,20 +81,17 @@ impl OpenHomeQueue { item } - fn ensure_track_id(&mut self, index: usize) -> Result { - if index >= self.items.len() { - return Err(ControlPointError::OpenHomeError(format!("Index out of bounds in OpenHomeQueue: {}", index))); - } - - if let Some(id) = self.track_ids.get(index).copied() { - return Ok(id); - } - - self.refresh_from_openhome()?; - self.track_ids - .get(index) - .copied() - .ok_or_else(|| ControlPointError::OpenHomeError(format!("Failed to resolve OpenHome track id at index {}", index))) + fn add_playback_item( + &mut self, + item: PlaybackItem, + after_id: u32, + ) -> Result { + self.ensure_playlist_source_selected()?; + let metadata_xml = build_metadata_xml(&item); + let new_id = self + .playlist_client + .insert(after_id, &item.uri, &metadata_xml)?; + Ok(new_id) } /// CASE 1: Replace queue while preserving the currently playing item as first. @@ -309,40 +100,30 @@ impl OpenHomeQueue { fn replace_queue_preserve_current( &mut self, new_items: Vec, - playing_idx: usize, - playing_id: u32, - ) -> Result<(), ControlPointError> { + playing_id: usize, + ) -> Result<(), ControlPointError> { + // Get current track IDs from OpenHome + let current_track_ids = self.track_ids()?; + // Delete everything except the currently playing item // Using delete_id_if_exists() to handle cases where another control point // may have already modified the playlist - for &track_id in self.track_ids.iter().rev() { - if track_id != playing_id { + for &track_id in current_track_ids.iter().rev() { + if track_id as usize != playing_id { self.playlist_client.delete_id_if_exists(track_id)?; } } - // Rebuild: [currently_playing, new_items...] - let mut rebuilt_items = Vec::with_capacity(1 + new_items.len()); - let mut rebuilt_ids = Vec::with_capacity(1 + new_items.len()); - - rebuilt_items.push(self.items[playing_idx].clone()); - rebuilt_ids.push(playing_id); - - let mut previous_id = playing_id; + // Insert new items after the currently playing track + let mut previous_id = playing_id as u32; for item in new_items { let metadata = build_metadata_xml(&item); let new_id = self .playlist_client .insert(previous_id, &item.uri, &metadata)?; previous_id = new_id; - rebuilt_ids.push(new_id); - rebuilt_items.push(self.item_with_openhome_id(item, new_id)); } - self.items = rebuilt_items; - self.track_ids = rebuilt_ids; - self.current_index = Some(0); // Currently playing is now at index 0 - debug!( renderer = self.renderer_id.0.as_str(), "Gentle sync completed: preserved playing track as first item (not in new playlist)" @@ -351,6 +132,81 @@ impl OpenHomeQueue { Ok(()) } + /// Helper: Delete items marked for deletion in reverse order with logging. + fn delete_marked_items( + &mut self, + old_ids: &[u32], + keep_flags: &[bool], + position_label: &str, + ) -> Result<(), ControlPointError> { + for (idx, &track_id) in old_ids.iter().enumerate().rev() { + if !keep_flags[idx] { + debug!( + renderer = self.renderer_id.0.as_str(), + track_id, + position = position_label, + "RENDERER OP: DeleteId({})", + track_id + ); + self.playlist_client.delete_id_if_exists(track_id)?; + } + } + Ok(()) + } + + /// Helper: Rebuild a playlist section (before or after pivot) using LCS results. + fn rebuild_playlist_section( + &mut self, + new_items: &[PlaybackItem], + keep_new_flags: &[bool], + old_ids: &[u32], + keep_old_flags: &[bool], + mut previous_id: u32, + position_label: &str, + ) -> Result { + // Collect IDs of kept items (in order) + let remaining_ids: Vec = old_ids + .iter() + .enumerate() + .filter_map(|(idx, &id)| if keep_old_flags[idx] { Some(id) } else { None }) + .collect(); + + let mut remaining_idx = 0; + + // Rebuild section + for (idx, item) in new_items.iter().enumerate() { + if keep_new_flags[idx] { + let existing_id = remaining_ids[remaining_idx]; + remaining_idx += 1; + previous_id = existing_id; + debug!( + renderer = self.renderer_id.0.as_str(), + track_id = existing_id, + position = position_label, + "KEPT existing track ID {}", + existing_id + ); + } else { + let metadata = build_metadata_xml(item); + let new_id = self + .playlist_client + .insert(previous_id, &item.uri, &metadata)?; + debug!( + renderer = self.renderer_id.0.as_str(), + after_id = previous_id, + new_id, + position = position_label, + "RENDERER OP: Insert(after={}) -> new_id={}", + previous_id, + new_id + ); + previous_id = new_id; + } + } + + Ok(previous_id) + } + /// CASE 2: Replace queue with double-LCS (before and after the pivot). /// The currently playing item IS in the new playlist, so we use it as a pivot /// and apply LCS separately to the portions before and after it. @@ -358,20 +214,28 @@ impl OpenHomeQueue { &mut self, new_items: Vec, pivot_idx_new: usize, - pivot_id: u32, + pivot_id: usize, ) -> Result<(), ControlPointError> { + // Get current state from OpenHome + let snapshot = self.queue_snapshot()?; + let current_track_ids = self.track_ids()?; + // Find the pivot index in our current state - let pivot_idx = self - .track_ids + let pivot_idx = current_track_ids .iter() - .position(|&id| id == pivot_id) - .ok_or_else(|| ControlPointError::OpenHomeError(format!("Pivot track ID {} not found in playlist", pivot_id)))?; + .position(|&id| id as usize == pivot_id) + .ok_or_else(|| { + ControlPointError::OpenHomeError(format!( + "Pivot track ID {} not found in playlist", + pivot_id + )) + })?; // Split current data at the pivot - let old_before: Vec = self.items[..pivot_idx].to_vec(); - let old_after: Vec = self.items[pivot_idx + 1..].to_vec(); - let old_ids_before: Vec = self.track_ids[..pivot_idx].to_vec(); - let old_ids_after: Vec = self.track_ids[pivot_idx + 1..].to_vec(); + let old_before: Vec = snapshot.items[..pivot_idx].to_vec(); + let old_after: Vec = snapshot.items[pivot_idx + 1..].to_vec(); + let old_ids_before: Vec = current_track_ids[..pivot_idx].to_vec(); + let old_ids_after: Vec = current_track_ids[pivot_idx + 1..].to_vec(); let new_before = &new_items[..pivot_idx_new]; let new_after = &new_items[pivot_idx_new + 1..]; @@ -383,86 +247,24 @@ impl OpenHomeQueue { let (keep_old_before, keep_new_before) = lcs_flags(&old_before, new_before); // Delete items marked for deletion in AFTER part (reverse order) - for (idx, &track_id) in old_ids_after.iter().enumerate().rev() { - if !keep_old_after[idx] { - debug!( - renderer = self.renderer_id.0.as_str(), - track_id, - position = "AFTER pivot", - "RENDERER OP: DeleteId({})", - track_id - ); - self.playlist_client.delete_id_if_exists(track_id)?; - } - } + self.delete_marked_items(&old_ids_after, &keep_old_after, "AFTER pivot")?; // Delete items marked for deletion in BEFORE part (reverse order) - for (idx, &track_id) in old_ids_before.iter().enumerate().rev() { - if !keep_old_before[idx] { - debug!( - renderer = self.renderer_id.0.as_str(), - track_id, - position = "BEFORE pivot", - "RENDERER OP: DeleteId({})", - track_id - ); - self.playlist_client.delete_id_if_exists(track_id)?; - } - } + self.delete_marked_items(&old_ids_before, &keep_old_before, "BEFORE pivot")?; // Rebuild the playlist: [BEFORE, PIVOT, AFTER] - let mut rebuilt_items = Vec::with_capacity(new_items.len()); - let mut rebuilt_ids = Vec::with_capacity(new_items.len()); - - // Collect IDs of kept items in BEFORE part (in order) - let remaining_before: Vec = old_ids_before - .iter() - .enumerate() - .filter_map(|(idx, &id)| if keep_old_before[idx] { Some(id) } else { None }) - .collect(); - - let mut remaining_before_idx = 0; - let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID; - // Rebuild BEFORE part - for (idx, item) in new_before.iter().enumerate() { - if keep_new_before[idx] { - let existing_id = remaining_before[remaining_before_idx]; - remaining_before_idx += 1; - previous_id = existing_id; - rebuilt_ids.push(existing_id); - rebuilt_items.push(self.item_with_openhome_id(item.clone(), existing_id)); - debug!( - renderer = self.renderer_id.0.as_str(), - track_id = existing_id, - position = "BEFORE pivot", - "KEPT existing track ID {}", - existing_id - ); - } else { - let metadata = build_metadata_xml(item); - let new_id = self - .playlist_client - .insert(previous_id, &item.uri, &metadata)?; - debug!( - renderer = self.renderer_id.0.as_str(), - after_id = previous_id, - new_id, - position = "BEFORE pivot", - "RENDERER OP: Insert(after={}) -> new_id={}", - previous_id, - new_id - ); - previous_id = new_id; - rebuilt_ids.push(new_id); - rebuilt_items.push(self.item_with_openhome_id(item.clone(), new_id)); - } - } + let previous_id = self.rebuild_playlist_section( + new_before, + &keep_new_before, + &old_ids_before, + &keep_old_before, + OPENHOME_PLAYLIST_HEAD_ID, + "BEFORE pivot", + )?; // Add PIVOT (keeps its ID!) - rebuilt_ids.push(pivot_id); - rebuilt_items.push(self.item_with_openhome_id(new_items[pivot_idx_new].clone(), pivot_id)); - previous_id = pivot_id; + let previous_id = pivot_id as u32; debug!( renderer = self.renderer_id.0.as_str(), pivot_id, @@ -472,70 +274,20 @@ impl OpenHomeQueue { pivot_idx_new ); - // Collect IDs of kept items in AFTER part (in order) - let remaining_after: Vec = old_ids_after - .iter() - .enumerate() - .filter_map(|(idx, &id)| if keep_old_after[idx] { Some(id) } else { None }) - .collect(); - - let mut remaining_after_idx = 0; - // Rebuild AFTER part - for (idx, item) in new_after.iter().enumerate() { - if keep_new_after[idx] { - let existing_id = remaining_after[remaining_after_idx]; - remaining_after_idx += 1; - previous_id = existing_id; - rebuilt_ids.push(existing_id); - rebuilt_items.push(self.item_with_openhome_id(item.clone(), existing_id)); - debug!( - renderer = self.renderer_id.0.as_str(), - track_id = existing_id, - position = "AFTER pivot", - "KEPT existing track ID {}", - existing_id - ); - } else { - let metadata = build_metadata_xml(item); - let new_id = self - .playlist_client - .insert(previous_id, &item.uri, &metadata)?; - debug!( - renderer = self.renderer_id.0.as_str(), - after_id = previous_id, - new_id, - position = "AFTER pivot", - "RENDERER OP: Insert(after={}) -> new_id={}", - previous_id, - new_id - ); - previous_id = new_id; - rebuilt_ids.push(new_id); - rebuilt_items.push(self.item_with_openhome_id(item.clone(), new_id)); - } - } - - self.items = rebuilt_items; - self.track_ids = rebuilt_ids; - self.current_index = Some(pivot_idx_new); // Pivot is at its new position - - // VERIFICATION: Check that pivot ID is preserved - let final_pivot_id = self.track_ids.get(pivot_idx_new).copied(); - if final_pivot_id != Some(pivot_id) { - return Err(ControlPointError::OpenHomeError(format!( - "CRITICAL BUG: Pivot ID changed from {} to {:?} during replace_queue_with_pivot!", - pivot_id, - final_pivot_id - ))); - } + self.rebuild_playlist_section( + new_after, + &keep_new_after, + &old_ids_after, + &keep_old_after, + previous_id, + "AFTER pivot", + )?; debug!( renderer = self.renderer_id.0.as_str(), pivot_idx = pivot_idx_new, pivot_id, - final_playlist_len = self.track_ids.len(), - pivot_verified = true, "Gentle sync completed: double-LCS with pivot (playing track preserved)" ); @@ -546,9 +298,13 @@ impl OpenHomeQueue { fn replace_queue_standard_lcs( &mut self, items: Vec, - current_index: Option, + _current_index: Option, ) -> Result<(), ControlPointError> { - let (keep_current, keep_desired) = lcs_flags(&self.items, &items); + // Get current state from OpenHome + let snapshot = self.queue_snapshot()?; + let current_track_ids = self.track_ids()?; + + let (keep_current, keep_desired) = lcs_flags(&snapshot.items, &items); let items_to_keep = keep_current.iter().filter(|&&k| k).count(); let items_to_delete = keep_current.iter().filter(|&&k| !k).count(); @@ -571,27 +327,33 @@ impl OpenHomeQueue { "Using delete_all() for complete replacement (more robust for live playlists)" ); self.playlist_client.delete_all()?; - self.track_ids.clear(); - self.items.clear(); } else { // Selective deletion when keeping some items - for idx in (0..self.track_ids.len()).rev() { + for idx in (0..current_track_ids.len()).rev() { if !keep_current[idx] { - let track_id = self.track_ids[idx]; + let track_id = current_track_ids[idx]; // Use delete_id_if_exists() to handle cases where another control point // may have already modified the playlist self.playlist_client.delete_id_if_exists(track_id)?; - self.track_ids.remove(idx); - self.items.remove(idx); } } } - let remaining_ids = self.track_ids.clone(); + // Rebuild by inserting new items + let remaining_ids: Vec = current_track_ids + .iter() + .enumerate() + .filter_map(|(idx, &id)| { + if keep_current.get(idx).copied().unwrap_or(false) { + Some(id) + } else { + None + } + }) + .collect(); + let mut remaining_idx = 0usize; let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID; - let mut rebuilt_items = Vec::with_capacity(items.len()); - let mut rebuilt_ids = Vec::with_capacity(items.len()); for (idx, item) in items.into_iter().enumerate() { if keep_desired[idx] { @@ -603,16 +365,12 @@ impl OpenHomeQueue { let existing_id = remaining_ids[remaining_idx]; remaining_idx += 1; previous_id = existing_id; - rebuilt_ids.push(existing_id); - rebuilt_items.push(self.item_with_openhome_id(item, existing_id)); } else { let metadata = build_metadata_xml(&item); let new_id = self .playlist_client .insert(previous_id, &item.uri, &metadata)?; previous_id = new_id; - rebuilt_ids.push(new_id); - rebuilt_items.push(self.item_with_openhome_id(item, new_id)); } } @@ -622,39 +380,10 @@ impl OpenHomeQueue { ))); } - let previous_index = self.current_index.and_then(|idx| { - if idx < rebuilt_ids.len() { - Some(idx) - } else { - None - } - }); - let normalized = current_index - .filter(|&i| i < rebuilt_ids.len()) - .or(previous_index) - .or_else(|| { - if rebuilt_ids.is_empty() { - None - } else { - Some(0) - } - }); - self.items = rebuilt_items; - self.track_ids = rebuilt_ids; - self.current_index = normalized; Ok(()) } } -pub fn didl_id_from_metadata(xml: &str) -> Option { - if xml.trim().is_empty() { - return None; - } - - let parsed = pmodidl::parse_metadata::(xml).ok()?; - parsed.data.items.first().map(|item| item.id.clone()) -} - fn build_metadata_xml(item: &PlaybackItem) -> String { let title = item .metadata @@ -750,6 +479,51 @@ fn lcs_flags(current: &[PlaybackItem], desired: &[PlaybackItem]) -> (Vec, } impl QueueBackend for OpenHomeQueue { + fn len(&self) -> Result { + Ok(self.track_ids()?.len()) + } + + /// Return the list of OpenHome track IDs in order. + fn track_ids(&self) -> Result, ControlPointError> { + self.ensure_playlist_source_selected()?; + self.playlist_client.id_array() + } + + fn id_to_position(&self, id: u32) -> Result { + self.track_ids()? + .iter() + .position(|&tid| tid == id) + .ok_or_else(|| { + ControlPointError::QueueError(format!("Item {} id is not present in the queue", id)) + }) + } + + fn position_to_id(&self, index: usize) -> Result { + let idxs = self.track_ids()?; + + if index < idxs.len() { + Ok(idxs[index]) + } else { + Err(ControlPointError::QueueError(format!( + "Index out of bound {} >= {}", + index, + idxs.len() + ))) + } + } + + fn current_track(&self) -> Result, ControlPointError> { + Ok(Some(self.playlist_client.id()?)) + } + + fn current_index(&self) -> Result, ControlPointError> { + if let Some(id) = self.current_track()? { + return Ok(Some(self.id_to_position(id)?)); + } + + Ok(None) + } + fn queue_snapshot(&self) -> Result { self.ensure_playlist_source_selected()?; let entries = self.playlist_client.read_all_tracks()?; @@ -763,23 +537,27 @@ impl QueueBackend for OpenHomeQueue { let current_id = self.playlist_client.id().ok(); // Find the index of the current track in the playlist - let current_index = - current_id.and_then(|id| items.iter().position(|entry_id| entry_id.backend_id == id as usize)); + let current_index = current_id.and_then(|id| { + items + .iter() + .position(|entry_id| entry_id.backend_id == id as usize) + }); Ok(QueueSnapshot { - items: self.items.clone(), - current_index: self.current_index, + items: items, + current_index: current_index, }) } fn set_index(&mut self, index: Option) -> Result<(), ControlPointError> { - let normalized = index.filter(|&i| i < self.items.len()); - if let Some(idx) = normalized { - let track_id = self.ensure_track_id(idx)?; + if let Some(index) = index { + let track_id = self.position_to_id(index)?; self.ensure_playlist_source_selected()?; - self.playlist_client.play_id(track_id)?; + self.playlist_client.seek_id(track_id)?; + } else { + self.ensure_playlist_source_selected()?; + self.playlist_client.stop()?; } - self.current_index = normalized; Ok(()) } @@ -788,33 +566,60 @@ impl QueueBackend for OpenHomeQueue { items: Vec, current_index: Option, ) -> Result<(), ControlPointError> { + if let Some(ci) = current_index { + if ci >= items.len() { + return Err(ControlPointError::QueueError(format!( + "Invalid Current index parameter {} >= {}", + ci, + items.len() + ))); + } + } + + self.ensure_playlist_source_selected()?; + self.playlist_client.delete_all()?; + + if items.is_empty() { + return Ok(()); + } + + let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID; + + for item in items { + let metadata = build_metadata_xml(&item); + let new_id = self + .playlist_client + .insert(previous_id, &item.uri, &metadata)?; + previous_id = new_id; + } + + Ok(()) + } + + fn sync_queue(&mut self, items: Vec) -> Result<(), ControlPointError> { self.ensure_playlist_source_selected()?; if items.is_empty() { self.playlist_client.delete_all()?; - self.items.clear(); - self.track_ids.clear(); - self.current_index = None; return Ok(()); } // Synchronize local state with the actual OpenHome playlist before computing // differences. Without this, any drift between our cache and the renderer // (e.g., manual edits from another control point) would keep the stale items. - self.refresh_from_openhome()?; - - // Get the currently playing track ID from the renderer - let playing_info = self.playlist_client.id().ok().and_then(|id| { - self.track_ids - .iter() - .position(|&tid| tid == id) - .map(|idx| (idx, id, self.items[idx].uri.clone())) + let snapshot = self.queue_snapshot()?; + let playing_info = snapshot.current_index.and_then(|idx| { + Some(( + idx, + snapshot.items[idx].backend_id, + snapshot.items[idx].uri.clone(), + )) }); debug!( renderer = self.renderer_id.0.as_str(), - actual_items = self.items.len(), + actual_items = snapshot.items.len(), playing_info_detected = playing_info.is_some(), - "OpenHome playlist state refreshed before replace_queue" + "OpenHome playlist state" ); if let Some((playing_idx, playing_id, playing_uri)) = playing_info { @@ -842,7 +647,7 @@ impl QueueBackend for OpenHomeQueue { "Gentle sync: currently playing item not in new playlist, preserving as first item" ); - self.replace_queue_preserve_current(items, playing_idx, playing_id)?; + self.replace_queue_preserve_current(items, playing_id)?; } } else { // No currently playing item or can't determine it - use standard LCS @@ -850,72 +655,93 @@ impl QueueBackend for OpenHomeQueue { renderer = self.renderer_id.0.as_str(), "No currently playing item, using standard LCS sync" ); - self.replace_queue_standard_lcs(items, current_index)?; + self.replace_queue_standard_lcs(items, Some(0))?; } Ok(()) } fn get_item(&self, index: usize) -> Result, ControlPointError> { - Ok(self.items.get(index).cloned()) + let snapshot = self.queue_snapshot()?; + + if index < snapshot.items.len() { + return Ok(Some(snapshot.items[index].clone())); + } + + Err(ControlPointError::QueueError(format!( + "get_item index out of bound {} >= {}", + index, + snapshot.items.len() + ))) } fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> { - if index >= self.items.len() { - return Ok(()); + let metadata = build_metadata_xml(&item); + + let ids = self.track_ids()?; + + if index >= ids.len() { + return Err(ControlPointError::QueueError(format!( + "get_item index out of bound {} >= {}", + index, + ids.len() + ))); } self.ensure_playlist_source_selected()?; - let track_id = self.ensure_track_id(index)?; + + let track_id = ids[index]; let before_id = if index == 0 { OPENHOME_PLAYLIST_HEAD_ID } else { - self.ensure_track_id(index - 1)? + ids[index - 1] }; + let ci = self.current_index()?; // Use delete_id_if_exists() to handle cases where another control point // may have already modified the playlist - self.playlist_client.delete_id_if_exists(track_id)?; - let metadata = build_metadata_xml(&item); + self.playlist_client.delete_id(track_id)?; let new_id = self .playlist_client .insert(before_id, &item.uri, &metadata)?; - if self.current_index == Some(index) { - self.playlist_client.play_id(new_id)?; + if ci == Some(index) { + self.playlist_client.seek_id(new_id)?; } - self.items[index] = self.item_with_openhome_id(item, new_id); - self.track_ids[index] = new_id; Ok(()) } /// Override enqueue_items to add items directly to the OpenHome playlist. - fn enqueue_items(&mut self, items: Vec, mode: EnqueueMode) -> Result<(), ControlPointError> { + fn enqueue_items( + &mut self, + items: Vec, + mode: EnqueueMode, + ) -> Result<(), ControlPointError> { if items.is_empty() { return Ok(()); } match mode { EnqueueMode::AppendToEnd => { + let ids = self.track_ids()?; + let mut after_id = if ids.len() > 0 { + ids[ids.len() - 1] + } else { + OPENHOME_PLAYLIST_HEAD_ID + }; // Append to the end of the OpenHome playlist - let mut after_id = self.track_ids.last().copied(); - for item in items { - after_id = Some(self.add_playback_item(item, after_id, false)?); + after_id = self.add_playback_item(item, after_id)?; } } EnqueueMode::InsertAfterCurrent => { - // Insert after the current playing track - let after_id = if let Some(idx) = self.current_index { - self.track_ids.get(idx).copied() + if let Some(mut after_id) = self.current_track()? { + for item in items { + after_id = self.add_playback_item(item, after_id)?; + } } else { - None - }; - - let mut next_after_id = after_id; - for item in items { - next_after_id = Some(self.add_playback_item(item, next_after_id, false)?); + self.enqueue_items(items, EnqueueMode::AppendToEnd)?; } } EnqueueMode::ReplaceAll => { @@ -923,9 +749,108 @@ impl QueueBackend for OpenHomeQueue { self.replace_queue(items, None)?; } } + Ok(()) + } + + // Optimized helpers to avoid unnecessary network calls + + /// Optimized clear_queue: use delete_all() directly instead of replace_queue. + fn clear_queue(&mut self) -> Result<(), ControlPointError> { + self.ensure_playlist_source_selected()?; + self.playlist_client.delete_all() + } + + /// Optimized is_empty: only fetch track IDs, not the full playlist. + fn is_empty(&self) -> Result { + Ok(self.track_ids()?.is_empty()) + } + + /// Optimized upcoming_len: calculate from len() and current_index() without fetching items. + fn upcoming_len(&self) -> Result { + let len = self.len()?; + match self.current_index()? { + None => Ok(len), + Some(idx) => Ok(len.saturating_sub(idx + 1)), + } + } + + /// Optimized peek_current: use primitives instead of full snapshot. + fn peek_current(&self) -> Result, ControlPointError> { + let len = self.len()?; + if len == 0 { + return Ok(None); + } + + let current_idx = self.current_index()?; + let resolved_index = match current_idx { + Some(idx) if idx < len => Some(idx), + _ => None, + }; + + let item_index = resolved_index.unwrap_or(0); + let item = match self.get_item(item_index)? { + Some(item) => item, + None => return Ok(None), + }; + + let remaining = match resolved_index { + Some(idx) => len.saturating_sub(idx + 1), + None => len, + }; + + Ok(Some((item, remaining))) + } + + /// Optimized dequeue_next: use primitives instead of full snapshot. + fn dequeue_next(&mut self) -> Result, ControlPointError> { + let len = self.len()?; + if len == 0 { + return Ok(None); + } + + let current_idx = self.current_index()?; + let next_index = match current_idx { + None => 0, + Some(idx) => { + let candidate = idx + 1; + if candidate >= len { + return Ok(None); + } + candidate + } + }; + + let Some(item) = self.get_item(next_index)? else { + return Ok(None); + }; + + let remaining = len.saturating_sub(next_index + 1); + self.set_index(Some(next_index))?; + Ok(Some((item, remaining))) + } + + /// Optimized append_or_init_index: use enqueue_items which is already optimized. + fn append_or_init_index(&mut self, items: Vec) -> Result<(), ControlPointError> { + let was_empty = self.is_empty()?; + + // Use the already optimized enqueue_items(AppendToEnd) + self.enqueue_items(items, EnqueueMode::AppendToEnd)?; + + // If the queue was empty before, set index to 0 + if was_empty && !self.is_empty()? { + self.set_index(Some(0))?; + } - // Refresh local cache from OpenHome after modification - self.refresh_from_openhome()?; Ok(()) } } + +impl QueueFromRendererInfo for OpenHomeQueue { + fn from_renderer_info(renderer: &RendererInfo) -> Result { + OpenHomeQueue::from_renderer_info(renderer) + } + + fn to_backend(self) -> MusicQueue { + MusicQueue::OpenHome(self) + } +} diff --git a/pmocontrol/src/queue/snapshot.rs b/pmocontrol/src/queue/snapshot.rs new file mode 100644 index 00000000..a59abb7f --- /dev/null +++ b/pmocontrol/src/queue/snapshot.rs @@ -0,0 +1,91 @@ +use crate::{DeviceId, model::TrackMetadata}; + +/// Canonical representation of a track in a renderer queue. +/// +/// This type is the bridge between: +/// - the UPnP MediaServer (DIDL-Lite items), +/// - the ControlPoint runtime, +/// - and the different queue backends (internal / OpenHome). +/// +/// It is intentionally DIDL-centric: every item in a queue comes from +/// a UPnP ContentDirectory and carries its MediaServer identity. +#[derive(Clone, Debug)] +pub struct PlaybackItem { + /// Identifier of the UPnP MediaServer that owns this content. + /// + /// Typically this is the UDN of the MediaServer device, or an + /// equivalent logical identifier. + pub media_server_id: DeviceId, + + /// Internal ID of the item in the queue. + /// This ID only has meaning when returning a snapshot. + /// In an internal queue, it can have any value; the position in the vector is what matters. + /// In principle, it can be set to usize::MAX. + pub backend_id: usize, + + /// DIDL-Lite `id` attribute of the `item` in the ContentDirectory. + /// + /// This, combined with `media_server_id`, is the logical identity + /// of the track across refreshes of the MediaServer state. + pub didl_id: String, + + /// Main resource URI to be used for playback. + /// + /// This is usually the first `` element (or a selected one) + /// from the DIDL-Lite item. + pub uri: String, + + /// UPnP protocolInfo string for the resource (e.g., "http-get:*:audio/flac:*"). + /// + /// This string describes the protocol, network, MIME type, and additional + /// info about the media resource. It's required for proper UPnP/OpenHome + /// renderer compatibility. + pub protocol_info: String, + + /// Optional rich metadata for the track (title, artist, album, cover, + /// duration, …). + /// + /// The exact structure is defined in `TrackMetadata` and may + /// aggregate information from DIDL, tags, or additional sources. + pub metadata: Option, +} + +impl PlaybackItem { + /// Returns a stable, backend-agnostic logical identifier for this item. + /// + /// By default this is the concatenation of the MediaServer identifier + /// and the DIDL `id`. Backends and higher-level logic should use this + /// when they need to match items across queue rebuilds. + pub fn unique_id(&self) -> String { + // ADAPTE si MediaServerId n'implémente pas Display : utilise + // un champ string interne ou une méthode as_str(). + format!("{}::{}", self.media_server_id.0, self.didl_id) + } +} + + +/// Logical snapshot of a renderer queue. +/// +/// This is the canonical view used by the ControlPoint and the REST/API +/// layer. It is independent of how the queue is actually stored (local +/// in-memory queue, OpenHome playlist, …). +#[derive(Clone, Debug)] +pub struct QueueSnapshot { + /// All items currently in the queue, in play order. + pub items: Vec, + /// Index (0-based) of the current item in `items`, or `None` if + /// no item is currently selected. + pub current_index: Option, +} + +impl QueueSnapshot { + /// Returns the number of items in the snapshot. + pub fn len(&self) -> usize { + self.items.len() + } + + /// Returns `true` if the snapshot contains no items. + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } +} diff --git a/pmocontrol/src/registry.rs b/pmocontrol/src/registry.rs index ec85bb48..cb91e5bb 100644 --- a/pmocontrol/src/registry.rs +++ b/pmocontrol/src/registry.rs @@ -2,26 +2,28 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::SystemTime; -use tracing::warn; - use crate::errors::ControlPointError; +use crate::events::{MediaServerEventBus, RendererEventBus}; use crate::media_server::MusicServer; use crate::model::RendererInfo; use crate::music_renderer::MusicRenderer; -use crate::{DeviceId, DeviceIdentity, DeviceOnline, UpnpMediaServer}; +use crate::{ + DeviceId, DeviceIdentity, DeviceOnline, MediaServerEvent, RendererEvent, UpnpMediaServer, +}; const DEFAULT_MAX_AGE: u32 = 1800; #[derive(Debug, Clone)] pub struct DeviceItem { - music_rendrer : Option>, + music_renderer: Option>, music_server: Option>, } -#[derive(Debug, Default)] pub struct DeviceRegistry { devices: HashMap, udn_index: HashMap, + renderer_bus: RendererEventBus, + server_bus: MediaServerEventBus, } #[derive(Debug, Clone)] @@ -32,74 +34,85 @@ pub enum DeviceUpdate { impl DeviceItem { pub fn as_music_renderer(&self) -> Result, ControlPointError> { - match self { - DeviceItem::MusicRenderer(renderer) => Ok(Arc::clone(renderer)), - _ => Err(ControlPointError::IsNotAMediaRender(format!("{:#?}", self))), - } + self.music_renderer + .clone() // Clone l'Option> + .ok_or_else(|| ControlPointError::IsNotAMediaRender(format!("{:#?}", self))) } pub fn is_a_music_renderer(&self) -> bool { - match self { - DeviceItem::MusicRenderer(_) => true, - _ => false, - } + self.music_renderer.is_some() } pub fn as_music_server(&self) -> Result, ControlPointError> { - match self { - DeviceItem::MusicServer(server) => Ok(Arc::clone(server)), - _ => Err(ControlPointError::IsNotAMediaServer(format!("{:#?}", self))), - } + self.music_server + .clone() // Clone l'Option> + .ok_or_else(|| ControlPointError::IsNotAMediaRender(format!("{:#?}", self))) } pub fn is_a_music_server(&self) -> bool { - match self { - DeviceItem::MusicServer(_) => true, - _ => false, - } + self.music_server.is_some() } } impl DeviceOnline for DeviceItem { fn is_online(&self) -> bool { - match self { - DeviceItem::MusicRenderer(r) => r.is_online(), - DeviceItem::MusicServer(s) => s.is_online(), - } + self.music_renderer + .as_ref() + .map_or(false, |r| r.is_online()) + || self.music_server.as_ref().map_or(false, |s| s.is_online()) } fn last_seen(&self) -> SystemTime { - match self { - DeviceItem::MusicRenderer(r) => r.last_seen(), - DeviceItem::MusicServer(s) => s.last_seen(), + let renderer_time = self.music_renderer.as_ref().map(|r| r.last_seen()); + let server_time = self.music_server.as_ref().map(|s| s.last_seen()); + + match (renderer_time, server_time) { + (Some(r), Some(s)) => r.max(s), // Le plus récent + (Some(r), None) => r, + (None, Some(s)) => s, + (None, None) => unreachable!("DeviceEntry must have at least one component"), } } fn has_been_seen_now(&self, max_age: u32) { - match self { - DeviceItem::MusicRenderer(r) => r.has_been_seen_now(max_age), - DeviceItem::MusicServer(s) => s.has_been_seen_now(max_age), + if let Some(r) = &self.music_renderer { + r.has_been_seen_now(max_age); + } + if let Some(s) = &self.music_server { + s.has_been_seen_now(max_age); } } fn mark_as_offline(&self) { - match self { - DeviceItem::MusicRenderer(r) => r.mark_as_offline(), - DeviceItem::MusicServer(s) => s.mark_as_offline(), + if let Some(r) = &self.music_renderer { + r.mark_as_offline(); + } + if let Some(s) = &self.music_server { + s.mark_as_offline(); } } fn max_age(&self) -> u32 { - match self { - DeviceItem::MusicRenderer(r) => r.max_age(), - DeviceItem::MusicServer(s) => s.max_age(), + let renderer_max_age = self.music_renderer.as_ref().map(|r| r.max_age()); + let server_max_age = self.music_server.as_ref().map(|s| s.max_age()); + + match (renderer_max_age, server_max_age) { + (Some(r), Some(s)) => r.max(s), // Le plus récent + (Some(r), None) => r, + (None, Some(s)) => s, + (None, None) => unreachable!("DeviceEntry must have at least one component"), } } } impl DeviceRegistry { - pub fn new() -> Self { - Self::default() + pub fn new(renderer_bus: RendererEventBus, server_bus: MediaServerEventBus) -> Self { + Self { + devices: HashMap::new(), + udn_index: HashMap::new(), + renderer_bus, + server_bus, + } } pub fn list_renderers(&self) -> Result>, ControlPointError> { @@ -126,59 +139,6 @@ impl DeviceRegistry { self.devices.get(id)?.as_music_server().ok() } - pub fn push_renderer(&mut self, info: RendererInfo, max_age: u32) { - if let Some(existing) = self.devices.get(&info.id()) { - existing.has_been_seen_now(max_age); - } else { - // let renderer = MusicRenderer::from_renderer_info(info); - match MusicRenderer::from_renderer_info(info.clone()) { - Ok(renderer) => { - self.devices - .insert(info.id(), DeviceItem::MusicRenderer(renderer)); - self.udn_index.insert(info.udn().to_ascii_lowercase(), info.id()); - } - Err(err) => { - warn!("Failed to create renderer: {:#?}\n", err) - } - } - } - } - - pub fn push_server(&mut self, info: UpnpMediaServer, max_age: u32) { - if let Some(existing) = self.devices.get(&info.id()) { - existing.has_been_seen_now(max_age); - } else { - let server = MusicServer::Upnp(info.clone()); - self.devices - .insert(info.id(), DeviceItem::MusicServer(Arc::new(server))); - self.udn_index.insert(info.udn().to_ascii_lowercase(), info.id()); - } - } - - pub fn device_says_byebye(&mut self, udn: &str) { - if let Some(device) = self.get_device_by_udn(udn) { - device.mark_as_offline(); - } - } - - pub fn apply_update(&mut self, update: DeviceUpdate) { - match update { - DeviceUpdate::OfflineById(id) => { - if let Some(renderer) = self.devices.get(&id) { - renderer.mark_as_offline(); - } - } - DeviceUpdate::OfflineByUdn(udn) => { - let lookup = udn.to_ascii_lowercase(); - if let Some(id) = self.udn_index.get(&lookup) { - if let Some(renderer) = self.devices.get(id) { - renderer.mark_as_offline(); - } - } - } - } - } - pub fn get_device_by_udn(&self, udn: &str) -> Option { let lookup = udn.to_ascii_lowercase(); self.udn_index @@ -197,4 +157,147 @@ impl DeviceRegistry { self.get_device_by_udn(udn) .and_then(|item| item.as_music_server().ok()) } + + pub fn push_renderer(&mut self, info: &RendererInfo, max_age: u32) { + let device_id = info.id(); + + if let Some(entry) = self.devices.get_mut(&device_id) { + if let Some(renderer) = &entry.music_renderer { + let was_online = renderer.is_online(); + renderer.has_been_seen_now(max_age); + + if !was_online { + self.renderer_bus.broadcast(RendererEvent::Online { + id: device_id, + info: info.clone(), + }); + } + return; + } + // Entry existe mais pas de renderer -> on l'ajoute + if let Ok(new_renderer) = MusicRenderer::from_renderer_info(info) { + entry.music_renderer = Some(Arc::new(new_renderer)); + self.udn_index + .insert(info.udn().to_ascii_lowercase(), device_id.clone()); + + // Broadcast sur le bon bus + self.renderer_bus.broadcast(RendererEvent::Online { + id: device_id, + info: info.clone(), + }); + } + } else { + // Entry n'existe pas -> on crée + if let Ok(new_renderer) = MusicRenderer::from_renderer_info(info) { + let new_entry = DeviceItem { + music_renderer: Some(Arc::new(new_renderer)), + music_server: None, + }; + + self.devices.insert(device_id.clone(), new_entry); + self.udn_index + .insert(info.udn().to_ascii_lowercase(), device_id.clone()); + + // Broadcast sur le bon bus + self.renderer_bus.broadcast(RendererEvent::Online { + id: device_id, + info: info.clone(), + }); + } + } + } + + pub fn push_server(&mut self, info: &UpnpMediaServer, max_age: u32) { + let device_id = info.id(); + + if let Some(entry) = self.devices.get_mut(&device_id) { + if let Some(server) = &entry.music_server { + let was_online = server.is_online(); + server.has_been_seen_now(max_age); + + if !was_online { + self.server_bus.broadcast(MediaServerEvent::Online { + server_id: device_id, + info: info.clone(), + }); + } + + return; + } + // Entry existe mais pas de renderer -> on l'ajoute + if let Ok(new_server) = MusicServer::from_server_info(info) { + entry.music_server = Some(Arc::new(new_server)); + self.udn_index + .insert(info.udn().to_ascii_lowercase(), device_id.clone()); + + // Broadcast sur le bon bus + self.server_bus.broadcast(MediaServerEvent::Online { + server_id: device_id, + info: info.clone(), + }); + } + } else { + // Entry n'existe pas -> on crée + if let Ok(new_server) = MusicServer::from_server_info(info) { + let new_entry = DeviceItem { + music_renderer: None, + music_server: Some(Arc::new(new_server)), + }; + + self.devices.insert(device_id.clone(), new_entry); + self.udn_index + .insert(info.udn().to_ascii_lowercase(), device_id.clone()); + + // Broadcast sur le bon bus + self.server_bus.broadcast(MediaServerEvent::Online { + server_id: device_id, + info: info.clone(), + }); + } + } + } + + pub fn device_says_byebye(&mut self, udn: &str) { + let lookup = udn.to_ascii_lowercase(); + + if let Some(id) = self.udn_index.get(&lookup) { + if let Some(device) = self.devices.get(id) { + device.mark_as_offline(); + + // Broadcast sur le bon bus + if device.is_a_music_renderer() { + self.renderer_bus + .broadcast(RendererEvent::Offline { id: id.clone() }); + } + if device.is_a_music_server() { + self.server_bus.broadcast(MediaServerEvent::Offline { + server_id: id.clone(), + }); + } + } + } + } + + pub fn check_timeouts(&mut self) { + let now = SystemTime::now(); + + for (id, device) in &self.devices { + if let Ok(elapsed) = now.duration_since(device.last_seen()) { + if elapsed.as_secs() > device.max_age() as u64 { + device.mark_as_offline(); + + // Broadcast sur le bon bus + if device.is_a_music_renderer() { + self.renderer_bus + .broadcast(RendererEvent::Offline { id: id.clone() }); + } + if device.is_a_music_server() { + self.server_bus.broadcast(MediaServerEvent::Offline { + server_id: id.clone(), + }); + } + } + } + } + } } diff --git a/pmocontrol/src/avtransport_client.rs b/pmocontrol/src/upnp_clients/avtransport_client.rs similarity index 100% rename from pmocontrol/src/avtransport_client.rs rename to pmocontrol/src/upnp_clients/avtransport_client.rs diff --git a/pmocontrol/src/connection_manager_client.rs b/pmocontrol/src/upnp_clients/connection_manager_client.rs similarity index 100% rename from pmocontrol/src/connection_manager_client.rs rename to pmocontrol/src/upnp_clients/connection_manager_client.rs diff --git a/pmocontrol/src/upnp_clients/mod.rs b/pmocontrol/src/upnp_clients/mod.rs new file mode 100644 index 00000000..257c204d --- /dev/null +++ b/pmocontrol/src/upnp_clients/mod.rs @@ -0,0 +1,41 @@ +mod openhome_client; +mod avtransport_client; +mod rendering_control_client; +mod connection_manager_client; + + +pub use crate::upnp_clients::avtransport_client::{AvTransportClient, PositionInfo}; +pub use crate::upnp_clients::rendering_control_client::RenderingControlClient; +pub use crate::upnp_clients::connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo}; +pub use crate::upnp_clients::openhome_client::{ + OPENHOME_PLAYLIST_HEAD_ID, + OhTrackEntry,OhTrack, + OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, OhTimeClient, OhVolumeClient, +}; + +/// Resolve a possibly relative controlURL against the description URL. +/// +/// - If `control_url` is already absolute (starts with http:// or https://), it is returned as-is. +/// - Otherwise, it is resolved against the scheme://host:port of `description_url`. +pub fn resolve_control_url(description_url: &str, control_url: &str) -> String { + if control_url.starts_with("http://") || control_url.starts_with("https://") { + return control_url.to_string(); + } + + // Extract "scheme://host[:port]" from description_url + if let Some((scheme, rest)) = description_url.split_once("://") { + if let Some(pos) = rest.find('/') { + let authority = &rest[..pos]; + let base = format!("{}://{}", scheme, authority); + + if control_url.starts_with('/') { + return format!("{}{}", base, control_url); + } else { + return format!("{}/{}", base, control_url); + } + } + } + + // Fallback: just return the raw control_url if we cannot parse + control_url.to_string() +} diff --git a/pmocontrol/src/openhome_client.rs b/pmocontrol/src/upnp_clients/openhome_client.rs similarity index 83% rename from pmocontrol/src/openhome_client.rs rename to pmocontrol/src/upnp_clients/openhome_client.rs index 9bf4ac85..4d121f78 100644 --- a/pmocontrol/src/openhome_client.rs +++ b/pmocontrol/src/upnp_clients/openhome_client.rs @@ -7,6 +7,7 @@ use crate::soap_client::{ invoke_upnp_action, parse_bool, parse_visible_flag, }; use anyhow::{Result, anyhow}; +use pmodidl::DIDLLite; use tracing::{debug, info, trace, warn}; use xmltree::{Element, XMLNode}; @@ -17,11 +18,25 @@ use crate::model::RendererInfo; /// historical 0xFFFFFFFF sentinel. pub const OPENHOME_PLAYLIST_HEAD_ID: u32 = 0; -#[derive(Debug, Clone)] -pub struct OhTrackEntry { - pub id: u32, - pub uri: String, - pub metadata_xml: String, +pub trait OhTrack { + fn uri(&self) -> &str; + fn metadata_xml(&self) -> Option<&str>; + + fn metadata(&self) -> Option { + self.metadata_xml() + .as_deref() + .and_then(parse_track_metadata_from_didl) + } + + fn didl_id(&self) -> Option { + let trimmed = self.metadata_xml()?.trim(); + if trimmed.is_empty() { + return None; + } + + let parsed = pmodidl::parse_metadata::(trimmed).ok()?; + parsed.data.items.first().map(|item| item.id.clone()) + } } #[derive(Debug, Clone)] @@ -31,10 +46,20 @@ pub struct OhInfoTrack { } impl OhInfoTrack { - pub fn metadata(&self) -> Option { - self.metadata_xml - .as_deref() - .and_then(parse_track_metadata_from_didl) + pub fn new(uri: &str, metadata_xml: Option<&str>) -> Self { + OhInfoTrack { + uri: uri.to_string(), + metadata_xml: metadata_xml.map(|s| s.to_string()), + } + } +} + +impl OhTrack for OhInfoTrack { + fn metadata_xml(&self) -> Option<&str> { + self.metadata_xml.as_deref() + } + fn uri(&self) -> &str { + &self.uri } } @@ -58,12 +83,52 @@ pub struct OhProductSource { pub visible: bool, } +#[derive(Debug, Clone)] +pub struct OhInfoClient { + pub control_url: String, + pub service_type: String, +} + #[derive(Debug, Clone)] pub struct OhPlaylistClient { pub control_url: String, pub service_type: String, } +#[derive(Debug, Clone)] +pub struct OhTrackEntry { + pub id: u32, + uri: String, + metadata_xml: String, +} + +impl OhTrackEntry { + pub fn new(id: u32, uri: &str, metadata_xml: &str) -> Self { + OhTrackEntry { + id, + uri: uri.to_string(), + metadata_xml: metadata_xml.to_string(), + } + } + + pub fn id(&self) -> u32 { + self.id + } +} + +impl OhTrack for OhTrackEntry { + fn metadata_xml(&self) -> Option<&str> { + if self.metadata_xml.is_empty() { + None + } else { + Some(&self.metadata_xml) + } + } + fn uri(&self) -> &str { + &self.uri + } +} + impl OhPlaylistClient { pub fn new(control_url: String, service_type: String) -> Self { Self { @@ -73,14 +138,46 @@ impl OhPlaylistClient { } pub fn from_renderer_info(info: &RendererInfo) -> Result { - let control_url = info.oh_playlist_control_url() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create playlist control client".to_string()))?; + let control_url = info.oh_playlist_control_url().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create playlist control client".to_string()) + })?; - let service_type = info.oh_playlist_service_type() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create playlist service client".to_string()))?; + let service_type = info.oh_playlist_service_type().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create playlist service client".to_string()) + })?; Ok(OhPlaylistClient::new(control_url, service_type)) } + /// Report the uri and metadata for a given track id. + /// Returns a 800 fault code if the given id is not in the playlist. + pub fn read(&self, id: u32) -> Result { + let id_str = id.to_string(); + let args = [("IdList", id_str.as_str())]; + + let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Read", &args)?; + + if call_result.status == 800 { + return Err(ControlPointError::OpenHomeError(format!( + "Id {} not found in OpenHome playlist", + id + ))); + } + + let envelope: &pmoupnp::soap::SoapEnvelope = ensure_success("Read", &call_result)?; + + let response = + find_child_with_suffix(&envelope.body.content, "ReadResponse").ok_or_else(|| { + ControlPointError::OpenHomeError(format!( + "Missing ReadResponse element in SOAP body" + )) + })?; + + let track_uri = extract_child_text(response, "Uri")?; + let metadata = extract_child_text(response, "Metadata")?; + + Ok(OhTrackEntry::new(id, &track_uri, &metadata)) + } + pub fn read_list(&self, id_list: &[u32]) -> Result, ControlPointError> { if id_list.is_empty() { return Ok(Vec::new()); @@ -96,9 +193,13 @@ impl OhPlaylistClient { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "ReadList", &args)?; - let envelope = ensure_success("ReadList", &call_result)?; + let envelope: &pmoupnp::soap::SoapEnvelope = ensure_success("ReadList", &call_result)?; let response = find_child_with_suffix(&envelope.body.content, "ReadListResponse") - .ok_or_else(|| ControlPointError::OpenHomeError(format!("Missing ReadListResponse element in SOAP body")))?; + .ok_or_else(|| { + ControlPointError::OpenHomeError(format!( + "Missing ReadListResponse element in SOAP body" + )) + })?; let track_list_b64 = extract_child_text_any(response, &["TrackList", "Value"])?; let track_list_sample: String = track_list_b64.chars().take(256).collect(); @@ -143,7 +244,9 @@ impl OhPlaylistClient { Ok(new_id) } - pub fn play_id(&self, id: u32) -> Result<(), ControlPointError> { + /// The id of the current track (the track currently playing or that would be played + /// if the Play action was invoked). Or 0 if the playlist is empty. + pub fn seek_id(&self, id: u32) -> Result<(), ControlPointError> { let id_str = id.to_string(); let args = [("Value", id_str.as_str())]; @@ -171,11 +274,13 @@ impl OhPlaylistClient { pub fn id(&self) -> Result { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Id", &[])?; - let envelope = ensure_success("Id", &call_result) - .map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?; + let envelope = ensure_success("Id", &call_result)?; - let response = find_child_with_suffix(&envelope.body.content, "IdResponse") - .ok_or_else(|| ControlPointError::UpnpMissingReturnValue("IdResponse".to_string()))?; + let response = find_child_with_suffix(&envelope.body.content, "IdResponse").ok_or_else(|| { + ControlPointError::OpenHomeError(format!( + "Missing ReadResponse element in SOAP body" + )) + })?; let id_text = extract_child_text(response, "Value")?; let id = id_text.parse::().map_err(|_| { ControlPointError::UpnpBadReturnValue("Playlist.Id".to_string(), id_text) @@ -276,15 +381,6 @@ impl OhPlaylistClient { handle_action_response("DeleteAll", &call_result) } - pub fn current_id(&self) -> Result { - let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Id", &[])?; - let envelope = ensure_success("Id", &call_result)?; - let response = find_child_with_suffix(&envelope.body.content, "IdResponse") - .ok_or_else(|| anyhow!("Missing IdResponse element in SOAP body"))?; - let value: String = extract_child_text_any(response, &["Value"])?; - Ok(value) - } - pub fn tracks_max(&self) -> Result { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "TracksMax", &[])?; @@ -387,12 +483,6 @@ impl OhPlaylistClient { } } -#[derive(Debug, Clone)] -pub struct OhInfoClient { - pub control_url: String, - pub service_type: String, -} - impl OhInfoClient { pub fn new(control_url: String, service_type: String) -> Self { Self { @@ -402,11 +492,13 @@ impl OhInfoClient { } pub fn from_renderer_info(info: &RendererInfo) -> Result { - let control_url = info.oh_info_control_url() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create info control client".to_string()))?; + let control_url = info.oh_info_control_url().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create info control client".to_string()) + })?; - let service_type = info.oh_info_service_type() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create info service client".to_string()))?; + let service_type = info.oh_info_service_type().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create info service client".to_string()) + })?; Ok(OhInfoClient::new(control_url, service_type)) } @@ -472,11 +564,13 @@ impl OhTimeClient { } pub fn from_renderer_info(info: &RendererInfo) -> Result { - let control_url = info.oh_time_control_url() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create time control client".to_string()))?; + let control_url = info.oh_time_control_url().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create time control client".to_string()) + })?; - let service_type = info.oh_time_service_type() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create time service client".to_string()))?; + let service_type = info.oh_time_service_type().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create time service client".to_string()) + })?; Ok(OhTimeClient::new(control_url, service_type)) } @@ -528,15 +622,16 @@ impl OhVolumeClient { } pub fn from_renderer_info(info: &RendererInfo) -> Result { - let control_url = info.oh_volume_control_url() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create volume control client".to_string()))?; + let control_url = info.oh_volume_control_url().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create volume control client".to_string()) + })?; - let service_type = info.oh_volume_service_type() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create volume service client".to_string()))?; + let service_type = info.oh_volume_service_type().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create volume service client".to_string()) + })?; Ok(OhVolumeClient::new(control_url, service_type)) } - pub fn volume(&self) -> Result { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Volume", &[])?; let envelope = ensure_success("Volume", &call_result)?; @@ -596,11 +691,13 @@ impl OhRadioClient { } pub fn from_renderer_info(info: &RendererInfo) -> Result { - let control_url = info.oh_radio_control_url() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create radio control client".to_string()))?; + let control_url = info.oh_radio_control_url().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create radio control client".to_string()) + })?; - let service_type = info.oh_radio_service_type() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create radio service client".to_string()))?; + let service_type = info.oh_radio_service_type().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create radio service client".to_string()) + })?; Ok(OhRadioClient::new(control_url, service_type)) } @@ -646,15 +743,16 @@ impl OhProductClient { } pub fn from_renderer_info(info: &RendererInfo) -> Result { - let control_url = info.oh_product_control_url() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create product control client".to_string()))?; + let control_url = info.oh_product_control_url().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create product control client".to_string()) + })?; - let service_type = info.oh_product_service_type() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create product service client".to_string()))?; + let service_type = info.oh_product_service_type().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create product service client".to_string()) + })?; Ok(OhProductClient::new(control_url, service_type)) } - pub fn source_xml(&self) -> Result> { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "SourceXml", &[])?; @@ -799,6 +897,15 @@ pub fn parse_track_metadata_from_didl(xml: &str) -> Option { }) } +pub fn didl_id_from_metadata(xml: &str) -> Option { + if xml.trim().is_empty() { + return None; + } + + let parsed = pmodidl::parse_metadata::(xml).ok()?; + parsed.data.items.first().map(|item| item.id.clone()) +} + fn parse_track_list(payload: &str) -> Result, ControlPointError> { let trimmed = payload.trim(); if trimmed.is_empty() { @@ -810,7 +917,9 @@ fn parse_track_list(payload: &str) -> Result, ControlPointErro } else { let bytes = decode_base64(trimmed)?; let decoded = String::from_utf8(bytes).map_err(|err| { - ControlPointError::OpenHomeError(format!("TrackList payload not valid UTF-8 after base64 decode: {err}")) + ControlPointError::OpenHomeError(format!( + "TrackList payload not valid UTF-8 after base64 decode: {err}" + )) })?; (decoded, true) }; @@ -823,8 +932,9 @@ fn parse_track_list(payload: &str) -> Result, ControlPointErro ); let mut reader = std::io::Cursor::new(xml.as_bytes()); - let root = Element::parse(&mut reader) - .map_err(|err| ControlPointError::OpenHomeError(format!("Failed to parse OpenHome TrackList XML: {}", err)))?; + let root = Element::parse(&mut reader).map_err(|err| { + ControlPointError::OpenHomeError(format!("Failed to parse OpenHome TrackList XML: {}", err)) + })?; let mut entries = Vec::new(); for node in &root.children { @@ -856,9 +966,9 @@ fn parse_track_entry(elem: &Element) -> Result ))); } - let id = id_text - .parse::() - .map_err(|_| ControlPointError::OpenHomeError(format!("Invalid OpenHome Entry Id: {}", id_text)))?; + let id = id_text.parse::().map_err(|_| { + ControlPointError::OpenHomeError(format!("Invalid OpenHome Entry Id: {}", id_text)) + })?; let uri = extract_child_text_local(elem, "Uri")?; let metadata_xml = extract_child_text_optional_local(elem, "Metadata")?.unwrap_or_default(); diff --git a/pmocontrol/src/rendering_control_client.rs b/pmocontrol/src/upnp_clients/rendering_control_client.rs similarity index 100% rename from pmocontrol/src/rendering_control_client.rs rename to pmocontrol/src/upnp_clients/rendering_control_client.rs diff --git a/pmocontrol/src/upnp_renderer.rs b/pmocontrol/src/upnp_renderer.rs deleted file mode 100644 index c460d160..00000000 --- a/pmocontrol/src/upnp_renderer.rs +++ /dev/null @@ -1,317 +0,0 @@ -use std::sync::{Arc, RwLock}; - -use anyhow::{Result, anyhow}; - -use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus}; -use crate::connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo}; -use crate::errors::ControlPointError; -use crate::rendering_control_client::RenderingControlClient; -use crate::{ - AvTransportClient, DeviceRegistry, PlaybackPosition, PlaybackState, PositionInfo, RendererInfo, - TransportControl, VolumeControl, -}; - -/// High-level handle representing a renderer and its optional AVTransport client. -#[derive(Clone, Debug)] -pub struct UpnpRenderer { - // registry: Arc>, - avtransport: Option, - rendering_control: Option, - connection_manager: Option, - has_avtransport_set_next: bool, -} - -impl UpnpRenderer { - pub fn has_avtransport(&self) -> bool { - self.avtransport.is_some() - } - - pub fn has_rendering_control(&self) -> bool { - self.rendering_control.is_some() - } - - pub fn has_connection_manager(&self) -> bool { - self.connection_manager.is_some() - } - - pub fn avtransport(&self) -> Result<&AvTransportClient, ControlPointError> { - self.avtransport.as_ref().ok_or_else(|| { - ControlPointError::upnp_operation_not_supported("AvTransport", "Renderer") - }) - } - - pub fn rendering_control(&self) -> Result<&RenderingControlClient, ControlPointError> { - self.rendering_control.as_ref().ok_or_else(|| { - ControlPointError::upnp_operation_not_supported("RenderingControl", "Renderer") - }) - } - - pub fn connection_manager(&self) -> Result<&ConnectionManagerClient, ControlPointError> { - self.connection_manager - .as_ref() - .ok_or_else(|| ControlPointError::upnp_operation_not_supported("ConnectionManager", "Renderer")) - } - - pub fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { - let avt = self.avtransport()?; - avt.set_av_transport_uri(uri, meta)?; - avt.play(0, "1") - } - - // /// Best-effort attempt to configure the next URI via AVTransport SetNextAVTransportURI. - // pub fn set_next_uri(&self, next_uri: &str, next_meta: &str) -> Result<()> { - // if !self.has_avtransport_set_next { - // return Err(op_not_supported("SetNextAVTransportURI", "AVTransport")); - // } - - // let client = self.avtransport()?; - // let result = client.set_next_av_transport_uri(next_uri, next_meta); - - // if result.is_ok() { - // let mut reg = self.registry.write().unwrap(); - // reg.mark_renderer_supports_set_next(&self.info.id); - // } - - // result - // } - - pub fn pause(&self) -> Result<(), ControlPointError> { - let avt = self.avtransport()?; - avt.pause(0) - } - - pub fn stop(&self) -> Result<(), ControlPointError> { - let avt = self.avtransport()?; - avt.stop(0) - } - - pub fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { - let avt = self.avtransport()?; - avt.seek(0, "REL_TIME", hhmmss) - } - - pub fn get_master_volume(&self) -> Result { - let rc = self.rendering_control()?; - rc.get_volume(0, "Master") - } - - pub fn set_master_volume(&self, volume: u16) -> Result<(), ControlPointError> { - let rc = self.rendering_control()?; - rc.set_volume(0, "Master", volume) - } - - pub fn get_master_mute(&self) -> Result { - let rc = self.rendering_control()?; - rc.get_mute(0, "Master") - } - - pub fn set_master_mute(&self, mute: bool) -> Result<(), ControlPointError> { - let rc = self.rendering_control()?; - rc.set_mute(0, "Master", mute) - } - - pub fn protocol_info(&self) -> Result { - let cm = self.connection_manager()?; - cm.get_protocol_info() - } - - pub fn connection_ids(&self) -> Result, ControlPointError> { - let cm = self.connection_manager()?; - cm.get_current_connection_ids() - } - - pub fn connection_info(&self, connection_id: i32) -> Result { - let cm = self.connection_manager()?; - cm.get_current_connection_info(connection_id) - } - - pub fn from_info(info: &RendererInfo) -> Self { - let avtransport = match ( - info.avtransport_control_url(), - info.avtransport_service_type(), - ) { - (Some(url), Some(service)) => Some(AvTransportClient::new(url, service)), - _ => None, - }; - - let rendering_control = match ( - info.rendering_control_control_url(), - info.rendering_control_service_type(), - ) { - (Some(url), Some(service)) => Some(RenderingControlClient::new(url, service)), - _ => None, - }; - - let connection_manager = match ( - info.connection_manager_control_url(), - info.connection_manager_service_type(), - ) { - (Some(url), Some(service)) => Some(ConnectionManagerClient::new(url, service)), - _ => None, - }; - - Self { - // registry: Arc::clone(registry), - avtransport, - rendering_control, - connection_manager, - has_avtransport_set_next: info.capabilities().has_avtransport_set_next(), - } - } -} - -/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`]. -/// -/// Cette impl se base sur AVTransport (InstanceID = 0). -impl TransportControl for UpnpRenderer { - fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { - let avt = self.avtransport()?; - avt.set_av_transport_uri(uri, meta)?; - avt.play(0, "1") - } - - fn play(&self) -> Result<(), ControlPointError> { - let avt = self.avtransport()?; - avt.play(0, "1") - } - - fn pause(&self) -> Result<(), ControlPointError> { - let avt = self.avtransport()?; - avt.pause(0) - } - - fn stop(&self) -> Result<(), ControlPointError> { - let avt = self.avtransport()?; - avt.stop(0) - } - - fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { - let avt = self.avtransport()?; - avt.seek(0, "REL_TIME", hhmmss) - } -} - -/// Implémentation UPnP RenderingControl de `VolumeControl` pour [`UpnpRenderer`]. -/// -/// Cette impl se base sur le channel "Master" (InstanceID = 0). -impl VolumeControl for UpnpRenderer { - fn volume(&self) -> Result { - self.get_master_volume() - } - - fn set_volume(&self, v: u16) -> Result<(), ControlPointError> { - self.set_master_volume(v) - } - - fn mute(&self) -> Result { - self.get_master_mute() - } - - fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { - self.set_master_mute(m) - } -} - -/// Implémentation UPnP AV de `PlaybackStatus` pour [`UpnpRenderer`]. -/// -/// Utilise AVTransport::GetTransportInfo(InstanceID=0). -impl PlaybackStatus for UpnpRenderer { - fn playback_state(&self) -> Result { - let avt = self.avtransport()?; - let info = avt.get_transport_info(0)?; - Ok(PlaybackState::from_upnp_state( - &info.current_transport_state, - )) - } -} - -impl PlaybackPosition for UpnpRenderer { - fn playback_position(&self) -> Result { - let avt = self.avtransport()?; - let raw: PositionInfo = avt.get_position_info(0)?; - - Ok(PlaybackPositionInfo { - track: Some(raw.track), - rel_time: raw.rel_time, - abs_time: raw.abs_time, - track_duration: raw.track_duration, - track_metadata: raw.track_metadata, - track_uri: raw.track_uri, - }) - } -} - -// #[cfg(test)] -// mod tests { -// use super::*; -// use crate::model::{RendererCapabilities, RendererProtocol}; -// use crate::registry::{DeviceRegistry, DeviceUpdate}; -// use std::sync::{Arc, RwLock}; -// use std::time::SystemTime; - -// // fn renderer_info(id_suffix: &str, with_avtransport: bool) -> RendererInfo { -// // RendererInfo { -// // id: RendererId(format!("renderer-{id_suffix}")), -// // udn: format!("uuid:renderer-{id_suffix}"), -// // friendly_name: format!("Renderer {id_suffix}"), -// // model_name: "Model".into(), -// // manufacturer: "Manufacturer".into(), -// // protocol: RendererProtocol::UpnpAvOnly, -// // capabilities: RendererCapabilities { -// // has_avtransport: with_avtransport, -// // ..RendererCapabilities::default() -// // }, -// // location: "http://127.0.0.1/device.xml".into(), -// // server_header: "TestServer/1.0".into(), -// // avtransport_service_type: with_avtransport -// // .then(|| "urn:schemas-upnp-org:service:AVTransport:1".into()), -// // avtransport_control_url: with_avtransport -// // .then(|| "http://127.0.0.1/avtransport".into()), -// // rendering_control_service_type: None, -// // rendering_control_control_url: None, -// // connection_manager_service_type: None, -// // connection_manager_control_url: None, -// // oh_playlist_service_type: None, -// // oh_playlist_control_url: None, -// // oh_playlist_event_sub_url: None, -// // oh_info_service_type: None, -// // oh_info_control_url: None, -// // oh_info_event_sub_url: None, -// // oh_time_service_type: None, -// // oh_time_control_url: None, -// // oh_time_event_sub_url: None, -// // oh_volume_service_type: None, -// // oh_volume_control_url: None, -// // oh_radio_service_type: None, -// // oh_radio_control_url: None, -// // oh_product_service_type: None, -// // oh_product_control_url: None, -// // } -// // } - -// fn registry_with_renderer(info: RendererInfo) -> Arc> { -// let mut registry = DeviceRegistry::new(); -// registry.apply_update(DeviceUpdate::RendererOnline(info)); -// Arc::new(RwLock::new(registry)) -// } - -// #[test] -// fn renderer_without_avtransport() { -// let info = renderer_info("no-avt", false); -// let registry = registry_with_renderer(info.clone()); -// let renderer = UpnpRenderer::from_registry(info, ®istry); - -// assert_eq!(renderer.has_avtransport(), false); -// assert_eq!(renderer.id().0, "renderer-no-avt"); -// } - -// #[test] -// fn renderer_with_avtransport() { -// let info = renderer_info("with-avt", true); -// let registry = registry_with_renderer(info.clone()); -// let renderer = UpnpRenderer::from_registry(info, ®istry); - -// assert!(renderer.has_avtransport()); -// assert_eq!(renderer.friendly_name(), "Renderer with-avt"); -// } -// } From 5320545d563d8c1607805070122d51d8977ba536 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sat, 3 Jan 2026 08:19:23 +0100 Subject: [PATCH 4/4] =?UTF-8?q?Debugging=20de=20la=20nouvelle=20impl=C3=A9?= =?UTF-8?q?mentation.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../webapp/src/services/openhomePlaylist.ts | 55 - .../webapp/src/services/pmocontrol/types.ts | 290 +- pmoapp/webapp/src/views/RendererView.vue | 616 ++-- pmocontrol/examples/queue_pmomusic_demo.rs | 539 ---- pmocontrol/src/arylic_client/mod.rs | 76 +- pmocontrol/src/control_point.rs | 2548 +++-------------- pmocontrol/src/control_point_corrections.md | 130 + .../src/discovery/chromecast_discovery.rs | 8 +- pmocontrol/src/discovery/manager.rs | 11 +- pmocontrol/src/discovery/mod.rs | 30 +- pmocontrol/src/discovery/upnp_discovery.rs | 42 +- pmocontrol/src/errors.rs | 19 +- pmocontrol/src/identity.rs | 31 +- pmocontrol/src/lib.rs | 29 +- pmocontrol/src/linkplay_client/mod.rs | 62 +- pmocontrol/src/media_server.rs | 18 +- pmocontrol/src/media_server_events.rs | 286 +- pmocontrol/src/model.rs | 45 +- pmocontrol/src/music_renderer/arylic_tcp.rs | 90 +- pmocontrol/src/music_renderer/mod.rs | 2 +- .../src/music_renderer/musicrenderer.rs | 250 +- .../src/music_renderer/openhome_renderer.rs | 31 +- pmocontrol/src/online.rs | 12 +- pmocontrol/src/openapi.rs | 90 +- pmocontrol/src/pmoserver_ext.rs | 471 +-- pmocontrol/src/queue/mod.rs | 16 +- pmocontrol/src/queue/openhome.rs | 13 +- pmocontrol/src/registry.rs | 26 +- pmocontrol/src/sse.rs | 109 +- .../src/upnp_clients/avtransport_client.rs | 33 +- .../upnp_clients/connection_manager_client.rs | 88 +- .../upnp_clients/rendering_control_client.rs | 22 +- pmodidl/src/lib.rs | 4 +- tools/check_a_file.sh | 30 + 34 files changed, 1989 insertions(+), 4133 deletions(-) delete mode 100644 pmoapp/webapp/src/services/openhomePlaylist.ts delete mode 100644 pmocontrol/examples/queue_pmomusic_demo.rs create mode 100644 pmocontrol/src/control_point_corrections.md create mode 100755 tools/check_a_file.sh diff --git a/pmoapp/webapp/src/services/openhomePlaylist.ts b/pmoapp/webapp/src/services/openhomePlaylist.ts deleted file mode 100644 index 892fccd4..00000000 --- a/pmoapp/webapp/src/services/openhomePlaylist.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { - OpenHomePlaylistAddRequest, - OpenHomePlaylistSnapshot, -} from '@/services/pmocontrol/types' - -const API_BASE = '/api/control' - -export async function getOpenHomePlaylist(rendererId: string): Promise { - const resp = await fetch( - `${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist`, - ) - if (!resp.ok) { - throw new Error(`Failed to fetch OpenHome playlist: ${resp.status} ${resp.statusText}`) - } - return resp.json() -} - -export async function clearOpenHomePlaylist(rendererId: string): Promise { - const resp = await fetch( - `${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist/clear`, - { method: 'POST' }, - ) - if (!resp.ok) { - throw new Error(`Failed to clear OpenHome playlist: ${resp.status} ${resp.statusText}`) - } -} - -export async function addOpenHomeTrack( - rendererId: string, - payload: OpenHomePlaylistAddRequest, -): Promise { - const resp = await fetch( - `${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist/add`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }, - ) - if (!resp.ok) { - throw new Error(`Failed to add track to OpenHome playlist: ${resp.status} ${resp.statusText}`) - } -} - -export async function playOpenHomeTrack(rendererId: string, trackId: number): Promise { - const resp = await fetch( - `${API_BASE}/renderers/${encodeURIComponent( - rendererId, - )}/oh/playlist/play/${encodeURIComponent(trackId.toString())}`, - { method: 'POST' }, - ) - if (!resp.ok) { - throw new Error(`Failed to play OpenHome track ${trackId}: ${resp.status} ${resp.statusText}`) - } -} diff --git a/pmoapp/webapp/src/services/pmocontrol/types.ts b/pmoapp/webapp/src/services/pmocontrol/types.ts index 519ab02c..046108e5 100644 --- a/pmoapp/webapp/src/services/pmocontrol/types.ts +++ b/pmoapp/webapp/src/services/pmocontrol/types.ts @@ -5,55 +5,65 @@ // RENDERERS // ============================================================================ -export type RendererProtocolSummary = 'upnp' | 'openhome' | 'hybrid' +export type RendererProtocolSummary = + | "upnp" + | "openhome" + | "hybrid" + | "chromecast"; export interface RendererCapabilitiesSummary { - has_avtransport: boolean - has_avtransport_set_next: boolean - has_rendering_control: boolean - has_connection_manager: boolean - has_linkplay_http: boolean - has_arylic_tcp: boolean - has_oh_playlist: boolean - has_oh_volume: boolean - has_oh_info: boolean - has_oh_time: boolean - has_oh_radio: boolean + has_avtransport: boolean; + has_avtransport_set_next: boolean; + has_rendering_control: boolean; + has_connection_manager: boolean; + has_linkplay_http: boolean; + has_arylic_tcp: boolean; + has_oh_playlist: boolean; + has_oh_volume: boolean; + has_oh_info: boolean; + has_oh_time: boolean; + has_oh_radio: boolean; } export interface RendererSummary { - id: string - friendly_name: string - model_name: string - protocol: RendererProtocolSummary - capabilities: RendererCapabilitiesSummary - online: boolean + id: string; + friendly_name: string; + model_name: string; + protocol: RendererProtocolSummary; + capabilities: RendererCapabilitiesSummary; + online: boolean; } export interface RendererState { - id: string - friendly_name: string - transport_state: 'PLAYING' | 'PAUSED' | 'STOPPED' | 'TRANSITIONING' | 'NO_MEDIA' | 'UNKNOWN' - position_ms: number | null - duration_ms: number | null - volume: number | null // 0-100 - mute: boolean | null - queue_len: number - attached_playlist: AttachedPlaylistInfo | null - current_track: CurrentTrackMetadata | null + id: string; + friendly_name: string; + transport_state: + | "PLAYING" + | "PAUSED" + | "STOPPED" + | "TRANSITIONING" + | "NO_MEDIA" + | "UNKNOWN"; + position_ms: number | null; + duration_ms: number | null; + volume: number | null; // 0-100 + mute: boolean | null; + queue_len: number; + attached_playlist: AttachedPlaylistInfo | null; + current_track: CurrentTrackMetadata | null; } export interface CurrentTrackMetadata { - title: string | null - artist: string | null - album: string | null - album_art_uri: string | null + title: string | null; + artist: string | null; + album: string | null; + album_art_uri: string | null; } export interface AttachedPlaylistInfo { - server_id: string - container_id: string - has_seen_update: boolean + server_id: string; + container_id: string; + has_seen_update: boolean; } // ============================================================================ @@ -61,53 +71,26 @@ export interface AttachedPlaylistInfo { // ============================================================================ export interface QueueItem { - index: number // 0-based - uri: string - title: string | null - artist: string | null - album: string | null - album_art_uri: string | null - server_id: string | null - object_id: string | null + index: number; // 0-based + uri: string; + title: string | null; + artist: string | null; + album: string | null; + album_art_uri: string | null; + server_id: string | null; + object_id: string | null; } export interface QueueSnapshot { - renderer_id: string - items: QueueItem[] - current_index: number | null // Index de la piste en cours (null si rien en lecture) + renderer_id: string; + items: QueueItem[]; + current_index: number | null; // Index de la piste en cours (null si rien en lecture) } export interface FullRendererSnapshot { - state: RendererState - queue: QueueSnapshot - binding: AttachedPlaylistInfo | null -} - -// ============================================================================ -// OPENHOME PLAYLIST -// ============================================================================ - -export interface OpenHomePlaylistTrack { - id: number - uri: string - title: string | null - artist: string | null - album: string | null - album_art_uri: string | null -} - -export interface OpenHomePlaylistSnapshot { - renderer_id: string - current_id: number | null - current_index: number | null - tracks: OpenHomePlaylistTrack[] -} - -export interface OpenHomePlaylistAddRequest { - uri: string - metadata: string - after_id?: number | null - play?: boolean + state: RendererState; + queue: QueueSnapshot; + binding: AttachedPlaylistInfo | null; } // ============================================================================ @@ -115,26 +98,26 @@ export interface OpenHomePlaylistAddRequest { // ============================================================================ export interface MediaServerSummary { - id: string - friendly_name: string - model_name: string - online: boolean + id: string; + friendly_name: string; + model_name: string; + online: boolean; } export interface ContainerEntry { - id: string - title: string - class: string // UPnP class - is_container: boolean - child_count: number | null - artist: string | null - album: string | null - album_art_uri: string | null // ⚠️ Nom exact: album_art_uri + id: string; + title: string; + class: string; // UPnP class + is_container: boolean; + child_count: number | null; + artist: string | null; + album: string | null; + album_art_uri: string | null; // ⚠️ Nom exact: album_art_uri } export interface BrowseResponse { - container_id: string - entries: ContainerEntry[] + container_id: string; + entries: ContainerEntry[]; } // ============================================================================ @@ -142,26 +125,26 @@ export interface BrowseResponse { // ============================================================================ export interface VolumeSetRequest { - volume: number // 0-100 + volume: number; // 0-100 } export interface AttachPlaylistRequest { - server_id: string - container_id: string - auto_play?: boolean + server_id: string; + container_id: string; + auto_play?: boolean; } export interface PlayContentRequest { - server_id: string - object_id: string + server_id: string; + object_id: string; } export interface SuccessResponse { - message: string + message: string; } export interface ErrorResponse { - error: string + error: string; } // ============================================================================ @@ -169,40 +152,105 @@ export interface ErrorResponse { // ============================================================================ export type RendererEventPayload = - | { type: 'state_changed'; renderer_id: string; state: string; timestamp: string } - | { type: 'position_changed'; renderer_id: string; track: number | null; rel_time: string | null; track_duration: string | null; timestamp: string } - | { type: 'volume_changed'; renderer_id: string; volume: number; timestamp: string } - | { type: 'mute_changed'; renderer_id: string; mute: boolean; timestamp: string } - | { type: 'metadata_changed'; renderer_id: string; title: string | null; artist: string | null; album: string | null; album_art_uri: string | null; timestamp: string } - | { type: 'queue_updated'; renderer_id: string; queue_length: number; timestamp: string } - | { type: 'binding_changed'; renderer_id: string; server_id: string | null; container_id: string | null; timestamp: string } - | { type: 'online'; renderer_id: string; friendly_name: string; model_name: string; manufacturer: string; timestamp: string } - | { type: 'offline'; renderer_id: string; timestamp: string } + | { + type: "state_changed"; + renderer_id: string; + state: string; + timestamp: string; + } + | { + type: "position_changed"; + renderer_id: string; + track: number | null; + rel_time: string | null; + track_duration: string | null; + timestamp: string; + } + | { + type: "volume_changed"; + renderer_id: string; + volume: number; + timestamp: string; + } + | { + type: "mute_changed"; + renderer_id: string; + mute: boolean; + timestamp: string; + } + | { + type: "metadata_changed"; + renderer_id: string; + title: string | null; + artist: string | null; + album: string | null; + album_art_uri: string | null; + timestamp: string; + } + | { + type: "queue_updated"; + renderer_id: string; + queue_length: number; + timestamp: string; + } + | { + type: "binding_changed"; + renderer_id: string; + server_id: string | null; + container_id: string | null; + timestamp: string; + } + | { + type: "online"; + renderer_id: string; + friendly_name: string; + model_name: string; + manufacturer: string; + timestamp: string; + } + | { type: "offline"; renderer_id: string; timestamp: string }; export type MediaServerEventPayload = - | { type: 'global_updated'; server_id: string; system_update_id: number | null; timestamp: string } - | { type: 'containers_updated'; server_id: string; container_ids: string[]; timestamp: string } - | { type: 'online'; server_id: string; friendly_name: string; model_name: string; manufacturer: string; timestamp: string } - | { type: 'offline'; server_id: string; timestamp: string } + | { + type: "global_updated"; + server_id: string; + system_update_id: number | null; + timestamp: string; + } + | { + type: "containers_updated"; + server_id: string; + container_ids: string[]; + timestamp: string; + } + | { + type: "online"; + server_id: string; + friendly_name: string; + model_name: string; + manufacturer: string; + timestamp: string; + } + | { type: "offline"; server_id: string; timestamp: string }; export type UnifiedEventPayload = - | { category: 'renderer' } & RendererEventPayload - | { category: 'media_server' } & MediaServerEventPayload + | ({ category: "renderer" } & RendererEventPayload) + | ({ category: "media_server" } & MediaServerEventPayload); // ============================================================================ // MÉTADONNÉES PISTE // ============================================================================ export interface TrackMetadata { - title: string | null - artist: string | null - album: string | null - album_art_uri: string | null - duration_ms: number | null + title: string | null; + artist: string | null; + album: string | null; + album_art_uri: string | null; + duration_ms: number | null; } export interface PositionInfo { - track: number | null - rel_time: string | null // Format HH:MM:SS - track_duration: string | null // Format HH:MM:SS + track: number | null; + rel_time: string | null; // Format HH:MM:SS + track_duration: string | null; // Format HH:MM:SS } diff --git a/pmoapp/webapp/src/views/RendererView.vue b/pmoapp/webapp/src/views/RendererView.vue index 03048da5..c9ddab45 100644 --- a/pmoapp/webapp/src/views/RendererView.vue +++ b/pmoapp/webapp/src/views/RendererView.vue @@ -1,516 +1,302 @@ diff --git a/pmocontrol/examples/queue_pmomusic_demo.rs b/pmocontrol/examples/queue_pmomusic_demo.rs deleted file mode 100644 index 81b9bc69..00000000 --- a/pmocontrol/examples/queue_pmomusic_demo.rs +++ /dev/null @@ -1,539 +0,0 @@ -//! End-to-end queue demo that prefers the PMOMusic media server and exercises -//! the ControlPoint playback queue API. - -use std::collections::VecDeque; -use std::env; -use std::process; -use std::thread; -use std::time::Duration; - -use anyhow::{Context, Result}; -use pmocontrol::model::TrackMetadata; -use pmocontrol::{ - ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer, - MusicRendererBackend, UpnpMediaServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo, -}; - -const DEFAULT_TIMEOUT_SECS: u64 = 5; -const DEFAULT_DISCOVERY_SECS: u64 = 5; -const DEFAULT_MAX_TRACKS: usize = 3; -const MONITOR_DURATION_SECS: u64 = 600; -const MONITOR_POLL_SECS: u64 = 5; -const MAX_BROWSE_DEPTH: usize = 2; - -fn main() -> Result<()> { - let _ = tracing_subscriber::fmt::try_init(); - let config = CliConfig::parse_from_env().unwrap_or_else(|err| { - eprintln!("Error parsing arguments: {err}"); - print_usage_and_exit(); - }); - - if config.max_tracks == 0 { - eprintln!("--max-tracks must be >= 1"); - process::exit(1); - } - - println!( - "Starting queue_pmomusic_demo with timeout={}s discovery={}s max_tracks={}", - config.timeout_secs, config.discovery_secs, config.max_tracks - ); - - // ControlPoint::spawn starts the HttpXmlDescriptionProvider + DiscoveryManager combo. - let control_point = - ControlPoint::spawn(config.timeout_secs).context("Failed to start control point")?; - - println!( - "Discovery running for {} seconds before selecting devices...", - config.discovery_secs - ); - thread::sleep(Duration::from_secs(config.discovery_secs)); - - let registry = control_point.registry(); - let (renderer, server_info) = { - let reg = registry.read().expect("registry poisoned"); - let renderer_candidates: Vec = reg - .list_renderers() - .into_iter() - .filter(|info| !is_pmomusic_renderer(info)) - .collect(); - let renderer = pick_renderer(renderer_candidates) - .unwrap_or_else(|| no_renderer_and_exit("No suitable renderer found after discovery.")); - let server = pick_media_server(reg.list_servers()) - .unwrap_or_else(|| no_server_and_exit("No media server with ContentDirectory.")); - (renderer, server) - }; - - println!( - "Selected renderer \"{}\" (protocol={:?}, id={})", - renderer.friendly_name, renderer.protocol, renderer.id.0 - ); - println!( - "Selected media server \"{}\" at {} (id={})", - server_info.friendly_name, server_info.location, server_info.id.0 - ); - - let renderer_instance = MusicRendererBackend::from_renderer_info(renderer.clone(), ®istry) - .expect("Selected renderer is not usable by MusicRenderer façade"); - let supports_set_next = renderer_instance - .as_upnp() - .map(|upnp| upnp.supports_set_next()) - .unwrap_or(false); - println!( - "Renderer \"{}\": AVTransport present = {}, SetNextAVTransportURI supported = {}", - renderer.friendly_name, renderer.capabilities.has_avtransport, supports_set_next - ); - - let timeout = Duration::from_secs(config.timeout_secs); - let server = - UpnpMediaServer::from_info(&server_info, timeout).context("Failed to init MusicServer")?; - - let root_entries = server - .browse_root() - .context("Failed to browse ContentDirectory root")?; - println!("Root returned {} entries", root_entries.len()); - - // Try to find a playlist container first - let (playback_items, bound_container_id) = - collect_playable_items_with_binding(&server, &root_entries, config.max_tracks) - .context("Failed to derive playable items from ContentDirectory root/children")?; - - if playback_items.is_empty() { - println!("No playable tracks were found on the selected server."); - process::exit(1); - } - - println!( - "Discovered {} playable items; enqueuing…", - playback_items.len() - ); - - if let Some(ref container_id) = bound_container_id { - println!( - "Found playlist container '{}' to bind queue to", - container_id - ); - } else { - println!("No playlist container found; queue will not be bound to server"); - } - - let mut planned_queue: VecDeque; - let renderer_id = renderer.id.clone(); - control_point - .clear_queue(&renderer_id) - .context("Failed to clear playback queue")?; - control_point - .enqueue_items(&renderer_id, playback_items) - .context("Failed to enqueue playback items")?; - - // Attach queue to playlist container if we found one - if let Some(container_id) = bound_container_id { - control_point - .attach_queue_to_playlist(&renderer_id, server_info.id.clone(), container_id.clone()) - .context("Failed to attach queue to playlist container")?; - println!( - "✓ Queue attached to playlist container {} on server {}", - container_id, server_info.friendly_name - ); - } - - let snapshot = control_point - .get_queue_snapshot(&renderer_id) - .context("Failed to snapshot queue after enqueue")?; - print_queue_snapshot(&snapshot); - planned_queue = snapshot.clone().into(); - - control_point - .play_next_from_queue(&renderer_id) - .context("Failed to start playback from queue")?; - let mut current_track = planned_queue.pop_front(); - let remaining = control_point - .get_queue_snapshot(&renderer_id) - .context("Failed to snapshot queue after play_next_from_queue")?; - planned_queue = remaining.clone().into(); - println!( - "Playback started on \"{}\"; {} tracks remaining in queue.", - renderer.friendly_name, - remaining.len() - ); - - println!( - "Monitoring queue auto-advance for {} seconds (poll every {}s)…", - MONITOR_DURATION_SECS, MONITOR_POLL_SECS - ); - - // Subscribe to media server events to observe playlist updates - let media_event_rx = control_point.subscribe_media_server_events(); - - let poll_count = MONITOR_DURATION_SECS / MONITOR_POLL_SECS; - for tick in 0..poll_count { - thread::sleep(Duration::from_secs(MONITOR_POLL_SECS)); - - // Drain any MediaServerEvent that arrived since last poll - loop { - match media_event_rx.try_recv() { - Ok(MediaServerEvent::GlobalUpdated { - server_id, - system_update_id, - }) => { - println!( - " 📢 MediaServer {} global update (SystemUpdateID={:?})", - server_id.0, system_update_id - ); - } - Ok(MediaServerEvent::ContainersUpdated { - server_id, - container_ids, - }) => { - println!( - " 📢 MediaServer {} containers updated: {:?}", - server_id.0, container_ids - ); - - // Check if our 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!( - " 🔄 Bound playlist container '{}' was updated, queue will refresh automatically", - bound_container - ); - } - } - } - Err(_) => break, // No more events, continue with normal monitoring - } - } - - let snapshot = control_point - .get_queue_snapshot(&renderer_id) - .context("Queue snapshot failed during monitoring loop")?; - let new_plan: VecDeque = snapshot.clone().into(); - if planned_queue.len() > new_plan.len() { - let removed = planned_queue.len() - new_plan.len(); - for _ in 0..removed { - current_track = planned_queue.pop_front(); - } - } - planned_queue = new_plan; - - let playback_info = control_point - .music_renderer_by_id(&renderer_id) - .and_then(|renderer| renderer.playback_position().ok()); - - let title = current_track_title(current_track.as_ref()); - if let Some(info) = playback_info { - println!( - "[tick {tick}] Queue length = {} | now playing: {} [{}]", - snapshot.len(), - title, - format_playback_position(&info) - ); - } else { - println!( - "[tick {tick}] Queue length = {} | now playing: {} [position unavailable]", - snapshot.len(), - title - ); - } - } - - println!("Monitoring finished, exiting."); - Ok(()) -} - -#[derive(Debug)] -struct CliConfig { - timeout_secs: u64, - discovery_secs: u64, - max_tracks: usize, -} - -impl CliConfig { - fn parse_from_env() -> Result { - let mut timeout_secs = DEFAULT_TIMEOUT_SECS; - let mut discovery_secs = DEFAULT_DISCOVERY_SECS; - let mut max_tracks = DEFAULT_MAX_TRACKS; - - let mut args = env::args().skip(1); - while let Some(arg) = args.next() { - match arg.as_str() { - "--timeout-secs" => { - let value = args - .next() - .ok_or_else(|| "--timeout-secs requires a value".to_string())?; - timeout_secs = value.parse().map_err(|err| { - format!("Invalid value for --timeout-secs ({value}): {err}") - })?; - } - "--discovery-secs" => { - let value = args - .next() - .ok_or_else(|| "--discovery-secs requires a value".to_string())?; - discovery_secs = value.parse().map_err(|err| { - format!("Invalid value for --discovery-secs ({value}): {err}") - })?; - } - "--max-tracks" => { - let value = args - .next() - .ok_or_else(|| "--max-tracks requires a value".to_string())?; - max_tracks = value.parse().map_err(|err| { - format!("Invalid value for --max-tracks ({value}): {err}") - })?; - } - "--help" | "-h" => { - print_usage_and_exit(); - } - unknown => { - return Err(format!("Unknown argument: {unknown}")); - } - } - } - - Ok(Self { - timeout_secs, - discovery_secs, - max_tracks, - }) - } -} - -fn pick_renderer(renderers: Vec) -> Option { - let mut candidates: Vec = renderers; - - if candidates.is_empty() { - return None; - } - - println!("Renderer candidates:"); - for (idx, info) in candidates.iter().enumerate() { - println!( - " [{}] {} | model={} | location={} | online={}", - idx, info.friendly_name, info.model_name, info.location, info.online - ); - } - - let selected = candidates.remove(0); - println!( - "Automatically selecting renderer index 0: {}", - selected.friendly_name - ); - Some(selected) -} - -fn pick_media_server(servers: Vec) -> Option { - let mut candidates: Vec = servers - .into_iter() - .filter(|info| info.has_content_directory) - .filter(|info| info.content_directory_control_url.is_some()) - .collect(); - - if candidates.is_empty() { - return None; - } - - if let Some(idx) = candidates.iter().position(is_pmomusic_server) { - let server = candidates.remove(idx); - println!( - "Preferring PMOMusic server \"{}\" (server header: {}).", - server.friendly_name, server.server_header - ); - Some(server) - } else { - println!("No PMOMusic server discovered; falling back to first ContentDirectory server."); - Some(candidates.remove(0)) - } -} - -fn is_pmomusic_server(info: &UpnpMediaServer) -> bool { - let name = info.friendly_name.to_ascii_lowercase(); - let header = info.server_header.to_ascii_lowercase(); - name.contains("pmomusic") || header.contains("pmomusic") -} - -fn is_pmomusic_renderer(info: &RendererInfo) -> bool { - info.friendly_name - .to_ascii_lowercase() - .contains("pmomusic audio renderer") -} - -fn collect_playable_items_with_binding( - server: &UpnpMediaServer, - entries: &[MediaEntry], - max_tracks: usize, -) -> Result<(Vec, Option)> { - // First, try to find a playlist container - let playlist_container = entries.iter().find(|entry| { - entry.is_container - && entry - .class - .to_ascii_lowercase() - .contains("object.container.playlistcontainer") - }); - - if let Some(playlist) = playlist_container { - println!( - "Found playlist container: '{}' (id: {}, class: {})", - playlist.title, playlist.id, playlist.class - ); - - // Browse the playlist container - match server.browse_children(&playlist.id, 0, max_tracks as u32) { - Ok(children) => { - let mut items = Vec::new(); - for entry in &children { - if let Some(item) = playback_item_from_entry(server, entry) { - items.push(item); - if items.len() >= max_tracks { - break; - } - } - } - - if !items.is_empty() { - return Ok((items, Some(playlist.id.clone()))); - } - - println!( - "Playlist container '{}' is empty, falling back to general browse", - playlist.title - ); - } - Err(err) => { - println!( - "Failed to browse playlist container '{}': {}, falling back", - playlist.title, err - ); - } - } - } else { - println!("No playlist container found in root entries, using fallback"); - } - - // Fallback: collect from any container/item - let mut items = Vec::new(); - for entry in entries { - gather_items_from_entry(server, entry, max_tracks, 0, &mut items)?; - if items.len() >= max_tracks { - break; - } - } - Ok((items, None)) -} - -fn gather_items_from_entry( - server: &UpnpMediaServer, - entry: &MediaEntry, - max_tracks: usize, - depth: usize, - out: &mut Vec, -) -> Result<()> { - if out.len() >= max_tracks { - return Ok(()); - } - - if entry.is_container { - if depth >= MAX_BROWSE_DEPTH { - return Ok(()); - } - - match server.browse_children(&entry.id, 0, 50) { - Ok(children) => { - for child in children { - gather_items_from_entry(server, &child, max_tracks, depth + 1, out)?; - if out.len() >= max_tracks { - break; - } - } - } - Err(err) => { - tracing::warn!( - container_id = entry.id.as_str(), - error = %err, - "Failed to browse child container" - ); - } - } - return Ok(()); - } - - if let Some(item) = playback_item_from_entry(server, entry) { - out.push(item); - } - Ok(()) -} - -fn playback_item_from_entry(server: &UpnpMediaServer, entry: &MediaEntry) -> Option { - if entry.title.to_ascii_lowercase().contains("live stream") { - return None; - } - let resource = entry.resources.iter().find(|res| res.is_audio())?; - let metadata = TrackMetadata { - title: Some(entry.title.clone()), - artist: entry.artist.clone(), - album: entry.album.clone(), - genre: entry.genre.clone(), - album_art_uri: entry.album_art_uri.clone(), - date: entry.date.clone(), - track_number: entry.track_number.clone(), - creator: entry.creator.clone(), - }; - Some(PlaybackItem { - media_server_id: server.id().clone(), - didl_id: entry.id.clone(), - uri: resource.uri.clone(), - protocol_info: resource.protocol_info.clone(), - metadata: Some(metadata), - }) -} - -fn print_queue_snapshot(items: &[PlaybackItem]) { - println!("Current queue snapshot ({} items):", items.len()); - for (idx, item) in items.iter().enumerate() { - let label = item - .metadata - .as_ref() - .and_then(|meta| meta.title.as_deref()) - .unwrap_or_else(|| item.uri.as_str()); - println!(" [{}] {} -> {}", idx, label, item.uri); - } - if items.is_empty() { - println!(" "); - } -} - -fn current_track_title(item: Option<&PlaybackItem>) -> String { - match item { - Some(track) => track - .metadata - .as_ref() - .and_then(|meta| meta.title.as_deref()) - .unwrap_or_else(|| track.uri.as_str()) - .to_string(), - None => "".to_string(), - } -} - -fn format_playback_position(info: &PlaybackPositionInfo) -> String { - let rel = info.rel_time.as_deref().unwrap_or("-"); - let dur = info.track_duration.as_deref().unwrap_or("-"); - format!("{rel} / {dur}") -} - -fn no_renderer_and_exit(message: &str) -> ! { - println!("{message}"); - process::exit(1); -} - -fn no_server_and_exit(message: &str) -> ! { - println!("{message}"); - process::exit(1); -} - -fn print_usage_and_exit() -> ! { - println!( - "Usage: cargo run -p pmocontrol --example queue_pmomusic_demo -- [--timeout-secs N] [--discovery-secs N] [--max-tracks N]" - ); - process::exit(1); -} diff --git a/pmocontrol/src/arylic_client/mod.rs b/pmocontrol/src/arylic_client/mod.rs index b93d4c0a..38aafd51 100644 --- a/pmocontrol/src/arylic_client/mod.rs +++ b/pmocontrol/src/arylic_client/mod.rs @@ -1,4 +1,10 @@ -use std::{io::{Read, Write}, net::{Shutdown, TcpStream, ToSocketAddrs}, sync::{Mutex, OnceLock}, thread, time::{Duration, Instant}}; +use std::{ + io::{Read, Write}, + net::{Shutdown, TcpStream, ToSocketAddrs}, + sync::{Mutex, OnceLock}, + thread, + time::{Duration, Instant}, +}; use tracing::{debug, warn}; @@ -18,7 +24,6 @@ fn last_command_time() -> &'static Mutex { LAST_COMMAND_TIME.get_or_init(|| Mutex::new(Instant::now())) } - /// Mode d’attente de réponse pour une commande TCP Arylic. enum ResponseMode<'a> { /// On n’attend aucune réponse (fire-and-forget). @@ -40,16 +45,14 @@ fn send_command_with_mode( let packet = encode_packet(payload); stream.write_all(&packet).map_err(|_| { - ControlPointError::ArilycTcpError( - format!( + ControlPointError::ArilycTcpError(format!( "Failed to write Arylic TCP packet for {}: {}", host, payload )) })?; stream.flush().map_err(|_| { - ControlPointError::ArilycTcpError( - format!( + ControlPointError::ArilycTcpError(format!( "Failed to flush Arylic TCP stream for {} (command {})", host, payload )) @@ -91,8 +94,7 @@ fn send_command_with_mode( Err(ControlPointError::ArilycTcpError(format!( "No expected response for optional command {} on {}", - payload, - host + payload, host ))) } } @@ -110,9 +112,7 @@ fn read_expected_response( Err(err) => { return Err(ControlPointError::ArilycTcpError(format!( "Failed to read Arylic TCP response for {} (command {}): {}", - host, - payload, - err + host, payload, err ))); } }; @@ -128,8 +128,7 @@ fn read_expected_response( Err(ControlPointError::ArilycTcpError(format!( "No expected response for command {} on {}", - payload, - host + payload, host ))) } @@ -171,11 +170,15 @@ pub fn send_command_optional( ) } -pub fn send_command_no_response(host: &str, port: u16, timeout: Duration, payload: &str) -> Result<(), ControlPointError> { +pub fn send_command_no_response( + host: &str, + port: u16, + timeout: Duration, + payload: &str, +) -> Result<(), ControlPointError> { send_command_with_mode(host, port, timeout, payload, ResponseMode::None).map(|_| ()) } - fn connect(host: &str, port: u16, timeout: Duration) -> Result { if let Ok(mut last_time) = last_command_time().lock() { let elapsed = last_time.elapsed(); @@ -197,18 +200,15 @@ fn connect(host: &str, port: u16, timeout: Duration) -> Result { stream .set_read_timeout(Some(timeout)) .and_then(|_| stream.set_write_timeout(Some(timeout))) - .map_err(|err| { + .map_err(|_| { ControlPointError::ArilycTcpError(format!( "Failed to set socket timeouts for {}", address @@ -251,29 +251,37 @@ fn encode_packet(payload: &str) -> Vec { fn read_packet(stream: &mut TcpStream) -> Result { let mut header = [0u8; 4]; - stream.read_exact(&mut header) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; + stream + .read_exact(&mut header) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?; if header != PACKET_HEADER { - return Err(ControlPointError::ArilycTcpError(format!("Invalid Arylic packet header: {:x?}", header))); + return Err(ControlPointError::ArilycTcpError(format!( + "Invalid Arylic packet header: {:x?}", + header + ))); } let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; + stream + .read_exact(&mut len_buf) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?; let len = u32::from_le_bytes(len_buf) as usize; let mut checksum_buf = [0u8; 4]; - stream.read_exact(&mut checksum_buf) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; + stream + .read_exact(&mut checksum_buf) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?; let expected_checksum = u32::from_le_bytes(checksum_buf); let mut reserved = [0u8; 8]; - stream.read_exact(&mut reserved) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; + stream + .read_exact(&mut reserved) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?; let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; + stream + .read_exact(&mut payload) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?; let actual_checksum = payload.iter().fold(0u32, |acc, b| acc + (*b as u32)); if actual_checksum != expected_checksum { @@ -284,5 +292,5 @@ fn read_packet(stream: &mut TcpStream) -> Result { } Ok(String::from_utf8(payload) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?) } diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index 5a20d50c..cecafee5 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -1,91 +1,33 @@ -use std::collections::{HashMap, HashSet}; -use std::hash::{DefaultHasher, Hash, Hasher}; -use std::io::{self, BufRead, BufReader, Read, Write}; -use std::marker::PhantomData; -use std::net::{IpAddr, TcpListener, TcpStream, UdpSocket}; -use std::ops::{Deref, DerefMut}; -use std::sync::{Arc, Mutex, MutexGuard, RwLock}; +use std::io; +use std::sync::{Arc, Mutex, RwLock}; use std::thread; -use std::time::{Duration, Instant}; +use std::time::Duration; -use anyhow::{anyhow, Context}; -use crossbeam_channel::{unbounded, Receiver, Sender}; +use anyhow::anyhow; +use crossbeam_channel::Receiver; use pmodidl::{DIDLLite, Item as DidlItem, Resource as DidlResource}; use pmoupnp::ssdp::SsdpClient; use quick_xml::se::to_string as to_didl_string; -use thiserror::Error; use tracing::{debug, error, info, warn}; -use ureq::{http, Agent}; -use xmltree::{Element, XMLNode}; - -pub const OPENHOME_SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(2); - -use crate::capabilities::{ - PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, - VolumeControl, -}; -use crate::control_point::music_queue::MusicQueue; -use crate::control_point::openhome_queue::OpenHomeQueue; -use crate::discovery::DiscoveryManager; use crate::discovery::manager::UDNRegistry; +use crate::errors::ControlPointError; use crate::events::{MediaServerEventBus, RendererEventBus}; -use crate::media_server::{ - playback_item_from_entry, MediaBrowser, UpnpMediaServer, ServerId, -}; +use crate::media_server::{MediaBrowser, MusicServer, playback_item_from_entry}; use crate::media_server_events::spawn_media_server_event_runtime; -use crate::model::TrackMetadata; -use crate::model::{MediaServerEvent, RendererEvent, ServiceId, RendererInfo}; -use crate::music_renderer::MusicRenderer; +use crate::model::{MediaServerEvent, RendererEvent}; +use crate::model::{PlaybackState, TrackMetadata}; +use crate::music_renderer::{MusicRenderer, PlaybackPositionInfo, PlaylistBinding}; + +use crate::{DeviceId, DeviceIdentity, DeviceOnline, PlaybackSource}; + #[cfg(feature = "pmoserver")] use crate::openapi::{ CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView, RendererStateView, }; -use crate::openhome::{ - build_info_client, build_playlist_client, build_product_client, OhServiceKind, -}; -use crate::openhome_client::parse_track_metadata_from_didl; -use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; -use crate::music_renderer::openhome_renderer::map_openhome_state; -use crate::music_renderer::time_utils::format_hhmmss_u32; -use crate::provider::HttpXmlDescriptionProvider; -use crate::queue::backend::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot}; -use crate::queue_interne::InternalQueue; -use crate::registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate}; -use crate::upnp_renderer::UpnpRenderer; -use crate::MusicRendererBackend; -use crate::MusicRendererInfo; - - -/// Optional attachment between a renderer playback queue and a server-side -/// DIDL-Lite playlist container. -/// -/// When a queue is bound to a playlist, the control point will automatically -/// refresh it whenever the server notifies us of changes to that container. -/// User-driven mutations (clear, enqueue, etc.) break the binding automatically. -#[derive(Clone, Debug)] -pub struct PlaylistBinding { - /// MediaServer that owns the playlist container. - pub server_id: ServerId, - /// DIDL-Lite object id of the playlist container. - pub container_id: String, - /// True once at least one ContainerUpdateIDs notification has been seen. - pub(crate) has_seen_update: bool, - /// Flag used internally to signal that the queue should be refreshed - /// from the server container. - pub(crate) pending_refresh: bool, - /// Whether the next refresh should auto-start playback if the renderer is idle. - pub(crate) auto_play_on_refresh: bool, -} - -#[derive(Debug, Error)] -pub enum OpenHomeAccessError { - #[error("Renderer {0} not found")] - RendererNotFound(String), - #[error("Renderer {0} has no OpenHome playlist service")] - PlaylistNotSupported(String), -} +use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot}; +use crate::registry::DeviceRegistry; /// Control point minimal : /// - lance un SsdpClient dans un thread, @@ -97,10 +39,9 @@ pub enum OpenHomeAccessError { /// et n'utiliser les événements SSE que comme signaux de rafraîchissement. pub struct ControlPoint { registry: Arc>, - udn_cache: Arc>, + // udn_cache: Arc>, event_bus: RendererEventBus, media_event_bus: MediaServerEventBus, - runtime: Arc, } impl ControlPoint { @@ -108,10 +49,13 @@ impl ControlPoint { /// /// `timeout_secs` : timeout HTTP pour la récupération des descriptions UPnP. pub fn spawn(timeout_secs: u64) -> io::Result { - let registry = Arc::new(RwLock::new(DeviceRegistry::new())); let event_bus = RendererEventBus::new(); + let udn_cache = Arc::new(Mutex::new(UDNRegistry::new())); let media_event_bus = MediaServerEventBus::new(); - let runtime = Arc::new(RuntimeState::new()); + let registry = Arc::new(RwLock::new(DeviceRegistry::new( + &event_bus, + &media_event_bus, + ))); // SsdpClient let client = SsdpClient::new()?; // pmoupnp::ssdp::SsdpClient @@ -121,14 +65,15 @@ impl ControlPoint { // Arc utilisé dans le thread let registry_for_thread = Arc::clone(®istry); - let event_bus_for_discovery = event_bus.clone(); - let media_event_bus_for_discovery = media_event_bus.clone(); + let udn_cache_for_thread = Arc::clone(&udn_cache); // Thread de découverte thread::spawn(move || { - // Provider HTTP+XML et DiscoveryManager VIVENT dans le thread - let provider = HttpXmlDescriptionProvider::new(timeout_secs); - let mut discovery = DiscoveryManager::new(provider); + use crate::discovery::UpnpDiscoveryManager; + + // Créer le gestionnaire de découverte UPNP + let mut discovery = + UpnpDiscoveryManager::new(registry_for_thread, udn_cache_for_thread); // ACTIVE DISCOVERY : envoyer quelques M-SEARCH au démarrage // pour forcer les devices à répondre rapidement. @@ -147,62 +92,10 @@ impl ControlPoint { std::thread::sleep(Duration::from_millis(200)); } - // La closure passée à run_event_loop capture discovery par mutable borrow - // => FnMut, ce que SsdpClient::run_event_loop accepte. + // La closure passe les événements SSDP au gestionnaire de découverte + // Le registry émet automatiquement les événements Online/Offline client.run_event_loop(move |event| { - let updates: Vec = discovery.handle_ssdp_event(event); - - if updates.is_empty() { - return; - } - - if let Ok(mut reg) = registry_for_thread.write() { - for update in updates { - // Émettre les événements Online/Offline avant d'appliquer l'update - match &update { - DeviceUpdate::RendererOnline(info) => { - event_bus_for_discovery.broadcast(RendererEvent::Online { - id: info.id.clone(), - info: info.clone(), - }); - } - DeviceUpdate::RendererOfflineById(id) => { - event_bus_for_discovery.broadcast(RendererEvent::Offline { - id: id.clone(), - }); - } - DeviceUpdate::RendererOfflineByUdn(udn) => { - // Trouver l'ID avant de marquer offline - if let Some(renderer) = reg.get_renderer_by_udn(udn) { - event_bus_for_discovery.broadcast(RendererEvent::Offline { - id: renderer.id.clone(), - }); - } - } - DeviceUpdate::ServerOnline(info) => { - media_event_bus_for_discovery.broadcast(MediaServerEvent::Online { - server_id: info.id.clone(), - info: info.clone(), - }); - } - DeviceUpdate::ServerOfflineById(id) => { - media_event_bus_for_discovery.broadcast(MediaServerEvent::Offline { - server_id: id.clone(), - }); - } - DeviceUpdate::ServerOfflineByUdn(udn) => { - // Trouver l'ID avant de marquer offline - if let Some(server) = reg.get_server_by_udn(udn) { - media_event_bus_for_discovery.broadcast(MediaServerEvent::Offline { - server_id: server.id.clone(), - }); - } - } - } - - reg.apply_update(update); - } - } + discovery.handle_ssdp_event(event); }); }); @@ -235,106 +128,31 @@ impl ControlPoint { }); // Thread de vérification de présence périodique - // Vérifie toutes les 60 secondes que les devices connus sont toujours accessibles - let registry_for_presence = Arc::clone(®istry); - let event_bus_for_presence = event_bus.clone(); - let media_event_bus_for_presence = media_event_bus.clone(); + // Vérifie toutes les 60 secondes les timeouts des devices + let registry_for_timeout = Arc::clone(®istry); + thread::spawn(move || { - use ureq::Agent; - - // HTTP client avec timeout court pour les vérifications de présence - let config = Agent::config_builder() - .timeout_global(Some(Duration::from_secs(5))) - .build(); - let agent: Agent = config.into(); - loop { - // Attendre 60 secondes avant le prochain cycle thread::sleep(Duration::from_secs(60)); - debug!("Starting periodic presence check for devices"); - - let mut updates = Vec::new(); - - // Lire la liste des devices - if let Ok(reg) = registry_for_presence.read() { - // Vérifier les renderers - for renderer in reg.list_renderers() { - if !renderer.online { - continue; // Skip déjà offline - } - - // Faire un HTTP HEAD pour vérifier la présence - match agent.head(&renderer.location).call() { - Ok(_) => { - // Device répond toujours - debug!("Renderer {} ({:?}) is still online", - renderer.friendly_name, renderer.id); - } - Err(e) => { - // Device ne répond plus - warn!("Renderer {} ({:?}) is no longer responding: {} - marking offline", - renderer.friendly_name, renderer.id, e); - updates.push(DeviceUpdate::RendererOfflineById(renderer.id)); - } - } - } - - // Vérifier les servers - for server in reg.list_servers() { - if !server.online { - continue; // Skip déjà offline - } - - // Faire un HTTP HEAD pour vérifier la présence - match agent.head(&server.location).call() { - Ok(_) => { - // Device répond toujours - debug!("Server {} ({:?}) is still online", - server.friendly_name, server.id); - } - Err(e) => { - // Device ne répond plus - warn!("Server {} ({:?}) is no longer responding: {} - marking offline", - server.friendly_name, server.id, e); - updates.push(DeviceUpdate::ServerOfflineById(server.id)); - } - } - } - } - - // Appliquer les updates et émettre les événements - if !updates.is_empty() { - if let Ok(mut reg) = registry_for_presence.write() { - for update in updates { - // Émettre les événements Offline - match &update { - DeviceUpdate::RendererOfflineById(id) => { - event_bus_for_presence.broadcast(RendererEvent::Offline { - id: id.clone(), - }); - } - DeviceUpdate::ServerOfflineById(id) => { - media_event_bus_for_presence.broadcast(MediaServerEvent::Offline { - server_id: id.clone(), - }); - } - _ => {} - } - - reg.apply_update(update); - } - } + // Le registry vérifie les timeouts et émet automatiquement les événements Offline + if let Ok(mut reg) = registry_for_timeout.write() { + reg.check_timeouts(); } } }); // Thread de découverte mDNS pour Chromecast let registry_for_mdns = Arc::clone(®istry); + let udn_cache_for_mdns = Arc::clone(&udn_cache); thread::spawn(move || { - use crate::chromecast_discovery; + use crate::discovery::ChromecastDiscoveryManager; use futures_util::StreamExt; + // Créer le gestionnaire de découverte UPNP + let mut discovery_manager = + ChromecastDiscoveryManager::new(registry_for_mdns, udn_cache_for_mdns); + debug!("Starting mDNS discovery thread for Chromecast devices"); const SERVICE_NAME: &str = "_googlecast._tcp.local"; @@ -354,17 +172,12 @@ impl ControlPoint { while let Some(result) = stream.next().await { match result { Ok(response) => { - debug!("Received mDNS response with {} records", - response.records().count()); + debug!( + "Received mDNS response with {} records", + response.records().count() + ); - // Process the mDNS response - if let Some(update) = chromecast_discovery::process_mdns_response(response) { - debug!("Processed Chromecast device update: {:?}", update); - - // Update the registry - let mut registry = registry_for_mdns.write().unwrap(); - registry.apply_update(update); - } + discovery_manager.handle_mdns_response(response); } Err(e) => { warn!("mDNS discovery error: {}", e); @@ -381,46 +194,37 @@ impl ControlPoint { }); }); - let runtime_cp = ControlPoint { + let polling_cp = ControlPoint { registry: Arc::clone(®istry), + // udn_cache: udn_cache.clone(), event_bus: event_bus.clone(), media_event_bus: media_event_bus.clone(), - runtime: Arc::clone(&runtime), }; thread::spawn(move || { + use std::collections::HashMap; + + // Local cache for change detection (not a source of truth) + let mut polling_cache: HashMap = HashMap::new(); let mut tick: u32 = 0; loop { - let infos = { - let reg = runtime_cp.registry.read().unwrap(); - reg.list_renderers() + // Get renderers directly from registry - they already contain backends + let renderers = { + let reg = polling_cp.registry.read().unwrap(); + reg.list_renderers().unwrap_or_else(|_| vec![]) }; - // Create MusicRendererBackend for each renderer from registry - // No caching needed - backends manage their own connections - let renderers: Vec = infos - .into_iter() - .filter_map(|info| { - MusicRendererBackend::from_renderer_info(info, &runtime_cp.registry) - }) - .collect(); - for renderer in renderers { - let info = renderer.info(); - - if !info.online { + if !renderer.is_online() { continue; } - let renderer_id = info.id.clone(); + let renderer_id = renderer.id(); - // Initialize runtime entry if it doesn't exist - // Queue is managed by MusicRenderer, not RuntimeState - if !runtime_cp.runtime.has_entry(&renderer_id) { - runtime_cp.runtime.create_entry(&renderer_id); - } - let prev_snapshot = runtime_cp.runtime.snapshot_for(&renderer_id); + // Get previous snapshot from local cache + let prev_snapshot = + polling_cache.get(&renderer_id).cloned().unwrap_or_default(); let mut new_snapshot = prev_snapshot.clone(); let prev_position = prev_snapshot.position.clone(); @@ -432,7 +236,7 @@ impl ControlPoint { }; if has_changed { - runtime_cp.emit_renderer_event(RendererEvent::PositionChanged { + polling_cp.emit_renderer_event(RendererEvent::PositionChanged { id: renderer_id.clone(), position: position.clone(), }); @@ -453,7 +257,7 @@ impl ControlPoint { artist = metadata.artist.as_deref(), "Emitting metadata changed event" ); - runtime_cp.emit_renderer_event( + polling_cp.emit_renderer_event( RendererEvent::MetadataChanged { id: renderer_id.clone(), metadata: metadata.clone(), @@ -490,7 +294,7 @@ impl ControlPoint { // Emit event only for non-transient states to reduce noise // and avoid overwhelming the renderer during track changes if has_changed && !matches!(logical_state, PlaybackState::Transitioning) { - runtime_cp.emit_renderer_event(RendererEvent::StateChanged { + polling_cp.emit_renderer_event(RendererEvent::StateChanged { id: renderer_id.clone(), state: logical_state.clone(), }); @@ -504,7 +308,7 @@ impl ControlPoint { if tick % 3 == 0 { if let Ok(volume) = renderer.volume() { if prev_snapshot.last_volume != Some(volume) { - runtime_cp.emit_renderer_event(RendererEvent::VolumeChanged { + polling_cp.emit_renderer_event(RendererEvent::VolumeChanged { id: renderer_id.clone(), volume, }); @@ -515,7 +319,7 @@ impl ControlPoint { if let Ok(mute) = renderer.mute() { if prev_snapshot.last_mute != Some(mute) { - runtime_cp.emit_renderer_event(RendererEvent::MuteChanged { + polling_cp.emit_renderer_event(RendererEvent::MuteChanged { id: renderer_id.clone(), mute, }); @@ -525,9 +329,8 @@ impl ControlPoint { } } - runtime_cp - .runtime - .update_snapshot(&renderer_id, new_snapshot); + // Update local cache + polling_cache.insert(renderer_id, new_snapshot); } tick = tick.wrapping_add(1); @@ -542,123 +345,94 @@ impl ControlPoint { timeout_secs, )?; - let (oh_event_tx, oh_event_rx) = unbounded::(); - let event_forwarder_cp = ControlPoint { - registry: Arc::clone(®istry), - event_bus: event_bus.clone(), - media_event_bus: media_event_bus.clone(), - runtime: Arc::clone(&runtime), - }; - - thread::Builder::new() - .name("cp-openhome-event-forwarder".into()) - .spawn(move || { - while let Ok(event) = oh_event_rx.recv() { - event_forwarder_cp.emit_renderer_event(event); - } - })?; - - spawn_openhome_event_runtime( - Arc::clone(®istry), - Arc::clone(&runtime), - event_bus.clone(), - oh_event_tx, - )?; - // Worker thread to process MediaServerEvent and trigger queue refreshes // for renderers bound to updated playlist containers let registry_for_media_worker = Arc::clone(®istry); - let runtime_for_media_worker = Arc::clone(&runtime); let event_bus_for_media_worker = event_bus.clone(); let media_rx = media_event_bus.subscribe(); thread::Builder::new() .name("cp-media-server-event-worker".into()) - .spawn(move || loop { - let event = match media_rx.recv() { - Ok(e) => e, - Err(_) => { - warn!("MediaServerEvent channel closed, worker exiting"); - break; - } - }; + .spawn(move || { + loop { + let event = match media_rx.recv() { + Ok(e) => e, + Err(_) => { + warn!("MediaServerEvent channel closed, worker exiting"); + break; + } + }; - match event { - MediaServerEvent::GlobalUpdated { - server_id, - system_update_id, - } => { - info!( - server = server_id.0.as_str(), - system_update_id = system_update_id, - "MediaServer global update" - ); - } - MediaServerEvent::ContainersUpdated { - server_id, - container_ids, - } => { - // Get all renderers from registry and check their bindings - let renderers_to_refresh: Vec = { - let reg = registry_for_media_worker.read().unwrap(); - let mut to_refresh = Vec::new(); - - if let Ok(renderers) = reg.list_renderers() { - for renderer in renderers { - let renderer_id = renderer.info().id.clone(); - if let Some(binding) = renderer.get_playlist_binding() { - if binding.server_id == server_id - && container_ids.contains(&binding.container_id) - { - // Update binding to mark refresh needed - let mut updated_binding = binding.clone(); - updated_binding.pending_refresh = true; - updated_binding.has_seen_update = true; - renderer.set_playlist_binding(Some(updated_binding)); - to_refresh.push(renderer_id); - } + match event { + MediaServerEvent::GlobalUpdated { + server_id, + system_update_id, + } => { + info!( + server = server_id.0.as_str(), + system_update_id = system_update_id, + "MediaServer global update" + ); + } + MediaServerEvent::ContainersUpdated { + server_id, + container_ids, + } => { + // Find all renderers bound to the updated containers + let renderers_to_refresh: Vec<(DeviceId, Arc)> = { + let reg = registry_for_media_worker.read().unwrap(); + match reg.list_renderers() { + Ok(renderers) => renderers + .into_iter() + .filter_map(|renderer| { + // Mark binding for refresh if it matches + if renderer.mark_binding_for_refresh(&server_id, &container_ids) { + Some((renderer.id(), renderer)) + } else { + None + } + }) + .collect(), + Err(e) => { + warn!(error = %e, "Failed to list renderers for container update"); + Vec::new() } } - } + }; - to_refresh - }; - - for renderer_id in renderers_to_refresh { - debug!( - renderer = renderer_id.0.as_str(), - server = server_id.0.as_str(), - "Triggering queue refresh for bound playlist" - ); - - if let Err(err) = refresh_attached_queue_for( - ®istry_for_media_worker, - &runtime_for_media_worker, - &renderer_id, - &event_bus_for_media_worker, - None, - ) { - warn!( + // Trigger refresh for each affected renderer (outside of registry lock) + for (renderer_id, _renderer) in renderers_to_refresh { + debug!( renderer = renderer_id.0.as_str(), server = server_id.0.as_str(), - error = %err, - "Failed to refresh queue from playlist container" + "Triggering queue refresh for bound playlist" ); + + if let Err(err) = refresh_attached_queue_for( + ®istry_for_media_worker, + &renderer_id, + &event_bus_for_media_worker, + None, + ) { + warn!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + error = %err, + "Failed to refresh queue from playlist container" + ); + } } } - } - MediaServerEvent::Online { server_id, info } => { - debug!( - server = server_id.0.as_str(), - friendly_name = info.friendly_name.as_str(), - "MediaServer came online" - ); - } - MediaServerEvent::Offline { server_id } => { - debug!( - server = server_id.0.as_str(), - "MediaServer went offline" - ); + MediaServerEvent::Online { server_id, info } => { + debug!( + server = server_id.0.as_str(), + friendly_name = info.friendly_name.as_str(), + "MediaServer came online" + ); + } + MediaServerEvent::Offline { server_id } => { + debug!(server = server_id.0.as_str(), "MediaServer went offline"); + } } } })?; @@ -666,7 +440,6 @@ impl ControlPoint { // Periodic refresh worker for bound playlists // Every 60 seconds, trigger a refresh for all renderers with active bindings let registry_for_periodic = Arc::clone(®istry); - let runtime_for_periodic = Arc::clone(&runtime); let event_bus_for_periodic = event_bus.clone(); thread::Builder::new() @@ -677,24 +450,25 @@ impl ControlPoint { thread::sleep(Duration::from_secs(60)); // Collect all renderers with active bindings and mark them for refresh - let renderers_to_refresh: Vec = { + let renderers_to_refresh: Vec = { let reg = registry_for_periodic.read().unwrap(); - let mut to_refresh = Vec::new(); - - if let Ok(renderers) = reg.list_renderers() { - for renderer in renderers { - let renderer_id = renderer.info().id.clone(); - if let Some(binding) = renderer.get_playlist_binding() { - // Mark binding for refresh - let mut updated_binding = binding.clone(); - updated_binding.pending_refresh = true; - renderer.set_playlist_binding(Some(updated_binding)); - to_refresh.push(renderer_id); - } + match reg.list_renderers() { + Ok(renderers) => renderers + .into_iter() + .filter_map(|renderer| { + // Mark binding for refresh if it exists + if renderer.mark_pending_refresh() { + Some(renderer.id()) + } else { + None + } + }) + .collect(), + Err(e) => { + warn!(error = %e, "Failed to list renderers for periodic refresh"); + Vec::new() } } - - to_refresh }; // Trigger refresh for each bound renderer (outside of lock) @@ -706,7 +480,6 @@ impl ControlPoint { if let Err(err) = refresh_attached_queue_for( ®istry_for_periodic, - &runtime_for_periodic, &renderer_id, &event_bus_for_periodic, None, @@ -723,10 +496,9 @@ impl ControlPoint { Ok(Self { registry, - udn_cache: Arc::new(Mutex::new(UDNRegistry::new())), + // udn_cache, event_bus, media_event_bus, - runtime, }) } @@ -735,38 +507,6 @@ impl ControlPoint { Arc::clone(&self.registry) } - /// Snapshot list of renderers currently known by the registry. - pub fn list_upnp_renderers(&self) -> Vec { - let infos = { - let reg = self.registry.read().unwrap(); - reg.list_renderers() - }; - - infos - .into_iter() - .map(|info| UpnpRenderer::from_info(&info)) - .collect() - } - - /// Return the first renderer in the registry, if any. - pub fn default_upnp_renderer(&self) -> Option> { - let info = { - let reg = self.registry.read().unwrap(); - reg.list_renderers().into_iter().next() - }?; - - Some(UpnpRenderer::from_info(&info)) - } - - /// Lookup a renderer by id. - pub fn renderer_by_id(&self, id: &ServiceId) -> Option> { - - let reg = self.registry.read().unwrap(); - reg.get_renderer(id) - - } - - /// Snapshot list of music renderers (protocol-agnostic view). pub fn list_music_renderers(&self) -> Vec> { let reg = self.registry.read().unwrap(); @@ -780,19 +520,19 @@ impl ControlPoint { } /// Lookup a music renderer by id. - pub fn music_renderer_by_id(&self, id: &ServiceId) -> Option> { + pub fn music_renderer_by_id(&self, id: &DeviceId) -> Option> { let reg = self.registry.read().unwrap(); reg.get_renderer(id) } /// Snapshot list of media servers currently known by the registry. - pub fn list_media_servers(&self) -> Vec { + pub fn list_media_servers(&self) -> Result>, ControlPointError> { let reg = self.registry.read().unwrap(); reg.list_servers() } /// Lookup a media server by id. - pub fn media_server(&self, id: &ServerId) -> Option { + pub fn media_server(&self, id: &DeviceId) -> Option> { let reg = self.registry.read().unwrap(); reg.get_server(id) } @@ -804,29 +544,21 @@ impl ControlPoint { /// attachment stays consistent with the local `QueueBackend` snapshot. /// The actual structural change then goes through the backend helpers /// (`QueueBackend::clear_queue` via `MusicRenderer::get_queue_mut()`). - pub fn clear_queue(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { - if !self.runtime.has_entry(renderer_id) { - let err = Self::runtime_entry_missing(renderer_id); - warn!( - renderer = renderer_id.0.as_str(), - "Cannot clear queue: renderer not registered in runtime" - ); - return Err(err); - } - + pub fn clear_queue(&self, renderer_id: &DeviceId) -> Result<(), ControlPointError> { // User-driven mutation: detach any playlist binding self.detach_playlist_binding(renderer_id, "clear_queue"); // Clear the queue on the backend (backend-agnostic) - let renderer = self.music_renderer_by_id(renderer_id) - .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0)) + })?; // Get queue length before clearing let removed = renderer.get_queue().upcoming_len().unwrap_or(0); renderer.clear_queue()?; - // Sync backend state to local cache (for OpenHome, this updates the cache) + // Sync backend state to local cache renderer.sync_queue_state()?; debug!( @@ -854,26 +586,17 @@ impl ControlPoint { /// accessed via `MusicRenderer::get_queue_mut()`). pub fn enqueue_items( &self, - renderer_id: &ServiceId, + renderer_id: &DeviceId, items: Vec, - ) -> anyhow::Result<()> { - if !self.runtime.has_entry(renderer_id) { - let err = Self::runtime_entry_missing(renderer_id); - warn!( - renderer = renderer_id.0.as_str(), - "Cannot enqueue items: renderer not registered in runtime" - ); - return Err(err); - } - + ) -> Result<(), ControlPointError> { // User-driven mutation: detach any playlist binding self.detach_playlist_binding(renderer_id, "enqueue_items"); // Enqueue items using QueueBackend abstraction (works for both backends) let item_count = items.len(); - let renderer = self - .music_renderer_by_id(renderer_id) - .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0)) + })?; let new_len = { let mut queue = renderer.get_queue_mut(); @@ -897,54 +620,19 @@ impl ControlPoint { Ok(()) } - /// Read-only snapshot of the upcoming queue items for a renderer. + /// Read-only snapshot of the queue items and current index for a renderer. /// - /// This helper never mutates the runtime. It simply exposes the pending - /// items as seen by the local `QueueBackend`. For a full `(items, index)` - /// view, prefer [`get_full_queue_snapshot`]. - pub fn get_queue_snapshot( - &self, - renderer_id: &ServiceId, - ) -> anyhow::Result> { - if !self.runtime.has_entry(renderer_id) { - let err = Self::runtime_entry_missing(renderer_id); - warn!( - renderer = renderer_id.0.as_str(), - "Cannot snapshot queue: renderer not registered in runtime" - ); - return Err(err); - } - - self.runtime - .queue_snapshot(renderer_id) - .ok_or_else(|| Self::runtime_entry_missing(renderer_id)) - } - - /// Read-only helper returning both queue items and the current index. - /// - /// This is the most detailed queue view exposed publicly and is meant - /// for UI/REST layers that need an authoritative snapshot without - /// mutating the runtime. + /// Returns both the queue items and the current playing index. + /// This is the authoritative queue view for UI/REST layers. pub fn get_full_queue_snapshot( &self, - renderer_id: &ServiceId, - ) -> anyhow::Result<(Vec, Option)> { - if !self.runtime.has_entry(renderer_id) { - // Renderer not yet initialized in runtime (just discovered via SSDP) - // This is normal and will be fixed on first polling cycle - debug!( - renderer = renderer_id.0.as_str(), - "Renderer not yet initialized in runtime, returning empty queue" - ); - return Err(Self::runtime_entry_missing(renderer_id)); - } + renderer_id: &DeviceId, + ) -> Result<(Vec, Option), ControlPointError> { + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0)) + })?; - // Get queue from renderer - let renderer = self.music_renderer_by_id(renderer_id) - .map_err(|_| Self::runtime_entry_missing(renderer_id))?; - let queue = renderer.get_queue(); - let snapshot = queue.queue_snapshot() - .map_err(|e| ControlPointError::QueueError(e.to_string()))?; + let snapshot = renderer.queue_snapshot()?; Ok((snapshot.items, snapshot.current_index)) } @@ -952,58 +640,68 @@ impl ControlPoint { /// /// Useful for UI layers that want to display the currently playing /// track even when the renderer is not returning metadata via UPnP. - pub fn get_current_track_metadata(&self, renderer_id: &ServiceId) -> Option { - self.runtime.current_track_metadata(renderer_id) + pub fn get_current_track_metadata(&self, renderer_id: &DeviceId) -> Option { + self.music_renderer_by_id(renderer_id) + .and_then(|r| r.last_metadata()) } /// Gets the backend queue snapshot for renderers with persistent queues. /// - /// Returns the queue snapshot if the renderer has a backend queue (e.g., OpenHome), - /// or None if it doesn't (e.g., AVTransport). + /// Returns the queue snapshot if the renderer has a backend queue, + /// or None if it doesn't. pub fn get_renderer_queue_snapshot( &self, - renderer_id: &ServiceId, - ) -> anyhow::Result> { - let renderer = self.music_renderer_by_id(renderer_id) - .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + renderer_id: &DeviceId, + ) -> Result { + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0)) + })?; renderer.queue_snapshot() } /// Gets the length of the backend queue for renderers with persistent queues. /// - /// Returns the queue length if the renderer has a backend queue, or 0 if it doesn't. - pub fn get_renderer_queue_length(&self, renderer_id: &ServiceId) -> anyhow::Result { - let snapshot = self.get_renderer_queue_snapshot(renderer_id)?; - Ok(snapshot.map(|s| s.len()).unwrap_or(0)) + /// Returns the queue length. + pub fn get_renderer_queue_length( + &self, + renderer_id: &DeviceId, + ) -> Result { + Ok(self.get_renderer_queue_snapshot(renderer_id)?.len()) } /// Build a fully consistent snapshot for UI consumers (state + queue + binding). #[cfg(feature = "pmoserver")] pub fn renderer_full_snapshot( &self, - renderer_id: &ServiceId, + renderer_id: &DeviceId, ) -> anyhow::Result { let renderer = self .music_renderer_by_id(renderer_id) .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; let info = renderer.info(); - // Get runtime snapshot (state, position, volume, metadata) - let runtime_snapshot = self.runtime.snapshot_for(renderer_id); + // Query current state directly from renderer + let current_state = renderer.playback_state().ok(); + let current_position = renderer.playback_position().ok(); + let current_volume = renderer.volume().ok(); + let current_mute = renderer.mute().ok(); + let last_metadata = renderer.last_metadata(); // Get queue from renderer (works for all backends) - let queue_snapshot = renderer.get_queue().queue_snapshot() + let queue_snapshot = renderer + .get_queue() + .queue_snapshot() .map_err(|e| anyhow!("Failed to get queue snapshot: {}", e))?; let queue_items = queue_snapshot.items; let mut queue_current_index = queue_snapshot.current_index; - let playback_source = self.runtime.playback_source(renderer_id); + let playback_source = renderer.playback_source(); let queue_len = queue_items.len(); // Try heuristics to determine current_index if not set if queue_current_index.is_none() { - if let Some(position) = runtime_snapshot.position.as_ref() { + if let Some(position) = current_position.as_ref() { if let Some(uri) = position.track_uri.as_ref() { if let Some(idx) = queue_items.iter().position(|item| item.uri == *uri) { queue_current_index = Some(idx); @@ -1019,8 +717,7 @@ impl ControlPoint { // Final fallback: if playing from queue and no index, assume first track if queue_current_index.is_none() && matches!(playback_source, PlaybackSource::FromQueue) - && runtime_snapshot - .state + && current_state .as_ref() .map(|state| matches!(state, PlaybackState::Playing | PlaybackState::Paused)) .unwrap_or(false) @@ -1059,39 +756,32 @@ impl ControlPoint { }, ); - let (position_ms, duration_ms) = - convert_runtime_position(runtime_snapshot.position.as_ref()); + let (position_ms, duration_ms) = convert_runtime_position(current_position.as_ref()); let queue_current_metadata = queue_current_index .and_then(|idx| queue_items.get(idx)) .map(current_track_from_playback_item); - // Prefer queue metadata, fallback to runtime metadata + // Prefer queue metadata, fallback to cached metadata let current_track = queue_current_metadata.or_else(|| { - runtime_snapshot - .last_metadata - .as_ref() - .map(|meta| CurrentTrackMetadata { - title: meta.title.clone(), - artist: meta.artist.clone(), - album: meta.album.clone(), - album_art_uri: meta.album_art_uri.clone(), - }) + last_metadata.as_ref().map(|meta| CurrentTrackMetadata { + title: meta.title.clone(), + artist: meta.artist.clone(), + album: meta.album.clone(), + album_art_uri: meta.album_art_uri.clone(), + }) }); let state_view = RendererStateView { id: renderer_id.0.clone(), - friendly_name: info.friendly_name.clone(), - transport_state: runtime_snapshot - .state + friendly_name: info.friendly_name().to_string(), + transport_state: current_state .as_ref() .map(|state| state.as_str().to_string()) .unwrap_or_else(|| "UNKNOWN".to_string()), position_ms, duration_ms, - volume: runtime_snapshot - .last_volume - .and_then(|value| u8::try_from(value).ok()), - mute: runtime_snapshot.last_mute, + volume: current_volume.and_then(|value| u8::try_from(value).ok()), + mute: current_mute, queue_len, attached_playlist: binding.clone(), current_track, @@ -1104,32 +794,32 @@ impl ControlPoint { }) } - /// Clears the renderer's backend queue. + /// Clears the renderer's queue. /// - /// For renderers with persistent queues (OpenHome), this clears the queue on the renderer. - /// For other renderers, this returns an error. - pub fn clear_renderer_queue(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { - let renderer = self.music_renderer_by_id(renderer_id) - .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; - renderer.clear_queue()?; - renderer.sync_queue_state() + /// Works for both internal queues and persistent backend queues (OpenHome). + pub fn clear_renderer_queue(&self, renderer_id: &DeviceId) -> Result<(), ControlPointError> { + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0)) + })?; + renderer.clear_queue() } /// Adds a track to the renderer's backend queue. /// - /// For renderers with persistent queues (OpenHome), this adds the track to the queue. + /// For renderers with persistent queues, this adds the track to the queue. /// For other renderers, this returns an error. /// /// Returns the backend-specific track ID if applicable. pub fn add_track_to_renderer( &self, - renderer_id: &ServiceId, + renderer_id: &DeviceId, uri: &str, metadata: &str, after_id: Option, play: bool, ) -> anyhow::Result> { - let renderer = self.music_renderer_by_id(renderer_id) + let renderer = self + .music_renderer_by_id(renderer_id) .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; let track_id = renderer.add_track_to_queue(uri, metadata, after_id, play)?; renderer.sync_queue_state()?; @@ -1138,18 +828,18 @@ impl ControlPoint { /// Selects and plays a specific track from the renderer's backend queue. /// - /// For renderers with persistent queues (OpenHome), this uses the track ID. + /// For renderers with persistent queues, this uses the track ID. /// For other renderers, this returns an error. pub fn select_renderer_track( &self, - renderer_id: &ServiceId, + renderer_id: &DeviceId, track_id: u32, - ) -> anyhow::Result<()> { - let renderer = self.music_renderer_by_id(renderer_id) - .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + ) -> Result<(), ControlPointError> { + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + ControlPointError::ControlPoint(format!("Renderer {} not found", renderer_id.0)) + })?; renderer.select_queue_track(track_id)?; - self.runtime - .set_playback_source(renderer_id, PlaybackSource::FromQueue); + renderer.set_playback_source(PlaybackSource::FromQueue); renderer.sync_queue_state() } @@ -1159,18 +849,9 @@ impl ControlPoint { /// The method only reads queue content via the runtime helpers and /// delegates potential structural mutations to `QueueBackend` (when an item /// needs to be restored after a playback error). - pub fn play_current_from_queue(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { - if !self.runtime.has_entry(renderer_id) { - let err = Self::runtime_entry_missing(renderer_id); - warn!( - renderer = renderer_id.0.as_str(), - "Cannot play current: renderer not registered in runtime" - ); - return Err(err); - } - + pub fn play_current_from_queue(&self, renderer_id: &DeviceId) -> Result<(), ControlPointError> { let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { - anyhow!("Renderer {} not found", renderer_id.0) + ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0)) })?; // Use generic queue access (works for all backends) @@ -1179,8 +860,7 @@ impl ControlPoint { renderer = renderer_id.0.as_str(), "play_current_from_queue: queue is empty or no current item" ); - self.runtime - .set_playback_source(renderer_id, PlaybackSource::None); + renderer.set_playback_source(PlaybackSource::None); return Ok(()); }; @@ -1193,159 +873,91 @@ impl ControlPoint { // Temporarily disable auto-advance to prevent race condition // when renderer sends Stopped event during SetAVTransportURI - self.runtime.set_playback_source(renderer_id, PlaybackSource::None); + renderer.set_playback_source(PlaybackSource::None); - let playback = (|| -> anyhow::Result<()> { - let didl_metadata = playback_item_to_didl(&item); - renderer.play_uri(&item.uri, &didl_metadata)?; - Ok(()) - })(); - - match playback { - Ok(()) => { - info!( - renderer = renderer_id.0.as_str(), - uri = item.uri.as_str(), - "Queue playback started (current item)" - ); - - // Sauvegarder les métadonnées dans le snapshot pour que current_track soit disponible - // même si le renderer UPnP ne retourne pas de métadonnées dans GetPositionInfo - let metadata = playback_item_track_metadata(&item); - self.runtime.update_snapshot_with(renderer_id, |snapshot| { - snapshot.last_metadata = Some(metadata); - }); - - self.runtime - .set_playback_source(renderer_id, PlaybackSource::FromQueue); - Ok(()) - } - Err(e) => { - error!( - renderer = renderer_id.0.as_str(), - error = %e, - "Failed to play current item from queue" - ); - self.runtime - .set_playback_source(renderer_id, PlaybackSource::None); - Err(e) - } - } - } - - /// Advances the queue by one item, starts playback and updates the snapshot. - /// - /// The structural mutation uses the `QueueBackend::dequeue_next` helper - /// (through `RuntimeState`) so that all pointer updates are consistent. - pub fn play_next_from_queue(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { - if !self.runtime.has_entry(renderer_id) { - let err = Self::runtime_entry_missing(renderer_id); - warn!( + // Start playback + let didl_metadata = playback_item_to_didl(&item); + if let Err(err) = renderer.play_uri(&item.uri, &didl_metadata) { + error!( renderer = renderer_id.0.as_str(), - "Cannot advance queue: renderer not registered in runtime" + error = %err, + "Failed to play current item from queue" ); + renderer.set_playback_source(PlaybackSource::None); return Err(err); } + info!( + renderer = renderer_id.0.as_str(), + uri = item.uri.as_str(), + "Queue playback started (current item)" + ); + + // Save metadata in renderer for current track availability + // even if the renderer doesn't return metadata in GetPositionInfo + let metadata = playback_item_track_metadata(&item); + renderer.set_last_metadata(Some(metadata)); + + renderer.set_playback_source(PlaybackSource::FromQueue); + Ok(()) + } + + /// Advances the queue by one item, starts playback and updates the snapshot. + pub fn play_next_from_queue(&self, renderer_id: &DeviceId) -> Result<(), ControlPointError> { let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { - anyhow!("Renderer {} not found", renderer_id.0) + ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0)) })?; - // Use generic queue access (works for all backends) + // Dequeue next item let Some((item, remaining_after)) = renderer.get_queue_mut().dequeue_next()? else { debug!( renderer = renderer_id.0.as_str(), "play_next_from_queue: queue is empty" ); - self.runtime - .set_playback_source(renderer_id, PlaybackSource::None); + renderer.set_playback_source(PlaybackSource::None); return Ok(()); }; - let queue_before = remaining_after + 1; debug!( renderer = renderer_id.0.as_str(), - queue_before, queue_after = remaining_after, uri = item.uri.as_str(), "Dequeued next playback item" ); - let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { - warn!( - renderer = renderer_id.0.as_str(), - "Renderer disappeared before queue playback could start" - ); - anyhow!("Renderer {} not found", renderer_id.0) - })?; - // Temporarily disable auto-advance to prevent race condition // when renderer sends Stopped event during SetAVTransportURI - self.runtime.set_playback_source(renderer_id, PlaybackSource::None); + renderer.set_playback_source(PlaybackSource::None); - let playback = (|| -> anyhow::Result<()> { - let didl_metadata = playback_item_to_didl(&item); - renderer.play_uri(&item.uri, &didl_metadata)?; - Ok(()) - })(); - - if let Err(err) = playback { + // Start playback + let didl_metadata = playback_item_to_didl(&item); + if let Err(err) = renderer.play_uri(&item.uri, &didl_metadata) { error!( renderer = renderer_id.0.as_str(), error = %err, "Failed to start playback for queued item" ); // Try to requeue the item - if renderer.get_queue_mut() - .enqueue_items(vec![item.clone()], EnqueueMode::InsertAfterCurrent) - .is_err() - { - warn!( - renderer = renderer_id.0.as_str(), - "Failed to requeue item after playback error" - ); - } - self.runtime - .set_playback_source(renderer_id, PlaybackSource::None); + let _ = renderer + .get_queue_mut() + .enqueue_items(vec![item.clone()], EnqueueMode::InsertAfterCurrent); + renderer.set_playback_source(PlaybackSource::None); return Err(err); } - // Sauvegarder les métadonnées dans le snapshot pour que current_track soit disponible - // même si le renderer UPnP ne retourne pas de métadonnées dans GetPositionInfo + // Save metadata for current track let metadata = playback_item_track_metadata(&item); - self.runtime.update_snapshot_with(renderer_id, |snapshot| { - snapshot.last_metadata = Some(metadata); - }); + renderer.set_last_metadata(Some(metadata)); - self.runtime - .set_playback_source(renderer_id, PlaybackSource::FromQueue); + renderer.set_playback_source(PlaybackSource::FromQueue); debug!( renderer = renderer_id.0.as_str(), queue_len = remaining_after, "Started playback from queue" ); - if let Some(snapshot) = self.runtime.queue_snapshot(renderer_id) { - if let Some(next_item) = snapshot.first() { - if let Some(upnp) = renderer.as_upnp() { - let known_supported = upnp.supports_set_next(); - if known_supported || upnp.has_avtransport() { - let next_didl_metadata = playback_item_to_didl(next_item); - match upnp.set_next_uri(&next_item.uri, &next_didl_metadata) { - Ok(_) => debug!( - renderer = renderer_id.0.as_str(), - "Prefetched next track via SetNextAVTransportURI" - ), - Err(err) => debug!( - renderer = renderer_id.0.as_str(), - error = %err, - "SetNextAVTransportURI failed for next queue item; continuing without prefetch" - ), - } - } - } - } - } + // Prefetch next track if supported + self.prefetch_next_track(&renderer, renderer_id); // Emit QueueUpdated event self.emit_renderer_event(RendererEvent::QueueUpdated { @@ -1356,39 +968,67 @@ impl ControlPoint { Ok(()) } - /// Jumps to a specific index in the queue and starts playback. - /// - /// For OpenHome renderers, this uses the playlist's native SeekId capability. - /// For internal queues, this updates the current index and starts playback. - pub fn play_queue_index(&self, renderer_id: &ServiceId, index: usize) -> anyhow::Result<()> { - if !self.runtime.has_entry(renderer_id) { - let err = Self::runtime_entry_missing(renderer_id); - warn!( - renderer = renderer_id.0.as_str(), - "Cannot jump to index: renderer not registered in runtime" - ); - return Err(err); + /// Prefetches the next track in the queue if the renderer supports it. + fn prefetch_next_track(&self, renderer: &Arc, renderer_id: &DeviceId) { + // Only attempt prefetch if the renderer supports it + if !renderer.supports_set_next() { + return; } + // Get the next item from the queue using peek_current + let Ok(Some((_, remaining))) = renderer.get_queue().peek_current() else { + return; + }; + + if remaining == 0 { + return; + } + + let queue_snapshot = match renderer.get_queue().queue_snapshot() { + Ok(snapshot) => snapshot, + Err(_) => return, + }; + + // Get next item (current + 1) + let next_index = queue_snapshot.current_index.map(|i| i + 1).unwrap_or(0); + let Some(next_item) = queue_snapshot.items.get(next_index) else { + return; + }; + + let next_didl_metadata = playback_item_to_didl(next_item); + match renderer.set_next_uri(&next_item.uri, &next_didl_metadata) { + Ok(_) => debug!( + renderer = renderer_id.0.as_str(), + "Prefetched next track via SetNextAVTransportURI" + ), + Err(err) => debug!( + renderer = renderer_id.0.as_str(), + error = %err, + "SetNextAVTransportURI failed; continuing without prefetch" + ), + } + } + + /// Jumps to a specific index in the queue and starts playback. + pub fn play_queue_index( + &self, + renderer_id: &DeviceId, + index: usize, + ) -> Result<(), ControlPointError> { let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { - anyhow!("Renderer {} not found", renderer_id.0) + ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0)) })?; - // Use generic queue access (works for all backends) + // Set queue index renderer.get_queue_mut().set_index(Some(index))?; - let (item, remaining) = match renderer.get_queue().peek_current()? { - Some(result) => result, - None => { - debug!( - renderer = renderer_id.0.as_str(), - index, - "play_queue_index: no item at index" - ); - self.runtime - .set_playback_source(renderer_id, PlaybackSource::None); - return Ok(()); - } + let Some((item, remaining)) = renderer.get_queue().peek_current()? else { + debug!( + renderer = renderer_id.0.as_str(), + index, "play_queue_index: no item at index" + ); + renderer.set_playback_source(PlaybackSource::None); + return Ok(()); }; debug!( @@ -1399,108 +1039,35 @@ impl ControlPoint { "Playing item at index from queue" ); - let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { - warn!( - renderer = renderer_id.0.as_str(), - "Renderer disappeared before queue playback could start" - ); - anyhow!("Renderer {} not found", renderer_id.0) - })?; - // Temporarily disable auto-advance to prevent race condition - // when renderer sends Stopped event during SetAVTransportURI - self.runtime.set_playback_source(renderer_id, PlaybackSource::None); + renderer.set_playback_source(PlaybackSource::None); - let playback = (|| -> anyhow::Result<()> { - let didl_metadata = playback_item_to_didl(&item); - renderer.play_uri(&item.uri, &didl_metadata)?; - Ok(()) - })(); - - match playback { - Ok(()) => { - info!( - renderer = renderer_id.0.as_str(), - index, - uri = item.uri.as_str(), - "Queue playback started at index" - ); - - let metadata = playback_item_track_metadata(&item); - self.runtime.update_snapshot_with(renderer_id, |snapshot| { - snapshot.last_metadata = Some(metadata); - }); - - self.runtime - .set_playback_source(renderer_id, PlaybackSource::FromQueue); - Ok(()) - } - Err(err) => { - error!( - renderer = renderer_id.0.as_str(), - index, - error = %err, - "Failed to start playback for queued item at index" - ); - self.runtime - .set_playback_source(renderer_id, PlaybackSource::None); - Err(err) - } + // Start playback + let didl_metadata = playback_item_to_didl(&item); + if let Err(err) = renderer.play_uri(&item.uri, &didl_metadata) { + error!( + renderer = renderer_id.0.as_str(), + index, + error = %err, + "Failed to start playback for queued item at index" + ); + renderer.set_playback_source(PlaybackSource::None); + return Err(err); } - } - fn start_queue_playback_if_idle(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { - let snapshot = self.runtime.snapshot_for(renderer_id); - let renderer_playing = matches!(snapshot.state, Some(PlaybackState::Playing)); - let from_queue = self.runtime.is_playing_from_queue(renderer_id); - - debug!( + info!( renderer = renderer_id.0.as_str(), - renderer_playing, - from_queue, - state = ?snapshot.state, - "start_queue_playback_if_idle: checking if should start playback" + index, + uri = item.uri.as_str(), + "Queue playback started at index" ); - // Only skip if the renderer is actually playing - // Don't skip just because playback_source is FromQueue - the renderer might have stopped - if renderer_playing { - debug!( - renderer = renderer_id.0.as_str(), - "start_queue_playback_if_idle: skipping because renderer is already playing" - ); - return Ok(()); - } + // Save metadata + let metadata = playback_item_track_metadata(&item); + renderer.set_last_metadata(Some(metadata)); - // Check if queue has ANY items (not just upcoming items after current) - // This is important for newly attached playlists with current_index set - let has_items = self - .music_renderer_by_id(renderer_id) - .ok() - .and_then(|r| r.get_queue().queue_snapshot().ok()) - .map(|s| !s.items.is_empty()) - .unwrap_or(false); - if !has_items { - debug!( - renderer = renderer_id.0.as_str(), - "start_queue_playback_if_idle: queue is empty" - ); - return Ok(()); - } - - // Play current item if index is set, otherwise play next (advances index to 0) - let has_current_index = self - .music_renderer_by_id(renderer_id) - .ok() - .and_then(|r| r.get_queue().queue_snapshot().ok()) - .and_then(|s| s.current_index) - .is_some(); - - if has_current_index { - self.play_current_from_queue(renderer_id) - } else { - self.play_next_from_queue(renderer_id) - } + renderer.set_playback_source(PlaybackSource::FromQueue); + Ok(()) } /// Stop playback in response to user action (e.g., Stop button in UI). @@ -1508,19 +1075,15 @@ impl ControlPoint { /// This method marks the stop as user-requested to prevent automatic /// advancement to the next track in the queue when the STOPPED event /// is received from the renderer. - pub fn user_stop(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { - // Mark that user requested stop before actually stopping - self.runtime.mark_user_stop_requested(renderer_id); - + pub fn user_stop(&self, renderer_id: &DeviceId) -> Result<(), ControlPointError> { // Get renderer and call stop let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { - warn!( - renderer = renderer_id.0.as_str(), - "Cannot stop: renderer not found in registry" - ); - anyhow!("Renderer {} not found", renderer_id.0) + ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0)) })?; + // Mark that user requested stop before actually stopping + renderer.mark_user_stop_requested(); + debug!(renderer = renderer_id.0.as_str(), "User-requested stop"); renderer.stop() @@ -1555,10 +1118,10 @@ impl ControlPoint { /// The queue will be automatically refreshed when the playlist changes on the server. pub fn attach_queue_to_playlist( &self, - renderer_id: &ServiceId, - server_id: ServerId, + renderer_id: &DeviceId, + server_id: DeviceId, container_id: String, - ) -> anyhow::Result<()> { + ) -> Result<(), ControlPointError> { self.attach_queue_to_playlist_with_options(renderer_id, server_id, container_id, false) } @@ -1567,22 +1130,22 @@ impl ControlPoint { /// Same queue-mutation guarantees as [`attach_queue_to_playlist`]. pub fn attach_queue_to_playlist_with_options( &self, - renderer_id: &ServiceId, - server_id: ServerId, + renderer_id: &DeviceId, + server_id: DeviceId, container_id: String, auto_play: bool, - ) -> anyhow::Result<()> { + ) -> Result<(), ControlPointError> { self.attach_queue_to_playlist_internal(renderer_id, &server_id, &container_id, auto_play) } /// Internal implementation shared by every attach wrapper. fn attach_queue_to_playlist_internal( &self, - renderer_id: &ServiceId, - server_id: &ServerId, + renderer_id: &DeviceId, + server_id: &DeviceId, container_id: &str, auto_play: bool, - ) -> anyhow::Result<()> { + ) -> Result<(), ControlPointError> { // CRITICAL: When attaching a new playlist to a renderer, we must UNCONDITIONALLY // clear the RENDERER queue first (but NOT the local queue cache, which will be // replaced by refresh_attached_queue_for() using replace_entire_playlist()). @@ -1598,7 +1161,7 @@ impl ControlPoint { // Prepare the renderer for the new playlist (backend-agnostic) let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { - anyhow!("Renderer {} not found", renderer_id.0) + ControlPointError::ControlPoint(format!("Renderer {} not found", renderer_id.0)) })?; renderer.clear_for_playlist_attach()?; @@ -1622,9 +1185,9 @@ impl ControlPoint { auto_play_on_refresh: auto_play, }; - let renderer = self - .music_renderer_by_id(renderer_id) - .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + ControlPointError::ControlPoint(format!("Renderer {} not found", renderer_id.0)) + })?; renderer.set_playlist_binding(Some(binding.clone())); info!( @@ -1641,26 +1204,21 @@ impl ControlPoint { }); // For initial attach with auto_play, force playback start (don't check if idle) - let mut auto_start_cb = |rid: &ServiceId| { + let mut auto_start_cb = |rid: &DeviceId| { debug!( renderer = rid.0.as_str(), "Attach callback: forcing playback start (not checking if idle)" ); self.play_current_from_queue(rid) }; - let callback: Option<&mut dyn FnMut(&ServiceId) -> anyhow::Result<()>> = if auto_play { - Some(&mut auto_start_cb) - } else { - None - }; + let callback: Option<&mut dyn FnMut(&DeviceId) -> Result<(), ControlPointError>> = + if auto_play { + Some(&mut auto_start_cb) + } else { + None + }; - refresh_attached_queue_for( - &self.registry, - &self.runtime, - renderer_id, - &self.event_bus, - callback, - ) + refresh_attached_queue_for(&self.registry, renderer_id, &self.event_bus, callback) } /// Detach a renderer's queue from its associated playlist container. @@ -1668,7 +1226,7 @@ impl ControlPoint { /// Public mutation API paired with `attach_queue_to_playlist*`. After calling /// this, the queue will no longer be automatically refreshed from the server. /// If no binding existed, this is a no-op. - pub fn detach_queue_playlist(&self, renderer_id: &ServiceId) { + pub fn detach_queue_playlist(&self, renderer_id: &DeviceId) { self.detach_playlist_binding(renderer_id, "api_detach"); } @@ -1678,8 +1236,8 @@ impl ControlPoint { /// bound to a server playlist container, or `None` otherwise. pub fn current_queue_playlist_binding( &self, - renderer_id: &ServiceId, - ) -> Option<(ServerId, String, bool)> { + renderer_id: &DeviceId, + ) -> Option<(DeviceId, String, bool)> { let renderer = self.music_renderer_by_id(renderer_id)?; renderer.get_playlist_binding().map(|binding| { ( @@ -1694,7 +1252,7 @@ impl ControlPoint { /// /// Invariant: every user-driven queue mutation **must** call this method so /// that bindings never become out of sync with the local queue snapshot. - fn detach_playlist_binding(&self, renderer_id: &ServiceId, reason: &str) { + fn detach_playlist_binding(&self, renderer_id: &DeviceId, reason: &str) { let renderer = match self.music_renderer_by_id(renderer_id) { Some(r) => r, None => return, @@ -1731,16 +1289,20 @@ impl ControlPoint { fn handle_renderer_event(&self, event: &RendererEvent) { if let RendererEvent::StateChanged { id, state } = event { + let Some(renderer) = self.music_renderer_by_id(id) else { + return; + }; + match state { PlaybackState::Stopped => { // Check if user requested stop (via Stop button in UI) - if self.runtime.check_and_clear_user_stop_requested(id) { + if renderer.check_and_clear_user_stop_requested() { debug!( renderer = id.0.as_str(), "Renderer stopped by user request; not auto-advancing" ); - self.runtime.set_playback_source(id, PlaybackSource::None); - } else if self.runtime.is_playing_from_queue(id) { + renderer.set_playback_source(PlaybackSource::None); + } else if renderer.is_playing_from_queue() { debug!( renderer = id.0.as_str(), "Renderer stopped after queue-driven playback; advancing" @@ -1751,27 +1313,19 @@ impl ControlPoint { error = %err, "Auto-advance failed; clearing queue playback state" ); - self.runtime.set_playback_source(id, PlaybackSource::None); + renderer.set_playback_source(PlaybackSource::None); } } else { - self.runtime.set_playback_source(id, PlaybackSource::None); + renderer.set_playback_source(PlaybackSource::None); } } PlaybackState::Playing => { - self.runtime.mark_external_if_idle(id); + renderer.mark_external_if_idle(); } _ => {} } } } - - fn runtime_entry_missing(renderer_id: &ServiceId) -> anyhow::Error { - anyhow!( - "Renderer {} not registered in control point runtime", - renderer_id.0 - ) - } - } #[cfg(feature = "pmoserver")] @@ -1800,6 +1354,8 @@ fn parse_hms_to_ms(hms: Option<&str>) -> Option { Some((hours * 3600 + minutes * 60 + seconds) * 1000) } +/// Snapshot of renderer state used for change detection in the polling thread. +/// This is a local cache, not a source of truth. #[derive(Clone, Default)] struct RendererRuntimeSnapshot { state: Option, @@ -1809,169 +1365,6 @@ struct RendererRuntimeSnapshot { last_metadata: Option, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -enum PlaybackSource { - #[default] - None, - FromQueue, - External, -} - -struct RendererRuntimeEntry { - snapshot: RendererRuntimeSnapshot, - playback_source: PlaybackSource, - user_stop_requested: bool, -} - -impl Default for RendererRuntimeEntry { - fn default() -> Self { - Self { - snapshot: RendererRuntimeSnapshot::default(), - playback_source: PlaybackSource::None, - user_stop_requested: false, - } - } -} - -struct RuntimeState { - entries: Mutex>, -} - -struct RuntimeOpenHomeQueueProvider { - runtime: Arc, -} - -// impl OpenHomeQueueProvider for RuntimeOpenHomeQueueProvider { -// fn renderer_state(&self, renderer_id: &RendererId) -> anyhow::Result { -// self.runtime.renderer_state(renderer_id) -// } - -// fn renderer_state_mut<'a>( -// &'a self, -// renderer_id: &RendererId, -// ) -> anyhow::Result> { -// self.runtime.renderer_state_mut(renderer_id) -// } - -// fn invalidate_openhome_cache(&self, renderer_id: &RendererId) -> anyhow::Result<()> { -// self.runtime.invalidate_openhome_cache(renderer_id); -// Ok(()) -// } -// } - -impl RuntimeState { - fn new() -> Self { - Self { - entries: Mutex::new(HashMap::new()), - } - } - - fn snapshot_for(&self, id: &ServiceId) -> RendererRuntimeSnapshot { - let entries = self.entries.lock().unwrap(); - entries - .get(id) - .map(|entry| entry.snapshot.clone()) - .unwrap_or_default() - } - - fn update_snapshot(&self, id: &ServiceId, snapshot: RendererRuntimeSnapshot) { - self.with_entry(id, |entry| { - entry.snapshot = snapshot; - }); - } - - fn update_snapshot_with(&self, id: &ServiceId, f: F) - where - F: FnOnce(&mut RendererRuntimeSnapshot), - { - self.with_entry(id, |entry| { - f(&mut entry.snapshot); - }); - } - - fn has_entry(&self, id: &ServiceId) -> bool { - let entries = self.entries.lock().unwrap(); - entries.contains_key(id) - } - - - // fn renderer_state(&self, id: &RendererId) -> anyhow::Result { - // let entries = self.entries.lock().unwrap(); - // let entry = entries - // .get(id) - // .ok_or_else(|| anyhow!("Renderer {} not registered in runtime", id.0))?; - // Ok(RendererRuntimeState { - // queue: entry.queue.clone(), - // }) - // } - - fn current_track_metadata(&self, id: &ServiceId) -> Option { - let entries = self.entries.lock().unwrap(); - entries - .get(id) - .and_then(|entry| entry.snapshot.last_metadata.clone()) - } - - - fn set_playback_source(&self, id: &ServiceId, source: PlaybackSource) { - let mut entries = self.entries.lock().unwrap(); - if let Some(entry) = entries.get_mut(id) { - entry.playback_source = source; - } - } - - fn playback_source(&self, id: &ServiceId) -> PlaybackSource { - let entries = self.entries.lock().unwrap(); - entries - .get(id) - .map(|entry| entry.playback_source) - .unwrap_or(PlaybackSource::None) - } - - fn is_playing_from_queue(&self, id: &ServiceId) -> bool { - matches!(self.playback_source(id), PlaybackSource::FromQueue) - } - - fn mark_external_if_idle(&self, id: &ServiceId) { - let mut entries = self.entries.lock().unwrap(); - if let Some(entry) = entries.get_mut(id) { - if matches!(entry.playback_source, PlaybackSource::None) { - entry.playback_source = PlaybackSource::External; - } - } - } - - fn with_entry(&self, id: &ServiceId, f: F) -> R - where - F: FnOnce(&mut RendererRuntimeEntry) -> R, - { - let mut entries = self.entries.lock().unwrap(); - let entry = entries - .entry(id.clone()) - .or_insert_with(RendererRuntimeEntry::default); - f(entry) - } - - - fn mark_user_stop_requested(&self, id: &ServiceId) { - let mut entries = self.entries.lock().unwrap(); - if let Some(entry) = entries.get_mut(id) { - entry.user_stop_requested = true; - } - } - - fn check_and_clear_user_stop_requested(&self, id: &ServiceId) -> bool { - let mut entries = self.entries.lock().unwrap(); - if let Some(entry) = entries.get_mut(id) { - let was_requested = entry.user_stop_requested; - entry.user_stop_requested = false; - was_requested - } else { - false - } - } -} - /// Internal helper to refresh a renderer's playback queue from its bound /// playlist container. /// @@ -1980,12 +1373,11 @@ impl RuntimeState { /// currently playing item when possible. fn refresh_attached_queue_for( registry: &Arc>, - runtime: &Arc, - renderer_id: &ServiceId, + renderer_id: &DeviceId, event_bus: &RendererEventBus, - mut after_refresh: Option<&mut dyn FnMut(&ServiceId) -> anyhow::Result<()>>, -) -> anyhow::Result<()> { - // Step 1: Get renderer and check binding + mut after_refresh: Option<&mut dyn FnMut(&DeviceId) -> Result<(), ControlPointError>>, +) -> Result<(), ControlPointError> { + // Step 1: Get renderer from registry let renderer = { let reg = registry.read().unwrap(); reg.get_renderer(renderer_id) @@ -2002,7 +1394,16 @@ fn refresh_attached_queue_for( } }; - let (server_id, container_id, auto_play) = { + // Check if there's a binding and if it needs refresh + if !renderer.has_pending_refresh() { + debug!( + renderer = renderer_id.0.as_str(), + "refresh_attached_queue_for: no pending refresh needed" + ); + return Ok(()); + } + + let (server_id, container_id) = { let binding = match renderer.get_playlist_binding() { Some(b) => b, None => { @@ -2014,36 +1415,21 @@ fn refresh_attached_queue_for( } }; - if !binding.pending_refresh { - debug!( - renderer = renderer_id.0.as_str(), - "refresh_attached_queue_for: pending_refresh is false, nothing to do" - ); - return Ok(()); - } - - // Mark as processed - clone, modify, and set back - let mut updated_binding = binding.clone(); - updated_binding.pending_refresh = false; - let auto_play = updated_binding.auto_play_on_refresh; - updated_binding.auto_play_on_refresh = false; - renderer.set_playlist_binding(Some(updated_binding)); - - ( - binding.server_id.clone(), - binding.container_id.clone(), - auto_play, - ) + (binding.server_id.clone(), binding.container_id.clone()) }; - // Step 2: Fetch MediaServerInfo from registry - let server_info = { + // Reset the pending_refresh flag and consume auto_play + renderer.reset_pending_refresh(); + let auto_play = renderer.consume_auto_play(); + + // Step 2: Get server from registry + let music_server = { let reg = registry.read().unwrap(); reg.get_server(&server_id) }; - let server_info = match server_info { - Some(info) => info, + let music_server = match music_server { + Some(s) => s, None => { warn!( renderer = renderer_id.0.as_str(), @@ -2054,7 +1440,7 @@ fn refresh_attached_queue_for( } }; - if !server_info.online { + if !music_server.is_online() { debug!( renderer = renderer_id.0.as_str(), server = server_id.0.as_str(), @@ -2063,17 +1449,7 @@ fn refresh_attached_queue_for( return Ok(()); } - if !server_info.has_content_directory { - debug!( - renderer = renderer_id.0.as_str(), - server = server_id.0.as_str(), - "refresh_attached_queue_for: server has no ContentDirectory" - ); - return Ok(()); - } - - // Step 3: Create MusicServer and browse container - let music_server = UpnpMediaServer::from_info(&server_info, Duration::from_secs(5))?; + // Step 3: Browse container const MAX_BROWSE_ATTEMPTS: usize = 3; const BROWSE_RETRY_DELAY_MS: u64 = 200; @@ -2123,7 +1499,7 @@ fn refresh_attached_queue_for( // Step 4: Convert MediaEntry to PlaybackItem let new_items: Vec = entries .iter() - .filter_map(|entry| playback_item_from_entry(&music_server, entry)) + .filter_map(|entry| playback_item_from_entry(music_server.clone(), entry)) .collect(); if new_items.is_empty() { @@ -2132,12 +1508,9 @@ fn refresh_attached_queue_for( server = server_id.0.as_str(), container = container_id.as_str(), total_entries = entries.len(), - "Refreshed playlist is empty, clearing queue - all entries were filtered out" + "Refreshed playlist is empty, clearing queue" ); - { - let mut queue = renderer.queue.lock().unwrap(); - queue.clear_queue()?; - } + renderer.clear_queue()?; // Emit QueueUpdated event event_bus.broadcast(RendererEvent::QueueUpdated { @@ -2148,154 +1521,22 @@ fn refresh_attached_queue_for( return Ok(()); } - // Step 5: GENTLE SYNCHRONIZATION - // Use incremental replace_queue() instead of replace_with_attached_playlist() + // Step 5: GENTLE SYNCHRONIZATION using sync_queue() // This uses LCS algorithm to minimize playlist operations and avoid interrupting playback - // Get the full queue snapshot to access the item currently being played - let (full_queue, current_idx) = { - let reg = registry.read().unwrap(); - reg.renderer_by_id(renderer_id) - .and_then(|info| MusicRenderer::from_renderer_info(info).ok()) - .and_then(|r| { - let queue = r.get_queue(); - queue.queue_snapshot().ok().map(|s| (s.items, s.current_index)) - }) - .unwrap_or((vec![], None)) - }; + info!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + container = container_id.as_str(), + total_items = new_items.len(), + "Refreshing playlist with sync_queue" + ); - // Get the item currently being played (at current_index), not the next one in queue - let current_item = current_idx.and_then(|idx| full_queue.get(idx).cloned()); - - // Check if renderer is playing - if so, we MUST preserve the current track - // We use multiple signals to determine playback state: - // 1. Direct playback_state() query (may fail on some renderers like upmpdcli) - // 2. Presence of current_idx (if we have a current track, likely playing) - let is_playing = { - let renderer_info = { - let reg = registry.read().unwrap(); - reg.get_renderer(renderer_id) - }; - - if let Some(info) = renderer_info { - if let Some(renderer) = MusicRendererBackend::from_renderer_info(info, registry) { - // Try direct query first - if matches!(renderer.playback_state(), Ok(PlaybackState::Playing)) { - true - } else if current_idx.is_some() && !full_queue.is_empty() { - // Fallback: if we have a current index and non-empty queue, - // assume playback is happening (handles renderers where playback_state() fails) - debug!( - renderer = renderer_id.0.as_str(), - "playback_state() failed or not Playing, but current_idx is set - assuming playback" - ); - true - } else { - false - } - } else { - false - } - } else { - false - } - }; - - let item_found_at = current_item.as_ref().and_then(|current| { - let current_uid = current.unique_id(); - new_items - .iter() - .position(|new_item| new_item.unique_id() == current_uid) - .or_else(|| { - new_items - .iter() - .position(|new_item| new_item.uri == current.uri) - }) - }); + renderer.sync_queue(new_items)?; let final_queue_len = { - let music_renderer = { - let reg = registry.read().unwrap(); - reg.renderer_by_id(renderer_id) - .and_then(|info| MusicRenderer::from_renderer_info(info).ok()) - }; - - let music_renderer = match music_renderer { - Some(r) => r, - None => { - warn!( - renderer = renderer_id.0.as_str(), - "refresh_attached_queue_for: renderer not found in registry" - ); - return Ok(()); - } - }; - - let mut queue = music_renderer.get_queue_mut(); - if let Some(idx) = item_found_at { - // Current item is in new playlist - use gentle incremental update - queue.replace_queue(new_items.clone(), Some(idx))?; - info!( - renderer = renderer_id.0.as_str(), - server = server_id.0.as_str(), - container = container_id.as_str(), - total_items = new_items.len(), - current_index = idx, - upcoming = new_items.len().saturating_sub(idx + 1), - current_preserved = true, - is_playing, - "Gentle refresh: current item found in new playlist" - ); - new_items.len() - } else if let Some(ref current) = current_item { - // Current item NOT in new playlist - if is_playing { - // CRITICAL: Preserve current playing track by inserting it at the beginning - let mut combined = Vec::with_capacity(new_items.len() + 1); - combined.push(current.clone()); - combined.extend(new_items.clone()); - queue.replace_queue(combined, Some(0))?; - info!( - renderer = renderer_id.0.as_str(), - server = server_id.0.as_str(), - container = container_id.as_str(), - total_items = new_items.len() + 1, - current_index = 0, - upcoming = new_items.len(), - current_preserved = true, - current_reinserted = true, - is_playing = true, - "Gentle refresh: preserved playing track not in new playlist" - ); - new_items.len() + 1 - } else { - // Not playing, use new playlist as-is - queue.replace_queue(new_items.clone(), None)?; - info!( - renderer = renderer_id.0.as_str(), - server = server_id.0.as_str(), - container = container_id.as_str(), - total_items = new_items.len(), - current_preserved = false, - is_playing = false, - "Gentle refresh: replaced queue (not playing)" - ); - new_items.len() - } - } else { - // No current item, use new playlist as-is - queue.replace_queue(new_items.clone(), None)?; - info!( - renderer = renderer_id.0.as_str(), - server = server_id.0.as_str(), - container = container_id.as_str(), - total_items = new_items.len(), - current_preserved = false, - is_playing, - "Gentle refresh: no current item" - ); - new_items.len() - } + let snapshot = renderer.queue_snapshot()?; + snapshot.items.len() }; // Emit QueueUpdated event @@ -2344,38 +1585,6 @@ fn refresh_attached_queue_for( Ok(()) } -const OPENHOME_TRACK_PREFIX: &str = "openhome:"; - -fn playback_item_from_openhome_track( - renderer_id: &ServiceId, - track: &OpenHomePlaylistTrack, -) -> PlaybackItem { - let metadata = TrackMetadata { - title: track.title.clone(), - artist: track.artist.clone(), - album: track.album.clone(), - genre: None, - album_art_uri: track.album_art_uri.clone(), - date: None, - track_number: None, - creator: None, - }; - - PlaybackItem { - media_server_id: ServerId(format!("openhome:{}", renderer_id.0)), - didl_id: format!("{}{}", OPENHOME_TRACK_PREFIX, track.id), - uri: track.uri.clone(), - // OpenHome tracks don't provide protocolInfo, use generic default - protocol_info: "http-get:*:audio/*:*".to_string(), - metadata: Some(metadata), - } -} - -fn openhome_track_id_from_item(item: &PlaybackItem) -> Option { - let raw = item.didl_id.strip_prefix(OPENHOME_TRACK_PREFIX)?; - raw.parse::().ok() -} - fn didl_item_from_playback_item(item: &PlaybackItem) -> DidlItem { let metadata = item.metadata.as_ref(); let title = metadata @@ -2447,851 +1656,6 @@ fn playback_item_track_metadata(item: &PlaybackItem) -> TrackMetadata { }) } -const OPENHOME_SUBSCRIPTION_TIMEOUT_SECS: u64 = 300; -const OPENHOME_RENEWAL_MARGIN_SECS: u64 = 60; - -fn spawn_openhome_event_runtime( - registry: Arc>, - runtime: Arc, - event_bus: RendererEventBus, - event_tx: Sender, -) -> io::Result<()> { - let listener = TcpListener::bind("0.0.0.0:0")?; - let listener_addr = listener - .local_addr() - .map_err(|err| io::Error::new(io::ErrorKind::Other, err))?; - - info!("OpenHome event listener bound on {}", listener_addr); - - let (notify_tx, notify_rx) = unbounded::(); - thread::Builder::new() - .name("openhome-event-http".into()) - .spawn(move || run_openhome_http_listener(listener, notify_tx))?; - - let worker = OpenHomeEventRuntime::new( - registry, - runtime, - event_bus, - notify_rx, - event_tx, - listener_addr.port(), - ); - - thread::Builder::new() - .name("openhome-event-worker".into()) - .spawn(move || worker.run()) - .map(|_| ()) -} - -struct OpenHomeEventRuntime { - registry: Arc>, - runtime: Arc, - event_bus: RendererEventBus, - notify_rx: Receiver, - event_tx: Sender, - listener_port: u16, - http_timeout: Duration, - subscriptions: HashMap, - path_index: HashMap, -} - -impl OpenHomeEventRuntime { - fn new( - registry: Arc>, - runtime: Arc, - event_bus: RendererEventBus, - notify_rx: Receiver, - event_tx: Sender, - listener_port: u16, - ) -> Self { - Self { - registry, - runtime, - event_bus, - notify_rx, - event_tx, - listener_port, - http_timeout: Duration::from_secs(5), - subscriptions: HashMap::new(), - path_index: HashMap::new(), - } - } - - fn run(mut self) { - loop { - self.drain_notifications(); - self.refresh_renderers(); - self.renew_expiring(); - thread::sleep(Duration::from_millis(250)); - } - } - - fn drain_notifications(&mut self) { - while let Ok(notify) = self.notify_rx.try_recv() { - self.handle_notification(notify); - } - } - - fn refresh_renderers(&mut self) { - let renderer_infos = { - let reg = self.registry.read().unwrap(); - reg.list_renderers() - }; - - let mut active: HashSet = HashSet::new(); - - for info in renderer_infos { - if !info.online { - continue; - } - - if let Some(url) = info.oh_playlist_event_sub_url.clone() { - let key = OpenHomeSubscriptionKey::new(&info.id, OhServiceKind::Playlist); - active.insert(key.clone()); - self.ensure_subscription(key, info.clone(), url); - } - - if let Some(url) = info.oh_info_event_sub_url.clone() { - let key = OpenHomeSubscriptionKey::new(&info.id, OhServiceKind::Info); - active.insert(key.clone()); - self.ensure_subscription(key, info.clone(), url); - } - - if let Some(url) = info.oh_time_event_sub_url.clone() { - let key = OpenHomeSubscriptionKey::new(&info.id, OhServiceKind::Time); - active.insert(key.clone()); - self.ensure_subscription(key, info.clone(), url); - } - } - - let stale: Vec = self - .subscriptions - .keys() - .filter(|key| !active.contains(*key)) - .cloned() - .collect(); - - for key in stale { - if let Some(mut entry) = self.subscriptions.remove(&key) { - self.path_index.remove(&entry.callback_path); - if let Err(err) = Self::unsubscribe_entry(self.http_timeout, &mut entry) { - warn!( - renderer = entry.renderer.friendly_name.as_str(), - service = entry.service.as_str(), - error = %err, - "Failed to unsubscribe from OpenHome events" - ); - } - } - } - } - - fn ensure_subscription( - &mut self, - key: OpenHomeSubscriptionKey, - info: RendererInfo, - event_url: String, - ) { - let entry = self.subscriptions.entry(key.clone()).or_insert_with(|| { - OpenHomeSubscriptionState::new(info.clone(), key.service, event_url.clone()) - }); - - entry.update(info, event_url); - self.path_index - .insert(entry.callback_path.clone(), key.clone()); - - if entry.sid.is_none() && entry.should_retry() { - if let Err(err) = Self::subscribe_entry(self.listener_port, self.http_timeout, entry) { - warn!( - renderer = entry.renderer.friendly_name.as_str(), - service = entry.service.as_str(), - error = %err, - "OpenHome SUBSCRIBE failed" - ); - entry.defer_retry(); - } - } - } - - fn renew_expiring(&mut self) { - let now = Instant::now(); - let mut to_renew = Vec::new(); - for (key, entry) in self.subscriptions.iter() { - if let Some(exp) = entry.expires_at { - if exp <= now + Duration::from_secs(OPENHOME_RENEWAL_MARGIN_SECS) { - to_renew.push(key.clone()); - } - } - } - - for key in to_renew { - if let Some(entry) = self.subscriptions.get_mut(&key) { - if let Err(err) = Self::renew_entry(self.http_timeout, entry) { - warn!( - renderer = entry.renderer.friendly_name.as_str(), - service = entry.service.as_str(), - error = %err, - "Failed to renew OpenHome subscription" - ); - entry.reset_subscription(); - } - } - } - } - - fn handle_notification(&mut self, notify: OpenHomeIncomingNotify) { - let Some(key) = self.path_index.get(¬ify.path).cloned() else { - debug!("Dropping OpenHome notify for unknown path {}", notify.path); - return; - }; - - let Some(entry) = self.subscriptions.get(&key) else { - return; - }; - - if let (Some(expected), Some(received)) = (&entry.sid, ¬ify.sid) { - if !expected.eq_ignore_ascii_case(received) { - debug!( - renderer = entry.renderer.friendly_name.as_str(), - service = entry.service.as_str(), - expected_sid = expected.as_str(), - received_sid = received.as_str(), - "Ignoring OpenHome notify with mismatched SID" - ); - return; - } - } - - let properties = - parse_openhome_propertyset(&entry.renderer.id, &entry.service, ¬ify.body); - if properties.is_empty() { - return; - } - - match entry.service { - OhServiceKind::Playlist => { - if properties - .iter() - .any(|(name, _)| is_id_array_property(name)) - { - // OpenHome playlist changed event - // Queue synchronization is now handled by the generic queue backend - // and doesn't need special treatment here - debug!( - renderer = entry.renderer.friendly_name.as_str(), - "Received OpenHome IdArray change event" - ); - } - } - OhServiceKind::Info => { - self.handle_info_properties(&entry.renderer.id, properties); - } - OhServiceKind::Time => { - self.handle_time_properties(&entry.renderer.id, properties); - } - OhServiceKind::Volume | OhServiceKind::Product => {} - } - } - - fn handle_info_properties(&self, renderer_id: &ServiceId, properties: Vec<(String, String)>) { - let mut metadata_xml: Option = None; - let mut transport_state: Option = None; - let mut track_id: Option = None; - let mut track_uri: Option = None; - - for (name, value) in properties { - match name.as_str() { - "Metadata" | "TrackMetadata" => { - if !value.trim().is_empty() { - metadata_xml = Some(value); - } - } - "TransportState" => { - transport_state = Some(value); - } - "Id" | "TrackId" => { - if let Ok(id) = value.trim().parse::() { - track_id = Some(id); - } - } - "Uri" | "TrackUri" => { - if !value.trim().is_empty() { - track_uri = Some(value); - } - } - _ => {} - } - } - - if let Some(xml) = metadata_xml { - if let Some(metadata) = parse_track_metadata_from_didl(&xml) { - self.runtime.update_snapshot_with(renderer_id, |snapshot| { - snapshot.last_metadata = Some(metadata.clone()); - let mut position = snapshot - .position - .clone() - .unwrap_or_else(|| empty_playback_position()); - position.track_metadata = Some(xml.clone()); - snapshot.position = Some(position); - }); - let _ = self.event_tx.send(RendererEvent::MetadataChanged { - id: renderer_id.clone(), - metadata, - }); - } - } - - if track_id.is_some() || track_uri.is_some() { - self.runtime.update_snapshot_with(renderer_id, |snapshot| { - let mut position = snapshot - .position - .clone() - .unwrap_or_else(|| empty_playback_position()); - if let Some(id) = track_id { - position.track = Some(id); - } - if let Some(uri) = track_uri.clone() { - position.track_uri = Some(uri); - } - snapshot.position = Some(position); - }); - } - - if let Some(state_str) = transport_state { - let playback_state = map_openhome_state(&state_str); - let _ = self.event_tx.send(RendererEvent::StateChanged { - id: renderer_id.clone(), - state: playback_state, - }); - } - } - - fn handle_time_properties(&self, renderer_id: &ServiceId, properties: Vec<(String, String)>) { - let mut duration: Option = None; - let mut seconds: Option = None; - - for (name, value) in properties { - match name.as_str() { - "Duration" => { - duration = value.trim().parse::().ok(); - } - "Seconds" => { - seconds = value.trim().parse::().ok(); - } - _ => {} - } - } - - if duration.is_none() && seconds.is_none() { - return; - } - - let position = self.runtime.snapshot_for(renderer_id).position; - let mut new_position = position.unwrap_or_else(|| empty_playback_position()); - if let Some(d) = duration { - new_position.track_duration = Some(format_hhmmss_u32(d)); - } - if let Some(s) = seconds { - new_position.rel_time = Some(format_hhmmss_u32(s)); - } - - self.runtime.update_snapshot_with(renderer_id, |snapshot| { - snapshot.position = Some(new_position.clone()); - }); - - let _ = self.event_tx.send(RendererEvent::PositionChanged { - id: renderer_id.clone(), - position: new_position, - }); - } - - fn subscribe_entry( - listener_port: u16, - http_timeout: Duration, - entry: &mut OpenHomeSubscriptionState, - ) -> anyhow::Result<()> { - let event_url = entry.event_sub_url.clone(); - let (remote_host, remote_port) = - parse_host_port(&event_url).context("Cannot extract host for SUBSCRIBE")?; - 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 - ); - - debug!( - renderer = entry.renderer.friendly_name.as_str(), - service = entry.service.as_str(), - callback = callback_url.as_str(), - "Subscribing to OpenHome events" - ); - - let host_header = format!("{}:{}", remote_host, remote_port); - let timeout_header = format!("Second-{}", OPENHOME_SUBSCRIPTION_TIMEOUT_SECS); - let callback_header = format!("<{}>", callback_url); - - let request = http::Request::builder() - .method("SUBSCRIBE") - .uri(&event_url) - .header("HOST", host_header) - .header("CALLBACK", callback_header) - .header("NT", "upnp:event") - .header("TIMEOUT", timeout_header) - .body(()) - .map_err(anyhow::Error::new)?; - - let response = build_agent(http_timeout).run(request)?; - if !response.status().is_success() { - anyhow::bail!("SUBSCRIBE returned HTTP {}", response.status()); - } - - let sid = response - .headers() - .get("SID") - .and_then(|value| value.to_str().ok()) - .map(|s| s.to_string()) - .ok_or_else(|| anyhow::anyhow!("SUBSCRIBE response missing SID"))?; - let timeout = parse_timeout( - response - .headers() - .get("TIMEOUT") - .and_then(|value| value.to_str().ok()), - ) - .unwrap_or(Duration::from_secs(OPENHOME_SUBSCRIPTION_TIMEOUT_SECS)); - - entry.sid = Some(sid); - entry.expires_at = Some(Instant::now() + timeout); - entry.retry_after = Instant::now() + Duration::from_secs(5); - - info!( - renderer = entry.renderer.friendly_name.as_str(), - service = entry.service.as_str(), - "Subscribed to OpenHome events (timeout {}s)", - timeout.as_secs() - ); - - Ok(()) - } - - fn renew_entry( - http_timeout: Duration, - entry: &mut OpenHomeSubscriptionState, - ) -> anyhow::Result<()> { - let sid = entry.sid.clone().context("Cannot renew without SID")?; - let request = http::Request::builder() - .method("SUBSCRIBE") - .uri(&entry.event_sub_url) - .header("SID", sid) - .header( - "TIMEOUT", - format!("Second-{}", OPENHOME_SUBSCRIPTION_TIMEOUT_SECS), - ) - .body(()) - .map_err(anyhow::Error::new)?; - let response = build_agent(http_timeout).run(request)?; - if !response.status().is_success() { - anyhow::bail!("SUBSCRIBE renewal failed with {}", response.status()); - } - let timeout = parse_timeout( - response - .headers() - .get("TIMEOUT") - .and_then(|value| value.to_str().ok()), - ) - .unwrap_or(Duration::from_secs(OPENHOME_SUBSCRIPTION_TIMEOUT_SECS)); - entry.expires_at = Some(Instant::now() + timeout); - info!( - renderer = entry.renderer.friendly_name.as_str(), - service = entry.service.as_str(), - "Renewed OpenHome subscription (timeout {}s)", - timeout.as_secs() - ); - Ok(()) - } - - fn unsubscribe_entry( - http_timeout: Duration, - entry: &mut OpenHomeSubscriptionState, - ) -> anyhow::Result<()> { - let sid = match entry.sid.take() { - Some(sid) => sid, - None => return Ok(()), - }; - - let request = http::Request::builder() - .method("UNSUBSCRIBE") - .uri(&entry.event_sub_url) - .header("SID", sid) - .body(()) - .map_err(anyhow::Error::new)?; - let response = build_agent(http_timeout).run(request)?; - if !response.status().is_success() { - warn!( - renderer = entry.renderer.friendly_name.as_str(), - service = entry.service.as_str(), - status = response.status().as_u16(), - "UNSUBSCRIBE returned non-success status" - ); - } - Ok(()) - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -struct OpenHomeSubscriptionKey { - renderer_id: ServiceId, - service: OhServiceKind, -} - -impl OpenHomeSubscriptionKey { - fn new(renderer_id: &ServiceId, service: OhServiceKind) -> Self { - Self { - renderer_id: renderer_id.clone(), - service, - } - } -} - -struct OpenHomeSubscriptionState { - renderer: RendererInfo, - service: OhServiceKind, - event_sub_url: String, - callback_path: String, - sid: Option, - expires_at: Option, - retry_after: Instant, -} - -impl OpenHomeSubscriptionState { - fn new(renderer: RendererInfo, service: OhServiceKind, event_sub_url: String) -> Self { - Self { - callback_path: build_openhome_callback_path(&renderer.id, service), - renderer, - service, - event_sub_url, - sid: None, - expires_at: None, - retry_after: Instant::now(), - } - } - - fn update(&mut self, renderer: RendererInfo, event_url: String) { - if self.renderer.location != renderer.location || self.event_sub_url != event_url { - self.event_sub_url = event_url; - self.sid = None; - self.expires_at = None; - self.retry_after = Instant::now(); - } - self.renderer = renderer; - } - - fn should_retry(&self) -> bool { - Instant::now() >= self.retry_after - } - - fn defer_retry(&mut self) { - self.retry_after = Instant::now() + Duration::from_secs(15); - } - - fn reset_subscription(&mut self) { - self.sid = None; - self.expires_at = None; - self.retry_after = Instant::now() + Duration::from_secs(5); - } -} - -struct OpenHomeIncomingNotify { - path: String, - sid: Option, - body: Vec, -} - -fn run_openhome_http_listener(listener: TcpListener, notify_tx: Sender) { - for stream in listener.incoming() { - match stream { - Ok(mut stream) => { - if let Err(err) = stream.set_read_timeout(Some(Duration::from_secs(5))) { - warn!( - "Failed to set read timeout on OpenHome notify connection: {}", - err - ); - } - - match read_openhome_http_request(&mut stream) { - Ok(request) => { - if request.method != "NOTIFY" { - let _ = write_openhome_http_response( - &mut stream, - 405, - "Method Not Allowed", - ); - continue; - } - - let notify = OpenHomeIncomingNotify { - path: request.path, - sid: request.headers.get("sid").cloned(), - body: request.body, - }; - - if notify_tx.send(notify).is_err() { - warn!("Dropping OpenHome notify because worker channel is closed"); - } - let _ = write_openhome_http_response(&mut stream, 200, "OK"); - } - Err(err) => { - warn!("Failed to parse OpenHome notify request: {}", err); - let _ = write_openhome_http_response(&mut stream, 400, "Bad Request"); - } - } - } - Err(err) => { - warn!("Incoming OpenHome notify connection failed: {}", err); - } - } - } -} - -struct OpenHomeHttpRequest { - method: String, - path: String, - headers: HashMap, - body: Vec, -} - -fn read_openhome_http_request(stream: &mut TcpStream) -> io::Result { - let mut reader = BufReader::new(stream.try_clone()?); - let mut request_line = String::new(); - if reader.read_line(&mut request_line)? == 0 { - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "missing request line", - )); - } - - let request_line = request_line.trim_end_matches(&['\r', '\n'][..]); - let mut parts = request_line.split_whitespace(); - let method = parts - .next() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing method"))? - .to_ascii_uppercase(); - let path = parts - .next() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing path"))? - .to_string(); - - let mut headers = HashMap::new(); - loop { - let mut line = String::new(); - let len = reader.read_line(&mut line)?; - if len == 0 { - break; - } - let trimmed = line.trim_end_matches(&['\r', '\n'][..]); - if trimmed.is_empty() { - break; - } - if let Some((name, value)) = trimmed.split_once(':') { - headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string()); - } - } - - let content_length: usize = headers - .get("content-length") - .and_then(|v| v.parse().ok()) - .unwrap_or(0); - - let mut body = vec![0u8; content_length]; - reader.read_exact(&mut body)?; - - Ok(OpenHomeHttpRequest { - method, - path, - headers, - body, - }) -} - -fn write_openhome_http_response( - stream: &mut TcpStream, - status: u16, - message: &str, -) -> io::Result<()> { - let response = format!( - "HTTP/1.1 {} {}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - status, message - ); - stream.write_all(response.as_bytes()) -} - -fn build_openhome_callback_path(id: &ServiceId, service: OhServiceKind) -> String { - let mut sanitized = String::new(); - for ch in id.0.chars() { - if ch.is_ascii_alphanumeric() { - sanitized.push(ch); - } else { - sanitized.push('_'); - } - } - let mut hasher = DefaultHasher::new(); - id.hash(&mut hasher); - service.hash(&mut hasher); - let suffix = hasher.finish(); - format!("/openhome-events/{}/{:x}", service.as_str(), suffix) -} - -fn parse_openhome_propertyset( - renderer_id: &ServiceId, - service: &OhServiceKind, - body: &[u8], -) -> Vec<(String, String)> { - let mut properties = Vec::new(); - let reader = std::io::Cursor::new(body); - let Ok(root) = Element::parse(reader) else { - warn!( - renderer = renderer_id.0.as_str(), - service = service.as_str(), - "Failed to parse OpenHome notify payload" - ); - return properties; - }; - - 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, - }) { - if let Some(text) = child.get_text() { - properties.push((child.name.clone(), text.into_owned())); - } - } - } - - properties -} - -fn is_id_array_property(name: &str) -> bool { - name.trim().to_ascii_lowercase().ends_with("idarray") -} - -fn empty_playback_position() -> PlaybackPositionInfo { - PlaybackPositionInfo { - track: None, - rel_time: None, - abs_time: None, - track_duration: None, - track_metadata: None, - track_uri: None, - } -} - -fn parse_timeout(raw: Option<&str>) -> Option { - let value = raw?; - let lower = value.trim().to_ascii_lowercase(); - if lower == "second-infinite" { - return Some(Duration::from_secs(OPENHOME_SUBSCRIPTION_TIMEOUT_SECS)); - } - if let Some(idx) = lower.find("second-") { - let number = &lower[idx + 7..]; - if let Ok(seconds) = number.parse::() { - return Some(Duration::from_secs(seconds)); - } - } - None -} - -fn parse_host_port(url: &str) -> Option<(String, u16)> { - let default_port = if url.to_ascii_lowercase().starts_with("https://") { - 443 - } else { - 80 - }; - let (_, rest) = url.split_once("://")?; - let mut parts = rest.splitn(2, '/'); - let authority = parts.next()?.trim(); - if authority.starts_with('[') { - let end = authority.find(']')?; - let host = &authority[1..end]; - let remainder = authority.get(end + 1..).unwrap_or(""); - let port = if let Some(stripped) = remainder.strip_prefix(':') { - stripped.parse().unwrap_or(default_port) - } else { - default_port - }; - Some((host.to_string(), port)) - } else if let Some((host, port)) = authority.split_once(':') { - Some((host.to_string(), port.parse().ok()?)) - } else { - Some((authority.to_string(), default_port)) - } -} - -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 - ) - } else { - format!("{}:{}", remote_host, remote_port) - }; - let bind_addr = if is_ipv6 { "[::]:0" } else { "0.0.0.0:0" }; - let socket = UdpSocket::bind(bind_addr)?; - socket.connect(&target)?; - Ok(socket.local_addr()?.ip()) -} - -fn format_ip(ip: &IpAddr) -> String { - match ip { - IpAddr::V4(v4) => v4.to_string(), - IpAddr::V6(v6) => format!("[{}]", v6), - } -} - -fn build_agent(timeout: Duration) -> Agent { - Agent::config_builder() - .timeout_global(Some(timeout)) - .http_status_as_error(false) - .allow_non_standard_methods(true) - .build() - .into() -} - -/// Parse "HH:MM:SS" style time strings to seconds. -/// -/// Returns None for empty or sentinel values such as "NOT_IMPLEMENTED" or "-:--:--". -fn parse_hms_to_secs(s: &str) -> Option { - let s = s.trim(); - if s.is_empty() { - return None; - } - - // Common sentinel values for "no information" in UPnP implementations. - if s == "NOT_IMPLEMENTED" || s == "-:--:--" { - return None; - } - - let parts: Vec<_> = s.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) -} - fn parse_optional_hms_to_secs(value: &Option) -> Option { value.as_ref().and_then(|s| parse_hms_to_secs(s)) } @@ -3308,12 +1672,10 @@ fn compute_logical_playback_state( prev_position: Option<&PlaybackPositionInfo>, current_position: Option<&PlaybackPositionInfo>, ) -> PlaybackState { - use PlaybackState::*; - // Rule 1: Arylic / LinkPlay sometimes report STOPPED while the stream is // actually playing. If we detect that the relative time advances between // two polls, we treat this as Playing. - if let Stopped = raw { + if let PlaybackState::Stopped = raw { if let (Some(prev), Some(curr)) = (prev_position, current_position) { if let (Some(prev_rel), Some(curr_rel)) = ( parse_optional_hms_to_secs(&prev.rel_time), @@ -3323,7 +1685,7 @@ fn compute_logical_playback_state( let delta = curr_rel - prev_rel; // Our poll loop runs every 1s; accept small jitter in the delta. if delta <= 5 { - return Playing; + return PlaybackState::Playing; } } } @@ -3333,13 +1695,13 @@ fn compute_logical_playback_state( // Rule 2: Some devices report NO_MEDIA_PRESENT while exposing a non-zero // track duration. In practice this behaves like a stopped transport with // a loaded track. - if let NoMedia = raw { + if let PlaybackState::NoMedia = raw { let duration_secs = current_position .and_then(|p| parse_optional_hms_to_secs(&p.track_duration)) .or_else(|| prev_position.and_then(|p| parse_optional_hms_to_secs(&p.track_duration))); if matches!(duration_secs, Some(d) if d > 0) { - return Stopped; + return PlaybackState::Stopped; } } @@ -3420,3 +1782,29 @@ fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option Option { + let s = s.trim(); + if s.is_empty() { + return None; + } + + // Common sentinel values for "no information" in UPnP implementations. + if s == "NOT_IMPLEMENTED" || s == "-:--:--" { + return None; + } + + let parts: Vec<_> = s.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) +} diff --git a/pmocontrol/src/control_point_corrections.md b/pmocontrol/src/control_point_corrections.md new file mode 100644 index 00000000..6df5801d --- /dev/null +++ b/pmocontrol/src/control_point_corrections.md @@ -0,0 +1,130 @@ +# Corrections à appliquer à control_point.rs + +## Changements effectués avec succès : + +1. ✅ Thread de découverte UPNP : utilise maintenant `UpnpDiscoveryManager` +2. ✅ Boucle de polling : utilise `Arc` directement avec traits +3. ✅ Suppression du thread OpenHome event forwarder + +## Corrections restantes à faire manuellement : + +### 1. Thread de vérification des timeouts (ligne ~173) + +**Remplacer :** +```rust +let registry_for_presence = Arc::clone(®istry); +let event_bus_for_presence = event_bus.clone(); +let media_event_bus_for_presence = media_event_bus.clone(); +thread::spawn(move || { + use ureq::Agent; + // ... tout le code de vérification HTTP manuelle ... +}); +``` + +**Par :** +```rust +let registry_for_timeout = Arc::clone(®istry); + +thread::spawn(move || { + loop { + thread::sleep(Duration::from_secs(60)); + + // Le registry vérifie les timeouts et émet automatiquement les événements Offline + if let Ok(mut reg) = registry_for_timeout.write() { + reg.check_timeouts(); + } + } +}); +``` + +### 2. Thread Chromecast mDNS (à ajouter après le thread de timeout) + +**Ajouter :** +```rust +// Thread de découverte mDNS pour Chromecast +let registry_for_mdns = Arc::clone(®istry); +let udn_cache_for_mdns = Arc::clone(&udn_cache); +thread::spawn(move || { + use crate::discovery::ChromecastDiscoveryManager; + use futures_util::StreamExt; + + let mut discovery_manager = + ChromecastDiscoveryManager::new(registry_for_mdns, udn_cache_for_mdns); + + debug!("Starting mDNS discovery thread for Chromecast devices"); + + const SERVICE_NAME: &str = "_googlecast._tcp.local"; + + async_std::task::block_on(async { + match mdns::discover::all(SERVICE_NAME, Duration::from_secs(15)) { + Ok(discovery) => { + let stream = discovery.listen(); + futures_util::pin_mut!(stream); + + debug!("mDNS discovery stream started for Chromecast devices"); + + while let Some(result) = stream.next().await { + match result { + Ok(response) => { + debug!( + "Received mDNS response with {} records", + response.records().count() + ); + discovery_manager.handle_mdns_response(response); + } + Err(e) => { + warn!("mDNS discovery error: {}", e); + } + } + } + + warn!("mDNS discovery stream ended unexpectedly"); + } + Err(e) => { + error!("Failed to start mDNS discovery: {}", e); + } + } + }); +}); +``` + +### 3. Correction de la boucle de polling (ligne ~200) + +**Le type doit être :** +```rust +let renderers: Vec> = infos // Pas Vec +``` + +### 4. Retour du constructeur (fin de fonction) + +**Remplacer :** +```rust +Ok(Self { + registry, + event_bus, + media_event_bus, + runtime, +}) +``` + +**Par :** +```rust +Ok(Self { + registry, + udn_cache, + event_bus, + media_event_bus, + runtime, +}) +``` + +## Suppressions à faire : + +- [ ] Supprimer toutes les fonctions OpenHome (à partir de `spawn_openhome_event_runtime`) +- [ ] Supprimer `OpenHomeAccessError` enum +- [ ] Supprimer `OPENHOME_SNAPSHOT_CACHE_TTL` constante +- [ ] Nettoyer les imports obsolètes en haut du fichier + +## Note importante : + +Le code ne compilera pas tant que tous les imports obsolètes ne seront pas nettoyés, mais la structure sera correcte. diff --git a/pmocontrol/src/discovery/chromecast_discovery.rs b/pmocontrol/src/discovery/chromecast_discovery.rs index 2cbac7ba..7088163b 100644 --- a/pmocontrol/src/discovery/chromecast_discovery.rs +++ b/pmocontrol/src/discovery/chromecast_discovery.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::net::IpAddr; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; use crate::DeviceId; use crate::DeviceRegistry; @@ -17,13 +17,13 @@ use tracing::{debug, warn}; /// Gestionnaire des événements mDNS pour Chromecast. pub struct ChromecastDiscoveryManager { - device_registry: Arc>, + device_registry: Arc>, udn_cache: Arc>, } impl ChromecastDiscoveryManager { pub fn new( - device_registry: Arc>, + device_registry: Arc>, udn_cache: Arc>, ) -> Self { Self { @@ -173,7 +173,7 @@ impl ChromecastDiscoveryManager { // Register the renderer self.device_registry - .lock() + .write() .expect("DeviceRegistry mutex lock failed") .push_renderer(&renderer_info, default_max_age as u32); } diff --git a/pmocontrol/src/discovery/manager.rs b/pmocontrol/src/discovery/manager.rs index 4ad726c2..3d3aad95 100644 --- a/pmocontrol/src/discovery/manager.rs +++ b/pmocontrol/src/discovery/manager.rs @@ -1,7 +1,7 @@ use std::{ collections::HashMap, sync::{Arc, Mutex}, - time::{Instant, SystemTime}, + time::Instant, }; struct UDNSeen { @@ -20,18 +20,21 @@ impl UDNRegistry { } } - /// Returns `true` if the UDN has been seen for at least half of its lifetime + /// Returns `true` if we should fetch/process this UDN (either first time or more than half max_age elapsed) pub fn should_fetch(registry: Arc>, udn: &str, max_age: u64) -> bool { let now = Instant::now(); let mut r = registry.lock().expect("UDNRegistry mutex lock failed"); if let Some(seen) = r.seen.get_mut(udn) { + // If more than half the max_age has elapsed, we should fetch/process again if now.duration_since(seen.last_seen).as_secs() > max_age / 2 { - false - } else { seen.last_seen = now; true + } else { + // Too soon, skip this SSDP event + false } } else { + // First time seeing this UDN, insert and fetch r.seen.insert( udn.to_string(), UDNSeen { diff --git a/pmocontrol/src/discovery/mod.rs b/pmocontrol/src/discovery/mod.rs index 8f95c14b..d5d64e7a 100644 --- a/pmocontrol/src/discovery/mod.rs +++ b/pmocontrol/src/discovery/mod.rs @@ -1,30 +1,8 @@ -use crate::{RendererInfo, UpnpMediaServer}; - +pub mod arylic; +pub mod chromecast_discovery; pub mod manager; pub mod upnp_discovery; pub mod upnp_provider; -pub mod chromecast_discovery; -pub mod arylic; -/// Fournit les descriptions haut niveau à partir d’un endpoint découvert. -/// L’implémentation pourra, plus tard, faire un HTTP GET sur `location` -/// et parser la description pour remplir RendererInfo / MediaServerInfo. -pub trait DeviceDescriptionProvider: Send + Sync { - /// Construit un RendererInfo pour cet endpoint, ou None s’il - /// ne correspond pas à un renderer audio intéressant. - fn build_renderer_info( - &self, - udn: &str, - location: &str, - server_header: &str, - ) -> Option; - - /// Construit un MediaServerInfo pour cet endpoint, ou None s’il - /// ne correspond pas à un media server (ou pas intéressant). - fn build_server_info( - &self, - udn: &str, - location: &str, - server_header: &str, - ) -> Option; -} \ No newline at end of file +pub use chromecast_discovery::ChromecastDiscoveryManager; +pub use upnp_discovery::UpnpDiscoveryManager; diff --git a/pmocontrol/src/discovery/upnp_discovery.rs b/pmocontrol/src/discovery/upnp_discovery.rs index d6aa9d9a..8cfbc46e 100644 --- a/pmocontrol/src/discovery/upnp_discovery.rs +++ b/pmocontrol/src/discovery/upnp_discovery.rs @@ -1,19 +1,27 @@ use crate::{DeviceRegistry, discovery::upnp_provider::ParsedDeviceDescription}; use pmoupnp::ssdp::SsdpEvent; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; use crate::discovery::manager::UDNRegistry; /// Gestionnaire des événements SSDP -> DeviceUpdate. pub struct UpnpDiscoveryManager { - device_registry: Arc>, + device_registry: Arc>, udn_cache: Arc>, } impl UpnpDiscoveryManager { - // Dans handle_ssdp_event (upnp_discovery.rs) - fn handle_ssdp_event(&mut self, event: SsdpEvent) { + pub fn new(device_registry: Arc>, + udn_cache: Arc>, + ) -> Self { + Self { + device_registry, + udn_cache, + } + } + + pub fn handle_ssdp_event(&mut self, event: SsdpEvent) { let (alive, usn, location, max_age, server_header) = match event { SsdpEvent::Alive { usn, @@ -39,24 +47,20 @@ impl UpnpDiscoveryManager { // ✅ Fetch + parse if let Ok(info) = ParsedDeviceDescription::new(&udn, &location, &server_header,5) { if let Some(renderer_info) = info.build_renderer() { - self.device_registry - .lock() - .expect("UDNRegistry mutex lock failed") - .push_renderer(&renderer_info,max_age); - } else { - if let Some(server_info) = info.build_server() { - self.device_registry - .lock() - .expect("UDNRegistry mutex lock failed") - .push_server(&server_info,max_age); + if let Ok(mut reg) = self.device_registry.write() { + reg.push_renderer(&renderer_info, max_age); + } + } else if let Some(server_info) = info.build_server() { + if let Ok(mut reg) = self.device_registry.write() { + reg.push_server(&server_info, max_age); + } } } - }} + } } else { - self.device_registry - .lock() - .expect("UDNRegistry mutex lock failed") - .device_says_byebye(&udn); + if let Ok(mut reg) = self.device_registry.write() { + reg.device_says_byebye(&udn); + } } } } diff --git a/pmocontrol/src/errors.rs b/pmocontrol/src/errors.rs index 6492e51c..29cc36a4 100644 --- a/pmocontrol/src/errors.rs +++ b/pmocontrol/src/errors.rs @@ -24,11 +24,11 @@ pub enum ControlPointError { #[error("Missing {0} element in SOAP body")] UpnpMissingReturnValue(String), #[error("Invalid {0} value: {1}")] - UpnpBadReturnValue(String,String), + UpnpBadReturnValue(String, String), #[error("Soap Error: Upnp action call {0}")] SoapAction(String), #[error("{0} returned UPnP error {1}: {2} (HTTP status {3})")] - SoapUpnpParseError(String,u32, String, u32), + SoapUpnpParseError(String, u32, String, u32), #[error("{0} failed with HTTP status {1} and body: {2}")] SoapActionWrongBody(String, u32, String), #[error("Soap Error: No envelop for action {0}")] @@ -45,23 +45,26 @@ pub enum ControlPointError { QueueError(String), #[error("Invalid time format: {0}")] InvalidTimeFormat(String), + #[error("Error on snapshot: {0}")] + SnapshotError(String), + #[error("Error on ControlPoint: {0}")] + ControlPoint(String), } impl ControlPointError { pub fn upnp_operation_not_supported(operation: &str, service: &str) -> Self { - ControlPointError::UpnpOperationNotSupported(operation.to_string(),service.to_string()) + ControlPointError::UpnpOperationNotSupported(operation.to_string(), service.to_string()) } pub fn upnp_missing_return_value(value: &str) -> Self { ControlPointError::UpnpMissingReturnValue(value.to_string()) } - pub fn upnp_bad_return_value(name : &str, value : &str ) -> Self { - ControlPointError::UpnpBadReturnValue(name.to_string(),value.to_string()) - + pub fn upnp_bad_return_value(name: &str, value: &str) -> Self { + ControlPointError::UpnpBadReturnValue(name.to_string(), value.to_string()) } - pub fn arilyc_tcp_error(message : &str ) -> Self { + pub fn arilyc_tcp_error(message: &str) -> Self { ControlPointError::ArilycTcpError(message.to_string()) } -} \ No newline at end of file +} diff --git a/pmocontrol/src/identity.rs b/pmocontrol/src/identity.rs index 64c47d8b..00bc23d2 100644 --- a/pmocontrol/src/identity.rs +++ b/pmocontrol/src/identity.rs @@ -1,14 +1,29 @@ use crate::DeviceId; +use crate::model::DeviceBasicInfo; pub trait DeviceIdentity { - fn id(&self) -> DeviceId ; + fn id(&self) -> DeviceId; fn udn(&self) -> &str; - fn friendly_name(&self) -> &str ; - fn model_name(&self) -> &str ; - fn manufacturer(&self) -> &str ; - fn location(&self) -> &str ; + fn friendly_name(&self) -> &str; + fn model_name(&self) -> &str; + fn manufacturer(&self) -> &str; + fn location(&self) -> &str; fn server_header(&self) -> &str; - fn is_a_media_server(&self) -> bool { false } - fn is_a_music_renderer(&self) -> bool { false } -} \ No newline at end of file + fn is_a_media_server(&self) -> bool { + false + } + fn is_a_music_renderer(&self) -> bool { + false + } + + /// Returns basic device information suitable for event notifications + fn basic_info(&self) -> DeviceBasicInfo { + DeviceBasicInfo { + id: self.id(), + friendly_name: self.friendly_name().to_string(), + model_name: self.model_name().to_string(), + manufacturer: self.manufacturer().to_string(), + } + } +} diff --git a/pmocontrol/src/lib.rs b/pmocontrol/src/lib.rs index 3a9ab60d..df323eba 100644 --- a/pmocontrol/src/lib.rs +++ b/pmocontrol/src/lib.rs @@ -1,23 +1,21 @@ mod events; mod media_server_events; -pub mod queue; -pub mod discovery; -pub mod upnp_clients; pub mod arylic_client; -pub mod linkplay_client; pub mod control_point; +pub mod discovery; pub mod errors; +pub mod identity; +pub mod linkplay_client; +pub mod linkplay_utils; pub mod media_server; pub mod model; pub mod music_renderer; +pub mod online; +pub mod queue; pub mod registry; pub mod soap_client; -pub mod online; -pub mod identity; -pub mod linkplay_utils; - - +pub mod upnp_clients; // pmoserver extension (optional) #[cfg(feature = "pmoserver")] @@ -32,20 +30,18 @@ use std::time::Duration; #[cfg(feature = "pmoserver")] pub use pmoserver_ext::ControlPointExt; - -pub use control_point::{ControlPoint, PlaylistBinding}; -pub use media_server::{ - MediaBrowser, MediaEntry, MediaResource, UpnpMediaServer, -}; +pub use control_point::ControlPoint; +pub use media_server::{MediaBrowser, MediaEntry, MediaResource, UpnpMediaServer}; pub use queue::{EnqueueMode, PlaybackItem, QueueSnapshot}; pub use model::{ - MediaServerEvent, RendererCapabilities, RendererEvent, RendererInfo, RendererProtocol, + MediaServerEvent, PlaybackSource, RendererCapabilities, RendererEvent, RendererInfo, + RendererProtocol, }; pub use registry::{DeviceRegistry, DeviceUpdate}; -pub use soap_client::invoke_upnp_action; pub use online::DeviceOnline; +pub use soap_client::invoke_upnp_action; pub use identity::DeviceIdentity; @@ -53,4 +49,3 @@ pub use identity::DeviceIdentity; pub struct DeviceId(pub String); const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30); - diff --git a/pmocontrol/src/linkplay_client/mod.rs b/pmocontrol/src/linkplay_client/mod.rs index 27bf9913..d072aac6 100644 --- a/pmocontrol/src/linkplay_client/mod.rs +++ b/pmocontrol/src/linkplay_client/mod.rs @@ -1,10 +1,16 @@ use std::time::Duration; use serde::Deserialize; -use tracing::debug; use ureq::Agent; -use crate::{errors::ControlPointError, model::PlaybackState, music_renderer::{PlaybackPositionInfo, time_utils::{format_hhmmss, ms_to_seconds}}}; +use crate::{ + errors::ControlPointError, + model::PlaybackState, + music_renderer::{ + PlaybackPositionInfo, + time_utils::{format_hhmmss, ms_to_seconds}, + }, +}; const STATUS_COMMAND: &str = "getPlayerStatus"; @@ -101,32 +107,41 @@ pub fn percent_encode(input: &str) -> String { out } -pub fn fetch_status_for_host(host: &str, timeout: Duration) -> Result { +pub fn fetch_status_for_host( + host: &str, + timeout: Duration, +) -> Result { let url = format!("http://{}/httpapi.asp?command={}", host, STATUS_COMMAND); - let mut response = build_agent(timeout) - .get(&url) - .call() - .map_err(|_| ControlPointError::ArilycTcpError(format!("HTTP request failed for LinkPlay status on {}", host)))?; + let mut response = build_agent(timeout).get(&url).call().map_err(|_| { + ControlPointError::ArilycTcpError(format!( + "HTTP request failed for LinkPlay status on {}", + host + )) + })?; - let body = response - .body_mut() - .read_to_string() - .map_err(|e| ControlPointError::ArilycTcpError(format!("Failed to read LinkPlay status body : {}",e)))?; + let body = response.body_mut().read_to_string().map_err(|e| { + ControlPointError::ArilycTcpError(format!("Failed to read LinkPlay status body : {}", e)) + })?; parse_linkplay_status(&body) } fn parse_linkplay_status(body: &str) -> Result { - let raw: LinkPlayStatusRaw = serde_json::from_str(body) - .map_err(|e| ControlPointError::LinkPlayError(format!("Failed to parse LinkPlay status JSON: {}", e)))?; + let raw: LinkPlayStatusRaw = serde_json::from_str(body).map_err(|e| { + ControlPointError::LinkPlayError(format!("Failed to parse LinkPlay status JSON: {}", e)) + })?; - let curpos_ms = raw.curpos.parse::() - .map_err(|_| ControlPointError::LinkPlayError(format!("Invalid curpos value: {}", raw.curpos)))?; + let curpos_ms = raw.curpos.parse::().map_err(|_| { + ControlPointError::LinkPlayError(format!("Invalid curpos value: {}", raw.curpos)) + })?; - let totlen_ms = raw.totlen.parse::() - .map_err(|_| ControlPointError::LinkPlayError(format!("Invalid totlen value: {}", raw.totlen)))?; + let totlen_ms = raw.totlen.parse::().map_err(|_| { + ControlPointError::LinkPlayError(format!("Invalid totlen value: {}", raw.totlen)) + })?; - let volume = raw.vol.parse::() + let volume = raw + .vol + .parse::() .map_err(|_| ControlPointError::LinkPlayError(format!("Invalid vol value: {}", raw.vol)))? .min(100); @@ -134,11 +149,15 @@ fn parse_linkplay_status(body: &str) -> Result true, "0" => false, other => { - return Err(ControlPointError::LinkPlayError(format!("Invalid mute value: {}", other))); + return Err(ControlPointError::LinkPlayError(format!( + "Invalid mute value: {}", + other + ))); } }; - let track_index = raw.plicurr + let track_index = raw + .plicurr .and_then(|s| s.parse::().ok()) .filter(|idx| *idx > 0); @@ -151,6 +170,3 @@ fn parse_linkplay_status(body: &str) -> Result, content_directory_control_url: Option, ) -> Arc { - Arc::new(Self::new( + Arc::new(UpnpMediaServer::new( id, udn, friendly_name, @@ -110,6 +110,10 @@ impl UpnpMediaServer { map_didl_entries(&didl_xml) } + fn has_content_directory(&self) -> bool { + self.has_content_directory + } + fn search_impl( &self, container_id: &str, @@ -384,9 +388,15 @@ pub enum MusicServer { } impl MusicServer { - pub fn from_server_info(info: &UpnpMediaServer) -> Result { + pub fn from_server_info(info: &UpnpMediaServer) -> Result { Ok(MusicServer::Upnp(info.clone())) } + + pub fn has_content_directory(&self) -> bool { + match self { + MusicServer::Upnp(u) => u.has_content_directory(), + } + } } impl DeviceOnline for MusicServer { @@ -502,7 +512,7 @@ impl MediaBrowser for MusicServer { /// This function filters out containers and entries without audio resources, /// returning None for items that cannot be played. pub fn playback_item_from_entry( - server: &UpnpMediaServer, + server: Arc, entry: &MediaEntry, ) -> Option { // Ignore containers diff --git a/pmocontrol/src/media_server_events.rs b/pmocontrol/src/media_server_events.rs index aac2ef41..1dbffcec 100644 --- a/pmocontrol/src/media_server_events.rs +++ b/pmocontrol/src/media_server_events.rs @@ -8,20 +8,23 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use crossbeam_channel::{Receiver, Sender, unbounded}; -use tracing::{debug, info, warn}; +use tracing::{debug, error, info, warn}; use ureq::{Agent, http}; use xmltree::{Element, XMLNode}; -use crate::DeviceId; use crate::events::MediaServerEventBus; -use crate::media_server::{UpnpMediaServer}; +use crate::media_server::MusicServer; use crate::model::MediaServerEvent; -use crate::upnp_clients::resolve_control_url; use crate::registry::DeviceRegistry; -use crate::{DeviceOnline,DeviceIdentity}; +use crate::upnp_clients::resolve_control_url; +use crate::{DeviceId, DeviceIdentity, DeviceOnline}; const SUBSCRIPTION_TIMEOUT_SECS: u64 = 300; const RENEWAL_SAFETY_MARGIN_SECS: u64 = 60; +const HTTP_READ_TIMEOUT_SECS: u64 = 5; +const WORKER_LOOP_INTERVAL_MILLIS: u64 = 250; +const RETRY_DELAY_SECS: u64 = 15; +const SUBSCRIPTION_RESET_DELAY_SECS: u64 = 5; /// Launch the media server event runtime responsible for subscribing /// to ContentDirectory updates and forwarding notifications on the bus. @@ -67,11 +70,47 @@ struct IncomingNotify { body: Vec, } +impl IncomingNotify { + fn validate_sid(&self, expected: &Option) -> bool { + match (&self.sid, expected) { + (Some(received), Some(expected)) => expected.eq_ignore_ascii_case(received), + _ => false, + } + } +} + +/// Manages retry timing for subscription operations +struct RetryPolicy { + retry_after: Instant, +} + +impl RetryPolicy { + fn new() -> Self { + Self { + retry_after: Instant::now(), + } + } + + fn should_retry(&self) -> bool { + Instant::now() >= self.retry_after + } + + fn defer_retry(&mut self) { + self.retry_after = Instant::now() + Duration::from_secs(RETRY_DELAY_SECS); + } + + fn schedule_soon(&mut self) { + self.retry_after = Instant::now() + Duration::from_secs(SUBSCRIPTION_RESET_DELAY_SECS); + } +} + fn run_http_listener(listener: TcpListener, notify_tx: Sender) { for stream in listener.incoming() { match stream { Ok(mut stream) => { - if let Err(err) = stream.set_read_timeout(Some(Duration::from_secs(5))) { + if let Err(err) = + stream.set_read_timeout(Some(Duration::from_secs(HTTP_READ_TIMEOUT_SECS))) + { warn!("Failed to set read timeout on notify connection: {}", err); } @@ -209,7 +248,7 @@ impl MediaServerEventWorker { self.drain_notifications(); self.refresh_servers(); self.renew_expiring(); - thread::sleep(Duration::from_millis(250)); + thread::sleep(Duration::from_millis(WORKER_LOOP_INTERVAL_MILLIS)); } } @@ -220,54 +259,60 @@ impl MediaServerEventWorker { } fn refresh_servers(&mut self) { - let server_infos = { + let servers = { let reg = self.registry.read().unwrap(); - reg.list_servers() + match reg.list_servers() { + Ok(servers) => servers, + Err(e) => { + error!("Failed to list servers: {}", e); + return; + } + } }; let mut active: HashSet = HashSet::new(); - for info in server_infos { - if !info.is_online() || !info.has_content_directory { + for server in servers { + if !server.is_online() || !server.has_content_directory() { continue; } - active.insert(info.id.clone()); + active.insert(server.id()); let entry = self .subscriptions - .entry(info.id.clone()) - .or_insert_with(|| SubscriptionState::new(info.clone())); - entry.update(info); + .entry(server.id()) + .or_insert_with(|| SubscriptionState::from_music_server(&server)); + entry.update_from_server(&server); self.path_index - .insert(entry.callback_path.clone(), entry.info.id.clone()); + .insert(entry.callback_path.clone(), entry.device_id.clone()); if entry.event_sub_url.is_none() { - if entry.should_retry() { - match fetch_event_sub_url(&entry.info.location, self.http_timeout) { + if entry.retry_policy.should_retry() { + match fetch_event_sub_url(&entry.location, self.http_timeout) { Ok(Some(url)) => { debug!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), callback = url.as_str(), "ContentDirectory eventSub URL resolved" ); entry.event_sub_url = Some(url); - entry.retry_after = Instant::now(); + entry.retry_policy = RetryPolicy::new(); } Ok(None) => { debug!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), "No ContentDirectory eventSub URL found" ); - entry.defer_retry(); + entry.retry_policy.defer_retry(); continue; } Err(err) => { warn!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), error = %err, "Failed to fetch ContentDirectory eventSub URL" ); - entry.defer_retry(); + entry.retry_policy.defer_retry(); continue; } } @@ -276,21 +321,21 @@ impl MediaServerEventWorker { } } - if entry.sid.is_none() && entry.should_retry() { + if entry.sid.is_none() && entry.retry_policy.should_retry() { if let Err(err) = Self::subscribe_entry(self.listener_port, self.http_timeout, entry) { warn!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), error = %err, "ContentDirectory SUBSCRIBE failed" ); - entry.defer_retry(); + entry.retry_policy.defer_retry(); } } } - let stale_ids: Vec = self + let stale_ids: Vec = self .subscriptions .keys() .filter(|id| !active.contains(*id)) @@ -320,7 +365,7 @@ impl MediaServerEventWorker { if let Some(entry) = self.subscriptions.get_mut(&id) { if let Err(err) = Self::renew_entry(self.http_timeout, entry) { warn!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), error = %err, "Failed to renew ContentDirectory subscription" ); @@ -340,8 +385,10 @@ impl MediaServerEventWorker { .as_ref() .context("EventSub URL missing for server")?; + let (host_header, timeout_header) = build_subscribe_headers(event_url)?; + let (remote_host, remote_port) = - parse_host_port(event_url).context("Cannot extract host for SUBSCRIBE")?; + parse_host_port(event_url).context("Cannot extract host for callback")?; let local_ip = determine_local_ip(&remote_host, remote_port) .context("Cannot determine local IP for callback")?; @@ -353,13 +400,11 @@ impl MediaServerEventWorker { ); debug!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), callback = callback_url.as_str(), "Subscribing to ContentDirectory events" ); - let host_header = format!("{}:{}", remote_host, remote_port); - let timeout_header = format!("Second-{}", SUBSCRIPTION_TIMEOUT_SECS); let callback_header = format!("<{}>", callback_url); let request = http::Request::builder() @@ -393,10 +438,10 @@ impl MediaServerEventWorker { entry.sid = Some(sid); entry.expires_at = Some(Instant::now() + timeout); - entry.retry_after = Instant::now() + Duration::from_secs(5); + entry.retry_policy.schedule_soon(); info!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), "Subscribed to ContentDirectory events (timeout {}s)", timeout.as_secs() ); @@ -414,10 +459,9 @@ impl MediaServerEventWorker { .as_ref() .cloned() .context("SID missing for renew")?; - let (remote_host, remote_port) = - parse_host_port(event_url).context("Cannot extract host for renew")?; - let host_header = format!("{}:{}", remote_host, remote_port); - let timeout_header = format!("Second-{}", SUBSCRIPTION_TIMEOUT_SECS); + + let (host_header, timeout_header) = build_subscribe_headers(event_url)?; + let request = http::Request::builder() .method("SUBSCRIBE") .uri(event_url) @@ -441,7 +485,7 @@ impl MediaServerEventWorker { .unwrap_or(Duration::from_secs(SUBSCRIPTION_TIMEOUT_SECS)); entry.expires_at = Some(Instant::now() + timeout); debug!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), "Renewed ContentDirectory subscription" ); Ok(()) @@ -470,7 +514,7 @@ impl MediaServerEventWorker { Ok(req) => req, Err(err) => { warn!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), error = %err, "Failed to build UNSUBSCRIBE request" ); @@ -482,12 +526,12 @@ impl MediaServerEventWorker { Ok(response) => { if response.status().is_success() { debug!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), "Unsubscribed from ContentDirectory events" ); } else { warn!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), status = %response.status(), "UNSUBSCRIBE returned non-success status" ); @@ -495,7 +539,7 @@ impl MediaServerEventWorker { } Err(err) => { warn!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), error = %err, "UNSUBSCRIBE request failed" ); @@ -513,32 +557,30 @@ impl MediaServerEventWorker { return; }; - if let (Some(expected), Some(received)) = (&entry.sid, ¬ify.sid) { - if !expected.eq_ignore_ascii_case(received) { - debug!( - server = entry.info.friendly_name.as_str(), - expected_sid = expected.as_str(), - received_sid = received.as_str(), - "Ignoring notify with mismatched SID" - ); - return; - } + if !notify.validate_sid(&entry.sid) { + debug!( + server = entry.friendly_name.as_str(), + expected_sid = entry.sid.as_deref().unwrap_or("none"), + received_sid = notify.sid.as_deref().unwrap_or("none"), + "Ignoring notify with mismatched SID" + ); + return; } - for event in parse_notify_payload(&entry.info.id, ¬ify.body) { + for event in parse_notify_payload(&entry.device_id, ¬ify.body) { match &event { MediaServerEvent::GlobalUpdated { system_update_id, .. } => { debug!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), update_id = system_update_id.unwrap_or_default(), "Broadcasting MediaServerEvent::GlobalUpdated" ); } MediaServerEvent::ContainersUpdated { container_ids, .. } => { debug!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), changed_containers = container_ids.join(",").as_str(), "Broadcasting MediaServerEvent::ContainersUpdated" ); @@ -553,52 +595,63 @@ impl MediaServerEventWorker { } struct SubscriptionState { - info: UpnpMediaServer, + device_id: DeviceId, + location: String, + friendly_name: String, event_sub_url: Option, sid: Option, expires_at: Option, callback_path: String, - retry_after: Instant, + retry_policy: RetryPolicy, } impl SubscriptionState { - fn new(info: UpnpMediaServer) -> Self { + /// Creates a new subscription state from a MusicServer + fn from_music_server(server: &MusicServer) -> Self { Self { - callback_path: build_callback_path(&info.id), - info, + callback_path: build_callback_path(&server.id()), + device_id: server.id(), + location: server.location().to_string(), + friendly_name: server.friendly_name().to_string(), event_sub_url: None, sid: None, expires_at: None, - retry_after: Instant::now(), + retry_policy: RetryPolicy::new(), } } - fn update(&mut self, info: UpnpMediaServer) { - if self.info.location != info.location { + /// Updates the subscription state from a MusicServer + fn update_from_server(&mut self, server: &MusicServer) { + let new_location = server.location(); + if self.location != new_location { + // Location changed - invalidate subscription self.event_sub_url = None; self.sid = None; self.expires_at = None; - self.retry_after = Instant::now(); + self.retry_policy = RetryPolicy::new(); } - self.info = info; - } - - fn should_retry(&self) -> bool { - Instant::now() >= self.retry_after - } - - fn defer_retry(&mut self) { - self.retry_after = Instant::now() + Duration::from_secs(15); + self.device_id = server.id(); + self.location = new_location.to_string(); + self.friendly_name = server.friendly_name().to_string(); } fn reset_subscription(&mut self) { self.sid = None; self.expires_at = None; - self.retry_after = Instant::now() + Duration::from_secs(5); + self.retry_policy.schedule_soon(); } } -fn build_callback_path(id: &ServerId) -> String { +/// Builds HTTP headers common to SUBSCRIBE requests +fn build_subscribe_headers(event_url: &str) -> Result<(String, String)> { + let (remote_host, remote_port) = + parse_host_port(event_url).context("Cannot extract host for SUBSCRIBE")?; + let host_header = format!("{}:{}", remote_host, remote_port); + let timeout_header = format!("Second-{}", SUBSCRIPTION_TIMEOUT_SECS); + Ok((host_header, timeout_header)) +} + +fn build_callback_path(id: &DeviceId) -> String { let mut sanitized = String::new(); for ch in id.0.chars() { if ch.is_ascii_alphanumeric() { @@ -615,7 +668,7 @@ fn build_callback_path(id: &ServerId) -> String { format!("/media-server-events/{}-{:x}", sanitized, suffix) } -fn parse_notify_payload(server_id: &ServerId, body: &[u8]) -> Vec { +fn parse_notify_payload(server_id: &DeviceId, body: &[u8]) -> Vec { let mut events = Vec::new(); let reader = std::io::Cursor::new(body); let Ok(root) = Element::parse(reader) else { @@ -629,27 +682,21 @@ 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 { - 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(); - if let Ok(value) = trimmed.parse::() { - system_update_id = Some(value); - } else { - system_update_id = None; + // Navigate through property elements + for property in xml_children(&root) { + for child in xml_children(property) { + match child.name.as_str() { + "SystemUpdateID" => { + system_update_id = child + .get_text() + .and_then(|text| text.trim().parse::().ok()); + } + "ContainerUpdateIDs" => { + if let Some(text) = child.get_text() { + container_ids = parse_container_update_ids(text.as_ref()); } } - } else if child.name == "ContainerUpdateIDs" { - if let Some(text) = child.get_text() { - container_ids = parse_container_update_ids(text.as_ref()); - } + _ => {} } } } @@ -671,6 +718,14 @@ fn parse_notify_payload(server_id: &ServerId, body: &[u8]) -> Vec impl Iterator { + element.children.iter().filter_map(|node| match node { + XMLNode::Element(elem) => Some(elem), + _ => None, + }) +} + fn parse_container_update_ids(raw: &str) -> Vec { let trimmed = raw.trim(); if trimmed.is_empty() { @@ -702,14 +757,9 @@ fn parse_container_update_ids(raw: &str) -> Vec { } fn child_text(element: &Element, name: &str) -> Option { - for node in &element.children { - if let XMLNode::Element(child) = node { - if child.name == name { - return child.get_text().map(|cow| cow.into_owned()); - } - } - } - None + xml_children(element) + .find(|child| child.name == name) + .and_then(|child| child.get_text().map(|cow| cow.into_owned())) } fn fetch_event_sub_url(location: &str, timeout: Duration) -> Result> { @@ -734,20 +784,18 @@ fn fetch_event_sub_url(location: &str, timeout: Duration) -> Result return Ok(None), }; - for node in &service_list.children { - if let XMLNode::Element(service) = node { - let Some(service_type) = child_text(service, "serviceType") else { - continue; - }; - if !service_type - .to_ascii_lowercase() - .contains("urn:schemas-upnp-org:service:contentdirectory:") - { - continue; - } - if let Some(event_sub) = child_text(service, "eventSubURL") { - return Ok(Some(resolve_control_url(location, &event_sub))); - } + for service in xml_children(service_list) { + let Some(service_type) = child_text(service, "serviceType") else { + continue; + }; + if !service_type + .to_ascii_lowercase() + .contains("urn:schemas-upnp-org:service:contentdirectory:") + { + continue; + } + if let Some(event_sub) = child_text(service, "eventSubURL") { + return Ok(Some(resolve_control_url(location, &event_sub))); } } diff --git a/pmocontrol/src/model.rs b/pmocontrol/src/model.rs index 051b5e83..63c82759 100644 --- a/pmocontrol/src/model.rs +++ b/pmocontrol/src/model.rs @@ -1,8 +1,28 @@ -use crate::control_point::PlaylistBinding; -use crate::media_server::UpnpMediaServer; -use crate::music_renderer::PlaybackPositionInfo; +use crate::music_renderer::{PlaybackPositionInfo, PlaylistBinding}; use crate::{DeviceId, DeviceIdentity}; +/// Basic device information for event notifications +/// Contains only the essential identification fields +#[derive(Clone, Debug)] +pub struct DeviceBasicInfo { + pub id: DeviceId, + pub friendly_name: String, + pub model_name: String, + pub manufacturer: String, +} + +impl DeviceBasicInfo { + /// Create from any type implementing DeviceIdentity + pub fn from_device(device: &D) -> Self { + Self { + id: device.id(), + friendly_name: device.friendly_name().to_string(), + model_name: device.model_name().to_string(), + manufacturer: device.manufacturer().to_string(), + } + } +} + /// High-level playback state across backends. #[derive(Clone, Debug)] pub enum PlaybackState { @@ -48,6 +68,21 @@ impl PlaybackState { } } +/// Indicates the source of the current playback. +/// +/// This helps the control point distinguish between playback initiated +/// from the queue vs external sources (e.g., user playing from another app). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PlaybackSource { + /// No playback active or source unknown. + #[default] + None, + /// Playback was started from the control point's queue. + FromQueue, + /// Playback was started externally (e.g., from another app). + External, +} + #[derive(Clone, Debug, PartialEq)] pub struct TrackMetadata { pub title: Option, @@ -401,7 +436,7 @@ pub enum RendererEvent { }, Online { id: DeviceId, - info: RendererInfo, + info: DeviceBasicInfo, }, Offline { id: DeviceId, @@ -420,7 +455,7 @@ pub enum MediaServerEvent { }, Online { server_id: DeviceId, - info: UpnpMediaServer, + info: DeviceBasicInfo, }, Offline { server_id: DeviceId, diff --git a/pmocontrol/src/music_renderer/arylic_tcp.rs b/pmocontrol/src/music_renderer/arylic_tcp.rs index 85c139fb..7cebd074 100644 --- a/pmocontrol/src/music_renderer/arylic_tcp.rs +++ b/pmocontrol/src/music_renderer/arylic_tcp.rs @@ -4,17 +4,19 @@ use serde::Deserialize; use tracing::debug; use crate::DeviceIdentity; -use crate::arylic_client::{ARYLIC_TCP_PORT, DEFAULT_TIMEOUT_SECS, send_command_no_response, send_command_optional, send_command_required}; +use crate::arylic_client::{ + ARYLIC_TCP_PORT, DEFAULT_TIMEOUT_SECS, send_command_no_response, send_command_optional, + send_command_required, +}; use crate::errors::ControlPointError; use crate::linkplay_client::extract_linkplay_host; use crate::model::{PlaybackState, RendererInfo}; use crate::music_renderer::RendererFromMediaRendererInfo; use crate::music_renderer::capabilities::{ - PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, - VolumeControl, + PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl, }; -use crate::music_renderer::time_utils::{ms_to_seconds, format_hhmmss, parse_hhmmss_strict}; use crate::music_renderer::musicrenderer::MusicRendererBackend; +use crate::music_renderer::time_utils::{format_hhmmss, ms_to_seconds, parse_hhmmss_strict}; /// Raw response from Arylic MCU+PINFGET command #[derive(Debug, Deserialize)] @@ -145,7 +147,7 @@ impl TransportControl for ArylicTcpRenderer { fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { let _ = parse_hhmmss_strict(hhmmss)?; Err(ControlPointError::ArilycTcpError( - "Arylic TCP seek_rel_time is not implemented yet for this device.".to_string() + "Arylic TCP seek_rel_time is not implemented yet for this device.".to_string(), )) } } @@ -255,59 +257,77 @@ fn parse_playback_info(payload: &str) -> Result() - .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid curpos value: {}", raw.curpos)))?; + let json_blob = &json_blob[json_start..=json_end]; - let totlen_ms = raw.totlen.parse::() - .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid totlen value: {}", raw.totlen)))?; + let raw: ArylicPlaybackInfoRaw = serde_json::from_str(json_blob).map_err(|e| { + ControlPointError::ArilycTcpError(format!( + "Failed to parse Arylic playback info JSON: {}", + e + )) + })?; - let volume = raw.vol.and_then(|raw_vol| { - match raw_vol.parse::() { - Ok(value) => Some(value.min(100)), - Err(err) => { - debug!("Invalid Arylic `vol` value {}: {}", raw_vol, err); - None - } + let curpos_ms = raw.curpos.parse::().map_err(|_| { + ControlPointError::ArilycTcpError(format!("Invalid curpos value: {}", raw.curpos)) + })?; + + let totlen_ms = raw.totlen.parse::().map_err(|_| { + ControlPointError::ArilycTcpError(format!("Invalid totlen value: {}", raw.totlen)) + })?; + + let volume = raw.vol.and_then(|raw_vol| match raw_vol.parse::() { + Ok(value) => Some(value.min(100)), + Err(err) => { + debug!("Invalid Arylic `vol` value {}: {}", raw_vol, err); + None } }); - let mute = raw.mute.and_then(|raw_mute| { - match raw_mute.as_str() { - "1" => Some(true), - "0" => Some(false), - other => { - debug!("Invalid Arylic `mute` value {}", other); - None - } + let mute = raw.mute.and_then(|raw_mute| match raw_mute.as_str() { + "1" => Some(true), + "0" => Some(false), + other => { + debug!("Invalid Arylic `mute` value {}", other); + None } }); - let playlist_size = raw.plicount.and_then(|raw_count| { - match raw_count.parse::() { + let playlist_size = raw + .plicount + .and_then(|raw_count| match raw_count.parse::() { Ok(count) if count > 0 => Some(count), Ok(_) => None, Err(err) => { debug!("Invalid Arylic `plicount` value {}: {}", raw_count, err); None } - } - }); + }); - let track_index = raw.plicurr.and_then(|raw_idx| { - match raw_idx.parse::() { + let track_index = raw + .plicurr + .and_then(|raw_idx| match raw_idx.parse::() { Ok(idx) if idx > 0 => Some(idx), Ok(_) => None, Err(err) => { debug!("Invalid Arylic `plicurr` value {}: {}", raw_idx, err); None } - } - }); + }); Ok(ArylicPlaybackInfo { status_raw: raw.status, diff --git a/pmocontrol/src/music_renderer/mod.rs b/pmocontrol/src/music_renderer/mod.rs index b9c5049e..56cffbc9 100644 --- a/pmocontrol/src/music_renderer/mod.rs +++ b/pmocontrol/src/music_renderer/mod.rs @@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex}; pub use crate::music_renderer::capabilities::{ PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, }; -pub use crate::music_renderer::musicrenderer::MusicRenderer; +pub use crate::music_renderer::musicrenderer::{MusicRenderer, PlaylistBinding}; use crate::{ RendererInfo, errors::ControlPointError, music_renderer::musicrenderer::MusicRendererBackend, }; diff --git a/pmocontrol/src/music_renderer/musicrenderer.rs b/pmocontrol/src/music_renderer/musicrenderer.rs index 3011a912..90a2600f 100644 --- a/pmocontrol/src/music_renderer/musicrenderer.rs +++ b/pmocontrol/src/music_renderer/musicrenderer.rs @@ -9,25 +9,44 @@ use std::sync::{Arc, Mutex}; use std::time::SystemTime; -use crate::control_point::PlaylistBinding; use crate::errors::ControlPointError; -use crate::model::{RendererInfo, RendererProtocol, TrackMetadata, PlaybackState}; +use crate::model::{PlaybackSource, PlaybackState, RendererInfo, RendererProtocol, TrackMetadata}; use crate::music_renderer::RendererFromMediaRendererInfo; use crate::music_renderer::arylic_tcp::ArylicTcpRenderer; use crate::music_renderer::capabilities::{ - PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, - VolumeControl, + PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl, }; use crate::music_renderer::chromecast_renderer::ChromecastRenderer; use crate::music_renderer::linkplay_renderer::LinkPlayRenderer; use crate::music_renderer::openhome_renderer::OpenHomeRenderer; use crate::music_renderer::upnp_renderer::UpnpRenderer; use crate::online::DeviceConnectionState; -use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot}; +use crate::queue::{ + EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot, +}; use crate::{DeviceId, DeviceIdentity, DeviceOnline}; use tracing::warn; +/// Describes a renderer's attachment to a media server playlist container. +/// +/// When a renderer is attached to a playlist, the ControlPoint monitors the container +/// for updates and automatically refreshes the queue when changes are detected. +#[derive(Clone, Debug)] +pub struct PlaylistBinding { + /// MediaServer that owns the playlist container. + pub server_id: DeviceId, + /// DIDL-Lite object id of the playlist container. + pub container_id: String, + /// True once at least one ContainerUpdateIDs notification has been seen. + pub(crate) has_seen_update: bool, + /// Flag used internally to signal that the queue should be refreshed + /// from the server container. + pub(crate) pending_refresh: bool, + /// Whether the next refresh should auto-start playback if the renderer is idle. + pub(crate) auto_play_on_refresh: bool, +} + /// Backend-agnostic façade exposing transport, volume, and status contracts. #[derive(Clone, Debug)] pub enum MusicRendererBackend { @@ -49,6 +68,17 @@ pub enum MusicRendererBackend { }, } +/// Internal state for tracking playback and control flow. +#[derive(Debug, Clone, Default)] +struct MusicRendererState { + /// Last known track metadata (cached to avoid repeated queries). + last_metadata: Option, + /// Source of the current playback (queue vs external). + playback_source: PlaybackSource, + /// Flag to distinguish user-requested stop from automatic events. + user_stop_requested: bool, +} + #[derive(Debug, Clone)] pub struct MusicRenderer { info: RendererInfo, @@ -56,6 +86,7 @@ pub struct MusicRenderer { backend: Arc>, queue: Arc>, playlist_binding: Arc>>, + state: Arc>, } impl MusicRenderer { @@ -72,6 +103,7 @@ impl MusicRenderer { backend, queue, playlist_binding: Arc::new(Mutex::new(None)), + state: Arc::new(Mutex::new(MusicRendererState::default())), }; Arc::new(renderer) @@ -88,6 +120,7 @@ impl MusicRenderer { backend, queue, playlist_binding: Arc::new(Mutex::new(None)), + state: Arc::new(Mutex::new(MusicRendererState::default())), }; Ok(renderer) } @@ -179,6 +212,23 @@ impl MusicRenderer { .queue_snapshot() } + /// Get the current queue item without advancing. + /// Returns the item and count of remaining items after current. + pub fn peek_current(&self) -> Result, ControlPointError> { + self.queue + .lock() + .expect("Queue mutex poisoned") + .peek_current() + } + + /// Get the count of items remaining after the current index. + pub fn upcoming_len(&self) -> Result { + self.queue + .lock() + .expect("Queue mutex poisoned") + .upcoming_len() + } + /// Play the current item from the queue. pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> { let queue = self.queue.lock().expect("Queue mutex poisoned"); @@ -248,6 +298,22 @@ impl MusicRenderer { self.backend.lock().expect("Backend mutex poisoned").stop() } + /// Set the next URI for gapless playback (UPnP AVTransport only). + /// + /// Returns Ok if the backend supports this feature and it succeeded. + /// Returns Err if not supported or if it failed. + pub fn set_next_uri(&self, uri: &str, metadata: &str) -> Result<(), ControlPointError> { + let backend = self.backend.lock().expect("Backend mutex poisoned"); + + match &*backend { + MusicRendererBackend::Upnp(upnp) => upnp.set_next_uri(uri, metadata), + MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_next_uri(uri, metadata), + _ => Err(ControlPointError::ControlPoint( + "SetNextURI not supported by this backend".to_string(), + )), + } + } + /// Transport control: seek to relative time pub fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { self.backend @@ -303,17 +369,126 @@ impl MusicRenderer { /// Sets the playlist binding for this renderer. pub fn set_playlist_binding(&self, binding: Option) { - *self.playlist_binding.lock().expect("Playlist binding mutex poisoned") = binding; + *self + .playlist_binding + .lock() + .expect("Playlist binding mutex poisoned") = binding; } /// Gets the current playlist binding, if any. pub fn get_playlist_binding(&self) -> Option { - self.playlist_binding.lock().expect("Playlist binding mutex poisoned").clone() + self.playlist_binding + .lock() + .expect("Playlist binding mutex poisoned") + .clone() } /// Clears the playlist binding. pub fn clear_playlist_binding(&self) { - *self.playlist_binding.lock().expect("Playlist binding mutex poisoned") = None; + *self + .playlist_binding + .lock() + .expect("Playlist binding mutex poisoned") = None; + } + + /// Marks the current playlist binding for refresh if it matches the given server and container. + /// Returns true if a matching binding was found and marked. + pub fn mark_binding_for_refresh(&self, server_id: &DeviceId, container_ids: &[String]) -> bool { + let mut binding_guard = self + .playlist_binding + .lock() + .expect("Playlist binding mutex poisoned"); + + if let Some(binding) = binding_guard.as_mut() { + if &binding.server_id == server_id && container_ids.contains(&binding.container_id) { + binding.pending_refresh = true; + binding.has_seen_update = true; + return true; + } + } + + false + } + + /// Checks if the binding has pending_refresh flag set. + /// Returns false if no binding exists. + pub fn has_pending_refresh(&self) -> bool { + self.playlist_binding + .lock() + .expect("Playlist binding mutex poisoned") + .as_ref() + .map(|b| b.pending_refresh) + .unwrap_or(false) + } + + /// Resets the pending_refresh flag to false. + /// Does nothing if no binding exists. + pub fn reset_pending_refresh(&self) { + if let Some(binding) = self + .playlist_binding + .lock() + .expect("Playlist binding mutex poisoned") + .as_mut() + { + binding.pending_refresh = false; + } + } + + /// Marks the binding as pending refresh. + /// Returns true if a binding exists and was marked, false otherwise. + pub fn mark_pending_refresh(&self) -> bool { + self.playlist_binding + .lock() + .expect("Playlist binding mutex poisoned") + .as_mut() + .map(|binding| { + binding.pending_refresh = true; + true + }) + .unwrap_or(false) + } + + /// Consumes and returns the auto_play_on_refresh flag, resetting it to false. + /// Returns false if no binding exists. + pub fn consume_auto_play(&self) -> bool { + self.playlist_binding + .lock() + .expect("Playlist binding mutex poisoned") + .as_mut() + .map(|binding| { + let auto_play = binding.auto_play_on_refresh; + binding.auto_play_on_refresh = false; + auto_play + }) + .unwrap_or(false) + } + + /// Add items to the queue using the specified enqueue mode. + pub fn enqueue_items( + &self, + items: Vec, + mode: EnqueueMode, + ) -> Result<(), ControlPointError> { + let mut queue = self.queue.lock().expect("Queue mutex poisoned"); + queue.enqueue_items(items, mode) + } + + /// Synchronize the queue with new items while preserving the current track. + /// + /// This method intelligently updates the queue: + /// - If the current track is in the new items, it keeps playing at the new position + /// - If the current track is NOT in the new items, it's preserved as the first item + /// - If there's no current track, the queue is simply replaced + pub fn sync_queue(&self, items: Vec) -> Result<(), ControlPointError> { + let mut queue = self.queue.lock().expect("Queue mutex poisoned"); + queue.sync_queue(items) + } + + /// Set the current queue index (for advanced use). + /// Note: This does NOT start playback. Use select_queue_track() to play. + pub fn set_queue_index(&self, index: Option) -> Result<(), ControlPointError> { + let mut queue = self.queue.lock().expect("Queue mutex poisoned"); + queue.set_index(index) } /// Clears the renderer's queue using the generic QueueBackend trait. @@ -340,7 +515,7 @@ impl MusicRenderer { // The generic implementation cannot support this without more context // (media_server_id, didl_id, protocol_info, etc.). Err(ControlPointError::QueueError( - "add_track_to_queue requires backend-specific implementation".to_string() + "add_track_to_queue requires backend-specific implementation".to_string(), )) } @@ -360,7 +535,8 @@ impl MusicRenderer { queue.set_index(Some(position))?; // Get the item to play - let item = queue.get_item(position)? + let item = queue + .get_item(position)? .ok_or_else(|| ControlPointError::QueueError("Track not found".to_string()))?; drop(queue); @@ -396,7 +572,8 @@ impl MusicRenderer { let queue = self.queue.lock().expect("Queue mutex poisoned"); // Get current track ID using generic QueueBackend trait - let track_id = queue.current_track()? + let track_id = queue + .current_track()? .ok_or_else(|| ControlPointError::QueueError("No current track".to_string()))?; drop(queue); @@ -416,6 +593,57 @@ impl MusicRenderer { pub fn get_queue_mut(&self) -> std::sync::MutexGuard<'_, MusicQueue> { self.queue.lock().unwrap() } + + // --- Playback State Management --- + + /// Gets the last known track metadata. + pub fn last_metadata(&self) -> Option { + self.state.lock().unwrap().last_metadata.clone() + } + + /// Sets the last known track metadata. + pub fn set_last_metadata(&self, metadata: Option) { + self.state.lock().unwrap().last_metadata = metadata; + } + + /// Gets the current playback source. + pub fn playback_source(&self) -> PlaybackSource { + self.state.lock().unwrap().playback_source + } + + /// Sets the playback source. + pub fn set_playback_source(&self, source: PlaybackSource) { + self.state.lock().unwrap().playback_source = source; + } + + /// Checks if currently playing from queue. + pub fn is_playing_from_queue(&self) -> bool { + matches!( + self.state.lock().unwrap().playback_source, + PlaybackSource::FromQueue + ) + } + + /// Marks playback as external if currently idle (source is None). + pub fn mark_external_if_idle(&self) { + let mut state = self.state.lock().unwrap(); + if matches!(state.playback_source, PlaybackSource::None) { + state.playback_source = PlaybackSource::External; + } + } + + /// Marks that the user requested a stop (to prevent auto-advance). + pub fn mark_user_stop_requested(&self) { + self.state.lock().unwrap().user_stop_requested = true; + } + + /// Checks and clears the user stop requested flag. + pub fn check_and_clear_user_stop_requested(&self) -> bool { + let mut state = self.state.lock().unwrap(); + let was_requested = state.user_stop_requested; + state.user_stop_requested = false; + was_requested + } } /// Helper function to build DIDL-Lite metadata XML from TrackMetadata diff --git a/pmocontrol/src/music_renderer/openhome_renderer.rs b/pmocontrol/src/music_renderer/openhome_renderer.rs index d9f0a76c..895ca3e6 100644 --- a/pmocontrol/src/music_renderer/openhome_renderer.rs +++ b/pmocontrol/src/music_renderer/openhome_renderer.rs @@ -1,12 +1,11 @@ use crate::DeviceIdentity; use crate::music_renderer::capabilities::{ - PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, - VolumeControl, + PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl, }; -use crate::music_renderer::time_utils::{parse_time_flexible, format_hhmmss_u32}; +use crate::music_renderer::time_utils::{format_hhmmss_u32, parse_time_flexible}; use crate::errors::ControlPointError; -use crate::model::{RendererInfo, PlaybackState}; +use crate::model::{PlaybackState, RendererInfo}; use crate::music_renderer::RendererFromMediaRendererInfo; use crate::music_renderer::musicrenderer::MusicRendererBackend; use crate::music_renderer::openhome::{ @@ -49,7 +48,6 @@ impl OpenHomeRenderer { } } - pub fn has_playlist(&self) -> bool { self.playlist.is_some() } @@ -71,10 +69,9 @@ impl OpenHomeRenderer { } fn playlist_client_for(&self, op: &str) -> Result<&OhPlaylistClient, ControlPointError> { - let playlist = self - .playlist - .as_ref() - .ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Playlist"))?; + let playlist = self.playlist.as_ref().ok_or_else(|| { + ControlPointError::upnp_operation_not_supported(op, "OpenHome Playlist") + })?; self.ensure_playlist_source_selected()?; Ok(playlist) } @@ -97,10 +94,9 @@ impl OpenHomeRenderer { .ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Volume")) } - fn ensure_playlist_source_selected(&self) -> Result<(),ControlPointError> { + fn ensure_playlist_source_selected(&self) -> Result<(), ControlPointError> { if let Some(product) = &self.product_client { - product - .ensure_playlist_source_selected() + product.ensure_playlist_source_selected() } else { Ok(()) } @@ -132,7 +128,6 @@ impl OpenHomeRenderer { // }) // } - /// Retourne la longueur de la playlist OpenHome sans récupérer toutes les métadonnées. /// Plus rapide que snapshot_openhome_playlist() pour juste connaître le nombre de pistes. pub(crate) fn openhome_playlist_len(&self) -> Result { @@ -183,11 +178,8 @@ impl OpenHomeRenderer { } } - impl RendererFromMediaRendererInfo for OpenHomeRenderer { - fn from_renderer_info( - info: &RendererInfo, - ) -> Result { + fn from_renderer_info(info: &RendererInfo) -> Result { let renderer = OpenHomeRenderer::new( build_playlist_client(&info), build_info_client(&info), @@ -210,7 +202,6 @@ impl RendererFromMediaRendererInfo for OpenHomeRenderer { fn to_backend(self) -> MusicRendererBackend { MusicRendererBackend::OpenHome(self) } - } impl TransportControl for OpenHomeRenderer { @@ -228,6 +219,9 @@ impl TransportControl for OpenHomeRenderer { // Reuse the same insertion logic as the queue path so that we honor // renderer expectations (IdArray sequencing, etc.). self.add_track_openhome(uri, meta, None, true)?; + + // Start playback (like UPnP renderer does with avt.play()) + playlist.play()?; Ok(()) } @@ -336,4 +330,3 @@ pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState { other => PlaybackState::Unknown(other.to_string()), } } - diff --git a/pmocontrol/src/online.rs b/pmocontrol/src/online.rs index f22d3ec0..ed8dbb3a 100644 --- a/pmocontrol/src/online.rs +++ b/pmocontrol/src/online.rs @@ -1,9 +1,9 @@ use std::{ sync::{Arc, Mutex}, - time::{SystemTime}, + time::SystemTime, }; -#[derive(Clone,Debug)] +#[derive(Clone, Debug)] pub struct DeviceConnectionState { online: bool, last_seen: SystemTime, @@ -20,8 +20,11 @@ pub trait DeviceOnline { impl DeviceConnectionState { pub fn new() -> Self { - DeviceConnectionState { - online: false, last_seen: std::time::UNIX_EPOCH, max_age: 1800 } + DeviceConnectionState { + online: true, + last_seen: SystemTime::now(), + max_age: 1800, + } } pub fn make() -> Arc> { @@ -43,6 +46,7 @@ impl DeviceConnectionState { pub fn has_been_seen_now(&mut self, max_age: u32) { self.last_seen = SystemTime::now(); self.max_age = max_age; + self.online = true; } pub fn mark_as_offline(&mut self) { diff --git a/pmocontrol/src/openapi.rs b/pmocontrol/src/openapi.rs index 82e7a0b1..4b37bd97 100644 --- a/pmocontrol/src/openapi.rs +++ b/pmocontrol/src/openapi.rs @@ -149,28 +149,6 @@ pub struct QueueSnapshot { pub current_index: Option, } -// ============================================================================ -// OPENHOME PLAYLIST -// ============================================================================ - -#[cfg(feature = "pmoserver")] -pub use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; - -/// Requête pour ajouter un track à la playlist OpenHome -#[cfg(feature = "pmoserver")] -#[derive(Debug, Clone, Deserialize, ToSchema)] -pub struct OpenHomePlaylistAddRequest { - /// URI du flux à insérer - pub uri: String, - /// Métadonnées DIDL-Lite complètes - pub metadata: String, - /// ID devant lequel insérer (None => fin de playlist) - pub after_id: Option, - /// Si true, démarre immédiatement la lecture du track inséré - #[serde(default)] - pub play: bool, -} - // ============================================================================ // MEDIA SERVERS // ============================================================================ @@ -318,20 +296,22 @@ pub struct ErrorResponse { 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. +Cette API permet de contrôler les renderers audio et de naviguer dans les serveurs de médias de manière agnostique du backend. ## Fonctionnalités ### Renderers -- **Découverte** : Liste des renderers disponibles +- **Découverte** : Liste des renderers disponibles (tous types: UPnP AV, OpenHome, LinkPlay, Chromecast) - **État** : Récupération de l'état détaillé d'un renderer -- **Contrôle transport** : Play, pause, stop, next +- **Contrôle transport** : Play, pause, stop, resume, next - **Contrôle volume** : Lecture et modification du volume / mute -- **Queue** : Gestion de la queue de lecture +- **Queue unifiée** : Gestion de la queue de lecture (indépendante du backend) +- **Navigation dans la queue** : Saut à un index spécifique ### 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 +- **Auto-play** : Option pour démarrer la lecture automatiquement ### Serveurs de médias - **Découverte** : Liste des serveurs disponibles @@ -339,11 +319,12 @@ Cette API permet de contrôler les renderers UPnP et de naviguer dans les serveu ## 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 +Le Control Point PMOMusic est un point de contrôle multi-backend qui : +1. Découvre automatiquement les renderers et serveurs via SSDP et mDNS +2. Maintient un registre unifié des devices actifs +3. Abstrait les différences entre backends (UPnP AV, OpenHome, LinkPlay, Arylic TCP, Chromecast) +4. Gère une queue de lecture unifiée avec synchronisation optionnelle aux playlists serveur +5. Expose une API REST cohérente indépendante du type de renderer ## Exemples d'utilisation @@ -352,12 +333,33 @@ Le Control Point PMOMusic est un point de contrôle UPnP qui : GET /control/renderers ``` -### Contrôler un renderer +### Obtenir l'état complet d'un renderer +``` +GET /control/renderers/{renderer_id}/full +``` + +### Contrôler la lecture ``` POST /control/renderers/{renderer_id}/play POST /control/renderers/{renderer_id}/pause +POST /control/renderers/{renderer_id}/stop +POST /control/renderers/{renderer_id}/resume +POST /control/renderers/{renderer_id}/next +``` + +### Naviguer dans la queue +``` +POST /control/renderers/{renderer_id}/queue/seek + Body: {"index": 5} +``` + +### Contrôler le volume +``` POST /control/renderers/{renderer_id}/volume/set Body: {"volume": 50} +POST /control/renderers/{renderer_id}/volume/up +POST /control/renderers/{renderer_id}/volume/down +POST /control/renderers/{renderer_id}/mute/toggle ``` ### Attacher une playlist @@ -365,7 +367,17 @@ POST /control/renderers/{renderer_id}/volume/set POST /control/renderers/{renderer_id}/binding/attach Body: { "server_id": "uuid:...", - "container_id": "0$/Music/MyPlaylist" + "container_id": "0$/Music/MyPlaylist", + "auto_play": true + } +``` + +### Jouer du contenu +``` +POST /control/renderers/{renderer_id}/queue/play + Body: { + "server_id": "uuid:...", + "object_id": "0$/Music/Track.flac" } ``` @@ -384,16 +396,15 @@ GET /control/servers/{server_id}/containers/{container_id} paths( crate::pmoserver_ext::list_renderers, crate::pmoserver_ext::get_renderer_state, + crate::pmoserver_ext::get_renderer_full_snapshot, crate::pmoserver_ext::get_renderer_queue, crate::pmoserver_ext::get_renderer_binding, - crate::pmoserver_ext::get_openhome_playlist, - crate::pmoserver_ext::clear_openhome_playlist, - crate::pmoserver_ext::add_openhome_playlist_item, - crate::pmoserver_ext::play_openhome_track, crate::pmoserver_ext::play_renderer, crate::pmoserver_ext::pause_renderer, crate::pmoserver_ext::stop_renderer, + crate::pmoserver_ext::resume_renderer, crate::pmoserver_ext::next_renderer, + crate::pmoserver_ext::seek_queue_index, crate::pmoserver_ext::set_renderer_volume, crate::pmoserver_ext::volume_up_renderer, crate::pmoserver_ext::volume_down_renderer, @@ -415,17 +426,16 @@ GET /control/servers/{server_id}/containers/{container_id} RendererState, CurrentTrackMetadata, AttachedPlaylistInfo, + FullRendererSnapshot, QueueItem, QueueSnapshot, - OpenHomePlaylistSnapshot, - OpenHomePlaylistTrack, - OpenHomePlaylistAddRequest, MediaServerSummary, ContainerEntry, BrowseResponse, VolumeSetRequest, AttachPlaylistRequest, PlayContentRequest, + SeekQueueRequest, SuccessResponse, ErrorResponse, )), diff --git a/pmocontrol/src/pmoserver_ext.rs b/pmocontrol/src/pmoserver_ext.rs index 09771f04..d909d2a2 100644 --- a/pmocontrol/src/pmoserver_ext.rs +++ b/pmocontrol/src/pmoserver_ext.rs @@ -4,27 +4,22 @@ //! et naviguer dans les serveurs de médias. #[cfg(feature = "pmoserver")] -use crate::control_point::{ - ControlPoint, OpenHomeAccessError, OPENHOME_SNAPSHOT_CACHE_TTL, -}; +use crate::control_point::ControlPoint; #[cfg(feature = "pmoserver")] -use crate::media_server::{ - playback_item_from_entry, MediaBrowser, MediaEntry, UpnpMediaServer, ServerId, -}; +use crate::media_server::{MediaBrowser, playback_item_from_entry}; #[cfg(feature = "pmoserver")] -use crate::model::{RendererCapabilities, ServiceId, RendererProtocol, TrackMetadata}; +use crate::model::{RendererCapabilities, RendererProtocol}; #[cfg(feature = "pmoserver")] use crate::openapi::{ AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse, - FullRendererSnapshot, MediaServerSummary, OpenHomePlaylistAddRequest, OpenHomePlaylistSnapshot, - PlayContentRequest, QueueItem, QueueSnapshot, RendererCapabilitiesSummary, - RendererProtocolSummary, RendererState, RendererSummary, SeekQueueRequest, SuccessResponse, - VolumeSetRequest, + FullRendererSnapshot, MediaServerSummary, PlayContentRequest, QueueSnapshot, + RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary, + SeekQueueRequest, SuccessResponse, VolumeSetRequest, }; #[cfg(feature = "pmoserver")] -use crate::queue_backend::PlaybackItem; +use crate::queue::PlaybackItem; #[cfg(feature = "pmoserver")] -use crate::{PlaybackPosition, PlaybackStatus, TransportControl, VolumeControl}; +use crate::{DeviceId, DeviceIdentity, DeviceOnline}; #[cfg(feature = "pmoserver")] use async_trait::async_trait; @@ -49,8 +44,6 @@ use utoipa::OpenApi; #[cfg(feature = "pmoserver")] const BROWSE_PAGE_SIZE: u32 = 100; #[cfg(feature = "pmoserver")] -const MEDIA_SERVER_SOAP_TIMEOUT: Duration = Duration::from_secs(15); -#[cfg(feature = "pmoserver")] const BROWSE_REQUEST_TIMEOUT: Duration = Duration::from_secs(20); // Timeouts for simple commands (play/pause/stop) @@ -105,12 +98,12 @@ async fn list_renderers(State(state): State) -> Json, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); let snapshot = state .control_point .renderer_full_snapshot(&rid) @@ -162,7 +155,7 @@ async fn get_renderer_full_snapshot( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); let snapshot = state .control_point .renderer_full_snapshot(&rid) @@ -193,7 +186,7 @@ async fn get_renderer_queue( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); let snapshot = state .control_point .renderer_full_snapshot(&rid) @@ -220,7 +213,7 @@ async fn get_renderer_binding( State(state): State, Path(renderer_id): Path, ) -> Result>, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); let snapshot = state .control_point .renderer_full_snapshot(&rid) @@ -252,7 +245,7 @@ async fn play_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); let renderer = state .control_point .music_renderer_by_id(&rid) @@ -328,7 +321,7 @@ async fn pause_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); let renderer = state .control_point .music_renderer_by_id(&rid) @@ -404,7 +397,7 @@ async fn stop_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); state .control_point .music_renderer_by_id(&rid) @@ -481,7 +474,7 @@ async fn resume_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); state .control_point .music_renderer_by_id(&rid) @@ -562,7 +555,7 @@ async fn next_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); state .control_point .music_renderer_by_id(&rid) @@ -643,7 +636,7 @@ async fn seek_queue_index( Path(renderer_id): Path, Json(payload): Json, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); state .control_point .music_renderer_by_id(&rid) @@ -659,9 +652,8 @@ async fn seek_queue_index( let control_point = Arc::clone(&state.control_point); let rid_for_task = rid.clone(); let index = payload.index; - let seek_task = tokio::task::spawn_blocking(move || { - control_point.play_queue_index(&rid_for_task, index) - }); + let seek_task = + tokio::task::spawn_blocking(move || control_point.play_queue_index(&rid_for_task, index)); time::timeout(TRANSPORT_COMMAND_TIMEOUT, seek_task) .await @@ -729,7 +721,7 @@ async fn set_renderer_volume( Path(renderer_id): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); let renderer = state .control_point @@ -807,7 +799,7 @@ async fn volume_up_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); let renderer = state .control_point @@ -892,7 +884,7 @@ async fn volume_down_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); let renderer = state .control_point @@ -977,7 +969,7 @@ async fn toggle_mute_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); let renderer = state .control_point @@ -1064,8 +1056,8 @@ async fn attach_playlist_binding( Path(renderer_id): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); - let sid = ServerId(req.server_id.clone()); + let rid = DeviceId(renderer_id.clone()); + let sid = DeviceId(req.server_id.clone()); let container_id = req.container_id.clone(); let control_point = Arc::clone(&state.control_point); @@ -1146,7 +1138,7 @@ async fn detach_playlist_binding( State(state): State, Path(renderer_id): Path, ) -> Json { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); state.control_point.detach_queue_playlist(&rid); @@ -1160,298 +1152,6 @@ async fn detach_playlist_binding( }) } -// ============================================================================ -// HANDLERS - OPENHOME PLAYLIST -// ============================================================================ - -/// GET /control/renderers/{renderer_id}/oh/playlist - Snapshot de la playlist OH -#[cfg(feature = "pmoserver")] -#[utoipa::path( - get, - path = "/renderers/{renderer_id}/oh/playlist", - params( - ("renderer_id" = String, Path, description = "ID unique du renderer") - ), - responses( - (status = 200, description = "Playlist OpenHome", body = OpenHomePlaylistSnapshot), - (status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse) - ), - tag = "control" -)] -// async fn get_openhome_playlist( -// State(state): State, -// Path(renderer_id): Path, -// ) -> Result, (StatusCode, Json)> { -// let rid = RendererId(renderer_id.clone()); -// let control_point = Arc::clone(&state.control_point); -// let rid_for_task = rid.clone(); - -// let fetch_task = tokio::task::spawn_blocking(move || { -// control_point.get_cached_openhome_playlist_snapshot( -// &rid_for_task, -// OPENHOME_SNAPSHOT_CACHE_TTL, -// ) -// }); - -// let snapshot = fetch_task -// .await -// .map_err(|e| { -// warn!( -// renderer = renderer_id.as_str(), -// error = %e, -// "Join error while fetching OpenHome playlist" -// ); -// ( -// StatusCode::INTERNAL_SERVER_ERROR, -// Json(ErrorResponse { -// error: format!("Internal task error: {}", e), -// }), -// ) -// })? -// .map_err(|e| { -// warn!( -// renderer = renderer_id.as_str(), -// error = %e, -// "Failed to read OpenHome playlist" -// ); -// map_openhome_error(&rid, e, "read OpenHome playlist") -// })?; - -// Ok(Json(snapshot)) -// } - -/// POST /control/renderers/{renderer_id}/oh/playlist/clear - Vide la playlist OH -#[cfg(feature = "pmoserver")] -#[utoipa::path( - post, - path = "/renderers/{renderer_id}/oh/playlist/clear", - params( - ("renderer_id" = String, Path, description = "ID unique du renderer") - ), - responses( - (status = 200, description = "Playlist vidée", body = SuccessResponse), - (status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse) - ), - tag = "control" -)] -async fn clear_openhome_playlist( - State(state): State, - Path(renderer_id): Path, -) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); - let control_point = Arc::clone(&state.control_point); - let rid_for_task = rid.clone(); - - let clear_task = - tokio::task::spawn_blocking(move || control_point.clear_renderer_queue(&rid_for_task)); - - time::timeout(QUEUE_COMMAND_TIMEOUT, clear_task) - .await - .map_err(|_| { - warn!( - renderer = renderer_id.as_str(), - timeout = QUEUE_COMMAND_TIMEOUT.as_secs(), - "Clearing OpenHome playlist timed out" - ); - ( - StatusCode::GATEWAY_TIMEOUT, - Json(ErrorResponse { - error: format!( - "Clear playlist timed out after {}s", - QUEUE_COMMAND_TIMEOUT.as_secs() - ), - }), - ) - })? - .map_err(|e| { - warn!( - renderer = renderer_id.as_str(), - error = %e, - "Join error while clearing OpenHome playlist" - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: format!("Internal task error: {}", e), - }), - ) - })? - .map_err(|e| { - warn!( - renderer = renderer_id.as_str(), - error = %e, - "Failed to clear OpenHome playlist" - ); - map_openhome_error(&rid, e, "clear OpenHome playlist") - })?; - - Ok(Json(SuccessResponse { - message: "OpenHome playlist cleared".to_string(), - })) -} - -/// POST /control/renderers/{renderer_id}/oh/playlist/add - Ajoute un track OH -#[cfg(feature = "pmoserver")] -#[utoipa::path( - post, - path = "/renderers/{renderer_id}/oh/playlist/add", - params( - ("renderer_id" = String, Path, description = "ID unique du renderer") - ), - request_body = OpenHomePlaylistAddRequest, - responses( - (status = 200, description = "Track ajouté", body = SuccessResponse), - (status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse) - ), - tag = "control" -)] -async fn add_openhome_playlist_item( - State(state): State, - Path(renderer_id): Path, - Json(req): Json, -) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); - let control_point = Arc::clone(&state.control_point); - let rid_for_task = rid.clone(); - - let add_task = tokio::task::spawn_blocking(move || { - control_point.add_track_to_renderer( - &rid_for_task, - &req.uri, - &req.metadata, - req.after_id, - req.play, - ) - .map(|_| ()) // Ignore the track_id result for backward compatibility - }); - - time::timeout(QUEUE_COMMAND_TIMEOUT, add_task) - .await - .map_err(|_| { - warn!( - renderer = renderer_id.as_str(), - timeout = QUEUE_COMMAND_TIMEOUT.as_secs(), - "Adding OpenHome track timed out" - ); - ( - StatusCode::GATEWAY_TIMEOUT, - Json(ErrorResponse { - error: format!( - "Add track timed out after {}s", - QUEUE_COMMAND_TIMEOUT.as_secs() - ), - }), - ) - })? - .map_err(|e| { - warn!( - renderer = renderer_id.as_str(), - error = %e, - "Join error while adding OpenHome track" - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: format!("Internal task error: {}", e), - }), - ) - })? - .map_err(|e| { - warn!( - renderer = renderer_id.as_str(), - error = %e, - "Failed to add OpenHome track" - ); - map_openhome_error(&rid, e, "add OpenHome track") - })?; - - Ok(Json(SuccessResponse { - message: "Track added to OpenHome playlist".to_string(), - })) -} - -/// POST /control/renderers/{renderer_id}/oh/playlist/play/{track_id} - PlayId OH -#[cfg(feature = "pmoserver")] -#[utoipa::path( - post, - path = "/renderers/{renderer_id}/oh/playlist/play/{track_id}", - params( - ("renderer_id" = String, Path, description = "ID unique du renderer"), - ("track_id" = String, Path, description = "ID OpenHome du morceau") - ), - responses( - (status = 200, description = "Lecture démarrée", body = SuccessResponse), - (status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse) - ), - tag = "control" -)] -async fn play_openhome_track( - State(state): State, - Path((renderer_id, track_id)): Path<(String, String)>, -) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); - let parsed_id = track_id.parse::().map_err(|e| { - ( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: format!("Invalid track id '{}': {}", track_id, e), - }), - ) - })?; - - let control_point = Arc::clone(&state.control_point); - let rid_for_task = rid.clone(); - - let play_task = tokio::task::spawn_blocking(move || { - control_point.select_renderer_track(&rid_for_task, parsed_id) - }); - - time::timeout(QUEUE_COMMAND_TIMEOUT, play_task) - .await - .map_err(|_| { - warn!( - renderer = renderer_id.as_str(), - timeout = QUEUE_COMMAND_TIMEOUT.as_secs(), - "PlayId command timed out" - ); - ( - StatusCode::GATEWAY_TIMEOUT, - Json(ErrorResponse { - error: format!( - "Play track timed out after {}s", - QUEUE_COMMAND_TIMEOUT.as_secs() - ), - }), - ) - })? - .map_err(|e| { - warn!( - renderer = renderer_id.as_str(), - error = %e, - "Join error while playing OpenHome track" - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: format!("Internal task error: {}", e), - }), - ) - })? - .map_err(|e| { - warn!( - renderer = renderer_id.as_str(), - error = %e, - track_id = parsed_id, - "Failed to start OpenHome track" - ); - map_openhome_error(&rid, e, "play OpenHome track") - })?; - - Ok(Json(SuccessResponse { - message: format!("Playing OpenHome track {}", parsed_id), - })) -} - // ============================================================================ // HANDLERS - QUEUE CONTENT // ============================================================================ @@ -1477,8 +1177,8 @@ async fn play_content( Path(renderer_id): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); - let sid = ServerId(req.server_id.clone()); + let rid = DeviceId(renderer_id.clone()); + let sid = DeviceId(req.server_id.clone()); let object_id = req.object_id.clone(); let object_id_for_log = object_id.clone(); @@ -1606,8 +1306,8 @@ async fn add_to_queue( Path(renderer_id): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); - let sid = ServerId(req.server_id.clone()); + let rid = DeviceId(renderer_id.clone()); + let sid = DeviceId(req.server_id.clone()); let object_id = req.object_id.clone(); let object_id_for_log = object_id.clone(); @@ -1707,15 +1407,15 @@ async fn add_to_queue( tag = "control" )] async fn list_servers(State(state): State) -> Json> { - let servers = state.control_point.list_media_servers(); + let servers = state.control_point.list_media_servers().unwrap_or_default(); 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, + id: s.id().0.clone(), + friendly_name: s.friendly_name().to_string(), + model_name: s.model_name().to_string(), + online: s.is_online(), }) .collect(); @@ -1742,9 +1442,9 @@ 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 sid = DeviceId(server_id.clone()); - let server_info = state.control_point.media_server(&sid).ok_or_else(|| { + let server = state.control_point.media_server(&sid).ok_or_else(|| { ( StatusCode::NOT_FOUND, Json(ErrorResponse { @@ -1753,7 +1453,7 @@ async fn browse_container( ) })?; - if !server_info.online { + if !server.is_online() { return Err(( StatusCode::SERVICE_UNAVAILABLE, Json(ErrorResponse { @@ -1762,7 +1462,7 @@ async fn browse_container( )); } - if !server_info.has_content_directory { + if !server.has_content_directory() { return Err(( StatusCode::NOT_IMPLEMENTED, Json(ErrorResponse { @@ -1771,21 +1471,11 @@ async fn browse_container( )); } - let music_server = - UpnpMediaServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT).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), - }), - ) - })?; - // Use spawn_blocking to avoid blocking the async runtime with synchronous SOAP calls let container_id_clone = container_id.clone(); + let server_clone = server.clone(); let browse_task = tokio::task::spawn_blocking(move || { - music_server.browse_children(&container_id_clone, 0, BROWSE_PAGE_SIZE) + server_clone.browse_children(&container_id_clone, 0, BROWSE_PAGE_SIZE) }); let entries = time::timeout(BROWSE_REQUEST_TIMEOUT, browse_task) @@ -1869,32 +1559,6 @@ fn map_snapshot_error( ) } -#[cfg(feature = "pmoserver")] -fn map_openhome_error( - renderer_id: &ServiceId, - err: anyhow::Error, - context: &str, -) -> (StatusCode, Json) { - if err.downcast_ref::().is_some() { - ( - StatusCode::NOT_FOUND, - Json(ErrorResponse { - error: err.to_string(), - }), - ) - } else { - ( - StatusCode::BAD_GATEWAY, - Json(ErrorResponse { - error: format!( - "Failed to {context} for renderer {}: {}", - renderer_id.0, err - ), - }), - ) - } -} - /// Helper to fetch playback items from a media server object (container or item). /// /// This function browses the server to get the entries and converts them to PlaybackItem. @@ -1902,30 +1566,27 @@ fn map_openhome_error( #[cfg(feature = "pmoserver")] fn fetch_playback_items( control_point: &ControlPoint, - server_id: &ServerId, + server_id: &DeviceId, object_id: &str, ) -> anyhow::Result> { - // Get server info from registry - let server_info = control_point + // Get server from registry + let server = control_point .media_server(server_id) .ok_or_else(|| anyhow::anyhow!("Server {} not found", server_id.0))?; - if !server_info.online { + if !server.is_online() { return Err(anyhow::anyhow!("Server {} is offline", server_id.0)); } - if !server_info.has_content_directory { + if !server.has_content_directory() { return Err(anyhow::anyhow!( "Server {} does not support ContentDirectory", server_id.0 )); } - // Create MusicServer - let music_server = UpnpMediaServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT)?; - // Browse the object to get entries - let entries = music_server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?; + let entries = server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?; debug!( server_id = server_id.0.as_str(), @@ -1939,7 +1600,7 @@ fn fetch_playback_items( // Convert to PlaybackItem let items: Vec = entries .iter() - .filter_map(|entry| playback_item_from_entry(&music_server, entry)) + .filter_map(|entry| playback_item_from_entry(server.clone(), entry)) .collect(); if items.is_empty() && !entries.is_empty() { @@ -2009,7 +1670,10 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc MusicQueue; - fn build_from_renderer_info( - renderer: &RendererInfo, - ) -> Result + fn build_from_renderer_info(renderer: &RendererInfo) -> Result where Self: Sized, { @@ -42,4 +40,4 @@ pub trait QueueFromRendererInfo { let backend = Self::build_from_renderer_info(renderer)?; Ok(Arc::new(Mutex::new(backend))) } -} \ No newline at end of file +} diff --git a/pmocontrol/src/queue/openhome.rs b/pmocontrol/src/queue/openhome.rs index 11abe994..1bf3d102 100644 --- a/pmocontrol/src/queue/openhome.rs +++ b/pmocontrol/src/queue/openhome.rs @@ -5,7 +5,8 @@ use tracing::debug; use crate::errors::ControlPointError; use crate::upnp_clients::{ - OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrack, OhTrackEntry + OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrack, + OhTrackEntry, }; // use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; use crate::queue::{ @@ -253,8 +254,8 @@ impl OpenHomeQueue { self.delete_marked_items(&old_ids_before, &keep_old_before, "BEFORE pivot")?; // Rebuild the playlist: [BEFORE, PIVOT, AFTER] - // Rebuild BEFORE part - let previous_id = self.rebuild_playlist_section( + // Rebuild BEFORE part (we don't need the returned previous_id) + self.rebuild_playlist_section( new_before, &keep_new_before, &old_ids_before, @@ -263,7 +264,7 @@ impl OpenHomeQueue { "BEFORE pivot", )?; - // Add PIVOT (keeps its ID!) + // PIVOT keeps its ID and position - it's the anchor point let previous_id = pivot_id as u32; debug!( renderer = self.renderer_id.0.as_str(), @@ -513,7 +514,9 @@ impl QueueBackend for OpenHomeQueue { } fn current_track(&self) -> Result, ControlPointError> { - Ok(Some(self.playlist_client.id()?)) + let id = self.playlist_client.id()?; + // OpenHome returns 0 when no track is selected/playing + if id == 0 { Ok(None) } else { Ok(Some(id)) } } fn current_index(&self) -> Result, ControlPointError> { diff --git a/pmocontrol/src/registry.rs b/pmocontrol/src/registry.rs index cb91e5bb..c36e8ce1 100644 --- a/pmocontrol/src/registry.rs +++ b/pmocontrol/src/registry.rs @@ -106,12 +106,12 @@ impl DeviceOnline for DeviceItem { } impl DeviceRegistry { - pub fn new(renderer_bus: RendererEventBus, server_bus: MediaServerEventBus) -> Self { + pub fn new(renderer_bus: &RendererEventBus, server_bus: &MediaServerEventBus) -> Self { Self { devices: HashMap::new(), udn_index: HashMap::new(), - renderer_bus, - server_bus, + renderer_bus: renderer_bus.clone(), + server_bus: server_bus.clone(), } } @@ -168,8 +168,8 @@ impl DeviceRegistry { if !was_online { self.renderer_bus.broadcast(RendererEvent::Online { - id: device_id, - info: info.clone(), + id: device_id.clone(), + info: info.basic_info(), }); } return; @@ -182,8 +182,8 @@ impl DeviceRegistry { // Broadcast sur le bon bus self.renderer_bus.broadcast(RendererEvent::Online { - id: device_id, - info: info.clone(), + id: device_id.clone(), + info: info.basic_info(), }); } } else { @@ -201,7 +201,7 @@ impl DeviceRegistry { // Broadcast sur le bon bus self.renderer_bus.broadcast(RendererEvent::Online { id: device_id, - info: info.clone(), + info: info.basic_info(), }); } } @@ -217,8 +217,8 @@ impl DeviceRegistry { if !was_online { self.server_bus.broadcast(MediaServerEvent::Online { - server_id: device_id, - info: info.clone(), + server_id: device_id.clone(), + info: info.basic_info(), }); } @@ -232,8 +232,8 @@ impl DeviceRegistry { // Broadcast sur le bon bus self.server_bus.broadcast(MediaServerEvent::Online { - server_id: device_id, - info: info.clone(), + server_id: device_id.clone(), + info: info.basic_info(), }); } } else { @@ -251,7 +251,7 @@ impl DeviceRegistry { // Broadcast sur le bon bus self.server_bus.broadcast(MediaServerEvent::Online { server_id: device_id, - info: info.clone(), + info: info.basic_info(), }); } } diff --git a/pmocontrol/src/sse.rs b/pmocontrol/src/sse.rs index 57f84278..c90e2af4 100644 --- a/pmocontrol/src/sse.rs +++ b/pmocontrol/src/sse.rs @@ -19,8 +19,6 @@ use crate::control_point::ControlPoint; #[cfg(feature = "pmoserver")] use crate::model::{MediaServerEvent, RendererEvent}; #[cfg(feature = "pmoserver")] -use crate::registry::DeviceRegistryRead; -#[cfg(feature = "pmoserver")] use async_stream::stream; #[cfg(feature = "pmoserver")] use axum::{ @@ -34,6 +32,9 @@ use serde::Serialize; #[cfg(feature = "pmoserver")] use std::sync::Arc; +use crate::{DeviceIdentity, DeviceOnline}; +use tracing::error; + // ============================================================================ // PAYLOADS SSE // ============================================================================ @@ -175,17 +176,23 @@ pub async fn renderer_events_sse( let initial_renderers = { let registry = control_point.registry(); let reg = registry.read().unwrap(); - reg.list_renderers() + match reg.list_renderers() { + Ok(renderers) => renderers, + Err(e) => { + error!("Failed to list renderers: {}", e); + Vec::new() + } + } }; - for info in initial_renderers { - if info.is_online() { + for renderer in initial_renderers { + if renderer.is_online() { let timestamp = chrono::Utc::now(); let payload = RendererEventPayload::Online { - renderer_id: info.id().0, - friendly_name: info.friendly_name().to_string(), - model_name: info. ().to_string(), - manufacturer: info.manufacturer().to_string(), + renderer_id: renderer.id().0, + friendly_name: renderer.friendly_name().to_string(), + model_name: renderer.model_name().to_string(), + manufacturer: renderer.manufacturer().to_string(), timestamp, }; @@ -258,9 +265,9 @@ pub async fn renderer_events_sse( RendererEvent::Online { id, info } => { RendererEventPayload::Online { renderer_id: id.0, - friendly_name: info.friendly_name, - model_name: info.model_name, - manufacturer: info.manufacturer, + friendly_name: info.friendly_name.clone(), + model_name: info.model_name.clone(), + manufacturer: info.manufacturer.clone(), timestamp, } } @@ -318,17 +325,23 @@ pub async fn media_server_events_sse( let initial_servers = { let registry = control_point.registry(); let reg = registry.read().unwrap(); - reg.list_servers() + match reg.list_servers() { + Ok(servers) => servers, + Err(e) => { + error!("Failed to list servers: {}", e); + Vec::new() + } + } }; - for info in initial_servers { - if info.online { + for server in initial_servers { + if server.is_online() { let timestamp = chrono::Utc::now(); let payload = MediaServerEventPayload::Online { - server_id: info.id.0.clone(), - friendly_name: info.friendly_name.clone(), - model_name: info.model_name.clone(), - manufacturer: info.manufacturer.clone(), + server_id: server.id().0, + friendly_name: server.friendly_name().to_string(), + model_name: server.model_name().to_string(), + manufacturer: server.manufacturer().to_string(), timestamp, }; @@ -360,9 +373,9 @@ pub async fn media_server_events_sse( MediaServerEvent::Online { server_id, info } => { MediaServerEventPayload::Online { server_id: server_id.0, - friendly_name: info.friendly_name, - model_name: info.model_name, - manufacturer: info.manufacturer, + friendly_name: info.friendly_name.clone(), + model_name: info.model_name.clone(), + manufacturer: info.manufacturer.clone(), timestamp, } } @@ -429,18 +442,32 @@ pub async fn all_events_sse(State(control_point): State>) -> i let (initial_renderers, initial_servers) = { let registry = control_point.registry(); let reg = registry.read().unwrap(); - (reg.list_renderers(), reg.list_servers()) + let renderers = match reg.list_renderers() { + Ok(r) => r, + Err(e) => { + error!("Failed to list renderers: {}", e); + Vec::new() + } + }; + let servers = match reg.list_servers() { + Ok(s) => s, + Err(e) => { + error!("Failed to list servers: {}", e); + Vec::new() + } + }; + (renderers, servers) }; // Send renderer Online events - for info in initial_renderers { - if info.online { + for renderer in initial_renderers { + if renderer.is_online() { let timestamp = chrono::Utc::now(); let renderer_payload = RendererEventPayload::Online { - renderer_id: info.id.0.clone(), - friendly_name: info.friendly_name.clone(), - model_name: info.model_name.clone(), - manufacturer: info.manufacturer.clone(), + renderer_id: renderer.id().0, + friendly_name: renderer.friendly_name().to_string(), + model_name: renderer.model_name().to_string(), + manufacturer: renderer.manufacturer().to_string(), timestamp, }; let payload = UnifiedEventPayload::Renderer(renderer_payload); @@ -452,14 +479,14 @@ pub async fn all_events_sse(State(control_point): State>) -> i } // Send server Online events - for info in initial_servers { - if info.online { + for server in initial_servers { + if server.is_online() { let timestamp = chrono::Utc::now(); let server_payload = MediaServerEventPayload::Online { - server_id: info.id.0.clone(), - friendly_name: info.friendly_name.clone(), - model_name: info.model_name.clone(), - manufacturer: info.manufacturer.clone(), + server_id: server.id().0, + friendly_name: server.friendly_name().to_string(), + model_name: server.model_name().to_string(), + manufacturer: server.manufacturer().to_string(), timestamp, }; let payload = UnifiedEventPayload::MediaServer(server_payload); @@ -535,9 +562,9 @@ pub async fn all_events_sse(State(control_point): State>) -> i RendererEvent::Online { id, info } => { RendererEventPayload::Online { renderer_id: id.0, - friendly_name: info.friendly_name, - model_name: info.model_name, - manufacturer: info.manufacturer, + friendly_name: info.friendly_name.clone(), + model_name: info.model_name.clone(), + manufacturer: info.manufacturer.clone(), timestamp, } } @@ -576,9 +603,9 @@ pub async fn all_events_sse(State(control_point): State>) -> i MediaServerEvent::Online { server_id, info } => { MediaServerEventPayload::Online { server_id: server_id.0, - friendly_name: info.friendly_name, - model_name: info.model_name, - manufacturer: info.manufacturer, + friendly_name: info.friendly_name.clone(), + model_name: info.model_name.clone(), + manufacturer: info.manufacturer.clone(), timestamp, } } diff --git a/pmocontrol/src/upnp_clients/avtransport_client.rs b/pmocontrol/src/upnp_clients/avtransport_client.rs index 088b5547..ce1dc839 100644 --- a/pmocontrol/src/upnp_clients/avtransport_client.rs +++ b/pmocontrol/src/upnp_clients/avtransport_client.rs @@ -5,10 +5,10 @@ use std::time::Duration; use crate::{ errors::ControlPointError, soap_client::{ - extract_child_text, handle_action_response, invoke_upnp_action, invoke_upnp_action_with_timeout + extract_child_text, handle_action_response, invoke_upnp_action, + invoke_upnp_action_with_timeout, }, }; -use anyhow::{Result, anyhow}; use pmoupnp::soap::SoapEnvelope; use xmltree::{Element, XMLNode}; @@ -54,10 +54,11 @@ impl AvTransportClient { ))); } - let envelope = call_result - .envelope - .as_ref() - .ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetTransportInfo response")))?; + let envelope = call_result.envelope.as_ref().ok_or_else(|| { + ControlPointError::UpnpError(format!( + "Missing SOAP envelope in GetTransportInfo response" + )) + })?; parse_transport_info(envelope) } @@ -181,7 +182,11 @@ impl AvTransportClient { fn parse_transport_info(envelope: &SoapEnvelope) -> Result { let response = find_child_with_suffix(&envelope.body.content, "GetTransportInfoResponse") - .ok_or_else(|| ControlPointError::UpnpError(format!("Missing GetTransportInfoResponse element in SOAP body")))?; + .ok_or_else(|| { + ControlPointError::UpnpError(format!( + "Missing GetTransportInfoResponse element in SOAP body" + )) + })?; let current_transport_state = extract_child_text(response, "CurrentTransportState")?; let current_transport_status = extract_child_text(response, "CurrentTransportStatus")?; @@ -268,7 +273,6 @@ fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a E }) } - #[cfg(test)] mod tests { use super::*; @@ -382,10 +386,11 @@ impl AvTransportClient { ))); } - let envelope = call_result - .envelope - .as_ref() - .ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetPositionInfo response")))?; + let envelope = call_result.envelope.as_ref().ok_or_else(|| { + ControlPointError::UpnpError(format!( + "Missing SOAP envelope in GetPositionInfo response" + )) + })?; parse_position_info(envelope) } @@ -393,7 +398,9 @@ impl AvTransportClient { fn parse_position_info(envelope: &SoapEnvelope) -> Result { let response = find_child_with_suffix(&envelope.body.content, "GetPositionInfoResponse") - .ok_or_else(|| ControlPointError::UpnpError(format!("Missing GetPositionInfoResponse element")))?; + .ok_or_else(|| { + ControlPointError::UpnpError(format!("Missing GetPositionInfoResponse element")) + })?; // Helpers allow missing text (AVTransport allows empty durations) fn opt(parent: &Element, name: &str) -> Option { diff --git a/pmocontrol/src/upnp_clients/connection_manager_client.rs b/pmocontrol/src/upnp_clients/connection_manager_client.rs index a2ab2f21..26f60118 100644 --- a/pmocontrol/src/upnp_clients/connection_manager_client.rs +++ b/pmocontrol/src/upnp_clients/connection_manager_client.rs @@ -1,8 +1,10 @@ -use anyhow::{Result, anyhow}; - -use crate::{errors::ControlPointError, soap_client::{ - SoapCallResult, ensure_success, extract_child_text, extract_child_text_allow_empty, find_child_with_suffix, invoke_upnp_action, parse_upnp_error -}}; +use crate::{ + errors::ControlPointError, + soap_client::{ + ensure_success, extract_child_text, extract_child_text_allow_empty, find_child_with_suffix, + invoke_upnp_action, parse_upnp_error, + }, +}; #[derive(Debug, Clone)] pub struct ConnectionManagerClient { @@ -48,22 +50,25 @@ impl ConnectionManagerClient { ensure_success("GetProtocolInfo", &call_result)?; - let envelope = call_result - .envelope - .as_ref() - .ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetProtocolInfo response")))?; + let envelope = call_result.envelope.as_ref().ok_or_else(|| { + ControlPointError::UpnpError(format!( + "Missing SOAP envelope in GetProtocolInfo response" + )) + })?; if let Some(err) = parse_upnp_error(envelope) { return Err(ControlPointError::UpnpError(format!( "GetProtocolInfo returned UPnP error {}: {} (HTTP status {})", - err.error_code, - err.error_description, - call_result.status + err.error_code, err.error_description, call_result.status ))); } let response = find_child_with_suffix(&envelope.body.content, "GetProtocolInfoResponse") - .ok_or_else(|| ControlPointError::UpnpError(format!("Missing GetProtocolInfoResponse element in SOAP body")))?; + .ok_or_else(|| { + ControlPointError::UpnpError(format!( + "Missing GetProtocolInfoResponse element in SOAP body" + )) + })?; let source_text = extract_child_text_allow_empty(response, "Source")?; let sink_text = extract_child_text_allow_empty(response, "Sink")?; @@ -85,24 +90,25 @@ impl ConnectionManagerClient { ensure_success("GetCurrentConnectionIDs", &call_result)?; - let envelope = call_result - .envelope - .as_ref() - .ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetCurrentConnectionIDs response")))?; + let envelope = call_result.envelope.as_ref().ok_or_else(|| { + ControlPointError::UpnpError(format!( + "Missing SOAP envelope in GetCurrentConnectionIDs response" + )) + })?; if let Some(err) = parse_upnp_error(envelope) { return Err(ControlPointError::UpnpError(format!( "GetCurrentConnectionIDs returned UPnP error {}: {} (HTTP status {})", - err.error_code, - err.error_description, - call_result.status + err.error_code, err.error_description, call_result.status ))); } let response = find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionIDsResponse") .ok_or_else(|| { - ControlPointError::UpnpError(format!("Missing GetCurrentConnectionIDsResponse element in SOAP body")) + ControlPointError::UpnpError(format!( + "Missing GetCurrentConnectionIDsResponse element in SOAP body" + )) })?; let ids_text = extract_child_text_allow_empty(response, "ConnectionIDs")?; @@ -118,9 +124,9 @@ impl ConnectionManagerClient { if value.is_empty() { continue; } - let parsed = value - .parse::() - .map_err(|_| ControlPointError::UpnpError(format!("Invalid ConnectionID value: {}", value)))?; + let parsed = value.parse::().map_err(|_| { + ControlPointError::UpnpError(format!("Invalid ConnectionID value: {}", value)) + })?; ids.push(parsed); } @@ -128,7 +134,10 @@ impl ConnectionManagerClient { } /// GetCurrentConnectionInfo - pub fn get_current_connection_info(&self, connection_id: i32) -> Result { + pub fn get_current_connection_info( + &self, + connection_id: i32, + ) -> Result { let connection_id_str = connection_id.to_string(); let args = [("ConnectionID", connection_id_str.as_str())]; @@ -141,33 +150,38 @@ impl ConnectionManagerClient { ensure_success("GetCurrentConnectionInfo", &call_result)?; - let envelope = call_result - .envelope - .as_ref() - .ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetCurrentConnectionInfo response")))?; + let envelope = call_result.envelope.as_ref().ok_or_else(|| { + ControlPointError::UpnpError(format!( + "Missing SOAP envelope in GetCurrentConnectionInfo response" + )) + })?; if let Some(err) = parse_upnp_error(envelope) { return Err(ControlPointError::UpnpError(format!( "GetCurrentConnectionInfo returned UPnP error {}: {} (HTTP status {})", - err.error_code, - err.error_description, - call_result.status + err.error_code, err.error_description, call_result.status ))); } let response = find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionInfoResponse") .ok_or_else(|| { - ControlPointError::UpnpError(format!("Missing GetCurrentConnectionInfoResponse element in SOAP body")) + ControlPointError::UpnpError(format!( + "Missing GetCurrentConnectionInfoResponse element in SOAP body" + )) })?; let rcs_id = extract_child_text(response, "RcsID")? .parse::() - .map_err(|_| ControlPointError::UpnpError(format!("Invalid RcsID value in response")))?; + .map_err(|_| { + ControlPointError::UpnpError(format!("Invalid RcsID value in response")) + })?; let av_transport_id = extract_child_text(response, "AVTransportID")? .parse::() - .map_err(|_| ControlPointError::UpnpError(format!("Invalid AVTransportID value in response")))?; + .map_err(|_| { + ControlPointError::UpnpError(format!("Invalid AVTransportID value in response")) + })?; let protocol_info = extract_child_text_allow_empty(response, "ProtocolInfo")?; let peer_connection_manager = @@ -175,7 +189,9 @@ impl ConnectionManagerClient { let peer_connection_id = extract_child_text(response, "PeerConnectionID")? .parse::() - .map_err(|_| ControlPointError::UpnpError(format!("Invalid PeerConnectionID value in response")))?; + .map_err(|_| { + ControlPointError::UpnpError(format!("Invalid PeerConnectionID value in response")) + })?; let direction = extract_child_text(response, "Direction")?; let status = extract_child_text(response, "Status")?; diff --git a/pmocontrol/src/upnp_clients/rendering_control_client.rs b/pmocontrol/src/upnp_clients/rendering_control_client.rs index 48225bba..3e7108d1 100644 --- a/pmocontrol/src/upnp_clients/rendering_control_client.rs +++ b/pmocontrol/src/upnp_clients/rendering_control_client.rs @@ -1,11 +1,10 @@ use crate::{ errors::ControlPointError, soap_client::{ - SoapCallResult, ensure_success, extract_child_text, find_child_with_suffix, - handle_action_response, invoke_upnp_action, parse_upnp_error, + ensure_success, extract_child_text, find_child_with_suffix, handle_action_response, + invoke_upnp_action, parse_upnp_error, }, }; -use anyhow::{Result, anyhow}; use tracing::debug; #[derive(Debug, Clone)] @@ -96,22 +95,23 @@ impl RenderingControlClient { ensure_success("GetMute", &call_result)?; - let envelope = call_result - .envelope - .as_ref() - .ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetMute response")))?; + let envelope = call_result.envelope.as_ref().ok_or_else(|| { + ControlPointError::UpnpError(format!("Missing SOAP envelope in GetMute response")) + })?; if let Some(err) = parse_upnp_error(envelope) { return Err(ControlPointError::UpnpError(format!( "GetMute returned UPnP error {}: {} (HTTP status {})", - err.error_code, - err.error_description, - call_result.status + err.error_code, err.error_description, call_result.status ))); } let response = find_child_with_suffix(&envelope.body.content, "GetMuteResponse") - .ok_or_else(|| ControlPointError::UpnpError(format!("Missing GetMuteResponse element in SOAP body")))?; + .ok_or_else(|| { + ControlPointError::UpnpError(format!( + "Missing GetMuteResponse element in SOAP body" + )) + })?; let text = extract_child_text(response, "CurrentMute")?; let mute = match text.as_str() { diff --git a/pmodidl/src/lib.rs b/pmodidl/src/lib.rs index 1f1afda2..70fc3882 100644 --- a/pmodidl/src/lib.rs +++ b/pmodidl/src/lib.rs @@ -5,11 +5,11 @@ use bevy_reflect::Reflect; use pmoutils::ToXmlElement; use serde::{Deserialize, Serialize}; -use std::time::SystemTime; -use std::{borrow::Cow, time::Instant}; +use std::borrow::Cow; use std::collections::HashSet; use std::fmt::Write; use std::io::Cursor; +use std::time::SystemTime; use xmltree::{Element, XMLNode}; // ============= Couche d'abstraction générique ============= diff --git a/tools/check_a_file.sh b/tools/check_a_file.sh new file mode 100755 index 00000000..5726c3ec --- /dev/null +++ b/tools/check_a_file.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +crate=$1 +pattern=$2 + +cargo check -p "${crate}" 2>&1 \ +| awk -v pattern="${pattern}" ' + /^warning/ || /^error/ { + if (inmsg && file ~ pattern) { + print message"\n===============\n" + } + inmsg = 1 + start = 1 + message = $0 + next + } + start { + file = $NF + start = 0 + message = message"\n"$0 + next + } + inmsg { + message = message"\n"$0 + } + END { + if (inmsg && file ~ pattern) { + print message"\n===============\n" + } + }'