require 'uri'
module Sonos::Endpoint::AVTransport
TRANSPORT_ENDPOINT = '/MediaRenderer/AVTransport/Control'
TRANSPORT_XMLNS = 'urn:schemas-upnp-org:service:AVTransport:1'
# Get information about the currently playing track.
# @return [Hash] information about the current track.
def now_playing
response = send_transport_message('GetPositionInfo')
body = response.body[:get_position_info_response]
doc = Nokogiri::XML(body[:track_meta_data])
# No music
return nil if doc.children.length == 0
art_path = doc.xpath('//upnp:albumArtURI').inner_text
# TODO: No idea why this is necessary. Maybe its a Nokogiri thing
art_path.sub!('/getaa?s=1=x-sonos-http', '/getaa?s=1&u=x-sonos-http')
{
title: doc.xpath('//dc:title').inner_text,
artist: doc.xpath('//dc:creator').inner_text,
album: doc.xpath('//upnp:album').inner_text,
info: doc.xpath('//r:streamContent').inner_text,
queue_position: body[:track],
track_duration: body[:track_duration],
current_position: body[:rel_time],
uri: body[:track_uri],
album_art: "http://#{self.ip}:#{Sonos::PORT}#{art_path}"
}
end
def has_music?
!now_playing.nil?
end
# Get information about the state the player is in.
def get_player_state
response = send_transport_message('GetTransportInfo')
body = response.body[:get_transport_info_response]
{
status: body[:current_transport_status],
state: body[:current_transport_state],
speed: body[:current_speed],
}
end
# Returns true if the player is not in a paused or stopped state
def is_playing?
state = get_player_state[:state]
!['PAUSED_PLAYBACK', 'STOPPED'].include?(state)
end
# Pause the currently playing track.
def pause
parse_response send_transport_message('Pause')
end
# Play the currently selected track or play a stream.
# @param [String] uri Optional uri of the track to play. Leaving this blank, plays the current track.
def play(uri = nil)
# Play a song from the uri
set_av_transport_uri(uri) and return if uri
# Play the currently selected track
parse_response send_transport_message('Play')
end
# Stop playing.
def stop
parse_response send_transport_message('Stop')
end
# Play the next track.
def next
parse_response send_transport_message('Next')
end
# Play the previous track.
def previous
parse_response send_transport_message('Previous')
end
def line_in(speaker)
set_av_transport_uri('x-rincon-stream:' + speaker.uid.sub('uuid:', ''))
end
# Seeks to a given timestamp in the current track
# @param [Fixnum] seconds
def seek(seconds = 0)
# Must be sent in the format of HH:MM:SS
timestamp = Time.at(seconds).utc.strftime('%H:%M:%S')
parse_response send_transport_message('Seek', "REL_TIME#{timestamp}")
end
# Seeks the playlist selection to the provided index
def select_track(index)
parse_response send_transport_message('Seek', "TRACK_NR#{index}")
end
# Clear the queue
def clear_queue
parse_response send_transport_message('RemoveAllTracksFromQueue')
end
# Save queue
def save_queue(title)
parse_response send_transport_message('SaveQueue', "
#{title}")
end
# Adds a track to the queue
# @param[String] uri Uri of track
# @param[String] didl Stanza of DIDL-Lite metadata (generally created by #add_spotify_to_queue)
# @param[Integer] position Optional queue insertion position. Leaving this blank inserts at the end.
# @return[Integer] Queue position of the added track
def add_to_queue(uri, didl = '', position = 0)
response = send_transport_message('AddURIToQueue', "#{uri}#{didl}#{position}1")
# TODO yeah, this error handling is a bit soft. For consistency's sake :)
pos = response.xpath('.//FirstTrackNumberEnqueued').text
if pos.length != 0
pos.to_i
end
end
# Adds a Spotify track to the queue along with extra data for better metadata retrieval
# @param[Hash] opts Various options (id, user, region and type)
# @param[Integer] position Optional queue insertion position. Leaving this blank inserts at the end.
# @return[Integer] Queue position of the added track(s)
def add_spotify_to_queue(opts = {}, position = 0)
opts = {
:id => '',
:user => nil,
:region => nil,
:type => 'track'
}.merge(opts)
# Basic validation of the accepted types; playlists need an associated user
# and the toplist (for tracks and albums) need to specify a region.
return nil if opts[:type] == 'playlist' and opts[:user].nil?
return nil if opts[:type] =~ /toplist_tracks/ and opts[:region].nil?
# In order for the player to retrieve track duration, artist, album etc
# we need to pass it some metadata ourselves.
didl_metadata = "<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"><item id="#{rand(10000000..99999999)}spotify%3a#{opts[:type]}%3a#{opts[:id]}" restricted="true"><dc:title></dc:title><upnp:class>object.item.audioItem.musicTrack</upnp:class><desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">SA_RINCON2311_X_#Svc2311-0-Token</desc></item></DIDL-Lite>"
r_id = rand(10000000..99999999)
case opts[:type]
when /playlist/
uri = "x-rincon-cpcontainer:#{r_id}spotify%3auser%3a#{opts[:user]}%3aplaylist%3a#{opts[:id]}"
when /toplist_(tracks)/
subtype = opts[:type].sub('toplist_', '') # only 'tracks' are supported right now by Sonos.
uri = "x-rincon-cpcontainer:#{r_id}toplist%2f#{subtype}%2fregion%2f#{opts[:region]}"
when /album/
uri = "x-rincon-cpcontainer:#{r_id}spotify%3aalbum%3a#{opts[:id]}"
when /artist/
uri = "x-rincon-cpcontainer:#{r_id}tophits%3aspotify%3aartist%3a#{opts[:id]}"
when /starred/
uri = "x-rincon-cpcontainer:#{r_id}starred"
when /track/
uri = "x-sonos-spotify:spotify%3a#{opts[:type]}%3a#{opts[:id]}"
else
return nil
end
add_to_queue(uri, didl_metadata, position)
end
# Add an Rdio object to the queue (album or track), anything else can only
# be streamed (play now).
# @param[Hash] opts Various options (album/track keys, username and type)
# @param[Integer] position Optional queue insertion position. Leaving this blank inserts at the end.
# @return[Integer] Queue position of the added track(s)
def add_rdio_to_queue(opts = {}, position = 0)
opts = {
:username => nil,
:album => nil,
:track => nil,
:type => 'track',
:format => 'mp3' # can be changed, but only 'mp3' is valid.
}.merge(opts)
return nil if opts[:username].nil?
# Both tracks and albums require the album key. And tracks need a track
# key of course.
return nil if opts[:album].nil?
return nil if opts[:type] == 'track' and opts[:track].nil?
# In order for valid DIDL we'll pass an empty :track for albums.
opts[:track] = '' if opts[:type] == 'album'
didl_metadata = "<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"><item id="00030020_t%3a%3a#{opts[:track]}%3a%3aa%3a%3a#{opts[:album]}" parentID="0004006c_a%3a%3a#{opts[:album]}" restricted="true"><dc:title></dc:title><upnp:class>object.item.audioItem.musicTrack</upnp:class><desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">SA_RINCON2823_#{opts[:username]}</desc></item></DIDL-Lite>"
case opts[:type]
when /track/
uri = "x-sonos-http:_t%3a%3a#{opts[:track]}%3a%3aa%3a%3a#{opts[:album]}.#{opts[:format]}?sid=11&flags=32"
when /album/
type_id = '0004006c_a'
uri = "x-rincon-cpcontainer:#{type_id}%3a%3a#{opts[:album]}"
else
return nil
end
add_to_queue(uri, didl_metadata, position)
end
# Removes a track from the queue
# @param[String] object_id Track's queue ID
def remove_from_queue(object_id)
parse_response send_transport_message('RemoveTrackFromQueue', "#{object_id}0")
end
# Join another speaker's group.
# Trying to call this on a stereo pair slave will fail.
def join(master)
parse_response set_av_transport_uri('x-rincon:' + master.uid.sub('uuid:', ''))
end
# Add another speaker to this group.
# Trying to call this on a stereo pair slave will fail.
def group(slave)
slave.join(self)
end
# Ungroup from its current group.
# Trying to call this on a stereo pair slave will fail.
def ungroup
parse_response send_transport_message('BecomeCoordinatorOfStandaloneGroup')
end
# Set a sleep timer up to 23:59:59
# E.g. '00:11:00' for 11 minutes.
# @param duration [String] Duration of timer or nil to clear.
def set_sleep_timer(duration)
if duration.nil?
duration = ''
elsif duration.gsub(':', '').to_i > 235959
duration = '23:59:59'
end
parse_response send_transport_message('ConfigureSleepTimer', "#{duration}")
end
private
# Play a stream.
def set_av_transport_uri(uri)
send_transport_message('SetAVTransportURI', "#{uri}")
end
def transport_client
@transport_client ||= Savon.client endpoint: "http://#{self.group_master.ip}:#{Sonos::PORT}#{TRANSPORT_ENDPOINT}", namespace: Sonos::NAMESPACE, log: Sonos.logging_enabled
end
def send_transport_message(name, part = '1')
action = "#{TRANSPORT_XMLNS}##{name}"
message = %Q{0#{part}}
transport_client.call(name, soap_action: action, message: message)
end
end