bencevans/node-sonos

View on GitHub
lib/sonos.js

Summary

Maintainability
F
5 days
Test Coverage
/**
 * Sonos library to control (almost) everything from your sonos devices
 * @module sonos
 * @requires './Service'
 * @requires 'events'
 * @requires 'axios'
 * @requires 'debug'
 * @requires './events/listener'
 */

// Some constants
const TRANSPORT_ENDPOINT = '/MediaRenderer/AVTransport/Control'
const RENDERING_ENDPOINT = '/MediaRenderer/RenderingControl/Control'
const DEVICE_ENDPOINT = '/DeviceProperties/Control'

// Dependencies
const URL = require('url').URL
const util = require('util')
const EventEmitter = require('events').EventEmitter
const request = require('axios')
const debug = require('debug')('sonos:main')
const Listener = require('./events/adv-listener')
const Helpers = require('./helpers')

// All the services
var Services = {
  AlarmClock: require('./services/AlarmClock'),
  AudioIn: require('./services/AudioIn'),
  AVTransport: require('./services/AVTransport'),
  ContentDirectory: require('./services/ContentDirectory'),
  DeviceProperties: require('./services/DeviceProperties'),
  GroupManagement: require('./services/GroupManagement'),
  GroupRenderingControl: require('./services/GroupRenderingControl'),
  MusicServices: require('./services/MusicServices'),
  RenderingControl: require('./services/RenderingControl'),
  Service: require('./services/Service'),
  SystemProperties: require('./services/SystemProperties'),
  ZoneGroupTopology: require('./services/ZoneGroupTopology')
}

var SpotifyRegion = {
  EU: '2311',
  US: '3079'
}

/**
 * Create an instance of Sonos
 * @class Sonos
 * @param {String} host IP/DNS
 * @param {Number} port
 * @returns {Sonos}
 */
var Sonos = function Sonos (host, port, options) {
  this.host = host
  this.port = port || 1400
  this.options = options || {}
  if (!this.options.endpoints) this.options.endpoints = {}
  if (!this.options.endpoints.transport) this.options.endpoints.transport = TRANSPORT_ENDPOINT
  if (!this.options.endpoints.rendering) this.options.endpoints.rendering = RENDERING_ENDPOINT
  if (!this.options.endpoints.device) this.options.endpoints.device = DEVICE_ENDPOINT

  this.options.spotify = this.options.spotify || {}
  this.options.spotify.region = this.options.spotify.region || SpotifyRegion.US

  // Attach to newListener event
  var self = this
  var implicitListen = async function (event, listener) {
    if (event === 'newListener') return
    self.removeListener('newListener', implicitListen)
    return Listener.subscribeTo(self).catch(err => {
      debug('Error subscribing to listener %j', err)
    })
  }
  this.on('newListener', implicitListen)
  // Maybe stop the eventListener once last listener is removed?
}

// Make Sonos an EventEmitter
util.inherits(Sonos, EventEmitter)

/**
 * Get Music Library Information
 * @param  {String}   searchType  Choice - artists, albumArtists, albums, genres, composers, tracks, playlists, share
 * @param  {Object}   options     Optional - default {start: 0, total: 100}
 * @returns {Object}  {returned: {String}, total: {String}, items:[{title:{String}, uri: {String}}]}
 */
Sonos.prototype.getMusicLibrary = async function (searchType, options) {
  return this.searchMusicLibrary(searchType, null, options)
}

/**
 * Get Music Library Information
 * @param  {String}   searchType  Choice - artists, albumArtists, albums, genres, composers, tracks, playlists, share
 * @param  {String}   searchTerm  Optional - search term to search for
 * @param  {Object}   requestOptions     Optional - default {start: 0, total: 100}
 * @param  {String}   separator   Optional - default is colon. `:` or `/`
 * @returns {Object} {returned: {String}, total: {String}, items:[{title:{String}, uri: {String}}]}
 */
