use std::time::Duration;
pub const PORT: u16 = 1400;
const TIMEOUT: Duration = Duration::from_millis(2500);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Service {
AvTransport,
Rendering,
ContentDirectory,
Topology,
}
impl Service {
fn path(self) -> &'static str {
match self {
Service::AvTransport => "MediaRenderer/AVTransport/Control",
Service::Rendering => "MediaRenderer/RenderingControl/Control",
Service::ContentDirectory => "MediaServer/ContentDirectory/Control",
Service::Topology => "ZoneGroupTopology/Control",
}
}
fn urn(self) -> &'static str {
match self {
Service::AvTransport => "urn:schemas-upnp-org:service:AVTransport:1",
Service::Rendering => "urn:schemas-upnp-org:service:RenderingControl:1",
Service::ContentDirectory => "urn:schemas-upnp-org:service:ContentDirectory:1",
Service::Topology => "urn:schemas-upnp-org:service:ZoneGroupTopology:1",
}
}
}
pub fn envelope(service: Service, action: &str, args: &str) -> String {
format!(
"<?xml version=\"1.0\"?>\
<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" \
s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">\
<s:Body><u:{action} xmlns:u=\"{urn}\">{args}</u:{action}></s:Body></s:Envelope>",
action = action,
urn = service.urn(),
args = args,
)
}
pub fn call(
client: &reqwest::blocking::Client,
host: &str,
service: Service,
action: &str,
args: &str,
) -> Result<String, String> {
let url = format!("http://{host}:{PORT}/{}", service.path());
let soapaction = format!("\"{}#{action}\"", service.urn());
let resp = client
.post(&url)
.header("Content-Type", "text/xml; charset=\"utf-8\"")
.header("SOAPACTION", soapaction)
.timeout(TIMEOUT)
.body(envelope(service, action, args))
.send()
.map_err(|e| format!("{action}: {e}"))?;
let status = resp.status();
let body = resp.text().unwrap_or_default();
if !status.is_success() {
let code = tag_text(&body, "errorCode").unwrap_or_default();
return Err(if code.is_empty() {
format!("{action}: HTTP {status}")
} else {
format!("{action}: UPnP error {code}")
});
}
Ok(body)
}
pub fn tag_text<'a>(xml: &'a str, tag: &str) -> Option<&'a str> {
let open = format!("<{tag}");
let start = xml.find(&open)?;
let after_open = start + xml[start..].find('>')? + 1;
if xml[start..after_open].ends_with("/>") {
return Some("");
}
let close = format!("</{tag}>");
let end = xml[after_open..].find(&close)? + after_open;
Some(&xml[after_open..end])
}
pub fn unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find('&') {
out.push_str(&rest[..i]);
let tail = &rest[i..];
let (entity, decoded): (&str, char) = if tail.starts_with("&") {
("&", '&')
} else if tail.starts_with("<") {
("<", '<')
} else if tail.starts_with(">") {
(">", '>')
} else if tail.starts_with(""") {
(""", '"')
} else if tail.starts_with("'") {
("'", '\'')
} else if tail.starts_with("'") {
("'", '\'')
} else if tail.starts_with("'") {
("'", '\'')
} else {
out.push('&');
rest = &tail[1..];
continue;
};
out.push(decoded);
rest = &tail[entity.len()..];
}
out.push_str(rest);
out
}
pub fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envelope_carries_action_and_urn() {
let e = envelope(Service::AvTransport, "Pause", "<InstanceID>0</InstanceID>");
assert!(e.contains("<u:Pause xmlns:u=\"urn:schemas-upnp-org:service:AVTransport:1\">"));
assert!(e.contains("<InstanceID>0</InstanceID>"));
assert!(e.ends_with("</u:Pause></s:Body></s:Envelope>"));
}
#[test]
fn service_paths_are_the_documented_ones() {
assert_eq!(
Service::Rendering.path(),
"MediaRenderer/RenderingControl/Control"
);
assert_eq!(Service::Topology.path(), "ZoneGroupTopology/Control");
}
#[test]
fn tag_text_reads_flat_bodies() {
let xml = "<x><CurrentTransportState>PLAYING</CurrentTransportState></x>";
assert_eq!(tag_text(xml, "CurrentTransportState"), Some("PLAYING"));
assert_eq!(tag_text(xml, "Nope"), None);
}
#[test]
fn tag_text_tolerates_attributes_and_self_closing() {
assert_eq!(tag_text("<a b=\"1\">hi</a>", "a"), Some("hi"));
assert_eq!(tag_text("<a/>", "a"), Some(""));
}
#[test]
fn unescape_handles_one_level() {
assert_eq!(unescape("a &lt;b&gt; c"), "a <b> c");
assert_eq!(
unescape(&unescape("&lt;ZoneGroup&gt;")),
"<ZoneGroup>"
);
}
#[test]
fn unescape_leaves_unknown_entities_alone_without_looping() {
assert_eq!(unescape("100 € & more"), "100 € & more");
}
#[test]
fn escape_round_trips_through_unescape() {
let raw = "Tom & Jerry's <hit> \"song\"";
assert_eq!(unescape(&escape(raw)), raw);
}
}