diff --git a/pmocontrol/examples/linkplay_demo.rs b/pmocontrol/examples/linkplay_demo.rs new file mode 100644 index 00000000..d4499ad2 --- /dev/null +++ b/pmocontrol/examples/linkplay_demo.rs @@ -0,0 +1,101 @@ +use std::io; +use std::thread; +use std::time::Duration; + +use pmocontrol::{ + ControlPoint, DeviceRegistryRead, MusicRenderer, PlaybackPosition, PlaybackPositionInfo, + PlaybackState, PlaybackStatus, VolumeControl, +}; + +fn main() -> io::Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + println!("Starting PMOMusic LinkPlay demo..."); + + let cp = ControlPoint::spawn(5)?; + println!("Waiting 5 seconds for SSDP discovery..."); + thread::sleep(Duration::from_secs(5)); + + let registry = cp.registry(); + let renderers = { + let reg = registry.read().unwrap(); + reg.list_renderers() + }; + + if renderers.is_empty() { + println!("No renderers discovered."); + return Ok(()); + } + + println!("Discovered renderers:"); + for (idx, info) in renderers.iter().enumerate() { + println!( + " [{}] {} | model={} | LinkPlay HTTP={}", + idx, info.friendly_name, info.model_name, info.capabilities.has_linkplay_http + ); + } + + let linkplay_renderers: Vec = { + let reg = registry.read().unwrap(); + renderers + .iter() + .filter(|info| info.capabilities.has_linkplay_http) + .filter_map(|info| MusicRenderer::from_registry_info(info.clone(), ®)) + .collect() + }; + + if linkplay_renderers.is_empty() { + println!("\nNo LinkPlay-capable renderers detected."); + return Ok(()); + } + + println!("\nLinkPlay renderer status:"); + for renderer in linkplay_renderers { + println!("- {} ({})", renderer.friendly_name(), renderer.id().0); + + match renderer.playback_state() { + Ok(state) => println!(" state: {}", format_playback_state(&state)), + Err(err) => println!(" state: error: {}", err), + } + + match renderer.playback_position() { + Ok(pos) => println!(" position: {}", format_position(&pos)), + Err(err) => println!(" position: error: {}", err), + } + + match renderer.volume() { + Ok(vol) => println!(" volume: {}", vol), + Err(err) => println!(" volume: error: {}", err), + } + + match renderer.mute() { + Ok(mute) => println!(" mute: {}", mute), + Err(err) => println!(" mute: error: {}", err), + } + + println!(); + } + + Ok(()) +} + +fn format_playback_state(state: &PlaybackState) -> String { + match state { + PlaybackState::Stopped => "Stopped".to_string(), + PlaybackState::Playing => "Playing".to_string(), + PlaybackState::Paused => "Paused".to_string(), + PlaybackState::Transitioning => "Transitioning".to_string(), + PlaybackState::NoMedia => "NoMedia".to_string(), + PlaybackState::Unknown(raw) => format!("Unknown({})", raw), + } +} + +fn format_position(pos: &PlaybackPositionInfo) -> String { + let rel = pos.rel_time.as_deref().unwrap_or("-"); + let dur = pos.track_duration.as_deref().unwrap_or("-"); + let track = pos + .track + .map(|t| t.to_string()) + .unwrap_or_else(|| "-".to_string()); + + format!("track={} rel_time={} duration={}", track, rel, dur) +} diff --git a/pmocontrol/examples/renderer_demo.rs b/pmocontrol/examples/renderer_demo.rs index 93ad44ea..52683ffb 100644 --- a/pmocontrol/examples/renderer_demo.rs +++ b/pmocontrol/examples/renderer_demo.rs @@ -82,6 +82,7 @@ fn main() -> Result<()> { " [{}] {} | model={} | udn={} | location={}", idx, info.friendly_name, info.model_name, info.udn, info.location ); + print_backend(" ", r); print_capabilities(" ", &info.capabilities, &info.protocol); } @@ -96,6 +97,7 @@ fn main() -> Result<()> { println!(" UDN : {}", info.udn); println!(" Location : {}", info.location); println!(" Protocol : {:?}", info.protocol); + print_backend(" ", renderer); print_capabilities(" ", &info.capabilities, &info.protocol); if let Some(upnp) = renderer.as_upnp() { @@ -216,15 +218,24 @@ fn main() -> Result<()> { fn print_capabilities(prefix: &str, caps: &RendererCapabilities, proto: &RendererProtocol) { println!("{prefix}Capabilities:"); - println!("{prefix} Protocol : {:?}", proto); - println!("{prefix} AVTransport : {}", caps.has_avtransport); - println!("{prefix} RendControl : {}", caps.has_rendering_control); - println!("{prefix} ConnManager : {}", caps.has_connection_manager); - println!("{prefix} OH Playlist : {}", caps.has_oh_playlist); - println!("{prefix} OH Volume : {}", caps.has_oh_volume); - println!("{prefix} OH Info : {}", caps.has_oh_info); - println!("{prefix} OH Time : {}", caps.has_oh_time); - println!("{prefix} OH Radio : {}", caps.has_oh_radio); + println!("{prefix} Protocol : {:?}", proto); + println!("{prefix} AVTransport : {}", caps.has_avtransport); + println!("{prefix} RendControl : {}", caps.has_rendering_control); + println!("{prefix} ConnManager : {}", caps.has_connection_manager); + println!("{prefix} LinkPlay HTTP : {}", caps.has_linkplay_http); + println!("{prefix} OH Playlist : {}", caps.has_oh_playlist); + println!("{prefix} OH Volume : {}", caps.has_oh_volume); + println!("{prefix} OH Info : {}", caps.has_oh_info); + println!("{prefix} OH Time : {}", caps.has_oh_time); + println!("{prefix} OH Radio : {}", caps.has_oh_radio); +} + +fn print_backend(prefix: &str, renderer: &MusicRenderer) { + let backend = match renderer { + MusicRenderer::Upnp(_) => "UpnpRenderer (UPnP AV / DLNA)", + MusicRenderer::LinkPlay(_) => "LinkPlayRenderer (LinkPlay HTTP)", + }; + println!("{prefix}Backend : {backend}"); } fn dump_renderer_state(renderer: &MusicRenderer, label: &str) -> Result<()> { diff --git a/pmocontrol/src/capabilities.rs b/pmocontrol/src/capabilities.rs index 461c0940..93a003de 100644 --- a/pmocontrol/src/capabilities.rs +++ b/pmocontrol/src/capabilities.rs @@ -1,8 +1,6 @@ // pmocontrol/src/capabilities.rs use anyhow::Result; -use crate::avtransport_client::PositionInfo; - /// Logical playback position across backends. /// /// Times peuvent être soit en secondes, soit en "HH:MM:SS" selon ce que diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index f85dda5d..62e01f1b 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -91,14 +91,14 @@ impl ControlPoint { let reg = runtime_cp.registry.read().unwrap(); reg.list_renderers() .into_iter() - .map(|info| UpnpRenderer::from_registry(info, ®)) + .filter_map(|info| MusicRenderer::from_registry_info(info, ®)) .collect::>() }; let mut seen_ids = HashSet::new(); for renderer in renderers { - let info = &renderer.info; + let info = renderer.info(); // Ne pas poller les renderers offline if !info.online { diff --git a/pmocontrol/src/lib.rs b/pmocontrol/src/lib.rs index aa91326d..1dec03ec 100644 --- a/pmocontrol/src/lib.rs +++ b/pmocontrol/src/lib.rs @@ -5,6 +5,7 @@ pub mod capabilities; pub mod connection_manager_client; pub mod control_point; pub mod discovery; +pub mod linkplay; pub mod model; pub mod provider; pub mod upnp_renderer; @@ -18,6 +19,7 @@ pub use capabilities::{TransportControl, VolumeControl, PlaybackState, PlaybackS pub use connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo}; pub use control_point::ControlPoint; pub use rendering_control_client::RenderingControlClient; +pub use linkplay::LinkPlayRenderer; pub use upnp_renderer::UpnpRenderer; pub use music_renderer::MusicRenderer; diff --git a/pmocontrol/src/linkplay.rs b/pmocontrol/src/linkplay.rs new file mode 100644 index 00000000..8e19500d --- /dev/null +++ b/pmocontrol/src/linkplay.rs @@ -0,0 +1,478 @@ +use std::char; +use std::collections::HashMap; +use std::fmt; +use std::time::Duration; + +use anyhow::{anyhow, Context, Result}; +use tracing::debug; +use ureq::Agent; + +use crate::capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, + VolumeControl, +}; +use crate::model::{RendererId, 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 { + pub info: RendererInfo, + host: String, + timeout: Duration, +} + +impl fmt::Debug for LinkPlayRenderer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("LinkPlayRenderer") + .field("id", &self.info.id) + .field("friendly_name", &self.info.friendly_name) + .field("host", &self.host) + .finish() + } +} + +impl LinkPlayRenderer { + /// Build a LinkPlay backend from a registry snapshot. + pub fn from_renderer_info(info: RendererInfo) -> Result { + let host = extract_linkplay_host(&info.location) + .ok_or_else(|| anyhow!("Renderer {} has no valid LOCATION host", info.udn))?; + + Ok(Self { + info, + host, + timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS), + }) + } + + fn agent(&self) -> Agent { + build_agent(self.timeout) + } + + pub fn id(&self) -> &RendererId { + &self.info.id + } + + pub fn friendly_name(&self) -> &str { + &self.info.friendly_name + } + + fn send_player_command(&self, command: &str) -> Result<()> { + let url = format!( + "http://{}/httpapi.asp?command=setPlayerCmd:{}", + self.host, command + ); + self.agent() + .get(&url) + .call() + .with_context(|| 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<()> { + let encoded = percent_encode(uri); + self.send_player_command(&format!("play:{}", encoded)) + } + + fn play(&self) -> Result<()> { + self.send_player_command("resume") + } + + fn pause(&self) -> Result<()> { + self.send_player_command("pause") + } + + fn stop(&self) -> Result<()> { + self.send_player_command("stop") + } + + fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { + let secs = parse_hhmmss_to_secs(hhmmss) + .ok_or_else(|| anyhow!("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<()> { + 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<()> { + 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() + .with_context(|| format!("HTTP request failed for LinkPlay status on {}", host))?; + + let body = response + .body_mut() + .read_to_string() + .context("Failed to read LinkPlay status body")?; + + 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 + }, + } + } +} + +fn parse_linkplay_status(body: &str) -> Result { + let mut map = parse_flat_json(body)?; + + let state_raw = map + .remove("status") + .ok_or_else(|| anyhow!("LinkPlay status missing `status` field"))?; + + 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(anyhow!("Invalid LinkPlay mute value: {}", other)); + } + None => return Err(anyhow!("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(|| anyhow!("LinkPlay status missing `{}` field", key))?; + raw.parse::() + .with_context(|| format!("Invalid `{}` value: {}", key, raw)) +} + +fn parse_u16_field(map: &HashMap, key: &str) -> Result { + let raw = map + .get(key) + .ok_or_else(|| anyhow!("LinkPlay status missing `{}` field", key))?; + let value = raw + .parse::() + .with_context(|| format!("Invalid `{}` value: {}", key, raw))?; + Ok(value.min(100)) +} + +fn parse_flat_json(input: &str) -> Result> { + let mut chars = input.chars().peekable(); + skip_ws(&mut chars); + if chars.next() != Some('{') { + return Err(anyhow!("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(anyhow!("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(anyhow!( + "Unexpected character '{}' while parsing JSON", + other + )); + } + None => return Err(anyhow!("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(anyhow!("Unsupported JSON value in LinkPlay status")), + } +} + +fn parse_json_string( + chars: &mut std::iter::Peekable>, +) -> Result { + if chars.next() != Some('"') { + return Err(anyhow!("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(|| anyhow!("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(|| anyhow!("Invalid \\u escape"))?; + hex.push(h); + } + let code = u16::from_str_radix(&hex, 16) + .with_context(|| format!("Invalid unicode escape: {}", hex))?; + if let Some(c) = char::from_u32(code as u32) { + out.push(c); + } else { + return Err(anyhow!("Invalid unicode code point: {}", code)); + } + } + other => return Err(anyhow!("Unsupported escape: {}", other)), + } + } + other => out.push(other), + } + } + + Err(anyhow!("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(anyhow!("Invalid number")); + } + + Ok(out) +} + +fn expect_literal( + chars: &mut std::iter::Peekable>, + literal: &str, +) -> Result<()> { + for expected in literal.chars() { + match chars.next() { + Some(ch) if ch == expected => {} + _ => return Err(anyhow!("Invalid literal while parsing JSON")), + } + } + Ok(()) +} + +fn expect_char(chars: &mut std::iter::Peekable>, expected: char) -> Result<()> { + match chars.next() { + Some(ch) if ch == expected => Ok(()), + _ => Err(anyhow!("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/model.rs b/pmocontrol/src/model.rs index 5c483d6c..19a762b6 100644 --- a/pmocontrol/src/model.rs +++ b/pmocontrol/src/model.rs @@ -18,6 +18,7 @@ pub struct RendererCapabilities { pub has_avtransport: bool, pub has_rendering_control: bool, pub has_connection_manager: bool, + pub has_linkplay_http: bool, pub has_oh_playlist: bool, pub has_oh_volume: bool, diff --git a/pmocontrol/src/music_renderer.rs b/pmocontrol/src/music_renderer.rs index fa233722..caaf8eb8 100644 --- a/pmocontrol/src/music_renderer.rs +++ b/pmocontrol/src/music_renderer.rs @@ -2,20 +2,25 @@ use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus}; use crate::model::{RendererId, RendererInfo, RendererProtocol}; -use crate::{DeviceRegistry, PlaybackPosition, PlaybackState, PositionInfo, TransportControl, UpnpRenderer, VolumeControl}; +use crate::{ + DeviceRegistry, LinkPlayRenderer, PlaybackPosition, PlaybackState, TransportControl, + UpnpRenderer, VolumeControl, +}; use anyhow::Result; +use tracing::warn; /// Music view of a renderer, independent of the underlying protocol/backend. /// -/// For now, only UPnP AV renderers are supported via [`UpnpRenderer`], -/// but this type is designed to host additional backends (e.g. OpenHome) -/// later on. +/// Currently supported backends: +/// - [`UpnpRenderer`] (AVTransport + RenderingControl) +/// - [`LinkPlayRenderer`] (LinkPlay HTTP API) +/// Additional backends (e.g. OpenHome) can be integrated later. #[derive(Clone, Debug)] pub enum MusicRenderer { /// UPnP AV / DLNA backend. Upnp(UpnpRenderer), - // Future backends could be added here, e.g.: - // OpenHome(OpenHomeRenderer), + /// LinkPlay HTTP backend (Arylic and similar devices). + LinkPlay(LinkPlayRenderer), } impl MusicRenderer { @@ -23,6 +28,7 @@ impl MusicRenderer { pub fn id(&self) -> &RendererId { match self { MusicRenderer::Upnp(r) => r.id(), + MusicRenderer::LinkPlay(r) => r.id(), } } @@ -30,20 +36,20 @@ impl MusicRenderer { pub fn friendly_name(&self) -> &str { match self { MusicRenderer::Upnp(r) => r.friendly_name(), + MusicRenderer::LinkPlay(r) => r.friendly_name(), } } /// Protocol classification (UPnP AV only, OpenHome only, hybrid). pub fn protocol(&self) -> &RendererProtocol { - match self { - MusicRenderer::Upnp(r) => &r.info.protocol, - } + &self.info().protocol } /// Full static info as stored in the registry. pub fn info(&self) -> &RendererInfo { match self { MusicRenderer::Upnp(r) => &r.info, + MusicRenderer::LinkPlay(r) => &r.info, } } @@ -51,19 +57,30 @@ impl MusicRenderer { pub fn as_upnp(&self) -> Option<&UpnpRenderer> { match self { MusicRenderer::Upnp(r) => Some(r), + _ => None, } } /// Construct a music renderer from a [`RendererInfo`] and the registry. /// /// Returns `None` when no supported backend can be built for this renderer. - /// Currently, only UPnP AV / hybrid renderers are mapped to [`MusicRenderer::Upnp`]. + /// UPnP AV / hybrid renderers map either to [`MusicRenderer::LinkPlay`] (when supported) + /// or [`MusicRenderer::Upnp`]. pub fn from_registry_info( info: RendererInfo, registry: &DeviceRegistry, ) -> Option { match info.protocol { RendererProtocol::UpnpAvOnly | RendererProtocol::Hybrid => { + if info.capabilities.has_linkplay_http { + match LinkPlayRenderer::from_renderer_info(info.clone()) { + Ok(renderer) => return Some(MusicRenderer::LinkPlay(renderer)), + Err(err) => warn!( + "Failed to build LinkPlay backend for {}: {}. Falling back to UPnP.", + info.friendly_name, err + ), + } + } Some(MusicRenderer::Upnp(UpnpRenderer::from_registry(info, registry))) } RendererProtocol::OpenHomeOnly => { @@ -76,78 +93,85 @@ impl MusicRenderer { /// Implémentation générique de `TransportControl` pour [`MusicRenderer`]. /// -/// Pour l'instant, seule la variante [`MusicRenderer::Upnp`] est supportée, -/// et la délégation se fait vers l'implémentation UPnP AV. +/// Les variantes UPnP et LinkPlay délèguent aux backends correspondants. impl TransportControl for MusicRenderer { fn play_uri(&self, uri: &str, meta: &str) -> Result<()> { match self { MusicRenderer::Upnp(r) => r.play_uri(uri, meta), + MusicRenderer::LinkPlay(r) => r.play_uri(uri, meta), } } fn play(&self) -> Result<()> { match self { MusicRenderer::Upnp(r) => TransportControl::play(r), + MusicRenderer::LinkPlay(r) => r.play(), } } fn pause(&self) -> Result<()> { match self { MusicRenderer::Upnp(r) => r.pause(), + MusicRenderer::LinkPlay(r) => r.pause(), } } fn stop(&self) -> Result<()> { match self { MusicRenderer::Upnp(r) => r.stop(), + MusicRenderer::LinkPlay(r) => r.stop(), } } fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { match self { MusicRenderer::Upnp(r) => r.seek_rel_time(hhmmss), + MusicRenderer::LinkPlay(r) => r.seek_rel_time(hhmmss), } } } /// Implémentation générique de `VolumeControl` pour [`MusicRenderer`]. /// -/// Pour l'instant, seule la variante [`MusicRenderer::Upnp`] est supportée, -/// et la délégation se fait vers l'implémentation UPnP RenderingControl. +/// Les variantes UPnP et LinkPlay délèguent aux backends correspondants. impl VolumeControl for MusicRenderer { fn volume(&self) -> Result { match self { MusicRenderer::Upnp(r) => r.get_master_volume(), + MusicRenderer::LinkPlay(r) => r.volume(), } } fn set_volume(&self, v: u16) -> Result<()> { match self { MusicRenderer::Upnp(r) => r.set_master_volume(v), + MusicRenderer::LinkPlay(r) => r.set_volume(v), } } fn mute(&self) -> Result { match self { MusicRenderer::Upnp(r) => r.get_master_mute(), + MusicRenderer::LinkPlay(r) => r.mute(), } } fn set_mute(&self, m: bool) -> Result<()> { match self { MusicRenderer::Upnp(r) => r.set_master_mute(m), + MusicRenderer::LinkPlay(r) => r.set_mute(m), } } } /// Implémentation générique de `PlaybackStatus` pour [`MusicRenderer`]. /// -/// La variante UPnP délègue à [`UpnpRenderer`]. Les backends OpenHome -/// futurs mapperont l'état OH vers `PlaybackState`. +/// Chaque backend fournit sa propre source d'état (UPnP AVTransport ou LinkPlay HTTP). impl PlaybackStatus for MusicRenderer { fn playback_state(&self) -> Result { match self { MusicRenderer::Upnp(r) => PlaybackStatus::playback_state(r), + MusicRenderer::LinkPlay(r) => r.playback_state(), } } } @@ -156,7 +180,7 @@ impl PlaybackPosition for MusicRenderer { fn playback_position(&self) -> Result { match self { MusicRenderer::Upnp(r) => r.playback_position(), - // MusicRenderer::OpenHome(r) => r.playback_position(), plus tard + MusicRenderer::LinkPlay(r) => r.playback_position(), } } -} \ No newline at end of file +} diff --git a/pmocontrol/src/provider.rs b/pmocontrol/src/provider.rs index fb1d49e8..ff8ec1a8 100644 --- a/pmocontrol/src/provider.rs +++ b/pmocontrol/src/provider.rs @@ -1,5 +1,5 @@ use std::io::BufReader; -use std::time::SystemTime; +use std::time::{Duration, SystemTime}; use quick_xml::{Error as XmlError, Reader, events::Event}; use thiserror::Error; @@ -7,6 +7,7 @@ use tracing::{debug, warn}; use crate::avtransport_client::AvTransportClient; use crate::discovery::{DeviceDescriptionProvider, DiscoveredEndpoint}; +use crate::linkplay::detect_linkplay_http; use crate::model::{ MediaServerCapabilities, MediaServerId, MediaServerInfo, RendererCapabilities, RendererId, RendererInfo, RendererProtocol, @@ -273,7 +274,13 @@ impl HttpXmlDescriptionProvider { .as_deref() .unwrap_or_else(|| endpoint.udn.as_str()); let udn = raw_udn.to_ascii_lowercase(); - let caps = detect_renderer_capabilities(&parsed.service_types); + let mut caps = detect_renderer_capabilities(&parsed.service_types); + if detect_linkplay_http( + &endpoint.location, + Duration::from_secs(self.timeout_secs.max(1)), + ) { + caps.has_linkplay_http = true; + } let protocol = detect_renderer_protocol(&caps); let now = SystemTime::now();