Sonos.prototype.searchMusicLibrary = async function (searchType, searchTerm, requestOptions = {}, separator = ':') {
  const searchTypes = {
    artists: 'A:ARTIST',
    albumArtists: 'A:ALBUMARTIST',
    albums: 'A:ALBUM',
    genres: 'A:GENRE',
    composers: 'A:COMPOSER',
    tracks: 'A:TRACKS',
    playlists: 'A:PLAYLISTS',
    sonos_playlists: 'SQ',
    share: 'S'
  }
  var defaultOptions = {
    BrowseFlag: 'BrowseDirectChildren',
    Filter: '*',
    StartingIndex: '0',
    RequestedCount: '100',
    SortCriteria: ''
  }
  let searches = `${searchTypes[searchType]}${separator}`

  if (searchTerm && searchTerm !== '') {
    searches = searches.concat(searchType === 'share' ? searchTerm : encodeURIComponent(searchTerm))
  }

  var opts = {
    ObjectID: searches
  }
  if (requestOptions && requestOptions.start !== undefined) opts.StartingIndex = requestOptions.start
  if (requestOptions && requestOptions.total !== undefined) opts.RequestedCount = requestOptions.total
  // opts = _.extend(defaultOptions, opts)
  opts = Object.assign({}, defaultOptions, opts)
  return this.contentDirectoryService().GetResult(opts)
}

/**
 * Get Sonos Playlist
 * @param {String}    playlistId      Sonos id of the playlist
 * @param  {Object}   requestOptions  Optional - default {start: 0, total: 100}
 * @returns {Object} {returned: {String}, total: {String}, items:[{title:{String}, uri: {String}}]}
 */
Sonos.prototype.getPlaylist = async function (playlistId, requestOptions = {}) {
  return this.searchMusicLibrary('sonos_playlists', playlistId, requestOptions)
}

/**
 * Create a new sonos playlist
 * @param {String}  title   Name of the playlist
 * @returns {Object} { NumTracksAdded: 0, NewQueueLength: 0, NewUpdateID: 0, AssignedObjectID: 'SQ:3' }
 */
Sonos.prototype.createPlaylist = async function (title) {
  debug('Sonos.createPlaylist(%j)', title)
  return this.avTransportService().CreateSavedQueue(title).then(result => {
    return {
      NumTracksAdded: parseInt(result.NumTracksAdded),
      NewQueueLength: parseInt(result.NewQueueLength),
      NewUpdateID: parseInt(result.NewUpdateID),
      AssignedObjectID: result.AssignedObjectID
    }
  }, () => {
    return {
      NumTracksAdded: 0,
      NewQueueLength: 0,
      NewUpdateID: 0,
      AssignedObjectID: null
    }
  })
}

/**
 * Delete sonos playlist
 * @param {Number}  objectId   Sonos id of the playlist
 * @returns {Boolean} Playlist deleted
 */
Sonos.prototype.deletePlaylist = async function (playlistId) {
  debug('Sonos.deletePlaylist()')
  return this.contentDirectoryService().DestroyObject({ ObjectID: `SQ:${playlistId}` }).then(() => { return true }, () => { return false })
}

/**
 * Add uri to sonos playlist
 * @param {Number}  playlistId   Sonos id of the playlist
 * @param {String}  uri   Uri to add to the playlist
 * @returns {Object} { NumTracksAdded: 1, NewQueueLength: 2, NewUpdateID: 2 }
 */
Sonos.prototype.addToPlaylist = async function (playlistId, uri) {
  debug('Sonos.addToPlaylist(%j, %j)', playlistId, uri)

  const result = await this.getPlaylist(playlistId)
  const meta = Helpers.GenerateMetadata(uri, undefined, this.options.spotify.region)

  return this.avTransportService().AddURIToSavedQueue({
    InstanceID: 0,
    ObjectID: `SQ:${playlistId}`,
    UpdateID: result.updateID,
    EnqueuedURI: Helpers.EncodeXml(meta.uri),
    EnqueuedURIMetaData: Helpers.EncodeXml(meta.metadata),
    AddAtIndex: 4294967295
  }).then(result => {
    return {
      NumTracksAdded: parseInt(result.NumTracksAdded),
      NewQueueLength: parseInt(result.NewQueueLength),
      NewUpdateID: parseInt(result.NewUpdateID)
    }
  }, () => {
    return {
      NumTracksAdded: 0,
      NewQueueLength: 0,
      NewUpdateID: 0
    }
  })
}

/**
 * Remove track from playlist
 * @param {Number}  playlistId   Sonos id of the playlist
 * @param {String}  index   Index of song to remove
 * @returns {Object} { QueueLengthChange: -1, NewQueueLength: 2, NewUpdateID: 2 }
 */
