From 64450185c55828d0935260da050a12e8a5ce7469 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 28 Dec 2025 17:45:06 +0100 Subject: [PATCH] =?UTF-8?q?=C3=A9norme=20refactoring=20de=20PMOcontrol=20s?= =?UTF-8?q?tep=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