node-red-contrib-sonos-plus
Version:
A set of Node-RED nodes to control SONOS player in your local network.
1,183 lines (1,062 loc) • 158 kB
JavaScript
/**
* All functions provided by Universal node.
* Universal node: all except commands related to groups and player.
*
* @module Universal
*
* @author Henning Klages
*
*/
'use strict'
const { PACKAGE_PREFIX, REGEX_ANYCHAR, REGEX_CSV, REGEX_HTTP, REGEX_IP, REGEX_DNS,
REGEX_QUEUEMODES, REGEX_RADIO_ID, REGEX_SERIAL, REGEX_TIME_SPECIAL, REGEX_MACADDRESS,
REGEX_TIME_DELTA, TIMEOUT_HTTP_REQUEST,
ML_REQUESTS_MAXIMUM, QUEUE_REQUESTS_MAXIMUM, REGEX_ALBUMARTISTDISPLAY
} = require('./Globals.js')
const { discoverSpecificSonosPlayerBySerial } = require('./Discovery.js')
const { sendWakeUp } = require('./Wake-on-lan')
const { createGroupSnapshot, getGroupCurrent, getGroupsAll, getSonosPlaylists, getSonosQueueV2,
playGroupNotification, playJoinerNotification, restoreGroupSnapshot, getAlarmsAll, getMySonos,
getMusicLibraryItemsV2, getSonosPlaylistTracks, setVolumeOnMembers, getSelectedPlayerHostname,
getCoordinatorHostname
} = require('./Commands.js')
const { executeActionV8, failureV2, getDeviceInfo, getDeviceProperties, getMusicServiceId,
getMusicServiceName, validatedGroupProperties, replaceAposColon, getDeviceBatteryLevel,
isOnlineSonosPlayer
} = require('./Extensions.js')
const { isTruthy, isTruthyProperty, isTruthyPropertyStringNotEmpty, validRegex,
validToInteger, encodeHtmlEntity, extractSatellitesUuids, validTime, validPropertyRequiredRegex,
validPropertyRequiredInteger, validPropertyRequiredOnOff
} = require('./Helper.js')
const { SonosDevice, MetaDataHelper } = require('@svrooij/sonos/lib')
const Dns = require('dns')
const dnsPromises = Dns.promises
const debug = require('debug')(`${PACKAGE_PREFIX}universal-create`)
module.exports = function (RED) {
// Function lexical order, ascending
const COMMAND_TABLE_UNIVERSAL = {
'coordinator.delegate': coordinatorDelegateCoordination,
'group.adjust.volume': groupAdjustVolume,
'group.cancel.sleeptimer': groupCancelSleeptimer,
'group.clear.queue': groupClearQueue,
'group.create.snap': groupCreateSnapshot,
'group.create.volumesnap': groupCreateVolumeSnapshot,
'group.get.actions': groupGetTransportActions,
'group.get.crossfade': groupGetCrossfadeMode,
'group.get.members': groupGetMembers,
'group.get.mutestate': groupGetMute,
'group.get.playbackstate': groupGetPlaybackstate,
'group.get.queue': groupGetQueue,
'group.get.queue.length': groupGetQueueLength,
'group.get.sleeptimer': groupGetSleeptimer,
'group.get.state': groupGetState,
'group.get.trackplus': groupGetTrackPlus,
'group.get.volume': groupGetVolume,
'group.next.track': groupNextTrack,
'group.pause': groupPause,
'group.play': groupPlay,
'group.play.export': groupPlayExport,
'group.play.library.playlist': groupPlayLibraryItem,
'group.play.library.album': groupPlayLibraryItem,
'group.play.library.artist': groupPlayLibraryItem,
'group.play.library.track': groupPlayLibraryItem,
'group.play.mysonos': groupPlayMySonos,
'group.play.notification': groupPlayNotification,
'group.play.queue': groupPlayQueue,
'group.play.snap': groupPlaySnapshot,
'group.play.sonosplaylist': groupPlaySonosPlaylist,
'group.play.streamhttp': groupPlayStreamHttp,
'group.play.track': groupPlayTrack,
'group.play.tunein': groupPlayTuneIn,
'group.previous.track': groupPreviousTrack,
'group.queue.library.playlist': groupQueueLibraryItem,
'group.queue.library.album': groupQueueLibraryItem,
'group.queue.library.artist': groupQueueLibraryItem,
'group.queue.library.track': groupQueueLibraryItem,
'group.queue.sonosplaylist': groupQueueSonosPlaylist,
'group.queue.uri': groupQueueUri,
'group.queue.urispotify': groupQueueUriFromSpotify,
'group.remove.tracks': groupRemoveTracks,
'group.save.queue': groupSaveQueueToSonosPlaylist,
'group.seek': groupSeek,
'group.seek.delta': groupSeekDelta,
'group.set.crossfade': groupSetCrossfade,
'group.set.mutestate': groupSetMute,
'group.set.queuemode': groupSetQueuemode,
'group.set.sleeptimer': groupSetSleeptimer,
'group.set.volume': groupSetVolume,
'group.stop': groupStop,
'group.toggle.playback': groupTogglePlayback,
'household.add.satellites': householdAddSatellites,
'household.add.subwoofer': householdAddSubwoofer,
'household.create.group': householdCreateGroup,
'household.create.stereopair': householdCreateStereoPair,
'household.disable.alarm': householdDisableAlarm,
'household.enable.alarm': householdEnableAlarm,
'household.get.alarms': householdGetAlarms,
'household.get.musiclibrary.options': householdGetMusicLibraryAlbumArtistDisplayOption,
'household.get.groups': householdGetGroups,
'household.get.sonosplaylists': householdGetSonosPlaylists,
'household.get.sonosplaylisttracks': householdGetSonosPlaylistTracks,
'household.remove.satellites': householdRemoveSatellites,
'household.remove.sonosplaylist': householdRemoveSonosPlaylist,
'household.separate.group': householdSeparateGroup,
'household.separate.stereopair': householdSeparateStereoPair,
'household.set.alarmTime': householdSetAlarmTime,
'household.test.player': householdTestPlayerOnline,
'household.update.musiclibrary': householdMusicLibraryUpdate,
'household.wakeup.player': householdPlayerWakeUp,
'joiner.play.notification': joinerPlayNotification,
'player.adjust.volume': playerAdjustVolume,
'player.become.standalone': playerBecomeStandalone,
'player.get.bass': playerGetBass,
'player.get.batterylevel': playerGetBatteryLevel,
'player.get.buttonlockstate': playerGetButtonLockState,
'player.get.dialoglevel': playerGetEq,
'player.get.led': playerGetLed,
'player.get.loudness': playerGetLoudness,
'player.get.mutestate': playerGetMute,
'player.get.nightmode': playerGetEq,
'player.get.subwoofer': playerGetEq,
'player.get.properties': playerGetProperties,
'player.get.queue': playerGetQueue,
'player.get.role': playerGetRole,
'player.get.subgain': playerGetEq,
'player.get.treble': playerGetTreble,
'player.get.volume': playerGetVolume,
'player.join.group': playerJoinGroup,
'player.play.avtransport': playerPlayAvtransport,
'player.play.clip': playerPlayClip,
'player.play.linein': playerPlayLineIn,
'player.play.tv': playerPlayTv,
'player.set.bass': playerSetBass,
'player.set.buttonlockstate': playerSetButtonLockState,
'player.set.dialoglevel': playerSetEQ,
'player.set.led': playerSetLed,
'player.set.loudness': playerSetLoudness,
'player.set.mutestate': playerSetMute,
'player.set.nightmode': playerSetEQ,
'player.set.subwoofer': playerSetEQ,
'player.set.subgain': playerSetEQ,
'player.set.treble': playerSetTreble,
'player.set.volume': playerSetVolume,
'player.test': playerTest,
'player.execute.action.v8': playerDirectAction8 // hidden
}
/**
* Create Universal node, store nodeDialog, valid ip address and subscribe to messages.
* @param {object} config current node configuration data
*/
function SonosUniversalNode (config) {
const nodeName = 'universal'
// same as in SonosManageMySonosNode
const thisMethodName = `${nodeName} create and subscribe`
debug('method:%s', thisMethodName)
RED.nodes.createNode(this, config)
const configNode = RED.nodes.getNode(config.confignode);
// async wrap reduces .then
(async function (configuration, configurationNode, node) {
node.status({}) // Clear node status
const port = 1400 // assuming this port, used to build playerUrlObject
let ipv4Validated // used to build playerUrlObject
let playerUrlObject // for node.on etc
if (isTruthyPropertyStringNotEmpty(configurationNode, ['ipaddress'])) {
// Case A: using ip address or DNS name(must be resolved). SONOS does not accept DNS.
const hostname = configurationNode.ipaddress // ipv4 address or dns
if (REGEX_IP.test(hostname)) { // not the favorite but should be here before DNS
ipv4Validated = hostname
} else if (REGEX_DNS.test(hostname)) { // favorite option
try { // redundant - just to get custom error message
const ipv4Array = await dnsPromises.resolve4(hostname)
// dnsPromise returns an array with all data
ipv4Validated = ipv4Array[0]
} catch (err) {
throw new Error(`${PACKAGE_PREFIX} could not resolve dns name >>${hostname}`)
}
} else {
throw new Error(`${PACKAGE_PREFIX} ipv4/DNS field is invalid >> >>${hostname}`)
}
debug('using ip address >>%s', ipv4Validated)
playerUrlObject = new URL(`http://${ipv4Validated}:${port}`)
// If box is ticked it is checked whether that IP address is reachable (http request)
if (!configuration.avoidCheckPlayerAvailability) {
const isOnline = await isOnlineSonosPlayer(playerUrlObject, TIMEOUT_HTTP_REQUEST)
if (!isOnline)
throw new Error(`${PACKAGE_PREFIX} device not reachable/rejected >>${ipv4Validated}`)
}
// => ip is valid or no check requested
} else if (isTruthyPropertyStringNotEmpty(configurationNode, ['serialnum'])) {
// Case B: using serial number and start a discover
const serialNb = configurationNode.serialnum
if (!REGEX_SERIAL.test(serialNb))
throw new Error(`${PACKAGE_PREFIX} serial number invalid >>${serialNb}`)
try { // redundant - just to get custom error message
ipv4Validated = await discoverSpecificSonosPlayerBySerial(serialNb)
debug('found ip address >>%s', ipv4Validated)
} catch (err) {
// discovery failed - either no player found or no matching serial number
throw new Error(`${PACKAGE_PREFIX} discovery failed`)
}
} else {
throw new Error(`${PACKAGE_PREFIX} serial number/ipv4//DNS name are missing/invalid`)
}
// subscribe and set processing function (all invalid options are done ahead)
try { // redundant - only to get custom error message
node.on('input', (msg, send, done) => {
debug('msg received >>%s', thisMethodName)
processInputMsg(node, configuration, msg, ipv4Validated)
// processInputMsg sets msg.nrcspCmd to current command
.then((msgUpdate) => {
Object.assign(msg, msgUpdate) // Defines the output message
send(msg) // incompatibility to 0.x
done() // incompatibility to 0.x
node.status({ 'fill': 'green', 'shape': 'dot', 'text': `ok:${msg.nrcspCmd}` })
debug('OK: %s', msg.nrcspCmd)
})
.catch((err) => {
let lastFunction = 'processInputMsg'
if (msg.nrcspCmd && typeof msg.nrcspCmd === 'string') {
lastFunction = msg.nrcspCmd
}
failureV2(node, msg, done, err, lastFunction)
})
})
debug('successfully subscribed - node.on')
const success = (configuration.avoidCheckPlayerAvailability
? 'ok:ready - maybe not online' : 'ok:ready')
node.status({ fill: 'green', shape: 'dot', text: success })
} catch (err) {
throw new Error(`${PACKAGE_PREFIX} could not subscribe to msg`)
}
})(config, configNode, this) // handle any error of async wrapper but not the messages
.catch((err) => {
debug(`Error: ${thisMethodName} >>%s`,
JSON.stringify(err, Object.getOwnPropertyNames(err)))
if (isTruthyPropertyStringNotEmpty(err, ['message'])) {
if (err.message.startsWith(PACKAGE_PREFIX)) { // means custom error messages
// my custom error messages from throws
this.status({ fill: 'red', shape: 'dot', text: `error: ${err.message}` })
} else {
this.status({
fill: 'red', shape: 'dot',
text: 'error: any not handled in create node - see debug'
})
}
}
})
}
/**
* Validate sonos player object, command and dispatch further.
* @param {object} node current node
* @param {object} config current node configuration
* @param {string} config.command the command from node dialog
* @param {string} config.state the state from node dialog
* @param {object} msg incoming message
* @param {string} urlHost host of SONOS player such as 192.168.178.37
*
* Creates also msg.nrcspCmd with the used command in lower case.
* Modifies msg.payload if set in dialog or for output!
*
* @returns {promise} All commands have to return a promise - object
* example: returning {} means msg is not modified (except msg.nrcspCmd)
* example: returning { 'payload': true } means
* the original msg.payload will be modified and set to true.
*/
async function processInputMsg (node, config, msg, urlHost) {
debug('command:%s', 'processInputMsg')
const tsPlayer = new SonosDevice(urlHost)
if (!isTruthy(tsPlayer)) {
throw new Error(`${PACKAGE_PREFIX} tsPlayer is undefined`)
}
if (!(isTruthyPropertyStringNotEmpty(tsPlayer, ['host'])
&& isTruthyProperty(tsPlayer, ['port']))) {
throw new Error(`${PACKAGE_PREFIX} tsPlayer ip address or port is missing `)
}
// needed for my extension in Extensions
tsPlayer.urlObject = new URL(`http://${tsPlayer.host}:${tsPlayer.port}`)
// Command, required: node dialog overrules msg, store lowercase version in msg.nrcspCmd
let command
if (config.command !== 'message') { // Command specified in node dialog
command = config.command
} else {
if (!isTruthyPropertyStringNotEmpty(msg, ['topic'])) {
throw new Error(`${PACKAGE_PREFIX} command is undefined/invalid`)
}
command = String(msg.topic).toLowerCase()
// You may omit group. prefix - so we add it here
const REGEX_PREFIX = /^(household|group|player|joiner)/
if (!REGEX_PREFIX.test(command)) {
command = `group.${command}`
}
}
if (!Object.prototype.hasOwnProperty.call(COMMAND_TABLE_UNIVERSAL,
command)) {
throw new Error(`${PACKAGE_PREFIX} command is invalid >>${command} `)
}
msg.nrcspCmd = command // Store command, is used in playerSetEQ, playerGetEQ, groupPlayLibrary*
// State: node dialog overrules msg.
let state
if (config.state) { // Payload specified in node dialog
state = RED.util.evaluateNodeProperty(config.state, config.stateType, node)
if (typeof state === 'string') {
if (state !== '') {
msg.payload = state
}
} else if (typeof state === 'number') {
if (state !== '') {
msg.payload = state
}
} else if (typeof state === 'boolean') {
msg.payload = state
}
}
return COMMAND_TABLE_UNIVERSAL[msg.nrcspCmd](msg, tsPlayer)
}
//
// COMMANDS
//
/**
* Coordinator delegate coordination of group. New player must be in same group!
* @param {object} msg incoming message + msg.nrcspCmd
* @param {string} msg.payload new coordinator name - must be in same group and different
* @param {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise<object>} {}
*
* @throws {error} 'Player is not coordinator', 'Could not find player name in current group'
* 'New coordinator must be different from current'
* @throws {error} all methods
*/
async function coordinatorDelegateCoordination (msg, tsPlayer) {
debug('command:%s', 'coordinatorDelegateCoordination')
// Payload new player name is required.
const validPlayerName = validRegex(msg, 'payload', REGEX_ANYCHAR, 'player name')
const validated = await validatedGroupProperties(msg)
const groupData = await getGroupCurrent(tsPlayer, validated.playerName)
// Player must be coordinator to be able to delegate
if (groupData.playerIndex !== 0) {
throw new Error(`${PACKAGE_PREFIX} Player is not coordinator`)
}
// Check PlayerName is in group and not same as old coordinator
const indexNewCoordinator = groupData.members.findIndex((p) => p.playerName === validPlayerName)
if (indexNewCoordinator === -1) {
throw new Error(`${PACKAGE_PREFIX} Could not find player name in current group`)
}
if (indexNewCoordinator === 0) {
throw new Error(`${PACKAGE_PREFIX} New coordinator must be different from current`)
}
const ts1Player = new SonosDevice(groupData.members[groupData.playerIndex].urlObject.hostname)
await ts1Player.AVTransportService.DelegateGroupCoordinationTo(
{
'InstanceID': 0, 'NewCoordinator': groupData.members[indexNewCoordinator].uuid,
'RejoinGroup': true
})
return {}
}
/**
* Adjust group volume and outputs new volume.
* @param {object} msg incoming message
* {(string|number)} msg.payload -100 to + 100, integer
* {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise<object>} property newVolume as string, range 0 ... 100
*
* @throws {error} all methods
*/
async function groupAdjustVolume (msg, tsPlayer) {
debug('command:%s', 'groupAdjustVolume')
// required: msg.payload volume
const adjustVolume = validPropertyRequiredInteger(msg, 'payload', -100, +100)
// create new sonos-ts player object with coordinator
const hostname = await getCoordinatorHostname(msg, tsPlayer)
const tsCoordinator = new SonosDevice(hostname)
const result = await tsCoordinator.GroupRenderingControlService.SetRelativeGroupVolume(
{ 'InstanceID': 0, 'Adjustment': adjustVolume })
const newVolume = result.NewVolume
return { newVolume } // caution newVolume property!
}
/**
* Cancel group sleep timer.
* @param {object} msg incoming message
* @param {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise<object>} {}
*
* @throws {error} all methods
*/
async function groupCancelSleeptimer (msg, tsPlayer) {
debug('command:%s', 'groupCancelSleeptimer')
const validated = await validatedGroupProperties(msg)
const groupData = await getGroupCurrent(tsPlayer, validated.playerName)
const tsCoordinator = new SonosDevice(groupData.members[0].urlObject.hostname)
await tsCoordinator.AVTransportService.ConfigureSleepTimer(
{ 'InstanceID': 0, 'NewSleepTimerDuration': '' })
return {}
}
/**
* Clear queue.
* @param {object} msg incoming message
* @param {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise<object>} {}
*
* @throws {error} all methods
*/
async function groupClearQueue (msg, tsPlayer) {
debug('command:%s', 'groupClearQueue')
const validated = await validatedGroupProperties(msg)
const groupData = await getGroupCurrent(tsPlayer, validated.playerName)
const tsCoordinator = new SonosDevice(groupData.members[0].urlObject.hostname)
tsCoordinator.urlObject = groupData.members[0].urlObject
await tsCoordinator.AVTransportService.RemoveAllTracksFromQueue()
return {}
}
/**
* Create a snapshot of the given group of players.
* @param {object} msg incoming message
* @param {boolean} [msg.snapVolumes = false] will capture the players volumes
* @param {boolean} [msg.snapMutestates = false] will capture the players mutestates
* @param {boolean} [msg.sonosPlaylistName = null] will capture the players mutestates
* @param {string} [msg.playerName = using tssPlayer] SONOS-Playername
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise<object>} property payload is object see createGroupSnapshot
*
* @throws {error} 'snapVolumes (msg.snapVolumes) is not boolean',
* 'snapMutestates (msg.snapMutestates) is not boolean'
* @throws {error} all methods
*/
async function groupCreateSnapshot (msg, tsPlayer) {
debug('command:%s', 'groupCreateSnapshot')
// Validate msg properties
const options
= { 'snapVolumes': false, 'snapMutestates': false, sonosPlaylistName: null } // defaults
if (isTruthyProperty(msg, ['snapVolumes'])) {
if (typeof msg.snapVolumes !== 'boolean') {
throw new Error(`${PACKAGE_PREFIX}: snapVolumes (snapVolumes) is not boolean`)
}
options.snapVolumes = msg.snapVolumes
}
if (isTruthyProperty(msg, ['snapMutestates'])) {
if (typeof msg.snapMutestates !== 'boolean') {
throw new Error(`${PACKAGE_PREFIX}: snapMutestates (snapMutestates) is not boolean`)
}
options.snapMutestates = msg.snapMutestates
}
if (isTruthyProperty(msg, ['sonosPlaylistName'])) {
if (typeof msg.sonosPlaylistName !== 'string') {
throw new Error(`${PACKAGE_PREFIX}: sonosPlaylistName is not string`)
}
if (!REGEX_ANYCHAR.test(msg.sonosPlaylistName)) {
// eslint-disable-next-line max-len
throw new Error(`${PACKAGE_PREFIX}: sonosPlaylistName name has wrong syntax`)
}
options.sonosPlaylistName = msg.sonosPlaylistName
}
// Validate msg.playerName
const validated = await validatedGroupProperties(msg)
const groupData = await getGroupCurrent(tsPlayer, validated.playerName)
const payload = await createGroupSnapshot(groupData.members, options)
return { payload }
}
/**
* Group create volume snap shot (used for adjust group volume)
* @param {object} msg incoming message
* @param {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise<object>} {}
*
* @throws {error} all methods
*/
async function groupCreateVolumeSnapshot (msg, tsPlayer) {
debug('command:%s', 'groupCreateVolumeSnapshot')
const validated = await validatedGroupProperties(msg)
const groupData = await getGroupCurrent(tsPlayer, validated.playerName)
const tsCoordinator = new SonosDevice(groupData.members[0].urlObject.hostname)
await tsCoordinator.GroupRenderingControlService.SnapshotGroupVolume(
{ 'InstanceID': 0 })
return {}
}
/**
* Get group transport actions.
* @param {object} msg incoming message
* @param {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise<object>} property payload is string csv transportActions
*
* @throws {error} all methods
*/
// eslint-disable-next-line max-len
async function groupGetTransportActions (msg, tsPlayer) {
debug('command:%s', 'groupGetTransportActions')
const validated = await validatedGroupProperties(msg)
const groupData = await getGroupCurrent(tsPlayer, validated.playerName)
const tsCoordinator = new SonosDevice(groupData.members[0].urlObject.hostname)
const result = await tsCoordinator.AVTransportService.GetCurrentTransportActions(
{ 'InstanceID': 0 })
const payload = result.Actions
return { payload }
}
/**
* Get group crossfade mode.
* @param {object} msg incoming message
* @param {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise<object>} property payload string on|off
*
* @throws {error} all methods
*/
async function groupGetCrossfadeMode (msg, tsPlayer) {
debug('command:%s', 'groupGetCrossfadeMode')
const validated = await validatedGroupProperties(msg)
const groupData = await getGroupCurrent(tsPlayer, validated.playerName)
const tsCoordinator = new SonosDevice(groupData.members[0].urlObject.hostname)
const result = await tsCoordinator.AVTransportService.GetCrossfadeMode(
{ 'InstanceID': 0 })
const payload = (result.CrossfadeMode ? 'on' : 'off')
return { payload }
}
/**
* Get array of group member - this group.
* @param {object} msg incoming message
* @param {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise<object>} property payload is GroupMember[]
*
* @throws {error} all methods
*/
async function groupGetMembers (msg, tsPlayer) {
debug('command:%s', 'groupGetMembers')
const validated = await validatedGroupProperties(msg)
const groupData = await getGroupCurrent(tsPlayer, validated.playerName)
return { 'payload': groupData.members }
}
/**
* Get group mute.
* @param {object} msg incoming message
* @param {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise<object>} property payload string on|off
*
* @throws {error} all methods
*/
async function groupGetMute (msg, tsPlayer) {
debug('command:%s', 'groupGetMute')
const validated = await validatedGroupProperties(msg)
const groupData = await getGroupCurrent(tsPlayer, validated.playerName)
const tsCoordinator = new SonosDevice(groupData.members[0].urlObject.hostname)
const result = await tsCoordinator.GroupRenderingControlService.GetGroupMute(
{ 'InstanceID': 0 })
const payload = (result.CurrentMute ? 'on' : 'off')
return { payload }
}
/**
* Get the playback state of that group, the specified player belongs to.
* @param {object} msg incoming message
* @param {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise<object>} property payload is string state
* state: { 'stopped', 'playing', 'paused_playback', 'transitioning', 'no_media_present' }
*
* @throws {error} all methods
*/
async function groupGetPlaybackstate (msg, tsPlayer) {
debug('command:%s', 'groupGetPlaybackstate')
const validated = await validatedGroupProperties(msg)
const groupData = await getGroupCurrent(tsPlayer, validated.playerName)
const tsCoordinator = new SonosDevice(groupData.members[0].urlObject.hostname)
tsCoordinator.urlObject = groupData.members[0].urlObject
const transportInfoObject = await tsCoordinator.AVTransportService.GetTransportInfo({
InstanceID: 0
})
const payload = transportInfoObject.CurrentTransportState.toLowerCase()
return { payload }
}
/**
* Get group SONOS queue - the SONOS queue of the coordinator.
* @param {object} msg incoming message
* @param {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise<object>} property payload is array of queue items as object.
*
* @throws {error} all methods
*/
async function groupGetQueue (msg, tsPlayer) {
debug('command:%s', 'groupGetQueue')
const validated = await validatedGroupProperties(msg)
const groupData = await getGroupCurrent(tsPlayer, validated.playerName)
const tsCoordinator = new SonosDevice(groupData.members[0].urlObject.hostname)
tsCoordinator.urlObject = groupData.members[0].urlObject
const payload = await getSonosQueueV2(tsCoordinator, QUEUE_REQUESTS_MAXIMUM)
return { payload }
}
/**
* Get group SONOS queue length - the SONOS queue of the coordinator.
* @param {object} msg incoming message
* @param {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise<object>} property payload is array of queue items as object.
*
* @throws {error} all methods
*/
async function groupGetQueueLength (msg, tsPlayer) {
debug('command:%s', 'groupGetQueueLength')
const validated = await validatedGroupProperties(msg)
const groupData = await getGroupCurrent(tsPlayer, validated.playerName)
const tsCoordinator = new SonosDevice(groupData.members[0].urlObject.hostname)
tsCoordinator.urlObject = groupData.members[0].urlObject
// Get queue length, Q:0 = SONOS-Queue // browseQueue.TotalMatches
const browseQueue = await tsCoordinator.ContentDirectoryService.Browse({
'ObjectID': 'Q:0', 'BrowseFlag': 'BrowseDirectChildren', 'Filter': '*',
'StartingIndex': 0, 'RequestedCount': 1, 'SortCriteria': ''
})
const payload = browseQueue.TotalMatches
return { payload }
}
/**
* Get group sleeptimer.
* @param {object} msg incoming message
* @param {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise<object>} property payload string hh:mm:ss
*
* @throws {error} all methods
*/
async function groupGetSleeptimer (msg, tsPlayer) {
debug('command:%s', 'groupGetSleeptimer')
const validated = await validatedGroupProperties(msg)
const groupData = await getGroupCurrent(tsPlayer, validated.playerName)
const tsCoordinator = new SonosDevice(groupData.members[0].urlObject.hostname)
const result = await tsCoordinator.AVTransportService.GetRemainingSleepTimerDuration(
{ 'InstanceID': 0 })
let payload = 'none'
if (isTruthyProperty(result, ['RemainingSleepTimerDuration'])) {
payload = result.RemainingSleepTimerDuration
}
return { payload }
}
/**
* Get state (see return) of that group, the specified player belongs to.
* @param {object} msg incoming message
* @param {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer sonos-ts player with JavaScript build-in URL urlObject- as default
*
* @returns {promise<object>} property payload is string state
*
* state: 'stopped' | 'playing' | 'paused_playback' | 'transitioning' | 'no_media_present' }
* queue mode: 'NORMAL', 'REPEAT_ONE', 'REPEAT_ALL', 'SHUFFLE',
* 'SHUFFLE_NOREPEAT', 'SHUFFLE_REPEAT_ONE'
*
* @throws {error} 'current MediaInfo is invalid', 'PlayMode is invalid/missing/not string'
* @throws {error} all methods
*/
async function groupGetState (msg, tsPlayer) {
debug('command:%s', 'groupGetState')
const validated = await validatedGroupProperties(msg)
const groupData = await getGroupCurrent(tsPlayer, validated.playerName)
const tsCoordinator = new SonosDevice(groupData.members[0].urlObject.hostname)
tsCoordinator.urlObject = groupData.members[0].urlObject
const playbackstateObject = await tsCoordinator.AVTransportService.GetTransportInfo({
InstanceID: 0
})
const playbackstate = playbackstateObject.CurrentTransportState.toLowerCase()
let result
result = await tsCoordinator.GroupRenderingControlService.GetGroupMute(
{ 'InstanceID': 0 })
const muteState = (result.CurrentMute ? 'on' : 'off')
result = await tsCoordinator.GroupRenderingControlService.GetGroupVolume(
{ 'InstanceID': 0 })
const volume = result.CurrentVolume
// Get current media data and extract queueActivated
const mediaData = await tsCoordinator.AVTransportService.GetMediaInfo()
if (!isTruthy(mediaData)) {
throw new Error(`${PACKAGE_PREFIX} current MediaInfo is invalid`)
}
const uri
= (isTruthyPropertyStringNotEmpty(mediaData, ['CurrentURI']) ? mediaData.CurrentURI : '')
const queueActivated = uri.startsWith('x-rincon-queue')
const tvActivated = uri.startsWith('x-sonos-htastream')
// Queue mode is in parameter PlayMode
result = await tsCoordinator.AVTransportService.GetTransportSettings(
{ 'InstanceID': 0 })
if (!isTruthyPropertyStringNotEmpty(result, ['PlayMode'])) {
throw new Error(`${PACKAGE_PREFIX}: PlayMode is invalid/missing/not string`)
}
const queueMode = result.PlayMode
return {
'payload': {
playbackstate,
'coordinatorName': groupData.members[0].playerName, // 0 stands for coordinator
volume,
muteState,
tvActivated,
queueActivated,
queueMode,
'members': groupData.members,
'size': groupData.members.length,
'id': groupData.groupId
}
}
}
/**
* Get group media and position(track) info.
* @param {object} msg incoming message
* @param {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise<object>} property payload is object: media: {object}, trackInfo: {object},
* positionInfo: {object}, queueActivated: true/false
*
* @throws {error} 'current position data is invalid',
* @throws {error} all methods
*/
async function groupGetTrackPlus (msg, tsPlayer) {
debug('command:%s', 'groupGetTrackPlus')
const validated = await validatedGroupProperties(msg)
const groupData = await getGroupCurrent(tsPlayer, validated.playerName)
const tsCoordinator = new SonosDevice(groupData.members[0].urlObject.hostname)
tsCoordinator.urlObject = groupData.members[0].urlObject
// Get current media data and extract queueActivated
const mediaData = await tsCoordinator.AVTransportService.GetMediaInfo()
if (!isTruthy(mediaData)) {
throw new Error(`${PACKAGE_PREFIX} current media data is invalid`)
}
const uri
= (isTruthyPropertyStringNotEmpty(mediaData, ['CurrentURI']) ? mediaData.CurrentURI : '')
const queueActivated = uri.startsWith('x-rincon-queue')
let serviceId = await getMusicServiceId(uri)
// Get station uri for all "x-sonosapi-stream"
// eslint-disable-next-line max-len
const stationArtUri = (uri.startsWith('x-sonosapi-stream') ? `${tsCoordinator.urlObject.origin}/getaa?s=1&u=${uri}` : '')
// Get current position data
const positionData = await tsCoordinator.AVTransportService.GetPositionInfo()
if (!isTruthy(positionData)) {
throw new Error(`${PACKAGE_PREFIX} current position data is invalid`)
}
if (isTruthyPropertyStringNotEmpty(positionData, ['TrackURI'])) {
const trackUri = positionData.TrackURI
if (serviceId === '') {
serviceId = await getMusicServiceId(trackUri)
}
}
const serviceName = getMusicServiceName(serviceId)
let artist = ''
let title = ''
if (!isTruthyPropertyStringNotEmpty(positionData, ['TrackMetaData', 'Artist'])) {
// Missing artist: TuneIn provides artist and title in Title field
if (!isTruthyPropertyStringNotEmpty(positionData, ['TrackMetaData', 'Title'])) {
debug('Warning: no artist, no title %s', JSON.stringify(positionData.TrackMetaData))
} else if (positionData.TrackMetaData.Title.indexOf(' - ') > 0) {
debug('split data to artist and title')
artist = positionData.TrackMetaData.Title.split(' - ')[0]
title = positionData.TrackMetaData.Title.split(' - ')[1]
} else {
debug('Warning: invalid combination artist title receive')
title = positionData.TrackMetaData.Title
}
} else {
artist = positionData.TrackMetaData.Artist
if (!isTruthyPropertyStringNotEmpty(positionData, ['TrackMetaData', 'Title'])) {
// Title unknown
} else {
debug('got artist and title')
title = positionData.TrackMetaData.Title
}
}
// eslint-disable-next-line max-len
const album = (isTruthyPropertyStringNotEmpty(positionData, ['TrackMetaData', 'Album']) ? positionData.TrackMetaData.Album : '')
let artUri = ''
if (isTruthyPropertyStringNotEmpty(positionData, ['TrackMetaData', 'AlbumArtUri'])) {
artUri = positionData.TrackMetaData.AlbumArtUri
if (typeof artUri === 'string' && artUri.startsWith('/getaa')) {
artUri = tsCoordinator.urlObject.origin + artUri
}
}
return {
'payload': {
artist, album, title, artUri, mediaData, queueActivated,
serviceId, serviceName, stationArtUri, positionData
}
}
}
/**
* Get group volume.
* @param {object} msg incoming message
* @param {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise<object>} property payload is string range 0 100
*
* @throws {error} all methods
*/
async function groupGetVolume (msg, tsPlayer) {
debug('command:%s', 'groupGetVolume')
const validated = await validatedGroupProperties(msg)
const groupData = await getGroupCurrent(tsPlayer, validated.playerName)
const tsCoordinator = new SonosDevice(groupData.members[0].urlObject.hostname)
const result = await tsCoordinator.GroupRenderingControlService.GetGroupVolume(
{ 'InstanceID': 0 })
const payload = result.CurrentVolume
return { payload }
}
/**
* Play next track in that group.
* @param {object} msg incoming message
* @param {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise<object>} {}
*
* @throws {error} all methods
*/
async function groupNextTrack (msg, tsPlayer) {
debug('command:%s', 'groupNextTrack')
const validated = await validatedGroupProperties(msg)
const groupData = await getGroupCurrent(tsPlayer, validated.playerName)
const tsCoordinator = new SonosDevice(groupData.members[0].urlObject.hostname)
await tsCoordinator.Next()
return {}
}
/**
* Pause playing in that group, the specified player belongs to.
* @param {object} msg incoming message
* @param {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise<object>} {}
*
* @throws {error} all methods
*/
async function groupPause (msg, tsPlayer) {
debug('command:%s', 'groupPause')
const validated = await validatedGroupProperties(msg)
const groupData = await getGroupCurrent(tsPlayer, validated.playerName)
const tsCoordinator = new SonosDevice(groupData.members[0].urlObject.hostname)
await tsCoordinator.Pause()
return {}
}
/**
* Starts playing content. Content must have been set before.
* @param {object} msg incoming message
* @param {number/string} [msg.volume] volume - if missing do not touch volume
* @param {number} [msg.sameVolume=true] shall all players play at same volume level.
* @param {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise<object>} {}
*
* @throws {error} 'msg.sameVolume is nonsense: player is standalone'
* @throws {error} all methods
*/
async function groupPlay (msg, tsPlayer) {
debug('command:%s', 'groupPlay')
// Validate msg.playerName, msg.volume, msg.sameVolume -error are thrown
const validated = await validatedGroupProperties(msg)
const groupData = await getGroupCurrent(tsPlayer, validated.playerName)
if (validated.sameVolume === false && groupData.members.length === 1) {
throw new Error(`${PACKAGE_PREFIX} msg.sameVolume is nonsense: player is standalone`)
}
const tsCoordinator = new SonosDevice(groupData.members[0].urlObject.hostname)
tsCoordinator.urlObject = groupData.members[0].urlObject // to be on the save side
await tsCoordinator.Play()
if (validated.volume !== -1) {
if (validated.sameVolume) { // set all player
for (let i = 0; i < groupData.members.length; i++) {
const tsPlayer = new SonosDevice(groupData.members[i].urlObject.hostname)
await tsPlayer.SetVolume(validated.volume)
}
} else { // set only one player
const tsPlayer = new SonosDevice(
groupData.members[groupData.playerIndex].urlObject.hostname)
await tsPlayer.SetVolume(validated.volume)
}
}
return {}
}
/**
* Play playlist, album, artist, track from Music Library on
* group (combination of export, play.export)
* @param {object} msg incoming message
* @param {string} msg.payload search string, part of item title
* @param {string} msg.nrcspCmd identify the item type
* @param {number/string} [msg.volume] volume - if missing do not touch volume
* @param {boolean} [msg.sameVolume=true] shall all players play at same volume level.
* @param {boolean} [msg.clearQueue=true] if true and export.queue = true the queue is cleared.
* @param {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer node-sonos player with urlObject - as default
*
* @returns {promise<object>} {}
*
* @throws {error} 'msg.sameVolume is nonsense: player is standalone'
* @throws {error} all methods
*/
async function groupPlayLibraryItem (msg, tsPlayer) {
debug('command:%s', 'groupPlayLibraryItem')
const validSearch
= validRegex(msg, 'payload', REGEX_ANYCHAR, 'search string')
let type = ''
if (msg.nrcspCmd === 'group.play.library.playlist') {
type = 'A:PLAYLISTS:'
} else if (msg.nrcspCmd === 'group.play.library.album') {
type = 'A:ALBUM:'
} else if (msg.nrcspCmd === 'group.play.library.artist') {
type = 'A:ARTIST:'
} else if (msg.nrcspCmd === 'group.play.library.track') {
type = 'A:TRACKS:'
} else {
// Can not happen
}
const list = await getMusicLibraryItemsV2(type, validSearch, ML_REQUESTS_MAXIMUM, tsPlayer)
if (list.length === 0) {
throw new Error(`${PACKAGE_PREFIX} no matching item found`)
}
const exportData = {
'uri': replaceAposColon(list[0].uri),
'metadata': list[0].uri,
'queue': true
}
// hand over to play.export
// Validate msg.playerName, msg.volume, msg.sameVolume -error are thrown
const validated = await validatedGroupProperties(msg)
const groupData = await getGroupCurrent(tsPlayer, validated.playerName)
if (validated.sameVolume === false && groupData.members.length === 1) {
throw new Error(`${PACKAGE_PREFIX} msg.sameVolume is nonsense: player is standalone`)
}
const tsCoordinator = new SonosDevice(groupData.members[0].urlObject.hostname)
tsCoordinator.urlObject = groupData.members[0].urlObject
if (validated.clearQueue) {
await tsCoordinator.AVTransportService.RemoveAllTracksFromQueue()
}
await tsCoordinator.AVTransportService.AddURIToQueue({
InstanceID: 0, EnqueuedURI: exportData.uri, EnqueuedURIMetaData: exportData.metadata,
DesiredFirstTrackNumberEnqueued: 0, EnqueueAsNext: true
})
await tsCoordinator.SwitchToQueue()
if (validated.volume !== -1) {
if (validated.sameVolume) { // set all player
for (let i = 0; i < groupData.members.length; i++) {
const tsPlayer = new SonosDevice(groupData.members[i].urlObject.hostname)
await tsPlayer.SetVolume(validated.volume)
}
} else { // set only one player
const tsPlayer = new SonosDevice(
groupData.members[groupData.playerIndex].urlObject.hostname)
await tsPlayer.SetVolume(validated.volume)
}
}
await tsCoordinator.Play()
return {}
}
/**
* Play title from My Sonos on group (combination of export.item, play.export)
* @param {object} msg incoming message
* @param {string} msg.payload search string, part of title in My Sonos
* @param {number/string} [msg.volume] volume - if missing do not touch volume
* @param {boolean} [msg.sameVolume=true] shall all players play at same volume level.
* @param {boolean} [msg.clearQueue=true] if true and export.queue = true the queue is cleared.
* @param {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer node-sonos player with urlObject - as default
*
* @returns {promise<object>} {}
*
* @throws {error} 'msg.sameVolume is nonsense: player is standalone'
* @throws {error} all methods
*/
async function groupPlayMySonos (msg, tsPlayer) {
debug('command:%s', 'groupPlayMySonos')
const validSearch
= validRegex(msg, 'payload', REGEX_ANYCHAR, 'search string')
const mySonosItems = await getMySonos(tsPlayer)
if (!isTruthy(mySonosItems)) {
throw new Error(`${PACKAGE_PREFIX} could not find any My Sonos items`)
}
// find in title property (findIndex returns -1 if not found)
const foundIndex = mySonosItems.findIndex((item) => {
return (item.title.includes(validSearch))
})
if (foundIndex === -1) {
throw new Error(`${PACKAGE_PREFIX} no title matching search string >>${validSearch}`)
}
const exportData = {
'uri': mySonosItems[foundIndex].uri,
'metadata': mySonosItems[foundIndex].metadata,
'queue': (mySonosItems[foundIndex].processingType === 'queue')
}
// hand over to play.export
// Validate msg.playerName, msg.volume, msg.sameVolume -error are thrown
const validated = await validatedGroupProperties(msg)
const groupData = await getGroupCurrent(tsPlayer, validated.playerName)
if (validated.sameVolume === false && groupData.members.length === 1) {
throw new Error(`${PACKAGE_PREFIX} msg.sameVolume is nonsense: player is standalone`)
}
const tsCoordinator = new SonosDevice(groupData.members[0].urlObject.hostname)
tsCoordinator.urlObject = groupData.members[0].urlObject
if (exportData.queue) {
if (validated.clearQueue) {
await tsCoordinator.AVTransportService.RemoveAllTracksFromQueue()
}
await tsCoordinator.AVTransportService.AddURIToQueue({
InstanceID: 0, EnqueuedURI: exportData.uri, EnqueuedURIMetaData: exportData.metadata,
DesiredFirstTrackNumberEnqueued: 0, EnqueueAsNext: true
})
await tsCoordinator.SwitchToQueue()
} else {
await tsCoordinator.AVTransportService.SetAVTransportURI({
InstanceID: 0, CurrentURI: exportData.uri, CurrentURIMetaData: exportData.metadata
})
}
if (validated.volume !== -1) {
if (validated.sameVolume) { // set all player
for (let i = 0; i < groupData.members.length; i++) {
const tsPlayer = new SonosDevice(groupData.members[i].urlObject.hostname)
await tsPlayer.SetVolume(validated.volume)
}
} else { // set only one player
const tsPlayer = new SonosDevice(
groupData.members[groupData.playerIndex].urlObject.hostname)
await tsPlayer.SetVolume(validated.volume)
}
}
await tsCoordinator.Play()
return {}
}
/**
* Play data being exported form My Sonos (uri/metadata) on a current group
* @param {object} msg incoming message
* @param {string} msg.payload content to be played
* @param {string} msg.payload.uri uri to be played/queued (not changed)
* @param {boolean} msg.payload.queue indicator: has to be queued
* @param {string} [msg.payload..metadata] metadata (not changed)
* @param {number/string} [msg.volume] volume - if missing do not touch volume
* @param {boolean} [msg.sameVolume=true] shall all players play at same volume level.
* @param {boolean} [msg.clearQueue=true] if true and export.queue = true the queue is cleared.
* @param {string} [msg.playerName = using tsPlayer] SONOS-Playername
* @param {object} tsPlayer node-sonos player with urlObject - as default
*
* @returns {promise<object>} {}
*
* @throws {error} 'uri is missing', 'queue identifier is missing',
* 'msg.sameVolume is nonsense: player is standalone'
* @throws {error} all methods
*/
async function groupPlayExport (msg, tsPlayer) {
debug('command:%s', 'groupPlayExport')
// Simple validation of export and activation
const exportData = msg.payload
if (!isTruthyPropertyStringNotEmpty(exportData, ['uri'])) {
throw new Error(`${PACKAGE_PREFIX} uri is missing`)
}
if (!isTruthyProperty(exportData, ['queue