Sonos.prototype.removeFromPlaylist = async function (playlistId, index) {
  debug('Sonos.removeFromPlaylist(%j, %j)', playlistId, index)

  const result = await this.getPlaylist(playlistId)
  return this.avTransportService().ReorderTracksInSavedQueue({
    InstanceID: 0,
    ObjectID: `SQ:${playlistId}`,
    UpdateID: result.updateID,
    TrackList: index,
    NewPositionList: ''
  }).then(result => {
    return {
      QueueLengthChange: parseInt(result.QueueLengthChange),
      NewQueueLength: parseInt(result.NewQueueLength),
      NewUpdateID: parseInt(result.NewUpdateID)
    }
  }, () => {
    return {
      QueueLengthChange: 0,
      NewQueueLength: 0,
      NewUpdateID: 0
    }
  })
}

/**
 * Get Sonos Favorites
 * @returns {Object} {returned: {String}, total: {String}, items:[{title:{String}, uri: {String}}]}
 */
Sonos.prototype.getFavorites = async function () {
  const options = {
    BrowseFlag: 'BrowseDirectChildren',
    Filter: '*',
    StartingIndex: '0',
    RequestedCount: '100',
    SortCriteria: '',
    ObjectID: 'FV:2'
  }

  return this.contentDirectoryService().GetResult(options)
}

/**
 * Get Current Track
 * @returns {Object} All the info of the current track
 */
Sonos.prototype.currentTrack = async function () {
  debug('Sonos.currentTrack()')
  return this.avTransportService().CurrentTrack()
}

/**
 * Get Current Volume
 * @returns {Number} The current volume
 */
Sonos.prototype.getVolume = async function () {
  debug('Sonos.getVolume()')
  if (this._isSubscribed && this._volume) return this._volume
  return this.renderingControlService().GetVolume()
}

/**
 * Get Current Muted
 * @returns {Boolean}
 */
Sonos.prototype.getMuted = async function () {
  debug('Sonos.getMuted()')
  if (this._isSubscribed && this._mute) return this._mute
  return this.renderingControlService().GetMute()
}

/**
 * Resumes Queue or Plays Provided URI
 * @param  {String|Object}   options      Optional - URI to a Audio Stream or Object with play options
 * @returns {Boolean} Started playing?
 */
Sonos.prototype.play = async function (options) {
  debug('Sonos.play(%j)', options)
  if (!options) {
    await this.avTransportService().Play()
    return true
  } else {
    const result = await this.queue(options)
    await this.selectQueue()
    await this.selectTrack(result.FirstTrackNumberEnqueued)
    return this.avTransportService().Play()
      .then(result => { return true })
  }
}

/**
 * Plays a uri directly (the queue stays the same)
 * @param  {String|Object}   options URI to a Audio Stream or Object with play options see `Helpers.GenerateMetadata`
 * @returns {Boolean}
 */
Sonos.prototype.setAVTransportURI = async function (options) {
  debug('Sonos.setAVTransportURI(%j)', options)

  if (typeof options !== 'string' && typeof options !== 'object') {
    return new Error('Options should be an object or a string')
  }

  // If the options is a string try to generate metadata from it
  if (typeof options === 'string') options = Helpers.GenerateMetadata(options, undefined, this.options.spotify.region)

  // We need this later.
  const self = this

  if (options.uri) {
    return this.avTransportService().SetAVTransportURI({
      InstanceID: 0,
      CurrentURI: Helpers.EncodeXml(options.uri),
      CurrentURIMetaData: Helpers.EncodeXml(options.metadata)
    }).then(result => {
      // Start playing by default, the notifications don't want to start playing by default.
      if ((!options.onlySetUri || options.onlySetUri !== true) && result) {
        return self.play()
      }
      return true
    })
  } else {
    return new Error('Error with uri')
  }
}

/**
 * Stop What's Playing
 * @returns {boolean}
 */
Sonos.prototype.stop = async function () {
  debug('Sonos.stop()')
  return this.avTransportService().Stop().then(result => { return true })
}

/**
 * Get all the groups, replaces some functionality of 'getTopology()'
 */
Sonos.prototype.getAllGroups = async function () {
  debug('Sonos.GetAllGroups()')
  const zones = await this.zoneGroupTopologyService().AllZoneGroups()
  const zonesArray = Array.isArray(zones) ? zones : [zones]

  zonesArray.forEach((zone) => {
    if (zone.Coordinator) {
      const coordinator = zone.ZoneGroupMember.find((member) => {
        return member.UUID === zone.Coordinator
      })
      if (coordinator) {
        zone.Name = coordinator.ZoneName
        if (zone.ZoneGroupMember.length > 1) zone.Name += ` + ${zone.ZoneGroupMember.length - 1}`

        var uri = new URL(coordinator.Location)
        zone.host = uri.hostname
        zone.port = parseInt(uri.port)
        zone.CoordinatorDevice = function () {
          return new Sonos(this.host, this.port)
        }
      }
    } else if (zone.ZoneGroupMember.length > 0) {
      zone.Name = zone.ZoneGroupMember[0].ZoneName
    }
  })
  return zonesArray
}

