From 9c90e706f1b4c950e781f5b5b7b75fa846403145 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Tue, 30 Dec 2025 16:50:26 +0100 Subject: [PATCH] Enorme refactoring de PMO control step 2 --- .vscode/settings.json | 2 +- pmocontrol/Cargo.toml | 8 +- pmocontrol/src/arylic_client/mod.rs | 288 ++++++ pmocontrol/src/arylic_tcp.rs | 671 ------------ pmocontrol/src/control_point.rs | 900 ++++------------ pmocontrol/src/discovery/arylic.rs | 66 ++ .../src/discovery/chromecast_discovery.rs | 281 ++--- pmocontrol/src/discovery/manager.rs | 33 +- pmocontrol/src/discovery/mod.rs | 24 + pmocontrol/src/discovery/upnp_discovery.rs | 472 +-------- pmocontrol/src/discovery/upnp_provider.rs | 425 ++++---- pmocontrol/src/errors.rs | 2 + pmocontrol/src/lib.rs | 34 +- pmocontrol/src/linkplay_client/mod.rs | 156 +++ pmocontrol/src/linkplay_renderer.rs | 466 --------- pmocontrol/src/linkplay_utils.rs | 0 pmocontrol/src/media_server.rs | 42 +- pmocontrol/src/media_server_events.rs | 16 +- pmocontrol/src/model.rs | 97 +- pmocontrol/src/music_renderer.rs | 961 ------------------ pmocontrol/src/music_renderer/arylic_tcp.rs | 321 ++++++ .../src/{ => music_renderer}/capabilities.rs | 47 +- .../chromecast_renderer.rs | 57 +- .../src/music_renderer/linkplay_renderer.rs | 134 +++ pmocontrol/src/music_renderer/mod.rs | 53 + .../src/music_renderer/musicrenderer.rs | 737 ++++++++++++++ .../src/{ => music_renderer}/openhome.rs | 29 +- .../{ => music_renderer}/openhome_renderer.rs | 120 +-- pmocontrol/src/music_renderer/time_utils.rs | 218 ++++ .../src/music_renderer/upnp_renderer.rs | 221 ++++ pmocontrol/src/online.rs | 20 +- pmocontrol/src/openhome_playlist.rs | 31 - pmocontrol/src/queue/backend.rs | 233 +---- pmocontrol/src/queue/interne.rs | 250 ++++- pmocontrol/src/queue/mod.rs | 44 +- pmocontrol/src/queue/music_queue.rs | 163 ++- pmocontrol/src/queue/openhome.rs | 899 ++++++++-------- pmocontrol/src/queue/snapshot.rs | 91 ++ pmocontrol/src/registry.rs | 287 ++++-- .../{ => upnp_clients}/avtransport_client.rs | 0 .../connection_manager_client.rs | 0 pmocontrol/src/upnp_clients/mod.rs | 41 + .../src/{ => upnp_clients}/openhome_client.rs | 236 +++-- .../rendering_control_client.rs | 0 pmocontrol/src/upnp_renderer.rs | 317 ------ 45 files changed, 4404 insertions(+), 5089 deletions(-) create mode 100644 pmocontrol/src/arylic_client/mod.rs delete mode 100644 pmocontrol/src/arylic_tcp.rs create mode 100644 pmocontrol/src/discovery/arylic.rs create mode 100644 pmocontrol/src/linkplay_client/mod.rs delete mode 100644 pmocontrol/src/linkplay_renderer.rs create mode 100644 pmocontrol/src/linkplay_utils.rs delete mode 100644 pmocontrol/src/music_renderer.rs create mode 100644 pmocontrol/src/music_renderer/arylic_tcp.rs rename pmocontrol/src/{ => music_renderer}/capabilities.rs (62%) rename pmocontrol/src/{ => music_renderer}/chromecast_renderer.rs (93%) create mode 100644 pmocontrol/src/music_renderer/linkplay_renderer.rs create mode 100644 pmocontrol/src/music_renderer/mod.rs create mode 100644 pmocontrol/src/music_renderer/musicrenderer.rs rename pmocontrol/src/{ => music_renderer}/openhome.rs (89%) rename pmocontrol/src/{ => music_renderer}/openhome_renderer.rs (82%) create mode 100644 pmocontrol/src/music_renderer/time_utils.rs create mode 100644 pmocontrol/src/music_renderer/upnp_renderer.rs delete mode 100644 pmocontrol/src/openhome_playlist.rs create mode 100644 pmocontrol/src/queue/snapshot.rs rename pmocontrol/src/{ => upnp_clients}/avtransport_client.rs (100%) rename pmocontrol/src/{ => upnp_clients}/connection_manager_client.rs (100%) create mode 100644 pmocontrol/src/upnp_clients/mod.rs rename pmocontrol/src/{ => upnp_clients}/openhome_client.rs (83%) rename pmocontrol/src/{ => upnp_clients}/rendering_control_client.rs (100%) delete mode 100644 pmocontrol/src/upnp_renderer.rs diff --git a/.vscode/settings.json b/.vscode/settings.json index 2c0ba4ce..71eac550 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,7 +20,7 @@ "rust-analyzer.numThreads": 4, "rust-analyzer.cargo.loadOutDirsFromCheck": false, "rust-analyzer.checkOnSave.enable": true, - "rust-analyzer.checkOnSave.command": "clippy", + "rust-analyzer.checkOnSave.command": "check", "rust-analyzer.checkOnSave.extraArgs": ["--all-features"], "rust-analyzer.exclude": [ "target", diff --git a/pmocontrol/Cargo.toml b/pmocontrol/Cargo.toml index b828d5f3..a5df9755 100644 --- a/pmocontrol/Cargo.toml +++ b/pmocontrol/Cargo.toml @@ -23,13 +23,13 @@ mdns = "3.0" async-std = "1.12" futures-util = "0.3" smol = "2.0" +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } # pmoserver extension support (optional) pmoserver = { path = "../pmoserver", optional = true } utoipa = { version = "5.4.0", optional = true } axum = { version = "0.8.4", optional = true } -serde = { workspace = true, optional = true } -serde_json = { workspace = true, optional = true } tokio = { workspace = true, features = ["sync", "rt"], optional = true } tokio-util = { version = "0.7", optional = true } async-trait = { version = "0.1", optional = true } @@ -38,11 +38,9 @@ async-stream = { version = "0.3", optional = true } chrono = { version = "0.4", features = ["serde"], optional = true } [dev-dependencies] -serde = { workspace = true } -serde_json = { workspace = true } percent-encoding = "2.3" [features] default = [] # Active l'API REST pmoserver -pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:serde", "dep:serde_json", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:chrono"] +pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:chrono"] diff --git a/pmocontrol/src/arylic_client/mod.rs b/pmocontrol/src/arylic_client/mod.rs new file mode 100644 index 00000000..b93d4c0a --- /dev/null +++ b/pmocontrol/src/arylic_client/mod.rs @@ -0,0 +1,288 @@ +use std::{io::{Read, Write}, net::{Shutdown, TcpStream, ToSocketAddrs}, sync::{Mutex, OnceLock}, thread, time::{Duration, Instant}}; + +use tracing::{debug, warn}; + +use crate::errors::ControlPointError; + +pub const ARYLIC_TCP_PORT: u16 = 8899; +pub const DEFAULT_TIMEOUT_SECS: u64 = 3; + +const PACKET_HEADER: [u8; 4] = [0x18, 0x96, 0x18, 0x20]; +const RESERVED_BYTES: [u8; 8] = [0; 8]; +const MAX_RESPONSE_ATTEMPTS: usize = 8; + +// Garde global pour respecter le délai de 200ms entre commandes +static LAST_COMMAND_TIME: OnceLock> = OnceLock::new(); + +fn last_command_time() -> &'static Mutex { + LAST_COMMAND_TIME.get_or_init(|| Mutex::new(Instant::now())) +} + + +/// Mode d’attente de réponse pour une commande TCP Arylic. +enum ResponseMode<'a> { + /// On n’attend aucune réponse (fire-and-forget). + None, + /// On attend une réponse, mais si la lecture échoue immédiatement, on traite comme succès. + Optional(&'a [&'a str]), + /// On attend une réponse, et l’absence de réponse est une erreur. + Required(&'a [&'a str]), +} + +fn send_command_with_mode( + host: &str, + port: u16, + timeout: Duration, + payload: &str, + mode: ResponseMode<'_>, +) -> Result, ControlPointError> { + let mut stream = connect(host, port, timeout)?; + let packet = encode_packet(payload); + + stream.write_all(&packet).map_err(|_| { + ControlPointError::ArilycTcpError( + format!( + "Failed to write Arylic TCP packet for {}: {}", + host, payload + )) + })?; + + stream.flush().map_err(|_| { + ControlPointError::ArilycTcpError( + format!( + "Failed to flush Arylic TCP stream for {} (command {})", + host, payload + )) + })?; + + match mode { + ResponseMode::None => { + debug!( + "Arylic TCP fire-and-forget command sent to {}: {}", + host, payload + ); + let _ = stream.shutdown(Shutdown::Write); + Ok(None) + } + ResponseMode::Required(expected) => { + read_expected_response(&mut stream, host, payload, expected).map(Some) + } + ResponseMode::Optional(expected) => { + for _ in 0..MAX_RESPONSE_ATTEMPTS { + match read_packet(&mut stream) { + Ok(response) => { + if expected.iter().any(|p| response.starts_with(p)) { + return Ok(Some(response)); + } + debug!( + "Ignoring unsolicited Arylic payload from {}: {}", + host, response + ); + } + Err(err) => { + debug!( + "No full response for Arylic TCP command {} on {}: {}. Treating as success and relying on PINFGET.", + payload, host, err + ); + return Ok(None); + } + } + } + + Err(ControlPointError::ArilycTcpError(format!( + "No expected response for optional command {} on {}", + payload, + host + ))) + } + } +} + +fn read_expected_response( + stream: &mut TcpStream, + host: &str, + payload: &str, + expected: &[&str], +) -> Result { + for _ in 0..MAX_RESPONSE_ATTEMPTS { + let response = match read_packet(stream) { + Ok(resp) => resp, + Err(err) => { + return Err(ControlPointError::ArilycTcpError(format!( + "Failed to read Arylic TCP response for {} (command {}): {}", + host, + payload, + err + ))); + } + }; + if expected.iter().any(|prefix| response.starts_with(prefix)) { + return Ok(response); + } + + debug!( + "Ignoring unsolicited Arylic payload from {}: {}", + host, response + ); + } + + Err(ControlPointError::ArilycTcpError(format!( + "No expected response for command {} on {}", + payload, + host + ))) +} + +pub fn send_command_required( + host: &str, + port: u16, + timeout: Duration, + payload: &str, + expected: &[&str], +) -> Result { + match send_command_with_mode( + host, + port, + timeout, + payload, + ResponseMode::Required(expected), + )? { + Some(s) => Ok(s), + None => Err(ControlPointError::ArilycTcpError(format!( + "Arylic TCP: no response payload for required command {}", + payload + ))), + } +} + +pub fn send_command_optional( + host: &str, + port: u16, + timeout: Duration, + payload: &str, + expected: &[&str], +) -> Result, ControlPointError> { + send_command_with_mode( + host, + port, + timeout, + payload, + ResponseMode::Optional(expected), + ) +} + +pub fn send_command_no_response(host: &str, port: u16, timeout: Duration, payload: &str) -> Result<(), ControlPointError> { + send_command_with_mode(host, port, timeout, payload, ResponseMode::None).map(|_| ()) +} + + +fn connect(host: &str, port: u16, timeout: Duration) -> Result { + if let Ok(mut last_time) = last_command_time().lock() { + let elapsed = last_time.elapsed(); + if elapsed < Duration::from_millis(200) { + let wait = Duration::from_millis(200) - elapsed; + debug!( + "Waiting {:?} before sending command to respect 200ms interval", + wait + ); + thread::sleep(wait); + } + *last_time = Instant::now(); + } + + let address = if host.contains(':') { + format!("[{}]:{}", host, port) + } else { + format!("{host}:{port}") + }; + + let mut last_err = None; + for addr in address + .to_socket_addrs() + .map_err(|_| { + ControlPointError::ArilycTcpError(format!("Failed to resolve {}:{}", host, port)) + })? + { + match TcpStream::connect_timeout(&addr, timeout) { + Ok(stream) => { + stream + .set_read_timeout(Some(timeout)) + .and_then(|_| stream.set_write_timeout(Some(timeout))) + .map_err(|err| { + ControlPointError::ArilycTcpError(format!( + "Failed to set socket timeouts for {}", + address + )) + })?; + + return Ok(stream); + } + Err(err) => { + last_err = Some((addr, err)); + } + } + } + + match last_err { + Some((addr, err)) => Err(ControlPointError::ArilycTcpError(format!( + "Failed to connect to {} via {}: {}", + host, addr, err + ))), + None => Err(ControlPointError::ArilycTcpError(format!( + "No socket addresses resolved for {}", + address + ))), + } +} + +fn encode_packet(payload: &str) -> Vec { + let bytes = payload.as_bytes(); + let len = bytes.len() as u32; + let checksum = bytes.iter().fold(0u32, |acc, b| acc + (*b as u32)); + + let mut out = Vec::with_capacity(4 + 4 + 4 + 8 + bytes.len()); + out.extend_from_slice(&PACKET_HEADER); + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(&checksum.to_le_bytes()); + out.extend_from_slice(&RESERVED_BYTES); + out.extend_from_slice(bytes); + out +} + +fn read_packet(stream: &mut TcpStream) -> Result { + let mut header = [0u8; 4]; + stream.read_exact(&mut header) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; + if header != PACKET_HEADER { + return Err(ControlPointError::ArilycTcpError(format!("Invalid Arylic packet header: {:x?}", header))); + } + + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; + let len = u32::from_le_bytes(len_buf) as usize; + + let mut checksum_buf = [0u8; 4]; + stream.read_exact(&mut checksum_buf) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; + let expected_checksum = u32::from_le_bytes(checksum_buf); + + let mut reserved = [0u8; 8]; + stream.read_exact(&mut reserved) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; + + let mut payload = vec![0u8; len]; + stream.read_exact(&mut payload) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; + + let actual_checksum = payload.iter().fold(0u32, |acc, b| acc + (*b as u32)); + if actual_checksum != expected_checksum { + warn!( + "Arylic payload checksum mismatch: expected={} actual={}", + expected_checksum, actual_checksum + ); + } + + Ok(String::from_utf8(payload) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?) +} diff --git a/pmocontrol/src/arylic_tcp.rs b/pmocontrol/src/arylic_tcp.rs deleted file mode 100644 index 7aa18aef..00000000 --- a/pmocontrol/src/arylic_tcp.rs +++ /dev/null @@ -1,671 +0,0 @@ -use std::collections::HashMap; -use std::io::{Read, Write}; -use std::net::{Shutdown, TcpStream, ToSocketAddrs}; -use std::sync::{Mutex, OnceLock}; -use std::thread; -use std::time::Duration; - -use anyhow::{Context, Result}; -use tracing::{debug, warn}; - -use crate::DeviceIdentity; -use crate::capabilities::{ - PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, - VolumeControl, -}; -use crate::errors::ControlPointError; -use crate::linkplay_renderer::{extract_linkplay_host, parse_flat_json}; -use crate::model::RendererInfo; -use std::time::Instant; - -// Garde global pour respecter le délai de 200ms entre commandes -static LAST_COMMAND_TIME: OnceLock> = OnceLock::new(); - -fn last_command_time() -> &'static Mutex { - LAST_COMMAND_TIME.get_or_init(|| Mutex::new(Instant::now())) -} - -const ARYLIC_TCP_PORT: u16 = 8899; -const PACKET_HEADER: [u8; 4] = [0x18, 0x96, 0x18, 0x20]; -const RESERVED_BYTES: [u8; 8] = [0; 8]; -const MAX_RESPONSE_ATTEMPTS: usize = 8; -const DEFAULT_TIMEOUT_SECS: u64 = 3; - -static DETECTION_CACHE: OnceLock>> = OnceLock::new(); - -/// Mode d’attente de réponse pour une commande TCP Arylic. -enum ResponseMode<'a> { - /// On n’attend aucune réponse (fire-and-forget). - None, - /// On attend une réponse, mais si la lecture échoue immédiatement, on traite comme succès. - Optional(&'a [&'a str]), - /// On attend une réponse, et l’absence de réponse est une erreur. - Required(&'a [&'a str]), -} - -fn detection_cache() -> &'static Mutex> { - DETECTION_CACHE.get_or_init(|| Mutex::new(HashMap::new())) -} - -/// Probe whether the renderer at the given location exposes the Arylic TCP API. -pub(crate) fn detect_arylic_tcp(location: &str, timeout: Duration) -> bool { - let Some(host) = extract_linkplay_host(location) else { - return false; - }; - - if let Ok(cache) = detection_cache().lock() { - if let Some(result) = cache.get(&host) { - return *result; - } - } - - let detected = match try_detect_tcp(&host, timeout) { - Ok(_) => true, - Err(err) => { - debug!( - "Arylic TCP detection failed for {} (host={}): {}", - location, host, err - ); - false - } - }; - - if let Ok(mut cache) = detection_cache().lock() { - cache.insert(host, detected); - } - - detected -} - -fn try_detect_tcp(host: &str, timeout: Duration) -> Result<(), ControlPointError> { - let payload = send_command_required( - host, - ARYLIC_TCP_PORT, - timeout, - "MCU+INF+GET", - &["AXX+INF+", "AXX+DEV+"], - )?; - - if payload.starts_with("AXX+INF+") || payload.starts_with("AXX+DEV+") { - Ok(()) - } else { - Err(ControlPointError::ArilycTcpError(format!( - "Unexpected INF response from {}: {}", - host, - payload - ))) - } -} - -/// Backend speaking the Arylic TCP control protocol (port 8899). -#[derive(Clone, Debug)] -pub struct ArylicTcpRenderer { - host: String, - port: u16, - timeout: Duration, -} - -impl ArylicTcpRenderer { - pub fn from_renderer_info(info: RendererInfo) -> Result { - let host = extract_linkplay_host(info.location()) - .ok_or_else(|| ControlPointError::ArilycTcpError(format!("Renderer {} has no valid LOCATION host", info.udn())))?; - - Ok(Self { - host, - port: ARYLIC_TCP_PORT, - timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS), - }) - } - - fn send_required(&self, cmd: &str, expected: &[&str]) -> Result { - send_command_required(&self.host, self.port, self.timeout, cmd, expected) - } - - fn send_optional(&self, cmd: &str, expected: &[&str]) -> Result, ControlPointError> { - send_command_optional(&self.host, self.port, self.timeout, cmd, expected) - } - - fn send_no_response(&self, cmd: &str) -> Result<(), ControlPointError> { - send_command_no_response(&self.host, self.port, self.timeout, cmd) - } - - fn fetch_playback_info(&self) -> Result { - let payload = self.send_required("MCU+PINFGET", &["AXX+PLY+INF"])?; - match parse_playback_info(&payload) { - Ok(info) => Ok(info), - Err(err) => { - debug!( - "Failed to parse Arylic playback info for {}: {}", - self.host, err - ); - Err(err) - } - } - } - - fn format_volume_command(value: u16) -> String { - format!("MCU+VOL+{:03}", value.min(100)) - } - - fn parse_volume_payload(payload: &str) -> Result { - let data = payload - .strip_prefix("AXX+VOL+") - .ok_or_else(|| ControlPointError::ArilycTcpError(format!("Unexpected volume response: {}", payload)))?; - let value: u16 = data - .trim() - .parse() - .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid volume value: {}", data)))?; - Ok(value.min(100)) - } - - fn parse_mute_payload(payload: &str) -> Result { - let data = payload - .strip_prefix("AXX+MUT+") - .ok_or_else(|| ControlPointError::ArilycTcpError(format!("Unexpected mute response: {}", payload)))?; - match data.trim() { - "000" | "0" => Ok(false), - "001" | "1" => Ok(true), - other => Err(ControlPointError::ArilycTcpError(format!("Invalid mute value: {}", other))), - } - } -} - -impl TransportControl for ArylicTcpRenderer { - fn play_uri(&self, _uri: &str, _meta: &str) -> Result<(), ControlPointError> { - Err(ControlPointError::upnp_operation_not_supported( - "Arylic TCP backend does not support direct URL loading.", - "ArylicTcpRenderer", - )) - } - - fn play(&self) -> Result<(), ControlPointError> { - self.send_no_response("MCU+PLY-PLA") - } - - fn pause(&self) -> Result<(), ControlPointError> { - let _ = self.send_optional("MCU+PLY-PUS", &["AXX+PLY+"])?; - Ok(()) - } - - fn stop(&self) -> Result<(), ControlPointError> { - self.send_no_response("MCU+PLY-STP") - } - - fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { - let _ = parse_hhmmss(hhmmss)?; - Err(ControlPointError::ArilycTcpError(format!( - "Arylic TCP seek_rel_time is not implemented yet for this device." - ))) - } -} - -impl VolumeControl for ArylicTcpRenderer { - fn volume(&self) -> Result { - if let Ok(info) = self.fetch_playback_info() { - if let Some(vol) = info.volume { - return Ok(vol); - } - debug!( - "Arylic playback info for {} missing volume, falling back to VOL GET", - self.host - ); - } - let payload = self.send_required("MCU+VOL+GET", &["AXX+VOL+"])?; - Self::parse_volume_payload(&payload) - } - - fn set_volume(&self, v: u16) -> Result<(), ControlPointError> { - let command = Self::format_volume_command(v); - let _ = self.send_optional(&command, &["AXX+VOL+"])?; - Ok(()) - } - - fn mute(&self) -> Result { - if let Ok(info) = self.fetch_playback_info() { - if let Some(mute) = info.mute { - return Ok(mute); - } - debug!( - "Arylic playback info for {} missing mute, falling back to MUT GET", - self.host - ); - } - let payload = self.send_required("MCU+MUT+GET", &["AXX+MUT+"])?; - Self::parse_mute_payload(&payload) - } - - fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { - let command = if m { "MCU+MUT+001" } else { "MCU+MUT+000" }; - let payload = self.send_required(command, &["AXX+MUT+"])?; - let _ = Self::parse_mute_payload(&payload)?; - Ok(()) - } -} - -impl PlaybackStatus for ArylicTcpRenderer { - fn playback_state(&self) -> Result { - let info = self.fetch_playback_info()?; - Ok(info.playback_state()) - } -} - -impl PlaybackPosition for ArylicTcpRenderer { - fn playback_position(&self) -> Result { - let info = self.fetch_playback_info()?; - Ok(info.position_info()) - } -} - -#[derive(Debug)] -struct ArylicPlaybackInfo { - status_raw: String, - curpos_ms: u64, - totlen_ms: u64, - volume: Option, - mute: Option, - playlist_size: Option, - track_index: Option, -} - -impl ArylicPlaybackInfo { - fn playback_state(&self) -> PlaybackState { - match self.status_raw.as_str() { - "play" => PlaybackState::Playing, - "pause" => PlaybackState::Paused, - "stop" => PlaybackState::Stopped, - other => PlaybackState::Unknown(other.to_string()), - } - } - - fn position_info(&self) -> PlaybackPositionInfo { - let track = match (self.track_index, self.playlist_size) { - (Some(idx), Some(count)) if count > 0 => Some(idx.min(count)), - (Some(idx), _) => Some(idx), - _ => None, - }; - - PlaybackPositionInfo { - track, - rel_time: Some(format_hms(self.curpos_ms / 1000)), - abs_time: None, - track_duration: if self.totlen_ms > 0 { - Some(format_hms(self.totlen_ms / 1000)) - } else { - None - }, - track_metadata: None, - track_uri: None, - } - } -} - -fn parse_playback_info(payload: &str) -> Result { - let json_blob = payload - .strip_prefix("AXX+PLY+INF") - .ok_or_else(|| ControlPointError::ArilycTcpError(format!("Unexpected playback info prefix: {}", payload)))?; - - let json_blob = json_blob.trim_end_matches('&').trim(); - let map = parse_flat_json(json_blob)?; - - let status_raw = map - .get("status") - .cloned() - .ok_or_else(|| ControlPointError::ArilycTcpError(format!("Playback info missing `status` field")))?; - - let curpos_ms = parse_u64_field(&map, "curpos")?; - let totlen_ms = parse_u64_field(&map, "totlen")?; - - let volume = match map.get("vol") { - Some(raw) => match raw.parse::() { - Ok(value) => Some(value.min(100)), - Err(err) => { - debug!("Invalid Arylic `vol` value {}: {}", raw, err); - None - } - }, - None => None, - }; - - let mute = match map.get("mute") { - Some(value) if value == "1" => Some(true), - Some(value) if value == "0" => Some(false), - Some(other) => { - debug!("Invalid Arylic `mute` value {}", other); - None - } - None => None, - }; - - let playlist_size = map - .get("plicount") - .and_then(|raw| match raw.parse::() { - Ok(count) if count > 0 => Some(count), - Ok(_) => None, - Err(err) => { - debug!("Invalid Arylic `plicount` value {}: {}", raw, err); - None - } - }); - - let track_index = map.get("plicurr").and_then(|raw| match raw.parse::() { - Ok(idx) if idx > 0 => Some(idx), - Ok(_) => None, - Err(err) => { - debug!("Invalid Arylic `plicurr` value {}: {}", raw, err); - None - } - }); - - Ok(ArylicPlaybackInfo { - status_raw, - curpos_ms, - totlen_ms, - volume, - mute, - playlist_size, - track_index, - }) -} - -fn parse_u64_field(map: &HashMap, key: &str) -> Result { - let raw = map - .get(key) - .ok_or_else(|| ControlPointError::ArilycTcpError(format!("Playback info missing `{}` field", key)))?; - raw.parse::() - .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid `{}` value: {}", key, raw))) -} - -fn connect(host: &str, port: u16, timeout: Duration) -> Result { - if let Ok(mut last_time) = last_command_time().lock() { - let elapsed = last_time.elapsed(); - if elapsed < Duration::from_millis(200) { - let wait = Duration::from_millis(200) - elapsed; - debug!( - "Waiting {:?} before sending command to respect 200ms interval", - wait - ); - thread::sleep(wait); - } - *last_time = Instant::now(); - } - - let address = if host.contains(':') { - format!("[{}]:{}", host, port) - } else { - format!("{host}:{port}") - }; - - let mut last_err = None; - for addr in address - .to_socket_addrs() - .with_context(|| format!("Failed to resolve {}:{}", host, port)) - .map_err(|_| { - ControlPointError::ArilycTcpError(format!("Failed to resolve {}:{}", host, port)) - })? - { - match TcpStream::connect_timeout(&addr, timeout) { - Ok(stream) => { - stream - .set_read_timeout(Some(timeout)) - .and_then(|_| stream.set_write_timeout(Some(timeout))) - .with_context(|| format!("Failed to set socket timeouts for {}", address)) - .map_err(|err| { - ControlPointError::ArilycTcpError(format!( - "Failed to set socket timeouts for {}", - address - )) - })?; - - return Ok(stream); - } - Err(err) => { - last_err = Some((addr, err)); - } - } - } - - match last_err { - Some((addr, err)) => Err(ControlPointError::ArilycTcpError(format!( - "Failed to connect to {} via {}: {}", - host, addr, err - ))), - None => Err(ControlPointError::ArilycTcpError(format!( - "No socket addresses resolved for {}", - address - ))), - } -} - -fn encode_packet(payload: &str) -> Vec { - let bytes = payload.as_bytes(); - let len = bytes.len() as u32; - let checksum = bytes.iter().fold(0u32, |acc, b| acc + (*b as u32)); - - let mut out = Vec::with_capacity(4 + 4 + 4 + 8 + bytes.len()); - out.extend_from_slice(&PACKET_HEADER); - out.extend_from_slice(&len.to_le_bytes()); - out.extend_from_slice(&checksum.to_le_bytes()); - out.extend_from_slice(&RESERVED_BYTES); - out.extend_from_slice(bytes); - out -} - -fn read_packet(stream: &mut TcpStream) -> Result { - let mut header = [0u8; 4]; - stream.read_exact(&mut header) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; - if header != PACKET_HEADER { - return Err(ControlPointError::ArilycTcpError(format!("Invalid Arylic packet header: {:x?}", header))); - } - - let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; - let len = u32::from_le_bytes(len_buf) as usize; - - let mut checksum_buf = [0u8; 4]; - stream.read_exact(&mut checksum_buf) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; - let expected_checksum = u32::from_le_bytes(checksum_buf); - - let mut reserved = [0u8; 8]; - stream.read_exact(&mut reserved) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; - - let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; - - let actual_checksum = payload.iter().fold(0u32, |acc, b| acc + (*b as u32)); - if actual_checksum != expected_checksum { - warn!( - "Arylic payload checksum mismatch: expected={} actual={}", - expected_checksum, actual_checksum - ); - } - - Ok(String::from_utf8(payload) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?) -} - -fn format_hms(secs: u64) -> String { - let h = secs / 3600; - let m = (secs % 3600) / 60; - let s = secs % 60; - format!("{:02}:{:02}:{:02}", h, m, s) -} - -fn parse_hhmmss(value: &str) -> Result { - let parts: Vec<_> = value.split(':').collect(); - if parts.len() != 3 { - return Err(ControlPointError::ArilycTcpError(format!( - "Invalid time format `{}`. Expected HH:MM:SS.", - value - ))); - } - - let hours: u64 = parts[0] - .parse() - .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid hour component in {}", value)))?; - let minutes: u64 = parts[1] - .parse() - .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid minute component in {}", value)))?; - let seconds: u64 = parts[2] - .parse() - .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid second component in {}", value)))?; - - if minutes > 59 || seconds > 59 { - return Err(ControlPointError::ArilycTcpError(format!( - "Invalid HH:MM:SS value `{}`. Minutes and seconds must be < 60.", - value - ))); - } - - Ok(hours * 3600 + minutes * 60 + seconds) -} - -fn send_command_with_mode( - host: &str, - port: u16, - timeout: Duration, - payload: &str, - mode: ResponseMode<'_>, -) -> Result, ControlPointError> { - let mut stream = connect(host, port, timeout)?; - let packet = encode_packet(payload); - - stream.write_all(&packet).map_err(|_| { - ControlPointError::ArilycTcpError( - format!( - "Failed to write Arylic TCP packet for {}: {}", - host, payload - )) - })?; - - stream.flush().map_err(|_| { - ControlPointError::ArilycTcpError( - format!( - "Failed to flush Arylic TCP stream for {} (command {})", - host, payload - )) - })?; - - match mode { - ResponseMode::None => { - debug!( - "Arylic TCP fire-and-forget command sent to {}: {}", - host, payload - ); - let _ = stream.shutdown(Shutdown::Write); - Ok(None) - } - ResponseMode::Required(expected) => { - read_expected_response(&mut stream, host, payload, expected).map(Some) - } - ResponseMode::Optional(expected) => { - for _ in 0..MAX_RESPONSE_ATTEMPTS { - match read_packet(&mut stream) { - Ok(response) => { - if expected.iter().any(|p| response.starts_with(p)) { - return Ok(Some(response)); - } - debug!( - "Ignoring unsolicited Arylic payload from {}: {}", - host, response - ); - } - Err(err) => { - debug!( - "No full response for Arylic TCP command {} on {}: {}. Treating as success and relying on PINFGET.", - payload, host, err - ); - return Ok(None); - } - } - } - - Err(ControlPointError::ArilycTcpError(format!( - "No expected response for optional command {} on {}", - payload, - host - ))) - } - } -} - -fn read_expected_response( - stream: &mut TcpStream, - host: &str, - payload: &str, - expected: &[&str], -) -> Result { - for _ in 0..MAX_RESPONSE_ATTEMPTS { - let response = match read_packet(stream) { - Ok(resp) => resp, - Err(err) => { - return Err(ControlPointError::ArilycTcpError(format!( - "Failed to read Arylic TCP response for {} (command {}): {}", - host, - payload, - err - ))); - } - }; - if expected.iter().any(|prefix| response.starts_with(prefix)) { - return Ok(response); - } - - debug!( - "Ignoring unsolicited Arylic payload from {}: {}", - host, response - ); - } - - Err(ControlPointError::ArilycTcpError(format!( - "No expected response for command {} on {}", - payload, - host - ))) -} - -fn send_command_required( - host: &str, - port: u16, - timeout: Duration, - payload: &str, - expected: &[&str], -) -> Result { - match send_command_with_mode( - host, - port, - timeout, - payload, - ResponseMode::Required(expected), - )? { - Some(s) => Ok(s), - None => Err(ControlPointError::ArilycTcpError(format!( - "Arylic TCP: no response payload for required command {}", - payload - ))), - } -} - -fn send_command_optional( - host: &str, - port: u16, - timeout: Duration, - payload: &str, - expected: &[&str], -) -> Result, ControlPointError> { - send_command_with_mode( - host, - port, - timeout, - payload, - ResponseMode::Optional(expected), - ) -} - -fn send_command_no_response(host: &str, port: u16, timeout: Duration, payload: &str) -> Result<(), ControlPointError> { - send_command_with_mode(host, port, timeout, payload, ResponseMode::None).map(|_| ()) -} diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index d81d2251..5a20d50c 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -18,8 +18,6 @@ use tracing::{debug, error, info, warn}; use ureq::{http, Agent}; use xmltree::{Element, XMLNode}; -pub mod music_queue; -pub mod openhome_queue; pub const OPENHOME_SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(2); @@ -30,9 +28,10 @@ use crate::capabilities::{ use crate::control_point::music_queue::MusicQueue; use crate::control_point::openhome_queue::OpenHomeQueue; use crate::discovery::DiscoveryManager; +use crate::discovery::manager::UDNRegistry; use crate::events::{MediaServerEventBus, RendererEventBus}; use crate::media_server::{ - playback_item_from_entry, MediaBrowser, UpnpMediaServer, UpnpMediaServer, ServerId, + playback_item_from_entry, MediaBrowser, UpnpMediaServer, ServerId, }; use crate::media_server_events::spawn_media_server_event_runtime; use crate::model::TrackMetadata; @@ -48,7 +47,8 @@ use crate::openhome::{ }; use crate::openhome_client::parse_track_metadata_from_didl; use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; -use crate::openhome_renderer::{format_seconds, map_openhome_state}; +use crate::music_renderer::openhome_renderer::map_openhome_state; +use crate::music_renderer::time_utils::format_hhmmss_u32; use crate::provider::HttpXmlDescriptionProvider; use crate::queue::backend::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot}; use crate::queue_interne::InternalQueue; @@ -101,18 +101,6 @@ pub struct ControlPoint { event_bus: RendererEventBus, media_event_bus: MediaServerEventBus, runtime: Arc, - /// Optional attachment between a renderer playback queue and a - /// server-side DIDL-Lite playlist container. - /// - /// Key : RendererId - /// Value : PlaylistBinding - playlist_bindings: Arc>>, - /// Cache of MusicRenderer instances to avoid recreating them. - /// This is critical for Chromecast which maintains a persistent TLS connection. - /// - /// Key : RendererId - /// Value : MusicRenderer - renderer_cache: Arc>>, } impl ControlPoint { @@ -124,11 +112,6 @@ impl ControlPoint { let event_bus = RendererEventBus::new(); let media_event_bus = MediaServerEventBus::new(); let runtime = Arc::new(RuntimeState::new()); - // set_openhome_queue_provider(Arc::new(RuntimeOpenHomeQueueProvider { - // runtime: Arc::clone(&runtime), - // })); - let playlist_bindings = Arc::new(Mutex::new(HashMap::new())); - let renderer_cache = Arc::new(Mutex::new(HashMap::new())); // SsdpClient let client = SsdpClient::new()?; // pmoupnp::ssdp::SsdpClient @@ -403,8 +386,6 @@ impl ControlPoint { event_bus: event_bus.clone(), media_event_bus: media_event_bus.clone(), runtime: Arc::clone(&runtime), - playlist_bindings: Arc::clone(&playlist_bindings), - renderer_cache: Arc::clone(&renderer_cache), }; thread::spawn(move || { @@ -416,37 +397,12 @@ impl ControlPoint { reg.list_renderers() }; - // Build a map of current renderer IDs for cleanup - let current_ids: HashSet = infos.iter().map(|i| i.id.clone()).collect(); - - // Remove offline renderers from shared cache - { - let mut cache = runtime_cp.renderer_cache.lock().unwrap(); - cache.retain(|id, _| current_ids.contains(id)); - } - - // Get or create renderers from shared cache + // Create MusicRendererBackend for each renderer from registry + // No caching needed - backends manage their own connections let renderers: Vec = infos .into_iter() .filter_map(|info| { - let id = info.id.clone(); - - // Try to get from cache first - { - let cache = runtime_cp.renderer_cache.lock().unwrap(); - if let Some(renderer) = cache.get(&id) { - return Some(renderer.clone()); - } - } - - // Create new renderer and add to cache - if let Some(renderer) = MusicRendererBackend::from_renderer_info(info, &runtime_cp.registry) { - let mut cache = runtime_cp.renderer_cache.lock().unwrap(); - cache.insert(id, renderer.clone()); - Some(renderer) - } else { - None - } + MusicRendererBackend::from_renderer_info(info, &runtime_cp.registry) }) .collect(); @@ -457,54 +413,13 @@ impl ControlPoint { continue; } - let backend = if info.capabilities.has_oh_playlist { - PlaylistBackend::OpenHome - } else { - PlaylistBackend::PMOQueue - }; - let previous_backend = runtime_cp.runtime.playlist_backend(&info.id); - let runtime_entry_exists = runtime_cp.runtime.has_entry(&info.id); - - // Initialize queue if: backend changed OR runtime entry doesn't exist yet - if previous_backend != backend || !runtime_entry_exists { - runtime_cp.runtime.set_playlist_backend(&info.id, backend); - match backend { - PlaylistBackend::OpenHome => { - if let Some(queue) = build_openhome_queue(info) { - runtime_cp - .runtime - .set_music_queue(&info.id, MusicQueue::OpenHome(queue)); - } else { - runtime_cp.runtime.set_music_queue( - &info.id, - MusicQueue::Internal(InternalQueue::new()), - ); - } - } - PlaylistBackend::PMOQueue => { - runtime_cp.runtime.set_music_queue( - &info.id, - MusicQueue::Internal(InternalQueue::new()), - ); - } - } - if matches!(backend, PlaylistBackend::OpenHome) { - if let Err(err) = sync_openhome_playlist( - &runtime_cp.registry, - &runtime_cp.runtime, - &runtime_cp.event_bus, - &info.id, - ) { - debug!( - renderer = info.id.0.as_str(), - error = %err, - "Initial OpenHome playlist sync failed" - ); - } - } - } - let renderer_id = info.id.clone(); + + // Initialize runtime entry if it doesn't exist + // Queue is managed by MusicRenderer, not RuntimeState + if !runtime_cp.runtime.has_entry(&renderer_id) { + runtime_cp.runtime.create_entry(&renderer_id); + } let prev_snapshot = runtime_cp.runtime.snapshot_for(&renderer_id); let mut new_snapshot = prev_snapshot.clone(); let prev_position = prev_snapshot.position.clone(); @@ -633,8 +548,6 @@ impl ControlPoint { event_bus: event_bus.clone(), media_event_bus: media_event_bus.clone(), runtime: Arc::clone(&runtime), - playlist_bindings: Arc::clone(&playlist_bindings), - renderer_cache: Arc::clone(&renderer_cache), }; thread::Builder::new() @@ -650,14 +563,12 @@ impl ControlPoint { Arc::clone(&runtime), event_bus.clone(), oh_event_tx, - Arc::clone(&playlist_bindings), )?; // Worker thread to process MediaServerEvent and trigger queue refreshes // for renderers bound to updated playlist containers let registry_for_media_worker = Arc::clone(®istry); let runtime_for_media_worker = Arc::clone(&runtime); - let bindings_for_media_worker = Arc::clone(&playlist_bindings); let event_bus_for_media_worker = event_bus.clone(); let media_rx = media_event_bus.subscribe(); @@ -687,17 +598,26 @@ impl ControlPoint { server_id, container_ids, } => { + // Get all renderers from registry and check their bindings let renderers_to_refresh: Vec = { - let mut bindings = bindings_for_media_worker.lock().unwrap(); + let reg = registry_for_media_worker.read().unwrap(); let mut to_refresh = Vec::new(); - for (renderer_id, binding) in bindings.iter_mut() { - if binding.server_id == server_id - && container_ids.contains(&binding.container_id) - { - binding.pending_refresh = true; - binding.has_seen_update = true; - to_refresh.push(renderer_id.clone()); + if let Ok(renderers) = reg.list_renderers() { + for renderer in renderers { + let renderer_id = renderer.info().id.clone(); + if let Some(binding) = renderer.get_playlist_binding() { + if binding.server_id == server_id + && container_ids.contains(&binding.container_id) + { + // Update binding to mark refresh needed + let mut updated_binding = binding.clone(); + updated_binding.pending_refresh = true; + updated_binding.has_seen_update = true; + renderer.set_playlist_binding(Some(updated_binding)); + to_refresh.push(renderer_id); + } + } } } @@ -714,7 +634,6 @@ impl ControlPoint { if let Err(err) = refresh_attached_queue_for( ®istry_for_media_worker, &runtime_for_media_worker, - &bindings_for_media_worker, &renderer_id, &event_bus_for_media_worker, None, @@ -748,7 +667,6 @@ impl ControlPoint { // Every 60 seconds, trigger a refresh for all renderers with active bindings let registry_for_periodic = Arc::clone(®istry); let runtime_for_periodic = Arc::clone(&runtime); - let bindings_for_periodic = Arc::clone(&playlist_bindings); let event_bus_for_periodic = event_bus.clone(); thread::Builder::new() @@ -760,12 +678,20 @@ impl ControlPoint { // Collect all renderers with active bindings and mark them for refresh let renderers_to_refresh: Vec = { - let mut bindings = bindings_for_periodic.lock().unwrap(); + let reg = registry_for_periodic.read().unwrap(); let mut to_refresh = Vec::new(); - for (renderer_id, binding) in bindings.iter_mut() { - binding.pending_refresh = true; - to_refresh.push(renderer_id.clone()); + if let Ok(renderers) = reg.list_renderers() { + for renderer in renderers { + let renderer_id = renderer.info().id.clone(); + if let Some(binding) = renderer.get_playlist_binding() { + // Mark binding for refresh + let mut updated_binding = binding.clone(); + updated_binding.pending_refresh = true; + renderer.set_playlist_binding(Some(updated_binding)); + to_refresh.push(renderer_id); + } + } } to_refresh @@ -781,7 +707,6 @@ impl ControlPoint { if let Err(err) = refresh_attached_queue_for( ®istry_for_periodic, &runtime_for_periodic, - &bindings_for_periodic, &renderer_id, &event_bus_for_periodic, None, @@ -798,11 +723,10 @@ impl ControlPoint { Ok(Self { registry, + udn_cache: Arc::new(Mutex::new(UDNRegistry::new())), event_bus, media_event_bus, runtime, - playlist_bindings, - renderer_cache, }) } @@ -842,70 +766,23 @@ impl ControlPoint { } - /// Internal helper to get or create a renderer from the cache. - /// This ensures that Chromecast renderers maintain their persistent connections. - fn get_or_create_renderer(&self, info: RendererInfo) -> Option { - let id = info.id(); - - // Try to get from cache first - { - let cache = self.renderer_cache.lock().unwrap(); - if let Some(renderer) = cache.get(&id) { - return Some(renderer.clone()); - } - } - - // Not in cache, create new renderer - if let Some(renderer) = MusicRendererBackend::from_renderer_info(info, &self.registry) { - // Add to cache - let mut cache = self.renderer_cache.lock().unwrap(); - cache.insert(id, renderer.clone()); - Some(renderer) - } else { - None - } - } /// Snapshot list of music renderers (protocol-agnostic view). - pub fn list_music_renderers(&self) -> Vec { - let infos = { - let reg = self.registry.read().unwrap(); - reg.list_renderers() - }; - - // Clean up cache - remove renderers that are no longer in the registry - { - let current_ids: HashSet = infos.iter().map(|i| i.id.clone()).collect(); - let mut cache = self.renderer_cache.lock().unwrap(); - cache.retain(|id, _| current_ids.contains(id)); - } - - infos - .into_iter() - .filter_map(|info| self.get_or_create_renderer(info)) - .collect() + pub fn list_music_renderers(&self) -> Vec> { + let reg = self.registry.read().unwrap(); + reg.list_renderers().unwrap_or_else(|_| vec![]) } /// Return the first music renderer in the registry, if any. - pub fn default_music_renderer(&self) -> Option { - let infos = { - let reg = self.registry.read().unwrap(); - reg.list_renderers() - }; - - infos - .into_iter() - .find_map(|info| self.get_or_create_renderer(info)) + pub fn default_music_renderer(&self) -> Option> { + let reg = self.registry.read().unwrap(); + reg.list_renderers().ok()?.into_iter().next() } /// Lookup a music renderer by id. - pub fn music_renderer_by_id(&self, id: &ServiceId) -> Option { - let info = { - let reg = self.registry.read().unwrap(); - reg.get_renderer(id) - }?; - - self.get_or_create_renderer(info) + pub fn music_renderer_by_id(&self, id: &ServiceId) -> Option> { + let reg = self.registry.read().unwrap(); + reg.get_renderer(id) } /// Snapshot list of media servers currently known by the registry. @@ -926,7 +803,7 @@ impl ControlPoint { /// `detach_playlist_binding` beforehand so that any server-side playlist /// attachment stays consistent with the local `QueueBackend` snapshot. /// The actual structural change then goes through the backend helpers - /// (`QueueBackend::clear_queue` via `RuntimeState::with_music_queue_mut`). + /// (`QueueBackend::clear_queue` via `MusicRenderer::get_queue_mut()`). pub fn clear_queue(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { if !self.runtime.has_entry(renderer_id) { let err = Self::runtime_entry_missing(renderer_id); @@ -943,18 +820,15 @@ impl ControlPoint { // Clear the queue on the backend (backend-agnostic) let renderer = self.music_renderer_by_id(renderer_id) .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + + // Get queue length before clearing + let removed = renderer.get_queue().upcoming_len().unwrap_or(0); + renderer.clear_queue()?; // Sync backend state to local cache (for OpenHome, this updates the cache) renderer.sync_queue_state()?; - // Clear the local queue state - let removed = self.runtime.with_music_queue_mut(renderer_id, |queue| { - let removed = queue.upcoming_len()?; - queue.clear_queue()?; - Ok(removed) - })?; - debug!( renderer = renderer_id.0.as_str(), items_removed = removed, @@ -977,7 +851,7 @@ impl ControlPoint { /// Each caller-triggered queue mutation must first detach any playlist binding /// to avoid diverging from the server container, then manipulate the queue /// strictly through the `QueueBackend` helpers (here `QueueBackend::enqueue_items` - /// inside `RuntimeState::with_music_queue_mut`). + /// accessed via `MusicRenderer::get_queue_mut()`). pub fn enqueue_items( &self, renderer_id: &ServiceId, @@ -997,10 +871,15 @@ impl ControlPoint { // Enqueue items using QueueBackend abstraction (works for both backends) let item_count = items.len(); - let new_len = self.runtime.with_music_queue_mut(renderer_id, |queue| { + let renderer = self + .music_renderer_by_id(renderer_id) + .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + + let new_len = { + let mut queue = renderer.get_queue_mut(); queue.enqueue_items(items, EnqueueMode::AppendToEnd)?; - queue.upcoming_len() - })?; + queue.upcoming_len()? + }; debug!( renderer = renderer_id.0.as_str(), @@ -1060,9 +939,13 @@ impl ControlPoint { return Err(Self::runtime_entry_missing(renderer_id)); } - self.runtime - .queue_full_snapshot(renderer_id) - .ok_or_else(|| Self::runtime_entry_missing(renderer_id)) + // Get queue from renderer + let renderer = self.music_renderer_by_id(renderer_id) + .map_err(|_| Self::runtime_entry_missing(renderer_id))?; + let queue = renderer.get_queue(); + let snapshot = queue.queue_snapshot() + .map_err(|e| ControlPointError::QueueError(e.to_string()))?; + Ok((snapshot.items, snapshot.current_index)) } /// Read-only accessor to the last known metadata for the renderer. @@ -1094,15 +977,6 @@ impl ControlPoint { Ok(snapshot.map(|s| s.len()).unwrap_or(0)) } - pub fn get_cached_openhome_playlist_snapshot( - &self, - renderer_id: &ServiceId, - ttl: Duration, - ) -> anyhow::Result { - let renderer = self.openhome_renderer(renderer_id)?; - self.runtime.openhome_snapshot_cached(&renderer, ttl) - } - /// Build a fully consistent snapshot for UI consumers (state + queue + binding). #[cfg(feature = "pmoserver")] pub fn renderer_full_snapshot( @@ -1114,31 +988,21 @@ impl ControlPoint { .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; let info = renderer.info(); - // Get runtime snapshot (needed for all backends: volume, state, position) - let (runtime_snapshot, runtime_queue_items, runtime_current_index) = - self.runtime.renderer_snapshot_bundle(renderer_id); + // Get runtime snapshot (state, position, volume, metadata) + let runtime_snapshot = self.runtime.snapshot_for(renderer_id); - // Try to get queue from backend (returns Some for OpenHome, None for others) - let backend_queue = renderer.queue_snapshot()?; + // Get queue from renderer (works for all backends) + let queue_snapshot = renderer.get_queue().queue_snapshot() + .map_err(|e| anyhow!("Failed to get queue snapshot: {}", e))?; - // Use backend queue if available (OpenHome), otherwise use runtime queue (AVTransport) - let (queue_items, mut queue_current_index, from_backend) = if let Some(snapshot) = backend_queue { - debug!( - renderer = renderer_id.0.as_str(), - current_index = ?snapshot.current_index, - track_count = snapshot.items.len(), - "renderer_full_snapshot: using backend queue (OpenHome)" - ); - (snapshot.items, snapshot.current_index, true) - } else { - (runtime_queue_items, runtime_current_index, false) - }; + let queue_items = queue_snapshot.items; + let mut queue_current_index = queue_snapshot.current_index; let playback_source = self.runtime.playback_source(renderer_id); let queue_len = queue_items.len(); - // For non-backend queues (AVTransport), try heuristics to determine current_index - if !from_backend && queue_current_index.is_none() { + // Try heuristics to determine current_index if not set + if queue_current_index.is_none() { if let Some(position) = runtime_snapshot.position.as_ref() { if let Some(uri) = position.track_uri.as_ref() { if let Some(idx) = queue_items.iter().position(|item| item.uri == *uri) { @@ -1201,21 +1065,8 @@ impl ControlPoint { .and_then(|idx| queue_items.get(idx)) .map(current_track_from_playback_item); - // For backend queues (OpenHome), prefer queue metadata since runtime isn't updated - // For runtime queues (AVTransport), prefer runtime metadata which is fresher - let current_track = if from_backend { - queue_current_metadata.or_else(|| { - runtime_snapshot - .last_metadata - .as_ref() - .map(|meta| CurrentTrackMetadata { - title: meta.title.clone(), - artist: meta.artist.clone(), - album: meta.album.clone(), - album_art_uri: meta.album_art_uri.clone(), - }) - }) - } else { + // Prefer queue metadata, fallback to runtime metadata + let current_track = queue_current_metadata.or_else(|| { runtime_snapshot .last_metadata .as_ref() @@ -1225,8 +1076,7 @@ impl ControlPoint { album: meta.album.clone(), album_art_uri: meta.album_art_uri.clone(), }) - .or(queue_current_metadata) - }; + }); let state_view = RendererStateView { id: renderer_id.0.clone(), @@ -1323,15 +1173,8 @@ impl ControlPoint { anyhow!("Renderer {} not found", renderer_id.0) })?; - // If renderer has a backend queue (OpenHome), use backend playback - if renderer.queue_snapshot()?.is_some() { - renderer.play_current_from_backend_queue()?; - self.runtime.set_playback_source(renderer_id, PlaybackSource::FromQueue); - return Ok(()); - } - - // Otherwise use local queue playback (AVTransport) - let Some((item, remaining)) = self.runtime.peek_current(renderer_id) else { + // Use generic queue access (works for all backends) + let Some((item, remaining)) = renderer.get_queue().peek_current()? else { debug!( renderer = renderer_id.0.as_str(), "play_current_from_queue: queue is empty or no current item" @@ -1408,14 +1251,8 @@ impl ControlPoint { anyhow!("Renderer {} not found", renderer_id.0) })?; - // If renderer has a backend queue (OpenHome), use backend playback - if renderer.queue_snapshot()?.is_some() { - renderer.play_next_from_backend_queue()?; - self.runtime.set_playback_source(renderer_id, PlaybackSource::FromQueue); - return Ok(()); - } - - let Some((item, remaining_after)) = self.runtime.dequeue_next(renderer_id) else { + // Use generic queue access (works for all backends) + let Some((item, remaining_after)) = renderer.get_queue_mut().dequeue_next()? else { debug!( renderer = renderer_id.0.as_str(), "play_next_from_queue: queue is empty" @@ -1458,12 +1295,9 @@ impl ControlPoint { error = %err, "Failed to start playback for queued item" ); - if self - .runtime - .with_music_queue_mut(renderer_id, |queue| { - queue.enqueue_items(vec![item.clone()], EnqueueMode::InsertAfterCurrent)?; - Ok(()) - }) + // Try to requeue the item + if renderer.get_queue_mut() + .enqueue_items(vec![item.clone()], EnqueueMode::InsertAfterCurrent) .is_err() { warn!( @@ -1540,48 +1374,21 @@ impl ControlPoint { anyhow!("Renderer {} not found", renderer_id.0) })?; - // For backend queues (OpenHome), use select_track_index which handles the track_id lookup - if renderer.queue_snapshot()?.is_some() { - info!( - renderer = renderer_id.0.as_str(), - index, - "Seeking to OpenHome queue index" - ); + // Use generic queue access (works for all backends) + renderer.get_queue_mut().set_index(Some(index))?; - // Use the queue to select the track by index - self.runtime.with_music_queue_mut(renderer_id, |queue| { - if let crate::control_point::music_queue::MusicQueue::OpenHome(oh_queue) = queue { - oh_queue.select_track_index(index) - } else { - Err(anyhow!("Expected OpenHome queue")) - } - })?; - - info!( - renderer = renderer_id.0.as_str(), - index, - "Successfully seeked to OpenHome queue index" - ); - - self.runtime - .set_playback_source(renderer_id, PlaybackSource::FromQueue); - return self.sync_openhome_playlist_for(renderer_id); - } - - // For internal queue, set the index and play - self.runtime.with_music_queue_mut(renderer_id, |queue| { - queue.set_index(Some(index)) - })?; - - let Some((item, remaining)) = self.runtime.peek_current(renderer_id) else { - debug!( - renderer = renderer_id.0.as_str(), - index, - "play_queue_index: no item at index" - ); - self.runtime - .set_playback_source(renderer_id, PlaybackSource::None); - return Ok(()); + let (item, remaining) = match renderer.get_queue().peek_current()? { + Some(result) => result, + None => { + debug!( + renderer = renderer_id.0.as_str(), + index, + "play_queue_index: no item at index" + ); + self.runtime + .set_playback_source(renderer_id, PlaybackSource::None); + return Ok(()); + } }; debug!( @@ -1668,9 +1475,10 @@ impl ControlPoint { // Check if queue has ANY items (not just upcoming items after current) // This is important for newly attached playlists with current_index set let has_items = self - .runtime - .queue_full_snapshot(renderer_id) - .map(|(items, _)| !items.is_empty()) + .music_renderer_by_id(renderer_id) + .ok() + .and_then(|r| r.get_queue().queue_snapshot().ok()) + .map(|s| !s.items.is_empty()) .unwrap_or(false); if !has_items { debug!( @@ -1680,13 +1488,15 @@ impl ControlPoint { return Ok(()); } - let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { - anyhow!("Renderer {} not found", renderer_id.0) - })?; + // Play current item if index is set, otherwise play next (advances index to 0) + let has_current_index = self + .music_renderer_by_id(renderer_id) + .ok() + .and_then(|r| r.get_queue().queue_snapshot().ok()) + .and_then(|s| s.current_index) + .is_some(); - // For backend queues (OpenHome), play current (renderer tracks its own position) - // For local queues (AVTransport), dequeue next (we manage position locally) - if renderer.queue_snapshot()?.is_some() { + if has_current_index { self.play_current_from_queue(renderer_id) } else { self.play_next_from_queue(renderer_id) @@ -1797,10 +1607,7 @@ impl ControlPoint { // Clear the local queue (detach binding + clear runtime queue structure) self.detach_playlist_binding(renderer_id, "attach_new_playlist"); - self.runtime.with_music_queue_mut(renderer_id, |queue| { - queue.clear_queue()?; - Ok(()) - })?; + renderer.get_queue_mut().clear_queue()?; debug!( renderer = renderer_id.0.as_str(), @@ -1815,17 +1622,18 @@ impl ControlPoint { auto_play_on_refresh: auto_play, }; - { - let mut bindings = self.playlist_bindings.lock().unwrap(); - bindings.insert(renderer_id.clone(), binding.clone()); - info!( - renderer = renderer_id.0.as_str(), - server = server_id.0.as_str(), - container = container_id, - auto_play, - "Queue attached to playlist container" - ); - } + let renderer = self + .music_renderer_by_id(renderer_id) + .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + + renderer.set_playlist_binding(Some(binding.clone())); + info!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + container = container_id, + auto_play, + "Queue attached to playlist container" + ); self.emit_renderer_event(RendererEvent::BindingChanged { id: renderer_id.clone(), @@ -1849,7 +1657,6 @@ impl ControlPoint { refresh_attached_queue_for( &self.registry, &self.runtime, - &self.playlist_bindings, renderer_id, &self.event_bus, callback, @@ -1873,8 +1680,8 @@ impl ControlPoint { &self, renderer_id: &ServiceId, ) -> Option<(ServerId, String, bool)> { - let bindings = self.playlist_bindings.lock().unwrap(); - bindings.get(renderer_id).map(|binding| { + let renderer = self.music_renderer_by_id(renderer_id)?; + renderer.get_playlist_binding().map(|binding| { ( binding.server_id.clone(), binding.container_id.clone(), @@ -1888,11 +1695,14 @@ impl ControlPoint { /// Invariant: every user-driven queue mutation **must** call this method so /// that bindings never become out of sync with the local queue snapshot. fn detach_playlist_binding(&self, renderer_id: &ServiceId, reason: &str) { - let removed = { - let mut bindings = self.playlist_bindings.lock().unwrap(); - bindings.remove(renderer_id) + let renderer = match self.music_renderer_by_id(renderer_id) { + Some(r) => r, + None => return, }; + let removed = renderer.get_playlist_binding(); + renderer.set_playlist_binding(None); + if let Some(binding) = removed { info!( renderer = renderer_id.0.as_str(), @@ -1962,20 +1772,6 @@ impl ControlPoint { ) } - fn openhome_renderer(&self, renderer_id: &ServiceId) -> anyhow::Result { - let renderer = self - .music_renderer_by_id(renderer_id) - .ok_or_else(|| OpenHomeAccessError::RendererNotFound(renderer_id.0.clone()))?; - if !renderer.info().capabilities.has_oh_playlist { - return Err(OpenHomeAccessError::PlaylistNotSupported(renderer_id.0.clone()).into()); - } - Ok(renderer) - } - - fn sync_openhome_playlist_for(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { - sync_openhome_playlist(&self.registry, &self.runtime, &self.event_bus, renderer_id) - } - } #[cfg(feature = "pmoserver")] @@ -1989,18 +1785,6 @@ fn convert_runtime_position(position: Option<&PlaybackPositionInfo>) -> (Option< } } -fn build_openhome_queue(info: &RendererInfo) -> Option { - let playlist = build_playlist_client(info)?; - let info_client = build_info_client(info); - let product_client = build_product_client(info); - Some(OpenHomeQueue::new( - info.id.clone(), - playlist, - info_client, - product_client, - )) -} - #[cfg(feature = "pmoserver")] fn parse_hms_to_ms(hms: Option<&str>) -> Option { let value = hms?; @@ -2033,90 +1817,26 @@ enum PlaybackSource { External, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PlaylistBackend { - PMOQueue, - OpenHome, -} - struct RendererRuntimeEntry { snapshot: RendererRuntimeSnapshot, - pub queue: crate::control_point::music_queue::MusicQueue, - openhome_cache: OpenHomePlaylistCache, playback_source: PlaybackSource, user_stop_requested: bool, - playlist_backend: PlaylistBackend, } impl Default for RendererRuntimeEntry { fn default() -> Self { Self { snapshot: RendererRuntimeSnapshot::default(), - queue: MusicQueue::Internal(InternalQueue::new()), - openhome_cache: OpenHomePlaylistCache::default(), playback_source: PlaybackSource::None, user_stop_requested: false, - playlist_backend: PlaylistBackend::PMOQueue, } } } -#[derive(Debug, Default, Clone)] -struct OpenHomePlaylistCache { - ids: Option>, - snapshot: Option, - last_refresh: Option, -} - struct RuntimeState { entries: Mutex>, } -pub struct RendererRuntimeStateMut<'a> { - pub queue: MusicQueueGuard<'a>, - _guard: MutexGuard<'a, HashMap>, -} - -impl<'a> RendererRuntimeStateMut<'a> { - fn new( - guard: MutexGuard<'a, HashMap>, - queue_ptr: *mut MusicQueue, - ) -> Self { - Self { - queue: MusicQueueGuard::new(queue_ptr), - _guard: guard, - } - } -} - -pub struct MusicQueueGuard<'a> { - ptr: *mut MusicQueue, - _marker: PhantomData<&'a mut MusicQueue>, -} - -impl<'a> MusicQueueGuard<'a> { - fn new(ptr: *mut MusicQueue) -> Self { - Self { - ptr, - _marker: PhantomData, - } - } -} - -impl<'a> Deref for MusicQueueGuard<'a> { - type Target = MusicQueue; - - fn deref(&self) -> &Self::Target { - unsafe { &*self.ptr } - } -} - -impl<'a> DerefMut for MusicQueueGuard<'a> { - fn deref_mut(&mut self) -> &mut Self::Target { - unsafe { &mut *self.ptr } - } -} - struct RuntimeOpenHomeQueueProvider { runtime: Arc, } @@ -2174,34 +1894,6 @@ impl RuntimeState { entries.contains_key(id) } - fn with_music_queue_mut(&self, id: &ServiceId, f: F) -> anyhow::Result - where - F: FnOnce(&mut MusicQueue) -> anyhow::Result, - { - let mut entries = self.entries.lock().unwrap(); - let entry = entries - .get_mut(id) - .ok_or_else(|| anyhow!("Renderer {} not registered in runtime", id.0))?; - f(&mut entry.queue) - } - - fn queue_snapshot(&self, id: &ServiceId) -> Option> { - let entries = self.entries.lock().unwrap(); - entries - .get(id) - .and_then(|entry| entry.queue.upcoming_items().ok()) - } - - fn queue_full_snapshot(&self, id: &ServiceId) -> Option<(Vec, Option)> { - let entries = self.entries.lock().unwrap(); - entries.get(id).and_then(|entry| { - entry - .queue - .queue_snapshot() - .ok() - .map(|snapshot| (snapshot.items, snapshot.current_index)) - }) - } // fn renderer_state(&self, id: &RendererId) -> anyhow::Result { // let entries = self.entries.lock().unwrap(); @@ -2213,18 +1905,6 @@ impl RuntimeState { // }) // } - fn renderer_state_mut(&self, id: &ServiceId) -> anyhow::Result> { - let mut entries = self.entries.lock().unwrap(); - let queue_ptr = { - let entry = entries - .get_mut(id) - .ok_or_else(|| anyhow!("Renderer {} not registered in runtime", id.0))?; - let entry_ptr: *mut RendererRuntimeEntry = entry; - unsafe { &mut (*entry_ptr).queue as *mut MusicQueue } - }; - Ok(RendererRuntimeStateMut::new(entries, queue_ptr)) - } - fn current_track_metadata(&self, id: &ServiceId) -> Option { let entries = self.entries.lock().unwrap(); entries @@ -2232,44 +1912,6 @@ impl RuntimeState { .and_then(|entry| entry.snapshot.last_metadata.clone()) } - #[cfg(feature = "pmoserver")] - fn renderer_snapshot_bundle( - &self, - id: &ServiceId, - ) -> (RendererRuntimeSnapshot, Vec, Option) { - let entries = self.entries.lock().unwrap(); - if let Some(entry) = entries.get(id) { - match entry.queue.queue_snapshot() { - Ok(snapshot) => ( - entry.snapshot.clone(), - snapshot.items, - snapshot.current_index, - ), - Err(err) => { - warn!( - renderer = id.0.as_str(), - error = %err, - "Failed to build queue snapshot for renderer" - ); - (entry.snapshot.clone(), Vec::new(), None) - } - } - } else { - (RendererRuntimeSnapshot::default(), Vec::new(), None) - } - } - - fn dequeue_next(&self, id: &ServiceId) -> Option<(PlaybackItem, usize)> { - let mut entries = self.entries.lock().unwrap(); - let entry = entries.get_mut(id)?; - entry.queue.dequeue_next().ok().flatten() - } - - fn peek_current(&self, id: &ServiceId) -> Option<(PlaybackItem, usize)> { - let entries = self.entries.lock().unwrap(); - let entry = entries.get(id)?; - entry.queue.peek_current().ok().flatten() - } fn set_playback_source(&self, id: &ServiceId, source: PlaybackSource) { let mut entries = self.entries.lock().unwrap(); @@ -2310,74 +1952,6 @@ impl RuntimeState { f(entry) } - fn set_music_queue(&self, id: &ServiceId, queue: MusicQueue) { - self.with_entry(id, |entry| { - entry.queue = queue; - }); - } - - fn invalidate_openhome_cache(&self, id: &ServiceId) { - self.with_entry(id, |entry| { - entry.openhome_cache = OpenHomePlaylistCache::default(); - }); - } - - fn get_openhome_cache(&self, id: &ServiceId) -> Option { - let entries = self.entries.lock().unwrap(); - entries.get(id).map(|entry| entry.openhome_cache.clone()) - } - - fn set_openhome_cache(&self, id: &ServiceId, cache: OpenHomePlaylistCache) { - self.with_entry(id, |entry| { - entry.openhome_cache = cache; - }); - } - - fn openhome_snapshot_cached( - &self, - renderer: &MusicRendererBackend, - ttl: Duration, - ) -> anyhow::Result { - let ids = renderer.openhome_playlist_ids()?; - let now = Instant::now(); - - if let Some(cache) = self.get_openhome_cache(renderer.id()) { - if let (Some(cached_ids), Some(snapshot), Some(last_refresh)) = - (cache.ids, cache.snapshot, cache.last_refresh) - { - if cached_ids == ids && now.saturating_duration_since(last_refresh) <= ttl { - return Ok(snapshot); - } - } - } - - let snapshot = renderer.openhome_playlist_snapshot()?; - let cache = OpenHomePlaylistCache { - ids: Some(ids), - snapshot: Some(snapshot.clone()), - last_refresh: Some(now), - }; - self.set_openhome_cache(renderer.id(), cache); - Ok(snapshot) - } - - fn set_playlist_backend(&self, id: &ServiceId, backend: PlaylistBackend) { - self.with_entry(id, |entry| { - entry.playlist_backend = backend; - }); - } - - fn playlist_backend(&self, id: &ServiceId) -> PlaylistBackend { - let entries = self.entries.lock().unwrap(); - entries - .get(id) - .map(|entry| entry.playlist_backend) - .unwrap_or(PlaylistBackend::PMOQueue) - } - - fn uses_openhome_playlist(&self, id: &ServiceId) -> bool { - matches!(self.playlist_backend(id), PlaylistBackend::OpenHome) - } fn mark_user_stop_requested(&self, id: &ServiceId) { let mut entries = self.entries.lock().unwrap(); @@ -2407,15 +1981,29 @@ impl RuntimeState { fn refresh_attached_queue_for( registry: &Arc>, runtime: &Arc, - bindings: &Arc>>, renderer_id: &ServiceId, event_bus: &RendererEventBus, mut after_refresh: Option<&mut dyn FnMut(&ServiceId) -> anyhow::Result<()>>, ) -> anyhow::Result<()> { - // Step 1: Check binding and mark refresh as in-progress + // Step 1: Get renderer and check binding + let renderer = { + let reg = registry.read().unwrap(); + reg.get_renderer(renderer_id) + }; + + let renderer = match renderer { + Some(r) => r, + None => { + debug!( + renderer = renderer_id.0.as_str(), + "refresh_attached_queue_for: renderer not found" + ); + return Ok(()); + } + }; + let (server_id, container_id, auto_play) = { - let mut bindings_lock = bindings.lock().unwrap(); - let binding = match bindings_lock.get_mut(renderer_id) { + let binding = match renderer.get_playlist_binding() { Some(b) => b, None => { debug!( @@ -2434,10 +2022,13 @@ fn refresh_attached_queue_for( return Ok(()); } - // Mark as processed - binding.pending_refresh = false; - let auto_play = binding.auto_play_on_refresh; - binding.auto_play_on_refresh = false; + // Mark as processed - clone, modify, and set back + let mut updated_binding = binding.clone(); + updated_binding.pending_refresh = false; + let auto_play = updated_binding.auto_play_on_refresh; + updated_binding.auto_play_on_refresh = false; + renderer.set_playlist_binding(Some(updated_binding)); + ( binding.server_id.clone(), binding.container_id.clone(), @@ -2543,8 +2134,10 @@ fn refresh_attached_queue_for( total_entries = entries.len(), "Refreshed playlist is empty, clearing queue - all entries were filtered out" ); - runtime.with_music_queue_mut(renderer_id, |queue| queue.clear_queue())?; - runtime.invalidate_openhome_cache(renderer_id); + { + let mut queue = renderer.queue.lock().unwrap(); + queue.clear_queue()?; + } // Emit QueueUpdated event event_bus.broadcast(RendererEvent::QueueUpdated { @@ -2560,9 +2153,16 @@ fn refresh_attached_queue_for( // This uses LCS algorithm to minimize playlist operations and avoid interrupting playback // Get the full queue snapshot to access the item currently being played - let (full_queue, current_idx) = runtime - .queue_full_snapshot(renderer_id) - .unwrap_or((vec![], None)); + let (full_queue, current_idx) = { + let reg = registry.read().unwrap(); + reg.renderer_by_id(renderer_id) + .and_then(|info| MusicRenderer::from_renderer_info(info).ok()) + .and_then(|r| { + let queue = r.get_queue(); + queue.queue_snapshot().ok().map(|s| (s.items, s.current_index)) + }) + .unwrap_or((vec![], None)) + }; // Get the item currently being played (at current_index), not the next one in queue let current_item = current_idx.and_then(|idx| full_queue.get(idx).cloned()); @@ -2613,7 +2213,25 @@ fn refresh_attached_queue_for( }) }); - let final_queue_len = runtime.with_music_queue_mut(renderer_id, |queue| { + let final_queue_len = { + let music_renderer = { + let reg = registry.read().unwrap(); + reg.renderer_by_id(renderer_id) + .and_then(|info| MusicRenderer::from_renderer_info(info).ok()) + }; + + let music_renderer = match music_renderer { + Some(r) => r, + None => { + warn!( + renderer = renderer_id.0.as_str(), + "refresh_attached_queue_for: renderer not found in registry" + ); + return Ok(()); + } + }; + + let mut queue = music_renderer.get_queue_mut(); if let Some(idx) = item_found_at { // Current item is in new playlist - use gentle incremental update queue.replace_queue(new_items.clone(), Some(idx))?; @@ -2628,7 +2246,7 @@ fn refresh_attached_queue_for( is_playing, "Gentle refresh: current item found in new playlist" ); - Ok(new_items.len()) + new_items.len() } else if let Some(ref current) = current_item { // Current item NOT in new playlist if is_playing { @@ -2649,7 +2267,7 @@ fn refresh_attached_queue_for( is_playing = true, "Gentle refresh: preserved playing track not in new playlist" ); - Ok(new_items.len() + 1) + new_items.len() + 1 } else { // Not playing, use new playlist as-is queue.replace_queue(new_items.clone(), None)?; @@ -2662,7 +2280,7 @@ fn refresh_attached_queue_for( is_playing = false, "Gentle refresh: replaced queue (not playing)" ); - Ok(new_items.len()) + new_items.len() } } else { // No current item, use new playlist as-is @@ -2676,10 +2294,9 @@ fn refresh_attached_queue_for( is_playing, "Gentle refresh: no current item" ); - Ok(new_items.len()) + new_items.len() } - })?; - runtime.invalidate_openhome_cache(renderer_id); + }; // Emit QueueUpdated event event_bus.broadcast(RendererEvent::QueueUpdated { @@ -2830,77 +2447,6 @@ fn playback_item_track_metadata(item: &PlaybackItem) -> TrackMetadata { }) } -fn openhome_renderer_from_registry( - registry: &Arc>, - renderer_id: &ServiceId, -) -> anyhow::Result { - let info = { - let reg = registry.read().unwrap(); - reg.get_renderer(renderer_id) - .ok_or_else(|| OpenHomeAccessError::RendererNotFound(renderer_id.0.clone()))? - }; - let renderer = MusicRendererBackend::from_renderer_info(info, registry) - .and_then(|r| match r { - MusicRendererBackend::OpenHome(_) => Some(r), - _ => None, - }) - .ok_or_else(|| OpenHomeAccessError::PlaylistNotSupported(renderer_id.0.clone()))?; - Ok(renderer) -} - -fn sync_openhome_playlist( - registry: &Arc>, - runtime: &Arc, - event_bus: &RendererEventBus, - renderer_id: &ServiceId, -) -> anyhow::Result<()> { - let renderer = openhome_renderer_from_registry(registry, renderer_id)?; - - let snapshot = renderer.openhome_playlist_snapshot()?; - let playback_items: Vec = snapshot - .tracks - .iter() - .map(|track| playback_item_from_openhome_track(renderer_id, track)) - .collect(); - - debug!( - renderer = renderer_id.0.as_str(), - fetched_items = playback_items.len(), - current_id = ?snapshot.current_id, - "sync_openhome_playlist: fetched snapshot from renderer" - ); - - let current_id = snapshot.current_id; - - let current_index = current_id.and_then(|id| { - playback_items - .iter() - .position(|item| openhome_track_id_from_item(item) == Some(id)) - }); - - let queue_items = playback_items; - let queue_len = runtime - .with_music_queue_mut(renderer_id, move |queue| { - let len = queue_items.len(); - queue.replace_queue(queue_items, current_index)?; - Ok(len) - }) - .unwrap_or(0); - - debug!( - renderer = renderer_id.0.as_str(), - queue_len, - "sync_openhome_playlist: updated local queue" - ); - - event_bus.broadcast(RendererEvent::QueueUpdated { - id: renderer_id.clone(), - queue_length: queue_len, - }); - - Ok(()) -} - const OPENHOME_SUBSCRIPTION_TIMEOUT_SECS: u64 = 300; const OPENHOME_RENEWAL_MARGIN_SECS: u64 = 60; @@ -2909,7 +2455,6 @@ fn spawn_openhome_event_runtime( runtime: Arc, event_bus: RendererEventBus, event_tx: Sender, - playlist_bindings: Arc>>, ) -> io::Result<()> { let listener = TcpListener::bind("0.0.0.0:0")?; let listener_addr = listener @@ -2930,7 +2475,6 @@ fn spawn_openhome_event_runtime( notify_rx, event_tx, listener_addr.port(), - playlist_bindings, ); thread::Builder::new() @@ -2949,7 +2493,6 @@ struct OpenHomeEventRuntime { http_timeout: Duration, subscriptions: HashMap, path_index: HashMap, - playlist_bindings: Arc>>, } impl OpenHomeEventRuntime { @@ -2960,7 +2503,6 @@ impl OpenHomeEventRuntime { notify_rx: Receiver, event_tx: Sender, listener_port: u16, - playlist_bindings: Arc>>, ) -> Self { Self { registry, @@ -2972,7 +2514,6 @@ impl OpenHomeEventRuntime { http_timeout: Duration::from_secs(5), subscriptions: HashMap::new(), path_index: HashMap::new(), - playlist_bindings, } } @@ -3133,32 +2674,13 @@ impl OpenHomeEventRuntime { .iter() .any(|(name, _)| is_id_array_property(name)) { - if self.runtime.uses_openhome_playlist(&entry.renderer.id) { - // Check if this renderer has an active playlist binding - // If it does, skip sync because refresh_attached_queue_for() handles it - let has_active_binding = { - let bindings = self.playlist_bindings.lock().unwrap(); - bindings.contains_key(&entry.renderer.id) - }; - - if !has_active_binding { - // Synchronize the local queue with the renderer's playlist state - // This ensures our local mirror stays in sync when the renderer - // playlist changes from other control points or manual edits - if let Err(err) = sync_openhome_playlist( - &self.registry, - &self.runtime, - &self.event_bus, - &entry.renderer.id, - ) { - warn!( - renderer = entry.renderer.friendly_name.as_str(), - error = %err, - "Failed to sync OpenHome playlist after IdArray event" - ); - } - } - } + // OpenHome playlist changed event + // Queue synchronization is now handled by the generic queue backend + // and doesn't need special treatment here + debug!( + renderer = entry.renderer.friendly_name.as_str(), + "Received OpenHome IdArray change event" + ); } } OhServiceKind::Info => { @@ -3267,10 +2789,10 @@ impl OpenHomeEventRuntime { let position = self.runtime.snapshot_for(renderer_id).position; let mut new_position = position.unwrap_or_else(|| empty_playback_position()); if let Some(d) = duration { - new_position.track_duration = Some(format_seconds(d)); + new_position.track_duration = Some(format_hhmmss_u32(d)); } if let Some(s) = seconds { - new_position.rel_time = Some(format_seconds(s)); + new_position.rel_time = Some(format_hhmmss_u32(s)); } self.runtime.update_snapshot_with(renderer_id, |snapshot| { diff --git a/pmocontrol/src/discovery/arylic.rs b/pmocontrol/src/discovery/arylic.rs new file mode 100644 index 00000000..bf5c4219 --- /dev/null +++ b/pmocontrol/src/discovery/arylic.rs @@ -0,0 +1,66 @@ +use std::{ + collections::HashMap, + sync::{Mutex, OnceLock}, + time::Duration, +}; + +use tracing::debug; + +use crate::{ + arylic_client::{ARYLIC_TCP_PORT, send_command_required}, errors::ControlPointError, linkplay_client::extract_linkplay_host +}; + +static DETECTION_CACHE: OnceLock>> = OnceLock::new(); + +fn detection_cache() -> &'static Mutex> { + DETECTION_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Probe whether the renderer at the given location exposes the Arylic TCP API. +pub(crate) fn detect_arylic_tcp(location: &str, timeout: Duration) -> bool { + let Some(host) = extract_linkplay_host(location) else { + return false; + }; + + if let Ok(cache) = detection_cache().lock() { + if let Some(result) = cache.get(&host) { + return *result; + } + } + + let detected = match try_detect_tcp(&host, timeout) { + Ok(_) => true, + Err(err) => { + debug!( + "Arylic TCP detection failed for {} (host={}): {}", + location, host, err + ); + false + } + }; + + if let Ok(mut cache) = detection_cache().lock() { + cache.insert(host.to_string(), detected); + } + + detected +} + +fn try_detect_tcp(host: &str, timeout: Duration) -> Result<(), ControlPointError> { + let payload = send_command_required( + host, + ARYLIC_TCP_PORT, + timeout, + "MCU+INF+GET", + &["AXX+INF+", "AXX+DEV+"], + )?; + + if payload.starts_with("AXX+INF+") || payload.starts_with("AXX+DEV+") { + Ok(()) + } else { + Err(ControlPointError::ArilycTcpError(format!( + "Unexpected INF response from {}: {}", + host, payload + ))) + } +} diff --git a/pmocontrol/src/discovery/chromecast_discovery.rs b/pmocontrol/src/discovery/chromecast_discovery.rs index c87703a0..2cbac7ba 100644 --- a/pmocontrol/src/discovery/chromecast_discovery.rs +++ b/pmocontrol/src/discovery/chromecast_discovery.rs @@ -2,176 +2,181 @@ //! //! Chromecast devices advertise themselves using mDNS (Multicast DNS) on the //! `_googlecast._tcp.local` service, unlike UPnP devices which use SSDP. -//! This module handles the discovery of Chromecast devices and converts them -//! into `DeviceUpdate` events that can be processed by the `DeviceRegistry`. +//! This module handles the discovery of Chromecast devices and registers them +//! directly into the `DeviceRegistry`. use std::collections::HashMap; use std::net::IpAddr; -use std::time::SystemTime; +use std::sync::{Arc, Mutex}; use crate::DeviceId; -use crate::registry::DeviceUpdate; +use crate::DeviceRegistry; +use crate::discovery::manager::UDNRegistry; use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol}; use tracing::{debug, warn}; -/// Information about a discovered Chromecast device from mDNS. -#[derive(Clone, Debug)] -pub struct DiscoveredChromecast { - pub friendly_name: String, - pub host: String, - pub port: u16, - pub model: Option, - pub uuid: String, - pub manufacturer: Option, - pub last_seen: SystemTime, -} - -/// Manages the discovery and tracking of Chromecast devices via mDNS. +/// Gestionnaire des événements mDNS pour Chromecast. pub struct ChromecastDiscoveryManager { - discovered_devices: HashMap, + device_registry: Arc>, + udn_cache: Arc>, } impl ChromecastDiscoveryManager { - pub fn new() -> Self { + pub fn new( + device_registry: Arc>, + udn_cache: Arc>, + ) -> Self { Self { - discovered_devices: HashMap::new(), + device_registry, + udn_cache, } } - /// Adds or updates a discovered Chromecast device. - pub fn update_device(&mut self, device: DiscoveredChromecast) { - let uuid = device.uuid.clone(); - self.discovered_devices.insert(uuid, device); - } + /// Traite une réponse mDNS pour un appareil Chromecast. + /// + /// Cette fonction parse les réponses de service discovery mDNS pour les appareils + /// Chromecast et les enregistre directement dans le registre. + pub fn handle_mdns_response(&mut self, response: mdns::Response) { + // Extract basic information from the mDNS response + let service_name = match response.records().find_map(|r| { + if let mdns::RecordKind::PTR(ref name) = r.kind { + Some(name.clone()) + } else { + None + } + }) { + Some(name) => name, + None => { + warn!("No PTR record found in mDNS response"); + return; + } + }; - /// Retrieves a discovered device by UUID. - pub fn get_device(&self, uuid: &str) -> Option<&DiscoveredChromecast> { - self.discovered_devices.get(uuid) - } + debug!("Processing mDNS response for service: {}", service_name); - /// Lists all discovered devices. - pub fn list_devices(&self) -> Vec<&DiscoveredChromecast> { - self.discovered_devices.values().collect() - } -} + // Extract IP addresses + let addresses: Vec = response + .records() + .filter_map(|r| match r.kind { + mdns::RecordKind::A(addr) => Some(IpAddr::V4(addr)), + mdns::RecordKind::AAAA(addr) => Some(IpAddr::V6(addr)), + _ => None, + }) + .collect(); -/// Processes an mDNS response and converts it into a `DeviceUpdate` event. -/// -/// This function parses mDNS service discovery responses for Chromecast -/// devices and creates the appropriate update event for the device registry. -pub fn process_mdns_response(response: mdns::Response) -> Option { - // Extract basic information from the mDNS response - let service_name = response.records().filter_map(|r| { - if let mdns::RecordKind::PTR(ref name) = r.kind { - Some(name.clone()) - } else { - None + if addresses.is_empty() { + warn!("No IP address found for Chromecast device: {}", service_name); + return; } - }).next()?; - debug!("Processing mDNS response for service: {}", service_name); - - // Extract IP addresses - let addresses: Vec = response - .records() - .filter_map(|r| match r.kind { - mdns::RecordKind::A(addr) => Some(IpAddr::V4(addr)), - mdns::RecordKind::AAAA(addr) => Some(IpAddr::V6(addr)), - _ => None, - }) - .collect(); - - if addresses.is_empty() { - warn!("No IP address found for Chromecast device: {}", service_name); - return None; - } - - // Prefer IPv4 addresses - let host = addresses - .iter() - .find(|addr| matches!(addr, IpAddr::V4(_))) - .or_else(|| addresses.first()) - .map(|addr| addr.to_string())?; - - // Extract port from SRV record - let port = response - .records() - .filter_map(|r| { - if let mdns::RecordKind::SRV { port, .. } = r.kind { - Some(port) - } else { - None + // Prefer IPv4 addresses + let host = match addresses + .iter() + .find(|addr| matches!(addr, IpAddr::V4(_))) + .or_else(|| addresses.first()) + { + Some(addr) => addr.to_string(), + None => { + warn!("Could not extract host from addresses"); + return; } - }) - .next() - .unwrap_or(8009); // Default Chromecast port + }; - // Extract TXT records for additional metadata - let txt_records: HashMap = response - .records() - .filter_map(|r| { - if let mdns::RecordKind::TXT(ref data) = r.kind { - Some(data.clone()) - } else { - None - } - }) - .flat_map(|data| { - // data is Vec, each string is "key=value" - data.into_iter().filter_map(|s| { - let parts: Vec<&str> = s.splitn(2, '=').collect(); - if parts.len() == 2 { - Some((parts[0].to_string(), parts[1].to_string())) + // Extract port from SRV record + let port = response + .records() + .find_map(|r| { + if let mdns::RecordKind::SRV { port, .. } = r.kind { + Some(port) } else { None } }) - }) - .collect(); + .unwrap_or(8009); // Default Chromecast port - // Extract metadata from TXT records - let model = txt_records.get("md").cloned(); - let uuid = txt_records - .get("id") - .cloned() - .unwrap_or_else(|| format!("chromecast-{}-{}", host, port)); - let manufacturer = Some("Google Inc.".to_string()); + // Extract TXT records for additional metadata + let txt_records: HashMap = response + .records() + .filter_map(|r| { + if let mdns::RecordKind::TXT(ref data) = r.kind { + Some(data.clone()) + } else { + None + } + }) + .flat_map(|data| { + // data is Vec, each string is "key=value" + data.into_iter().filter_map(|s| { + let parts: Vec<&str> = s.splitn(2, '=').collect(); + if parts.len() == 2 { + Some((parts[0].to_string(), parts[1].to_string())) + } else { + None + } + }) + }) + .collect(); - // Extract friendly name from TXT record "fn" if available - // Otherwise, extract from service instance name (PTR record) - let friendly_name = txt_records - .get("fn") - .cloned() - .unwrap_or_else(|| { - // Fallback: extract from service name, removing the UUID suffix if present - service_name - .split("._googlecast._tcp.local") - .next() - .unwrap_or("Unknown Chromecast") - .split('-') - .take_while(|part| part.len() != 32) // Skip 32-char hex UUID - .collect::>() - .join("-") - .trim() - .to_string() - }); + // Extract metadata from TXT records + let model = txt_records.get("md").cloned(); + let uuid = txt_records + .get("id") + .cloned() + .unwrap_or_else(|| format!("chromecast-{}-{}", host, port)); + let manufacturer = Some("Google Inc.".to_string()); - debug!( - "Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})", - friendly_name, host, port, uuid, model - ); + // Extract friendly name from TXT record "fn" if available + // Otherwise, extract from service instance name (PTR record) + let friendly_name = txt_records + .get("fn") + .cloned() + .unwrap_or_else(|| { + // Fallback: extract from service name, removing the UUID suffix if present + service_name + .split("._googlecast._tcp.local") + .next() + .unwrap_or("Unknown Chromecast") + .split('-') + .take_while(|part| part.len() != 32) // Skip 32-char hex UUID + .collect::>() + .join("-") + .trim() + .to_string() + }); - // Create RendererInfo for the registry - let renderer_info = build_renderer_info( - &uuid, - &friendly_name, - &host, - port, - model.as_deref(), - manufacturer.as_deref(), - ); + debug!( + "Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})", + friendly_name, host, port, uuid, model + ); - Some(DeviceUpdate::RendererOnline(renderer_info)) + // Build UDN and check cache + let udn = format!("uuid:{}", uuid); + + // Pour Chromecast, on utilise un max_age par défaut car mDNS n'a pas ce concept + let default_max_age = 1800u64; // 30 minutes + + // Check cache to avoid redundant updates + if !UDNRegistry::should_fetch(self.udn_cache.clone(), &udn, default_max_age) { + debug!("Chromecast {} recently seen, skipping", udn); + return; + } + + // Create RendererInfo for the registry + let renderer_info = build_renderer_info( + &uuid, + &friendly_name, + &host, + port, + model.as_deref(), + manufacturer.as_deref(), + ); + + // Register the renderer + self.device_registry + .lock() + .expect("DeviceRegistry mutex lock failed") + .push_renderer(&renderer_info, default_max_age as u32); + } } /// Builds a `RendererInfo` structure for a Chromecast device. diff --git a/pmocontrol/src/discovery/manager.rs b/pmocontrol/src/discovery/manager.rs index da26965c..4ad726c2 100644 --- a/pmocontrol/src/discovery/manager.rs +++ b/pmocontrol/src/discovery/manager.rs @@ -1,40 +1,45 @@ -use std::{collections::HashMap, sync::{Arc, Mutex, OnceLock}, time::{Instant, SystemTime}}; - -use tracing_subscriber::registry; - - +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::{Instant, SystemTime}, +}; struct UDNSeen { max_age: u64, last_seen: Instant, } -pub struct UDNRegistry { +pub struct UDNRegistry { seen: HashMap, } impl UDNRegistry { pub fn new() -> Self { - UDNRegistry { seen: HashMap::new() } + UDNRegistry { + seen: HashMap::new(), + } } /// Returns `true` if the UDN has been seen for at least half of its lifetime - pub fn should_fetch(registry : Arc>,udn: &str, max_age: u64 ) -> bool { + pub fn should_fetch(registry: Arc>, udn: &str, max_age: u64) -> bool { let now = Instant::now(); - let mut r = registry - .lock() - .expect("UDNRegistry mutex lock failed"); + let mut r = registry.lock().expect("UDNRegistry mutex lock failed"); if let Some(seen) = r.seen.get_mut(udn) { - if now.duration_since(seen.last_seen).as_secs() > max_age/2 { + if now.duration_since(seen.last_seen).as_secs() > max_age / 2 { false } else { seen.last_seen = now; true } } else { - r.seen.insert(udn.to_string(), UDNSeen { max_age, last_seen: now }); + r.seen.insert( + udn.to_string(), + UDNSeen { + max_age, + last_seen: now, + }, + ); true } } } - diff --git a/pmocontrol/src/discovery/mod.rs b/pmocontrol/src/discovery/mod.rs index e96cf0ad..8f95c14b 100644 --- a/pmocontrol/src/discovery/mod.rs +++ b/pmocontrol/src/discovery/mod.rs @@ -1,6 +1,30 @@ +use crate::{RendererInfo, UpnpMediaServer}; + pub mod manager; pub mod upnp_discovery; pub mod upnp_provider; pub mod chromecast_discovery; +pub mod arylic; +/// Fournit les descriptions haut niveau à partir d’un endpoint découvert. +/// L’implémentation pourra, plus tard, faire un HTTP GET sur `location` +/// et parser la description pour remplir RendererInfo / MediaServerInfo. +pub trait DeviceDescriptionProvider: Send + Sync { + /// Construit un RendererInfo pour cet endpoint, ou None s’il + /// ne correspond pas à un renderer audio intéressant. + fn build_renderer_info( + &self, + udn: &str, + location: &str, + server_header: &str, + ) -> Option; + /// Construit un MediaServerInfo pour cet endpoint, ou None s’il + /// ne correspond pas à un media server (ou pas intéressant). + fn build_server_info( + &self, + udn: &str, + location: &str, + server_header: &str, + ) -> Option; +} \ No newline at end of file diff --git a/pmocontrol/src/discovery/upnp_discovery.rs b/pmocontrol/src/discovery/upnp_discovery.rs index a079ae94..d6aa9d9a 100644 --- a/pmocontrol/src/discovery/upnp_discovery.rs +++ b/pmocontrol/src/discovery/upnp_discovery.rs @@ -1,49 +1,14 @@ -use std::collections::{HashMap, HashSet}; - +use crate::{DeviceRegistry, discovery::upnp_provider::ParsedDeviceDescription}; use pmoupnp::ssdp::SsdpEvent; +use std::sync::{Arc, Mutex}; use crate::discovery::manager::UDNRegistry; -use crate::media_server::UpnpMediaServer; -use crate::model::RendererInfo; -use crate::registry::DeviceUpdate; - -/// Fournit les descriptions haut niveau à partir d’un endpoint découvert. -/// L’implémentation pourra, plus tard, faire un HTTP GET sur `location` -/// et parser la description pour remplir RendererInfo / MediaServerInfo. -pub trait DeviceDescriptionProvider: Send + Sync { - /// Construit un RendererInfo pour cet endpoint, ou None s’il - /// ne correspond pas à un renderer audio intéressant. - fn build_renderer_info( - &self, - udn: &str, - location: &str, - server_header: &str, - ) -> Option; - - /// Construit un MediaServerInfo pour cet endpoint, ou None s’il - /// ne correspond pas à un media server (ou pas intéressant). - fn build_server_info( - &self, - udn: &str, - location: &str, - server_header: &str, - ) -> Option; -} /// Gestionnaire des événements SSDP -> DeviceUpdate. -pub struct DiscoveryManager

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

DiscoveryManager

-where - P: DeviceDescriptionProvider, -{ - pub fn new(provider: P) -> Self { - Self { provider } - } - - // Dans handle_ssdp_event (upnp_discovery.rs) - fn handle_ssdp_event(&mut self, event: SsdpEvent) -> Vec { - let (alive, usn, location, max_age, server_header) = match event { - SsdpEvent::Alive { - usn, - location, - max_age, - server, - .. - } - | SsdpEvent::SearchResponse { - usn, - location, - max_age, - server, - .. - } => (true, usn, location, max_age, server), - SsdpEvent::ByeBye { usn, .. } => (false, usn, "".to_string(), 0, "".to_string()), - }; - - if let Some(udn) = extract_udn_from_usn(&usn) { - if alive { - // ✅ Check cache - if !UDNRegistry::should_fetch(&udn, max_age) { - return vec![]; // Skip, vu récemment - } - - // ✅ Fetch + parse - let info = self.provider.build_renderer_info(&location)?; - vec![DeviceUpdate::RendererOnline(info)] - } else { - self.handle_byebye(udn, nt, &mut updates); - } - } else { - return vec![]; - } - } - - fn handle_alive( - &mut self, - udn: String, - nt: String, - location: String, - server_header: String, - max_age: u32, - updates: &mut Vec, - ) { - self.update_endpoint(udn, nt, location, server_header, max_age, updates); - } - - fn handle_search_response( - &mut self, - udn: String, - st: String, - location: String, - server_header: String, - max_age: u32, - updates: &mut Vec, - ) { - self.update_endpoint(udn, st, location, server_header, max_age, updates); - } - - fn handle_byebye(&mut self, udn: String, _nt: String, updates: &mut Vec) { - if let Some(endpoint) = self.endpoints.get(&udn) { - if endpoint.seen_as_renderer { - updates.push(DeviceUpdate::RendererOfflineByUdn(udn.clone())); - } - if endpoint.seen_as_server { - updates.push(DeviceUpdate::ServerOfflineByUdn(udn)); - } - } - } - - fn update_endpoint( - &mut self, - udn: String, - device_type: String, - location: String, - server_header: String, - max_age: u32, - updates: &mut Vec, - ) { - tracing::debug!( - "SSDP update: udn={} type={} location={} max_age={}", - udn, - device_type, - location, - max_age - ); - - let endpoint = self.endpoints.entry(udn.clone()).or_insert_with({ - let udn = udn.clone(); - let location = location.clone(); - let server_header = server_header.clone(); - move || DiscoveredEndpoint::new(udn, location, server_header, max_age) - }); - - endpoint.touch(location, server_header, max_age); - endpoint.types_seen.insert(device_type); - - // Toujours tenter de classifier et envoyer un événement Online pour maintenir - // l'état à jour dans le registre (important pour les serveurs qui ré-apparaissent) - if let Some(info) = self.provider.build_renderer_info(endpoint) { - if !endpoint.seen_as_renderer { - tracing::debug!( - "Renderer classified: udn={} friendly_name={} model={}", - info.udn(), - info.friendly_name(), - info.model_name() - ); - endpoint.seen_as_renderer = true; - } - updates.push(DeviceUpdate::RendererOnline(info)); - } - - if let Some(info) = self.provider.build_server_info(endpoint) { - if !endpoint.seen_as_server { - tracing::debug!( - "Server classified: udn={} friendly_name={} model={}", - info.udn, - info.friendly_name, - info.model_name - ); - endpoint.seen_as_server = true; - } - updates.push(DeviceUpdate::ServerOnline(info)); - } - } -} fn extract_udn_from_usn(usn: &str) -> Option { let lower = usn.trim().to_ascii_lowercase(); diff --git a/pmocontrol/src/discovery/upnp_provider.rs b/pmocontrol/src/discovery/upnp_provider.rs index a9a6dfe6..bf5ce640 100644 --- a/pmocontrol/src/discovery/upnp_provider.rs +++ b/pmocontrol/src/discovery/upnp_provider.rs @@ -1,17 +1,16 @@ use std::io::BufReader; -use std::time::{Duration, Instant, SystemTime}; +use std::time::Duration; use quick_xml::{Error as XmlError, Reader, events::Event}; use thiserror::Error; -use tracing::{debug, warn}; +use tracing::{debug}; use crate::DeviceId; -use crate::arylic_tcp::detect_arylic_tcp; -use crate::avtransport_client::AvTransportClient; -use crate::discovery::upnp_discovery::DeviceDescriptionProvider; -use crate::linkplay_renderer::detect_linkplay_http; +use crate::discovery::arylic::detect_arylic_tcp; +use crate::linkplay_client::{extract_linkplay_host, fetch_status_for_host}; use crate::media_server::UpnpMediaServer; use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol}; +use crate::upnp_clients::{AvTransportClient,resolve_control_url}; use ureq::Agent; @@ -32,8 +31,11 @@ pub enum DescriptionError { /// Parsed device description, plus (optionally) AVTransport endpoint. #[derive(Debug, Default)] -struct ParsedDeviceDescription { - udn: Option, +pub struct ParsedDeviceDescription { + timeout_secs: u64, + udn: String, + location: String, + server_header: String, device_type: Option, friendly_name: Option, manufacturer: Option, @@ -75,41 +77,18 @@ struct ParsedDeviceDescription { } impl ParsedDeviceDescription { - fn require_fields(self) -> Result { - if self.device_type.is_none() { - return Err(DescriptionError::MissingField("deviceType")); - } - if self.friendly_name.is_none() { - return Err(DescriptionError::MissingField("friendlyName")); - } - if self.model_name.is_none() { - return Err(DescriptionError::MissingField("modelName")); - } - Ok(self) - } -} - -/// HTTP-based XML description provider (UPnP device description.xml) -pub struct HttpXmlDescriptionProvider { - timeout_secs: u64, -} - -impl HttpXmlDescriptionProvider { - pub fn new(timeout_secs: u64) -> Self { - Self { timeout_secs } - } /// Fetch and parse the device description.xml at endpoint.location. - fn fetch_and_parse( - &self, + pub fn new( udn: &str, location: &str, server_header: &str, - ) -> Result { + timeout_secs: u64, + ) -> Result { debug!("Fetching description for {} at {}", udn, location); let config = Agent::config_builder() - .timeout_global(Some(std::time::Duration::from_secs(self.timeout_secs))) + .timeout_global(Some(std::time::Duration::from_secs(timeout_secs))) .build(); let agent: Agent = config.into(); @@ -129,6 +108,11 @@ impl HttpXmlDescriptionProvider { let mut buf = Vec::new(); let mut parsed = ParsedDeviceDescription::default(); + parsed.timeout_secs=timeout_secs; + parsed.location = location.to_string(); + parsed.udn = udn.to_string(); + parsed.server_header = server_header.to_string(); + let mut in_device = false; let mut in_service = false; let mut current_tag: Option = None; @@ -331,7 +315,7 @@ impl HttpXmlDescriptionProvider { match tag.as_str() { "UDN" => { - parsed.udn = Some(text); + parsed.udn = text; } "deviceType" => { parsed.device_type = Some(text); @@ -370,181 +354,274 @@ impl HttpXmlDescriptionProvider { parsed.require_fields() } - fn build_renderer( + pub fn build_renderer( &self, - udn: &str, - location: &str, - server_header: &str, - parsed: &ParsedDeviceDescription, ) -> Option { - let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase(); + let device_type = self.device_type.as_ref()?.to_ascii_lowercase(); if !device_type.contains("urn:schemas-upnp-org:device:mediarenderer:") && !device_type.contains("urn:av-openhome-org:device:mediarenderer:") && !device_type.contains("urn:av-openhome-org:device:source:") { debug!( "build_renderer: ignoring deviceType for {}: {}", - udn, device_type + self.udn, device_type ); return None; } - let raw_udn = parsed.udn.as_deref().unwrap_or_else(|| udn); - let udn = raw_udn.to_ascii_lowercase(); - let mut caps = detect_renderer_capabilities(&parsed.service_types); - if detect_linkplay_http(location, Duration::from_secs(self.timeout_secs.max(1))) { + let udn = self.udn.to_ascii_lowercase(); + let mut caps = detect_renderer_capabilities(&self.service_types); + if detect_linkplay_http(&self.location, Duration::from_secs(self.timeout_secs.max(1))) { caps.has_linkplay_http = true; } - if detect_arylic_tcp(location, Duration::from_secs(self.timeout_secs.max(1))) { + if detect_arylic_tcp(&self.location, Duration::from_secs(self.timeout_secs.max(1))) { caps.has_arylic_tcp = true; } let protocol = detect_renderer_protocol(&caps); - let now = Instant::now(); Some(RendererInfo::make( DeviceId(udn.clone()), udn, - parsed.friendly_name.clone().unwrap_or_default(), - parsed.model_name.clone().unwrap_or_default(), - parsed.manufacturer.clone().unwrap_or_default(), + self.friendly_name.clone().unwrap_or_default(), + self.model_name.clone().unwrap_or_default(), + self.manufacturer.clone().unwrap_or_default(), protocol, caps, - location.to_string(), - endpoint.server_header.clone(), - // online: true, - // last_seen: now, - // max_age: endpoint.max_age, - parsed.avtransport_service_type.clone(), - parsed + self.location.clone(), + self.server_header.clone(), + self.avtransport_service_type.clone(), + self .avtransport_control_url .as_ref() - .map(|ctrl| resolve_control_url(location, ctrl)), - parsed.rendering_control_service_type.clone(), - parsed + .map(|ctrl| resolve_control_url(&self.location, ctrl)), + self.rendering_control_service_type.clone(), + self .rendering_control_control_url .as_ref() - .map(|ctrl| resolve_control_url(location, ctrl)), - parsed.connection_manager_service_type.clone(), - parsed + .map(|ctrl| resolve_control_url(&self.location, ctrl)), + self.connection_manager_service_type.clone(), + self .connection_manager_control_url .as_ref() - .map(|ctrl| resolve_control_url(location, ctrl)), - parsed.oh_playlist_service_type.clone(), - parsed + .map(|ctrl| resolve_control_url(&self.location, ctrl)), + self.oh_playlist_service_type.clone(), + self .oh_playlist_control_url .as_ref() - .map(|ctrl| resolve_control_url(location, ctrl)), - parsed + .map(|ctrl| resolve_control_url(&self.location, ctrl)), + self .oh_playlist_event_sub_url .as_ref() - .map(|url| resolve_control_url(location, url)), - parsed.oh_info_service_type.clone(), - parsed + .map(|url| resolve_control_url(&self.location, url)), + self.oh_info_service_type.clone(), + self .oh_info_control_url .as_ref() - .map(|ctrl| resolve_control_url(location, ctrl)), - parsed + .map(|ctrl| resolve_control_url(&self.location, ctrl)), + self .oh_info_event_sub_url .as_ref() - .map(|url| resolve_control_url(location, url)), - parsed.oh_time_service_type.clone(), - parsed + .map(|url| resolve_control_url(&self.location, url)), + self.oh_time_service_type.clone(), + self .oh_time_control_url .as_ref() - .map(|ctrl| resolve_control_url(location, ctrl)), - parsed + .map(|ctrl| resolve_control_url(&self.location, ctrl)), + self .oh_time_event_sub_url .as_ref() - .map(|url| resolve_control_url(location, url)), - parsed.oh_volume_service_type.clone(), - parsed + .map(|url| resolve_control_url(&self.location, url)), + self.oh_volume_service_type.clone(), + self .oh_volume_control_url .as_ref() - .map(|ctrl| resolve_control_url(location, ctrl)), - parsed.oh_radio_service_type.clone(), - parsed + .map(|ctrl| resolve_control_url(&self.location, ctrl)), + self.oh_radio_service_type.clone(), + self .oh_radio_control_url .as_ref() - .map(|ctrl| resolve_control_url(location, ctrl)), - parsed.oh_product_service_type.clone(), - parsed + .map(|ctrl| resolve_control_url(&self.location, ctrl)), + self.oh_product_service_type.clone(), + self .oh_product_control_url .as_ref() - .map(|ctrl| resolve_control_url(location, ctrl)), + .map(|ctrl| resolve_control_url(&self.location, ctrl)), )) } - fn build_server( + pub fn build_server( &self, - endpoint: &DiscoveredEndpoint, - parsed: &ParsedDeviceDescription, ) -> Option { - let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase(); + let device_type = self.device_type.as_ref()?.to_ascii_lowercase(); if !device_type.contains("urn:schemas-upnp-org:device:mediaserver:") { return None; } - let raw_udn = parsed - .udn - .as_deref() - .unwrap_or_else(|| endpoint.udn.as_str()); - let udn = raw_udn.to_ascii_lowercase(); - let has_content_directory = parsed.service_types.iter().any(|st| { + let udn = self.udn.to_ascii_lowercase(); + let has_content_directory = self.service_types.iter().any(|st| { st.to_ascii_lowercase() .contains("urn:schemas-upnp-org:service:contentdirectory:") }); - let now = Instant::now(); - let content_directory_control_url = parsed + let content_directory_control_url = self .content_directory_control_url .as_ref() - .map(|ctrl| resolve_control_url(&endpoint.location, ctrl)); + .map(|ctrl| resolve_control_url(&self.location, ctrl)); - Some(UpnpMediaServer { - id: DeviceId(udn.clone()), + Some(UpnpMediaServer::new( + DeviceId(udn.clone()), udn, - friendly_name: parsed.friendly_name.clone().unwrap_or_default(), - model_name: parsed.model_name.clone().unwrap_or_default(), - manufacturer: parsed.manufacturer.clone().unwrap_or_default(), - location: endpoint.location.clone(), - server_header: endpoint.server_header.clone(), - online: true, - last_seen: now, - max_age: endpoint.max_age, + self.friendly_name.clone().unwrap_or_default(), + self.model_name.clone().unwrap_or_default(), + self.manufacturer.clone().unwrap_or_default(), + self.location.clone(), + self.server_header.clone(), has_content_directory, - content_directory_service_type: parsed.content_directory_service_type.clone(), + self.content_directory_service_type.clone(), content_directory_control_url, - }) + )) + } - /// New helper: build an AvTransportClient directly from a discovered endpoint. - /// /// Returns Ok(Some(client)) if an AVTransport service with a controlURL is present, /// Ok(None) if no AVTransport service was found. pub fn build_avtransport_client( &self, - endpoint: &DiscoveredEndpoint, ) -> Result, DescriptionError> { - let parsed = self.fetch_and_parse(endpoint)?; - - let service_type = match &parsed.avtransport_service_type { + let service_type = match &self.avtransport_service_type { Some(st) => st.clone(), None => return Ok(None), }; - let raw_control = match &parsed.avtransport_control_url { + let raw_control = match &self.avtransport_control_url { Some(ctrl) => ctrl.clone(), None => return Ok(None), }; - let control_url = resolve_control_url(&endpoint.location, &raw_control); + let control_url = resolve_control_url(&self.location, &raw_control); debug!( "AVTransport client for {}: service_type={} control_url={}", - endpoint.udn, service_type, control_url + &self.udn, service_type, control_url ); Ok(Some(AvTransportClient::new(control_url, service_type))) } + + fn require_fields(self) -> Result { + if self.device_type.is_none() { + return Err(DescriptionError::MissingField("deviceType")); + } + if self.friendly_name.is_none() { + return Err(DescriptionError::MissingField("friendlyName")); + } + if self.model_name.is_none() { + return Err(DescriptionError::MissingField("modelName")); + } + Ok(self) + } + + pub fn udn(&self) -> Option { + Some(self.udn.clone()) + } + pub fn device_type(&self) -> Option { + self.device_type.clone() + } + pub fn friendly_name(&self) -> Option { + self.friendly_name.clone() + } + pub fn manufacturer(&self) -> Option { + self.manufacturer.clone() + } + pub fn model_name(&self) -> Option { + self.model_name.clone() + } + pub fn service_types(&self) -> Vec { + self.service_types.clone() + } + + // New: AVTransport endpoint (if present in serviceList) + pub fn avtransport_service_type(&self) -> Option { + self.avtransport_service_type.clone() + } + pub fn avtransport_control_url(&self) -> Option { + self.avtransport_control_url.clone() + } + + // RenderingControl endpoint (if present in serviceList) + pub fn rendering_control_service_type(&self) -> Option { + self.rendering_control_service_type.clone() + } + pub fn rendering_control_control_url(&self) -> Option { + self.rendering_control_control_url.clone() + } + + // ConnectionManager endpoint (if present in serviceList) + pub fn connection_manager_service_type(&self) -> Option { + self.connection_manager_service_type.clone() + } + pub fn connection_manager_control_url(&self) -> Option { + self.connection_manager_control_url.clone() + } + + // ContentDirectory endpoint (if present in serviceList) + pub fn content_directory_service_type(&self) -> Option { + self.content_directory_service_type.clone() + } + pub fn content_directory_control_url(&self) -> Option { + self.content_directory_control_url.clone() + } + + // OpenHome endpoints (if present in serviceList) + pub fn oh_playlist_service_type(&self) -> Option { + self.oh_playlist_service_type.clone() + } + pub fn oh_playlist_control_url(&self) -> Option { + self.oh_playlist_control_url.clone() + } + pub fn oh_playlist_event_sub_url(&self) -> Option { + self.oh_playlist_event_sub_url.clone() + } + pub fn oh_info_service_type(&self) -> Option { + self.oh_info_service_type.clone() + } + pub fn oh_info_control_url(&self) -> Option { + self.oh_info_control_url.clone() + } + pub fn oh_info_event_sub_url(&self) -> Option { + self.oh_info_event_sub_url.clone() + } + pub fn oh_time_service_type(&self) -> Option { + self.oh_time_service_type.clone() + } + pub fn oh_time_control_url(&self) -> Option { + self.oh_time_control_url.clone() + } + pub fn oh_time_event_sub_url(&self) -> Option { + self.oh_time_event_sub_url.clone() + } + pub fn oh_volume_service_type(&self) -> Option { + self.oh_volume_service_type.clone() + } + pub fn oh_volume_control_url(&self) -> Option { + self.oh_volume_control_url.clone() + } + pub fn oh_radio_service_type(&self) -> Option { + self.oh_radio_service_type.clone() + } + pub fn oh_radio_control_url(&self) -> Option { + self.oh_radio_control_url.clone() + } + pub fn oh_product_service_type(&self) -> Option { + self.oh_product_service_type.clone() + } + pub fn oh_product_control_url(&self) -> Option { + self.oh_product_control_url.clone() + } +} + +/// HTTP-based XML description provider (UPnP device description.xml) +pub struct HttpXmlDescriptionProvider { + timeout_secs: u64, } // --- capabilities detection unchanged --- @@ -601,91 +678,23 @@ fn detect_renderer_protocol(caps: &RendererCapabilities) -> RendererProtocol { } } -/// Resolve a possibly relative controlURL against the description URL. -/// -/// - If `control_url` is already absolute (starts with http:// or https://), it is returned as-is. -/// - Otherwise, it is resolved against the scheme://host:port of `description_url`. -pub(crate) fn resolve_control_url(description_url: &str, control_url: &str) -> String { - if control_url.starts_with("http://") || control_url.starts_with("https://") { - return control_url.to_string(); - } - // Extract "scheme://host[:port]" from description_url - if let Some((scheme, rest)) = description_url.split_once("://") { - if let Some(pos) = rest.find('/') { - let authority = &rest[..pos]; - let base = format!("{}://{}", scheme, authority); - if control_url.starts_with('/') { - return format!("{}{}", base, control_url); - } else { - return format!("{}/{}", base, control_url); - } + +/// Detect whether a renderer exposes the LinkPlay HTTP API. +pub fn detect_linkplay_http(location: &str, timeout: Duration) -> bool { + let Some(host) = extract_linkplay_host(location) else { + return false; + }; + + match fetch_status_for_host(&host, timeout) { + Ok(_) => true, + Err(err) => { + debug!( + "LinkPlay detection failed for {} (host={}): {}", + location, host, err + ); + false } } - - // Fallback: just return the raw control_url if we cannot parse - control_url.to_string() -} - -#[cfg(test)] -mod tests { - use super::resolve_control_url; - - #[test] - fn resolves_relative_path_against_description() { - let base = "http://192.0.2.10:49152/device.xml"; - let control = "/upnp/control/playlist"; - let resolved = resolve_control_url(base, control); - assert_eq!(resolved, "http://192.0.2.10:49152/upnp/control/playlist"); - } - - #[test] - fn leaves_absolute_url_untouched() { - let url = "http://renderer.local:1400/MediaRenderer/Control"; - let resolved = resolve_control_url("http://example.invalid/device.xml", url); - assert_eq!(resolved, url); - } -} - -impl DeviceDescriptionProvider for HttpXmlDescriptionProvider { - fn build_renderer_info(&self, endpoint: &DiscoveredEndpoint) -> Option { - match self.fetch_and_parse(endpoint) { - Ok(parsed) => { - let device_type = parsed.device_type.as_deref().unwrap_or("unknown"); - debug!( - "Renderer description OK for {} at {} (deviceType={})", - endpoint.udn, endpoint.location, device_type - ); - self.build_renderer(endpoint, &parsed) - } - Err(err) => { - warn!( - "Failed to fetch/parse renderer description for {} at {}: {}", - endpoint.udn, endpoint.location, err - ); - None - } - } - } - - fn build_server_info(&self, endpoint: &DiscoveredEndpoint) -> Option { - match self.fetch_and_parse(endpoint) { - Ok(parsed) => { - let device_type = parsed.device_type.as_deref().unwrap_or("unknown"); - debug!( - "Server description OK for {} at {} (deviceType={})", - endpoint.udn, endpoint.location, device_type - ); - self.build_server(endpoint, &parsed) - } - Err(err) => { - warn!( - "Failed to fetch/parse server description for {} at {}: {}", - endpoint.udn, endpoint.location, err - ); - None - } - } - } -} +} \ No newline at end of file diff --git a/pmocontrol/src/errors.rs b/pmocontrol/src/errors.rs index 7f7a82d3..6492e51c 100644 --- a/pmocontrol/src/errors.rs +++ b/pmocontrol/src/errors.rs @@ -43,6 +43,8 @@ pub enum ControlPointError { MediaServerError(String), #[error("Queue Error: {0}")] QueueError(String), + #[error("Invalid time format: {0}")] + InvalidTimeFormat(String), } impl ControlPointError { diff --git a/pmocontrol/src/lib.rs b/pmocontrol/src/lib.rs index 7f990629..3a9ab60d 100644 --- a/pmocontrol/src/lib.rs +++ b/pmocontrol/src/lib.rs @@ -3,28 +3,20 @@ mod media_server_events; pub mod queue; pub mod discovery; -pub mod arylic_tcp; -pub mod avtransport_client; -pub mod capabilities; -pub mod chromecast_renderer; -pub mod connection_manager_client; +pub mod upnp_clients; +pub mod arylic_client; +pub mod linkplay_client; pub mod control_point; pub mod errors; -pub mod linkplay_renderer; pub mod media_server; pub mod model; pub mod music_renderer; -pub mod openhome; -pub mod openhome_client; -pub mod openhome_playlist; -pub mod openhome_renderer; -pub mod provider; pub mod registry; -pub mod rendering_control_client; pub mod soap_client; -pub mod upnp_renderer; pub mod online; pub mod identity; +pub mod linkplay_utils; + // pmoserver extension (optional) @@ -40,30 +32,16 @@ use std::time::Duration; #[cfg(feature = "pmoserver")] pub use pmoserver_ext::ControlPointExt; -pub use arylic_tcp::ArylicTcpRenderer; -pub use avtransport_client::{AvTransportClient, PositionInfo, TransportInfo}; -pub use capabilities::{ - PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, - VolumeControl, -}; -pub use chromecast_renderer::ChromecastRenderer; -pub use connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo}; + pub use control_point::{ControlPoint, PlaylistBinding}; -pub use linkplay_renderer::LinkPlayRenderer; pub use media_server::{ MediaBrowser, MediaEntry, MediaResource, UpnpMediaServer, }; -pub use music_renderer::MusicRendererBackend; -pub use openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; -pub use openhome_renderer::OpenHomeRenderer; pub use queue::{EnqueueMode, PlaybackItem, QueueSnapshot}; -pub use rendering_control_client::RenderingControlClient; -pub use upnp_renderer::UpnpRenderer; pub use model::{ MediaServerEvent, RendererCapabilities, RendererEvent, RendererInfo, RendererProtocol, }; -pub use provider::HttpXmlDescriptionProvider; pub use registry::{DeviceRegistry, DeviceUpdate}; pub use soap_client::invoke_upnp_action; diff --git a/pmocontrol/src/linkplay_client/mod.rs b/pmocontrol/src/linkplay_client/mod.rs new file mode 100644 index 00000000..27bf9913 --- /dev/null +++ b/pmocontrol/src/linkplay_client/mod.rs @@ -0,0 +1,156 @@ +use std::time::Duration; + +use serde::Deserialize; +use tracing::debug; +use ureq::Agent; + +use crate::{errors::ControlPointError, model::PlaybackState, music_renderer::{PlaybackPositionInfo, time_utils::{format_hhmmss, ms_to_seconds}}}; + +const STATUS_COMMAND: &str = "getPlayerStatus"; + +/// Raw response from LinkPlay getPlayerStatus API +#[derive(Debug, Deserialize)] +struct LinkPlayStatusRaw { + status: String, + curpos: String, + totlen: String, + vol: String, + mute: String, + #[serde(default)] + plicurr: Option, +} + +#[derive(Clone, Debug)] +pub struct LinkPlayStatus { + state_raw: String, + pub curpos_ms: u64, + pub totlen_ms: u64, + pub track_index: Option, + pub volume: u16, + pub mute: bool, +} + +impl LinkPlayStatus { + pub fn playback_state(&self) -> PlaybackState { + match self.state_raw.as_str() { + "play" => PlaybackState::Playing, + "pause" => PlaybackState::Paused, + "stop" => PlaybackState::Stopped, + "load" => PlaybackState::Transitioning, + other => PlaybackState::Unknown(other.to_string()), + } + } + + pub fn position_info(&self) -> PlaybackPositionInfo { + PlaybackPositionInfo { + track: self.track_index, + rel_time: Some(format_hhmmss(ms_to_seconds(self.curpos_ms))), + abs_time: None, + track_duration: if self.totlen_ms > 0 { + Some(format_hhmmss(ms_to_seconds(self.totlen_ms))) + } else { + None + }, + track_metadata: None, + track_uri: None, + } + } +} + +/// Extract the IP/host component from a LOCATION URL. +pub fn extract_linkplay_host(location: &str) -> Option { + let (_, rest) = location.split_once("://")?; + let authority = rest.split('/').next().unwrap_or(rest); + let without_auth = authority.split('@').last().unwrap_or(authority); + + if without_auth.starts_with('[') { + let end = without_auth.find(']')?; + let host = &without_auth[1..end]; + if host.is_empty() { + None + } else { + Some(host.to_string()) + } + } else { + let host = without_auth.split(':').next().unwrap_or(""); + if host.is_empty() { + None + } else { + Some(host.to_string()) + } + } +} + +pub fn build_agent(timeout: Duration) -> Agent { + Agent::config_builder() + .timeout_global(Some(timeout)) + .build() + .into() +} + +pub fn percent_encode(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for b in input.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(b as char); + } + _ => out.push_str(&format!("%{:02X}", b)), + } + } + out +} + +pub fn fetch_status_for_host(host: &str, timeout: Duration) -> Result { + let url = format!("http://{}/httpapi.asp?command={}", host, STATUS_COMMAND); + let mut response = build_agent(timeout) + .get(&url) + .call() + .map_err(|_| ControlPointError::ArilycTcpError(format!("HTTP request failed for LinkPlay status on {}", host)))?; + + let body = response + .body_mut() + .read_to_string() + .map_err(|e| ControlPointError::ArilycTcpError(format!("Failed to read LinkPlay status body : {}",e)))?; + + parse_linkplay_status(&body) +} + +fn parse_linkplay_status(body: &str) -> Result { + let raw: LinkPlayStatusRaw = serde_json::from_str(body) + .map_err(|e| ControlPointError::LinkPlayError(format!("Failed to parse LinkPlay status JSON: {}", e)))?; + + let curpos_ms = raw.curpos.parse::() + .map_err(|_| ControlPointError::LinkPlayError(format!("Invalid curpos value: {}", raw.curpos)))?; + + let totlen_ms = raw.totlen.parse::() + .map_err(|_| ControlPointError::LinkPlayError(format!("Invalid totlen value: {}", raw.totlen)))?; + + let volume = raw.vol.parse::() + .map_err(|_| ControlPointError::LinkPlayError(format!("Invalid vol value: {}", raw.vol)))? + .min(100); + + let mute = match raw.mute.as_str() { + "1" => true, + "0" => false, + other => { + return Err(ControlPointError::LinkPlayError(format!("Invalid mute value: {}", other))); + } + }; + + let track_index = raw.plicurr + .and_then(|s| s.parse::().ok()) + .filter(|idx| *idx > 0); + + Ok(LinkPlayStatus { + state_raw: raw.status, + curpos_ms, + totlen_ms, + track_index, + volume, + mute, + }) +} + + + diff --git a/pmocontrol/src/linkplay_renderer.rs b/pmocontrol/src/linkplay_renderer.rs deleted file mode 100644 index d47e813b..00000000 --- a/pmocontrol/src/linkplay_renderer.rs +++ /dev/null @@ -1,466 +0,0 @@ -use std::char; -use std::collections::HashMap; -use std::fmt; -use std::time::Duration; - -use anyhow::{Context, Result, anyhow}; -use tracing::debug; -use ureq::Agent; - -use crate::DeviceIdentity; -use crate::capabilities::{ - PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, - VolumeControl, -}; -use crate::errors::ControlPointError; -use crate::model::{RendererInfo}; - -const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3; -const STATUS_COMMAND: &str = "getPlayerStatus"; - -/// Renderer backend for devices exposing the LinkPlay HTTP API. -#[derive(Clone)] -pub struct LinkPlayRenderer { - host: String, - timeout: Duration, -} - -impl fmt::Debug for LinkPlayRenderer { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("LinkPlayRenderer") - .field("host", &self.host) - .finish() - } -} - -impl LinkPlayRenderer { - /// Build a LinkPlay backend from a registry snapshot. - pub fn from_renderer_info(info: RendererInfo) -> Result { - let host = extract_linkplay_host(&info.location()) - .ok_or_else(|| ControlPointError::LinkPlayError(format!("Renderer {} has no valid LOCATION host", info.udn())))?; - - Ok(Self { - host, - timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS), - }) - } - - fn agent(&self) -> Agent { - build_agent(self.timeout) - } - - fn send_player_command(&self, command: &str) -> Result<(), ControlPointError> { - let url = format!( - "http://{}/httpapi.asp?command=setPlayerCmd:{}", - self.host, command - ); - self.agent() - .get(&url) - .call() - .map_err(|_| ControlPointError::ArilycTcpError(format!("LinkPlay command {} failed for {}", command, self.host)))?; - Ok(()) - } - - fn fetch_status(&self) -> Result { - fetch_status_for_host(&self.host, self.timeout) - } -} - -impl TransportControl for LinkPlayRenderer { - fn play_uri(&self, uri: &str, _meta: &str) -> Result<(), ControlPointError> { - let encoded = percent_encode(uri); - self.send_player_command(&format!("play:{}", encoded)) - } - - fn play(&self) -> Result<(), ControlPointError> { - self.send_player_command("resume") - } - - fn pause(&self) -> Result<(), ControlPointError> { - self.send_player_command("pause") - } - - fn stop(&self) -> Result<(), ControlPointError> { - self.send_player_command("stop") - } - - fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { - let secs = parse_hhmmss_to_secs(hhmmss) - .ok_or_else(|| ControlPointError::LinkPlayError(format!("Invalid seek position format: {}", hhmmss)))?; - self.send_player_command(&format!("seek:{}", secs)) - } -} - -impl VolumeControl for LinkPlayRenderer { - fn volume(&self) -> Result { - Ok(self.fetch_status()?.volume) - } - - fn set_volume(&self, v: u16) -> Result<(), ControlPointError> { - let value = v.min(100); - self.send_player_command(&format!("vol:{}", value)) - } - - fn mute(&self) -> Result { - Ok(self.fetch_status()?.mute) - } - - fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { - self.send_player_command(if m { "mute:1" } else { "mute:0" }) - } -} - -impl PlaybackStatus for LinkPlayRenderer { - fn playback_state(&self) -> Result { - Ok(self.fetch_status()?.playback_state()) - } -} - -impl PlaybackPosition for LinkPlayRenderer { - fn playback_position(&self) -> Result { - Ok(self.fetch_status()?.position_info()) - } -} - -/// Detect whether a renderer exposes the LinkPlay HTTP API. -pub fn detect_linkplay_http(location: &str, timeout: Duration) -> bool { - let Some(host) = extract_linkplay_host(location) else { - return false; - }; - - match fetch_status_for_host(&host, timeout) { - Ok(_) => true, - Err(err) => { - debug!( - "LinkPlay detection failed for {} (host={}): {}", - location, host, err - ); - false - } - } -} - -/// Extract the IP/host component from a LOCATION URL. -pub fn extract_linkplay_host(location: &str) -> Option { - let (_, rest) = location.split_once("://")?; - let authority = rest.split('/').next().unwrap_or(rest); - let without_auth = authority.split('@').last().unwrap_or(authority); - - if without_auth.starts_with('[') { - let end = without_auth.find(']')?; - let host = &without_auth[1..end]; - if host.is_empty() { - None - } else { - Some(host.to_string()) - } - } else { - let host = without_auth.split(':').next().unwrap_or(""); - if host.is_empty() { - None - } else { - Some(host.to_string()) - } - } -} - -fn fetch_status_for_host(host: &str, timeout: Duration) -> Result { - let url = format!("http://{}/httpapi.asp?command={}", host, STATUS_COMMAND); - let mut response = build_agent(timeout) - .get(&url) - .call() - .map_err(|_| ControlPointError::ArilycTcpError(format!("HTTP request failed for LinkPlay status on {}", host)))?; - - let body = response - .body_mut() - .read_to_string() - .map_err(|e| ControlPointError::ArilycTcpError(format!("Failed to read LinkPlay status body : {}",e)))?; - - parse_linkplay_status(&body) -} - -fn build_agent(timeout: Duration) -> Agent { - Agent::config_builder() - .timeout_global(Some(timeout)) - .build() - .into() -} - -#[derive(Clone, Debug)] -struct LinkPlayStatus { - state_raw: String, - curpos_ms: u64, - totlen_ms: u64, - track_index: Option, - volume: u16, - mute: bool, -} - -impl LinkPlayStatus { - fn playback_state(&self) -> PlaybackState { - match self.state_raw.as_str() { - "play" => PlaybackState::Playing, - "pause" => PlaybackState::Paused, - "stop" => PlaybackState::Stopped, - "load" => PlaybackState::Transitioning, - other => PlaybackState::Unknown(other.to_string()), - } - } - - fn position_info(&self) -> PlaybackPositionInfo { - PlaybackPositionInfo { - track: self.track_index, - rel_time: Some(format_hms(self.curpos_ms / 1000)), - abs_time: None, - track_duration: if self.totlen_ms > 0 { - Some(format_hms(self.totlen_ms / 1000)) - } else { - None - }, - track_metadata: None, - track_uri: None, - } - } -} - -fn parse_linkplay_status(body: &str) -> Result { - let mut map = parse_flat_json(body)?; - - let state_raw = map - .remove("status") - .ok_or_else(|| ControlPointError::ArilycTcpError(format!("LinkPlay status missing `status` field")))?; - - let curpos_ms = parse_u64_field(&map, "curpos")?; - let totlen_ms = parse_u64_field(&map, "totlen")?; - let volume = parse_u16_field(&map, "vol")?; - let mute = match map.get("mute").map(|s| s.as_str()) { - Some("1") => true, - Some("0") => false, - Some(other) => { - return Err(ControlPointError::ArilycTcpError(format!("Invalid LinkPlay mute value: {}", other))); - } - None => return Err(ControlPointError::arilyc_tcp_error("LinkPlay status missing `mute` field")), - }; - - let track_index = map - .get("plicurr") - .and_then(|s| s.parse::().ok()) - .filter(|idx| *idx > 0); - - Ok(LinkPlayStatus { - state_raw, - curpos_ms, - totlen_ms, - track_index, - volume, - mute, - }) -} - -fn parse_u64_field(map: &HashMap, key: &str) -> Result { - let raw = map - .get(key) - .ok_or_else(|| ControlPointError::ArilycTcpError(format!("LinkPlay status missing `{}` field", key)))?; - raw.parse::() - .map_err(|_| ControlPointError::LinkPlayError(format!("Invalid `{}` value: {}", key, raw))) -} - -fn parse_u16_field(map: &HashMap, key: &str) -> Result { - let raw = map - .get(key) - .ok_or_else(|| ControlPointError::ArilycTcpError(format!("LinkPlay status missing `{}` field", key)))?; - let value = raw - .parse::() - .map_err(|_| ControlPointError::LinkPlayError(format!("Invalid `{}` value: {}", key, raw)))?; - Ok(value.min(100)) -} - -pub(crate) fn parse_flat_json(input: &str) -> Result, ControlPointError> { - let mut chars = input.chars().peekable(); - skip_ws(&mut chars); - if chars.next() != Some('{') { - return Err(ControlPointError::ArilycTcpError(format!("LinkPlay status is not a JSON object"))); - } - - let mut map = HashMap::new(); - loop { - skip_ws(&mut chars); - match chars.peek() { - Some('}') => { - chars.next(); - break; - } - Some(_) => {} - None => return Err(ControlPointError::ArilycTcpError(format!("Unexpected end of JSON object"))), - } - - let key = parse_json_string(&mut chars)?; - skip_ws(&mut chars); - expect_char(&mut chars, ':')?; - skip_ws(&mut chars); - let value = parse_json_value(&mut chars)?; - map.insert(key, value); - skip_ws(&mut chars); - - match chars.peek() { - Some(',') => { - chars.next(); - continue; - } - Some('}') => { - chars.next(); - break; - } - Some(other) => { - return Err(ControlPointError::ArilycTcpError(format!( - "Unexpected character '{}' while parsing JSON", - other - ))); - } - None => return Err(ControlPointError::ArilycTcpError(format!("Unexpected end of JSON while parsing fields"))), - } - } - - Ok(map) -} - -fn parse_json_value(chars: &mut std::iter::Peekable>) -> Result { - match chars.peek() { - Some('"') => parse_json_string(chars), - Some(ch) if ch.is_ascii_digit() || *ch == '-' => parse_json_number(chars), - Some('t') => { - expect_literal(chars, "true")?; - Ok("true".to_string()) - } - Some('f') => { - expect_literal(chars, "false")?; - Ok("false".to_string()) - } - _ => Err(ControlPointError::ArilycTcpError(format!("Unsupported JSON value in LinkPlay status"))), - } -} - -fn parse_json_string(chars: &mut std::iter::Peekable>) -> Result { - if chars.next() != Some('"') { - return Err(ControlPointError::ArilycTcpError(format!("Expected string"))); - } - - let mut out = String::new(); - while let Some(ch) = chars.next() { - match ch { - '"' => return Ok(out), - '\\' => { - let escaped = chars.next().ok_or_else(|| ControlPointError::ArilycTcpError(format!("Invalid escape")))?; - match escaped { - '"' => out.push('"'), - '\\' => out.push('\\'), - '/' => out.push('/'), - 'b' => out.push('\u{0008}'), - 'f' => out.push('\u{000C}'), - 'n' => out.push('\n'), - 'r' => out.push('\r'), - 't' => out.push('\t'), - 'u' => { - let mut hex = String::with_capacity(4); - for _ in 0..4 { - let h = chars.next().ok_or_else(|| ControlPointError::ArilycTcpError(format!("Invalid \\u escape")))?; - hex.push(h); - } - let code = u16::from_str_radix(&hex, 16) - .map_err(|e| ControlPointError::ArilycTcpError(format!("Invalid unicode escape: {}", hex)))?; - if let Some(c) = char::from_u32(code as u32) { - out.push(c); - } else { - return Err(ControlPointError::ArilycTcpError(format!("Invalid unicode code point: {}", code))); - } - } - other => return Err(ControlPointError::ArilycTcpError(format!("Unsupported escape: {}", other))), - } - } - other => out.push(other), - } - } - - Err(ControlPointError::ArilycTcpError(format!("Unterminated JSON string"))) -} - -fn parse_json_number(chars: &mut std::iter::Peekable>) -> Result { - let mut out = String::new(); - - if matches!(chars.peek(), Some('-')) { - out.push('-'); - chars.next(); - } - - while let Some(ch) = chars.peek() { - if ch.is_ascii_digit() || *ch == '.' { - out.push(*ch); - chars.next(); - } else { - break; - } - } - - if out.is_empty() || out == "-" { - return Err(ControlPointError::ArilycTcpError(format!("Invalid number"))); - } - - Ok(out) -} - -fn expect_literal( - chars: &mut std::iter::Peekable>, - literal: &str, -) -> Result<(), ControlPointError> { - for expected in literal.chars() { - match chars.next() { - Some(ch) if ch == expected => {} - _ => return Err(ControlPointError::ArilycTcpError(format!("Invalid literal while parsing JSON"))), - } - } - Ok(()) -} - -fn expect_char(chars: &mut std::iter::Peekable>, expected: char) -> Result<(), ControlPointError> { - match chars.next() { - Some(ch) if ch == expected => Ok(()), - _ => Err(ControlPointError::ArilycTcpError(format!("Missing '{}' while parsing JSON", expected))), - } -} - -fn skip_ws(chars: &mut std::iter::Peekable>) { - while matches!(chars.peek(), Some(ch) if ch.is_whitespace()) { - chars.next(); - } -} - -fn percent_encode(input: &str) -> String { - let mut out = String::with_capacity(input.len()); - for b in input.bytes() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { - out.push(b as char); - } - _ => out.push_str(&format!("%{:02X}", b)), - } - } - out -} - -fn parse_hhmmss_to_secs(s: &str) -> Option { - let parts: Vec<_> = s.split(':').collect(); - if parts.len() != 3 { - return None; - } - let h: u64 = parts[0].parse().ok()?; - let m: u64 = parts[1].parse().ok()?; - let sec: u64 = parts[2].parse().ok()?; - Some(h * 3600 + m * 60 + sec) -} - -fn format_hms(secs: u64) -> String { - let h = secs / 3600; - let m = (secs % 3600) / 60; - let s = secs % 60; - format!("{:02}:{:02}:{:02}", h, m, s) -} diff --git a/pmocontrol/src/linkplay_utils.rs b/pmocontrol/src/linkplay_utils.rs new file mode 100644 index 00000000..e69de29b diff --git a/pmocontrol/src/media_server.rs b/pmocontrol/src/media_server.rs index 0eea73c5..6bdb0d42 100644 --- a/pmocontrol/src/media_server.rs +++ b/pmocontrol/src/media_server.rs @@ -10,9 +10,9 @@ use xmltree::{Element, XMLNode}; use crate::errors::ControlPointError; use crate::model::TrackMetadata; use crate::online::{DeviceConnectionState, DeviceOnline}; -use crate::queue::backend::PlaybackItem; +use crate::queue::PlaybackItem; use crate::soap_client::{SoapCallResult, invoke_upnp_action_with_timeout}; -use crate::{DEFAULT_HTTP_TIMEOUT, DeviceId, DeviceIdentity}; +use crate::{DEFAULT_HTTP_TIMEOUT, DeviceId, DeviceIdentity, RendererInfo}; /// Snapshot of a media server discovered through UPnP SSDP. #[derive(Clone, Debug)] @@ -31,6 +31,33 @@ pub struct UpnpMediaServer { } impl UpnpMediaServer { + pub fn new( + id: DeviceId, + udn: String, + friendly_name: String, + model_name: String, + manufacturer: String, + location: String, + server_header: String, + has_content_directory: bool, + content_directory_service_type: Option, + content_directory_control_url: Option, + ) -> Self { + Self { + id, + udn, + friendly_name, + model_name, + manufacturer, + location, + server_header, + has_content_directory, + content_directory_service_type, + content_directory_control_url, + connection: DeviceConnectionState::make(), + } + } + pub fn make( id: DeviceId, udn: String, @@ -43,7 +70,7 @@ impl UpnpMediaServer { content_directory_service_type: Option, content_directory_control_url: Option, ) -> Arc { - Arc::new(Self { + Arc::new(Self::new( id, udn, friendly_name, @@ -54,8 +81,7 @@ impl UpnpMediaServer { has_content_directory, content_directory_service_type, content_directory_control_url, - connection: DeviceConnectionState::make(), - }) + )) } fn browse_with_flag( @@ -357,6 +383,12 @@ pub enum MusicServer { Upnp(UpnpMediaServer), } +impl MusicServer { + pub fn from_server_info(info: &UpnpMediaServer) -> Result { + Ok(MusicServer::Upnp(info.clone())) + } +} + impl DeviceOnline for MusicServer { fn is_online(&self) -> bool { match self { diff --git a/pmocontrol/src/media_server_events.rs b/pmocontrol/src/media_server_events.rs index 3a9c1a09..aac2ef41 100644 --- a/pmocontrol/src/media_server_events.rs +++ b/pmocontrol/src/media_server_events.rs @@ -12,11 +12,13 @@ use tracing::{debug, info, warn}; use ureq::{Agent, http}; use xmltree::{Element, XMLNode}; +use crate::DeviceId; use crate::events::MediaServerEventBus; -use crate::media_server::{UpnpMediaServer, ServerId}; +use crate::media_server::{UpnpMediaServer}; use crate::model::MediaServerEvent; -use crate::provider::resolve_control_url; -use crate::registry::{DeviceRegistry, DeviceRegistryRead}; +use crate::upnp_clients::resolve_control_url; +use crate::registry::DeviceRegistry; +use crate::{DeviceOnline,DeviceIdentity}; const SUBSCRIPTION_TIMEOUT_SECS: u64 = 300; const RENEWAL_SAFETY_MARGIN_SECS: u64 = 60; @@ -179,8 +181,8 @@ struct MediaServerEventWorker { http_timeout: Duration, notify_rx: Receiver, listener_port: u16, - subscriptions: HashMap, - path_index: HashMap, + subscriptions: HashMap, + path_index: HashMap, } impl MediaServerEventWorker { @@ -223,10 +225,10 @@ impl MediaServerEventWorker { reg.list_servers() }; - let mut active: HashSet = HashSet::new(); + let mut active: HashSet = HashSet::new(); for info in server_infos { - if !info.online || !info.has_content_directory { + if !info.is_online() || !info.has_content_directory { continue; } diff --git a/pmocontrol/src/model.rs b/pmocontrol/src/model.rs index 1e7c8625..051b5e83 100644 --- a/pmocontrol/src/model.rs +++ b/pmocontrol/src/model.rs @@ -1,10 +1,52 @@ -use std::sync::{Arc, Mutex}; - -use crate::{DeviceId,DeviceIdentity}; -use crate::capabilities::{PlaybackPositionInfo, PlaybackState}; use crate::control_point::PlaylistBinding; use crate::media_server::UpnpMediaServer; +use crate::music_renderer::PlaybackPositionInfo; +use crate::{DeviceId, DeviceIdentity}; +/// High-level playback state across backends. +#[derive(Clone, Debug)] +pub enum PlaybackState { + Stopped, + Playing, + Paused, + Transitioning, + NoMedia, + /// Backend-specific or unknown state string. + Unknown(String), +} + +impl PlaybackState { + /// Map a raw UPnP AVTransport CurrentTransportState string + /// to a logical PlaybackState. + pub fn from_upnp_state(raw: &str) -> Self { + let s = raw.trim().to_ascii_uppercase(); + match s.as_str() { + "STOPPED" => PlaybackState::Stopped, + "PLAYING" => PlaybackState::Playing, + "PAUSED_PLAYBACK" => PlaybackState::Paused, + // States from the AVTransport spec that we normalize: + "PAUSED_RECORDING" => PlaybackState::Paused, + "RECORDING" => PlaybackState::Playing, + // Common vendor-specific states: + "TRANSITIONING" => PlaybackState::Transitioning, + "BUFFERING" | "PREPARING" => PlaybackState::Transitioning, + "NO_MEDIA_PRESENT" => PlaybackState::NoMedia, + _ => PlaybackState::Unknown(raw.to_string()), + } + } + + /// Returns a human-readable label for the playback state. + pub fn as_str(&self) -> &str { + match self { + PlaybackState::Stopped => "STOPPED", + PlaybackState::Playing => "PLAYING", + PlaybackState::Paused => "PAUSED", + PlaybackState::Transitioning => "TRANSITIONING", + PlaybackState::NoMedia => "NO_MEDIA", + PlaybackState::Unknown(s) => s.as_str(), + } + } +} #[derive(Clone, Debug, PartialEq)] pub struct TrackMetadata { @@ -233,71 +275,70 @@ impl RendererInfo { &self.capabilities } - pub fn avtransport_service_type(&self) -> Option { + pub fn avtransport_service_type(&self) -> Option { self.avtransport_service_type.clone() } - pub fn avtransport_control_url(&self) -> Option { + pub fn avtransport_control_url(&self) -> Option { self.avtransport_control_url.clone() } - pub fn rendering_control_service_type(&self) -> Option { + pub fn rendering_control_service_type(&self) -> Option { self.rendering_control_service_type.clone() } - pub fn rendering_control_control_url(&self) -> Option { + pub fn rendering_control_control_url(&self) -> Option { self.rendering_control_control_url.clone() } - pub fn connection_manager_service_type(&self) -> Option { + pub fn connection_manager_service_type(&self) -> Option { self.connection_manager_service_type.clone() } - pub fn connection_manager_control_url(&self) -> Option { + pub fn connection_manager_control_url(&self) -> Option { self.connection_manager_control_url.clone() } - pub fn oh_playlist_service_type(&self) -> Option { + pub fn oh_playlist_service_type(&self) -> Option { self.oh_playlist_service_type.clone() } - pub fn oh_playlist_control_url(&self) -> Option { + pub fn oh_playlist_control_url(&self) -> Option { self.oh_playlist_control_url.clone() } - pub fn oh_playlist_event_sub_url(&self) -> Option { + pub fn oh_playlist_event_sub_url(&self) -> Option { self.oh_playlist_event_sub_url.clone() } - pub fn oh_info_service_type(&self) -> Option { + pub fn oh_info_service_type(&self) -> Option { self.oh_info_service_type.clone() } - pub fn oh_info_control_url(&self) -> Option { + pub fn oh_info_control_url(&self) -> Option { self.oh_info_control_url.clone() } - pub fn oh_info_event_sub_url(&self) -> Option { + pub fn oh_info_event_sub_url(&self) -> Option { self.oh_info_event_sub_url.clone() } - pub fn oh_time_service_type(&self) -> Option { + pub fn oh_time_service_type(&self) -> Option { self.oh_time_service_type.clone() } - pub fn oh_time_control_url(&self) -> Option { + pub fn oh_time_control_url(&self) -> Option { self.oh_time_control_url.clone() } - pub fn oh_time_event_sub_url(&self) -> Option { + pub fn oh_time_event_sub_url(&self) -> Option { self.oh_time_event_sub_url.clone() } - pub fn oh_volume_service_type(&self) -> Option { + pub fn oh_volume_service_type(&self) -> Option { self.oh_volume_service_type.clone() } - pub fn oh_volume_control_url(&self) -> Option { + pub fn oh_volume_control_url(&self) -> Option { self.oh_volume_control_url.clone() } - pub fn oh_radio_service_type(&self) -> Option { + pub fn oh_radio_service_type(&self) -> Option { self.oh_radio_service_type.clone() } - pub fn oh_radio_control_url(&self) -> Option { + pub fn oh_radio_control_url(&self) -> Option { self.oh_radio_control_url.clone() } - pub fn oh_product_service_type(&self) -> Option { + pub fn oh_product_service_type(&self) -> Option { self.oh_product_service_type.clone() } - pub fn oh_product_control_url(&self) -> Option { + pub fn oh_product_control_url(&self) -> Option { self.oh_product_control_url.clone() } - } impl DeviceIdentity for RendererInfo { @@ -323,7 +364,9 @@ impl DeviceIdentity for RendererInfo { &self.server_header } - fn is_a_music_renderer(&self) -> bool {true } + fn is_a_music_renderer(&self) -> bool { + true + } } #[derive(Clone, Debug)] diff --git a/pmocontrol/src/music_renderer.rs b/pmocontrol/src/music_renderer.rs deleted file mode 100644 index bdfd14ee..00000000 --- a/pmocontrol/src/music_renderer.rs +++ /dev/null @@ -1,961 +0,0 @@ -//! Backend-agnostic music renderer façade for PMOMusic. -//! -//! `MusicRenderer` wraps every supported backend (UPnP AV/DLNA, OpenHome, -//! LinkPlay HTTP, Arylic TCP, Chromecast, and the hybrid UPnP + Arylic pairing) behind a -//! single control surface. Higher layers in PMOMusic must only interact with -//! renderers through this type so that transport, volume, and state queries -//! stay backend-neutral. - -use std::sync::{Arc, Mutex, RwLock}; -use std::time::SystemTime; - -use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus}; -// use crate::control_point::RendererRuntimeStateMut; -use crate::control_point::music_queue::MusicQueue; -use crate::control_point::openhome_queue::didl_id_from_metadata; -use crate::errors::ControlPointError; -use crate::media_server::ServerId; -use crate::model::{ - RendererConnectionState, ServiceId, RendererInfo, RendererProtocol, TrackMetadata, -}; -use crate::openhome_client::parse_track_metadata_from_didl; -use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; -use crate::queue_backend::{PlaybackItem, QueueSnapshot}; -use crate::{ - ArylicTcpRenderer, ChromecastRenderer, DeviceIdentity, DeviceOnline, DeviceRegistry, LinkPlayRenderer, OpenHomeRenderer, PlaybackPosition, PlaybackState, TransportControl, UpnpRenderer, VolumeControl -}; -use anyhow::{Result, anyhow}; -use tracing::{debug, info, warn}; - -/// Backend-agnostic façade exposing transport, volume, and status contracts. -#[derive(Clone, Debug)] -pub enum MusicRendererBackend { - /// Classic UPnP AV / DLNA renderer (AVTransport + RenderingControl). - Upnp(UpnpRenderer), - /// Renderer powered by OpenHome services. - OpenHome(OpenHomeRenderer), - /// Renderer controlled via the LinkPlay HTTP API. - LinkPlay(LinkPlayRenderer), - /// Renderer reachable through the Arylic TCP control protocol (port 8899). - ArylicTcp(ArylicTcpRenderer), - /// Renderer controlled via the Google Cast protocol (Chromecast). - Chromecast(ChromecastRenderer), - /// Combined backend using UPnP for transport + volume writes and Arylic TCP - /// to read detailed playback information as well as live volume/mute state. - HybridUpnpArylic { - upnp: UpnpRenderer, - arylic: ArylicTcpRenderer, - }, -} - -#[derive(Debug, Clone)] -pub struct MusicRenderer { - info: RendererInfo, - connection: Arc>, - backend: Arc>, - queue: Arc>, -} - -impl MusicRenderer { - pub fn new( - info: RendererInfo, - backend: Arc>, - queue: Arc>, - ) -> Arc { - let connection = RendererConnectionState::new(); - - let renderer = MusicRenderer { - info, - connection, - backend, - queue, - }; - - Arc::new(renderer) - } - - pub fn from_renderer_info(info: RendererInfo) -> Result,ControlPointError> { - let connection = RendererConnectionState::new(); - let backend = MusicRendererBackend::from_renderer_info(info)?; - let queue = Mutex::new(MusicQueue::new()); - - let renderer = Arc::new(MusicRenderer { info, connection, backend, queue }); - Ok(renderer) - } - - pub fn info(&self) -> &RendererInfo { - &self.info - } - - /// Returns the protocol. - fn protocol(&self) -> RendererProtocol { - self.info.protocol() - } - - fn is_upnp(&self) -> bool { - match &*self.backend.lock().expect("Backend mutex poisoned") { - MusicRendererBackend::Upnp(_) => true, - _ => false, - } - } - - fn is_openhome(&self) -> bool { - match &*self.backend.lock().expect("Backend mutex poisoned") { - MusicRendererBackend::OpenHome(_) => true, - _ => false, - } - } - - fn is_linkplay(&self) -> bool { - match &*self.backend.lock().expect("Backend mutex poisoned") { - MusicRendererBackend::LinkPlay(_) => true, - _ => false, - } - } - - fn is_arylictcp(&self) -> bool { - match &*self.backend.lock().expect("Backend mutex poisoned") { - MusicRendererBackend::ArylicTcp(_) => true, - _ => false, - } - } - - fn is_chromecast(&self) -> bool { - match &*self.backend.lock().expect("Backend mutex poisoned") { - MusicRendererBackend::Chromecast(_) => true, - _ => false, - } - } - - fn is_hybridupnparylic(&self) -> bool { - match &*self.backend.lock().expect("Backend mutex poisoned") { - MusicRendererBackend::HybridUpnpArylic { .. } => true, - _ => false, - } - } - - /// Returns true if this renderer is known to support SetNextAVTransportURI. - pub fn supports_set_next(&self) -> bool { - self.info.capabilities().supports_set_next() - } - - -} - -impl DeviceIdentity for MusicRenderer { - fn id(&self) -> ServiceId { - self.info.id() - } - - fn udn(&self) -> &str { - &*self.info.udn() - } - - fn friendly_name(&self) -> &str { - &self.info.friendly_name() - } - - fn model_name(&self) -> &str { - &self.info.model_name() - } - fn manufacturer(&self) -> &str { - &self.info.manufacturer() - } - fn location(&self) -> &str { - &self.info.location() - } - fn server_header(&self) -> &str { - &self.info.server_header() - } - -} - -impl DeviceOnline for MusicRenderer { - fn is_online(&self) -> bool { - self.connection - .lock() - .expect("Connection mutex poisoned") - .is_online() - } - - fn last_seen(&self) -> SystemTime { - self.connection - .lock() - .expect("Connection mutex poisoned") - .last_seen() - } - - fn has_been_seen_now(&self, max_age: u32) { - self.connection - .lock() - .expect("Connection mutex poisoned") - .has_been_seen_now(max_age) - } - - fn mark_as_offline(&self) { - self.connection - .lock() - .expect("Connection mutex poisoned") - .mark_as_offline() - } - - fn max_age(&self) -> u32 { - self.connection - .lock() - .expect("Connection mutex poisoned") - .max_age() - }} -// #[derive(Clone, Debug)] -// pub struct RendererRuntimeState { -// pub queue: MusicQueue, -// } - -// pub trait OpenHomeQueueProvider: Send + Sync + 'static { -// fn renderer_state(&self, renderer_id: &RendererId) -> Result; -// fn renderer_state_mut<'a>( -// &'a self, -// renderer_id: &RendererId, -// ) -> Result>; -// fn invalidate_openhome_cache(&self, renderer_id: &RendererId) -> Result<()>; -// } - -// static OPENHOME_QUEUE_PROVIDER: OnceLock> = OnceLock::new(); - -// pub fn set_openhome_queue_provider(provider: Arc) { -// let _ = OPENHOME_QUEUE_PROVIDER.set(provider); -// } - -impl MusicRendererBackend { - // fn as_backend(&self) -> &dyn MusicRendererBackend { - // match self { - // MusicRendererBackend::OpenHome(r) => r, - // MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic, - // MusicRendererBackend::Upnp(r) => r, - // MusicRendererBackend::LinkPlay(r) => r, - // MusicRendererBackend::ArylicTcp(r) => r, - // MusicRendererBackend::Chromecast(r) => r, - // } - // } - - /// Construct a music renderer from a [`RendererInfo`] and the registry. - /// - /// Returns `None` when no supported backend can be built for this renderer. - /// UPnP AV / hybrid renderers map either to [`MusicRenderer::LinkPlay`] (when supported) - /// or [`MusicRenderer::Upnp`]. - pub fn from_renderer_info( - info: RendererInfo, - ) -> Result>, ControlPointError> { - - match info.protocol() { - RendererProtocol::OpenHomeOnly | RendererProtocol::OpenHomeHybrid => { - OpenHomeRenderer::from_renderer_info(info.clone()) - } - - _ => Err(ControlPointError::MusicRendererBackendBuild(format!("{:#?}", info.id()))), - } - - - // Le backend est de type openhome - - if matches!( - info.protocol(), - RendererProtocol::OpenHomeOnly | RendererProtocol::OpenHomeHybrid - ) { - if let Some(renderer) = { - let renderer = OpenHomeRenderer::new(info.clone()); - renderer.has_any_openhome_service().then_some(renderer) - } { - return Some(MusicRendererBackend::OpenHome(renderer)); - } - - if matches!(info.protocol(), RendererProtocol::OpenHomeOnly) { - warn!( - renderer = info.friendly_name(), - "Renderer advertises OpenHome only but exposes no usable services" - ); - return None; - } - } - - if matches!(info.protocol(), RendererProtocol::ChromecastOnly) { - if let Ok(renderer) = ChromecastRenderer::from_renderer_info(info.clone()) { - return Some(MusicRendererBackend::Chromecast(renderer)); - } - warn!( - renderer = info.friendly_name(), - "Failed to build Chromecast renderer" - ); - return None; - } - - match info.protocol() { - RendererProtocol::UpnpAvOnly | RendererProtocol::OpenHomeHybrid => { - let has_arylic = info.capabilities().has_arylic_tcp; - let has_avtransport = info.capabilities().has_avtransport; - - if has_arylic && has_avtransport { - // Construire UpnpRenderer - let upnp = UpnpRenderer::from_info(&info); - - // Construire ArylicTcpRenderer - match ArylicTcpRenderer::from_renderer_info(info.clone()) { - Ok(arylic) => { - return Some(MusicRendererBackend::HybridUpnpArylic { upnp, arylic }); - } - Err(err) => { - warn!( - "Failed to build Arylic TCP backend for {}: {}. Falling back to UPnP only.", - info.friendly_name(), - err - ); - return Some(MusicRendererBackend::Upnp(upnp)); - } - } - } - - // Pas d’Arylic : logique existante - if info.capabilities().has_linkplay_http() { - if let Ok(lp) = LinkPlayRenderer::from_renderer_info(info.clone()) { - return Some(MusicRendererBackend::LinkPlay(lp)); - } - } - - Some(MusicRendererBackend::Upnp(UpnpRenderer::from_info( - &info, - ))) - } - RendererProtocol::OpenHomeOnly => None, - RendererProtocol::ChromecastOnly => None, - } - } - - pub fn openhome_playlist_snapshot(&self) -> Result { - match self { - MusicRendererBackend::OpenHome(renderer) => renderer.snapshot_openhome_playlist(), - _ => Err(op_not_supported( - "openhome_playlist_snapshot", - self.unsupported_backend_name(), - )), - } - } - - pub fn openhome_playlist_len(&self) -> Result { - match self { - MusicRendererBackend::OpenHome(renderer) => renderer.openhome_playlist_len(), - _ => Err(op_not_supported( - "openhome_playlist_len", - self.unsupported_backend_name(), - )), - } - } - - pub fn openhome_playlist_ids(&self) -> Result> { - match self { - MusicRendererBackend::OpenHome(renderer) => renderer.openhome_playlist_ids(), - _ => Err(op_not_supported( - "openhome_playlist_ids", - self.unsupported_backend_name(), - )), - } - } - - pub fn openhome_playlist_clear(&self) -> Result<()> { - if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() { - let result = { - let mut state = provider.renderer_state_mut(self.id())?; - match &mut *state.queue { - MusicQueue::OpenHome(queue) => queue.clear(), - _ => Err(op_not_supported( - "openhome_playlist_clear", - self.unsupported_backend_name(), - )), - } - }; - if result.is_ok() { - provider.invalidate_openhome_cache(self.id())?; - } - result - } else { - self.fetch_openhome_playlist_clear() - } - } - - pub(crate) fn fetch_openhome_playlist_clear(&self) -> Result<()> { - match self { - MusicRendererBackend::OpenHome(renderer) => renderer.clear_openhome_playlist(), - _ => Err(op_not_supported( - "openhome_playlist_clear", - self.unsupported_backend_name(), - )), - } - } - - /// High-level method to prepare the renderer for attaching a new playlist. - /// - /// This method handles backend-specific clearing logic: - /// - For OpenHome: clears the OpenHome playlist - /// - For AVTransport/Chromecast/etc.: stops the renderer (since they don't have a persistent queue) - /// - /// This should be called by ControlPoint when attaching a new playlist, ensuring that: - /// - Any currently playing content is stopped - /// - The renderer is in a clean state ready to receive new content - pub fn clear_for_playlist_attach(&self) -> Result<()> { - match self { - MusicRendererBackend::OpenHome(_) => { - // For OpenHome: clear the playlist (DeleteAll also stops playback automatically) - // then explicitly stop to ensure clean state - self.openhome_playlist_clear()?; - self.stop().or_else(|err| -> Result<()> { - // If stop fails (e.g., already stopped), that's fine - warn!( - renderer = self.id().0.as_str(), - error = %err, - "Stop failed after clearing OpenHome playlist (continuing anyway)" - ); - Ok(()) - }) - } - MusicRendererBackend::Upnp(_) - | MusicRendererBackend::Chromecast(_) - | MusicRendererBackend::LinkPlay(_) - | MusicRendererBackend::ArylicTcp(_) - | MusicRendererBackend::HybridUpnpArylic { .. } => { - // For AVTransport and other single-track renderers: stop playback - // This ensures we're not in the middle of playing when we start the new playlist - self.stop().or_else(|err| { - // If stop fails (e.g., already stopped), that's fine - we just want to ensure it's not playing - warn!( - renderer = self.id().0.as_str(), - error = %err, - "Stop failed when preparing for playlist attach (continuing anyway)" - ); - Ok(()) - }) - } - } - } - - /// Synchronize the local queue state with the backend's actual state. - /// - /// - For OpenHome: fetches the playlist from the renderer and updates local cache - /// - For Internal queue/AVTransport: no-op (queue is already local) - pub fn sync_queue_state(&self) -> Result<()> { - match self { - MusicRendererBackend::OpenHome(_) => { - if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() { - // Fetch fresh playlist snapshot and update cache - provider.invalidate_openhome_cache(self.id())?; - } - Ok(()) - } - MusicRendererBackend::Upnp(_) - | MusicRendererBackend::Chromecast(_) - | MusicRendererBackend::LinkPlay(_) - | MusicRendererBackend::ArylicTcp(_) - | MusicRendererBackend::HybridUpnpArylic { .. } => { - // No sync needed - queue is local only - Ok(()) - } - } - } - - /// Clear the queue on both the backend and in local state. - /// - /// This ensures the backend renderer and local cache are consistent. - /// Should be called before queue mutations to ensure clean state. - pub fn clear_queue(&self) -> Result<()> { - match self { - MusicRendererBackend::OpenHome(_) => { - // For OpenHome: clear the playlist on the renderer itself - self.openhome_playlist_clear() - } - MusicRendererBackend::Upnp(_) - | MusicRendererBackend::Chromecast(_) - | MusicRendererBackend::LinkPlay(_) - | MusicRendererBackend::ArylicTcp(_) - | MusicRendererBackend::HybridUpnpArylic { .. } => { - // For other renderers: no persistent queue to clear - Ok(()) - } - } - } - - /// Add a track to the backend's queue, returning backend-specific track ID if applicable. - /// - /// - For OpenHome: adds to OpenHome playlist and returns track ID - /// - For others: returns error (not supported for single-track renderers) - pub fn add_track_to_queue( - &self, - uri: &str, - metadata: &str, - after_id: Option, - play: bool, - ) -> Result> { - match self { - MusicRendererBackend::OpenHome(_) => { - let track_id = self.openhome_playlist_add_track(uri, metadata, after_id, play)?; - Ok(Some(track_id)) - } - MusicRendererBackend::Upnp(_) - | MusicRendererBackend::Chromecast(_) - | MusicRendererBackend::LinkPlay(_) - | MusicRendererBackend::ArylicTcp(_) - | MusicRendererBackend::HybridUpnpArylic { .. } => Err(anyhow!( - "add_track_to_queue is not supported for {} backend", - self.unsupported_backend_name() - )), - } - } - - /// Select and play a specific track from the backend's queue. - /// - /// - For OpenHome: uses track ID to select from OpenHome playlist - /// - For others: returns error (use play_uri instead) - pub fn select_queue_track(&self, track_id: u32) -> Result<()> { - match self { - MusicRendererBackend::OpenHome(_) => self.openhome_playlist_play_id(track_id), - MusicRendererBackend::Upnp(_) - | MusicRendererBackend::Chromecast(_) - | MusicRendererBackend::LinkPlay(_) - | MusicRendererBackend::ArylicTcp(_) - | MusicRendererBackend::HybridUpnpArylic { .. } => Err(anyhow!( - "select_queue_track is not supported for {} backend", - self.unsupported_backend_name() - )), - } - } - - /// Get the current queue state from the backend. - /// - /// - For OpenHome: fetches current playlist snapshot - /// - For others: returns None (no persistent queue on backend) - pub fn queue_snapshot(&self) -> Result> { - match self { - MusicRendererBackend::OpenHome(_) => { - let oh_snapshot = self.openhome_playlist_snapshot()?; - - // Convert OpenHome tracks to PlaybackItems - let items: Vec = oh_snapshot - .tracks - .iter() - .map(|track| Self::playback_item_from_openhome_track(self.id(), track)) - .collect(); - - let snapshot = QueueSnapshot { - items, - current_index: oh_snapshot.current_index, - }; - - Ok(Some(snapshot)) - } - MusicRendererBackend::Upnp(_) - | MusicRendererBackend::Chromecast(_) - | MusicRendererBackend::LinkPlay(_) - | MusicRendererBackend::ArylicTcp(_) - | MusicRendererBackend::HybridUpnpArylic { .. } => { - // No backend queue for these renderers - Ok(None) - } - } - } - - /// Play the current item from the backend queue. - /// - /// - For OpenHome: Uses the native playlist to play the current track - /// - For others: Returns error (no backend queue) - pub fn play_current_from_backend_queue(&self) -> Result<()> { - match self { - MusicRendererBackend::OpenHome(_) => { - // Get the current OpenHome playlist snapshot - let snapshot = self.openhome_playlist_snapshot()?; - - debug!( - renderer = self.id().0.as_str(), - tracks_count = snapshot.tracks.len(), - current_id = ?snapshot.current_id, - current_index = ?snapshot.current_index, - "play_current_from_backend_queue: OpenHome snapshot fetched" - ); - - if snapshot.tracks.is_empty() { - return Err(anyhow!("OpenHome playlist is empty")); - } - - // Find the track_id to play (prefer current_id, then current_index, then first) - let target_track_id = if let Some(current_id) = snapshot.current_id { - debug!( - renderer = self.id().0.as_str(), - track_id = current_id, - "Using current_id for playback" - ); - Some(current_id) - } else if let Some(current_idx) = snapshot.current_index { - let track_id = snapshot.tracks.get(current_idx).map(|track| track.id); - debug!( - renderer = self.id().0.as_str(), - current_idx, - track_id = ?track_id, - "Using current_index for playback" - ); - track_id - } else { - let track_id = snapshot.tracks.first().map(|track| track.id); - debug!( - renderer = self.id().0.as_str(), - track_id = ?track_id, - "Using first track for playback" - ); - track_id - }; - - if let Some(track_id) = target_track_id { - info!( - renderer = self.id().0.as_str(), - track_id, "Calling openhome_playlist_play_id to start playback" - ); - self.openhome_playlist_play_id(track_id)?; - info!( - renderer = self.id().0.as_str(), - track_id, "Successfully called openhome_playlist_play_id" - ); - Ok(()) - } else { - Err(anyhow!("No track to play in OpenHome playlist")) - } - } - MusicRendererBackend::Upnp(_) - | MusicRendererBackend::Chromecast(_) - | MusicRendererBackend::LinkPlay(_) - | MusicRendererBackend::ArylicTcp(_) - | MusicRendererBackend::HybridUpnpArylic { .. } => Err(anyhow!( - "play_current_from_backend_queue is not supported for {} backend (no persistent queue)", - self.unsupported_backend_name() - )), - } - } - - /// Play the next item from the backend queue. - /// - /// - For OpenHome: Advances to the next track in the playlist - /// - For others: Returns error (no backend queue) - pub fn play_next_from_backend_queue(&self) -> Result<()> { - match self { - MusicRendererBackend::OpenHome(_) => { - // Get the current OpenHome playlist snapshot - let snapshot = self.openhome_playlist_snapshot()?; - - if snapshot.tracks.is_empty() { - return Err(anyhow!("OpenHome playlist is empty, cannot play next")); - } - - // Determine the next track_id - let next_track_id = match snapshot.current_index { - Some(idx) => { - // Take the next track if it exists, otherwise loop to first - snapshot - .tracks - .get(idx + 1) - .map(|track| track.id) - .or_else(|| snapshot.tracks.first().map(|track| track.id)) - } - None => snapshot.tracks.first().map(|track| track.id), - }; - - if let Some(track_id) = next_track_id { - self.openhome_playlist_play_id(track_id)?; - Ok(()) - } else { - Err(anyhow!( - "No track available to advance to in OpenHome playlist" - )) - } - } - MusicRendererBackend::Upnp(_) - | MusicRendererBackend::Chromecast(_) - | MusicRendererBackend::LinkPlay(_) - | MusicRendererBackend::ArylicTcp(_) - | MusicRendererBackend::HybridUpnpArylic { .. } => Err(anyhow!( - "play_next_from_backend_queue is not supported for {} backend (no persistent queue)", - self.unsupported_backend_name() - )), - } - } - - /// Convert an OpenHome playlist track to a PlaybackItem. - fn playback_item_from_openhome_track( - renderer_id: &ServiceId, - track: &OpenHomePlaylistTrack, - ) -> PlaybackItem { - let metadata = TrackMetadata { - title: track.title.clone(), - artist: track.artist.clone(), - album: track.album.clone(), - genre: None, - album_art_uri: track.album_art_uri.clone(), - date: None, - track_number: None, - creator: None, - }; - - PlaybackItem { - media_server_id: ServerId(format!("openhome:{}", renderer_id.0)), - didl_id: format!("openhome:{}", track.id), - uri: track.uri.clone(), - // OpenHome tracks don't provide protocolInfo, use generic default - protocol_info: "http-get:*:audio/*:*".to_string(), - metadata: Some(metadata), - } - } - - pub fn openhome_playlist_add_track( - &self, - uri: &str, - metadata: &str, - after_id: Option, - play: bool, - ) -> Result { - if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() { - let result = { - let mut state = provider.renderer_state_mut(self.id())?; - match &mut *state.queue { - MusicQueue::OpenHome(queue) => { - let playback_item = - Self::playback_item_from_params(self.id(), uri, metadata)?; - queue.add_playback_item(playback_item, after_id, play) - } - _ => Err(op_not_supported( - "openhome_playlist_add_track", - self.unsupported_backend_name(), - )), - } - }; - if result.is_ok() { - provider.invalidate_openhome_cache(self.id())?; - } - result - } else { - self.fetch_openhome_playlist_add_track(uri, metadata, after_id, play) - } - } - - pub fn openhome_playlist_play_id(&self, id: u32) -> Result<()> { - if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() { - let result = { - let mut state = provider.renderer_state_mut(self.id())?; - match &mut *state.queue { - MusicQueue::OpenHome(queue) => queue.select_track_id(id), - _ => Err(op_not_supported( - "openhome_playlist_play_id", - self.unsupported_backend_name(), - )), - } - }; - if result.is_ok() { - provider.invalidate_openhome_cache(self.id())?; - } - result - } else { - self.fetch_openhome_playlist_play_id(id) - } - } - - fn unsupported_backend_name(&self) -> &'static str { - match self { - MusicRendererBackend::Upnp(_) => "UPnP", - MusicRendererBackend::OpenHome(_) => "OpenHome", - MusicRendererBackend::LinkPlay(_) => "LinkPlay", - MusicRendererBackend::ArylicTcp(_) => "ArylicTcp", - MusicRendererBackend::Chromecast(_) => "Chromecast", - MusicRendererBackend::HybridUpnpArylic { .. } => "HybridUpnpArylic", - } - } - - fn fetch_openhome_playlist_add_track( - &self, - uri: &str, - metadata: &str, - after_id: Option, - play: bool, - ) -> Result { - match self { - MusicRendererBackend::OpenHome(renderer) => { - renderer.add_track_openhome(uri, metadata, after_id, play) - } - _ => Err(op_not_supported( - "openhome_playlist_add_track", - self.unsupported_backend_name(), - )), - } - } - - fn fetch_openhome_playlist_play_id(&self, id: u32) -> Result<()> { - match self { - MusicRendererBackend::OpenHome(renderer) => renderer.play_openhome_track_id(id), - _ => Err(op_not_supported( - "openhome_playlist_play_id", - self.unsupported_backend_name(), - )), - } - } - - fn playback_item_from_params( - renderer_id: &ServiceId, - uri: &str, - metadata_xml: &str, - ) -> Result { - let metadata = parse_track_metadata_from_didl(metadata_xml); - let didl_id = didl_id_from_metadata(metadata_xml) - .unwrap_or_else(|| format!("openhome:{}", renderer_id.0)); - Ok(PlaybackItem { - media_server_id: ServerId(format!("openhome:{}", renderer_id.0)), - didl_id, - uri: uri.to_string(), - // OpenHome tracks don't provide protocolInfo, use generic default - protocol_info: "http-get:*:audio/*:*".to_string(), - metadata, - }) - } -} - -/// Transport control façade that dispatches to whichever backend can fulfill -/// the request, returning a standardized error if the backend lacks support. -impl TransportControl for MusicRendererBackend { - fn play_uri(&self, uri: &str, meta: &str) -> Result<()> { - match self { - MusicRendererBackend::Upnp(upnp) => upnp.play_uri(uri, meta), - MusicRendererBackend::OpenHome(oh) => oh.play_uri(uri, meta), - MusicRendererBackend::LinkPlay(lp) => lp.play_uri(uri, meta), - MusicRendererBackend::ArylicTcp(_) => Err(op_not_supported("play_uri", "ArylicTcp")), - MusicRendererBackend::Chromecast(cc) => cc.play_uri(uri, meta), - MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta), - } - } - - fn play(&self) -> Result<()> { - match self { - MusicRendererBackend::Upnp(upnp) => upnp.play(), - MusicRendererBackend::OpenHome(oh) => oh.play(), - MusicRendererBackend::LinkPlay(lp) => lp.play(), - MusicRendererBackend::ArylicTcp(ary) => ary.play(), - MusicRendererBackend::Chromecast(cc) => cc.play(), - MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.play(), - } - } - - fn pause(&self) -> Result<()> { - match self { - MusicRendererBackend::Upnp(upnp) => upnp.pause(), - MusicRendererBackend::OpenHome(oh) => oh.pause(), - MusicRendererBackend::LinkPlay(lp) => lp.pause(), - MusicRendererBackend::ArylicTcp(ary) => ary.pause(), - MusicRendererBackend::Chromecast(cc) => cc.pause(), - MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.pause(), - } - } - - fn stop(&self) -> Result<()> { - match self { - MusicRendererBackend::Upnp(upnp) => upnp.stop(), - MusicRendererBackend::OpenHome(oh) => oh.stop(), - MusicRendererBackend::LinkPlay(lp) => lp.stop(), - MusicRendererBackend::ArylicTcp(ary) => ary.stop(), - MusicRendererBackend::Chromecast(cc) => cc.stop(), - MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.stop(), - } - } - - fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { - match self { - MusicRendererBackend::Upnp(upnp) => upnp.seek_rel_time(hhmmss), - MusicRendererBackend::OpenHome(oh) => oh.seek_rel_time(hhmmss), - MusicRendererBackend::LinkPlay(lp) => lp.seek_rel_time(hhmmss), - MusicRendererBackend::ArylicTcp(_) => { - Err(op_not_supported("seek_rel_time", "ArylicTcp")) - } - MusicRendererBackend::Chromecast(cc) => cc.seek_rel_time(hhmmss), - MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.seek_rel_time(hhmmss), - } - } -} - -/// Volume and mute controls exposed via the façade. -/// -/// Hybrid backends may read via Arylic TCP and write via UPnP, but callers -/// always depend on a single [`VolumeControl`] entry point. -impl VolumeControl for MusicRendererBackend { - fn volume(&self) -> Result { - match self { - MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.volume(), - MusicRendererBackend::ArylicTcp(ary) => ary.volume(), - MusicRendererBackend::OpenHome(oh) => oh.volume(), - MusicRendererBackend::Upnp(upnp) => upnp.volume(), - MusicRendererBackend::LinkPlay(lp) => lp.volume(), - MusicRendererBackend::Chromecast(cc) => cc.volume(), - } - } - - fn set_volume(&self, vol: u16) -> Result<()> { - match self { - MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_volume(vol), - MusicRendererBackend::ArylicTcp(ary) => ary.set_volume(vol), - MusicRendererBackend::OpenHome(oh) => oh.set_volume(vol), - MusicRendererBackend::Upnp(upnp) => upnp.set_volume(vol), - MusicRendererBackend::LinkPlay(lp) => lp.set_volume(vol), - MusicRendererBackend::Chromecast(cc) => cc.set_volume(vol), - } - } - - fn mute(&self) -> Result { - match self { - MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.mute(), - MusicRendererBackend::OpenHome(r) => r.mute(), - MusicRendererBackend::Upnp(r) => r.get_master_mute(), - MusicRendererBackend::LinkPlay(r) => r.mute(), - MusicRendererBackend::ArylicTcp(r) => r.mute(), - MusicRendererBackend::Chromecast(cc) => cc.mute(), - } - } - - fn set_mute(&self, m: bool) -> Result<()> { - match self { - MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.set_mute(m), - MusicRendererBackend::OpenHome(r) => r.set_mute(m), - MusicRendererBackend::Upnp(r) => r.set_master_mute(m), - MusicRendererBackend::LinkPlay(r) => r.set_mute(m), - MusicRendererBackend::ArylicTcp(r) => r.set_mute(m), - MusicRendererBackend::Chromecast(cc) => cc.set_mute(m), - } - } -} - -/// Playback-state queries sourced from the backend best suited for the job. -/// -/// Each backend reports into [`PlaybackState`], ensuring consumers never have -/// to reason about protocol-specific state machines. -impl PlaybackStatus for MusicRendererBackend { - fn playback_state(&self) -> Result { - match self { - MusicRendererBackend::Upnp(r) => PlaybackStatus::playback_state(r), - MusicRendererBackend::OpenHome(r) => PlaybackStatus::playback_state(r), - MusicRendererBackend::LinkPlay(r) => r.playback_state(), - MusicRendererBackend::ArylicTcp(r) => r.playback_state(), - MusicRendererBackend::Chromecast(cc) => cc.playback_state(), - MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_state(), - } - } -} - -/// Playback-position queries that always yield a [`PlaybackPositionInfo`] -/// regardless of the backend providing the raw transport data. -impl PlaybackPosition for MusicRendererBackend { - fn playback_position(&self) -> Result { - match self { - MusicRendererBackend::Upnp(r) => r.playback_position(), - MusicRendererBackend::OpenHome(r) => r.playback_position(), - MusicRendererBackend::LinkPlay(r) => r.playback_position(), - MusicRendererBackend::ArylicTcp(r) => r.playback_position(), - MusicRendererBackend::Chromecast(cc) => cc.playback_position(), - MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_position(), - } - } -} diff --git a/pmocontrol/src/music_renderer/arylic_tcp.rs b/pmocontrol/src/music_renderer/arylic_tcp.rs new file mode 100644 index 00000000..85c139fb --- /dev/null +++ b/pmocontrol/src/music_renderer/arylic_tcp.rs @@ -0,0 +1,321 @@ +use std::time::Duration; + +use serde::Deserialize; +use tracing::debug; + +use crate::DeviceIdentity; +use crate::arylic_client::{ARYLIC_TCP_PORT, DEFAULT_TIMEOUT_SECS, send_command_no_response, send_command_optional, send_command_required}; +use crate::errors::ControlPointError; +use crate::linkplay_client::extract_linkplay_host; +use crate::model::{PlaybackState, RendererInfo}; +use crate::music_renderer::RendererFromMediaRendererInfo; +use crate::music_renderer::capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, + VolumeControl, +}; +use crate::music_renderer::time_utils::{ms_to_seconds, format_hhmmss, parse_hhmmss_strict}; +use crate::music_renderer::musicrenderer::MusicRendererBackend; + +/// Raw response from Arylic MCU+PINFGET command +#[derive(Debug, Deserialize)] +struct ArylicPlaybackInfoRaw { + status: String, + curpos: String, + totlen: String, + #[serde(default)] + vol: Option, + #[serde(default)] + mute: Option, + #[serde(default)] + plicount: Option, + #[serde(default)] + plicurr: Option, +} + +/// Backend speaking the Arylic TCP control protocol (port 8899). +#[derive(Clone, Debug)] +pub struct ArylicTcpRenderer { + host: String, + port: u16, + timeout: Duration, +} + +impl ArylicTcpRenderer { + fn send_required(&self, cmd: &str, expected: &[&str]) -> Result { + send_command_required(&self.host, self.port, self.timeout, cmd, expected) + } + + fn send_optional( + &self, + cmd: &str, + expected: &[&str], + ) -> Result, ControlPointError> { + send_command_optional(&self.host, self.port, self.timeout, cmd, expected) + } + + fn send_no_response(&self, cmd: &str) -> Result<(), ControlPointError> { + send_command_no_response(&self.host, self.port, self.timeout, cmd) + } + + fn fetch_playback_info(&self) -> Result { + let payload = self.send_required("MCU+PINFGET", &["AXX+PLY+INF"])?; + match parse_playback_info(&payload) { + Ok(info) => Ok(info), + Err(err) => { + debug!( + "Failed to parse Arylic playback info for {}: {}", + self.host, err + ); + Err(err) + } + } + } + + fn format_volume_command(value: u16) -> String { + format!("MCU+VOL+{:03}", value.min(100)) + } + + fn parse_volume_payload(payload: &str) -> Result { + let data = payload.strip_prefix("AXX+VOL+").ok_or_else(|| { + ControlPointError::ArilycTcpError(format!("Unexpected volume response: {}", payload)) + })?; + let value: u16 = data.trim().parse().map_err(|_| { + ControlPointError::ArilycTcpError(format!("Invalid volume value: {}", data)) + })?; + Ok(value.min(100)) + } + + fn parse_mute_payload(payload: &str) -> Result { + let data = payload.strip_prefix("AXX+MUT+").ok_or_else(|| { + ControlPointError::ArilycTcpError(format!("Unexpected mute response: {}", payload)) + })?; + match data.trim() { + "000" | "0" => Ok(false), + "001" | "1" => Ok(true), + other => Err(ControlPointError::ArilycTcpError(format!( + "Invalid mute value: {}", + other + ))), + } + } +} + +impl RendererFromMediaRendererInfo for ArylicTcpRenderer { + fn from_renderer_info(info: &RendererInfo) -> Result { + let host = extract_linkplay_host(info.location()).ok_or_else(|| { + ControlPointError::ArilycTcpError(format!( + "Renderer {} has no valid LOCATION host", + info.udn() + )) + })?; + + Ok(Self { + host, + port: ARYLIC_TCP_PORT, + timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS), + }) + } + + fn to_backend(self) -> MusicRendererBackend { + MusicRendererBackend::ArylicTcp(self) + } +} + +impl TransportControl for ArylicTcpRenderer { + fn play_uri(&self, _uri: &str, _meta: &str) -> Result<(), ControlPointError> { + Err(ControlPointError::upnp_operation_not_supported( + "Arylic TCP backend does not support direct URL loading.", + "ArylicTcpRenderer", + )) + } + + fn play(&self) -> Result<(), ControlPointError> { + self.send_no_response("MCU+PLY-PLA") + } + + fn pause(&self) -> Result<(), ControlPointError> { + let _ = self.send_optional("MCU+PLY-PUS", &["AXX+PLY+"])?; + Ok(()) + } + + fn stop(&self) -> Result<(), ControlPointError> { + self.send_no_response("MCU+PLY-STP") + } + + fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { + let _ = parse_hhmmss_strict(hhmmss)?; + Err(ControlPointError::ArilycTcpError( + "Arylic TCP seek_rel_time is not implemented yet for this device.".to_string() + )) + } +} + +impl VolumeControl for ArylicTcpRenderer { + fn volume(&self) -> Result { + if let Ok(info) = self.fetch_playback_info() { + if let Some(vol) = info.volume { + return Ok(vol); + } + debug!( + "Arylic playback info for {} missing volume, falling back to VOL GET", + self.host + ); + } + let payload = self.send_required("MCU+VOL+GET", &["AXX+VOL+"])?; + Self::parse_volume_payload(&payload) + } + + fn set_volume(&self, v: u16) -> Result<(), ControlPointError> { + let command = Self::format_volume_command(v); + let _ = self.send_optional(&command, &["AXX+VOL+"])?; + Ok(()) + } + + fn mute(&self) -> Result { + if let Ok(info) = self.fetch_playback_info() { + if let Some(mute) = info.mute { + return Ok(mute); + } + debug!( + "Arylic playback info for {} missing mute, falling back to MUT GET", + self.host + ); + } + let payload = self.send_required("MCU+MUT+GET", &["AXX+MUT+"])?; + Self::parse_mute_payload(&payload) + } + + fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { + let command = if m { "MCU+MUT+001" } else { "MCU+MUT+000" }; + let payload = self.send_required(command, &["AXX+MUT+"])?; + let _ = Self::parse_mute_payload(&payload)?; + Ok(()) + } +} + +impl PlaybackStatus for ArylicTcpRenderer { + fn playback_state(&self) -> Result { + let info = self.fetch_playback_info()?; + Ok(info.playback_state()) + } +} + +impl PlaybackPosition for ArylicTcpRenderer { + fn playback_position(&self) -> Result { + let info = self.fetch_playback_info()?; + Ok(info.position_info()) + } +} + +#[derive(Debug)] +struct ArylicPlaybackInfo { + status_raw: String, + curpos_ms: u64, + totlen_ms: u64, + volume: Option, + mute: Option, + playlist_size: Option, + track_index: Option, +} + +impl ArylicPlaybackInfo { + fn playback_state(&self) -> PlaybackState { + match self.status_raw.as_str() { + "play" => PlaybackState::Playing, + "pause" => PlaybackState::Paused, + "stop" => PlaybackState::Stopped, + other => PlaybackState::Unknown(other.to_string()), + } + } + + fn position_info(&self) -> PlaybackPositionInfo { + let track = match (self.track_index, self.playlist_size) { + (Some(idx), Some(count)) if count > 0 => Some(idx.min(count)), + (Some(idx), _) => Some(idx), + _ => None, + }; + + PlaybackPositionInfo { + track, + rel_time: Some(format_hhmmss(ms_to_seconds(self.curpos_ms))), + abs_time: None, + track_duration: if self.totlen_ms > 0 { + Some(format_hhmmss(ms_to_seconds(self.totlen_ms))) + } else { + None + }, + track_metadata: None, + track_uri: None, + } + } +} + +fn parse_playback_info(payload: &str) -> Result { + let json_blob = payload.strip_prefix("AXX+PLY+INF").ok_or_else(|| { + ControlPointError::ArilycTcpError(format!("Unexpected playback info prefix: {}", payload)) + })?; + + let json_blob = json_blob.trim_end_matches('&').trim(); + + let raw: ArylicPlaybackInfoRaw = serde_json::from_str(json_blob) + .map_err(|e| ControlPointError::ArilycTcpError(format!("Failed to parse Arylic playback info JSON: {}", e)))?; + + let curpos_ms = raw.curpos.parse::() + .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid curpos value: {}", raw.curpos)))?; + + let totlen_ms = raw.totlen.parse::() + .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid totlen value: {}", raw.totlen)))?; + + let volume = raw.vol.and_then(|raw_vol| { + match raw_vol.parse::() { + Ok(value) => Some(value.min(100)), + Err(err) => { + debug!("Invalid Arylic `vol` value {}: {}", raw_vol, err); + None + } + } + }); + + let mute = raw.mute.and_then(|raw_mute| { + match raw_mute.as_str() { + "1" => Some(true), + "0" => Some(false), + other => { + debug!("Invalid Arylic `mute` value {}", other); + None + } + } + }); + + let playlist_size = raw.plicount.and_then(|raw_count| { + match raw_count.parse::() { + Ok(count) if count > 0 => Some(count), + Ok(_) => None, + Err(err) => { + debug!("Invalid Arylic `plicount` value {}: {}", raw_count, err); + None + } + } + }); + + let track_index = raw.plicurr.and_then(|raw_idx| { + match raw_idx.parse::() { + Ok(idx) if idx > 0 => Some(idx), + Ok(_) => None, + Err(err) => { + debug!("Invalid Arylic `plicurr` value {}: {}", raw_idx, err); + None + } + } + }); + + Ok(ArylicPlaybackInfo { + status_raw: raw.status, + curpos_ms, + totlen_ms, + volume, + mute, + playlist_size, + track_index, + }) +} diff --git a/pmocontrol/src/capabilities.rs b/pmocontrol/src/music_renderer/capabilities.rs similarity index 62% rename from pmocontrol/src/capabilities.rs rename to pmocontrol/src/music_renderer/capabilities.rs index 06f5b85c..bda9f8cf 100644 --- a/pmocontrol/src/capabilities.rs +++ b/pmocontrol/src/music_renderer/capabilities.rs @@ -1,7 +1,7 @@ // pmocontrol/src/capabilities.rs use anyhow::Result; -use crate::errors::ControlPointError; +use crate::{errors::ControlPointError, model::PlaybackState}; /// Logical playback position across backends. /// @@ -21,51 +21,6 @@ pub trait PlaybackPosition { fn playback_position(&self) -> Result; } -/// High-level playback state across backends. -#[derive(Clone, Debug)] -pub enum PlaybackState { - Stopped, - Playing, - Paused, - Transitioning, - NoMedia, - /// Backend-specific or unknown state string. - Unknown(String), -} - -impl PlaybackState { - /// Map a raw UPnP AVTransport CurrentTransportState string - /// to a logical PlaybackState. - pub fn from_upnp_state(raw: &str) -> Self { - let s = raw.trim().to_ascii_uppercase(); - match s.as_str() { - "STOPPED" => PlaybackState::Stopped, - "PLAYING" => PlaybackState::Playing, - "PAUSED_PLAYBACK" => PlaybackState::Paused, - // States from the AVTransport spec that we normalize: - "PAUSED_RECORDING" => PlaybackState::Paused, - "RECORDING" => PlaybackState::Playing, - // Common vendor-specific states: - "TRANSITIONING" => PlaybackState::Transitioning, - "BUFFERING" | "PREPARING" => PlaybackState::Transitioning, - "NO_MEDIA_PRESENT" => PlaybackState::NoMedia, - _ => PlaybackState::Unknown(raw.to_string()), - } - } - - /// Returns a human-readable label for the playback state. - pub fn as_str(&self) -> &str { - match self { - PlaybackState::Stopped => "STOPPED", - PlaybackState::Playing => "PLAYING", - PlaybackState::Paused => "PAUSED", - PlaybackState::Transitioning => "TRANSITIONING", - PlaybackState::NoMedia => "NO_MEDIA", - PlaybackState::Unknown(s) => s.as_str(), - } - } -} - /// Generic abstraction for playback status (transport state). /// /// For UPnP AV, this is backed by AVTransport::GetTransportInfo. diff --git a/pmocontrol/src/chromecast_renderer.rs b/pmocontrol/src/music_renderer/chromecast_renderer.rs similarity index 93% rename from pmocontrol/src/chromecast_renderer.rs rename to pmocontrol/src/music_renderer/chromecast_renderer.rs index e0906d86..bffee820 100644 --- a/pmocontrol/src/chromecast_renderer.rs +++ b/pmocontrol/src/music_renderer/chromecast_renderer.rs @@ -14,18 +14,19 @@ use std::sync::{Arc, Mutex, Once}; use std::thread::JoinHandle; -use anyhow::{Result, anyhow}; - use tracing::debug; use crate::DeviceIdentity; -use crate::capabilities::{ - PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, +use crate::music_renderer::capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl, }; -use crate::chromecast_discovery::{extract_host_from_location, extract_port_from_location}; +use crate::music_renderer::time_utils::{format_hhmmss_f64, parse_hhmmss_strict}; +use crate::discovery::chromecast_discovery::{extract_host_from_location, extract_port_from_location}; use crate::errors::ControlPointError; -use crate::model::{RendererInfo}; +use crate::model::{PlaybackState, RendererInfo}; +use crate::music_renderer::RendererFromMediaRendererInfo; +use crate::music_renderer::musicrenderer::MusicRendererBackend; use rust_cast::{ CastDevice, ChannelMessage, @@ -116,9 +117,8 @@ fn map_player_state(player_state: &CastPlayerState) -> PlaybackState { } -impl ChromecastRenderer { - /// Creates a new ChromecastRenderer from RendererInfo. - pub fn from_renderer_info(info: RendererInfo) -> Result { +impl RendererFromMediaRendererInfo for ChromecastRenderer { + fn from_renderer_info(info: &RendererInfo) -> Result { tracing::info!( "ChromecastRenderer::from_renderer_info location={} for {}", info.location(), @@ -147,6 +147,11 @@ impl ChromecastRenderer { thread_handle, }) } + + fn to_backend(self) -> MusicRendererBackend { + MusicRendererBackend::Chromecast(self) + } + } impl TransportControl for ChromecastRenderer { @@ -428,26 +433,7 @@ impl TransportControl for ChromecastRenderer { fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { debug!("ChromecastRenderer: seek_rel_time({})", hhmmss); - // Parse HH:MM:SS to seconds - let parts: Vec<&str> = hhmmss.split(':').collect(); - if parts.len() != 3 { - return Err(ControlPointError::ChromecastError(format!( - "Invalid time format, expected HH:MM:SS: {}", - hhmmss - ))); - } - - let hours: u32 = parts[0] - .parse() - .map_err(|_| ControlPointError::ChromecastError(format!("Invalid hours in time: {}", hhmmss)))?; - let minutes: u32 = parts[1] - .parse() - .map_err(|_| ControlPointError::ChromecastError(format!("Invalid minutes in time: {}", hhmmss)))?; - let seconds: u32 = parts[2] - .parse() - .map_err(|_| ControlPointError::ChromecastError(format!("Invalid seconds in time: {}", hhmmss)))?; - - let total_seconds = (hours * 3600 + minutes * 60 + seconds) as f32; + let total_seconds = parse_hhmmss_strict(hhmmss)? as f32; let device = connect_to_device(&self.host, self.port)?; @@ -563,13 +549,13 @@ impl PlaybackPosition for ChromecastRenderer { // Extract position information let rel_time = media_entry .current_time - .map(|time| format_time_hhmmss(time as f64)); + .map(|time| format_hhmmss_f64(time as f64)); let track_duration = media_entry .media .as_ref() .and_then(|m| m.duration) - .map(|dur| format_time_hhmmss(dur as f64)); + .map(|dur| format_hhmmss_f64(dur as f64)); let track_uri = media_entry.media.as_ref().map(|m| m.content_id.clone()); @@ -645,15 +631,6 @@ fn detect_content_type_from_meta(uri: &str, meta: &str) -> String { content_type.to_string() } -/// Converts seconds to HH:MM:SS format. -fn format_time_hhmmss(seconds: f64) -> String { - let total_secs = seconds as u64; - let hours = total_secs / 3600; - let minutes = (total_secs % 3600) / 60; - let secs = total_secs % 60; - format!("{:02}:{:02}:{:02}", hours, minutes, secs) -} - impl VolumeControl for ChromecastRenderer { fn volume(&self) -> Result { let device = connect_to_device(&self.host, self.port)?; diff --git a/pmocontrol/src/music_renderer/linkplay_renderer.rs b/pmocontrol/src/music_renderer/linkplay_renderer.rs new file mode 100644 index 00000000..f497f32b --- /dev/null +++ b/pmocontrol/src/music_renderer/linkplay_renderer.rs @@ -0,0 +1,134 @@ +use std::fmt; +use std::time::Duration; + +use ureq::Agent; + +use crate::DeviceIdentity; +use crate::linkplay_client::{LinkPlayStatus, build_agent, extract_linkplay_host, fetch_status_for_host, percent_encode}; +use crate::music_renderer::capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, + VolumeControl, +}; +use crate::music_renderer::time_utils::{parse_hhmmss_strict}; +use crate::errors::ControlPointError; +use crate::model::{RendererInfo, PlaybackState}; +use crate::music_renderer::RendererFromMediaRendererInfo; +use crate::music_renderer::musicrenderer::MusicRendererBackend; + +const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3; + + +/// Renderer backend for devices exposing the LinkPlay HTTP API. +#[derive(Clone)] +pub struct LinkPlayRenderer { + host: String, + timeout: Duration, +} + +impl fmt::Debug for LinkPlayRenderer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("LinkPlayRenderer") + .field("host", &self.host) + .finish() + } +} + +impl LinkPlayRenderer { + + fn agent(&self) -> Agent { + build_agent(self.timeout) + } + + fn send_player_command(&self, command: &str) -> Result<(), ControlPointError> { + let url = format!( + "http://{}/httpapi.asp?command=setPlayerCmd:{}", + self.host, command + ); + self.agent() + .get(&url) + .call() + .map_err(|_| ControlPointError::ArilycTcpError(format!("LinkPlay command {} failed for {}", command, self.host)))?; + Ok(()) + } + + fn fetch_status(&self) -> Result { + fetch_status_for_host(&self.host, self.timeout) + } +} + +impl RendererFromMediaRendererInfo for LinkPlayRenderer { + fn from_renderer_info(info: &RendererInfo) -> Result { + let host = extract_linkplay_host(&info.location()) + .ok_or_else(|| ControlPointError::LinkPlayError(format!("Renderer {} has no valid LOCATION host", info.udn())))?; + + Ok(Self { + host, + timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS), + }) + } + + + fn to_backend(self) -> MusicRendererBackend { + MusicRendererBackend::LinkPlay(self) + } + +} + +impl TransportControl for LinkPlayRenderer { + fn play_uri(&self, uri: &str, _meta: &str) -> Result<(), ControlPointError> { + let encoded = percent_encode(uri); + self.send_player_command(&format!("play:{}", encoded)) + } + + fn play(&self) -> Result<(), ControlPointError> { + self.send_player_command("resume") + } + + fn pause(&self) -> Result<(), ControlPointError> { + self.send_player_command("pause") + } + + fn stop(&self) -> Result<(), ControlPointError> { + self.send_player_command("stop") + } + + fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { + let secs = parse_hhmmss_strict(hhmmss)?; + self.send_player_command(&format!("seek:{}", secs)) + } +} + +impl VolumeControl for LinkPlayRenderer { + fn volume(&self) -> Result { + Ok(self.fetch_status()?.volume) + } + + fn set_volume(&self, v: u16) -> Result<(), ControlPointError> { + let value = v.min(100); + self.send_player_command(&format!("vol:{}", value)) + } + + fn mute(&self) -> Result { + Ok(self.fetch_status()?.mute) + } + + fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { + self.send_player_command(if m { "mute:1" } else { "mute:0" }) + } +} + +impl PlaybackStatus for LinkPlayRenderer { + fn playback_state(&self) -> Result { + Ok(self.fetch_status()?.playback_state()) + } +} + +impl PlaybackPosition for LinkPlayRenderer { + fn playback_position(&self) -> Result { + Ok(self.fetch_status()?.position_info()) + } +} + + + + diff --git a/pmocontrol/src/music_renderer/mod.rs b/pmocontrol/src/music_renderer/mod.rs new file mode 100644 index 00000000..b9c5049e --- /dev/null +++ b/pmocontrol/src/music_renderer/mod.rs @@ -0,0 +1,53 @@ +mod arylic_tcp; +mod linkplay_renderer; + +mod upnp_renderer; + +mod openhome; +mod openhome_renderer; + +mod capabilities; +mod chromecast_renderer; + +mod musicrenderer; +pub mod time_utils; + +use std::sync::{Arc, Mutex}; + +pub use crate::music_renderer::capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, +}; +pub use crate::music_renderer::musicrenderer::MusicRenderer; +use crate::{ + RendererInfo, errors::ControlPointError, music_renderer::musicrenderer::MusicRendererBackend, +}; + +pub trait RendererFromMediaRendererInfo { + fn from_renderer_info(renderer: &RendererInfo) -> Result + where + Self: Sized; + + fn to_backend(self) -> MusicRendererBackend + where + Self: Sized; + + fn build_from_renderer_info( + renderer: &RendererInfo, + ) -> Result + where + Self: Sized, + { + let instance = Self::from_renderer_info(renderer)?; + Ok(instance.to_backend()) + } + + fn make_from_renderer_info( + renderer: &RendererInfo, + ) -> Result>, ControlPointError> + where + Self: Sized, + { + let backend = Self::build_from_renderer_info(renderer)?; + Ok(Arc::new(Mutex::new(backend))) + } +} diff --git a/pmocontrol/src/music_renderer/musicrenderer.rs b/pmocontrol/src/music_renderer/musicrenderer.rs new file mode 100644 index 00000000..3011a912 --- /dev/null +++ b/pmocontrol/src/music_renderer/musicrenderer.rs @@ -0,0 +1,737 @@ +//! Backend-agnostic music renderer façade for PMOMusic. +//! +//! `MusicRenderer` wraps every supported backend (UPnP AV/DLNA, OpenHome, +//! LinkPlay HTTP, Arylic TCP, Chromecast, and the hybrid UPnP + Arylic pairing) behind a +//! single control surface. Higher layers in PMOMusic must only interact with +//! renderers through this type so that transport, volume, and state queries +//! stay backend-neutral. + +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; + +use crate::control_point::PlaylistBinding; +use crate::errors::ControlPointError; +use crate::model::{RendererInfo, RendererProtocol, TrackMetadata, PlaybackState}; +use crate::music_renderer::RendererFromMediaRendererInfo; +use crate::music_renderer::arylic_tcp::ArylicTcpRenderer; +use crate::music_renderer::capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, + VolumeControl, +}; +use crate::music_renderer::chromecast_renderer::ChromecastRenderer; +use crate::music_renderer::linkplay_renderer::LinkPlayRenderer; +use crate::music_renderer::openhome_renderer::OpenHomeRenderer; +use crate::music_renderer::upnp_renderer::UpnpRenderer; +use crate::online::DeviceConnectionState; +use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot}; +use crate::{DeviceId, DeviceIdentity, DeviceOnline}; + +use tracing::warn; + +/// Backend-agnostic façade exposing transport, volume, and status contracts. +#[derive(Clone, Debug)] +pub enum MusicRendererBackend { + /// Classic UPnP AV / DLNA renderer (AVTransport + RenderingControl). + Upnp(UpnpRenderer), + /// Renderer powered by OpenHome services. + OpenHome(OpenHomeRenderer), + /// Renderer controlled via the LinkPlay HTTP API. + LinkPlay(LinkPlayRenderer), + /// Renderer reachable through the Arylic TCP control protocol (port 8899). + ArylicTcp(ArylicTcpRenderer), + /// Renderer controlled via the Google Cast protocol (Chromecast). + Chromecast(ChromecastRenderer), + /// Combined backend using UPnP for transport + volume writes and Arylic TCP + /// to read detailed playback information as well as live volume/mute state. + HybridUpnpArylic { + upnp: UpnpRenderer, + arylic: ArylicTcpRenderer, + }, +} + +#[derive(Debug, Clone)] +pub struct MusicRenderer { + info: RendererInfo, + connection: Arc>, + backend: Arc>, + queue: Arc>, + playlist_binding: Arc>>, +} + +impl MusicRenderer { + pub fn new( + info: RendererInfo, + backend: Arc>, + queue: Arc>, + ) -> Arc { + let connection = DeviceConnectionState::new(); + + let renderer = MusicRenderer { + info, + connection: Arc::new(Mutex::new(connection)), + backend, + queue, + playlist_binding: Arc::new(Mutex::new(None)), + }; + + Arc::new(renderer) + } + + pub fn from_renderer_info(info: &RendererInfo) -> Result { + let connection = Arc::new(Mutex::new(DeviceConnectionState::new())); + let backend = MusicRendererBackend::make_from_renderer_info(info)?; + let queue = MusicQueue::make_from_renderer_info(info)?; + + let renderer = MusicRenderer { + info: info.clone(), + connection, + backend, + queue, + playlist_binding: Arc::new(Mutex::new(None)), + }; + Ok(renderer) + } + + pub fn info(&self) -> &RendererInfo { + &self.info + } + + /// Returns the protocol. + fn protocol(&self) -> RendererProtocol { + self.info.protocol() + } + + fn is_upnp(&self) -> bool { + match &*self.backend.lock().expect("Backend mutex poisoned") { + MusicRendererBackend::Upnp(_) => true, + _ => false, + } + } + + fn is_openhome(&self) -> bool { + match &*self.backend.lock().expect("Backend mutex poisoned") { + MusicRendererBackend::OpenHome(_) => true, + _ => false, + } + } + + fn is_linkplay(&self) -> bool { + match &*self.backend.lock().expect("Backend mutex poisoned") { + MusicRendererBackend::LinkPlay(_) => true, + _ => false, + } + } + + fn is_arylictcp(&self) -> bool { + match &*self.backend.lock().expect("Backend mutex poisoned") { + MusicRendererBackend::ArylicTcp(_) => true, + _ => false, + } + } + + fn is_chromecast(&self) -> bool { + match &*self.backend.lock().expect("Backend mutex poisoned") { + MusicRendererBackend::Chromecast(_) => true, + _ => false, + } + } + + fn is_hybridupnparylic(&self) -> bool { + match &*self.backend.lock().expect("Backend mutex poisoned") { + MusicRendererBackend::HybridUpnpArylic { .. } => true, + _ => false, + } + } + + /// Returns true if this renderer is known to support SetNextAVTransportURI. + pub fn supports_set_next(&self) -> bool { + self.info.capabilities().supports_set_next() + } + + /// Prepare the renderer for attaching a new playlist by clearing the queue and stopping playback. + pub fn clear_for_playlist_attach(&self) -> Result<(), ControlPointError> { + // Clear the queue first + self.queue + .lock() + .expect("Queue mutex poisoned") + .clear_queue()?; + + // Then stop playback (ignore errors if already stopped) + self.backend + .lock() + .expect("Backend mutex poisoned") + .stop() + .or_else(|err| { + warn!( + renderer = self.id().0.as_str(), + error = %err, + "Stop failed when preparing for playlist attach (continuing anyway)" + ); + Ok(()) + }) + } + + /// Get the current queue snapshot. + pub fn queue_snapshot(&self) -> Result { + self.queue + .lock() + .expect("Queue mutex poisoned") + .queue_snapshot() + } + + /// Play the current item from the queue. + pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> { + let queue = self.queue.lock().expect("Queue mutex poisoned"); + let backend = self.backend.lock().expect("Backend mutex poisoned"); + + // Get the current item from the queue + let (item, _remaining) = queue.peek_current()?.ok_or_else(|| { + ControlPointError::QueueError("Queue is empty, cannot play current".to_string()) + })?; + + // Build DIDL metadata if available + let metadata_xml = item + .metadata + .as_ref() + .map(|m| build_didl_lite_metadata(m, &item.uri, &item.protocol_info)) + .unwrap_or_default(); + + // Play the URI using the backend + backend.play_uri(&item.uri, &metadata_xml)?; + + Ok(()) + } + + /// Advance to and play the next item from the queue. + pub fn play_next_from_queue(&self) -> Result<(), ControlPointError> { + let mut queue = self.queue.lock().expect("Queue mutex poisoned"); + let backend = self.backend.lock().expect("Backend mutex poisoned"); + + // Dequeue the next item + let (item, _remaining) = queue + .dequeue_next()? + .ok_or_else(|| ControlPointError::QueueError("No next item in queue".to_string()))?; + + // Build DIDL metadata if available + let metadata_xml = item + .metadata + .as_ref() + .map(|m| build_didl_lite_metadata(m, &item.uri, &item.protocol_info)) + .unwrap_or_default(); + + // Play the URI using the backend + backend.play_uri(&item.uri, &metadata_xml)?; + + Ok(()) + } + + /// Transport control: play URI with metadata + pub fn play_uri(&self, uri: &str, metadata: &str) -> Result<(), ControlPointError> { + self.backend + .lock() + .expect("Backend mutex poisoned") + .play_uri(uri, metadata) + } + + /// Transport control: play + pub fn play(&self) -> Result<(), ControlPointError> { + self.backend.lock().expect("Backend mutex poisoned").play() + } + + /// Transport control: pause + pub fn pause(&self) -> Result<(), ControlPointError> { + self.backend.lock().expect("Backend mutex poisoned").pause() + } + + /// Transport control: stop + pub fn stop(&self) -> Result<(), ControlPointError> { + self.backend.lock().expect("Backend mutex poisoned").stop() + } + + /// Transport control: seek to relative time + pub fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { + self.backend + .lock() + .expect("Backend mutex poisoned") + .seek_rel_time(hhmmss) + } + + /// Volume control: get current volume + pub fn volume(&self) -> Result { + self.backend + .lock() + .expect("Backend mutex poisoned") + .volume() + } + + /// Volume control: set volume + pub fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> { + self.backend + .lock() + .expect("Backend mutex poisoned") + .set_volume(vol) + } + + /// Volume control: get mute state + pub fn mute(&self) -> Result { + self.backend.lock().expect("Backend mutex poisoned").mute() + } + + /// Volume control: set mute state + pub fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { + self.backend + .lock() + .expect("Backend mutex poisoned") + .set_mute(m) + } + + /// Get playback state + pub fn playback_state(&self) -> Result { + self.backend + .lock() + .expect("Backend mutex poisoned") + .playback_state() + } + + /// Get playback position + pub fn playback_position(&self) -> Result { + self.backend + .lock() + .expect("Backend mutex poisoned") + .playback_position() + } + + /// Sets the playlist binding for this renderer. + pub fn set_playlist_binding(&self, binding: Option) { + *self.playlist_binding.lock().expect("Playlist binding mutex poisoned") = binding; + } + + /// Gets the current playlist binding, if any. + pub fn get_playlist_binding(&self) -> Option { + self.playlist_binding.lock().expect("Playlist binding mutex poisoned").clone() + } + + /// Clears the playlist binding. + pub fn clear_playlist_binding(&self) { + *self.playlist_binding.lock().expect("Playlist binding mutex poisoned") = None; + } + + /// Clears the renderer's queue using the generic QueueBackend trait. + pub fn clear_queue(&self) -> Result<(), ControlPointError> { + let mut queue = self.queue.lock().expect("Queue mutex poisoned"); + queue.clear_queue() + } + + /// Adds a track to the queue. + /// + /// This is primarily for backends with persistent queues (OpenHome). + /// For backends without this capability, it returns an error. + /// + /// Returns the backend-specific track ID if applicable. + pub fn add_track_to_queue( + &self, + _uri: &str, + _metadata: &str, + _after_id: Option, + _play: bool, + ) -> Result, ControlPointError> { + // This operation requires backend-specific APIs (especially for OpenHome) + // that aren't exposed through the generic QueueBackend trait. + // The generic implementation cannot support this without more context + // (media_server_id, didl_id, protocol_info, etc.). + Err(ControlPointError::QueueError( + "add_track_to_queue requires backend-specific implementation".to_string() + )) + } + + /// Selects and plays a specific track from the queue by ID. + /// + /// Converts the track ID to a position using the generic QueueBackend trait, + /// then plays the track. + pub fn select_queue_track(&self, track_id: u32) -> Result<(), ControlPointError> { + let queue = self.queue.lock().expect("Queue mutex poisoned"); + + // Convert track ID to position using generic QueueBackend trait + let position = queue.id_to_position(track_id)?; + drop(queue); + + // Set the index + let mut queue = self.queue.lock().expect("Queue mutex poisoned"); + queue.set_index(Some(position))?; + + // Get the item to play + let item = queue.get_item(position)? + .ok_or_else(|| ControlPointError::QueueError("Track not found".to_string()))?; + drop(queue); + + // Build metadata XML + let metadata_xml = item + .metadata + .as_ref() + .map(|m| build_didl_lite_metadata(m, &item.uri, &item.protocol_info)) + .unwrap_or_default(); + + // Play the item using TransportControl + let backend = self.backend.lock().expect("Backend mutex poisoned"); + backend.play_uri(&item.uri, &metadata_xml)?; + + Ok(()) + } + + /// Synchronizes the queue state with the backend. + /// + /// For backends with persistent queues (OpenHome), this refreshes the local view. + /// For others, this is essentially a no-op (just reads the current state). + pub fn sync_queue_state(&self) -> Result<(), ControlPointError> { + let queue = self.queue.lock().expect("Queue mutex poisoned"); + // Calling queue_snapshot() triggers a refresh for backends that need it + let _ = queue.queue_snapshot()?; + Ok(()) + } + + /// Plays the current item from the backend queue. + /// + /// This is primarily for backends with persistent queues (OpenHome). + pub fn play_current_from_backend_queue(&self) -> Result<(), ControlPointError> { + let queue = self.queue.lock().expect("Queue mutex poisoned"); + + // Get current track ID using generic QueueBackend trait + let track_id = queue.current_track()? + .ok_or_else(|| ControlPointError::QueueError("No current track".to_string()))?; + + drop(queue); + + // Play it using select_queue_track + self.select_queue_track(track_id) + } + + /// Returns a reference to the queue (read-only access via lock). + /// This is a convenience method to avoid repetitive `queue.lock().unwrap()` patterns. + pub fn get_queue(&self) -> std::sync::MutexGuard<'_, MusicQueue> { + self.queue.lock().unwrap() + } + + /// Returns a mutable reference to the queue (write access via lock). + /// This is a convenience method to avoid repetitive `queue.lock().unwrap()` patterns. + pub fn get_queue_mut(&self) -> std::sync::MutexGuard<'_, MusicQueue> { + self.queue.lock().unwrap() + } +} + +/// Helper function to build DIDL-Lite metadata XML from TrackMetadata +fn build_didl_lite_metadata(metadata: &TrackMetadata, uri: &str, protocol_info: &str) -> String { + format!( + r#" + +{} +{} +{} +{} +{} +{} + +"#, + metadata.title.as_deref().unwrap_or("Unknown Title"), + metadata + .creator + .as_deref() + .or(metadata.artist.as_deref()) + .unwrap_or("Unknown Artist"), + metadata.artist.as_deref().unwrap_or("Unknown Artist"), + metadata.album.as_deref().unwrap_or("Unknown Album"), + metadata + .album_art_uri + .as_ref() + .map(|art_uri| format!("{}", art_uri)) + .unwrap_or_default(), + protocol_info, + uri + ) +} + +impl DeviceIdentity for MusicRenderer { + fn id(&self) -> DeviceId { + self.info.id() + } + + fn udn(&self) -> &str { + &*self.info.udn() + } + + fn friendly_name(&self) -> &str { + &self.info.friendly_name() + } + + fn model_name(&self) -> &str { + &self.info.model_name() + } + fn manufacturer(&self) -> &str { + &self.info.manufacturer() + } + fn location(&self) -> &str { + &self.info.location() + } + fn server_header(&self) -> &str { + &self.info.server_header() + } +} + +impl DeviceOnline for MusicRenderer { + fn is_online(&self) -> bool { + self.connection + .lock() + .expect("Connection mutex poisoned") + .is_online() + } + + fn last_seen(&self) -> SystemTime { + self.connection + .lock() + .expect("Connection mutex poisoned") + .last_seen() + } + + fn has_been_seen_now(&self, max_age: u32) { + self.connection + .lock() + .expect("Connection mutex poisoned") + .has_been_seen_now(max_age) + } + + fn mark_as_offline(&self) { + self.connection + .lock() + .expect("Connection mutex poisoned") + .mark_as_offline() + } + + fn max_age(&self) -> u32 { + self.connection + .lock() + .expect("Connection mutex poisoned") + .max_age() + } +} + +impl RendererFromMediaRendererInfo for MusicRendererBackend { + fn from_renderer_info(info: &RendererInfo) -> Result { + // Try OpenHome first for OpenHome-capable renderers + match info.protocol() { + RendererProtocol::OpenHomeOnly | RendererProtocol::OpenHomeHybrid => { + if let Ok(backend) = OpenHomeRenderer::build_from_renderer_info(info) { + return Ok(backend); + } + // If OpenHomeOnly failed, it's an error + if matches!(info.protocol(), RendererProtocol::OpenHomeOnly) { + return Err(ControlPointError::MusicRendererBackendBuild(format!( + "OpenHomeOnly renderer {} has no usable OpenHome services", + info.friendly_name() + ))); + } + // OpenHomeHybrid: fall through to try UPnP-based backends + } + RendererProtocol::ChromecastOnly => { + return ChromecastRenderer::build_from_renderer_info(info); + } + RendererProtocol::UpnpAvOnly => { + // Will be handled below + } + } + + // UPnP-based renderers (UpnpAvOnly or OpenHomeHybrid fallback) + let has_arylic = info.capabilities().has_arylic_tcp; + let has_avtransport = info.capabilities().has_avtransport(); + + // Try Hybrid UPnP + Arylic (special case: combines two backends) + if has_arylic && has_avtransport { + let upnp_backend = UpnpRenderer::build_from_renderer_info(info)?; + if let MusicRendererBackend::Upnp(upnp) = upnp_backend { + match ArylicTcpRenderer::build_from_renderer_info(info) { + Ok(MusicRendererBackend::ArylicTcp(arylic)) => { + return Ok(MusicRendererBackend::HybridUpnpArylic { upnp, arylic }); + } + Err(err) => { + warn!( + renderer = info.friendly_name(), + error = %err, + "Failed to build Arylic TCP backend, falling back to UPnP only" + ); + return Ok(MusicRendererBackend::Upnp(upnp)); + } + _ => unreachable!( + "ArylicTcpRenderer::build_from_renderer_info should return ArylicTcp variant" + ), + } + } else { + unreachable!("UpnpRenderer::build_from_renderer_info should return Upnp variant"); + } + } + + // Try LinkPlay + if info.capabilities().has_linkplay_http() { + if let Ok(backend) = LinkPlayRenderer::build_from_renderer_info(info) { + return Ok(backend); + } + } + + // Fallback to UPnP + if has_avtransport { + return UpnpRenderer::build_from_renderer_info(info); + } + + // No suitable backend found + Err(ControlPointError::MusicRendererBackendBuild(format!( + "No suitable backend for renderer {}", + info.friendly_name() + ))) + } + + fn to_backend(self) -> MusicRendererBackend { + self + } +} + +/// Transport control façade that dispatches to whichever backend can fulfill +/// the request, returning a standardized error if the backend lacks support. +impl TransportControl for MusicRendererBackend { + fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { + match self { + MusicRendererBackend::Upnp(upnp) => upnp.play_uri(uri, meta), + MusicRendererBackend::OpenHome(oh) => oh.play_uri(uri, meta), + MusicRendererBackend::LinkPlay(lp) => lp.play_uri(uri, meta), + MusicRendererBackend::ArylicTcp(_) => Err( + ControlPointError::upnp_operation_not_supported("play_uri", "ArylicTcp"), + ), + MusicRendererBackend::Chromecast(cc) => cc.play_uri(uri, meta), + MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta), + } + } + + fn play(&self) -> Result<(), ControlPointError> { + match self { + MusicRendererBackend::Upnp(upnp) => upnp.play(), + MusicRendererBackend::OpenHome(oh) => oh.play(), + MusicRendererBackend::LinkPlay(lp) => lp.play(), + MusicRendererBackend::ArylicTcp(ary) => ary.play(), + MusicRendererBackend::Chromecast(cc) => cc.play(), + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.play(), + } + } + + fn pause(&self) -> Result<(), ControlPointError> { + match self { + MusicRendererBackend::Upnp(upnp) => upnp.pause(), + MusicRendererBackend::OpenHome(oh) => oh.pause(), + MusicRendererBackend::LinkPlay(lp) => lp.pause(), + MusicRendererBackend::ArylicTcp(ary) => ary.pause(), + MusicRendererBackend::Chromecast(cc) => cc.pause(), + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.pause(), + } + } + + fn stop(&self) -> Result<(), ControlPointError> { + match self { + MusicRendererBackend::Upnp(upnp) => upnp.stop(), + MusicRendererBackend::OpenHome(oh) => oh.stop(), + MusicRendererBackend::LinkPlay(lp) => lp.stop(), + MusicRendererBackend::ArylicTcp(ary) => ary.stop(), + MusicRendererBackend::Chromecast(cc) => cc.stop(), + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.stop(), + } + } + + fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { + match self { + MusicRendererBackend::Upnp(upnp) => upnp.seek_rel_time(hhmmss), + MusicRendererBackend::OpenHome(oh) => oh.seek_rel_time(hhmmss), + MusicRendererBackend::LinkPlay(lp) => lp.seek_rel_time(hhmmss), + MusicRendererBackend::ArylicTcp(_) => Err( + ControlPointError::upnp_operation_not_supported("seek_rel_time", "ArylicTcp"), + ), + MusicRendererBackend::Chromecast(cc) => cc.seek_rel_time(hhmmss), + MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.seek_rel_time(hhmmss), + } + } +} + +/// Volume and mute controls exposed via the façade. +/// +/// Hybrid backends may read via Arylic TCP and write via UPnP, but callers +/// always depend on a single [`VolumeControl`] entry point. +impl VolumeControl for MusicRendererBackend { + fn volume(&self) -> Result { + match self { + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.volume(), + MusicRendererBackend::ArylicTcp(ary) => ary.volume(), + MusicRendererBackend::OpenHome(oh) => oh.volume(), + MusicRendererBackend::Upnp(upnp) => upnp.volume(), + MusicRendererBackend::LinkPlay(lp) => lp.volume(), + MusicRendererBackend::Chromecast(cc) => cc.volume(), + } + } + + fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> { + match self { + MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_volume(vol), + MusicRendererBackend::ArylicTcp(ary) => ary.set_volume(vol), + MusicRendererBackend::OpenHome(oh) => oh.set_volume(vol), + MusicRendererBackend::Upnp(upnp) => upnp.set_volume(vol), + MusicRendererBackend::LinkPlay(lp) => lp.set_volume(vol), + MusicRendererBackend::Chromecast(cc) => cc.set_volume(vol), + } + } + + fn mute(&self) -> Result { + match self { + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.mute(), + MusicRendererBackend::OpenHome(r) => r.mute(), + MusicRendererBackend::Upnp(r) => r.mute(), + MusicRendererBackend::LinkPlay(r) => r.mute(), + MusicRendererBackend::ArylicTcp(r) => r.mute(), + MusicRendererBackend::Chromecast(cc) => cc.mute(), + } + } + + fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { + match self { + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.set_mute(m), + MusicRendererBackend::OpenHome(r) => r.set_mute(m), + MusicRendererBackend::Upnp(r) => r.set_mute(m), + MusicRendererBackend::LinkPlay(r) => r.set_mute(m), + MusicRendererBackend::ArylicTcp(r) => r.set_mute(m), + MusicRendererBackend::Chromecast(cc) => cc.set_mute(m), + } + } +} + +/// Playback-state queries sourced from the backend best suited for the job. +/// +/// Each backend reports into [`PlaybackState`], ensuring consumers never have +/// to reason about protocol-specific state machines. +impl PlaybackStatus for MusicRendererBackend { + fn playback_state(&self) -> Result { + match self { + MusicRendererBackend::Upnp(r) => PlaybackStatus::playback_state(r), + MusicRendererBackend::OpenHome(r) => PlaybackStatus::playback_state(r), + MusicRendererBackend::LinkPlay(r) => r.playback_state(), + MusicRendererBackend::ArylicTcp(r) => r.playback_state(), + MusicRendererBackend::Chromecast(cc) => cc.playback_state(), + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_state(), + } + } +} + +/// Playback-position queries that always yield a [`PlaybackPositionInfo`] +/// regardless of the backend providing the raw transport data. +impl PlaybackPosition for MusicRendererBackend { + fn playback_position(&self) -> Result { + match self { + MusicRendererBackend::Upnp(r) => r.playback_position(), + MusicRendererBackend::OpenHome(r) => r.playback_position(), + MusicRendererBackend::LinkPlay(r) => r.playback_position(), + MusicRendererBackend::ArylicTcp(r) => r.playback_position(), + MusicRendererBackend::Chromecast(cc) => cc.playback_position(), + MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_position(), + } + } +} diff --git a/pmocontrol/src/openhome.rs b/pmocontrol/src/music_renderer/openhome.rs similarity index 89% rename from pmocontrol/src/openhome.rs rename to pmocontrol/src/music_renderer/openhome.rs index 736574c7..000d0cc2 100644 --- a/pmocontrol/src/openhome.rs +++ b/pmocontrol/src/music_renderer/openhome.rs @@ -1,6 +1,9 @@ -use crate::model::RendererInfo; -use crate::openhome_client::{ - OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, OhTimeClient, OhVolumeClient, +use crate::{ + model::RendererInfo, + upnp_clients::{ + OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, OhTimeClient, + OhVolumeClient, + }, }; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -35,14 +38,8 @@ fn endpoint_for(info: &RendererInfo, kind: OhServiceKind) -> Option ( - info.oh_info_control_url()?, - info.oh_info_service_type()?, - ), - OhServiceKind::Time => ( - info.oh_time_control_url()?, - info.oh_time_service_type()?, - ), + OhServiceKind::Info => (info.oh_info_control_url()?, info.oh_info_service_type()?), + OhServiceKind::Time => (info.oh_time_control_url()?, info.oh_time_service_type()?), OhServiceKind::Volume => ( info.oh_volume_control_url()?, info.oh_volume_service_type()?, @@ -110,16 +107,16 @@ pub fn build_product_client(info: &RendererInfo) -> Option { pub fn build_radio_client(info: &RendererInfo) -> Option { let control_url = info.oh_radio_control_url()?; let service_type = info.oh_radio_service_type()?; - Some(OhRadioClient::new( - control_url, - service_type, - )) + Some(OhRadioClient::new(control_url, service_type)) } #[cfg(test)] mod tests { use super::*; - use crate::{DeviceId, model::{RendererCapabilities, RendererInfo, RendererProtocol}}; + use crate::{ + DeviceId, + model::{RendererCapabilities, RendererInfo, RendererProtocol}, + }; fn sample_renderer_info() -> RendererInfo { RendererInfo::make( diff --git a/pmocontrol/src/openhome_renderer.rs b/pmocontrol/src/music_renderer/openhome_renderer.rs similarity index 82% rename from pmocontrol/src/openhome_renderer.rs rename to pmocontrol/src/music_renderer/openhome_renderer.rs index db6712ba..d9f0a76c 100644 --- a/pmocontrol/src/openhome_renderer.rs +++ b/pmocontrol/src/music_renderer/openhome_renderer.rs @@ -1,23 +1,22 @@ -use std::sync::{Arc, Mutex}; - -use crate::{DeviceIdentity, MusicRendererBackend}; -use crate::capabilities::{ - PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, +use crate::DeviceIdentity; +use crate::music_renderer::capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl, }; +use crate::music_renderer::time_utils::{parse_time_flexible, format_hhmmss_u32}; use crate::errors::ControlPointError; -use crate::model::RendererInfo; -use crate::openhome::{ +use crate::model::{RendererInfo, PlaybackState}; +use crate::music_renderer::RendererFromMediaRendererInfo; +use crate::music_renderer::musicrenderer::MusicRendererBackend; +use crate::music_renderer::openhome::{ build_info_client, build_playlist_client, build_product_client, build_radio_client, build_time_client, build_volume_client, }; -use crate::openhome_client::{ +use crate::upnp_clients::{ OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, - OhTimeClient, OhTrackEntry, OhVolumeClient, parse_track_metadata_from_didl, + OhTimeClient, OhVolumeClient, }; -use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; -use anyhow::{Result, anyhow}; use tracing::debug; #[derive(Clone, Debug)] @@ -50,29 +49,6 @@ impl OpenHomeRenderer { } } - pub fn from_renderer_info( - info: RendererInfo, - ) -> Result>, ControlPointError> { - let renderer = OpenHomeRenderer::new( - build_playlist_client(&info), - build_info_client(&info), - build_time_client(&info), - build_volume_client(&info), - build_product_client(&info), - build_radio_client(&info), - ); - - if renderer.has_any_openhome_service() { - Ok(Arc::new(Mutex::new(MusicRendererBackend::OpenHome( - renderer, - )))) - } else { - Err(ControlPointError::OpenHomeNotAValidDevice(format!( - "{:?}", - info.id() - ))) - } - } pub fn has_playlist(&self) -> bool { self.playlist.is_some() @@ -159,7 +135,7 @@ impl OpenHomeRenderer { /// Retourne la longueur de la playlist OpenHome sans récupérer toutes les métadonnées. /// Plus rapide que snapshot_openhome_playlist() pour juste connaître le nombre de pistes. - pub(crate) fn openhome_playlist_len(&self) -> Result { + pub(crate) fn openhome_playlist_len(&self) -> Result { let playlist = self.playlist_client_for("openhome_playlist_len")?; let ids = playlist.id_array()?; Ok(ids.len()) @@ -196,17 +172,47 @@ impl OpenHomeRenderer { let new_id = playlist.insert(insert_after, uri, metadata)?; if play { - playlist.play_id(new_id)?; + playlist.seek_id(new_id)?; } Ok(new_id) } pub(crate) fn play_openhome_track_id(&self, id: u32) -> Result<(), ControlPointError> { let playlist = self.playlist_client_for("play_openhome_track_id")?; - playlist.play_id(id) + playlist.seek_id(id) } } + +impl RendererFromMediaRendererInfo for OpenHomeRenderer { + fn from_renderer_info( + info: &RendererInfo, + ) -> Result { + let renderer = OpenHomeRenderer::new( + build_playlist_client(&info), + build_info_client(&info), + build_time_client(&info), + build_volume_client(&info), + build_product_client(&info), + build_radio_client(&info), + ); + + if renderer.has_any_openhome_service() { + Ok(renderer) + } else { + Err(ControlPointError::OpenHomeNotAValidDevice(format!( + "{:?}", + info.id() + ))) + } + } + + fn to_backend(self) -> MusicRendererBackend { + MusicRendererBackend::OpenHome(self) + } + +} + impl TransportControl for OpenHomeRenderer { fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { let playlist = self.playlist_client_for("play_uri")?; @@ -241,9 +247,7 @@ impl TransportControl for OpenHomeRenderer { } fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { - let seconds = parse_hms(hhmmss).ok_or_else(|| { - ControlPointError::upnp_bad_return_value("HH:MM:SS", &hhmmss) - })?; + let seconds = parse_time_flexible(hhmmss)?; let playlist = self.playlist_client_for("seek_rel_time")?; playlist.seek_second_absolute(seconds) } @@ -314,29 +318,15 @@ impl PlaybackPosition for OpenHomeRenderer { Ok(PlaybackPositionInfo { track: track_id, - rel_time: Some(format_seconds(time_info.elapsed_secs)), + rel_time: Some(format_hhmmss_u32(time_info.elapsed_secs)), abs_time: None, - track_duration: Some(format_seconds(time_info.duration_secs)), + track_duration: Some(format_hhmmss_u32(time_info.duration_secs)), track_metadata: track_metadata_xml, track_uri, }) } } -fn parse_hms(input: &str) -> Option { - let parts: Vec<&str> = input.split(':').collect(); - if parts.is_empty() || parts.len() > 3 { - return None; - } - - let mut total = 0u32; - for part in parts { - let value = part.parse::().ok()?; - total = total * 60 + value; - } - Some(total) -} - pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState { match raw.trim().to_ascii_uppercase().as_str() { "PLAYING" => PlaybackState::Playing, @@ -347,21 +337,3 @@ pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState { } } -pub(crate) fn format_seconds(seconds: u32) -> String { - let hours = seconds / 3600; - let minutes = (seconds % 3600) / 60; - let secs = seconds % 60; - format!("{hours:02}:{minutes:02}:{secs:02}") -} - -fn convert_oh_track_entry(entry: &OhTrackEntry) -> OpenHomePlaylistTrack { - let metadata = parse_track_metadata_from_didl(&entry.metadata_xml); - OpenHomePlaylistTrack { - id: entry.id, - uri: entry.uri.clone(), - title: metadata.as_ref().and_then(|m| m.title.clone()), - artist: metadata.as_ref().and_then(|m| m.artist.clone()), - album: metadata.as_ref().and_then(|m| m.album.clone()), - album_art_uri: metadata.and_then(|m| m.album_art_uri), - } -} diff --git a/pmocontrol/src/music_renderer/time_utils.rs b/pmocontrol/src/music_renderer/time_utils.rs new file mode 100644 index 00000000..819096f8 --- /dev/null +++ b/pmocontrol/src/music_renderer/time_utils.rs @@ -0,0 +1,218 @@ +//! Time formatting and parsing utilities for music renderers. +//! +//! This module provides utilities to convert between different time representations: +//! - HH:MM:SS format (UPnP standard) +//! - Seconds (u32/u64) +//! - Milliseconds +//! +//! All functions are designed to be robust and provide clear error messages. + +use crate::errors::ControlPointError; + +/// Formats a duration in seconds as HH:MM:SS. +/// +/// # Examples +/// ``` +/// # use pmocontrol::music_renderer::time_utils::format_hhmmss; +/// assert_eq!(format_hhmmss(0), "00:00:00"); +/// assert_eq!(format_hhmmss(61), "00:01:01"); +/// assert_eq!(format_hhmmss(3661), "01:01:01"); +/// ``` +pub fn format_hhmmss(seconds: u64) -> String { + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + let secs = seconds % 60; + format!("{:02}:{:02}:{:02}", hours, minutes, secs) +} + +/// Formats a duration in seconds as HH:MM:SS (u32 variant). +/// +/// Convenience wrapper for u32 values. +pub fn format_hhmmss_u32(seconds: u32) -> String { + format_hhmmss(seconds as u64) +} + +/// Formats a duration in seconds as HH:MM:SS (f64 variant). +/// +/// Used by Chromecast which returns floating point durations. +/// Rounds to nearest second. +pub fn format_hhmmss_f64(seconds: f64) -> String { + format_hhmmss(seconds.round() as u64) +} + +/// Parses a time string in HH:MM:SS, MM:SS, or SS format to seconds. +/// +/// This is the most flexible parser, supporting multiple formats: +/// - "HH:MM:SS" → hours * 3600 + minutes * 60 + seconds +/// - "MM:SS" → minutes * 60 + seconds +/// - "SS" → seconds +/// +/// # Examples +/// ``` +/// # use pmocontrol::music_renderer::time_utils::parse_time_flexible; +/// assert_eq!(parse_time_flexible("01:02:03").unwrap(), 3723); +/// assert_eq!(parse_time_flexible("02:03").unwrap(), 123); +/// assert_eq!(parse_time_flexible("42").unwrap(), 42); +/// ``` +/// +/// # Errors +/// Returns an error if: +/// - The input has more than 3 parts +/// - Any part is not a valid u32 +pub fn parse_time_flexible(input: &str) -> Result { + let parts: Vec<&str> = input.split(':').collect(); + + if parts.is_empty() || parts.len() > 3 { + return Err(ControlPointError::InvalidTimeFormat( + format!("Invalid time format '{}': expected HH:MM:SS, MM:SS, or SS", input) + )); + } + + let mut total = 0u32; + for part in parts { + let value = part.parse::().map_err(|_| { + ControlPointError::InvalidTimeFormat( + format!("Invalid numeric value '{}' in time string '{}'", part, input) + ) + })?; + total = total * 60 + value; + } + + Ok(total) +} + +/// Parses a strict HH:MM:SS format to seconds. +/// +/// This parser requires exactly 3 components separated by colons, +/// and validates that minutes and seconds are < 60. +/// +/// # Examples +/// ``` +/// # use pmocontrol::music_renderer::time_utils::parse_hhmmss_strict; +/// assert_eq!(parse_hhmmss_strict("01:02:03").unwrap(), 3723); +/// assert!(parse_hhmmss_strict("02:03").is_err()); // requires HH:MM:SS +/// assert!(parse_hhmmss_strict("00:61:00").is_err()); // minutes > 59 +/// ``` +/// +/// # Errors +/// Returns an error if: +/// - The format is not exactly HH:MM:SS +/// - Minutes or seconds are >= 60 +/// - Any component is not a valid u64 +pub fn parse_hhmmss_strict(input: &str) -> Result { + let parts: Vec<&str> = input.split(':').collect(); + + if parts.len() != 3 { + return Err(ControlPointError::InvalidTimeFormat( + format!("Invalid time format '{}': expected exactly HH:MM:SS", input) + )); + } + + let hours: u64 = parts[0].parse().map_err(|_| { + ControlPointError::InvalidTimeFormat( + format!("Invalid hour component in '{}'", input) + ) + })?; + + let minutes: u64 = parts[1].parse().map_err(|_| { + ControlPointError::InvalidTimeFormat( + format!("Invalid minute component in '{}'", input) + ) + })?; + + let seconds: u64 = parts[2].parse().map_err(|_| { + ControlPointError::InvalidTimeFormat( + format!("Invalid second component in '{}'", input) + ) + })?; + + if minutes >= 60 || seconds >= 60 { + return Err(ControlPointError::InvalidTimeFormat( + format!("Invalid time '{}': minutes and seconds must be < 60", input) + )); + } + + Ok(hours * 3600 + minutes * 60 + seconds) +} + +/// Converts milliseconds to seconds (rounding down). +#[inline] +pub fn ms_to_seconds(milliseconds: u64) -> u64 { + milliseconds / 1000 +} + +/// Converts seconds to milliseconds. +#[inline] +pub fn seconds_to_ms(seconds: u64) -> u64 { + seconds * 1000 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_hhmmss() { + assert_eq!(format_hhmmss(0), "00:00:00"); + assert_eq!(format_hhmmss(1), "00:00:01"); + assert_eq!(format_hhmmss(60), "00:01:00"); + assert_eq!(format_hhmmss(61), "00:01:01"); + assert_eq!(format_hhmmss(3600), "01:00:00"); + assert_eq!(format_hhmmss(3661), "01:01:01"); + assert_eq!(format_hhmmss(86399), "23:59:59"); + } + + #[test] + fn test_format_hhmmss_f64() { + assert_eq!(format_hhmmss_f64(123.4), "00:02:03"); + assert_eq!(format_hhmmss_f64(123.6), "00:02:04"); + } + + #[test] + fn test_parse_time_flexible() { + // HH:MM:SS format + assert_eq!(parse_time_flexible("01:02:03").unwrap(), 3723); + assert_eq!(parse_time_flexible("00:00:00").unwrap(), 0); + assert_eq!(parse_time_flexible("23:59:59").unwrap(), 86399); + + // MM:SS format + assert_eq!(parse_time_flexible("02:03").unwrap(), 123); + assert_eq!(parse_time_flexible("00:00").unwrap(), 0); + + // SS format + assert_eq!(parse_time_flexible("42").unwrap(), 42); + assert_eq!(parse_time_flexible("0").unwrap(), 0); + + // Errors + assert!(parse_time_flexible("").is_err()); + assert!(parse_time_flexible("1:2:3:4").is_err()); + assert!(parse_time_flexible("abc").is_err()); + assert!(parse_time_flexible("1:abc").is_err()); + } + + #[test] + fn test_parse_hhmmss_strict() { + assert_eq!(parse_hhmmss_strict("01:02:03").unwrap(), 3723); + assert_eq!(parse_hhmmss_strict("00:00:00").unwrap(), 0); + assert_eq!(parse_hhmmss_strict("23:59:59").unwrap(), 86399); + + // Errors - wrong format + assert!(parse_hhmmss_strict("02:03").is_err()); + assert!(parse_hhmmss_strict("42").is_err()); + + // Errors - invalid values + assert!(parse_hhmmss_strict("00:60:00").is_err()); + assert!(parse_hhmmss_strict("00:00:60").is_err()); + assert!(parse_hhmmss_strict("abc:00:00").is_err()); + } + + #[test] + fn test_ms_conversions() { + assert_eq!(ms_to_seconds(1000), 1); + assert_eq!(ms_to_seconds(1500), 1); // rounds down + assert_eq!(ms_to_seconds(999), 0); + + assert_eq!(seconds_to_ms(1), 1000); + assert_eq!(seconds_to_ms(0), 0); + } +} diff --git a/pmocontrol/src/music_renderer/upnp_renderer.rs b/pmocontrol/src/music_renderer/upnp_renderer.rs new file mode 100644 index 00000000..e12901e0 --- /dev/null +++ b/pmocontrol/src/music_renderer/upnp_renderer.rs @@ -0,0 +1,221 @@ +use crate::errors::ControlPointError; +use crate::music_renderer::RendererFromMediaRendererInfo; +use crate::music_renderer::capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, + VolumeControl, +}; +use crate::model::PlaybackState; +use crate::music_renderer::musicrenderer::MusicRendererBackend; +use crate::upnp_clients::{ + AvTransportClient, ConnectionInfo, ConnectionManagerClient, PositionInfo, ProtocolInfo, + RenderingControlClient, +}; +use crate::{DeviceIdentity, RendererInfo}; + +/// High-level handle representing a renderer and its optional AVTransport client. +#[derive(Clone, Debug)] +pub struct UpnpRenderer { + avtransport: Option, + rendering_control: Option, + connection_manager: Option, + has_avtransport_set_next: bool, +} + +impl UpnpRenderer { + /// Retourne true si le renderer à un service de type AVTransport. + pub fn has_avtransport(&self) -> bool { + self.avtransport.is_some() + } + + /// Retourne true si le renderer à un service de type RenderingControl. + pub fn has_rendering_control(&self) -> bool { + self.rendering_control.is_some() + } + + /// Retourne true si le renderer à un service de type ConnectionManager. + pub fn has_connection_manager(&self) -> bool { + self.connection_manager.is_some() + } + + /// Retourne le client de type AVTransport contenant les URL de controle et d'abonnement. + pub fn avtransport(&self) -> Result<&AvTransportClient, ControlPointError> { + self.avtransport.as_ref().ok_or_else(|| { + ControlPointError::upnp_operation_not_supported("AvTransport", "Renderer") + }) + } + + /// Retourne le client de type RenderingControl contenant les URL de controle et d'abonnement. + pub fn rendering_control(&self) -> Result<&RenderingControlClient, ControlPointError> { + self.rendering_control.as_ref().ok_or_else(|| { + ControlPointError::upnp_operation_not_supported("RenderingControl", "Renderer") + }) + } + + /// Retourne le client de type ConnectionManager contenant les URL de controle et d'abonnement. + pub fn connection_manager(&self) -> Result<&ConnectionManagerClient, ControlPointError> { + self.connection_manager.as_ref().ok_or_else(|| { + ControlPointError::upnp_operation_not_supported("ConnectionManager", "Renderer") + }) + } + + pub fn protocol_info(&self) -> Result { + let cm = self.connection_manager()?; + cm.get_protocol_info() + } + + pub fn connection_ids(&self) -> Result, ControlPointError> { + let cm = self.connection_manager()?; + cm.get_current_connection_ids() + } + + pub fn connection_info(&self, connection_id: i32) -> Result { + let cm = self.connection_manager()?; + cm.get_current_connection_info(connection_id) + } + + pub fn set_next_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { + if !self.has_avtransport_set_next { + return Err(ControlPointError::upnp_operation_not_supported( + "SetNextAVTransportURI", + "Renderer", + )); + } + let avt = self.avtransport()?; + avt.set_next_av_transport_uri(uri, meta) + } +} + +impl RendererFromMediaRendererInfo for UpnpRenderer { + fn from_renderer_info(info: &RendererInfo) -> Result { + // Prepare le service AVTTransport + let avtransport = match ( + info.avtransport_control_url(), + info.avtransport_service_type(), + ) { + (Some(url), Some(service)) => Some(AvTransportClient::new(url, service)), + _ => None, + }; + + // Prepare le service RenderingControl + let rendering_control = match ( + info.rendering_control_control_url(), + info.rendering_control_service_type(), + ) { + (Some(url), Some(service)) => Some(RenderingControlClient::new(url, service)), + _ => None, + }; + + // Prepare le service ConnectionManager + let connection_manager = match ( + info.connection_manager_control_url(), + info.connection_manager_service_type(), + ) { + (Some(url), Some(service)) => Some(ConnectionManagerClient::new(url, service)), + _ => None, + }; + + // Exige AVTTransport et RenderingControl au minimum + if avtransport.is_none() || rendering_control.is_none() { + return Err(ControlPointError::UpnpError(format!( + "Some mandatory services are missing on {:?}", + info.id(), + ))); + } + + Ok(Self { + avtransport, + rendering_control, + connection_manager, + has_avtransport_set_next: info.capabilities().has_avtransport_set_next(), + }) + } + + fn to_backend(self) -> MusicRendererBackend { + MusicRendererBackend::Upnp(self) + } +} + +/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`]. +/// +/// Cette impl se base sur AVTransport (InstanceID = 0). +impl TransportControl for UpnpRenderer { + fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { + let avt = self.avtransport()?; + avt.set_av_transport_uri(uri, meta)?; + avt.play(0, "1") + } + + fn play(&self) -> Result<(), ControlPointError> { + let avt = self.avtransport()?; + avt.play(0, "1") + } + + fn pause(&self) -> Result<(), ControlPointError> { + let avt = self.avtransport()?; + avt.pause(0) + } + + fn stop(&self) -> Result<(), ControlPointError> { + let avt = self.avtransport()?; + avt.stop(0) + } + + fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { + let avt = self.avtransport()?; + avt.seek(0, "REL_TIME", hhmmss) + } +} + +/// Implémentation UPnP RenderingControl de `VolumeControl` pour [`UpnpRenderer`]. +/// +/// Cette impl se base sur le channel "Master" (InstanceID = 0). +impl VolumeControl for UpnpRenderer { + fn volume(&self) -> Result { + let rc = self.rendering_control()?; + rc.get_volume(0, "Master") + } + + fn set_volume(&self, v: u16) -> Result<(), ControlPointError> { + let rc = self.rendering_control()?; + rc.set_volume(0, "Master", v) + } + + fn mute(&self) -> Result { + let rc = self.rendering_control()?; + rc.get_mute(0, "Master") + } + + fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { + let rc = self.rendering_control()?; + rc.set_mute(0, "Master", m) + } +} + +/// Implémentation UPnP AV de `PlaybackStatus` pour [`UpnpRenderer`]. +/// +/// Utilise AVTransport::GetTransportInfo(InstanceID=0). +impl PlaybackStatus for UpnpRenderer { + fn playback_state(&self) -> Result { + let avt = self.avtransport()?; + let info = avt.get_transport_info(0)?; + Ok(PlaybackState::from_upnp_state( + &info.current_transport_state, + )) + } +} + +impl PlaybackPosition for UpnpRenderer { + fn playback_position(&self) -> Result { + let avt = self.avtransport()?; + let raw: PositionInfo = avt.get_position_info(0)?; + + Ok(PlaybackPositionInfo { + track: Some(raw.track), + rel_time: raw.rel_time, + abs_time: raw.abs_time, + track_duration: raw.track_duration, + track_metadata: raw.track_metadata, + track_uri: raw.track_uri, + }) + } +} diff --git a/pmocontrol/src/online.rs b/pmocontrol/src/online.rs index b8e92c6b..f22d3ec0 100644 --- a/pmocontrol/src/online.rs +++ b/pmocontrol/src/online.rs @@ -1,12 +1,12 @@ use std::{ sync::{Arc, Mutex}, - time::{Instant, SystemTime}, + time::{SystemTime}, }; #[derive(Clone,Debug)] pub struct DeviceConnectionState { online: bool, - last_seen: Instant, + last_seen: SystemTime, max_age: u32, } @@ -19,13 +19,15 @@ pub trait DeviceOnline { } impl DeviceConnectionState { - pub fn make() -> Arc> { - Arc::new(Mutex::new(DeviceConnectionState { - online: false, - last_seen: std::time::UNIX_EPOCH, - max_age: 1800, - })) + pub fn new() -> Self { + DeviceConnectionState { + online: false, last_seen: std::time::UNIX_EPOCH, max_age: 1800 } } + + pub fn make() -> Arc> { + Arc::new(Mutex::new(DeviceConnectionState::new())) + } + pub fn is_online(&self) -> bool { self.online } @@ -39,7 +41,7 @@ impl DeviceConnectionState { } pub fn has_been_seen_now(&mut self, max_age: u32) { - self.last_seen = Instant::now(); + self.last_seen = SystemTime::now(); self.max_age = max_age; } diff --git a/pmocontrol/src/openhome_playlist.rs b/pmocontrol/src/openhome_playlist.rs deleted file mode 100644 index b9f85f7a..00000000 --- a/pmocontrol/src/openhome_playlist.rs +++ /dev/null @@ -1,31 +0,0 @@ -/// Snapshot de la playlist native OpenHome pour un renderer donné. -#[cfg_attr(feature = "pmoserver", derive(serde::Serialize, utoipa::ToSchema))] -#[derive(Debug, Clone)] -pub struct OpenHomePlaylistSnapshot { - /// ID du renderer concerné. - pub renderer_id: String, - /// ID courant dans la playlist (si connu). - pub current_id: Option, - /// Position courante dans la playlist (si connue). - pub current_index: Option, - /// Tracks présents dans la playlist native. - pub tracks: Vec, -} - -/// Représentation d'un track OpenHome tel qu'exposé par la playlist native. -#[cfg_attr(feature = "pmoserver", derive(serde::Serialize, utoipa::ToSchema))] -#[derive(Debug, Clone)] -pub struct OpenHomePlaylistTrack { - /// ID interne OpenHome du track. - pub id: u32, - /// URI de lecture. - pub uri: String, - /// Titre (optionnel si non fourni par le renderer). - pub title: Option, - /// Artiste (optionnel). - pub artist: Option, - /// Album (optionnel). - pub album: Option, - /// URI de pochette (optionnelle). - pub album_art_uri: Option, -} diff --git a/pmocontrol/src/queue/backend.rs b/pmocontrol/src/queue/backend.rs index 423bc3b5..d70db81e 100644 --- a/pmocontrol/src/queue/backend.rs +++ b/pmocontrol/src/queue/backend.rs @@ -28,103 +28,7 @@ //! - This identity is used by the sync helpers to preserve the current //! track across queue rebuilds when the MediaServer content changes. -use crate::DeviceId; -use crate::errors::ControlPointError; -// ADAPTE ces imports aux modules existants dans pmocontrol. -// Exemple probable : -// use crate::model::MediaServerId; -// use crate::model::TrackMetadata; -use crate::model::TrackMetadata; - -/// Canonical representation of a track in a renderer queue. -/// -/// This type is the bridge between: -/// - the UPnP MediaServer (DIDL-Lite items), -/// - the ControlPoint runtime, -/// - and the different queue backends (internal / OpenHome). -/// -/// It is intentionally DIDL-centric: every item in a queue comes from -/// a UPnP ContentDirectory and carries its MediaServer identity. -#[derive(Clone, Debug)] -pub struct PlaybackItem { - /// Identifier of the UPnP MediaServer that owns this content. - /// - /// Typically this is the UDN of the MediaServer device, or an - /// equivalent logical identifier. - pub media_server_id: DeviceId, - - /// L'ID interne de l'item dans la queue. - /// Cet ID n'a de sens que lors du retour d'un snapshot. - /// Dans une queue interne, il peut avoir n'importe quelle valeur, - /// l'ID qui compte et la position dans le vecteur. - /// Par principe, on la peut la mettre égale à usize::MAX. - pub backend_id: usize, - - /// DIDL-Lite `id` attribute of the `item` in the ContentDirectory. - /// - /// This, combined with `media_server_id`, is the logical identity - /// of the track across refreshes of the MediaServer state. - pub didl_id: String, - - /// Main resource URI to be used for playback. - /// - /// This is usually the first `` element (or a selected one) - /// from the DIDL-Lite item. - pub uri: String, - - /// UPnP protocolInfo string for the resource (e.g., "http-get:*:audio/flac:*"). - /// - /// This string describes the protocol, network, MIME type, and additional - /// info about the media resource. It's required for proper UPnP/OpenHome - /// renderer compatibility. - pub protocol_info: String, - - /// Optional rich metadata for the track (title, artist, album, cover, - /// duration, …). - /// - /// The exact structure is defined in `TrackMetadata` and may - /// aggregate information from DIDL, tags, or additional sources. - pub metadata: Option, -} - -impl PlaybackItem { - /// Returns a stable, backend-agnostic logical identifier for this item. - /// - /// By default this is the concatenation of the MediaServer identifier - /// and the DIDL `id`. Backends and higher-level logic should use this - /// when they need to match items across queue rebuilds. - pub fn unique_id(&self) -> String { - // ADAPTE si MediaServerId n'implémente pas Display : utilise - // un champ string interne ou une méthode as_str(). - format!("{}::{}", self.media_server_id.0, self.didl_id) - } -} - -/// Logical snapshot of a renderer queue. -/// -/// This is the canonical view used by the ControlPoint and the REST/API -/// layer. It is independent of how the queue is actually stored (local -/// in-memory queue, OpenHome playlist, …). -#[derive(Clone, Debug)] -pub struct QueueSnapshot { - /// All items currently in the queue, in play order. - pub items: Vec, - /// Index (0-based) of the current item in `items`, or `None` if - /// no item is currently selected. - pub current_index: Option, -} - -impl QueueSnapshot { - /// Returns the number of items in the snapshot. - pub fn len(&self) -> usize { - self.items.len() - } - - /// Returns `true` if the snapshot contains no items. - pub fn is_empty(&self) -> bool { - self.items.is_empty() - } -} +use crate::{PlaybackItem, QueueSnapshot, errors::ControlPointError}; /// High-level enqueue mode. /// @@ -164,6 +68,24 @@ pub trait QueueBackend { // BACKEND PRIMITIVES (must be implemented) // ===================================================================== + /// Returns the length of this queue. + fn len(&self) -> Result; + + /// Lists all the items in this queue. + fn track_ids(&self) -> Result, ControlPointError>; + + /// Converts a track ID to its position in the queue. + fn id_to_position(&self, id: u32) -> Result; + + /// Converts a track ID to its position in the queue. + fn position_to_id(&self, id: usize) -> Result; + + /// Return the current playing track identifier + fn current_track(&self) -> Result, ControlPointError>; + + /// Returns the current playing track index in the queue + fn current_index(&self) -> Result, ControlPointError>; + /// Returns the full snapshot (items + current index) of this queue. fn queue_snapshot(&self) -> Result; @@ -181,12 +103,28 @@ pub trait QueueBackend { current_index: Option, ) -> Result<(), ControlPointError>; + /// Updates the queue while adjusting so that the new current_index + /// corresponds to the old current index track. + /// If the old current index track is absent from the new queue, + /// it is kept as the first item and the new items are appended after it. + fn sync_queue(&mut self, items: Vec) -> Result<(), ControlPointError>; + /// Returns the item at `index`, if it exists. fn get_item(&self, index: usize) -> Result, ControlPointError>; /// Replaces the item at `index` with `item`. fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError>; + /// Enqueues items according to the selected `EnqueueMode`. + /// + /// This method only manipulates the queue structure; it does not + /// start playback. + fn enqueue_items( + &mut self, + items: Vec, + mode: EnqueueMode, + ) -> Result<(), ControlPointError>; + // ===================================================================== // DEFAULT HELPERS (backend-agnostic logic) // ===================================================================== @@ -196,41 +134,11 @@ pub trait QueueBackend { self.replace_queue(Vec::new(), None) } - /// Alias for `clear_queue`, semantic name for “empty before rebuild”. - fn empty_queue(&mut self) -> Result<(), ControlPointError> { - self.clear_queue() - } - - /// Returns the current index, if any. - fn current_index(&self) -> Result, ControlPointError> { - Ok(self.queue_snapshot()?.current_index) - } - - /// Returns the number of items in the queue. - fn len(&self) -> Result { - Ok(self.queue_snapshot()?.len()) - } - /// Returns `true` if the queue is empty. fn is_empty(&self) -> Result { Ok(self.queue_snapshot()?.is_empty()) } - /// Returns a full snapshot of the queue. - fn full_snapshot(&self) -> Result { - self.queue_snapshot() - } - - /// Returns an iterator over all items in the queue. - /// - /// The default implementation: - /// - takes a snapshot, - /// - returns a boxed iterator owning the underlying `Vec`. - fn iter_items(&self) -> Result>, ControlPointError> { - let snapshot = self.queue_snapshot()?; - Ok(Box::new(snapshot.items.into_iter())) - } - /// Returns the list of items that come strictly after the current index. fn upcoming_items(&self) -> Result, ControlPointError> { let snapshot = self.queue_snapshot()?; @@ -243,7 +151,12 @@ pub trait QueueBackend { /// Returns how many items remain in the queue after the current index. fn upcoming_len(&self) -> Result { - Ok(self.upcoming_items()?.len()) + let snapshot = self.queue_snapshot()?; + let len = snapshot.items.len(); + match snapshot.current_index { + None => Ok(len), + Some(idx) => Ok(len.saturating_sub(idx + 1)), + } } /// Returns the current item (or the first pending item if no index is set) @@ -312,36 +225,6 @@ pub trait QueueBackend { Ok(Some((item, remaining))) } - /// Enqueues items according to the selected `EnqueueMode`. - /// - /// This method only manipulates the queue structure; it does not - /// start playback. - fn enqueue_items(&mut self, items: Vec, mode: EnqueueMode) -> Result<(), ControlPointError> { - let mut snapshot = self.queue_snapshot()?; - - match mode { - EnqueueMode::AppendToEnd => { - snapshot.items.extend(items); - } - EnqueueMode::InsertAfterCurrent => { - let insert_pos = snapshot - .current_index - .map(|i| (i + 1).min(snapshot.items.len())) - .unwrap_or(0); - - for (offset, it) in items.into_iter().enumerate() { - snapshot.items.insert(insert_pos + offset, it); - } - } - EnqueueMode::ReplaceAll => { - snapshot.items = items; - snapshot.current_index = None; - } - } - - self.replace_queue(snapshot.items, snapshot.current_index) - } - /// Replaces the queue with `items` and sets a default index. fn replace_all(&mut self, items: Vec) -> Result<(), ControlPointError> { if items.is_empty() { @@ -410,7 +293,7 @@ pub trait QueueBackend { } } - /// Convenience helper to update an item “in place” at the given index. + /// Convenience helper to update an item "in place" at the given index. fn update_item( &mut self, index: usize, @@ -420,34 +303,10 @@ pub trait QueueBackend { let new_item = update(item); self.replace_item(index, new_item) } else { - Err(ControlPointError::QueueError(format!("Queue index {} out of range", index))) - } - } - - /// Synchronizes the queue with a new list of items coming from an - /// external MediaServer, trying to preserve the current track. - fn sync_from_external_preserve_current(&mut self, new_items: Vec) -> Result<(), ControlPointError> { - let snapshot = self.queue_snapshot()?; - let current = snapshot - .current_index - .and_then(|i| snapshot.items.get(i).cloned()); - - let Some(current) = current else { - return self.replace_all(new_items); - }; - - let current_uid = current.unique_id(); - - if let Some(new_idx) = new_items - .iter() - .position(|it| it.unique_id() == current_uid) - { - self.replace_queue(new_items, Some(new_idx)) - } else { - let mut items = Vec::with_capacity(new_items.len() + 1); - items.push(current); - items.extend(new_items); - self.replace_queue(items, Some(0)) + Err(ControlPointError::QueueError(format!( + "Queue index {} out of range", + index + ))) } } } diff --git a/pmocontrol/src/queue/interne.rs b/pmocontrol/src/queue/interne.rs index bbd3262a..7b61894c 100644 --- a/pmocontrol/src/queue/interne.rs +++ b/pmocontrol/src/queue/interne.rs @@ -15,18 +15,11 @@ //! - maintains a `current_index`, //! - never starts playback (transport control is handled elsewhere). -use std::sync::{Arc, Mutex}; - -use anyhow::Result; use crate::{ - DeviceId, RendererInfo, - DeviceIdentity, + DeviceId, DeviceIdentity, RendererInfo, errors::ControlPointError, - queue::{ - MusicQueue, - backend::{PlaybackItem, QueueBackend, QueueSnapshot}, - }, + queue::{MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot}, }; /// Internal/local queue implementation. @@ -54,27 +47,59 @@ impl InternalQueue { } } - pub fn from_renderer_info( - info: &RendererInfo, - ) -> Result { - Ok(InternalQueue::new( - info.id(), - ), - ) + pub fn from_renderer_info(info: &RendererInfo) -> Result { + Ok(InternalQueue::new(info.id())) } /// Exposes a read-only view of the underlying items. pub fn items(&self) -> &[PlaybackItem] { &self.items } - - /// Exposes the current index (read-only). - pub fn current_index(&self) -> Option { - self.current_index - } } impl QueueBackend for InternalQueue { + fn len(&self) -> Result { + Ok(self.items.len()) + } + + fn track_ids(&self) -> Result, ControlPointError> { + let ids: Vec = (0..self.len()?).map(|i| i as u32).collect(); + Ok(ids) + } + + fn id_to_position(&self, id: u32) -> Result { + Ok(id as usize) + } + + fn position_to_id(&self, id: usize) -> Result { + u32::try_from(id).map_err(|_| { + ControlPointError::QueueError(format!( + "Position {} exceeds u32::MAX", + id + )) + }) + } + + fn current_track(&self) -> Result, ControlPointError> { + match self.current_index { + None => Ok(None), + Some(i) => { + u32::try_from(i) + .map(Some) + .map_err(|_| { + ControlPointError::QueueError(format!( + "Current index {} exceeds u32::MAX", + i + )) + }) + } + } + } + + fn current_index(&self) -> Result, ControlPointError> { + Ok(self.current_index) + } + fn queue_snapshot(&self) -> Result { let mut items = self.items.clone(); for (i, item) in items.iter_mut().enumerate() { @@ -96,7 +121,11 @@ impl QueueBackend for InternalQueue { if i < self.items.len() { self.current_index = Some(i); } else { - self.current_index = None; + return Err(ControlPointError::QueueError(format!( + "Index out of bound {} >= {}", + i, + self.items.len() + ))); } } } @@ -113,14 +142,187 @@ impl QueueBackend for InternalQueue { Ok(()) } + fn sync_queue( + &mut self, + items: Vec + ) -> Result<(), ControlPointError> { + if items.is_empty() { + return self.replace_queue(Vec::new(), None); + } + + // Récupérer l'item actuel + let current = self.current_index + .and_then(|idx| self.items.get(idx).map(|item| (idx, item.uri.clone()))); + + if let Some((_current_idx, current_uri)) = current { + // Chercher l'item actuel dans la nouvelle liste (par URI) + let new_idx = items.iter().position(|item| item.uri == current_uri); + + if let Some(new_idx) = new_idx { + // Item trouvé dans la nouvelle liste + self.replace_queue(items, Some(new_idx)) + } else { + // Item pas trouvé, le garder comme premier + let current_item = self.items[self.current_index.unwrap()].clone(); + let mut new_items = Vec::with_capacity(items.len() + 1); + new_items.push(current_item); + new_items.extend(items); + self.replace_queue(new_items, Some(0)) + } + } else { + // Pas d'item actuel + self.replace_queue(items, None) + } + } + + fn enqueue_items( + &mut self, + items: Vec, + mode: crate::queue::EnqueueMode, + ) -> Result<(), ControlPointError> { + use crate::queue::EnqueueMode; + + match mode { + EnqueueMode::AppendToEnd => { + self.items.extend(items); + } + EnqueueMode::InsertAfterCurrent => { + let insert_pos = self.current_index + .map(|i| (i + 1).min(self.items.len())) + .unwrap_or(0); + + for (offset, item) in items.into_iter().enumerate() { + self.items.insert(insert_pos + offset, item); + } + } + EnqueueMode::ReplaceAll => { + self.items = items; + self.current_index = None; + } + } + Ok(()) + } + fn get_item(&self, index: usize) -> Result, ControlPointError> { - Ok(self.items.get(index).cloned()) + if index < self.items.len() { + Ok(self.items.get(index).cloned()) + } else { + Err(ControlPointError::QueueError(format!( + "get_item index out of bound {} >= {}", + index, + self.items.len() + ))) + } + } + + // Optimized helpers for InternalQueue + fn clear_queue(&mut self) -> Result<(), ControlPointError> { + self.items.clear(); + self.current_index = None; + Ok(()) + } + + fn is_empty(&self) -> Result { + Ok(self.items.is_empty()) + } + + fn upcoming_len(&self) -> Result { + let len = self.items.len(); + match self.current_index { + None => Ok(len), + Some(idx) => Ok(len.saturating_sub(idx + 1)), + } + } + + fn upcoming_items(&self) -> Result, ControlPointError> { + let items = match self.current_index { + None => self.items.clone(), + Some(idx) => self.items.iter().skip(idx + 1).cloned().collect(), + }; + Ok(items) + } + + fn peek_current(&self) -> Result, ControlPointError> { + if self.items.is_empty() { + return Ok(None); + } + + let len = self.items.len(); + let (item, resolved_index) = match self.current_index { + Some(idx) if idx < len => (self.items.get(idx).cloned(), Some(idx)), + _ => (self.items.first().cloned(), None), + }; + + let item = match item { + Some(item) => item, + None => return Ok(None), + }; + + let remaining = match resolved_index { + Some(idx) => len.saturating_sub(idx + 1), + None => len, + }; + + Ok(Some((item, remaining))) + } + + fn dequeue_next(&mut self) -> Result, ControlPointError> { + if self.items.is_empty() { + return Ok(None); + } + + let len = self.items.len(); + let next_index = match self.current_index { + None => 0, + Some(idx) => { + let candidate = idx + 1; + if candidate >= len { + return Ok(None); + } + candidate + } + }; + + let Some(item) = self.items.get(next_index).cloned() else { + return Ok(None); + }; + + let remaining = len.saturating_sub(next_index + 1); + self.current_index = Some(next_index); + Ok(Some((item, remaining))) + } + + fn append_or_init_index(&mut self, items: Vec) -> Result<(), ControlPointError> { + let was_empty = self.items.is_empty(); + self.items.extend(items); + + if was_empty && !self.items.is_empty() { + self.current_index = Some(0); + } + + Ok(()) } fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> { if index < self.items.len() { self.items[index] = item; + Ok(()) + } else { + Err(ControlPointError::QueueError(format!( + "Index out of bound {} >= {}", + index, + self.items.len() + ))) } - Ok(()) + } +} + +impl QueueFromRendererInfo for InternalQueue { + fn from_renderer_info(renderer: &RendererInfo) -> Result { + InternalQueue::from_renderer_info(renderer) + } + + fn to_backend(self) -> MusicQueue { + MusicQueue::Internal(self) } } diff --git a/pmocontrol/src/queue/mod.rs b/pmocontrol/src/queue/mod.rs index af5f3d86..55f934e6 100644 --- a/pmocontrol/src/queue/mod.rs +++ b/pmocontrol/src/queue/mod.rs @@ -1,7 +1,45 @@ mod music_queue; -mod openhome; mod backend; +mod snapshot; +mod openhome; mod interne; -pub use music_queue::MusicQueue; -pub use backend::{PlaybackItem, QueueBackend, QueueSnapshot, EnqueueMode}; +use std::sync::{Arc, Mutex}; + +pub use music_queue::MusicQueue; +pub use backend::{QueueBackend, EnqueueMode}; +pub use snapshot::{PlaybackItem, QueueSnapshot}; + +// Internal queue implementations - not part of the public API +pub(crate) use openhome::OpenHomeQueue; +pub(crate) use interne::InternalQueue; + +use crate::{RendererInfo, errors::ControlPointError}; + +pub trait QueueFromRendererInfo { + fn from_renderer_info(renderer: &RendererInfo) -> Result + where + Self: Sized; + + fn to_backend(self) -> MusicQueue; + + fn build_from_renderer_info( + renderer: &RendererInfo, + ) -> Result + where + Self: Sized, + { + let instance = Self::from_renderer_info(renderer)?; + Ok(instance.to_backend()) + } + + fn make_from_renderer_info( + renderer: &RendererInfo, + ) -> Result>, ControlPointError> + where + Self: Sized, + { + let backend = Self::build_from_renderer_info(renderer)?; + Ok(Arc::new(Mutex::new(backend))) + } +} \ No newline at end of file diff --git a/pmocontrol/src/queue/music_queue.rs b/pmocontrol/src/queue/music_queue.rs index be7c8be9..c7293319 100644 --- a/pmocontrol/src/queue/music_queue.rs +++ b/pmocontrol/src/queue/music_queue.rs @@ -1,9 +1,8 @@ -use crate::RendererInfo; -use crate::control_point::openhome_queue::OpenHomeQueue; use crate::errors::ControlPointError; -use crate::openhome_playlist::OpenHomePlaylistSnapshot; -use crate::queue::backend::{PlaybackItem, QueueBackend, QueueSnapshot}; -use crate::queue::interne::InternalQueue; +use crate::queue::{ + EnqueueMode, InternalQueue, OpenHomeQueue, QueueBackend, QueueFromRendererInfo, +}; +use crate::{PlaybackItem, QueueSnapshot, RendererInfo}; #[derive(Debug, Clone)] pub enum MusicQueue { @@ -12,39 +11,65 @@ pub enum MusicQueue { } impl MusicQueue { - + /// Creates a queue appropriate for the given renderer. + /// This is the factory method used by QueueFromRendererInfo trait. pub fn from_renderer_info(info: &RendererInfo) -> Result { if info.capabilities().has_oh_playlist() { - - Ok(MusicQueue::OpenHome(OpenHomeQueue::from_renderer_info(info)?)) + Ok(MusicQueue::OpenHome(OpenHomeQueue::from_renderer_info( + info, + )?)) } else { - Ok(MusicQueue::Internal(InternalQueue::from_renderer_info(info)?)) - } - } - - pub fn openhome_playlist_snapshot(&self) -> Result { - match self { - MusicQueue::OpenHome(queue) => queue.openhome_playlist_snapshot(), - _ => Err(ControlPointError::QueueError(format!( - "OpenHome playlist snapshot is only available for OpenHome queues" - ))), - } - } - - pub fn replace_with_attached_playlist( - &mut self, - items: Vec, - current_index: Option, - ) -> Result<(), ControlPointError> { - match self { - MusicQueue::OpenHome(queue) => queue.replace_entire_playlist(items, current_index), - MusicQueue::Internal(queue) => queue.replace_queue(items, current_index), + Ok(MusicQueue::Internal(InternalQueue::from_renderer_info( + info, + )?)) } } } - impl QueueBackend for MusicQueue { + // Primitives + fn len(&self) -> Result { + match self { + MusicQueue::Internal(q) => q.len(), + MusicQueue::OpenHome(q) => q.len(), + } + } + + fn track_ids(&self) -> Result, ControlPointError> { + match self { + MusicQueue::Internal(q) => q.track_ids(), + MusicQueue::OpenHome(q) => q.track_ids(), + } + } + + fn id_to_position(&self, id: u32) -> Result { + match self { + MusicQueue::Internal(q) => q.id_to_position(id), + MusicQueue::OpenHome(q) => q.id_to_position(id), + } + } + + fn position_to_id(&self, id: usize) -> Result { + match self { + MusicQueue::Internal(q) => q.position_to_id(id), + MusicQueue::OpenHome(q) => q.position_to_id(id), + } + } + + fn current_track(&self) -> Result, ControlPointError> { + match self { + MusicQueue::Internal(q) => q.current_track(), + MusicQueue::OpenHome(q) => q.current_track(), + } + } + + fn current_index(&self) -> Result, ControlPointError> { + match self { + MusicQueue::Internal(q) => q.current_index(), + MusicQueue::OpenHome(q) => q.current_index(), + } + } + fn queue_snapshot(&self) -> Result { match self { MusicQueue::Internal(q) => q.queue_snapshot(), @@ -70,6 +95,13 @@ impl QueueBackend for MusicQueue { } } + fn sync_queue(&mut self, items: Vec) -> Result<(), ControlPointError> { + match self { + MusicQueue::Internal(q) => q.sync_queue(items), + MusicQueue::OpenHome(q) => q.sync_queue(items), + } + } + fn get_item(&self, index: usize) -> Result, ControlPointError> { match self { MusicQueue::Internal(q) => q.get_item(index), @@ -83,4 +115,75 @@ impl QueueBackend for MusicQueue { MusicQueue::OpenHome(q) => q.replace_item(index, item), } } + + fn enqueue_items( + &mut self, + items: Vec, + mode: EnqueueMode, + ) -> Result<(), ControlPointError> { + match self { + MusicQueue::Internal(q) => q.enqueue_items(items, mode), + MusicQueue::OpenHome(q) => q.enqueue_items(items, mode), + } + } + + // Optimized helpers + fn clear_queue(&mut self) -> Result<(), ControlPointError> { + match self { + MusicQueue::Internal(q) => q.clear_queue(), + MusicQueue::OpenHome(q) => q.clear_queue(), + } + } + + fn is_empty(&self) -> Result { + match self { + MusicQueue::Internal(q) => q.is_empty(), + MusicQueue::OpenHome(q) => q.is_empty(), + } + } + + fn upcoming_len(&self) -> Result { + match self { + MusicQueue::Internal(q) => q.upcoming_len(), + MusicQueue::OpenHome(q) => q.upcoming_len(), + } + } + + fn upcoming_items(&self) -> Result, ControlPointError> { + match self { + MusicQueue::Internal(q) => q.upcoming_items(), + MusicQueue::OpenHome(q) => q.upcoming_items(), + } + } + + fn peek_current(&self) -> Result, ControlPointError> { + match self { + MusicQueue::Internal(q) => q.peek_current(), + MusicQueue::OpenHome(q) => q.peek_current(), + } + } + + fn dequeue_next(&mut self) -> Result, ControlPointError> { + match self { + MusicQueue::Internal(q) => q.dequeue_next(), + MusicQueue::OpenHome(q) => q.dequeue_next(), + } + } + + fn append_or_init_index(&mut self, items: Vec) -> Result<(), ControlPointError> { + match self { + MusicQueue::Internal(q) => q.append_or_init_index(items), + MusicQueue::OpenHome(q) => q.append_or_init_index(items), + } + } +} + +impl QueueFromRendererInfo for MusicQueue { + fn from_renderer_info(renderer: &RendererInfo) -> Result { + MusicQueue::from_renderer_info(renderer) + } + + fn to_backend(self) -> MusicQueue { + self + } } diff --git a/pmocontrol/src/queue/openhome.rs b/pmocontrol/src/queue/openhome.rs index e8f39672..11abe994 100644 --- a/pmocontrol/src/queue/openhome.rs +++ b/pmocontrol/src/queue/openhome.rs @@ -1,14 +1,16 @@ -use pmodidl::DIDLLite; +use std::usize; + use quick_xml::escape::escape; use tracing::debug; use crate::errors::ControlPointError; -use crate::openhome_client::{ - OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrackEntry, - parse_track_metadata_from_didl, +use crate::upnp_clients::{ + OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrack, OhTrackEntry +}; +// use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; +use crate::queue::{ + EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot, }; -use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; -use crate::queue::backend::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot}; use crate::{DeviceId, DeviceIdentity, RendererInfo}; /// Local mirror of an OpenHome playlist for a single renderer. @@ -18,9 +20,7 @@ pub struct OpenHomeQueue { playlist_client: OhPlaylistClient, info_client: Option, product_client: Option, - items: Vec, - current_index: Option, - track_ids: Vec, + // current_index: Option, } impl OpenHomeQueue { @@ -35,15 +35,10 @@ impl OpenHomeQueue { playlist_client: playlist, info_client, product_client, - items: Vec::new(), - current_index: None, - track_ids: Vec::new(), } } - pub fn from_renderer_info( - info: &RendererInfo, - ) -> Result { + pub fn from_renderer_info(info: &RendererInfo) -> Result { let playlist_client = OhPlaylistClient::from_renderer_info(info)?; let info_client = OhInfoClient::from_renderer_info(&info).ok(); let product_client = OhProductClient::from_renderer_info(&info).ok(); @@ -56,208 +51,6 @@ impl OpenHomeQueue { )) } - /// Reload the full OpenHome playlist snapshot into local playback items. - /// - /// This mirrors the logic previously implemented by - /// `OpenHomeRenderer::snapshot_openhome_playlist` but converts entries - /// directly into `PlaybackItem`s. - pub fn refresh_from_openhome(&mut self) -> Result<(), ControlPointError> { - self.ensure_playlist_source_selected()?; - let entries = self.playlist_client.read_all_tracks()?; - let mut items = Vec::with_capacity(entries.len()); - let mut track_ids = Vec::with_capacity(entries.len()); - - for entry in &entries { - items.push(self.playback_item_from_entry(entry)); - track_ids.push(entry.id); - } - - // Get the currently playing track ID from the renderer (may be None if no track is playing) - let current_id = self.playlist_client.id().ok(); - - // Find the index of the current track in the playlist - let current_index = - current_id.and_then(|id| track_ids.iter().position(|entry_id| *entry_id == id)); - - self.items = items; - self.track_ids = track_ids; - self.current_index = current_index; - Ok(()) - } - - pub fn openhome_playlist_snapshot(&self) -> Result { - let tracks = self - .items - .iter() - .zip(self.track_ids.iter()) - .map(|(item, track_id)| OpenHomePlaylistTrack { - id: *track_id, - uri: item.uri.clone(), - title: item.metadata.as_ref().and_then(|m| m.title.clone()), - artist: item.metadata.as_ref().and_then(|m| m.artist.clone()), - album: item.metadata.as_ref().and_then(|m| m.album.clone()), - album_art_uri: item.metadata.as_ref().and_then(|m| m.album_art_uri.clone()), - }) - .collect(); - - Ok(OpenHomePlaylistSnapshot { - renderer_id: self.renderer_id.0.clone(), - current_id: self - .current_index - .and_then(|idx| self.track_ids.get(idx).copied()), - current_index: self.current_index, - tracks, - }) - } - - pub fn len(&self) -> usize { - self.items.len() - } - - /// Return the list of OpenHome track IDs in order. - pub fn openhome_track_ids(&self) -> Vec { - self.track_ids.clone() - } - - pub fn select_track_id(&mut self, id: u32) -> Result<(), ControlPointError> { - let index = match self.track_ids.iter().position(|&tid| tid == id) { - Some(pos) => pos, - None => { - self.refresh_from_openhome()?; - self.track_ids - .iter() - .position(|&tid| tid == id) - .ok_or_else(|| ControlPointError::QueueError(format!("Unknown OpenHome track id {}", id)))? - } - }; - - self.ensure_playlist_source_selected()?; - self.playlist_client.play_id(id)?; - self.current_index = Some(index); - Ok(()) - } - - /// Selects and plays a track by its queue index (0-based). - pub fn select_track_index(&mut self, index: usize) -> Result<(), ControlPointError> { - let track_id = self.track_ids.get(index).copied().ok_or_else(|| { - ControlPointError::QueueError(format!( - "Index {} out of bounds (queue length: {})", - index, - self.track_ids.len() - )) - })?; - - self.ensure_playlist_source_selected()?; - self.playlist_client.play_id(track_id)?; - self.current_index = Some(index); - Ok(()) - } - - pub fn clear(&mut self) -> Result<(), ControlPointError> { - self.ensure_playlist_source_selected()?; - self.playlist_client.delete_all()?; - self.items.clear(); - self.track_ids.clear(); - self.current_index = None; - Ok(()) - } - - /// Replace the remote OpenHome playlist entirely with `items`. - /// - /// This is used when attaching a media server playlist: we want to drop any - /// stale entries (even if they were inserted by another control point) and - /// rebuild the renderer playlist from scratch. - pub fn replace_entire_playlist( - &mut self, - items: Vec, - current_index: Option, - ) -> Result<(), ControlPointError> { - self.ensure_playlist_source_selected()?; - self.playlist_client.delete_all()?; - self.items.clear(); - self.track_ids.clear(); - self.current_index = None; - - if items.is_empty() { - return Ok(()); - } - - let mut rebuilt_items = Vec::with_capacity(items.len()); - let mut rebuilt_ids = Vec::with_capacity(items.len()); - let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID; - - for item in items { - let metadata = build_metadata_xml(&item); - let new_id = self - .playlist_client - .insert(previous_id, &item.uri, &metadata)?; - previous_id = new_id; - rebuilt_ids.push(new_id); - rebuilt_items.push(self.item_with_openhome_id(item, new_id)); - } - - let normalized = current_index - .filter(|&idx| idx < rebuilt_ids.len()) - .or_else(|| Some(0)); - - self.items = rebuilt_items; - self.track_ids = rebuilt_ids; - self.current_index = normalized; - Ok(()) - } - - pub fn add_playback_item( - &mut self, - item: PlaybackItem, - after_id: Option, - play: bool, - ) -> Result { - self.ensure_playlist_source_selected()?; - let metadata_xml = build_metadata_xml(&item); - let insert_after = match after_id { - Some(id) => id, - None => self.track_ids.last().copied().unwrap_or(0), - }; - - let new_id = self - .playlist_client - .insert(insert_after, &item.uri, &metadata_xml)?; - - if play { - self.playlist_client.play_id(new_id)?; - } - - let mut insert_index = after_id - .and_then(|id| { - if id == 0 { - Some(0) - } else { - self.track_ids - .iter() - .position(|tid| *tid == id) - .map(|pos| pos + 1) - } - }) - .unwrap_or_else(|| self.track_ids.len()); - - if insert_index > self.track_ids.len() { - insert_index = self.track_ids.len(); - } - - self.track_ids.insert(insert_index, new_id); - let stored_item = self.item_with_openhome_id(item, new_id); - self.items.insert(insert_index, stored_item); - - self.current_index = if play { - Some(insert_index) - } else { - self.current_index - .map(|idx| if insert_index <= idx { idx + 1 } else { idx }) - }; - - Ok(new_id) - } - fn ensure_playlist_source_selected(&self) -> Result<(), ControlPointError> { if let Some(product) = &self.product_client { product.ensure_playlist_source_selected() @@ -267,14 +60,15 @@ impl OpenHomeQueue { } fn playback_item_from_entry(&self, entry: &OhTrackEntry) -> PlaybackItem { - let metadata = parse_track_metadata_from_didl(&entry.metadata_xml); - let didl_id = didl_id_from_metadata(&entry.metadata_xml) + let metadata = entry.metadata(); + let didl_id = entry + .didl_id() .unwrap_or_else(|| format!("openhome:{}", entry.id)); PlaybackItem { media_server_id: DeviceId(format!("openhome:{}", self.renderer_id.0)), backend_id: entry.id as usize, didl_id, - uri: entry.uri.clone(), + uri: entry.uri().to_string(), // OpenHome tracks don't provide protocolInfo, use generic default protocol_info: "http-get:*:audio/*:*".to_string(), metadata, @@ -287,20 +81,17 @@ impl OpenHomeQueue { item } - fn ensure_track_id(&mut self, index: usize) -> Result { - if index >= self.items.len() { - return Err(ControlPointError::OpenHomeError(format!("Index out of bounds in OpenHomeQueue: {}", index))); - } - - if let Some(id) = self.track_ids.get(index).copied() { - return Ok(id); - } - - self.refresh_from_openhome()?; - self.track_ids - .get(index) - .copied() - .ok_or_else(|| ControlPointError::OpenHomeError(format!("Failed to resolve OpenHome track id at index {}", index))) + fn add_playback_item( + &mut self, + item: PlaybackItem, + after_id: u32, + ) -> Result { + self.ensure_playlist_source_selected()?; + let metadata_xml = build_metadata_xml(&item); + let new_id = self + .playlist_client + .insert(after_id, &item.uri, &metadata_xml)?; + Ok(new_id) } /// CASE 1: Replace queue while preserving the currently playing item as first. @@ -309,40 +100,30 @@ impl OpenHomeQueue { fn replace_queue_preserve_current( &mut self, new_items: Vec, - playing_idx: usize, - playing_id: u32, - ) -> Result<(), ControlPointError> { + playing_id: usize, + ) -> Result<(), ControlPointError> { + // Get current track IDs from OpenHome + let current_track_ids = self.track_ids()?; + // Delete everything except the currently playing item // Using delete_id_if_exists() to handle cases where another control point // may have already modified the playlist - for &track_id in self.track_ids.iter().rev() { - if track_id != playing_id { + for &track_id in current_track_ids.iter().rev() { + if track_id as usize != playing_id { self.playlist_client.delete_id_if_exists(track_id)?; } } - // Rebuild: [currently_playing, new_items...] - let mut rebuilt_items = Vec::with_capacity(1 + new_items.len()); - let mut rebuilt_ids = Vec::with_capacity(1 + new_items.len()); - - rebuilt_items.push(self.items[playing_idx].clone()); - rebuilt_ids.push(playing_id); - - let mut previous_id = playing_id; + // Insert new items after the currently playing track + let mut previous_id = playing_id as u32; for item in new_items { let metadata = build_metadata_xml(&item); let new_id = self .playlist_client .insert(previous_id, &item.uri, &metadata)?; previous_id = new_id; - rebuilt_ids.push(new_id); - rebuilt_items.push(self.item_with_openhome_id(item, new_id)); } - self.items = rebuilt_items; - self.track_ids = rebuilt_ids; - self.current_index = Some(0); // Currently playing is now at index 0 - debug!( renderer = self.renderer_id.0.as_str(), "Gentle sync completed: preserved playing track as first item (not in new playlist)" @@ -351,6 +132,81 @@ impl OpenHomeQueue { Ok(()) } + /// Helper: Delete items marked for deletion in reverse order with logging. + fn delete_marked_items( + &mut self, + old_ids: &[u32], + keep_flags: &[bool], + position_label: &str, + ) -> Result<(), ControlPointError> { + for (idx, &track_id) in old_ids.iter().enumerate().rev() { + if !keep_flags[idx] { + debug!( + renderer = self.renderer_id.0.as_str(), + track_id, + position = position_label, + "RENDERER OP: DeleteId({})", + track_id + ); + self.playlist_client.delete_id_if_exists(track_id)?; + } + } + Ok(()) + } + + /// Helper: Rebuild a playlist section (before or after pivot) using LCS results. + fn rebuild_playlist_section( + &mut self, + new_items: &[PlaybackItem], + keep_new_flags: &[bool], + old_ids: &[u32], + keep_old_flags: &[bool], + mut previous_id: u32, + position_label: &str, + ) -> Result { + // Collect IDs of kept items (in order) + let remaining_ids: Vec = old_ids + .iter() + .enumerate() + .filter_map(|(idx, &id)| if keep_old_flags[idx] { Some(id) } else { None }) + .collect(); + + let mut remaining_idx = 0; + + // Rebuild section + for (idx, item) in new_items.iter().enumerate() { + if keep_new_flags[idx] { + let existing_id = remaining_ids[remaining_idx]; + remaining_idx += 1; + previous_id = existing_id; + debug!( + renderer = self.renderer_id.0.as_str(), + track_id = existing_id, + position = position_label, + "KEPT existing track ID {}", + existing_id + ); + } else { + let metadata = build_metadata_xml(item); + let new_id = self + .playlist_client + .insert(previous_id, &item.uri, &metadata)?; + debug!( + renderer = self.renderer_id.0.as_str(), + after_id = previous_id, + new_id, + position = position_label, + "RENDERER OP: Insert(after={}) -> new_id={}", + previous_id, + new_id + ); + previous_id = new_id; + } + } + + Ok(previous_id) + } + /// CASE 2: Replace queue with double-LCS (before and after the pivot). /// The currently playing item IS in the new playlist, so we use it as a pivot /// and apply LCS separately to the portions before and after it. @@ -358,20 +214,28 @@ impl OpenHomeQueue { &mut self, new_items: Vec, pivot_idx_new: usize, - pivot_id: u32, + pivot_id: usize, ) -> Result<(), ControlPointError> { + // Get current state from OpenHome + let snapshot = self.queue_snapshot()?; + let current_track_ids = self.track_ids()?; + // Find the pivot index in our current state - let pivot_idx = self - .track_ids + let pivot_idx = current_track_ids .iter() - .position(|&id| id == pivot_id) - .ok_or_else(|| ControlPointError::OpenHomeError(format!("Pivot track ID {} not found in playlist", pivot_id)))?; + .position(|&id| id as usize == pivot_id) + .ok_or_else(|| { + ControlPointError::OpenHomeError(format!( + "Pivot track ID {} not found in playlist", + pivot_id + )) + })?; // Split current data at the pivot - let old_before: Vec = self.items[..pivot_idx].to_vec(); - let old_after: Vec = self.items[pivot_idx + 1..].to_vec(); - let old_ids_before: Vec = self.track_ids[..pivot_idx].to_vec(); - let old_ids_after: Vec = self.track_ids[pivot_idx + 1..].to_vec(); + let old_before: Vec = snapshot.items[..pivot_idx].to_vec(); + let old_after: Vec = snapshot.items[pivot_idx + 1..].to_vec(); + let old_ids_before: Vec = current_track_ids[..pivot_idx].to_vec(); + let old_ids_after: Vec = current_track_ids[pivot_idx + 1..].to_vec(); let new_before = &new_items[..pivot_idx_new]; let new_after = &new_items[pivot_idx_new + 1..]; @@ -383,86 +247,24 @@ impl OpenHomeQueue { let (keep_old_before, keep_new_before) = lcs_flags(&old_before, new_before); // Delete items marked for deletion in AFTER part (reverse order) - for (idx, &track_id) in old_ids_after.iter().enumerate().rev() { - if !keep_old_after[idx] { - debug!( - renderer = self.renderer_id.0.as_str(), - track_id, - position = "AFTER pivot", - "RENDERER OP: DeleteId({})", - track_id - ); - self.playlist_client.delete_id_if_exists(track_id)?; - } - } + self.delete_marked_items(&old_ids_after, &keep_old_after, "AFTER pivot")?; // Delete items marked for deletion in BEFORE part (reverse order) - for (idx, &track_id) in old_ids_before.iter().enumerate().rev() { - if !keep_old_before[idx] { - debug!( - renderer = self.renderer_id.0.as_str(), - track_id, - position = "BEFORE pivot", - "RENDERER OP: DeleteId({})", - track_id - ); - self.playlist_client.delete_id_if_exists(track_id)?; - } - } + self.delete_marked_items(&old_ids_before, &keep_old_before, "BEFORE pivot")?; // Rebuild the playlist: [BEFORE, PIVOT, AFTER] - let mut rebuilt_items = Vec::with_capacity(new_items.len()); - let mut rebuilt_ids = Vec::with_capacity(new_items.len()); - - // Collect IDs of kept items in BEFORE part (in order) - let remaining_before: Vec = old_ids_before - .iter() - .enumerate() - .filter_map(|(idx, &id)| if keep_old_before[idx] { Some(id) } else { None }) - .collect(); - - let mut remaining_before_idx = 0; - let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID; - // Rebuild BEFORE part - for (idx, item) in new_before.iter().enumerate() { - if keep_new_before[idx] { - let existing_id = remaining_before[remaining_before_idx]; - remaining_before_idx += 1; - previous_id = existing_id; - rebuilt_ids.push(existing_id); - rebuilt_items.push(self.item_with_openhome_id(item.clone(), existing_id)); - debug!( - renderer = self.renderer_id.0.as_str(), - track_id = existing_id, - position = "BEFORE pivot", - "KEPT existing track ID {}", - existing_id - ); - } else { - let metadata = build_metadata_xml(item); - let new_id = self - .playlist_client - .insert(previous_id, &item.uri, &metadata)?; - debug!( - renderer = self.renderer_id.0.as_str(), - after_id = previous_id, - new_id, - position = "BEFORE pivot", - "RENDERER OP: Insert(after={}) -> new_id={}", - previous_id, - new_id - ); - previous_id = new_id; - rebuilt_ids.push(new_id); - rebuilt_items.push(self.item_with_openhome_id(item.clone(), new_id)); - } - } + let previous_id = self.rebuild_playlist_section( + new_before, + &keep_new_before, + &old_ids_before, + &keep_old_before, + OPENHOME_PLAYLIST_HEAD_ID, + "BEFORE pivot", + )?; // Add PIVOT (keeps its ID!) - rebuilt_ids.push(pivot_id); - rebuilt_items.push(self.item_with_openhome_id(new_items[pivot_idx_new].clone(), pivot_id)); - previous_id = pivot_id; + let previous_id = pivot_id as u32; debug!( renderer = self.renderer_id.0.as_str(), pivot_id, @@ -472,70 +274,20 @@ impl OpenHomeQueue { pivot_idx_new ); - // Collect IDs of kept items in AFTER part (in order) - let remaining_after: Vec = old_ids_after - .iter() - .enumerate() - .filter_map(|(idx, &id)| if keep_old_after[idx] { Some(id) } else { None }) - .collect(); - - let mut remaining_after_idx = 0; - // Rebuild AFTER part - for (idx, item) in new_after.iter().enumerate() { - if keep_new_after[idx] { - let existing_id = remaining_after[remaining_after_idx]; - remaining_after_idx += 1; - previous_id = existing_id; - rebuilt_ids.push(existing_id); - rebuilt_items.push(self.item_with_openhome_id(item.clone(), existing_id)); - debug!( - renderer = self.renderer_id.0.as_str(), - track_id = existing_id, - position = "AFTER pivot", - "KEPT existing track ID {}", - existing_id - ); - } else { - let metadata = build_metadata_xml(item); - let new_id = self - .playlist_client - .insert(previous_id, &item.uri, &metadata)?; - debug!( - renderer = self.renderer_id.0.as_str(), - after_id = previous_id, - new_id, - position = "AFTER pivot", - "RENDERER OP: Insert(after={}) -> new_id={}", - previous_id, - new_id - ); - previous_id = new_id; - rebuilt_ids.push(new_id); - rebuilt_items.push(self.item_with_openhome_id(item.clone(), new_id)); - } - } - - self.items = rebuilt_items; - self.track_ids = rebuilt_ids; - self.current_index = Some(pivot_idx_new); // Pivot is at its new position - - // VERIFICATION: Check that pivot ID is preserved - let final_pivot_id = self.track_ids.get(pivot_idx_new).copied(); - if final_pivot_id != Some(pivot_id) { - return Err(ControlPointError::OpenHomeError(format!( - "CRITICAL BUG: Pivot ID changed from {} to {:?} during replace_queue_with_pivot!", - pivot_id, - final_pivot_id - ))); - } + self.rebuild_playlist_section( + new_after, + &keep_new_after, + &old_ids_after, + &keep_old_after, + previous_id, + "AFTER pivot", + )?; debug!( renderer = self.renderer_id.0.as_str(), pivot_idx = pivot_idx_new, pivot_id, - final_playlist_len = self.track_ids.len(), - pivot_verified = true, "Gentle sync completed: double-LCS with pivot (playing track preserved)" ); @@ -546,9 +298,13 @@ impl OpenHomeQueue { fn replace_queue_standard_lcs( &mut self, items: Vec, - current_index: Option, + _current_index: Option, ) -> Result<(), ControlPointError> { - let (keep_current, keep_desired) = lcs_flags(&self.items, &items); + // Get current state from OpenHome + let snapshot = self.queue_snapshot()?; + let current_track_ids = self.track_ids()?; + + let (keep_current, keep_desired) = lcs_flags(&snapshot.items, &items); let items_to_keep = keep_current.iter().filter(|&&k| k).count(); let items_to_delete = keep_current.iter().filter(|&&k| !k).count(); @@ -571,27 +327,33 @@ impl OpenHomeQueue { "Using delete_all() for complete replacement (more robust for live playlists)" ); self.playlist_client.delete_all()?; - self.track_ids.clear(); - self.items.clear(); } else { // Selective deletion when keeping some items - for idx in (0..self.track_ids.len()).rev() { + for idx in (0..current_track_ids.len()).rev() { if !keep_current[idx] { - let track_id = self.track_ids[idx]; + let track_id = current_track_ids[idx]; // Use delete_id_if_exists() to handle cases where another control point // may have already modified the playlist self.playlist_client.delete_id_if_exists(track_id)?; - self.track_ids.remove(idx); - self.items.remove(idx); } } } - let remaining_ids = self.track_ids.clone(); + // Rebuild by inserting new items + let remaining_ids: Vec = current_track_ids + .iter() + .enumerate() + .filter_map(|(idx, &id)| { + if keep_current.get(idx).copied().unwrap_or(false) { + Some(id) + } else { + None + } + }) + .collect(); + let mut remaining_idx = 0usize; let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID; - let mut rebuilt_items = Vec::with_capacity(items.len()); - let mut rebuilt_ids = Vec::with_capacity(items.len()); for (idx, item) in items.into_iter().enumerate() { if keep_desired[idx] { @@ -603,16 +365,12 @@ impl OpenHomeQueue { let existing_id = remaining_ids[remaining_idx]; remaining_idx += 1; previous_id = existing_id; - rebuilt_ids.push(existing_id); - rebuilt_items.push(self.item_with_openhome_id(item, existing_id)); } else { let metadata = build_metadata_xml(&item); let new_id = self .playlist_client .insert(previous_id, &item.uri, &metadata)?; previous_id = new_id; - rebuilt_ids.push(new_id); - rebuilt_items.push(self.item_with_openhome_id(item, new_id)); } } @@ -622,39 +380,10 @@ impl OpenHomeQueue { ))); } - let previous_index = self.current_index.and_then(|idx| { - if idx < rebuilt_ids.len() { - Some(idx) - } else { - None - } - }); - let normalized = current_index - .filter(|&i| i < rebuilt_ids.len()) - .or(previous_index) - .or_else(|| { - if rebuilt_ids.is_empty() { - None - } else { - Some(0) - } - }); - self.items = rebuilt_items; - self.track_ids = rebuilt_ids; - self.current_index = normalized; Ok(()) } } -pub fn didl_id_from_metadata(xml: &str) -> Option { - if xml.trim().is_empty() { - return None; - } - - let parsed = pmodidl::parse_metadata::(xml).ok()?; - parsed.data.items.first().map(|item| item.id.clone()) -} - fn build_metadata_xml(item: &PlaybackItem) -> String { let title = item .metadata @@ -750,6 +479,51 @@ fn lcs_flags(current: &[PlaybackItem], desired: &[PlaybackItem]) -> (Vec, } impl QueueBackend for OpenHomeQueue { + fn len(&self) -> Result { + Ok(self.track_ids()?.len()) + } + + /// Return the list of OpenHome track IDs in order. + fn track_ids(&self) -> Result, ControlPointError> { + self.ensure_playlist_source_selected()?; + self.playlist_client.id_array() + } + + fn id_to_position(&self, id: u32) -> Result { + self.track_ids()? + .iter() + .position(|&tid| tid == id) + .ok_or_else(|| { + ControlPointError::QueueError(format!("Item {} id is not present in the queue", id)) + }) + } + + fn position_to_id(&self, index: usize) -> Result { + let idxs = self.track_ids()?; + + if index < idxs.len() { + Ok(idxs[index]) + } else { + Err(ControlPointError::QueueError(format!( + "Index out of bound {} >= {}", + index, + idxs.len() + ))) + } + } + + fn current_track(&self) -> Result, ControlPointError> { + Ok(Some(self.playlist_client.id()?)) + } + + fn current_index(&self) -> Result, ControlPointError> { + if let Some(id) = self.current_track()? { + return Ok(Some(self.id_to_position(id)?)); + } + + Ok(None) + } + fn queue_snapshot(&self) -> Result { self.ensure_playlist_source_selected()?; let entries = self.playlist_client.read_all_tracks()?; @@ -763,23 +537,27 @@ impl QueueBackend for OpenHomeQueue { let current_id = self.playlist_client.id().ok(); // Find the index of the current track in the playlist - let current_index = - current_id.and_then(|id| items.iter().position(|entry_id| entry_id.backend_id == id as usize)); + let current_index = current_id.and_then(|id| { + items + .iter() + .position(|entry_id| entry_id.backend_id == id as usize) + }); Ok(QueueSnapshot { - items: self.items.clone(), - current_index: self.current_index, + items: items, + current_index: current_index, }) } fn set_index(&mut self, index: Option) -> Result<(), ControlPointError> { - let normalized = index.filter(|&i| i < self.items.len()); - if let Some(idx) = normalized { - let track_id = self.ensure_track_id(idx)?; + if let Some(index) = index { + let track_id = self.position_to_id(index)?; self.ensure_playlist_source_selected()?; - self.playlist_client.play_id(track_id)?; + self.playlist_client.seek_id(track_id)?; + } else { + self.ensure_playlist_source_selected()?; + self.playlist_client.stop()?; } - self.current_index = normalized; Ok(()) } @@ -788,33 +566,60 @@ impl QueueBackend for OpenHomeQueue { items: Vec, current_index: Option, ) -> Result<(), ControlPointError> { + if let Some(ci) = current_index { + if ci >= items.len() { + return Err(ControlPointError::QueueError(format!( + "Invalid Current index parameter {} >= {}", + ci, + items.len() + ))); + } + } + + self.ensure_playlist_source_selected()?; + self.playlist_client.delete_all()?; + + if items.is_empty() { + return Ok(()); + } + + let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID; + + for item in items { + let metadata = build_metadata_xml(&item); + let new_id = self + .playlist_client + .insert(previous_id, &item.uri, &metadata)?; + previous_id = new_id; + } + + Ok(()) + } + + fn sync_queue(&mut self, items: Vec) -> Result<(), ControlPointError> { self.ensure_playlist_source_selected()?; if items.is_empty() { self.playlist_client.delete_all()?; - self.items.clear(); - self.track_ids.clear(); - self.current_index = None; return Ok(()); } // Synchronize local state with the actual OpenHome playlist before computing // differences. Without this, any drift between our cache and the renderer // (e.g., manual edits from another control point) would keep the stale items. - self.refresh_from_openhome()?; - - // Get the currently playing track ID from the renderer - let playing_info = self.playlist_client.id().ok().and_then(|id| { - self.track_ids - .iter() - .position(|&tid| tid == id) - .map(|idx| (idx, id, self.items[idx].uri.clone())) + let snapshot = self.queue_snapshot()?; + let playing_info = snapshot.current_index.and_then(|idx| { + Some(( + idx, + snapshot.items[idx].backend_id, + snapshot.items[idx].uri.clone(), + )) }); debug!( renderer = self.renderer_id.0.as_str(), - actual_items = self.items.len(), + actual_items = snapshot.items.len(), playing_info_detected = playing_info.is_some(), - "OpenHome playlist state refreshed before replace_queue" + "OpenHome playlist state" ); if let Some((playing_idx, playing_id, playing_uri)) = playing_info { @@ -842,7 +647,7 @@ impl QueueBackend for OpenHomeQueue { "Gentle sync: currently playing item not in new playlist, preserving as first item" ); - self.replace_queue_preserve_current(items, playing_idx, playing_id)?; + self.replace_queue_preserve_current(items, playing_id)?; } } else { // No currently playing item or can't determine it - use standard LCS @@ -850,72 +655,93 @@ impl QueueBackend for OpenHomeQueue { renderer = self.renderer_id.0.as_str(), "No currently playing item, using standard LCS sync" ); - self.replace_queue_standard_lcs(items, current_index)?; + self.replace_queue_standard_lcs(items, Some(0))?; } Ok(()) } fn get_item(&self, index: usize) -> Result, ControlPointError> { - Ok(self.items.get(index).cloned()) + let snapshot = self.queue_snapshot()?; + + if index < snapshot.items.len() { + return Ok(Some(snapshot.items[index].clone())); + } + + Err(ControlPointError::QueueError(format!( + "get_item index out of bound {} >= {}", + index, + snapshot.items.len() + ))) } fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> { - if index >= self.items.len() { - return Ok(()); + let metadata = build_metadata_xml(&item); + + let ids = self.track_ids()?; + + if index >= ids.len() { + return Err(ControlPointError::QueueError(format!( + "get_item index out of bound {} >= {}", + index, + ids.len() + ))); } self.ensure_playlist_source_selected()?; - let track_id = self.ensure_track_id(index)?; + + let track_id = ids[index]; let before_id = if index == 0 { OPENHOME_PLAYLIST_HEAD_ID } else { - self.ensure_track_id(index - 1)? + ids[index - 1] }; + let ci = self.current_index()?; // Use delete_id_if_exists() to handle cases where another control point // may have already modified the playlist - self.playlist_client.delete_id_if_exists(track_id)?; - let metadata = build_metadata_xml(&item); + self.playlist_client.delete_id(track_id)?; let new_id = self .playlist_client .insert(before_id, &item.uri, &metadata)?; - if self.current_index == Some(index) { - self.playlist_client.play_id(new_id)?; + if ci == Some(index) { + self.playlist_client.seek_id(new_id)?; } - self.items[index] = self.item_with_openhome_id(item, new_id); - self.track_ids[index] = new_id; Ok(()) } /// Override enqueue_items to add items directly to the OpenHome playlist. - fn enqueue_items(&mut self, items: Vec, mode: EnqueueMode) -> Result<(), ControlPointError> { + fn enqueue_items( + &mut self, + items: Vec, + mode: EnqueueMode, + ) -> Result<(), ControlPointError> { if items.is_empty() { return Ok(()); } match mode { EnqueueMode::AppendToEnd => { + let ids = self.track_ids()?; + let mut after_id = if ids.len() > 0 { + ids[ids.len() - 1] + } else { + OPENHOME_PLAYLIST_HEAD_ID + }; // Append to the end of the OpenHome playlist - let mut after_id = self.track_ids.last().copied(); - for item in items { - after_id = Some(self.add_playback_item(item, after_id, false)?); + after_id = self.add_playback_item(item, after_id)?; } } EnqueueMode::InsertAfterCurrent => { - // Insert after the current playing track - let after_id = if let Some(idx) = self.current_index { - self.track_ids.get(idx).copied() + if let Some(mut after_id) = self.current_track()? { + for item in items { + after_id = self.add_playback_item(item, after_id)?; + } } else { - None - }; - - let mut next_after_id = after_id; - for item in items { - next_after_id = Some(self.add_playback_item(item, next_after_id, false)?); + self.enqueue_items(items, EnqueueMode::AppendToEnd)?; } } EnqueueMode::ReplaceAll => { @@ -923,9 +749,108 @@ impl QueueBackend for OpenHomeQueue { self.replace_queue(items, None)?; } } + Ok(()) + } + + // Optimized helpers to avoid unnecessary network calls + + /// Optimized clear_queue: use delete_all() directly instead of replace_queue. + fn clear_queue(&mut self) -> Result<(), ControlPointError> { + self.ensure_playlist_source_selected()?; + self.playlist_client.delete_all() + } + + /// Optimized is_empty: only fetch track IDs, not the full playlist. + fn is_empty(&self) -> Result { + Ok(self.track_ids()?.is_empty()) + } + + /// Optimized upcoming_len: calculate from len() and current_index() without fetching items. + fn upcoming_len(&self) -> Result { + let len = self.len()?; + match self.current_index()? { + None => Ok(len), + Some(idx) => Ok(len.saturating_sub(idx + 1)), + } + } + + /// Optimized peek_current: use primitives instead of full snapshot. + fn peek_current(&self) -> Result, ControlPointError> { + let len = self.len()?; + if len == 0 { + return Ok(None); + } + + let current_idx = self.current_index()?; + let resolved_index = match current_idx { + Some(idx) if idx < len => Some(idx), + _ => None, + }; + + let item_index = resolved_index.unwrap_or(0); + let item = match self.get_item(item_index)? { + Some(item) => item, + None => return Ok(None), + }; + + let remaining = match resolved_index { + Some(idx) => len.saturating_sub(idx + 1), + None => len, + }; + + Ok(Some((item, remaining))) + } + + /// Optimized dequeue_next: use primitives instead of full snapshot. + fn dequeue_next(&mut self) -> Result, ControlPointError> { + let len = self.len()?; + if len == 0 { + return Ok(None); + } + + let current_idx = self.current_index()?; + let next_index = match current_idx { + None => 0, + Some(idx) => { + let candidate = idx + 1; + if candidate >= len { + return Ok(None); + } + candidate + } + }; + + let Some(item) = self.get_item(next_index)? else { + return Ok(None); + }; + + let remaining = len.saturating_sub(next_index + 1); + self.set_index(Some(next_index))?; + Ok(Some((item, remaining))) + } + + /// Optimized append_or_init_index: use enqueue_items which is already optimized. + fn append_or_init_index(&mut self, items: Vec) -> Result<(), ControlPointError> { + let was_empty = self.is_empty()?; + + // Use the already optimized enqueue_items(AppendToEnd) + self.enqueue_items(items, EnqueueMode::AppendToEnd)?; + + // If the queue was empty before, set index to 0 + if was_empty && !self.is_empty()? { + self.set_index(Some(0))?; + } - // Refresh local cache from OpenHome after modification - self.refresh_from_openhome()?; Ok(()) } } + +impl QueueFromRendererInfo for OpenHomeQueue { + fn from_renderer_info(renderer: &RendererInfo) -> Result { + OpenHomeQueue::from_renderer_info(renderer) + } + + fn to_backend(self) -> MusicQueue { + MusicQueue::OpenHome(self) + } +} diff --git a/pmocontrol/src/queue/snapshot.rs b/pmocontrol/src/queue/snapshot.rs new file mode 100644 index 00000000..a59abb7f --- /dev/null +++ b/pmocontrol/src/queue/snapshot.rs @@ -0,0 +1,91 @@ +use crate::{DeviceId, model::TrackMetadata}; + +/// Canonical representation of a track in a renderer queue. +/// +/// This type is the bridge between: +/// - the UPnP MediaServer (DIDL-Lite items), +/// - the ControlPoint runtime, +/// - and the different queue backends (internal / OpenHome). +/// +/// It is intentionally DIDL-centric: every item in a queue comes from +/// a UPnP ContentDirectory and carries its MediaServer identity. +#[derive(Clone, Debug)] +pub struct PlaybackItem { + /// Identifier of the UPnP MediaServer that owns this content. + /// + /// Typically this is the UDN of the MediaServer device, or an + /// equivalent logical identifier. + pub media_server_id: DeviceId, + + /// Internal ID of the item in the queue. + /// This ID only has meaning when returning a snapshot. + /// In an internal queue, it can have any value; the position in the vector is what matters. + /// In principle, it can be set to usize::MAX. + pub backend_id: usize, + + /// DIDL-Lite `id` attribute of the `item` in the ContentDirectory. + /// + /// This, combined with `media_server_id`, is the logical identity + /// of the track across refreshes of the MediaServer state. + pub didl_id: String, + + /// Main resource URI to be used for playback. + /// + /// This is usually the first `` element (or a selected one) + /// from the DIDL-Lite item. + pub uri: String, + + /// UPnP protocolInfo string for the resource (e.g., "http-get:*:audio/flac:*"). + /// + /// This string describes the protocol, network, MIME type, and additional + /// info about the media resource. It's required for proper UPnP/OpenHome + /// renderer compatibility. + pub protocol_info: String, + + /// Optional rich metadata for the track (title, artist, album, cover, + /// duration, …). + /// + /// The exact structure is defined in `TrackMetadata` and may + /// aggregate information from DIDL, tags, or additional sources. + pub metadata: Option, +} + +impl PlaybackItem { + /// Returns a stable, backend-agnostic logical identifier for this item. + /// + /// By default this is the concatenation of the MediaServer identifier + /// and the DIDL `id`. Backends and higher-level logic should use this + /// when they need to match items across queue rebuilds. + pub fn unique_id(&self) -> String { + // ADAPTE si MediaServerId n'implémente pas Display : utilise + // un champ string interne ou une méthode as_str(). + format!("{}::{}", self.media_server_id.0, self.didl_id) + } +} + + +/// Logical snapshot of a renderer queue. +/// +/// This is the canonical view used by the ControlPoint and the REST/API +/// layer. It is independent of how the queue is actually stored (local +/// in-memory queue, OpenHome playlist, …). +#[derive(Clone, Debug)] +pub struct QueueSnapshot { + /// All items currently in the queue, in play order. + pub items: Vec, + /// Index (0-based) of the current item in `items`, or `None` if + /// no item is currently selected. + pub current_index: Option, +} + +impl QueueSnapshot { + /// Returns the number of items in the snapshot. + pub fn len(&self) -> usize { + self.items.len() + } + + /// Returns `true` if the snapshot contains no items. + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } +} diff --git a/pmocontrol/src/registry.rs b/pmocontrol/src/registry.rs index ec85bb48..cb91e5bb 100644 --- a/pmocontrol/src/registry.rs +++ b/pmocontrol/src/registry.rs @@ -2,26 +2,28 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::SystemTime; -use tracing::warn; - use crate::errors::ControlPointError; +use crate::events::{MediaServerEventBus, RendererEventBus}; use crate::media_server::MusicServer; use crate::model::RendererInfo; use crate::music_renderer::MusicRenderer; -use crate::{DeviceId, DeviceIdentity, DeviceOnline, UpnpMediaServer}; +use crate::{ + DeviceId, DeviceIdentity, DeviceOnline, MediaServerEvent, RendererEvent, UpnpMediaServer, +}; const DEFAULT_MAX_AGE: u32 = 1800; #[derive(Debug, Clone)] pub struct DeviceItem { - music_rendrer : Option>, + music_renderer: Option>, music_server: Option>, } -#[derive(Debug, Default)] pub struct DeviceRegistry { devices: HashMap, udn_index: HashMap, + renderer_bus: RendererEventBus, + server_bus: MediaServerEventBus, } #[derive(Debug, Clone)] @@ -32,74 +34,85 @@ pub enum DeviceUpdate { impl DeviceItem { pub fn as_music_renderer(&self) -> Result, ControlPointError> { - match self { - DeviceItem::MusicRenderer(renderer) => Ok(Arc::clone(renderer)), - _ => Err(ControlPointError::IsNotAMediaRender(format!("{:#?}", self))), - } + self.music_renderer + .clone() // Clone l'Option> + .ok_or_else(|| ControlPointError::IsNotAMediaRender(format!("{:#?}", self))) } pub fn is_a_music_renderer(&self) -> bool { - match self { - DeviceItem::MusicRenderer(_) => true, - _ => false, - } + self.music_renderer.is_some() } pub fn as_music_server(&self) -> Result, ControlPointError> { - match self { - DeviceItem::MusicServer(server) => Ok(Arc::clone(server)), - _ => Err(ControlPointError::IsNotAMediaServer(format!("{:#?}", self))), - } + self.music_server + .clone() // Clone l'Option> + .ok_or_else(|| ControlPointError::IsNotAMediaRender(format!("{:#?}", self))) } pub fn is_a_music_server(&self) -> bool { - match self { - DeviceItem::MusicServer(_) => true, - _ => false, - } + self.music_server.is_some() } } impl DeviceOnline for DeviceItem { fn is_online(&self) -> bool { - match self { - DeviceItem::MusicRenderer(r) => r.is_online(), - DeviceItem::MusicServer(s) => s.is_online(), - } + self.music_renderer + .as_ref() + .map_or(false, |r| r.is_online()) + || self.music_server.as_ref().map_or(false, |s| s.is_online()) } fn last_seen(&self) -> SystemTime { - match self { - DeviceItem::MusicRenderer(r) => r.last_seen(), - DeviceItem::MusicServer(s) => s.last_seen(), + let renderer_time = self.music_renderer.as_ref().map(|r| r.last_seen()); + let server_time = self.music_server.as_ref().map(|s| s.last_seen()); + + match (renderer_time, server_time) { + (Some(r), Some(s)) => r.max(s), // Le plus récent + (Some(r), None) => r, + (None, Some(s)) => s, + (None, None) => unreachable!("DeviceEntry must have at least one component"), } } fn has_been_seen_now(&self, max_age: u32) { - match self { - DeviceItem::MusicRenderer(r) => r.has_been_seen_now(max_age), - DeviceItem::MusicServer(s) => s.has_been_seen_now(max_age), + if let Some(r) = &self.music_renderer { + r.has_been_seen_now(max_age); + } + if let Some(s) = &self.music_server { + s.has_been_seen_now(max_age); } } fn mark_as_offline(&self) { - match self { - DeviceItem::MusicRenderer(r) => r.mark_as_offline(), - DeviceItem::MusicServer(s) => s.mark_as_offline(), + if let Some(r) = &self.music_renderer { + r.mark_as_offline(); + } + if let Some(s) = &self.music_server { + s.mark_as_offline(); } } fn max_age(&self) -> u32 { - match self { - DeviceItem::MusicRenderer(r) => r.max_age(), - DeviceItem::MusicServer(s) => s.max_age(), + let renderer_max_age = self.music_renderer.as_ref().map(|r| r.max_age()); + let server_max_age = self.music_server.as_ref().map(|s| s.max_age()); + + match (renderer_max_age, server_max_age) { + (Some(r), Some(s)) => r.max(s), // Le plus récent + (Some(r), None) => r, + (None, Some(s)) => s, + (None, None) => unreachable!("DeviceEntry must have at least one component"), } } } impl DeviceRegistry { - pub fn new() -> Self { - Self::default() + pub fn new(renderer_bus: RendererEventBus, server_bus: MediaServerEventBus) -> Self { + Self { + devices: HashMap::new(), + udn_index: HashMap::new(), + renderer_bus, + server_bus, + } } pub fn list_renderers(&self) -> Result>, ControlPointError> { @@ -126,59 +139,6 @@ impl DeviceRegistry { self.devices.get(id)?.as_music_server().ok() } - pub fn push_renderer(&mut self, info: RendererInfo, max_age: u32) { - if let Some(existing) = self.devices.get(&info.id()) { - existing.has_been_seen_now(max_age); - } else { - // let renderer = MusicRenderer::from_renderer_info(info); - match MusicRenderer::from_renderer_info(info.clone()) { - Ok(renderer) => { - self.devices - .insert(info.id(), DeviceItem::MusicRenderer(renderer)); - self.udn_index.insert(info.udn().to_ascii_lowercase(), info.id()); - } - Err(err) => { - warn!("Failed to create renderer: {:#?}\n", err) - } - } - } - } - - pub fn push_server(&mut self, info: UpnpMediaServer, max_age: u32) { - if let Some(existing) = self.devices.get(&info.id()) { - existing.has_been_seen_now(max_age); - } else { - let server = MusicServer::Upnp(info.clone()); - self.devices - .insert(info.id(), DeviceItem::MusicServer(Arc::new(server))); - self.udn_index.insert(info.udn().to_ascii_lowercase(), info.id()); - } - } - - pub fn device_says_byebye(&mut self, udn: &str) { - if let Some(device) = self.get_device_by_udn(udn) { - device.mark_as_offline(); - } - } - - pub fn apply_update(&mut self, update: DeviceUpdate) { - match update { - DeviceUpdate::OfflineById(id) => { - if let Some(renderer) = self.devices.get(&id) { - renderer.mark_as_offline(); - } - } - DeviceUpdate::OfflineByUdn(udn) => { - let lookup = udn.to_ascii_lowercase(); - if let Some(id) = self.udn_index.get(&lookup) { - if let Some(renderer) = self.devices.get(id) { - renderer.mark_as_offline(); - } - } - } - } - } - pub fn get_device_by_udn(&self, udn: &str) -> Option { let lookup = udn.to_ascii_lowercase(); self.udn_index @@ -197,4 +157,147 @@ impl DeviceRegistry { self.get_device_by_udn(udn) .and_then(|item| item.as_music_server().ok()) } + + pub fn push_renderer(&mut self, info: &RendererInfo, max_age: u32) { + let device_id = info.id(); + + if let Some(entry) = self.devices.get_mut(&device_id) { + if let Some(renderer) = &entry.music_renderer { + let was_online = renderer.is_online(); + renderer.has_been_seen_now(max_age); + + if !was_online { + self.renderer_bus.broadcast(RendererEvent::Online { + id: device_id, + info: info.clone(), + }); + } + return; + } + // Entry existe mais pas de renderer -> on l'ajoute + if let Ok(new_renderer) = MusicRenderer::from_renderer_info(info) { + entry.music_renderer = Some(Arc::new(new_renderer)); + self.udn_index + .insert(info.udn().to_ascii_lowercase(), device_id.clone()); + + // Broadcast sur le bon bus + self.renderer_bus.broadcast(RendererEvent::Online { + id: device_id, + info: info.clone(), + }); + } + } else { + // Entry n'existe pas -> on crée + if let Ok(new_renderer) = MusicRenderer::from_renderer_info(info) { + let new_entry = DeviceItem { + music_renderer: Some(Arc::new(new_renderer)), + music_server: None, + }; + + self.devices.insert(device_id.clone(), new_entry); + self.udn_index + .insert(info.udn().to_ascii_lowercase(), device_id.clone()); + + // Broadcast sur le bon bus + self.renderer_bus.broadcast(RendererEvent::Online { + id: device_id, + info: info.clone(), + }); + } + } + } + + pub fn push_server(&mut self, info: &UpnpMediaServer, max_age: u32) { + let device_id = info.id(); + + if let Some(entry) = self.devices.get_mut(&device_id) { + if let Some(server) = &entry.music_server { + let was_online = server.is_online(); + server.has_been_seen_now(max_age); + + if !was_online { + self.server_bus.broadcast(MediaServerEvent::Online { + server_id: device_id, + info: info.clone(), + }); + } + + return; + } + // Entry existe mais pas de renderer -> on l'ajoute + if let Ok(new_server) = MusicServer::from_server_info(info) { + entry.music_server = Some(Arc::new(new_server)); + self.udn_index + .insert(info.udn().to_ascii_lowercase(), device_id.clone()); + + // Broadcast sur le bon bus + self.server_bus.broadcast(MediaServerEvent::Online { + server_id: device_id, + info: info.clone(), + }); + } + } else { + // Entry n'existe pas -> on crée + if let Ok(new_server) = MusicServer::from_server_info(info) { + let new_entry = DeviceItem { + music_renderer: None, + music_server: Some(Arc::new(new_server)), + }; + + self.devices.insert(device_id.clone(), new_entry); + self.udn_index + .insert(info.udn().to_ascii_lowercase(), device_id.clone()); + + // Broadcast sur le bon bus + self.server_bus.broadcast(MediaServerEvent::Online { + server_id: device_id, + info: info.clone(), + }); + } + } + } + + pub fn device_says_byebye(&mut self, udn: &str) { + let lookup = udn.to_ascii_lowercase(); + + if let Some(id) = self.udn_index.get(&lookup) { + if let Some(device) = self.devices.get(id) { + device.mark_as_offline(); + + // Broadcast sur le bon bus + if device.is_a_music_renderer() { + self.renderer_bus + .broadcast(RendererEvent::Offline { id: id.clone() }); + } + if device.is_a_music_server() { + self.server_bus.broadcast(MediaServerEvent::Offline { + server_id: id.clone(), + }); + } + } + } + } + + pub fn check_timeouts(&mut self) { + let now = SystemTime::now(); + + for (id, device) in &self.devices { + if let Ok(elapsed) = now.duration_since(device.last_seen()) { + if elapsed.as_secs() > device.max_age() as u64 { + device.mark_as_offline(); + + // Broadcast sur le bon bus + if device.is_a_music_renderer() { + self.renderer_bus + .broadcast(RendererEvent::Offline { id: id.clone() }); + } + if device.is_a_music_server() { + self.server_bus.broadcast(MediaServerEvent::Offline { + server_id: id.clone(), + }); + } + } + } + } + } } diff --git a/pmocontrol/src/avtransport_client.rs b/pmocontrol/src/upnp_clients/avtransport_client.rs similarity index 100% rename from pmocontrol/src/avtransport_client.rs rename to pmocontrol/src/upnp_clients/avtransport_client.rs diff --git a/pmocontrol/src/connection_manager_client.rs b/pmocontrol/src/upnp_clients/connection_manager_client.rs similarity index 100% rename from pmocontrol/src/connection_manager_client.rs rename to pmocontrol/src/upnp_clients/connection_manager_client.rs diff --git a/pmocontrol/src/upnp_clients/mod.rs b/pmocontrol/src/upnp_clients/mod.rs new file mode 100644 index 00000000..257c204d --- /dev/null +++ b/pmocontrol/src/upnp_clients/mod.rs @@ -0,0 +1,41 @@ +mod openhome_client; +mod avtransport_client; +mod rendering_control_client; +mod connection_manager_client; + + +pub use crate::upnp_clients::avtransport_client::{AvTransportClient, PositionInfo}; +pub use crate::upnp_clients::rendering_control_client::RenderingControlClient; +pub use crate::upnp_clients::connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo}; +pub use crate::upnp_clients::openhome_client::{ + OPENHOME_PLAYLIST_HEAD_ID, + OhTrackEntry,OhTrack, + OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, OhTimeClient, OhVolumeClient, +}; + +/// Resolve a possibly relative controlURL against the description URL. +/// +/// - If `control_url` is already absolute (starts with http:// or https://), it is returned as-is. +/// - Otherwise, it is resolved against the scheme://host:port of `description_url`. +pub fn resolve_control_url(description_url: &str, control_url: &str) -> String { + if control_url.starts_with("http://") || control_url.starts_with("https://") { + return control_url.to_string(); + } + + // Extract "scheme://host[:port]" from description_url + if let Some((scheme, rest)) = description_url.split_once("://") { + if let Some(pos) = rest.find('/') { + let authority = &rest[..pos]; + let base = format!("{}://{}", scheme, authority); + + if control_url.starts_with('/') { + return format!("{}{}", base, control_url); + } else { + return format!("{}/{}", base, control_url); + } + } + } + + // Fallback: just return the raw control_url if we cannot parse + control_url.to_string() +} diff --git a/pmocontrol/src/openhome_client.rs b/pmocontrol/src/upnp_clients/openhome_client.rs similarity index 83% rename from pmocontrol/src/openhome_client.rs rename to pmocontrol/src/upnp_clients/openhome_client.rs index 9bf4ac85..4d121f78 100644 --- a/pmocontrol/src/openhome_client.rs +++ b/pmocontrol/src/upnp_clients/openhome_client.rs @@ -7,6 +7,7 @@ use crate::soap_client::{ invoke_upnp_action, parse_bool, parse_visible_flag, }; use anyhow::{Result, anyhow}; +use pmodidl::DIDLLite; use tracing::{debug, info, trace, warn}; use xmltree::{Element, XMLNode}; @@ -17,11 +18,25 @@ use crate::model::RendererInfo; /// historical 0xFFFFFFFF sentinel. pub const OPENHOME_PLAYLIST_HEAD_ID: u32 = 0; -#[derive(Debug, Clone)] -pub struct OhTrackEntry { - pub id: u32, - pub uri: String, - pub metadata_xml: String, +pub trait OhTrack { + fn uri(&self) -> &str; + fn metadata_xml(&self) -> Option<&str>; + + fn metadata(&self) -> Option { + self.metadata_xml() + .as_deref() + .and_then(parse_track_metadata_from_didl) + } + + fn didl_id(&self) -> Option { + let trimmed = self.metadata_xml()?.trim(); + if trimmed.is_empty() { + return None; + } + + let parsed = pmodidl::parse_metadata::(trimmed).ok()?; + parsed.data.items.first().map(|item| item.id.clone()) + } } #[derive(Debug, Clone)] @@ -31,10 +46,20 @@ pub struct OhInfoTrack { } impl OhInfoTrack { - pub fn metadata(&self) -> Option { - self.metadata_xml - .as_deref() - .and_then(parse_track_metadata_from_didl) + pub fn new(uri: &str, metadata_xml: Option<&str>) -> Self { + OhInfoTrack { + uri: uri.to_string(), + metadata_xml: metadata_xml.map(|s| s.to_string()), + } + } +} + +impl OhTrack for OhInfoTrack { + fn metadata_xml(&self) -> Option<&str> { + self.metadata_xml.as_deref() + } + fn uri(&self) -> &str { + &self.uri } } @@ -58,12 +83,52 @@ pub struct OhProductSource { pub visible: bool, } +#[derive(Debug, Clone)] +pub struct OhInfoClient { + pub control_url: String, + pub service_type: String, +} + #[derive(Debug, Clone)] pub struct OhPlaylistClient { pub control_url: String, pub service_type: String, } +#[derive(Debug, Clone)] +pub struct OhTrackEntry { + pub id: u32, + uri: String, + metadata_xml: String, +} + +impl OhTrackEntry { + pub fn new(id: u32, uri: &str, metadata_xml: &str) -> Self { + OhTrackEntry { + id, + uri: uri.to_string(), + metadata_xml: metadata_xml.to_string(), + } + } + + pub fn id(&self) -> u32 { + self.id + } +} + +impl OhTrack for OhTrackEntry { + fn metadata_xml(&self) -> Option<&str> { + if self.metadata_xml.is_empty() { + None + } else { + Some(&self.metadata_xml) + } + } + fn uri(&self) -> &str { + &self.uri + } +} + impl OhPlaylistClient { pub fn new(control_url: String, service_type: String) -> Self { Self { @@ -73,14 +138,46 @@ impl OhPlaylistClient { } pub fn from_renderer_info(info: &RendererInfo) -> Result { - let control_url = info.oh_playlist_control_url() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create playlist control client".to_string()))?; + let control_url = info.oh_playlist_control_url().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create playlist control client".to_string()) + })?; - let service_type = info.oh_playlist_service_type() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create playlist service client".to_string()))?; + let service_type = info.oh_playlist_service_type().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create playlist service client".to_string()) + })?; Ok(OhPlaylistClient::new(control_url, service_type)) } + /// Report the uri and metadata for a given track id. + /// Returns a 800 fault code if the given id is not in the playlist. + pub fn read(&self, id: u32) -> Result { + let id_str = id.to_string(); + let args = [("IdList", id_str.as_str())]; + + let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Read", &args)?; + + if call_result.status == 800 { + return Err(ControlPointError::OpenHomeError(format!( + "Id {} not found in OpenHome playlist", + id + ))); + } + + let envelope: &pmoupnp::soap::SoapEnvelope = ensure_success("Read", &call_result)?; + + let response = + find_child_with_suffix(&envelope.body.content, "ReadResponse").ok_or_else(|| { + ControlPointError::OpenHomeError(format!( + "Missing ReadResponse element in SOAP body" + )) + })?; + + let track_uri = extract_child_text(response, "Uri")?; + let metadata = extract_child_text(response, "Metadata")?; + + Ok(OhTrackEntry::new(id, &track_uri, &metadata)) + } + pub fn read_list(&self, id_list: &[u32]) -> Result, ControlPointError> { if id_list.is_empty() { return Ok(Vec::new()); @@ -96,9 +193,13 @@ impl OhPlaylistClient { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "ReadList", &args)?; - let envelope = ensure_success("ReadList", &call_result)?; + let envelope: &pmoupnp::soap::SoapEnvelope = ensure_success("ReadList", &call_result)?; let response = find_child_with_suffix(&envelope.body.content, "ReadListResponse") - .ok_or_else(|| ControlPointError::OpenHomeError(format!("Missing ReadListResponse element in SOAP body")))?; + .ok_or_else(|| { + ControlPointError::OpenHomeError(format!( + "Missing ReadListResponse element in SOAP body" + )) + })?; let track_list_b64 = extract_child_text_any(response, &["TrackList", "Value"])?; let track_list_sample: String = track_list_b64.chars().take(256).collect(); @@ -143,7 +244,9 @@ impl OhPlaylistClient { Ok(new_id) } - pub fn play_id(&self, id: u32) -> Result<(), ControlPointError> { + /// The id of the current track (the track currently playing or that would be played + /// if the Play action was invoked). Or 0 if the playlist is empty. + pub fn seek_id(&self, id: u32) -> Result<(), ControlPointError> { let id_str = id.to_string(); let args = [("Value", id_str.as_str())]; @@ -171,11 +274,13 @@ impl OhPlaylistClient { pub fn id(&self) -> Result { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Id", &[])?; - let envelope = ensure_success("Id", &call_result) - .map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?; + let envelope = ensure_success("Id", &call_result)?; - let response = find_child_with_suffix(&envelope.body.content, "IdResponse") - .ok_or_else(|| ControlPointError::UpnpMissingReturnValue("IdResponse".to_string()))?; + let response = find_child_with_suffix(&envelope.body.content, "IdResponse").ok_or_else(|| { + ControlPointError::OpenHomeError(format!( + "Missing ReadResponse element in SOAP body" + )) + })?; let id_text = extract_child_text(response, "Value")?; let id = id_text.parse::().map_err(|_| { ControlPointError::UpnpBadReturnValue("Playlist.Id".to_string(), id_text) @@ -276,15 +381,6 @@ impl OhPlaylistClient { handle_action_response("DeleteAll", &call_result) } - pub fn current_id(&self) -> Result { - let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Id", &[])?; - let envelope = ensure_success("Id", &call_result)?; - let response = find_child_with_suffix(&envelope.body.content, "IdResponse") - .ok_or_else(|| anyhow!("Missing IdResponse element in SOAP body"))?; - let value: String = extract_child_text_any(response, &["Value"])?; - Ok(value) - } - pub fn tracks_max(&self) -> Result { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "TracksMax", &[])?; @@ -387,12 +483,6 @@ impl OhPlaylistClient { } } -#[derive(Debug, Clone)] -pub struct OhInfoClient { - pub control_url: String, - pub service_type: String, -} - impl OhInfoClient { pub fn new(control_url: String, service_type: String) -> Self { Self { @@ -402,11 +492,13 @@ impl OhInfoClient { } pub fn from_renderer_info(info: &RendererInfo) -> Result { - let control_url = info.oh_info_control_url() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create info control client".to_string()))?; + let control_url = info.oh_info_control_url().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create info control client".to_string()) + })?; - let service_type = info.oh_info_service_type() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create info service client".to_string()))?; + let service_type = info.oh_info_service_type().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create info service client".to_string()) + })?; Ok(OhInfoClient::new(control_url, service_type)) } @@ -472,11 +564,13 @@ impl OhTimeClient { } pub fn from_renderer_info(info: &RendererInfo) -> Result { - let control_url = info.oh_time_control_url() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create time control client".to_string()))?; + let control_url = info.oh_time_control_url().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create time control client".to_string()) + })?; - let service_type = info.oh_time_service_type() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create time service client".to_string()))?; + let service_type = info.oh_time_service_type().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create time service client".to_string()) + })?; Ok(OhTimeClient::new(control_url, service_type)) } @@ -528,15 +622,16 @@ impl OhVolumeClient { } pub fn from_renderer_info(info: &RendererInfo) -> Result { - let control_url = info.oh_volume_control_url() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create volume control client".to_string()))?; + let control_url = info.oh_volume_control_url().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create volume control client".to_string()) + })?; - let service_type = info.oh_volume_service_type() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create volume service client".to_string()))?; + let service_type = info.oh_volume_service_type().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create volume service client".to_string()) + })?; Ok(OhVolumeClient::new(control_url, service_type)) } - pub fn volume(&self) -> Result { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Volume", &[])?; let envelope = ensure_success("Volume", &call_result)?; @@ -596,11 +691,13 @@ impl OhRadioClient { } pub fn from_renderer_info(info: &RendererInfo) -> Result { - let control_url = info.oh_radio_control_url() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create radio control client".to_string()))?; + let control_url = info.oh_radio_control_url().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create radio control client".to_string()) + })?; - let service_type = info.oh_radio_service_type() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create radio service client".to_string()))?; + let service_type = info.oh_radio_service_type().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create radio service client".to_string()) + })?; Ok(OhRadioClient::new(control_url, service_type)) } @@ -646,15 +743,16 @@ impl OhProductClient { } pub fn from_renderer_info(info: &RendererInfo) -> Result { - let control_url = info.oh_product_control_url() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create product control client".to_string()))?; + let control_url = info.oh_product_control_url().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create product control client".to_string()) + })?; - let service_type = info.oh_product_service_type() - .ok_or_else(|| ControlPointError::OpenHomeError("Cannot create product service client".to_string()))?; + let service_type = info.oh_product_service_type().ok_or_else(|| { + ControlPointError::OpenHomeError("Cannot create product service client".to_string()) + })?; Ok(OhProductClient::new(control_url, service_type)) } - pub fn source_xml(&self) -> Result> { let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "SourceXml", &[])?; @@ -799,6 +897,15 @@ pub fn parse_track_metadata_from_didl(xml: &str) -> Option { }) } +pub fn didl_id_from_metadata(xml: &str) -> Option { + if xml.trim().is_empty() { + return None; + } + + let parsed = pmodidl::parse_metadata::(xml).ok()?; + parsed.data.items.first().map(|item| item.id.clone()) +} + fn parse_track_list(payload: &str) -> Result, ControlPointError> { let trimmed = payload.trim(); if trimmed.is_empty() { @@ -810,7 +917,9 @@ fn parse_track_list(payload: &str) -> Result, ControlPointErro } else { let bytes = decode_base64(trimmed)?; let decoded = String::from_utf8(bytes).map_err(|err| { - ControlPointError::OpenHomeError(format!("TrackList payload not valid UTF-8 after base64 decode: {err}")) + ControlPointError::OpenHomeError(format!( + "TrackList payload not valid UTF-8 after base64 decode: {err}" + )) })?; (decoded, true) }; @@ -823,8 +932,9 @@ fn parse_track_list(payload: &str) -> Result, ControlPointErro ); let mut reader = std::io::Cursor::new(xml.as_bytes()); - let root = Element::parse(&mut reader) - .map_err(|err| ControlPointError::OpenHomeError(format!("Failed to parse OpenHome TrackList XML: {}", err)))?; + let root = Element::parse(&mut reader).map_err(|err| { + ControlPointError::OpenHomeError(format!("Failed to parse OpenHome TrackList XML: {}", err)) + })?; let mut entries = Vec::new(); for node in &root.children { @@ -856,9 +966,9 @@ fn parse_track_entry(elem: &Element) -> Result ))); } - let id = id_text - .parse::() - .map_err(|_| ControlPointError::OpenHomeError(format!("Invalid OpenHome Entry Id: {}", id_text)))?; + let id = id_text.parse::().map_err(|_| { + ControlPointError::OpenHomeError(format!("Invalid OpenHome Entry Id: {}", id_text)) + })?; let uri = extract_child_text_local(elem, "Uri")?; let metadata_xml = extract_child_text_optional_local(elem, "Metadata")?.unwrap_or_default(); diff --git a/pmocontrol/src/rendering_control_client.rs b/pmocontrol/src/upnp_clients/rendering_control_client.rs similarity index 100% rename from pmocontrol/src/rendering_control_client.rs rename to pmocontrol/src/upnp_clients/rendering_control_client.rs diff --git a/pmocontrol/src/upnp_renderer.rs b/pmocontrol/src/upnp_renderer.rs deleted file mode 100644 index c460d160..00000000 --- a/pmocontrol/src/upnp_renderer.rs +++ /dev/null @@ -1,317 +0,0 @@ -use std::sync::{Arc, RwLock}; - -use anyhow::{Result, anyhow}; - -use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus}; -use crate::connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo}; -use crate::errors::ControlPointError; -use crate::rendering_control_client::RenderingControlClient; -use crate::{ - AvTransportClient, DeviceRegistry, PlaybackPosition, PlaybackState, PositionInfo, RendererInfo, - TransportControl, VolumeControl, -}; - -/// High-level handle representing a renderer and its optional AVTransport client. -#[derive(Clone, Debug)] -pub struct UpnpRenderer { - // registry: Arc>, - avtransport: Option, - rendering_control: Option, - connection_manager: Option, - has_avtransport_set_next: bool, -} - -impl UpnpRenderer { - pub fn has_avtransport(&self) -> bool { - self.avtransport.is_some() - } - - pub fn has_rendering_control(&self) -> bool { - self.rendering_control.is_some() - } - - pub fn has_connection_manager(&self) -> bool { - self.connection_manager.is_some() - } - - pub fn avtransport(&self) -> Result<&AvTransportClient, ControlPointError> { - self.avtransport.as_ref().ok_or_else(|| { - ControlPointError::upnp_operation_not_supported("AvTransport", "Renderer") - }) - } - - pub fn rendering_control(&self) -> Result<&RenderingControlClient, ControlPointError> { - self.rendering_control.as_ref().ok_or_else(|| { - ControlPointError::upnp_operation_not_supported("RenderingControl", "Renderer") - }) - } - - pub fn connection_manager(&self) -> Result<&ConnectionManagerClient, ControlPointError> { - self.connection_manager - .as_ref() - .ok_or_else(|| ControlPointError::upnp_operation_not_supported("ConnectionManager", "Renderer")) - } - - pub fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { - let avt = self.avtransport()?; - avt.set_av_transport_uri(uri, meta)?; - avt.play(0, "1") - } - - // /// Best-effort attempt to configure the next URI via AVTransport SetNextAVTransportURI. - // pub fn set_next_uri(&self, next_uri: &str, next_meta: &str) -> Result<()> { - // if !self.has_avtransport_set_next { - // return Err(op_not_supported("SetNextAVTransportURI", "AVTransport")); - // } - - // let client = self.avtransport()?; - // let result = client.set_next_av_transport_uri(next_uri, next_meta); - - // if result.is_ok() { - // let mut reg = self.registry.write().unwrap(); - // reg.mark_renderer_supports_set_next(&self.info.id); - // } - - // result - // } - - pub fn pause(&self) -> Result<(), ControlPointError> { - let avt = self.avtransport()?; - avt.pause(0) - } - - pub fn stop(&self) -> Result<(), ControlPointError> { - let avt = self.avtransport()?; - avt.stop(0) - } - - pub fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { - let avt = self.avtransport()?; - avt.seek(0, "REL_TIME", hhmmss) - } - - pub fn get_master_volume(&self) -> Result { - let rc = self.rendering_control()?; - rc.get_volume(0, "Master") - } - - pub fn set_master_volume(&self, volume: u16) -> Result<(), ControlPointError> { - let rc = self.rendering_control()?; - rc.set_volume(0, "Master", volume) - } - - pub fn get_master_mute(&self) -> Result { - let rc = self.rendering_control()?; - rc.get_mute(0, "Master") - } - - pub fn set_master_mute(&self, mute: bool) -> Result<(), ControlPointError> { - let rc = self.rendering_control()?; - rc.set_mute(0, "Master", mute) - } - - pub fn protocol_info(&self) -> Result { - let cm = self.connection_manager()?; - cm.get_protocol_info() - } - - pub fn connection_ids(&self) -> Result, ControlPointError> { - let cm = self.connection_manager()?; - cm.get_current_connection_ids() - } - - pub fn connection_info(&self, connection_id: i32) -> Result { - let cm = self.connection_manager()?; - cm.get_current_connection_info(connection_id) - } - - pub fn from_info(info: &RendererInfo) -> Self { - let avtransport = match ( - info.avtransport_control_url(), - info.avtransport_service_type(), - ) { - (Some(url), Some(service)) => Some(AvTransportClient::new(url, service)), - _ => None, - }; - - let rendering_control = match ( - info.rendering_control_control_url(), - info.rendering_control_service_type(), - ) { - (Some(url), Some(service)) => Some(RenderingControlClient::new(url, service)), - _ => None, - }; - - let connection_manager = match ( - info.connection_manager_control_url(), - info.connection_manager_service_type(), - ) { - (Some(url), Some(service)) => Some(ConnectionManagerClient::new(url, service)), - _ => None, - }; - - Self { - // registry: Arc::clone(registry), - avtransport, - rendering_control, - connection_manager, - has_avtransport_set_next: info.capabilities().has_avtransport_set_next(), - } - } -} - -/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`]. -/// -/// Cette impl se base sur AVTransport (InstanceID = 0). -impl TransportControl for UpnpRenderer { - fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { - let avt = self.avtransport()?; - avt.set_av_transport_uri(uri, meta)?; - avt.play(0, "1") - } - - fn play(&self) -> Result<(), ControlPointError> { - let avt = self.avtransport()?; - avt.play(0, "1") - } - - fn pause(&self) -> Result<(), ControlPointError> { - let avt = self.avtransport()?; - avt.pause(0) - } - - fn stop(&self) -> Result<(), ControlPointError> { - let avt = self.avtransport()?; - avt.stop(0) - } - - fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { - let avt = self.avtransport()?; - avt.seek(0, "REL_TIME", hhmmss) - } -} - -/// Implémentation UPnP RenderingControl de `VolumeControl` pour [`UpnpRenderer`]. -/// -/// Cette impl se base sur le channel "Master" (InstanceID = 0). -impl VolumeControl for UpnpRenderer { - fn volume(&self) -> Result { - self.get_master_volume() - } - - fn set_volume(&self, v: u16) -> Result<(), ControlPointError> { - self.set_master_volume(v) - } - - fn mute(&self) -> Result { - self.get_master_mute() - } - - fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { - self.set_master_mute(m) - } -} - -/// Implémentation UPnP AV de `PlaybackStatus` pour [`UpnpRenderer`]. -/// -/// Utilise AVTransport::GetTransportInfo(InstanceID=0). -impl PlaybackStatus for UpnpRenderer { - fn playback_state(&self) -> Result { - let avt = self.avtransport()?; - let info = avt.get_transport_info(0)?; - Ok(PlaybackState::from_upnp_state( - &info.current_transport_state, - )) - } -} - -impl PlaybackPosition for UpnpRenderer { - fn playback_position(&self) -> Result { - let avt = self.avtransport()?; - let raw: PositionInfo = avt.get_position_info(0)?; - - Ok(PlaybackPositionInfo { - track: Some(raw.track), - rel_time: raw.rel_time, - abs_time: raw.abs_time, - track_duration: raw.track_duration, - track_metadata: raw.track_metadata, - track_uri: raw.track_uri, - }) - } -} - -// #[cfg(test)] -// mod tests { -// use super::*; -// use crate::model::{RendererCapabilities, RendererProtocol}; -// use crate::registry::{DeviceRegistry, DeviceUpdate}; -// use std::sync::{Arc, RwLock}; -// use std::time::SystemTime; - -// // fn renderer_info(id_suffix: &str, with_avtransport: bool) -> RendererInfo { -// // RendererInfo { -// // id: RendererId(format!("renderer-{id_suffix}")), -// // udn: format!("uuid:renderer-{id_suffix}"), -// // friendly_name: format!("Renderer {id_suffix}"), -// // model_name: "Model".into(), -// // manufacturer: "Manufacturer".into(), -// // protocol: RendererProtocol::UpnpAvOnly, -// // capabilities: RendererCapabilities { -// // has_avtransport: with_avtransport, -// // ..RendererCapabilities::default() -// // }, -// // location: "http://127.0.0.1/device.xml".into(), -// // server_header: "TestServer/1.0".into(), -// // avtransport_service_type: with_avtransport -// // .then(|| "urn:schemas-upnp-org:service:AVTransport:1".into()), -// // avtransport_control_url: with_avtransport -// // .then(|| "http://127.0.0.1/avtransport".into()), -// // rendering_control_service_type: None, -// // rendering_control_control_url: None, -// // connection_manager_service_type: None, -// // connection_manager_control_url: None, -// // oh_playlist_service_type: None, -// // oh_playlist_control_url: None, -// // oh_playlist_event_sub_url: None, -// // oh_info_service_type: None, -// // oh_info_control_url: None, -// // oh_info_event_sub_url: None, -// // oh_time_service_type: None, -// // oh_time_control_url: None, -// // oh_time_event_sub_url: None, -// // oh_volume_service_type: None, -// // oh_volume_control_url: None, -// // oh_radio_service_type: None, -// // oh_radio_control_url: None, -// // oh_product_service_type: None, -// // oh_product_control_url: None, -// // } -// // } - -// fn registry_with_renderer(info: RendererInfo) -> Arc> { -// let mut registry = DeviceRegistry::new(); -// registry.apply_update(DeviceUpdate::RendererOnline(info)); -// Arc::new(RwLock::new(registry)) -// } - -// #[test] -// fn renderer_without_avtransport() { -// let info = renderer_info("no-avt", false); -// let registry = registry_with_renderer(info.clone()); -// let renderer = UpnpRenderer::from_registry(info, ®istry); - -// assert_eq!(renderer.has_avtransport(), false); -// assert_eq!(renderer.id().0, "renderer-no-avt"); -// } - -// #[test] -// fn renderer_with_avtransport() { -// let info = renderer_info("with-avt", true); -// let registry = registry_with_renderer(info.clone()); -// let renderer = UpnpRenderer::from_registry(info, ®istry); - -// assert!(renderer.has_avtransport()); -// assert_eq!(renderer.friendly_name(), "Renderer with-avt"); -// } -// }