use super::soap::{self, Service};
use reqwest::blocking::Client;
const INSTANCE: &str = "<InstanceID>0</InstanceID>";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TransportState {
Playing,
Paused,
Stopped,
Transitioning,
#[default]
Unknown,
}
impl TransportState {
pub fn is_playing(self) -> bool {
matches!(
self,
TransportState::Playing | TransportState::Transitioning
)
}
fn parse(s: &str) -> Self {
match s {
"PLAYING" => TransportState::Playing,
"PAUSED_PLAYBACK" => TransportState::Paused,
"STOPPED" => TransportState::Stopped,
"TRANSITIONING" => TransportState::Transitioning,
_ => TransportState::Unknown,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SourceKind {
#[default]
Queue,
AirPlay,
Tv,
LineIn,
Stream,
Grouped,
}
impl SourceKind {
pub fn parse(uri: &str) -> Self {
if uri.starts_with("x-sonos-vli:") && uri.contains("airplay") {
SourceKind::AirPlay
} else if uri.starts_with("x-sonos-htastream:") {
SourceKind::Tv
} else if uri.starts_with("x-rincon-stream:") {
SourceKind::LineIn
} else if uri.starts_with("x-rincon-mp3radio:") || uri.starts_with("x-rincon-stream-http") {
SourceKind::Stream
} else if uri.starts_with("x-rincon:") {
SourceKind::Grouped
} else {
SourceKind::Queue
}
}
pub fn label(self) -> &'static str {
match self {
SourceKind::AirPlay => "AirPlay",
SourceKind::Tv => "TV",
SourceKind::LineIn => "Line-in",
SourceKind::Stream => "Stream",
SourceKind::Grouped => "Grouped",
SourceKind::Queue => "Queue",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TrackInfo {
pub title: String,
pub artist: String,
pub album: String,
pub uri: String,
pub source: SourceKind,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Favorite {
pub title: String,
pub uri: String,
pub metadata: String,
}
impl Favorite {
pub fn is_container(&self) -> bool {
self.uri.starts_with("x-rincon-cpcontainer")
|| self.uri.starts_with("x-rincon-playlist")
|| self.uri.starts_with("file:")
}
}
const NOT_IMPLEMENTED: &str = "NOT_IMPLEMENTED";
fn didl_field(didl: &str, tag: &str) -> String {
soap::tag_text(didl, tag)
.map(soap::unescape)
.filter(|v| v != NOT_IMPLEMENTED)
.unwrap_or_default()
}
pub fn parse_position(body: &str) -> TrackInfo {
let uri = soap::tag_text(body, "TrackURI")
.map(soap::unescape)
.unwrap_or_default();
let raw_meta = soap::tag_text(body, "TrackMetaData").unwrap_or_default();
let didl = soap::unescape(raw_meta);
TrackInfo {
title: didl_field(&didl, "dc:title"),
artist: didl_field(&didl, "dc:creator"),
album: didl_field(&didl, "upnp:album"),
source: SourceKind::parse(&uri),
uri,
}
}
pub fn transport_state(client: &Client, host: &str) -> Result<TransportState, String> {
let body = soap::call(
client,
host,
Service::AvTransport,
"GetTransportInfo",
INSTANCE,
)?;
Ok(TransportState::parse(
soap::tag_text(&body, "CurrentTransportState").unwrap_or_default(),
))
}
pub fn position(client: &Client, host: &str) -> Result<TrackInfo, String> {
let body = soap::call(
client,
host,
Service::AvTransport,
"GetPositionInfo",
INSTANCE,
)?;
Ok(parse_position(&body))
}
pub fn volume(client: &Client, host: &str) -> Result<u8, String> {
let body = soap::call(
client,
host,
Service::Rendering,
"GetVolume",
&format!("{INSTANCE}<Channel>Master</Channel>"),
)?;
Ok(soap::tag_text(&body, "CurrentVolume")
.and_then(|v| v.trim().parse::<u8>().ok())
.unwrap_or(0))
}
pub fn muted(client: &Client, host: &str) -> Result<bool, String> {
let body = soap::call(
client,
host,
Service::Rendering,
"GetMute",
&format!("{INSTANCE}<Channel>Master</Channel>"),
)?;
Ok(soap::tag_text(&body, "CurrentMute").map(str::trim) == Some("1"))
}
pub fn play(client: &Client, host: &str) -> Result<(), String> {
soap::call(
client,
host,
Service::AvTransport,
"Play",
&format!("{INSTANCE}<Speed>1</Speed>"),
)
.map(|_| ())
}
pub fn pause(client: &Client, host: &str) -> Result<(), String> {
soap::call(client, host, Service::AvTransport, "Pause", INSTANCE).map(|_| ())
}
pub fn next(client: &Client, host: &str) -> Result<(), String> {
soap::call(client, host, Service::AvTransport, "Next", INSTANCE).map(|_| ())
}
pub fn previous(client: &Client, host: &str) -> Result<(), String> {
soap::call(client, host, Service::AvTransport, "Previous", INSTANCE).map(|_| ())
}
pub fn set_volume(client: &Client, host: &str, vol: u8) -> Result<(), String> {
soap::call(
client,
host,
Service::Rendering,
"SetVolume",
&format!(
"{INSTANCE}<Channel>Master</Channel><DesiredVolume>{}</DesiredVolume>",
vol.min(100)
),
)
.map(|_| ())
}
pub fn adjust_volume(client: &Client, host: &str, delta: i16) -> Result<(), String> {
soap::call(
client,
host,
Service::Rendering,
"SetRelativeVolume",
&format!("{INSTANCE}<Channel>Master</Channel><Adjustment>{delta}</Adjustment>"),
)
.map(|_| ())
}
pub fn set_mute(client: &Client, host: &str, mute: bool) -> Result<(), String> {
soap::call(
client,
host,
Service::Rendering,
"SetMute",
&format!(
"{INSTANCE}<Channel>Master</Channel><DesiredMute>{}</DesiredMute>",
u8::from(mute)
),
)
.map(|_| ())
}
pub fn play_uri(client: &Client, host: &str, uri: &str, metadata: &str) -> Result<(), String> {
soap::call(
client,
host,
Service::AvTransport,
"SetAVTransportURI",
&format!(
"{INSTANCE}<CurrentURI>{}</CurrentURI><CurrentURIMetaData>{}</CurrentURIMetaData>",
soap::escape(uri),
soap::escape(metadata)
),
)?;
play(client, host)
}
pub fn play_favorite(
client: &Client,
host: &str,
uuid: &str,
fav: &Favorite,
) -> Result<(), String> {
if !fav.is_container() {
return play_uri(client, host, &fav.uri, &fav.metadata);
}
soap::call(
client,
host,
Service::AvTransport,
"RemoveAllTracksFromQueue",
INSTANCE,
)?;
soap::call(
client,
host,
Service::AvTransport,
"AddURIToQueue",
&format!(
"{INSTANCE}<EnqueuedURI>{}</EnqueuedURI><EnqueuedURIMetaData>{}</EnqueuedURIMetaData>\
<DesiredFirstTrackNumberEnqueued>0</DesiredFirstTrackNumberEnqueued><EnqueueAsNext>1</EnqueueAsNext>",
soap::escape(&fav.uri),
soap::escape(&fav.metadata)
),
)?;
play_uri(client, host, &format!("x-rincon-queue:{uuid}#0"), "")
}
pub fn join(client: &Client, follower_host: &str, coordinator_uuid: &str) -> Result<(), String> {
play_uri(
client,
follower_host,
&format!("x-rincon:{coordinator_uuid}"),
"",
)
}
pub fn unjoin(client: &Client, host: &str) -> Result<(), String> {
soap::call(
client,
host,
Service::AvTransport,
"BecomeCoordinatorOfStandaloneGroup",
INSTANCE,
)
.map(|_| ())
}
pub fn favorites(client: &Client, host: &str) -> Result<Vec<Favorite>, String> {
let body = soap::call(
client,
host,
Service::ContentDirectory,
"Browse",
"<ObjectID>FV:2</ObjectID><BrowseFlag>BrowseDirectChildren</BrowseFlag>\
<Filter>*</Filter><StartingIndex>0</StartingIndex><RequestedCount>100</RequestedCount>\
<SortCriteria></SortCriteria>",
)?;
Ok(parse_favorites(&body))
}
pub fn parse_favorites(body: &str) -> Vec<Favorite> {
let didl = soap::unescape(soap::tag_text(body, "Result").unwrap_or(body));
let mut out = Vec::new();
for chunk in didl.split("<item ").skip(1) {
let title = didl_field(chunk, "dc:title");
let uri = didl_field(chunk, "res");
if title.is_empty() || uri.is_empty() {
continue;
}
out.push(Favorite {
title,
uri,
metadata: didl_field(chunk, "r:resMD"),
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transport_states_map_from_the_wire() {
assert_eq!(TransportState::parse("PLAYING"), TransportState::Playing);
assert_eq!(
TransportState::parse("PAUSED_PLAYBACK"),
TransportState::Paused
);
assert_eq!(TransportState::parse("weird"), TransportState::Unknown);
assert!(TransportState::Transitioning.is_playing());
assert!(!TransportState::Paused.is_playing());
}
#[test]
fn source_kind_classifies_sonos_uri_schemes() {
assert_eq!(
SourceKind::parse("x-sonos-vli:RINCON_X:1,airplay:311e2b"),
SourceKind::AirPlay
);
assert_eq!(
SourceKind::parse("x-sonos-htastream:RINCON_X:spdif"),
SourceKind::Tv
);
assert_eq!(
SourceKind::parse("x-rincon-mp3radio://192.168.1.9:9123/live.mp3"),
SourceKind::Stream
);
assert_eq!(SourceKind::parse("x-rincon:RINCON_Y"), SourceKind::Grouped);
assert_eq!(
SourceKind::parse("x-sonos-http:track%3a123.mp4"),
SourceKind::Queue
);
}
#[test]
fn parses_a_queue_track_with_metadata() {
let body = "<u:GetPositionInfoResponse><TrackURI>x-sonos-http:track123.mp4</TrackURI>\
<TrackMetaData><DIDL-Lite><item><dc:title>Windowlicker</dc:title>\
<dc:creator>Aphex Twin</dc:creator><upnp:album>Windowlicker</upnp:album>\
</item></DIDL-Lite></TrackMetaData></u:GetPositionInfoResponse>";
let t = parse_position(body);
assert_eq!(t.title, "Windowlicker");
assert_eq!(t.artist, "Aphex Twin");
assert_eq!(t.source, SourceKind::Queue);
}
#[test]
fn airplay_source_reports_no_metadata_not_a_bogus_title() {
let body = "<u:GetPositionInfoResponse><TrackURI>x-sonos-vli:RINCON_C43:1,airplay:311e</TrackURI>\
<TrackMetaData>NOT_IMPLEMENTED</TrackMetaData></u:GetPositionInfoResponse>";
let t = parse_position(body);
assert_eq!(t.source, SourceKind::AirPlay);
assert!(t.title.is_empty(), "sentinel must not leak into the label");
assert_eq!(t.source.label(), "AirPlay");
}
#[test]
fn parses_favorites_and_flags_containers() {
let didl = "<DIDL-Lite>\
<item id=\"FV:2/1\"><dc:title>Radio Paradise</dc:title>\
<res>x-rincon-mp3radio://stream.radioparadise.com/mp3-192</res></item>\
<item id=\"FV:2/2\"><dc:title>Deep House</dc:title>\
<res>x-rincon-cpcontainer:1006206c</res><r:resMD>meta</r:resMD></item>\
</DIDL-Lite>";
let favs = parse_favorites(&format!("<Result>{didl}</Result>"));
assert_eq!(favs.len(), 2);
assert_eq!(favs[0].title, "Radio Paradise");
assert!(!favs[0].is_container(), "a radio stream plays directly");
assert!(favs[1].is_container(), "a cpcontainer goes via the queue");
assert_eq!(favs[1].metadata, "meta");
}
#[test]
fn favorites_skip_entries_missing_a_uri() {
let didl = "<item id=\"FV:2/9\"><dc:title>Broken</dc:title></item>";
assert!(parse_favorites(&format!("<Result>{didl}</Result>")).is_empty());
}
}