/**
 * Become Coordinator of Standalone Group
 * @returns {boolean}
 */
Sonos.prototype.becomeCoordinatorOfStandaloneGroup = async function () {
  debug('Sonos.becomeCoordinatorOfStandaloneGroup()')
  return this.avTransportService().BecomeCoordinatorOfStandaloneGroup().then(result => { return true })
}

/**
 * Leave the group (shortcut to becomeCoordinatorOfStandaloneGroup)
 * @returns {boolean}
 */
Sonos.prototype.leaveGroup = async function () { return this.becomeCoordinatorOfStandaloneGroup() }

/**
 * Join an other device by name
 * @param  {String} otherDeviceName The name of de device you want to join, doesn't matter if that isn't the coordinator
 * @returns {Boolean}
 */
Sonos.prototype.joinGroup = function (otherDeviceName) {
  debug('Sonos.joinGroup(%j)', otherDeviceName)

  return this.getAllGroups()
    .then(groups => {
      const groupToJoin = groups.find(group =>
        group.ZoneGroupMember.some(member =>
          member.ZoneName.toLowerCase() === otherDeviceName.toLowerCase()))

      if (!groupToJoin) {
        return new Error(`Device with name ${otherDeviceName} isn't found`)
      } else {
        debug('Found coordinator %s', groupToJoin.Coordinator)
        return this.setAVTransportURI({ uri: `x-rincon:${groupToJoin.Coordinator}`, onlySetUri: true })
      }
    })
}

/**
 * Pause Current Queue
 * @returns {Boolean}
 */
Sonos.prototype.pause = async function () {
  debug('Sonos.pause()')
  return this.avTransportService().Pause().then(result => { return true })
}

/**
 * Seek in the current track
 * @param  {Number} seconds jump to x seconds.
 * @returns {Boolean}
 */
Sonos.prototype.seek = async function (seconds) {
  debug('Sonos.seek()')
  let hh = Math.floor(seconds / 3600)
  let mm = Math.floor((seconds - (hh * 3600)) / 60)
  let ss = seconds - ((hh * 3600) + (mm * 60))
  if (hh < 10) hh = '0' + hh
  if (mm < 10) mm = '0' + mm
  if (ss < 10) ss = '0' + ss
  return this.avTransportService().Seek({
    InstanceID: 0,
    Unit: 'REL_TIME',
    Target: hh + ':' + mm + ':' + ss
  }).then(result => { return true })
}

/**
 * Select specific track in queue
 * @param  {Number}   trackNr    Number of track in queue (optional, indexed from 1)
 * @returns {Boolean}
 */
Sonos.prototype.selectTrack = async function (trackNr) {
  debug('Sonos.selectTrack(%j)', trackNr)
  return this.avTransportService().Seek({
    InstanceID: 0,
    Unit: 'TRACK_NR',
    Target: trackNr
  }).then(result => { return true })
}

/**
 * Play next in queue
 * @returns {Boolean}
 */
Sonos.prototype.next = async function () {
  debug('Sonos.next()')
  return this.avTransportService().Next().then(result => { return true })
}

/**
 * Play previous in queue
 * @returns {Boolean}
 */
Sonos.prototype.previous = async function () {
  debug('Sonos.previous()')
  return this.avTransportService().Previous().then(result => { return true })
}

/**
 * Select Queue. Mostly required after turning on the speakers otherwise play, setPlaymode and other commands will fail.
 * @returns {Boolean} success
 */
Sonos.prototype.selectQueue = async function () {
  debug('Sonos.selectQueue()')
  return this.getZoneInfo()
    .then(info => {
      const uri = 'x-rincon-queue:RINCON_' + info.MACAddress.replace(/:/g, '') + '0' + this.port + '#0'
      return this.setAVTransportURI(uri)
    })
}

/**
 * Plays tunein based on radio station id
 * @param  {String}   stationId  tunein radio station id
 * @returns {Boolean}
 */
Sonos.prototype.playTuneinRadio = async function (stationId, stationTitle) {
  debug('Sonos.playTuneinRadio(%j, %j)', stationId, stationTitle)

  var options = Helpers.GenerateMetadata('radio:' + stationId, stationTitle)
  return this.setAVTransportURI(options)
}

