//! UPnP AV / DLNA media-server client: SSDP discovery (M-SEARCH to
//! 239.255.255.250:1900), the device description, and ContentDirectory
//! `Browse` (SOAP, DIDL-Lite results). Items play from their `res` HTTP URLs.
//! Servers outside multicast reach are listed in ~/medias/upnp.txt (one
//! description URL per line, `#` comments).
use std::net::{SocketAddr, UdpSocket};
use std::time::{Duration, Instant};
const SSDP: &str = "239.255.255.250:1900";
const MEDIA_SERVER: &str = "urn:schemas-upnp-org:device:MediaServer:1";
const CONTENT_DIRECTORY: &str = "urn:schemas-upnp-org:service:ContentDirectory:";
/// Browse page size; a level holds at most MAX_ENTRIES.
const PAGE: u32 = 200;
const MAX_ENTRIES: usize = 10_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Server {
pub name: String,
pub udn: String, // uuid:..., identifies the device across addresses
pub control: String, // absolute ContentDirectory control URL
pub service_type: String, // e.g. urn:schemas-upnp-org:service:ContentDirectory:1
pub icon: String, // absolute JPEG icon URL; empty for none
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Audio,
Video,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Entry {
Container { id: String, title: String, count: Option<u32> },
Item { title: String, url: String, kind: Kind, art: String, duration: u32, artist: String, album: String },
}
fn agent() -> ureq::Agent {
ureq::Agent::config_builder()
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(15)))
.build()
.into()
}
/// Servers answering an SSDP search within `wait`, plus those in upnp.txt;
/// unique by UDN, sorted by name.
pub fn discover(wait: Duration) -> Vec<Server> {
let mut locations = match search(SSDP.parse().unwrap(), wait) {
Ok(found) => found,
Err(error) => {
eprintln!("upnp: ssdp: {error}");
Vec::new()
}
};
locations.extend(configured());
servers(&locations)
}
/// Describe each location in parallel; failures are logged and skipped.
pub fn servers(locations: &[String]) -> Vec<Server> {
let agent = agent();
let described: Vec<Server> = std::thread::scope(|scope| {
let handles: Vec<_> = locations.iter().map(|l| scope.spawn(|| describe(&agent, l))).collect();
handles
.into_iter()
.zip(locations)
.filter_map(|(handle, location)| match handle.join() {
Ok(Ok(server)) => Some(server),
Ok(Err(error)) => {
eprintln!("upnp: {location}: {error}");
None
}
Err(_) => None,
})
.collect()
});
let mut unique: Vec<Server> = Vec::new();
for server in described {
if !unique.iter().any(|s| s.udn == server.udn) {
unique.push(server);
}
}
unique.sort_by_key(|s| s.name.to_lowercase());
unique
}
/// Send an M-SEARCH for media servers to `target` (twice: UDP is lossy) and
/// collect the LOCATION of every matching response until `wait` elapses.
pub fn search(target: SocketAddr, wait: Duration) -> std::io::Result<Vec<String>> {
let socket = UdpSocket::bind("0.0.0.0:0")?;
socket.set_multicast_ttl_v4(2)?;
let mx = wait.as_secs().clamp(1, 5);
let request =
format!("M-SEARCH * HTTP/1.1\r\nHOST: {SSDP}\r\nMAN: \"ssdp:discover\"\r\nMX: {mx}\r\nST: {MEDIA_SERVER}\r\n\r\n");
for _ in 0..2 {
socket.send_to(request.as_bytes(), target)?;
}
let deadline = Instant::now() + wait;
let mut found: Vec<String> = Vec::new();
let mut buf = [0u8; 2048];
while let Some(left) = deadline.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()) {
socket.set_read_timeout(Some(left))?;
let Ok((n, _)) = socket.recv_from(&mut buf) else { break };
if let Some(location) = response_location(&String::from_utf8_lossy(&buf[..n]))
&& !found.contains(&location)
{
found.push(location);
}
}
Ok(found)
}
/// LOCATION of an SSDP response whose ST is a media server or ContentDirectory.
fn response_location(response: &str) -> Option<String> {
let mut lines = response.lines();
if !lines.next()?.starts_with("HTTP/1.1 200") {
return None;
}
let (mut st, mut location) = (None, None);
for line in lines {
let Some((name, value)) = line.split_once(':') else { continue };
match name.trim().to_ascii_lowercase().as_str() {
"st" => st = Some(value.trim().to_string()),
"location" => location = Some(value.trim().to_string()),
_ => {}
}
}
let st = st?;
(st.starts_with("urn:schemas-upnp-org:device:MediaServer:") || st.starts_with(CONTENT_DIRECTORY))
.then_some(location?)
}
/// Description URLs from ~/medias/upnp.txt.
fn configured() -> Vec<String> {
let Some(home) = std::env::var_os("HOME") else { return Vec::new() };
let path = std::path::Path::new(&home).join("medias").join("upnp.txt");
std::fs::read_to_string(path)
.map(|text| {
text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(str::to_string).collect()
})
.unwrap_or_default()
}
fn get_text(agent: &ureq::Agent, url: &str) -> Result<String, String> {
let mut resp = agent.get(url).call().map_err(|e| e.to_string())?;
let status = resp.status().as_u16();
let text = resp.body_mut().read_to_string().map_err(|e| e.to_string())?;
if status != 200 {
return Err(format!("HTTP {status}"));
}
Ok(text)
}
/// Fetch and parse a device description.
pub fn describe(agent: &ureq::Agent, location: &str) -> Result<Server, String> {
parse_description(&get_text(agent, location)?, location)
}
/// The first (embedded or root) device offering ContentDirectory. Relative
/// URLs resolve against URLBase (UPnP 1.0) or the description URL.
fn parse_description(xml: &str, location: &str) -> Result<Server, String> {
let doc = roxmltree::Document::parse(xml).map_err(|e| format!("description: {e}"))?;
let root = doc.root_element();
let base = child_text(root, "URLBase").filter(|b| !b.is_empty()).unwrap_or_else(|| location.to_string());
for device in root.descendants().filter(|n| n.has_tag_name("device")) {
let services = device.children().filter(|n| n.has_tag_name("serviceList")).flat_map(|l| l.children());
let Some(service) = services
.filter(|n| n.has_tag_name("service"))
.find(|s| child_text(*s, "serviceType").is_some_and(|t| t.starts_with(CONTENT_DIRECTORY)))
else {
continue;
};
let control = child_text(service, "controlURL").ok_or("ContentDirectory without controlURL")?;
let icon = device
.children()
.filter(|n| n.has_tag_name("iconList"))
.flat_map(|l| l.children())
.filter(|n| n.has_tag_name("icon") && child_text(*n, "mimetype").as_deref() == Some("image/jpeg"))
.max_by_key(|n| child_text(*n, "width").and_then(|w| w.parse::<u32>().ok()).unwrap_or(0))
.and_then(|n| child_text(n, "url"))
.map(|u| resolve(&base, &u))
.unwrap_or_default();
return Ok(Server {
name: child_text(device, "friendlyName").unwrap_or_else(|| "Media server".into()),
udn: child_text(device, "UDN").unwrap_or_else(|| location.to_string()),
control: resolve(&base, &control),
service_type: child_text(service, "serviceType").unwrap_or_default(),
icon,
});
}
Err("no ContentDirectory service".into())
}
/// Text of the first child element named `name` (any namespace), trimmed.
fn child_text(node: roxmltree::Node, name: &str) -> Option<String> {
node.children().find(|n| n.has_tag_name(name)).map(|n| n.text().unwrap_or("").trim().to_string())
}
/// Resolve `url` against `base`: absolute, host-relative ("/x") or path-relative.
fn resolve(base: &str, url: &str) -> String {
if url.starts_with("http://") || url.starts_with("https://") {
return url.to_string();
}
let scheme_end = base.find("://").map(|i| i + 3).unwrap_or(0);
let host_end = base[scheme_end..].find('/').map(|i| scheme_end + i).unwrap_or(base.len());
if url.starts_with('/') {
return format!("{}{url}", &base[..host_end]);
}
let dir_end = base[host_end..].rfind('/').map(|i| host_end + i + 1);
match dir_end {
Some(end) => format!("{}{url}", &base[..end]),
None => format!("{}/{url}", &base[..host_end]),
}
}
/// All children of container `id` ("0" is the root), paged by PAGE.
pub fn browse(server: &Server, id: &str) -> Result<Vec<Entry>, String> {
let agent = agent();
let mut entries = Vec::new();
let mut start = 0u32;
loop {
let body = soap_browse(&agent, server, id, start)?;
let (page, returned, total) = parse_browse(&body, &server.control)?;
entries.extend(page);
start += returned;
if returned == 0 || start >= total || entries.len() >= MAX_ENTRIES {
break;
}
}
Ok(entries)
}
fn soap_browse(agent: &ureq::Agent, server: &Server, id: &str, start: u32) -> Result<String, String> {
let service = if server.service_type.is_empty() { "urn:schemas-upnp-org:service:ContentDirectory:1" } else { &server.service_type };
let body = format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\
<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" \
s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\"><s:Body>\
<u:Browse xmlns:u=\"{service}\"><ObjectID>{}</ObjectID>\
<BrowseFlag>BrowseDirectChildren</BrowseFlag><Filter>*</Filter>\
<StartingIndex>{start}</StartingIndex><RequestedCount>{PAGE}</RequestedCount>\
<SortCriteria></SortCriteria></u:Browse></s:Body></s:Envelope>",
escape(id)
);
let mut resp = agent
.post(&server.control)
.header("Content-Type", "text/xml; charset=\"utf-8\"")
.header("SOAPACTION", &format!("\"{service}#Browse\""))
.send(body.as_bytes())
.map_err(|e| format!("browse: {e}"))?;
let status = resp.status().as_u16();
let text = resp.body_mut().read_to_string().map_err(|e| format!("browse: {e}"))?;
if status != 200 {
let detail = roxmltree::Document::parse(&text)
.ok()
.and_then(|d| d.descendants().find(|n| n.has_tag_name("errorDescription")).and_then(|n| n.text().map(str::to_string)));
return Err(format!("browse: HTTP {status}{}", detail.map(|d| format!(" ({d})")).unwrap_or_default()));
}
Ok(text)
}
fn escape(text: &str) -> String {
text.replace('&', "&").replace('<', "<").replace('>', ">")
}
/// (entries, NumberReturned, TotalMatches) of a Browse response. `Result`
/// holds the DIDL-Lite document as escaped text.
fn parse_browse(body: &str, base: &str) -> Result<(Vec<Entry>, u32, u32), String> {
let doc = roxmltree::Document::parse(body).map_err(|e| format!("browse response: {e}"))?;
let field = |name: &str| doc.descendants().find(|n| n.has_tag_name(name)).and_then(|n| n.text()).unwrap_or("");
let didl = field("Result");
let entries = if didl.trim().is_empty() { Vec::new() } else { parse_didl(didl, base)? };
let returned = field("NumberReturned").trim().parse().unwrap_or(entries.len() as u32);
let total = field("TotalMatches").trim().parse().unwrap_or(0);
Ok((entries, returned, total))
}
/// Containers and playable (audio/video) items of a DIDL-Lite document;
/// image and other items are skipped.
fn parse_didl(didl: &str, base: &str) -> Result<Vec<Entry>, String> {
let doc = roxmltree::Document::parse(didl).map_err(|e| format!("DIDL-Lite: {e}"))?;
let mut entries = Vec::new();
for node in doc.root_element().children().filter(|n| n.is_element()) {
let title = child_text(node, "title").unwrap_or_default();
match node.tag_name().name() {
"container" => entries.push(Entry::Container {
id: node.attribute("id").unwrap_or_default().to_string(),
title,
count: node.attribute("childCount").and_then(|c| c.parse().ok()),
}),
"item" => {
if let Some(entry) = parse_item(node, title, base) {
entries.push(entry);
}
}
_ => {}
}
}
Ok(entries)
}
/// An item's first http-get `res` of its kind (servers list the original
/// before transcodes). Kind: upnp:class, else the res MIME type.
fn parse_item(node: roxmltree::Node, title: String, base: &str) -> Option<Entry> {
let class = child_text(node, "class").unwrap_or_default();
let class_kind = if class.starts_with("object.item.audioItem") {
Some(Kind::Audio)
} else if class.starts_with("object.item.videoItem") {
Some(Kind::Video)
} else {
None
};
let mime_kind = |res: &roxmltree::Node| {
let mime = res.attribute("protocolInfo")?.split(':').nth(2)?;
if mime.starts_with("audio/") {
Some(Kind::Audio)
} else if mime.starts_with("video/") {
Some(Kind::Video)
} else {
None
}
};
let res = node
.children()
.filter(|n| n.has_tag_name("res"))
.filter(|r| r.attribute("protocolInfo").is_some_and(|p| p.starts_with("http-get:")))
.find(|r| class_kind.is_none() || mime_kind(r).is_none_or(|k| Some(k) == class_kind))?;
let kind = class_kind.or_else(|| mime_kind(&res))?;
let url = res.text()?.trim();
let artist = child_text(node, "artist").or_else(|| child_text(node, "creator")).unwrap_or_default();
Some(Entry::Item {
title,
url: resolve(base, url),
kind,
art: child_text(node, "albumArtURI").map(|u| resolve(base, &u)).unwrap_or_default(),
duration: res.attribute("duration").map(parse_duration).unwrap_or(0),
artist,
album: child_text(node, "album").unwrap_or_default(),
})
}
/// DIDL `res@duration` "H+:MM:SS[.F+]" to whole seconds; 0 when malformed.
fn parse_duration(text: &str) -> u32 {
let whole = text.split('.').next().unwrap_or("");
let mut seconds = 0u32;
for part in whole.split(':') {
let Ok(n) = part.trim().parse::<u32>() else { return 0 };
seconds = seconds * 60 + n;
}
seconds
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpListener;
// Shapes follow minidlna 1.3 (ReadyMedia) responses.
const DESCRIPTION: &str = r#"<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0"><specVersion><major>1</major><minor>0</minor></specVersion>
<device><deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
<friendlyName>nas: minidlna</friendlyName><UDN>uuid:4d696e69-444c-164e-9d41-001122334455</UDN>
<iconList><icon><mimetype>image/png</mimetype><width>48</width><height>48</height><depth>24</depth><url>/icons/sm.png</url></icon>
<icon><mimetype>image/jpeg</mimetype><width>120</width><height>120</height><depth>24</depth><url>/icons/lrg.jpg</url></icon></iconList>
<serviceList><service><serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
<controlURL>/ctl/ConnectionMgr</controlURL></service>
<service><serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
<serviceId>urn:upnp-org:serviceId:ContentDirectory</serviceId><controlURL>/ctl/ContentDir</controlURL>
<eventSubURL>/evt/ContentDir</eventSubURL><SCPDURL>/ContentDir.xml</SCPDURL></service></serviceList></device></root>"#;
fn didl(body: &str) -> String {
format!(
"<DIDL-Lite xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" \
xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" xmlns:dlna=\"urn:schemas-dlna-org:metadata-1-0/\">{body}</DIDL-Lite>"
)
}
fn envelope(didl: &str, returned: u32, total: u32) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" \
s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\"><s:Body>\
<u:BrowseResponse xmlns:u=\"urn:schemas-upnp-org:service:ContentDirectory:1\"><Result>{}</Result>\
<NumberReturned>{returned}</NumberReturned><TotalMatches>{total}</TotalMatches><UpdateID>7</UpdateID>\
</u:BrowseResponse></s:Body></s:Envelope>",
escape(didl).replace('"', """)
)
}
const ROOT: &str = r#"<container id="1" parentID="0" restricted="1" searchable="1" childCount="4"><dc:title>Music</dc:title><upnp:class>object.container.storageFolder</upnp:class></container><container id="2" parentID="0" restricted="1" searchable="1" childCount="2"><dc:title>Video</dc:title><upnp:class>object.container.storageFolder</upnp:class></container>"#;
const VIDEOS: &str = r#"<item id="64$0" parentID="64" restricted="1"><dc:title>A Movie & More</dc:title><upnp:class>object.item.videoItem</upnp:class><dc:date>2026-01-02T10:00:00</dc:date><res size="1048576" duration="1:02:03.500" resolution="1920x1080" protocolInfo="http-get:*:video/x-matroska:DLNA.ORG_OP=01;DLNA.ORG_CI=0">http://10.0.0.5:8200/MediaItems/22.mkv</res><upnp:albumArtURI dlna:profileID="JPEG_TN">http://10.0.0.5:8200/AlbumArt/22-12.jpg</upnp:albumArtURI></item><item id="64$1" parentID="64" restricted="1"><dc:title>Song</dc:title><upnp:class>object.item.audioItem.musicTrack</upnp:class><upnp:artist>Band</upnp:artist><dc:creator>Other</dc:creator><upnp:album>Album</upnp:album><res duration="0:03:25.000" protocolInfo="http-get:*:audio/flac:*">/MediaItems/23.flac</res></item><item id="64$2" parentID="64" restricted="1"><dc:title>Photo</dc:title><upnp:class>object.item.imageItem.photo</upnp:class><res protocolInfo="http-get:*:image/jpeg:*">http://10.0.0.5:8200/MediaItems/24.jpg</res></item>"#;
#[test]
fn description_yields_the_content_directory_and_jpeg_icon() {
let server = parse_description(DESCRIPTION, "http://10.0.0.5:8200/rootDesc.xml").unwrap();
assert_eq!(server, Server {
name: "nas: minidlna".into(),
udn: "uuid:4d696e69-444c-164e-9d41-001122334455".into(),
control: "http://10.0.0.5:8200/ctl/ContentDir".into(),
service_type: "urn:schemas-upnp-org:service:ContentDirectory:1".into(),
icon: "http://10.0.0.5:8200/icons/lrg.jpg".into(),
});
let none = DESCRIPTION.replace("ContentDirectory:1", "Other:1");
assert!(parse_description(&none, "http://h/").is_err());
}
#[test]
fn urls_resolve_against_base() {
assert_eq!(resolve("http://h:1/a/desc.xml", "/ctl"), "http://h:1/ctl");
assert_eq!(resolve("http://h:1/a/desc.xml", "ctl"), "http://h:1/a/ctl");
assert_eq!(resolve("http://h:1", "ctl"), "http://h:1/ctl");
assert_eq!(resolve("http://h:1/", "http://o/x"), "http://o/x");
}
#[test]
fn didl_items_keep_audio_and_video_with_metadata() {
let (entries, returned, total) =
parse_browse(&envelope(&didl(VIDEOS), 3, 3), "http://10.0.0.5:8200/ctl/ContentDir").unwrap();
assert_eq!((returned, total), (3, 3));
assert_eq!(entries, [
Entry::Item {
title: "A Movie & More".into(),
url: "http://10.0.0.5:8200/MediaItems/22.mkv".into(),
kind: Kind::Video,
art: "http://10.0.0.5:8200/AlbumArt/22-12.jpg".into(),
duration: 3723,
artist: String::new(),
album: String::new(),
},
Entry::Item {
title: "Song".into(),
url: "http://10.0.0.5:8200/MediaItems/23.flac".into(),
kind: Kind::Audio,
art: String::new(),
duration: 205,
artist: "Band".into(),
album: "Album".into(),
},
]); // the photo is skipped
let (root, ..) = parse_browse(&envelope(&didl(ROOT), 2, 2), "http://h/").unwrap();
assert_eq!(root[0], Entry::Container { id: "1".into(), title: "Music".into(), count: Some(4) });
}
#[test]
fn ssdp_responses_need_200_and_a_media_server_target() {
let ok = "HTTP/1.1 200 OK\r\nCACHE-CONTROL: max-age=1810\r\nST: urn:schemas-upnp-org:device:MediaServer:1\r\n\
USN: uuid:x::urn:schemas-upnp-org:device:MediaServer:1\r\nLocation: http://10.0.0.5:8200/rootDesc.xml\r\n\r\n";
assert_eq!(response_location(ok).as_deref(), Some("http://10.0.0.5:8200/rootDesc.xml"));
assert_eq!(response_location(&ok.replace("MediaServer", "MediaRenderer")), None);
assert_eq!(response_location(&ok.replace("200 OK", "404 Not Found")), None);
assert_eq!(parse_duration("12:34"), 754);
assert_eq!(parse_duration("x"), 0);
}
/// Serve the description, paged Browse responses (1 entry per page) and
/// record SOAP bodies; answer one SSDP search over loopback UDP.
#[test]
fn discovers_describes_and_pages_through_a_server() {
let http = TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}", http.local_addr().unwrap());
let ssdp = UdpSocket::bind("127.0.0.1:0").unwrap();
let ssdp_addr = ssdp.local_addr().unwrap();
let location = format!("{base}/rootDesc.xml");
let reply = format!("HTTP/1.1 200 OK\r\nST: {MEDIA_SERVER}\r\nLOCATION: {location}\r\n\r\n");
std::thread::spawn(move || {
let mut buf = [0u8; 1024];
let (n, from) = ssdp.recv_from(&mut buf).unwrap();
assert!(String::from_utf8_lossy(&buf[..n]).contains("ST: urn:schemas-upnp-org:device:MediaServer:1"));
ssdp.send_to(reply.as_bytes(), from).unwrap();
});
let (bodies_tx, bodies) = std::sync::mpsc::channel::<String>();
std::thread::spawn(move || {
let pages = [ROOT.split("</container>").next().unwrap().to_string() + "</container>",
ROOT.split("</container>").nth(1).unwrap().to_string() + "</container>"];
for stream in http.incoming().take(3) {
let mut stream = stream.unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let mut length = 0;
loop {
let mut header = String::new();
reader.read_line(&mut header).unwrap();
if header.trim().is_empty() {
break;
}
if let Some(v) = header.to_ascii_lowercase().strip_prefix("content-length:") {
length = v.trim().parse().unwrap();
}
}
let mut body = vec![0u8; length];
reader.read_exact(&mut body).unwrap();
let body = String::from_utf8(body).unwrap();
let response = if line.starts_with("GET /rootDesc.xml") {
DESCRIPTION.to_string()
} else {
let start: usize = body.split("<StartingIndex>").nth(1).unwrap().split('<').next().unwrap().parse().unwrap();
bodies_tx.send(body).unwrap();
envelope(&didl(&pages[start]), 1, 2)
};
write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: text/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len()).unwrap();
}
});
let locations = search(ssdp_addr, Duration::from_millis(500)).unwrap();
assert_eq!(locations, [location]);
let found = servers(&locations);
assert_eq!(found.len(), 1);
assert_eq!(found[0].control, format!("{base}/ctl/ContentDir"));
let entries = browse(&found[0], "0").unwrap();
let titles: Vec<&str> = entries
.iter()
.map(|e| match e {
Entry::Container { title, .. } | Entry::Item { title, .. } => title.as_str(),
})
.collect();
assert_eq!(titles, ["Music", "Video"]);
let first = bodies.recv().unwrap();
assert!(first.contains("<ObjectID>0</ObjectID>") && first.contains("<BrowseFlag>BrowseDirectChildren</BrowseFlag>"));
assert!(bodies.recv().unwrap().contains("<StartingIndex>1</StartingIndex>"));
}
}