Files
pmomusic/pmocontrol/src/soap_client.rs
Eric Coissac 9b5a028f3f 🎨 Improve mobile safe-area support and refine UPnP/Chromecast rendering
- 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
2026-04-13 04:14:18 +02:00

529 lines
16 KiB
Rust

use std::sync::Arc;
use std::sync::OnceLock;
use std::time::Duration;
use anyhow::{Context, Result};
use pmoupnp::soap::{build_soap_request, parse_soap_envelope, SoapEnvelope};
use tracing::{debug, trace, warn, info};
use ureq::Agent;
use crate::errors::ControlPointError;
static SOAP_AGENT: OnceLock<Arc<Agent>> = OnceLock::new();
/// Number of retries for transient transport failures on control actions.
const SOAP_CONTROL_MAX_RETRIES: usize = 2;
/// Delay between retries (ms). Kept short: devices usually recover in <200 ms.
const SOAP_CONTROL_RETRY_DELAY_MS: u64 = 300;
fn get_soap_agent() -> Arc<Agent> {
SOAP_AGENT
.get_or_init(|| {
Arc::new(
Agent::config_builder()
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(30)))
.build()
.into(),
)
})
.clone()
}
/// Result of a SOAP call:
/// - HTTP status code
/// - raw XML body (always)
/// - parsed SOAP envelope if parsing succeeded
pub struct SoapCallResult {
pub status: ureq::http::StatusCode,
pub raw_body: String,
pub envelope: Option<SoapEnvelope>,
}
pub fn build_soap_body(
action: &str,
service_type: &str,
args: &[(&str, &str)],
) -> Result<String, xmltree::Error> {
build_soap_request(service_type, action, args)
}
/// Invoke a UPnP SOAP control action with automatic retry on transient failures.
///
/// Use this for **control operations** (Play, Pause, Stop, SetAVTransportURI, …)
/// where a transient TCP error should be absorbed silently.
///
/// Retries are attempted only on [`ControlPointError::is_transient_soap_error`]
/// (i.e. transport-level failures). Protocol-level errors (UPnP faults,
/// HTTP 4xx/5xx with a valid body) propagate immediately without retry.
///
/// For **polling reads** (GetTransportInfo, GetPositionInfo, …) prefer
/// [`invoke_upnp_action_with_timeout`] directly so that a slow device does
/// not hold the watcher thread for multiple retry cycles.
pub fn invoke_upnp_action(
control_url: &str,
service_type: &str,
action: &str,
args: &[(&str, &str)],
) -> Result<SoapCallResult, ControlPointError> {
let retry_delay = Duration::from_millis(SOAP_CONTROL_RETRY_DELAY_MS);
for attempt in 0..=SOAP_CONTROL_MAX_RETRIES {
match invoke_upnp_action_with_timeout(control_url, service_type, action, args, None) {
Ok(result) => return Ok(result),
Err(e) if e.is_transient_soap_error() && attempt < SOAP_CONTROL_MAX_RETRIES => {
info!(
url = control_url,
action = action,
attempt = attempt + 1,
error = %e,
"Transient SOAP error, retrying"
);
std::thread::sleep(retry_delay);
}
Err(e) => return Err(e),
}
}
// Unreachable: the loop either returns Ok or propagates Err above.
unreachable!()
}
pub fn invoke_upnp_action_with_timeout(
control_url: &str,
service_type: &str,
action: &str,
args: &[(&str, &str)],
timeout: Option<Duration>,
) -> Result<SoapCallResult, ControlPointError> {
let body_xml = build_soap_body(action, service_type, args)
.context("Failed to build SOAP request body")
.map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?;
let arg_log = summarize_args_for_log(args);
debug!(
url = control_url,
action = action,
service_type = service_type,
args = ?arg_log,
"Sending SOAP request"
);
trace!(body = body_xml.as_str(), "SOAP request body");
let request = if let Some(duration) = timeout {
let config = Agent::config_builder()
.http_status_as_error(false)
.timeout_global(Some(duration))
.build();
let agent: Agent = config.into();
agent.post(control_url)
} else {
get_soap_agent().post(control_url)
};
let soap_action_header = format!(r#""{}#{}""#, service_type, action);
let mut response = request
.header("Content-Type", r#"text/xml; charset="utf-8""#)
.header("SOAPAction", &soap_action_header)
.send(body_xml)
.with_context(|| format!("HTTP error when sending SOAP request to {}", control_url))
.map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?;
let status = response.status();
debug!(status = status.as_u16(), "SOAP response received");
// 5. Read full body
//
// API réelle (ureq 3.1.4):
// body_mut().read_to_string() -> Result<String, ureq::Error>
let raw_body = response
.body_mut()
.read_to_string()
.context("Failed to read SOAP response body")
.map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?;
// 6. Try to parse SOAP envelope; non-fatal on failure
let parsed_envelope = parse_soap_envelope(raw_body.as_bytes()).ok();
if !status.is_success() {
if is_oh_info_invalid_action(service_type, action, parsed_envelope.as_ref()) {
debug!(
url = control_url,
action = action,
service_type = service_type,
status = status.as_u16(),
"OpenHome Info action not supported (Invalid Action)"
);
} else {
warn!(
url = control_url,
action = action,
service_type = service_type,
status = status.as_u16(),
body_snippet = %response_snippet(&raw_body),
"SOAP call returned non-success status"
);
}
}
Ok(SoapCallResult {
status,
raw_body,
envelope: parsed_envelope,
})
}
fn summarize_args_for_log<'a>(args: &'a [(&'a str, &'a str)]) -> Vec<String> {
args.iter()
.map(|(name, value)| format!("{}:{}B {}", name, value.len(), preview_value(value)))
.collect()
}
fn preview_value(value: &str) -> String {
const MAX_PREVIEW: usize = 96;
if value.len() <= MAX_PREVIEW {
value.to_string()
} else {
format!("{}", &value[..MAX_PREVIEW])
}
}
fn response_snippet(body: &str) -> String {
const MAX_LEN: usize = 256;
let trimmed = body.trim();
if trimmed.len() <= MAX_LEN {
trimmed.to_string()
} else {
format!("{}", &trimmed[..MAX_LEN])
}
}
fn is_oh_info_invalid_action(
service_type: &str,
action: &str,
envelope: Option<&SoapEnvelope>,
) -> bool {
if service_type != "urn:av-openhome-org:service:Info:1" {
return false;
}
if action != "Id" && action != "TransportState" {
return false;
}
let Some(env) = envelope else {
return false;
};
match parse_upnp_error(env) {
Some(err) if err.error_code == 401 => true,
_ => false,
}
}
#[derive(Debug, Clone)]
pub struct UpnpError {
pub error_code: u32,
pub error_description: String,
}
pub 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")?;
let error_code_elem = upnp_error.children.iter().find_map(|node| match node {
xmltree::XMLNode::Element(elem) if elem.name.ends_with("errorCode") => Some(elem),
_ => None,
})?;
let error_code_text = error_code_elem.get_text()?.trim().to_string();
let error_code = error_code_text.parse::<u32>().ok()?;
let error_description = upnp_error
.children
.iter()
.find_map(|node| match node {
xmltree::XMLNode::Element(elem) if elem.name.ends_with("errorDescription") => {
elem.get_text().map(|t| t.trim().to_string())
}
_ => None,
})
.unwrap_or_default();
Some(UpnpError {
error_code,
error_description,
})
}
pub fn find_child_with_suffix<'a>(
parent: &'a xmltree::Element,
suffix: &str,
) -> Option<&'a xmltree::Element> {
parent.children.iter().find_map(|node| match node {
xmltree::XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem),
_ => None,
})
}
pub fn find_child_with_local_name<'a>(
parent: &'a xmltree::Element,
local: &str,
) -> Option<&'a xmltree::Element> {
parent.children.iter().find_map(|node| {
if let xmltree::XMLNode::Element(elem) = node {
if elem.name == local || elem.name.ends_with(&format!(":{}", local)) {
return Some(elem);
}
}
None
})
}
/// Ensures the SOAP call succeeded, returns error if HTTP status or UPnP error occurred
pub fn ensure_success(action: &str, call_result: &SoapCallResult) -> Result<(), ControlPointError> {
if call_result.status.is_success() {
if let Some(env) = &call_result.envelope {
if let Some(err) = parse_upnp_error(env) {
return Err(ControlPointError::SoapUpnpParseError(
action.to_string(),
err.error_code,
err.error_description,
call_result.status.as_u16() as u32,
));
}
}
return Ok(());
}
if let Some(env) = &call_result.envelope {
if let Some(err) = parse_upnp_error(env) {
return Err(ControlPointError::SoapUpnpParseError(
action.to_string(),
err.error_code,
err.error_description,
call_result.status.as_u16() as u32,
));
}
}
Err(ControlPointError::SoapActionWrongBody(
action.to_string(),
call_result.status.as_u16() as u32,
call_result.raw_body.clone(),
))
}
/// Ensures success and returns the SOAP envelope (for openhome_client compatibility)
pub fn ensure_success_with_envelope<'a>(
action: &str,
call_result: &'a SoapCallResult,
) -> Result<&'a SoapEnvelope, ControlPointError> {
if !call_result.status.is_success() {
if let Some(env) = &call_result.envelope {
if let Some(err) = parse_upnp_error(env) {
return Err(ControlPointError::SoapUpnpParseError(
action.to_string(),
err.error_code,
err.error_description,
call_result.status.as_u16() as u32,
));
}
}
return Err(ControlPointError::SoapActionWrongBody(
action.to_string(),
call_result.status.as_u16() as u32,
call_result.raw_body.clone(),
));
}
let envelope = call_result
.envelope
.as_ref()
.ok_or_else(|| ControlPointError::SoapNoEnvelop(action.to_string()))?;
if let Some(err) = parse_upnp_error(envelope) {
return Err(ControlPointError::SoapUpnpParseError(
action.to_string(),
err.error_code,
err.error_description,
call_result.status.as_u16() as u32,
));
}
Ok(envelope)
}
/// Handles action response, ensuring success
pub fn handle_action_response(
action: &str,
call_result: &SoapCallResult,
) -> Result<(), ControlPointError> {
ensure_success(action, call_result)
}
pub fn extract_child_text(
parent: &xmltree::Element,
suffix: &str,
) -> Result<String, ControlPointError> {
let child = find_child_with_suffix(parent, suffix)
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue(suffix.to_string()))?;
let text = child
.get_text()
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue(suffix.to_string()))?;
Ok(text)
}
pub fn extract_child_text_allow_empty(
parent: &xmltree::Element,
suffix: &str,
) -> Result<String, ControlPointError> {
let child = find_child_with_suffix(parent, suffix)
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue(suffix.to_string()))?;
let text = child
.get_text()
.map(|t| t.trim().to_string())
.unwrap_or_default();
Ok(text)
}
pub fn extract_child_text_optional(
parent: &xmltree::Element,
suffix: &str,
) -> Result<Option<String>> {
if let Some(child) = find_child_with_suffix(parent, suffix) {
let text = child
.get_text()
.map(|t| t.trim().to_string())
.unwrap_or_default();
Ok(Some(text))
} else {
Ok(None)
}
}
pub fn extract_child_text_any(
parent: &xmltree::Element,
suffixes: &[&str],
) -> Result<String, ControlPointError> {
for suffix in suffixes {
if let Ok(text) = extract_child_text(parent, suffix) {
return Ok(text);
}
}
Err(ControlPointError::UpnpMissingReturnValue(
suffixes.join(" or "),
))
}
pub fn extract_child_text_local(
parent: &xmltree::Element,
local: &str,
) -> Result<String, ControlPointError> {
let child = find_child_with_local_name(parent, local).ok_or_else(|| {
ControlPointError::SoapAction(format!("Missing {local} element in response"))
})?;
let text = child
.get_text()
.map(|t| t.trim().to_string())
.ok_or_else(|| {
ControlPointError::SoapAction(format!("{local} element missing text in response"))
})?;
Ok(text)
}
pub fn extract_child_text_optional_local(
parent: &xmltree::Element,
local: &str,
) -> Result<Option<String>, ControlPointError> {
if let Some(child) = find_child_with_local_name(parent, local) {
let text = child
.get_text()
.map(|t| t.trim().to_string())
.unwrap_or_default();
Ok(Some(text))
} else {
Ok(None)
}
}
pub fn parse_visible_flag(value: &str) -> bool {
let trimmed = value.trim();
if trimmed.eq_ignore_ascii_case("true") {
return true;
}
if trimmed.eq_ignore_ascii_case("false") {
return false;
}
trimmed == "1"
}
pub fn parse_bool(value: &str) -> bool {
value.trim() == "1"
}
pub fn decode_base64(input: &str) -> Result<Vec<u8>, ControlPointError> {
fn value(byte: u8) -> Option<u8> {
match byte {
b'A'..=b'Z' => Some(byte - b'A'),
b'a'..=b'z' => Some(byte - b'a' + 26),
b'0'..=b'9' => Some(byte - b'0' + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
let mut output = Vec::new();
let mut buffer: u32 = 0;
let mut bits_collected: u8 = 0;
for byte in input.bytes() {
if byte == b'=' {
break;
}
if byte == b'\r' || byte == b'\n' || byte == b' ' || byte == b'\t' {
continue;
}
let val = value(byte).ok_or_else(|| {
ControlPointError::ParsingError(format!("Invalid base64 character '{}'", byte as char))
})?;
buffer = (buffer << 6) | (val as u32);
bits_collected += 6;
if bits_collected >= 8 {
bits_collected -= 8;
let out = (buffer >> bits_collected) & 0xFF;
output.push(out as u8);
}
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::build_soap_body;
#[test]
fn build_body_preserves_openhome_argument_names() {
let args = [
("AfterId", "0"),
("Uri", "http://example.test/audio.flac"),
("Metadata", "<DIDL-Lite/>"),
];
let xml =
build_soap_body("Insert", "urn:av-openhome-org:service:Playlist:1", &args).unwrap();
assert!(xml.contains("<AfterId>0</AfterId>"));
assert!(xml.contains("<Uri>http://example.test/audio.flac</Uri>"));
assert!(xml.contains("<Metadata>"));
assert!(xml.contains("</Metadata>"));
assert!(xml.contains("DIDL-Lite"));
assert!(!xml.contains("<aAfterId>"));
}
}