/**
 * Plays Spotify radio based on artist uri
 * @param  {String}   artistId  Spotify artist id
 * @returns {Boolean}
 */
Sonos.prototype.playSpotifyRadio = async function (artistId, artistName) {
  debug('Sonos.playSpotifyRadio(%j, %j)', artistId, artistName)
  var options = Helpers.GenerateMetadata('spotify:artistRadio:' + artistId, artistName, this.options.spotify.region)
  return this.setAVTransportURI(options)
}

/**
 * Add a song to the queue.
 * @param  {String|Object}   options  Uri with audio stream of object with `uri` and `metadata`
 * @param  {Number}   positionInQueue Position in queue at which to add song (optional, indexed from 1,
 *                                    defaults to end of queue, 0 to explicitly set end of queue)
 * @returns {Object} Some info about the last queued file.
 */
Sonos.prototype.queue = async function (options, positionInQueue = 0) {
  debug('Sonos.queue(%j, %j)', options, positionInQueue)
  if (typeof options === 'string') options = Helpers.GenerateMetadata(options, undefined, this.options.spotify.region)
  return this.avTransportService().AddURIToQueue({
    InstanceID: 0,
    EnqueuedURI: Helpers.EncodeXml(options.uri),
    EnqueuedURIMetaData: Helpers.EncodeXml(options.metadata),
    DesiredFirstTrackNumberEnqueued: positionInQueue,
    EnqueueAsNext: 1
  })
}

/**
 * Remove a range of tracks from the queue.
 * @param {number} startIndex Where to start removing the tracks, 1 for first item!
 * @param {number} numberOfTracks How many tracks to remove.
 */
Sonos.prototype.removeTracksFromQueue = async function (startIndex, numberOfTracks = 1) {
  debug('Sonos.removeTracksFromQueue(%s,%s)', startIndex, numberOfTracks)
  return this.avTransportService().RemoveTrackRangeFromQueue({
    InstanceID: 0,
    UpdateID: 0,
    StartingIndex: startIndex,
    NumberOfTracks: numberOfTracks
  })
}

/**
 * Flush queue
 * @returns {Object}
 */
Sonos.prototype.flush = async function () {
  debug('Sonos.flush()')
  return this.avTransportService().RemoveAllTracksFromQueue()
}

/**
 * Get the LED State
 * @returns {String} state is a string, "On" or "Off"
 */
Sonos.prototype.getLEDState = async function () {
  debug('Sonos.getLEDState()')
  return this.devicePropertiesService().GetLEDState()
}

/**
 * Set the LED State
 * @param  {String}   newState           "On"/"Off"
 * @returns {Boolean}
 */
Sonos.prototype.setLEDState = async function (newState) {
  debug('Sonos.setLEDState(%j)', newState)
  return this.devicePropertiesService().SetLEDState(newState)
}

/**
 * Get Zone Info
 * @returns {Object}
 */
Sonos.prototype.getZoneInfo = async function () {
  debug('Sonos.getZoneInfo()')
  return this.devicePropertiesService().GetZoneInfo()
}

/**
 * Get Zone Attributes
 * @returns {Object}
 */
Sonos.prototype.getZoneAttrs = async function () {
  debug('Sonos.getZoneAttrs()')
  return this.devicePropertiesService().GetZoneAttributes()
}

/**
 * Get Information provided by /xml/device_description.xml
 * @returns {Object}
 */
Sonos.prototype.deviceDescription = async function () {
  debug('Sonos.deviceDescription()')
  return request(`http://${this.host}:${this.port}/xml/device_description.xml`)
    .then(response => response.data)
    .then(Helpers.SanitizeDeviceDescriptionXml)
    .then(Helpers.ParseXml)
    .then(result => {
      return result.root.device
    })
}

/**
 * Set Name
 * @param  {String}   name
 * @returns {Object}
 */
Sonos.prototype.setName = async function (name) {
  debug('Sonos.setName(%j)', name)
  name = name.replace(/[<&]/g, function (str) { return (str === '&') ? '&amp;' : '&lt;' })
  return this.devicePropertiesService().SetZoneAttributes({
    DesiredConfiguration: null,
    DesiredIcon: null,
    DesiredZoneName: name
  })
}

/**
 * Get the CurrentZoneName
 * @returns {String}
 */
Sonos.prototype.getName = async function () {
  debug('Sonos.getName()')
  return this.getZoneAttrs().then(attrs => {
    return attrs.CurrentZoneName
  })
}

