node-red-contrib-sonos-plus
Version:
A set of Node-RED nodes to control SONOS player in your local network.
484 lines (422 loc) • 18.9 kB
JavaScript
/**
* All functions provided by My Sonos node. My Sonos node handles Music-Library and My-Sonos.
* My Sonos also holds SONOS-Playlist.
*
* @module MySonos
*
* @author Henning Klages
*/
const {
PACKAGE_PREFIX, REGEX_ANYCHAR, REGEX_ANYCHAR_BLANK, REGEX_IP, REGEX_DNS,
REGEX_SERIAL, ML_REQUESTS_MAXIMUM, TIMEOUT_HTTP_REQUEST
} = require('./Globals.js')
const { discoverSpecificSonosPlayerBySerial } = require('./Discovery.js')
const { getMusicLibraryItemsV2, getMySonos
} = require('./Commands.js')
const { failureV2, isOnlineSonosPlayer, replaceAposColon
} = require('./Extensions.js')
const { isTruthy, isTruthyProperty, isTruthyPropertyStringNotEmpty, validRegex, validToInteger
} = require('./Helper.js')
const { SonosDevice } = require('@svrooij/sonos/lib')
const Dns = require('dns')
const dnsPromises = Dns.promises
const debug = require('debug')(`${PACKAGE_PREFIX}mysonos`)
module.exports = function (RED) {
// function lexical order, ascending
const COMMAND_TABLE_MYSONOS = {
'library.export.album': libraryExportItem,
'library.export.artist': libraryExportItem,
'library.export.playlist': libraryExportItem,
'library.export.track': libraryExportItem,
'library.get.albums': libraryGetItem,
'library.get.artists': libraryGetItem,
'library.get.playlists': libraryGetItem,
'library.get.tracks': libraryGetItem,
'mysonos.export.item': mysonosExportItem,
'mysonos.get.items': mysonosGetItems,
'mysonos.queue.item': mysonosQueueItem,
'mysonos.stream.item': mysonosStreamItem
}
/** Create My Sonos node, get valid ip address, store nodeDialog and subscribe to messages.
* @param {object} config current node configuration data
*/
function SonosManageMySonosNode (config) {
const nodeName = 'mysonos'
// same as in SonosUniversalNode
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 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 mysonos. prefix - so we add it here
const REGEX_PREFIX = /^(mysonos|library)/
if (!REGEX_PREFIX.test(command)) {
command = `mysonos.${command}`
}
}
if (!Object.prototype.hasOwnProperty.call(COMMAND_TABLE_MYSONOS, command)) {
throw new Error(`${PACKAGE_PREFIX} command is invalid >>${command} `)
}
msg.nrcspCmd = command // Store command, used in exportLibrary*
// 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_MYSONOS[command](msg, tsPlayer)
}
//
// COMMANDS
//
/**
* @typedef {object} exportedItem exported data which can be used in group.play.export
* @global
* @property {string} uri the URI to be used in SetAVTransport or AddURIToQeueu
* @property {string} metadata metadata for the uri
* @property {boolean} queue true means use AddURI otherwise SetAVTransport
*/
/** Exports first matching playlist, album, artist, track from Music Library
* @param {object} msg incoming message
* @param {string} msg.payload search string, part of item title
* @param {string} msg.nrcspCmd identify the item type
* @param {object} tsPlayer node-sonos player with urlObject - as default
*
* @returns {promise<exportedItem>}
*
* @throws {error} 'no matching item found'
* @throws {error} all methods
*/
async function libraryExportItem (msg, tsPlayer) {
debug('command:%s', 'libraryExportItem')
// payload title search string is required.
const validSearch = validRegex(msg, 'payload', REGEX_ANYCHAR, 'search string')
let type = ''
if (msg.nrcspCmd === 'library.export.playlist') {
type = 'A:PLAYLISTS:'
} else if (msg.nrcspCmd === 'library.export.album') {
type = 'A:ALBUM:'
} else if (msg.nrcspCmd === 'library.export.artist') {
type = 'A:ARTIST:'
} else if (msg.nrcspCmd === 'library.export.track') {
type = 'A:TRACKS:'
} else {
// Can not happen
}
const list = await getMusicLibraryItemsV2(type, validSearch, ML_REQUESTS_MAXIMUM, tsPlayer)
// select the first item returned
if (list.length === 0) {
throw new Error(`${PACKAGE_PREFIX} no matching item found`)
}
// 2021-08-03 there might be an apos; in uri - to be replaced!
const firstItem = {
'uri': replaceAposColon(list[0].uri),
'metadata': list[0].metadata
}
return { 'payload': { 'uri': firstItem.uri, 'metadata': firstItem.metadata, 'queue': true } }
}
/** Outputs Music-Library item (album, artist, playlist, tarck) as array -
* search string is optional
* @param {object} msg incoming message
* @param {string} [msg.payload] search string, part of title
* @param {string} msg.nrcspCmd identify the item type
* @param {object} tsPlayer node-sonos player with urlObject - as default
*
* @returns {promise} {payload: array of objects: uri metadata queue title artist}
* array may be empty
*
* @throws {error} all methods
*/
async function libraryGetItem (msg, tsPlayer) {
debug('command:%s', 'libraryGetItem')
// payload as title search string is optional.
// eslint-disable-next-line max-len
const validSearch = validRegex(msg, 'payload', REGEX_ANYCHAR_BLANK, 'payload search in title', '')
let type = ''
if (msg.nrcspCmd === 'library.get.playlists') {
type = 'A:PLAYLISTS:'
} else if (msg.nrcspCmd === 'library.get.albums') {
type = 'A:ALBUM:'
} else if (msg.nrcspCmd === 'library.get.artists') {
type = 'A:ARTIST:'
} else if (msg.nrcspCmd === 'library.get.tracks') {
type = 'A:TRACKS:'
} else {
// Can not happen
}
// ML_REQUESTS_MAXIMUM limits the overall number of items
const list = await getMusicLibraryItemsV2(type, validSearch, ML_REQUESTS_MAXIMUM, tsPlayer)
// add ip address to albumUri, processingType, modify uri (apos;)
const payload = list.map(element => {
if (typeof element.artUri === 'string' && element.artUri.startsWith('/getaa')) {
element.artUri = tsPlayer.urlObject.origin + element.artUri
}
element.processingType = 'queue'
element.uri = replaceAposColon(element.uri)
return element
})
return { payload }
}
/** Export first My-Sonos item matching search string.
* @param {object} msg incoming message
* @param {string} msg.payload search string
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise<exportedItem>}
*
* @throws 'could not find any My Sonos items', 'no title matching search string'
* @throws {error} all methods
*
* Info: content validation of mediaType, serviceName
*/
async function mysonosExportItem (msg, tsPlayer) {
debug('command:%s', 'mysonosExportItem')
// payload title search string is required.
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}`)
}
return { 'payload': {
'uri': mySonosItems[foundIndex].uri,
'metadata': mySonosItems[foundIndex].metadata,
'queue': (mySonosItems[foundIndex].processingType === 'queue')
} }
}
/** Outputs array of My-Sonos items as object.
* @param {object} msg incoming message
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise<mySonosItem[]>}
*
* @throws 'could not find any My Sonos items'
* @throws {error} all methods
*/
async function mysonosGetItems (msg, tsPlayer) {
debug('command:%s', 'mysonosGetItems')
const payload = await getMySonos(tsPlayer)
if (!isTruthy(payload)) {
throw new Error(`${PACKAGE_PREFIX} could not find any My Sonos items`)
}
return { payload }
}
/** Queues (aka add) first My-Sonos item matching search string.
* @param {object} msg incoming message
* @param {string} msg.payload search string
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise} {}
*
* @throws 'could not find any My Sonos items', 'no title matching search string'
* @throws {error} all methods
*
*/
async function mysonosQueueItem (msg, tsPlayer) {
debug('command:%s', 'mysonosQueueItem')
// payload title search string is required.
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, findIndex returns -1 if not found
const foundIndex = mySonosItems.findIndex((item) => {
return (item.title.includes(validSearch) && (item.processingType === 'queue'))
})
if (foundIndex === -1) {
throw new Error(`${PACKAGE_PREFIX} no title matching search string >>${validSearch}`)
}
await tsPlayer.AVTransportService.AddURIToQueue(
{ 'InstanceID': 0,
'EnqueuedURI': mySonosItems[foundIndex].uri,
'EnqueuedURIMetaData': mySonosItems[foundIndex].metadata,
'DesiredFirstTrackNumberEnqueued': 0,
'EnqueueAsNext': true
})
return {}
}
/** Stream (aka play) first My-Sonos item matching search string.
* @param {object} msg incoming message
* @param {string} msg.payload search string
* @param {string} msg.volume new volume 0 ..100
* @param {object} tsPlayer sonos-ts player with .urlObject as Javascript build-in URL
*
* @returns {promise} {}
*
* @throws 'could not find any My Sonos items', 'no title matching search string'
* @throws {error} all methods
*/
async function mysonosStreamItem (msg, tsPlayer) {
debug('command:%s', 'mysonosStreamItem')
// payload title search string is required.
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, findIndex returns -1 if not found
const foundIndex = mySonosItems.findIndex((item) => {
return (item.title.includes(validSearch) && (item.processingType === 'stream'))
})
if (foundIndex === -1) {
throw new Error(`${PACKAGE_PREFIX} no title matching search string >>${validSearch}`)
}
await tsPlayer.SetAVTransportURI(mySonosItems[foundIndex].uri)
// change volume if is provided
const newVolume = validToInteger(msg, 'volume', 0, 100, 'volume', -1)
if (newVolume !== -1) {
await tsPlayer.SetVolume(newVolume)
}
tsPlayer.Play()
return {}
}
RED.nodes.registerType('sonos-manage-mysonos', SonosManageMySonosNode)
}