UNPKG

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
/** * 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