/**
 * Get Play Mode
 * @returns {String}
 */
Sonos.prototype.getPlayMode = async function () {
  debug('Sonos.getPlayMode()')
  return this.avTransportService().GetTransportSettings()
    .then(result => {
      return result.PlayMode
    })
}

const playmodes = ['NORMAL', 'REPEAT_ONE', 'REPEAT_ALL', 'SHUFFLE', 'SHUFFLE_NOREPEAT', 'SHUFFLE_REPEAT_ONE']
/**
 * Set Play Mode
 * @param  {String} playmode
 * @returns {Object}
 */
Sonos.prototype.setPlayMode = async function (playmode) {
  debug('Sonos.setPlayMode(%j)', playmode)
  playmode = playmode.toUpperCase()
  if (playmodes.indexOf(playmode) === -1) throw new Error('Invalid play mode: ' + playmode + ' available modes ' + playmodes.join(', '))
  return this.avTransportService().SetPlayMode(playmode)
}

/**
 * Set Volume
 * @param  {number}   volume 0..100
 * @param  {string}   channel What channel to change, `Master` is default.
 * @returns {Object}
 */
Sonos.prototype.setVolume = async function (volume, channel = 'Master') {
  debug('Sonos.setVolume(%j)', volume)
  return this.renderingControlService().SetVolume(volume, channel)
}

/**
 * Adjust volume
 * @param  {number} volumeAdjustment The relative volume adjustment
 * @param  {string} channel The channel you want to set, `Master` is default.
 */
Sonos.prototype.adjustVolume = async function (volumeAdjustment, channel = 'Master') {
  debug('Sonos.adjustVolume(%j)', volumeAdjustment)
  return this.renderingControlService().SetRelativeVolume(volumeAdjustment, channel)
}

/**
 * Configure Sleep Timer
 * @param  {String} sleepTimerDuration
 * @returns {Object}
 */
Sonos.prototype.configureSleepTimer = async function (sleepTimerDuration) {
  debug('Sonos.sleepTimerDuration(%j)', sleepTimerDuration)
  return this.avTransportService().ConfigureSleepTimer(sleepTimerDuration)
}

/**
 * Set Muted
 * @param  {Boolean}  muted
 * @param  {string}   channel What channel to change, `Master` is default.
 * @returns {Object}
 */
Sonos.prototype.setMuted = async function (muted, channel = 'Master') {
  debug('Sonos.setMuted(%j)', muted)
  if (typeof muted === 'string') muted = !!parseInt(muted, 10)
  return this.renderingControlService().SetMute(muted, channel)
}

/**
 * Get device balance as number from -100 (full left) to +100 (full right), where 0 is left and right equal
 * @returns {Promise}
 */
Sonos.prototype.getBalance = async function () {
  return (
    (await this.renderingControlService().GetVolume('RF')) -
      (await this.renderingControlService().GetVolume('LF'))
  )
}

/**
 * Set device balance
 * @param {Number} balance  from -100 (full left) to +100 (full right), where 0 is left and right equal
 * @returns {Promise}
 */
Sonos.prototype.setBalance = async function (balance) {
  let rightVolume = 100
  let leftVolume = 100

  if (balance > 0) {
    leftVolume = 100 - balance
  } else if (balance < 0) {
    rightVolume = 100 - balance * -1
  }

  await this.renderingControlService().SetVolume(leftVolume, 'LF')
  await this.renderingControlService().SetVolume(rightVolume, 'RF')
}

/**
 * Get Zones in contact with current Zone with Group Data
 * @deprecated Doesn't work if you upgraded your system to Sonos v9.1
 * @returns {Object}
 */
Sonos.prototype.getTopology = async function () {
  debug('Sonos.getTopology()')
  return new Promise((resolve, reject) => {
    request(`http://${this.host}:${this.port}/status/topology`)
      .then(response => response.data)
      .then(Helpers.ParseXml)
      .then(result => {
        debug('getTopologyResult %j', result)
        const info = result.ZPSupportInfo

        if (!info) {
          resolve({ zones: [], mediaServers: [] })
          return
        }

        let zones = info.ZonePlayers.ZonePlayer
        if (!Array.isArray(zones)) {
          zones = [zones]
        }

        zones.forEach(zone => {
          zone.name = zone['_']
          delete zone['_']
        })

        const mediaServers = info.MediaServers.MediaServer
        mediaServers.forEach(s => {
          s.name = s['_']
          delete s['_']
        })

        resolve({ zones: zones, mediaServers: mediaServers })
      })
      .catch(reject)
  })
}
/**
 * Get Current Playback State
 * @returns {String} the current playback state
 */
