- Add viewport-fit=cover to HTML meta for iOS safe-area support - Extend bottom drawers and tab bar into system navigation area using env(safe_area_inset_bottom) - Adjust queue drawer transforms to account for safe-area padding + Add enrich_position_from_queue() helper and integrate across UPnP, Arylic TCP, LinkPlay & Chromecast backends to ensure queue-authoritative metadata + Add transient error retry logic for UPnP control actions (2 retries, 300ms delay) + Separate timeouts: short poll timeout for GetTransportInfo/GetPosition (3s), longer actiontimeout SetAVTURI/SetNext... for slow devices + Remove duplicate continuous-stream detection from play_uri() methods (now handled centrally) - Bump version to 0.3.49
448 lines
14 KiB
Rust
448 lines
14 KiB
Rust
// pmocontrol/src/avtransport_client.rs
|
||
|
||
use std::time::Duration;
|
||
|
||
use crate::{
|
||
errors::ControlPointError,
|
||
soap_client::{
|
||
extract_child_text, handle_action_response, invoke_upnp_action,
|
||
invoke_upnp_action_with_timeout,
|
||
},
|
||
};
|
||
use pmoupnp::soap::SoapEnvelope;
|
||
use xmltree::{Element, XMLNode};
|
||
|
||
/// Timeout for slow/long control actions (SetAVTransportURI, SetNextAVTransportURI).
|
||
const AVTRANSPORT_ACTION_TIMEOUT: Duration = Duration::from_secs(5);
|
||
/// Timeout for fast polling-read actions (GetTransportInfo, GetPositionInfo).
|
||
/// Must be well below the watcher short-interval (500 ms) to avoid cascading lateness.
|
||
const AVTRANSPORT_POLL_TIMEOUT: Duration = Duration::from_secs(3);
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct AvTransportClient {
|
||
pub control_url: String,
|
||
pub service_type: String,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct TransportInfo {
|
||
pub current_transport_state: String,
|
||
pub current_transport_status: String,
|
||
pub current_speed: String,
|
||
}
|
||
|
||
impl AvTransportClient {
|
||
pub fn new(control_url: String, service_type: String) -> Self {
|
||
Self {
|
||
control_url,
|
||
service_type,
|
||
}
|
||
}
|
||
|
||
/// AVTransport:1 — GetTransportInfo
|
||
pub fn get_transport_info(&self, instance_id: u32) -> Result<TransportInfo, ControlPointError> {
|
||
let instance_id_str = instance_id.to_string();
|
||
let args = [("InstanceID", instance_id_str.as_str())];
|
||
|
||
let call_result = invoke_upnp_action_with_timeout(
|
||
&self.control_url,
|
||
&self.service_type,
|
||
"GetTransportInfo",
|
||
&args,
|
||
Some(AVTRANSPORT_POLL_TIMEOUT),
|
||
)?;
|
||
|
||
if !call_result.status.is_success() {
|
||
return Err(ControlPointError::UpnpError(format!(
|
||
"GetTransportInfo failed with HTTP status {}",
|
||
call_result.status
|
||
)));
|
||
}
|
||
|
||
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
|
||
ControlPointError::UpnpError(format!(
|
||
"Missing SOAP envelope in GetTransportInfo response"
|
||
))
|
||
})?;
|
||
|
||
parse_transport_info(envelope)
|
||
}
|
||
|
||
/// AVTransport:1 — SetAVTransportURI
|
||
///
|
||
/// Pour l’instant on force `InstanceID = 0`, ce qui couvre la majorité
|
||
/// des MediaRenderers UPnP AV (un seul instance de transport).
|
||
///
|
||
/// - `uri` : CurrentURI
|
||
/// - `meta` : CurrentURIMetaData (DIDL-Lite ou chaîne vide)
|
||
pub fn set_av_transport_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||
// Log le DIDL-Lite envoyé (limité pour éviter de polluer les logs)
|
||
if !meta.is_empty() {
|
||
tracing::debug!(
|
||
"SetAVTransportURI - URI: {}, MetaData: {}",
|
||
&uri[..uri.len().min(80)],
|
||
&meta[..meta.len().min(500)]
|
||
);
|
||
}
|
||
|
||
let args = [
|
||
("InstanceID", "0"),
|
||
("CurrentURI", uri),
|
||
("CurrentURIMetaData", meta),
|
||
];
|
||
|
||
let call_result = invoke_upnp_action(
|
||
&self.control_url,
|
||
&self.service_type,
|
||
"SetAVTransportURI",
|
||
&args,
|
||
)?;
|
||
|
||
handle_action_response("SetAVTransportURI", &call_result)
|
||
}
|
||
|
||
/// AVTransport:1 — Play
|
||
pub fn play(&self, instance_id: u32, speed: &str) -> Result<(), ControlPointError> {
|
||
let instance_id_str = instance_id.to_string();
|
||
let args = [("InstanceID", instance_id_str.as_str()), ("Speed", speed)];
|
||
|
||
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Play", &args)?;
|
||
|
||
handle_action_response("Play", &call_result)
|
||
}
|
||
|
||
/// AVTransport:1 — Pause
|
||
pub fn pause(&self, instance_id: u32) -> Result<(), ControlPointError> {
|
||
let instance_id_str = instance_id.to_string();
|
||
let args = [("InstanceID", instance_id_str.as_str())];
|
||
|
||
let call_result =
|
||
invoke_upnp_action(&self.control_url, &self.service_type, "Pause", &args)?;
|
||
|
||
handle_action_response("Pause", &call_result)
|
||
}
|
||
|
||
/// AVTransport:1 — Stop
|
||
pub fn stop(&self, instance_id: u32) -> Result<(), ControlPointError> {
|
||
let instance_id_str = instance_id.to_string();
|
||
let args = [("InstanceID", instance_id_str.as_str())];
|
||
|
||
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Stop", &args)?;
|
||
|
||
handle_action_response("Stop", &call_result)
|
||
}
|
||
|
||
/// AVTransport:1 — Seek
|
||
pub fn seek(
|
||
&self,
|
||
instance_id: u32,
|
||
unit: &str,
|
||
target: &str,
|
||
) -> Result<(), ControlPointError> {
|
||
let instance_id_str = instance_id.to_string();
|
||
let args = [
|
||
("InstanceID", instance_id_str.as_str()),
|
||
("Unit", unit),
|
||
("Target", target),
|
||
];
|
||
|
||
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Seek", &args)?;
|
||
|
||
handle_action_response("Seek", &call_result)
|
||
}
|
||
|
||
/// Optional AVTransport:1 action SetNextAVTransportURI.
|
||
///
|
||
/// This should configure the *next* track to be played after the current one.
|
||
/// Many renderers do NOT implement this action; in that case the method
|
||
/// returns an error derived from the UPnP error code.
|
||
pub fn set_next_av_transport_uri(
|
||
&self,
|
||
next_uri: &str,
|
||
next_meta: &str,
|
||
) -> Result<(), ControlPointError> {
|
||
let args = [
|
||
("InstanceID", "0"),
|
||
("NextURI", next_uri),
|
||
("NextURIMetaData", next_meta),
|
||
];
|
||
|
||
let call_result = invoke_upnp_action_with_timeout(
|
||
&self.control_url,
|
||
&self.service_type,
|
||
"SetNextAVTransportURI",
|
||
&args,
|
||
Some(AVTRANSPORT_ACTION_TIMEOUT),
|
||
)?;
|
||
|
||
if let Err(err) = handle_action_response("SetNextAVTransportURI", &call_result) {
|
||
if let Some(env) = &call_result.envelope {
|
||
if let Some(upnp_error) = parse_upnp_error(env) {
|
||
if is_set_next_not_supported_error(&upnp_error) {
|
||
return Err(ControlPointError::upnp_operation_not_supported(
|
||
"SetNextAVTransportURI",
|
||
"AVTransport",
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
return Err(err);
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
fn parse_transport_info(envelope: &SoapEnvelope) -> Result<TransportInfo, ControlPointError> {
|
||
let response = find_child_with_suffix(&envelope.body.content, "GetTransportInfoResponse")
|
||
.ok_or_else(|| {
|
||
ControlPointError::UpnpError(format!(
|
||
"Missing GetTransportInfoResponse element in SOAP body"
|
||
))
|
||
})?;
|
||
|
||
let current_transport_state = extract_child_text(response, "CurrentTransportState")?;
|
||
let current_transport_status = extract_child_text(response, "CurrentTransportStatus")?;
|
||
let current_speed = extract_child_text(response, "CurrentSpeed")?;
|
||
|
||
Ok(TransportInfo {
|
||
current_transport_state,
|
||
current_transport_status,
|
||
current_speed,
|
||
})
|
||
}
|
||
|
||
/// Représente une erreur UPnP extraite d’un SOAP Fault.
|
||
#[derive(Debug, Clone)]
|
||
struct UpnpError {
|
||
pub error_code: u32,
|
||
pub error_description: String,
|
||
}
|
||
|
||
/// Parse un éventuel SOAP Fault contenant un UPnPError.
|
||
///
|
||
/// Schéma typique (SOAP 1.1) :
|
||
///
|
||
/// ```xml
|
||
/// <s:Body>
|
||
/// <s:Fault>
|
||
/// <faultcode>...</faultcode>
|
||
/// <faultstring>...</faultstring>
|
||
/// <detail>
|
||
/// <UPnPError xmlns="urn:schemas-upnp-org:control-1-0">
|
||
/// <errorCode>401</errorCode>
|
||
/// <errorDescription>Invalid Action</errorDescription>
|
||
/// </UPnPError>
|
||
/// </detail>
|
||
/// </s:Fault>
|
||
/// </s:Body>
|
||
/// ```
|
||
fn parse_upnp_error(envelope: &SoapEnvelope) -> Option<UpnpError> {
|
||
let fault = find_child_with_suffix(&envelope.body.content, "Fault")?;
|
||
let detail = find_child_with_suffix(fault, "detail")?;
|
||
let upnp_error = find_child_with_suffix(detail, "UPnPError")?;
|
||
|
||
// errorCode (obligatoire dans la spec)
|
||
let error_code_elem = upnp_error.children.iter().find_map(|node| match node {
|
||
XMLNode::Element(elem) if elem.name.ends_with("errorCode") => Some(elem),
|
||
_ => None,
|
||
})?;
|
||
|
||
let binding = error_code_elem.get_text()?;
|
||
let error_code_text = binding.trim();
|
||
let error_code = error_code_text.parse::<u32>().ok()?;
|
||
|
||
// errorDescription (optionnel, mais utile)
|
||
let error_description = upnp_error
|
||
.children
|
||
.iter()
|
||
.find_map(|node| match node {
|
||
XMLNode::Element(elem) if elem.name.ends_with("errorDescription") => {
|
||
elem.get_text().map(|t| t.trim().to_string())
|
||
}
|
||
_ => None,
|
||
})
|
||
.unwrap_or_else(|| String::from(""));
|
||
|
||
Some(UpnpError {
|
||
error_code,
|
||
error_description,
|
||
})
|
||
}
|
||
|
||
fn is_set_next_not_supported_error(err: &UpnpError) -> bool {
|
||
if err.error_code == 401 {
|
||
return true;
|
||
}
|
||
|
||
let desc = err.error_description.to_ascii_lowercase();
|
||
desc.contains("invalid action") || desc.contains("not implemented")
|
||
}
|
||
|
||
fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> {
|
||
parent.children.iter().find_map(|node| match node {
|
||
XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use pmoupnp::soap::{SoapBody, SoapEnvelope};
|
||
|
||
fn text_element(name: &str, text: &str) -> Element {
|
||
let mut elem = Element::new(name);
|
||
elem.children.push(XMLNode::Text(text.to_string()));
|
||
elem
|
||
}
|
||
|
||
#[test]
|
||
fn parse_transport_info_extracts_fields() {
|
||
let mut response = Element::new("u:GetTransportInfoResponse");
|
||
response.children.push(XMLNode::Element(text_element(
|
||
"CurrentTransportState",
|
||
"STOPPED",
|
||
)));
|
||
response.children.push(XMLNode::Element(text_element(
|
||
"CurrentTransportStatus",
|
||
"OK",
|
||
)));
|
||
response
|
||
.children
|
||
.push(XMLNode::Element(text_element("CurrentSpeed", "1")));
|
||
|
||
let mut body = Element::new("s:Body");
|
||
body.children.push(XMLNode::Element(response));
|
||
|
||
let envelope = SoapEnvelope {
|
||
header: None,
|
||
body: SoapBody { content: body },
|
||
};
|
||
|
||
let info = parse_transport_info(&envelope).unwrap();
|
||
assert_eq!(info.current_transport_state, "STOPPED");
|
||
assert_eq!(info.current_transport_status, "OK");
|
||
assert_eq!(info.current_speed, "1");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod upnp_error_tests {
|
||
use super::*;
|
||
use pmoupnp::soap::{SoapBody, SoapEnvelope};
|
||
|
||
fn text_element(name: &str, text: &str) -> Element {
|
||
let mut elem = Element::new(name);
|
||
elem.children.push(XMLNode::Text(text.to_string()));
|
||
elem
|
||
}
|
||
|
||
#[test]
|
||
fn parse_upnp_error_extracts_error_code_and_description() {
|
||
let error_code = text_element("errorCode", "401");
|
||
let error_description = text_element("errorDescription", "Invalid Action");
|
||
|
||
let mut upnp_error = Element::new("UPnPError");
|
||
upnp_error.children.push(XMLNode::Element(error_code));
|
||
upnp_error
|
||
.children
|
||
.push(XMLNode::Element(error_description));
|
||
|
||
let mut detail = Element::new("detail");
|
||
detail.children.push(XMLNode::Element(upnp_error));
|
||
|
||
let mut fault = Element::new("s:Fault");
|
||
fault.children.push(XMLNode::Element(detail));
|
||
|
||
let mut body = Element::new("s:Body");
|
||
body.children.push(XMLNode::Element(fault));
|
||
|
||
let envelope = SoapEnvelope {
|
||
header: None,
|
||
body: SoapBody { content: body },
|
||
};
|
||
|
||
let err = parse_upnp_error(&envelope).expect("Expected UPnPError");
|
||
assert_eq!(err.error_code, 401);
|
||
assert_eq!(err.error_description, "Invalid Action");
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct PositionInfo {
|
||
pub track: u32,
|
||
pub track_duration: Option<String>, // HH:MM:SS or None
|
||
pub rel_time: Option<String>, // HH:MM:SS or None
|
||
pub abs_time: Option<String>, // HH:MM:SS or None
|
||
pub track_metadata: Option<String>, // DIDL-Lite XML or None
|
||
pub track_uri: Option<String>, // Current track URI
|
||
}
|
||
|
||
impl AvTransportClient {
|
||
/// AVTransport:1 — GetPositionInfo
|
||
pub fn get_position_info(&self, instance_id: u32) -> Result<PositionInfo, ControlPointError> {
|
||
let instance_id_str = instance_id.to_string();
|
||
let args = [("InstanceID", instance_id_str.as_str())];
|
||
|
||
let call_result = invoke_upnp_action_with_timeout(
|
||
&self.control_url,
|
||
&self.service_type,
|
||
"GetPositionInfo",
|
||
&args,
|
||
Some(AVTRANSPORT_POLL_TIMEOUT),
|
||
)?;
|
||
|
||
if !call_result.status.is_success() {
|
||
return Err(ControlPointError::UpnpError(format!(
|
||
"GetPositionInfo failed with HTTP status {}",
|
||
call_result.status
|
||
)));
|
||
}
|
||
|
||
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
|
||
ControlPointError::UpnpError(format!(
|
||
"Missing SOAP envelope in GetPositionInfo response"
|
||
))
|
||
})?;
|
||
|
||
parse_position_info(envelope)
|
||
}
|
||
}
|
||
|
||
fn parse_position_info(envelope: &SoapEnvelope) -> Result<PositionInfo, ControlPointError> {
|
||
let response = find_child_with_suffix(&envelope.body.content, "GetPositionInfoResponse")
|
||
.ok_or_else(|| {
|
||
ControlPointError::UpnpError(format!("Missing GetPositionInfoResponse element"))
|
||
})?;
|
||
|
||
// Helpers allow missing text (AVTransport allows empty durations)
|
||
fn opt(parent: &Element, name: &str) -> Option<String> {
|
||
find_child_with_suffix(parent, name)
|
||
.and_then(|e| e.get_text())
|
||
.map(|s| s.trim().to_string())
|
||
.filter(|s| !s.is_empty())
|
||
}
|
||
|
||
let track = opt(response, "Track")
|
||
.unwrap_or_else(|| "0".into())
|
||
.parse::<u32>()
|
||
.unwrap_or(0);
|
||
|
||
let track_duration = opt(response, "TrackDuration");
|
||
let rel_time = opt(response, "RelTime");
|
||
let abs_time = opt(response, "AbsTime");
|
||
let track_metadata = opt(response, "TrackMetaData");
|
||
let track_uri = opt(response, "TrackURI");
|
||
|
||
Ok(PositionInfo {
|
||
track,
|
||
track_duration,
|
||
rel_time,
|
||
abs_time,
|
||
track_metadata,
|
||
track_uri,
|
||
})
|
||
}
|