Sonos.prototype.getCurrentState = async function () {
  debug('Sonos.currentState()')
  if (this._isSubscribed && this._state) return this._state
  return this.avTransportService().GetTransportInfo()
    .then(result => {
      return Helpers.TranslateState(result.CurrentTransportState)
    })
}

/**
 * Toggle the current playback, like the button. Currently only works for state `playing` or `paused`
 * @returns {Boolean}
 */
Sonos.prototype.togglePlayback = async function () {
  debug('Sonos.togglePlayback()')
  return this.getCurrentState()
    .then(state => {
      if (state === 'paused' || state === 'stopped') {
        return this.play()
      } else if (state === 'playing') {
        return this.pause()
      } else {
        debug('toggle playback doesn\'t support %s', state)
        return false
      }
    })
}

/**
 * Get Favorites Radio Stations
 * @param  {Object}   options     Optional - default {start: 0, total: 100}
 * @returns {Object} {returned: {String}, total: {String}, items:[{title:{String}, uri: {String}}]}
 */
Sonos.prototype.getFavoritesRadioStations = async function (options) {
  return this.getFavoritesRadio('stations', options)
}

/**
 * Get Favorites Radio Shows
 * @param  {Object}   options     Optional - default {start: 0, total: 100}
 * @returns {Object} {returned: {String}, total: {String}, items:[{title:{String}, uri: {String}}]}
 */
Sonos.prototype.getFavoritesRadioShows = async function (options) {
  return this.getFavoritesRadio('shows', options)
}

/**
 * Get Favorites Radio for a given radio type
 * @param  {String}   favoriteRadioType  Choice - stations, shows
 * @param  {Object}   options     Optional - default {start: 0, total: 100}
 * @returns  {Object} {returned: {String}, total: {String}, items:[{title:{String}, uri: {String}}]}
 */
Sonos.prototype.getFavoritesRadio = async function (favoriteRadioType, requestOptions = {}) {
  var radioTypes = {
    stations: 'R:0/0',
    shows: 'R:0/1'
  }

  var options = {
    BrowseFlag: 'BrowseDirectChildren',
    Filter: '*',
    StartingIndex: '0',
    RequestedCount: '100',
    SortCriteria: '',
    ObjectID: radioTypes[favoriteRadioType]
  }
  var opts = {}
  if (requestOptions.start !== undefined) opts.StartingIndex = requestOptions.start
  if (requestOptions.total !== undefined) opts.RequestedCount = requestOptions.total
  Object.assign(options, opts)
  return this.contentDirectoryService().GetResult(options)
}

Sonos.prototype.setSpotifyRegion = function (region) {
  this.options.spotify.region = region
}

/**
 * Get the current queue
 * @returns  {Object} {returned: {String}, total: {String}, items:[{title:{String}, uri: {String}}]}
 */
Sonos.prototype.getQueue = async function () {
  var options = {
    BrowseFlag: 'BrowseDirectChildren',
    Filter: '*',
    StartingIndex: '0',
    RequestedCount: '1000',
    SortCriteria: '',
    ObjectID: 'Q:0'
  }
  return this.contentDirectoryService().GetResult(options)
}

/**
 * Utility function which resolves a promise on an event
 * @param {String} eventName
 */
function asyncOnce (eventEmitter, eventName) {
  return new Promise((resolve, reject) => {
    eventEmitter.once(eventName, (m) => {
      resolve(m)
    })
  })
}

/**
 * Play uri and restore last state
 * @param  {String|Object}  options URI to a Audio Stream or Object with play options see `Helpers.GenerateMetadata`
 * @param  {String}         options.uri The URI of the stream
 * @param  {String}         options.metadata The metadata of the stream see `Helpers.GenerateMetadata`
 * @param  {Boolean}        options.onlyWhenPlaying Only play this notification on players currently 'playing'
 * @param  {Number}         options.volume The volume used for the notification.
 * @returns {Boolean}       Did the notification play? Only returns when finished reverting to old play settings.
 */
Sonos.prototype.playNotification = async function (options) {
  debug('sonos.playNotification(%j)', options)
  const state = await this.getCurrentState()
  debug('Current state %j', state)
  const wasPlaying = (state === 'playing' || state === 'transitioning')
  debug('Was playing %j', wasPlaying)
  const wasListening = Listener.isListening()
  let volume = 0
  if (typeof options === 'object') {
    if (!options.uri) {
      throw new Error('options.uri is required.')
    }
    // Return false when not playing.
    if (!wasPlaying && options.onlyWhenPlaying === true) {
      return false
    }

    if (options.volume) {
      volume = await this.getVolume()
    }

    if (!options.metadata) {
      options.metadata = Helpers.GenerateMetadata(options.uri).metadata
    }
  }

  const mediaInfo = await this.avTransportService().GetMediaInfo()
  debug('Current mediaInfo %j', mediaInfo)
  const positionInfo = await this.avTransportService().GetPositionInfo()
  debug('Current positionInfo %j', positionInfo)
  await this.setAVTransportURI(options)
  if (volume > 0) await this.setVolume(options.volume)
  const self = this

  await asyncOnce(this, 'PlaybackStopped')

  debug('sonos.playNotification reverting back to before')
  if (volume > 0) await this.setVolume(volume)
  if (!wasListening) await Listener.stopListener()
  await self.setAVTransportURI({ uri: mediaInfo.CurrentURI, metadata: mediaInfo.CurrentURIMetaData, onlySetUri: true })

  // These statements can maybe be improved. See discussion on https://github.com/bencevans/node-sonos/pull/430
  if (positionInfo.Track && positionInfo.Track > 1 && mediaInfo.NrTracks > 1) {
    debug('Selecting track %j', positionInfo.Track)
    await self.selectTrack(positionInfo.Track).catch(reason => {
      debug('Reverting back track failed, happens for some muic services.')
    })
  }

  if (positionInfo.RelTime && positionInfo.TrackDuration !== '0:00:00') {
    debug('Setting back time to %j', positionInfo.RelTime)
    await self.avTransportService().Seek({ InstanceID: 0, Unit: 'REL_TIME', Target: positionInfo.RelTime }).catch(reason => {
      debug('Reverting back track time failed, happens for some muic services (radio or stream).')
    })
  }

  if (wasPlaying) await self.play()
  return true
}

/**
 * Reorder tracks in queue.
 * @param {number} startingIndex Index of the first track to be moved
 * @param {number} numberOfTracks How many tracks do we want to move
 * @param {number} insertBefore Index of place where the tracks should be moved to
 * @param {number} updateId Not sure what this means, just leave it at `0`
 * @returns {Boolean}
 */
Sonos.prototype.reorderTracksInQueue = async function (startingIndex, numberOfTracks, insertBefore, updateId = 0) {
  return this.avTransportService().ReorderTracksInQueue({
    InstanceID: 0,
    UpdateID: updateId,
    StartingIndex: startingIndex,
    NumberOfTracks: numberOfTracks,
    InsertBefore: insertBefore
  })
}

/**
 * Get SpotifyConnect info, will error when no premium account is present
 * @returns {Object}
 */
Sonos.prototype.getSpotifyConnectInfo = async function () {
  const uri = `http://${this.host}:${this.port}/spotifyzc?action=getInfo`
  const resp = await request(uri)
  return resp.data
}

// ----------------------------- Services part

Sonos.prototype.alarmClockService = function () {
  return new Services.AlarmClock(this.host, this.port)
}

Sonos.prototype.avTransportService = function () {
  return new Services.AVTransport(this.host, this.port)
}

Sonos.prototype.contentDirectoryService = function () {
  return new Services.ContentDirectory(this.host, this.port)
}

Sonos.prototype.devicePropertiesService = function () {
  return new Services.DeviceProperties(this.host, this.port)
}

Sonos.prototype.renderingControlService = function () {
  return new Services.RenderingControl(this.host, this.port)
}

Sonos.prototype.zoneGroupTopologyService = function () {
  return new Services.ZoneGroupTopology(this.host, this.port)
}

// ----------------------------- End Services part

/**
 * Export
 */

module.exports.Sonos = Sonos
const deviceDiscovery = require('./deviceDiscovery')
module.exports.DeviceDiscovery = deviceDiscovery
/**
 * @deprecated Since 1.11.1
 * Please use Sonos.DeviceDiscovery or AsyncDeviceDiscovery
 */
module.exports.Search = deviceDiscovery
const asyncDeviceDiscovery = require('./asyncDeviceDiscovery')
module.exports.AsyncDeviceDiscovery = asyncDeviceDiscovery
module.exports.Helpers = Helpers
module.exports.Services = Services
module.exports.Listener = Listener
module.exports.SpotifyRegion = SpotifyRegion