├── .eslintrc ├── .gitattributes ├── .gitignore ├── ISSUE_TEMPLATE.md ├── LICENSE.md ├── README.md ├── lib ├── actions │ ├── aldilifeMusic.js │ ├── amazonMusic.js │ ├── appleMusic.js │ ├── bbcSounds.js │ ├── clearqueue.js │ ├── clip.js │ ├── clipall.js │ ├── clippreset.js │ ├── debug.js │ ├── equalizer.js │ ├── favorite.js │ ├── favorites.js │ ├── group.js │ ├── linein.js │ ├── lockvolumes.js │ ├── musicSearch.js │ ├── mute.js │ ├── napster.js │ ├── nextprevious.js │ ├── pandora.js │ ├── pauseall.js │ ├── playlist.js │ ├── playlists.js │ ├── playmode.js │ ├── playpause.js │ ├── preset.js │ ├── queue.js │ ├── reindex.js │ ├── say.js │ ├── sayall.js │ ├── saypreset.js │ ├── seek.js │ ├── services.js │ ├── setavtransporturi.js │ ├── siriusXM.js │ ├── sleep.js │ ├── spotify.js │ ├── state.js │ ├── sub.js │ ├── tunein.js │ ├── volume.js │ └── zones.js ├── helpers │ ├── all-player-announcement.js │ ├── file-duration.js │ ├── http-event-server.js │ ├── is-radio-or-line-in.js │ ├── preset-announcement.js │ ├── require-dir.js │ ├── single-player-announcement.js │ ├── try-download-tts.js │ └── try-load-json.js ├── music_services │ ├── appleDef.js │ ├── deezerDef.js │ ├── libraryDef.js │ └── spotifyDef.js ├── presets-loader.js ├── sirius-channels.json ├── sonos-http-api.js └── tts-providers │ ├── aws-polly.js │ ├── default │ └── google.js │ ├── elevenlabs.js │ ├── mac-os.js │ ├── microsoft.js │ └── voicerss.js ├── package-lock.json ├── package.json ├── presets └── example.json ├── server.js ├── settings.js ├── static ├── clips │ └── sample_clip.mp3 ├── docs │ ├── css │ │ ├── reset.css │ │ └── screen.css │ ├── images │ │ ├── explorer_icons.png │ │ ├── logo_small.png │ │ ├── pet_store_api.png │ │ ├── throbber.gif │ │ └── wordnik_api.png │ ├── index.html │ ├── lib │ │ ├── backbone-min.js │ │ ├── handlebars-1.0.0.js │ │ ├── highlight.7.3.pack.js │ │ ├── jquery-1.8.0.min.js │ │ ├── jquery.ba-bbq.min.js │ │ ├── jquery.slideto.min.js │ │ ├── jquery.wiggle.min.js │ │ ├── shred.bundle.js │ │ ├── shred │ │ │ └── content.js │ │ ├── swagger-client.js │ │ ├── swagger-oauth.js │ │ ├── swagger.js │ │ └── underscore-min.js │ ├── o2c.html │ ├── spec.js │ ├── swagger-ui.js │ └── swagger-ui.min.js ├── index.html ├── missing_api_key.mp3 └── sonos-icon.png └── test_endpoint.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "env": { 4 | "node": true, 5 | "mocha": true 6 | }, 7 | "rules": { 8 | "comma-dangle": 0, 9 | "strict": 0 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | presets.json 3 | presets/ 4 | cache 5 | settings.json 6 | static/tts 7 | .idea 8 | .ntvs_analysis.dat 9 | obj/ 10 | *.njsproj 11 | *.sln 12 | *.pem 13 | *.crt 14 | *.pfx 15 | *.key 16 | 17 | # VS 18 | .vscode 19 | .vscode/launch.json 20 | 21 | # Ignore Mac DS_Store files 22 | .DS_Store 23 | yarn.lock 24 | npm-debug.log 25 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Before you submit an issue 2 | 3 | Search among the current open _and_ closed issues for an answer to your question before submitting an issue! 4 | 5 | If you are looking for assistance with installing or just general questions, reach out on the gitter chat (https://gitter.im/node-sonos-http-api/Lobby) instead of filing an issue. It's easier for me if issues are primarily fokused on bugs and feature requests, and hopefully other people can assist you in the chat. 6 | 7 | If your question is Docker related, please file the issue at the https://github.com/chrisns/docker-node-sonos-http-api and also, make sure you have tested that image before asking a question. This is the only image that has any correlation to this project, and is guaranteed to be up to date. 8 | 9 | If your question has anything to do with Amazon Echo, it is probably better suited at https://github.com/rgraciano/echo-sonos. If you have questions about the requests sent to this library, figure out the exact request that is sent from the Alexa Skill (through the logs) and include that in the issue. 10 | 11 | Thank you! 12 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Jimmy Shimizu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /lib/actions/aldilifeMusic.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | function getMetadata(id, parentUri, type, title) { 3 | return ` 5 | "${title}"${type} 6 | SA_RINCON55303_X_#Svc55303-0-Token`; 7 | } 8 | 9 | function getUri(id, type) { 10 | var uri = { 11 | song: `x-sonos-http:ondemand_track%3a%3atra.${id}%7cv1%7cALBUM%7calb.mp4?sid=216&flags=8224&sn=13`, 12 | album: `x-rincon-cpcontainer:100420ecexplore%3aalbum%3a%3aAlb.${id}` 13 | }; 14 | 15 | return uri[type]; 16 | } 17 | 18 | const CLASSES = { 19 | song: 'object.item.audioItem.musicTrack', 20 | album: 'object.container.album.musicAlbum' 21 | }; 22 | 23 | const METADATA_URI_STARTERS = { 24 | song: '10032020ondemand_track%3a%3atra.', 25 | album: '100420ec' 26 | }; 27 | 28 | const PARENTS = { 29 | song: '100420ecexplore%3a', 30 | album: '100420ecexplore%3aalbum%3a' 31 | }; 32 | 33 | function aldilifeMusic(player, values) { 34 | const action = values[0]; 35 | const trackID = values[1].split(':')[1]; 36 | const type = values[1].split(':')[0]; 37 | var nextTrackNo = 0; 38 | 39 | const metadataID = METADATA_URI_STARTERS[type] + encodeURIComponent(trackID); 40 | const metadata = getMetadata(metadataID, PARENTS[type], CLASSES[type], ''); 41 | const uri = getUri(encodeURIComponent(trackID), type); 42 | 43 | if (action == 'queue') { 44 | return player.coordinator.addURIToQueue(uri, metadata); 45 | } else if (action == 'now') { 46 | nextTrackNo = player.coordinator.state.trackNo + 1; 47 | let promise = Promise.resolve(); 48 | if (player.coordinator.avTransportUri.startsWith('x-rincon-queue') === false) { 49 | promise = promise.then(() => player.coordinator.setAVTransport(`x-rincon-queue:${player.coordinator.uuid}#0`)); 50 | } 51 | 52 | return promise.then(() => { 53 | return player.coordinator.addURIToQueue(uri, metadata, true, nextTrackNo) 54 | .then((addToQueueStatus) => player.coordinator.trackSeek(addToQueueStatus.firsttracknumberenqueued)) 55 | .then(() => player.coordinator.play()); 56 | }); 57 | } else if (action == 'next') { 58 | nextTrackNo = player.coordinator.state.trackNo + 1; 59 | return player.coordinator.addURIToQueue(uri, metadata, true, nextTrackNo); 60 | } 61 | } 62 | 63 | module.exports = function (api) { 64 | api.registerAction('aldilifemusic', aldilifeMusic); 65 | }; 66 | -------------------------------------------------------------------------------- /lib/actions/amazonMusic.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | function getMetadata(id, parentUri, type, title) { 3 | return ` 4 | 5 | "${title}" 6 | ${type} 7 | SA_RINCON51463_X_#Svc51463-0-Token 8 | 9 | `; 10 | } 11 | 12 | function getSongUri(id) { 13 | return `x-sonosapi-hls-static:catalog%2ftracks%2f${id}%2f%3falbumAsin%3dB01JDKZWK0?sid=201&flags=0&sn=4`; 14 | } 15 | 16 | function getAlbumUri(id) { 17 | return `x-rincon-cpcontainer:1004206ccatalog%2falbums%2f${id}%2f%23album_desc?sid=201&flags=8300&sn=4`; 18 | } 19 | 20 | const uriTemplates = { 21 | song: getSongUri, 22 | album: getAlbumUri 23 | }; 24 | 25 | const CLASSES = { 26 | song: 'object.container.album.musicAlbum.#AlbumView', 27 | album: 'object.container.album.musicAlbum' 28 | }; 29 | 30 | const METADATA_URI_STARTERS = { 31 | song: '10030000catalog%2ftracks%2f', 32 | album: '1004206ccatalog' 33 | }; 34 | 35 | const METADATA_URI_ENDINGS = { 36 | song: '%2f%3falbumAsin%3d', 37 | album: '%2f%23album_desc' 38 | }; 39 | 40 | 41 | const PARENTS = { 42 | song: '1004206ccatalog%2falbums%2f', 43 | album: '10052064catalog%2fartists%2f' 44 | }; 45 | 46 | function amazonMusic(player, values) { 47 | const action = values[0]; 48 | const track = values[1]; 49 | const type = track.split(':')[0]; 50 | const trackID = track.split(':')[1]; 51 | 52 | var nextTrackNo = 0; 53 | 54 | const metadataID = METADATA_URI_STARTERS[type] + encodeURIComponent(trackID) + METADATA_URI_ENDINGS[type]; 55 | 56 | const metadata = getMetadata(metadataID, PARENTS[type], CLASSES[type], ''); 57 | const uri = uriTemplates[type](encodeURIComponent(trackID)); 58 | 59 | if (action == 'queue') { 60 | return player.coordinator.addURIToQueue(uri, metadata); 61 | } else if (action == 'now') { 62 | nextTrackNo = player.coordinator.state.trackNo + 1; 63 | let promise = Promise.resolve(); 64 | if (player.coordinator.avTransportUri.startsWith('x-rincon-queue') === false) { 65 | promise = promise.then(() => player.coordinator.setAVTransport(`x-rincon-queue:${player.coordinator.uuid}#0`)); 66 | } 67 | 68 | return promise.then(() => player.coordinator.addURIToQueue(uri, metadata, true, nextTrackNo)) 69 | .then(() => { if (nextTrackNo != 1) player.coordinator.nextTrack() }) 70 | .then(() => player.coordinator.play()); 71 | } else if (action == 'next') { 72 | nextTrackNo = player.coordinator.state.trackNo + 1; 73 | return player.coordinator.addURIToQueue(uri, metadata, true, nextTrackNo); 74 | } 75 | } 76 | 77 | module.exports = function (api) { 78 | api.registerAction('amazonmusic', amazonMusic); 79 | }; 80 | -------------------------------------------------------------------------------- /lib/actions/appleMusic.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function getMetadata(id, parentUri, type, title) { 4 | return ` 6 | "${title}"${type} 7 | SA_RINCON52231_X_#Svc52231-0-Token`; 8 | } 9 | 10 | function getSongUri(id) { 11 | return `x-sonos-http:${id}.mp4?sid=204&flags=8224&sn=4`; 12 | } 13 | 14 | function getAlbumUri(id) { 15 | return `x-rincon-cpcontainer:0004206c${id}`; 16 | } 17 | 18 | function getPlaylistUri(id) { 19 | return `x-rincon-cpcontainer:1006206c${id}`; 20 | } 21 | 22 | const uriTemplates = { 23 | song: getSongUri, 24 | album: getAlbumUri, 25 | playlist: getPlaylistUri, 26 | }; 27 | 28 | const CLASSES = { 29 | song: 'object.item.audioItem.musicTrack', 30 | album: 'object.item.audioItem.musicAlbum', 31 | playlist: 'object.container.playlistContainer.#PlaylistView' 32 | }; 33 | 34 | const METADATA_URI_STARTERS = { 35 | song: '00032020', 36 | album: '0004206c', 37 | playlist: '1006206c' 38 | }; 39 | 40 | const PARENTS = { 41 | song: '0004206calbum%3a', 42 | album: '00020000album%3a', 43 | playlist: '1006206cplaylist%3a' 44 | }; 45 | 46 | function appleMusic(player, values) { 47 | const action = values[0]; 48 | const trackID = values[1]; 49 | const type = trackID.split(':')[0]; 50 | let nextTrackNo = 0; 51 | 52 | const metadataID = METADATA_URI_STARTERS[type] + encodeURIComponent(trackID); 53 | const metadata = getMetadata(metadataID, PARENTS[type], CLASSES[type], ''); 54 | const uri = uriTemplates[type](encodeURIComponent(trackID)); 55 | 56 | if (action === 'queue') { 57 | return player.coordinator.addURIToQueue(uri, metadata); 58 | } else if (action === 'now') { 59 | nextTrackNo = player.coordinator.state.trackNo + 1; 60 | let promise = Promise.resolve(); 61 | if (player.coordinator.avTransportUri.startsWith('x-rincon-queue') === false) { 62 | promise = promise.then(() => player.coordinator.setAVTransport(`x-rincon-queue:${player.coordinator.uuid}#0`)); 63 | } 64 | return promise.then(() => player.coordinator.addURIToQueue(uri, metadata, true, nextTrackNo)) 65 | .then(() => { if (nextTrackNo !== 1) player.coordinator.nextTrack(); }) 66 | .then(() => player.coordinator.play()); 67 | } else if (action === 'next') { 68 | nextTrackNo = player.coordinator.state.trackNo + 1; 69 | return player.coordinator.addURIToQueue(uri, metadata, true, nextTrackNo); 70 | } 71 | 72 | return null; 73 | } 74 | 75 | module.exports = function appleMusicAction(api) { 76 | api.registerAction('applemusic', appleMusic); 77 | }; 78 | -------------------------------------------------------------------------------- /lib/actions/bbcSounds.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function getMetadata(station) { 4 | return ` 5 | BBC Soundsobject.item.audioItem.audioBroadcast 6 | SA_RINCON83207_`; 7 | } 8 | 9 | function getUri(station) { 10 | return `x-sonosapi-hls:stations%7eplayable%7e%7e${station}%7e%7eurn%3abbc%3aradio%3anetwork%3a${station}?sid=325&flags=288&sn=10`; 11 | } 12 | 13 | /** 14 | * @link https://gist.github.com/bpsib/67089b959e4fa898af69fea59ad74bc3 Stream names can be found here 15 | */ 16 | function bbcSounds(player, values) { 17 | const action = values[0]; 18 | const station = encodeURIComponent(values[1]); 19 | 20 | if (!station) { 21 | return Promise.reject('Expected BBC Sounds station name.'); 22 | } 23 | 24 | const metadata = getMetadata(station); 25 | const uri = getUri(station); 26 | 27 | if (action === 'play') { 28 | return player.coordinator.setAVTransport(uri, metadata).then(() => player.coordinator.play()); 29 | } else if (action === 'set') { 30 | return player.coordinator.setAVTransport(uri, metadata); 31 | } 32 | 33 | return Promise.reject('BBC Sounds only handles the {play} & {set} actions.'); 34 | } 35 | 36 | module.exports = function (api) { 37 | api.registerAction('bbcsounds', bbcSounds); 38 | } 39 | -------------------------------------------------------------------------------- /lib/actions/clearqueue.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function clearqueue(player) { 4 | return player.coordinator.clearQueue(); 5 | } 6 | 7 | module.exports = function (api) { 8 | api.registerAction('clearqueue', clearqueue); 9 | }; -------------------------------------------------------------------------------- /lib/actions/clip.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const path = require('path'); 3 | const fileDuration = require('../helpers/file-duration'); 4 | const settings = require('../../settings'); 5 | const singlePlayerAnnouncement = require('../helpers/single-player-announcement'); 6 | 7 | let port; 8 | 9 | const LOCAL_PATH_LOCATION = path.join(settings.webroot, 'clips'); 10 | 11 | const backupPresets = {}; 12 | 13 | function playClip(player, values) { 14 | const clipFileName = values[0]; 15 | let announceVolume = settings.announceVolume || 40; 16 | 17 | if (/^\d+$/i.test(values[1])) { 18 | // first parameter is volume 19 | announceVolume = values[1]; 20 | } 21 | 22 | return fileDuration(path.join(LOCAL_PATH_LOCATION, clipFileName)) 23 | .then((duration) => { 24 | return singlePlayerAnnouncement(player, `http://${player.system.localEndpoint}:${port}/clips/${clipFileName}`, announceVolume, duration); 25 | }); 26 | } 27 | 28 | module.exports = function(api) { 29 | port = api.getPort(); 30 | api.registerAction('clip', playClip); 31 | } 32 | -------------------------------------------------------------------------------- /lib/actions/clipall.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const path = require('path'); 3 | const settings = require('../../settings'); 4 | const allPlayerAnnouncement = require('../helpers/all-player-announcement'); 5 | const fileDuration = require('../helpers/file-duration'); 6 | 7 | let port; 8 | 9 | const LOCAL_PATH_LOCATION = path.join(settings.webroot, 'clips'); 10 | 11 | function playClipOnAll(player, values) { 12 | const clipFileName = values[0]; 13 | let announceVolume = settings.announceVolume || 40; 14 | 15 | if (/^\d+$/i.test(values[1])) { 16 | // first parameter is volume 17 | announceVolume = values[1]; 18 | } 19 | 20 | return fileDuration(path.join(LOCAL_PATH_LOCATION, clipFileName)) 21 | .then((duration) => { 22 | return allPlayerAnnouncement(player.system, `http://${player.system.localEndpoint}:${port}/clips/${clipFileName}`, announceVolume, duration); 23 | }); 24 | } 25 | 26 | module.exports = function (api) { 27 | port = api.getPort(); 28 | api.registerAction('clipall', playClipOnAll); 29 | } 30 | -------------------------------------------------------------------------------- /lib/actions/clippreset.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const path = require('path'); 3 | const settings = require('../../settings'); 4 | const presetAnnouncement = require('../helpers/preset-announcement'); 5 | const fileDuration = require('../helpers/file-duration'); 6 | const presets = require('../presets-loader'); 7 | 8 | let port; 9 | const LOCAL_PATH_LOCATION = path.join(settings.webroot, 'clips'); 10 | 11 | function playClipOnPreset(player, values) { 12 | const presetName = decodeURIComponent(values[0]); 13 | const clipFileName = decodeURIComponent(values[1]); 14 | 15 | const preset = presets[presetName]; 16 | 17 | if (!preset) { 18 | return Promise.reject(new Error(`No preset named ${presetName} could be found`)); 19 | } 20 | 21 | return fileDuration(path.join(LOCAL_PATH_LOCATION, clipFileName)) 22 | .then((duration) => { 23 | return presetAnnouncement(player.system, `http://${player.system.localEndpoint}:${port}/clips/${clipFileName}`, preset, duration); 24 | }); 25 | } 26 | 27 | module.exports = function (api) { 28 | port = api.getPort(); 29 | api.registerAction('clippreset', playClipOnPreset); 30 | } 31 | -------------------------------------------------------------------------------- /lib/actions/debug.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const pkg = require('../../package.json'); 3 | 4 | function debug(player) { 5 | const system = player.system; 6 | const debugInfo = { 7 | version: pkg.version, 8 | system: { 9 | localEndpoint: system.localEndpoint, 10 | availableServices: system.availableServices, 11 | }, 12 | players: system.players.map(x => ({ 13 | roomName: x.roomName, 14 | uuid: x.uuid, 15 | coordinator: x.coordinator.uuid, 16 | avTransportUri: x.avTransportUri, 17 | avTransportUriMetadata: x.avTransportUriMetadata, 18 | enqueuedTransportUri: x.enqueuedTransportUri, 19 | enqueuedTransportUriMetadata: x.enqueuedTransportUriMetadata, 20 | baseUrl: x.baseUrl, 21 | state: x._state 22 | })) 23 | }; 24 | return Promise.resolve(debugInfo); 25 | } 26 | 27 | module.exports = function (api) { 28 | api.registerAction('debug', debug); 29 | } 30 | -------------------------------------------------------------------------------- /lib/actions/equalizer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function nightMode(player, values) { 4 | let enable = values[0] === 'on'; 5 | if(values[0] == "toggle") enable = !player.coordinator.state.equalizer.nightMode; 6 | return player.nightMode(enable).then((response) => { 7 | return { status: 'success', nightmode: enable }; 8 | }); 9 | } 10 | 11 | function speechEnhancement(player, values) { 12 | let enable = values[0] === 'on'; 13 | if(values[0] == "toggle") enable = !player.coordinator.state.equalizer.speechEnhancement; 14 | return player.speechEnhancement(enable).then((response) => { 15 | return { status: 'success', speechenhancement: enable }; 16 | }); 17 | } 18 | 19 | function bass(player, values) { 20 | const level = parseInt(values[0]); 21 | return player.setBass(level); 22 | } 23 | 24 | function treble(player, values) { 25 | const level = parseInt(values[0]); 26 | return player.setTreble(level); 27 | } 28 | 29 | module.exports = function (api) { 30 | api.registerAction('nightmode', nightMode); 31 | api.registerAction('speechenhancement', speechEnhancement); 32 | api.registerAction('bass', bass); 33 | api.registerAction('treble', treble); 34 | } 35 | -------------------------------------------------------------------------------- /lib/actions/favorite.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | function favorite(player, values) { 3 | return player.coordinator.replaceWithFavorite(decodeURIComponent(values[0])) 4 | .then(() => player.coordinator.play()); 5 | } 6 | 7 | module.exports = function (api) { 8 | api.registerAction('favorite', favorite); 9 | api.registerAction('favourite', favorite); 10 | } 11 | -------------------------------------------------------------------------------- /lib/actions/favorites.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function favorites(player, values) { 4 | 5 | return player.system.getFavorites() 6 | .then((favorites) => { 7 | 8 | if (values[0] === 'detailed') { 9 | return favorites; 10 | } 11 | 12 | // only present relevant data 13 | return favorites.map(i => i.title); 14 | }); 15 | } 16 | 17 | module.exports = function (api) { 18 | api.registerAction('favorites', favorites); 19 | api.registerAction('favourites', favorites); 20 | }; 21 | -------------------------------------------------------------------------------- /lib/actions/group.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const logger = require('sonos-discovery/lib/helpers/logger'); 3 | 4 | function addToGroup(player, values) { 5 | const joiningRoomName = decodeURIComponent(values[0]); 6 | const joiningPlayer = player.system.getPlayer(joiningRoomName); 7 | if(!joiningPlayer) { 8 | logger.warn(`Room ${joiningRoomName} not found - can't group with ${player.roomName}`); 9 | return Promise.reject(new Error(`Room ${joiningRoomName} not found - can't group with ${player.roomName}`)); 10 | } 11 | return attachTo(joiningPlayer, player.coordinator); 12 | } 13 | 14 | function joinPlayer(player, values) { 15 | const receivingRoomName = decodeURIComponent(values[0]); 16 | const receivingPlayer = player.system.getPlayer(receivingRoomName); 17 | if(!receivingPlayer) { 18 | logger.warn(`Room ${receivingRoomName} not found - can't make ${player.roomName} join it`); 19 | return Promise.reject(new Error(`Room ${receivingRoomName} not found - can't make ${player.roomName} join it`)); 20 | } 21 | return attachTo(player, receivingPlayer.coordinator); 22 | } 23 | 24 | function rinconUri(player) { 25 | return `x-rincon:${player.uuid}`; 26 | } 27 | 28 | function attachTo(player, coordinator) { 29 | return player.setAVTransport(rinconUri(coordinator)); 30 | } 31 | 32 | function isolate(player) { 33 | return player.becomeCoordinatorOfStandaloneGroup(); 34 | } 35 | 36 | module.exports = function (api) { 37 | api.registerAction('add', addToGroup); 38 | api.registerAction('isolate', isolate); 39 | api.registerAction('ungroup', isolate); 40 | api.registerAction('leave', isolate); 41 | api.registerAction('join', joinPlayer); 42 | } 43 | -------------------------------------------------------------------------------- /lib/actions/linein.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function linein(player, values) { 4 | const sourcePlayerName = values[0]; 5 | let lineinSourcePlayer = player; 6 | 7 | if (sourcePlayerName) { 8 | lineinSourcePlayer = player.system.getPlayer(decodeURIComponent(sourcePlayerName)); 9 | } 10 | 11 | if (!lineinSourcePlayer) { 12 | return Promise.reject(new Error(`Could not find player ${sourcePlayerName}`)); 13 | } 14 | 15 | const uri = `x-rincon-stream:${lineinSourcePlayer.uuid}`; 16 | 17 | return player.coordinator.setAVTransport(uri) 18 | .then(() => player.coordinator.play()); 19 | } 20 | 21 | module.exports = function (api) { 22 | api.registerAction('linein', linein); 23 | } 24 | -------------------------------------------------------------------------------- /lib/actions/lockvolumes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const logger = require('sonos-discovery/lib/helpers/logger'); 3 | const lockVolumes = {}; 4 | 5 | function lockvolumes(player) { 6 | logger.debug('locking volumes'); 7 | // Locate all volumes 8 | var system = player.system; 9 | 10 | system.players.forEach((player) => { 11 | lockVolumes[player.uuid] = player.state.volume; 12 | }); 13 | 14 | // prevent duplicates, will ignore if no event listener is here 15 | system.removeListener('volume-change', restrictVolume); 16 | system.on('volume-change', restrictVolume); 17 | return Promise.resolve(); 18 | } 19 | 20 | function unlockvolumes(player) { 21 | logger.debug('unlocking volumes'); 22 | var system = player.system; 23 | system.removeListener('volume-change', restrictVolume); 24 | return Promise.resolve(); 25 | } 26 | 27 | function restrictVolume(info) { 28 | logger.debug(`should revert volume to ${lockVolumes[info.uuid]}`); 29 | const player = this.getPlayerByUUID(info.uuid); 30 | // Only do this if volume differs 31 | if (player.state.volume != lockVolumes[info.uuid]) 32 | return player.setVolume(lockVolumes[info.uuid]); 33 | } 34 | 35 | module.exports = function (api) { 36 | api.registerAction('lockvolumes', lockvolumes); 37 | api.registerAction('unlockvolumes', unlockvolumes); 38 | } -------------------------------------------------------------------------------- /lib/actions/musicSearch.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const request = require('request-promise'); 3 | const fs = require("fs"); 4 | const isRadioOrLineIn = require('../helpers/is-radio-or-line-in'); 5 | 6 | const appleDef = require('../music_services/appleDef'); 7 | const spotifyDef = require('../music_services/spotifyDef'); 8 | const deezerDef = require('../music_services/deezerDef'); 9 | const eliteDef = deezerDef.init(true); 10 | const libraryDef = require('../music_services/libraryDef'); 11 | 12 | const musicServices = ['apple','spotify','deezer','elite','library']; 13 | const serviceNames = {apple:'Apple Music',spotify:'Spotify',deezer:'Deezer',elite:'Deezer',library:'Library'}; 14 | const musicTypes = ['album','song','station','load','playlist']; 15 | 16 | var country = ''; 17 | var accountId = ''; 18 | var accountSN = ''; 19 | var searchType = 0; 20 | 21 | function getService(service) { 22 | if (service == 'apple') { 23 | return appleDef; 24 | } else 25 | if (service == 'spotify') { 26 | return spotifyDef; 27 | } else 28 | if (service == 'deezer') { 29 | return deezerDef; 30 | } else 31 | if (service == 'elite') { 32 | return eliteDef; 33 | } else 34 | if (service == 'library') { 35 | return libraryDef; 36 | } 37 | } 38 | 39 | function getAccountId(player, service) 40 | { 41 | accountId = ''; 42 | 43 | if (service != 'library') { 44 | return request({url: player.baseUrl + '/status/accounts',json: false}) 45 | .then((res) => { 46 | var actLoc = res.indexOf(player.system.getServiceType(serviceNames[service])); 47 | 48 | if (actLoc != -1) { 49 | var idLoc = res.indexOf('', actLoc)+4; 50 | var snLoc = res.indexOf('SerialNum="', actLoc)+11; 51 | 52 | accountId = res.substring(idLoc,res.indexOf('',idLoc)); 53 | accountSN = res.substring(snLoc,res.indexOf('"',snLoc)); 54 | } 55 | 56 | return Promise.resolve(); 57 | }); 58 | 59 | return promise; 60 | } else { 61 | return Promise.resolve(); 62 | } 63 | } 64 | 65 | function getRequestOptions(serviceDef, url) { 66 | const headers = serviceDef.headers(); 67 | return { 68 | url: url, 69 | json: true, 70 | headers: headers, 71 | } 72 | }; 73 | 74 | function doSearch(service, type, term) 75 | { 76 | var serviceDef = getService(service); 77 | var url = serviceDef.search[type]; 78 | var authenticate = serviceDef.authenticate; 79 | 80 | term = decodeURIComponent(term); 81 | 82 | // Check for search type specifiers 83 | if (term.indexOf(':') > -1) { 84 | var newTerm = ''; 85 | var artistPos = term.indexOf('artist:'); 86 | var albumPos = term.indexOf('album:'); 87 | var trackPos = term.indexOf('track:'); 88 | var nextPos = -1; 89 | var artist = ''; 90 | var album = ''; 91 | var track = ''; 92 | 93 | if (artistPos > -1) { 94 | nextPos = (albumPos < trackPos)?albumPos:trackPos; 95 | artist = term.substring(artistPos+7,(artistPos < nextPos)?nextPos:term.length); 96 | } 97 | if (albumPos > -1) { 98 | nextPos = (trackPos < artistPos)?trackPos:artistPos; 99 | album = term.substring(albumPos+6,(albumPos < nextPos)?nextPos:term.length); 100 | } 101 | if (trackPos > -1) { 102 | nextPos = (albumPos < artistPos)?albumPos:artistPos; 103 | track = term.substring(trackPos+6,(trackPos < nextPos)?nextPos:term.length); 104 | } 105 | 106 | newTerm = serviceDef.term(type, term, artist, album, track); 107 | 108 | } else { 109 | newTerm = (service == 'library')?term:encodeURIComponent(term); 110 | } 111 | 112 | if (type == 'song') { 113 | searchType = (trackPos > -1)?1:((artistPos > -1)?2:0); 114 | } 115 | url += newTerm; 116 | 117 | if (service == 'library') { 118 | return Promise.resolve(libraryDef.searchlib(type, newTerm)); 119 | } else 120 | if ((serviceDef.country != '') && (country == '')) { 121 | return request({url: 'http://ipinfo.io', 122 | json: true}) 123 | .then((res) => { 124 | country = res.country; 125 | url += serviceDef.country + country; 126 | return authenticate().then(() => request(getRequestOptions(serviceDef, url))); 127 | }); 128 | } else { 129 | if (serviceDef.country != '') { 130 | url += serviceDef.country + country; 131 | } 132 | 133 | return authenticate().then(() => request(getRequestOptions(serviceDef, url))); 134 | } 135 | } 136 | 137 | Array.prototype.shuffle=function(){ 138 | var len = this.length,temp,i 139 | while(len){ 140 | i=Math.random()*len-- >>> 0; 141 | temp=this[len],this[len]=this[i],this[i]=temp; 142 | } 143 | return this; 144 | } 145 | 146 | function loadTracks(player, service, type, tracksJson) 147 | { 148 | var tracks = getService(service).tracks(type, tracksJson); 149 | 150 | if ((service == 'library') && (type == 'album')) { 151 | tracks.isArtist = true; 152 | } else 153 | if (type != 'album') { 154 | if (searchType == 0) { 155 | // Determine if the request was for a specific song or for many songs by a specific artist 156 | if (tracks.count > 1) { 157 | var artistCount = 1; 158 | var trackCount = 1; 159 | var artists = tracks.queueTracks.map(function(track) { 160 | return track.artistName.toLowerCase(); 161 | }).sort(); 162 | var songs = tracks.queueTracks.map(function(track) { 163 | return track.trackName.toLowerCase(); 164 | }).sort(); 165 | 166 | var prevArtist=artists[0]; 167 | var prevTrack=songs[0]; 168 | 169 | for (var i=1; i < tracks.count;i++) { 170 | if (artists[i] != prevArtist) { 171 | artistCount++; 172 | prevArtist = artists[i]; 173 | } 174 | if (songs[i] != prevTrack) { 175 | trackCount++; 176 | prevTrack = songs[i]; 177 | } 178 | } 179 | tracks.isArtist = (trackCount/artistCount > 2); 180 | } 181 | } else { 182 | tracks.isArtist = (searchType == 2); 183 | } 184 | } 185 | 186 | //To avoid playing the same song first in a list of artist tracks when shuffle is on 187 | if (tracks.isArtist && player.coordinator.state.playMode.shuffle) { 188 | tracks.queueTracks.shuffle(); 189 | } 190 | 191 | return tracks; 192 | } 193 | 194 | 195 | function musicSearch(player, values) { 196 | const service = values[0]; 197 | const type = values[1]; 198 | const term = values[2]; 199 | const queueURI = 'x-rincon-queue:' + player.coordinator.uuid + '#0'; 200 | 201 | if (musicServices.indexOf(service) == -1) { 202 | return Promise.reject('Invalid music service'); 203 | } 204 | 205 | if (musicTypes.indexOf(type) == -1) { 206 | return Promise.reject('Invalid type ' + type); 207 | } 208 | 209 | if ((service == 'library') && ((type == 'load') || libraryDef.nolib())) { 210 | return libraryDef.load(player, (type == 'load')); 211 | } 212 | 213 | return getAccountId(player, service) 214 | .then(() => { 215 | return doSearch(service, type, term); 216 | }) 217 | .then((resList) => { 218 | const serviceDef = getService(service); 219 | serviceDef.service(player, accountId, accountSN, country); 220 | if (serviceDef.empty(type, resList)) { 221 | return Promise.reject('No matches were found'); 222 | } else { 223 | var UaM = null; 224 | 225 | if (type == 'station') { 226 | UaM = serviceDef.urimeta(type, resList); 227 | 228 | return player.coordinator.setAVTransport(UaM.uri, UaM.metadata) 229 | .then(() => player.coordinator.play()); 230 | } else 231 | if ((type == 'album' || type =='playlist') && (service != 'library')) { 232 | UaM = serviceDef.urimeta(type, resList); 233 | 234 | return player.coordinator.clearQueue() 235 | .then(() => player.coordinator.setAVTransport(queueURI, '')) 236 | .then(() => player.coordinator.addURIToQueue(UaM.uri, UaM.metadata, true, 1)) 237 | .then(() => player.coordinator.play()); 238 | } else { // Play songs 239 | var tracks = loadTracks(player, service, type, resList); 240 | 241 | if (tracks.count == 0) { 242 | return Promise.reject('No matches were found'); 243 | } else { 244 | if (tracks.isArtist) { // Play numerous songs by the specified artist 245 | return player.coordinator.clearQueue() 246 | .then(() => player.coordinator.setAVTransport(queueURI, '')) 247 | .then(() => player.coordinator.addURIToQueue(tracks.queueTracks[0].uri, tracks.queueTracks[0].metadata, true, 1)) 248 | .then(() => player.coordinator.play()) 249 | .then(() => { 250 | // Do not return promise since we want to be considered done from the calling context 251 | tracks.queueTracks.slice(1).reduce((promise, track, index) => { 252 | return promise.then(() => player.coordinator.addURIToQueue(track.uri, track.metadata, true, index + 2)); 253 | }, Promise.resolve()); 254 | }); 255 | } else { // Play the one specified song 256 | var empty = false; 257 | var nextTrackNo = 0; 258 | 259 | return player.coordinator.getQueue(0, 1) 260 | .then((queue) => { 261 | empty = (queue.length == 0); 262 | nextTrackNo = (empty) ? 1 : player.coordinator.state.trackNo + 1; 263 | }) 264 | .then(() => player.coordinator.addURIToQueue(tracks.queueTracks[0].uri, tracks.queueTracks[0].metadata, true, nextTrackNo)) 265 | .then(() => player.coordinator.setAVTransport(queueURI, '')) 266 | .then(() => { 267 | if (!empty) { 268 | return player.coordinator.nextTrack(); 269 | } 270 | }) 271 | .then(() => player.coordinator.play()); 272 | } 273 | } 274 | } 275 | } 276 | }); 277 | } 278 | 279 | module.exports = function (api) { 280 | api.registerAction('musicsearch', musicSearch); 281 | libraryDef.read(); 282 | }; 283 | -------------------------------------------------------------------------------- /lib/actions/mute.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | function mute(player) { 3 | return player.mute(); 4 | } 5 | 6 | function groupMute(player) { 7 | return player.coordinator.muteGroup(); 8 | } 9 | 10 | function unmute(player) { 11 | return player.unMute(); 12 | } 13 | 14 | function groupUnmute(player) { 15 | return player.coordinator.unMuteGroup(); 16 | } 17 | 18 | function toggleMute(player) { 19 | let ret = { status: 'success', muted: true }; 20 | 21 | if(player.state.mute) { 22 | ret.muted = false; 23 | return player.unMute().then((response) => { return ret; }); 24 | }; 25 | 26 | return player.mute().then((response) => { return ret; }); 27 | } 28 | 29 | module.exports = function (api) { 30 | api.registerAction('mute', mute); 31 | api.registerAction('unmute', unmute); 32 | api.registerAction('groupmute', groupMute); 33 | api.registerAction('groupunmute', groupUnmute); 34 | api.registerAction('mutegroup', groupMute); 35 | api.registerAction('unmutegroup', groupUnmute); 36 | api.registerAction('togglemute', toggleMute); 37 | } 38 | -------------------------------------------------------------------------------- /lib/actions/napster.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | function getMetadata(id, parentUri, type, title) { 3 | return ` 5 | "${title}"${type} 6 | SA_RINCON51975_X_#Svc51975-0-Token`; 7 | } 8 | 9 | function getUri(id, type) { 10 | var uri = { 11 | song: `x-sonos-http:ondemand_track%3a%3atra.${id}%7cv1%7cALBUM%7calb.mp4?sid=203&flags=8224&sn=13`, 12 | album: `x-rincon-cpcontainer:100420ecexplore%3aalbum%3a%3aAlb.${id}` 13 | }; 14 | 15 | return uri[type]; 16 | } 17 | 18 | const CLASSES = { 19 | song: 'object.item.audioItem.musicTrack', 20 | album: 'object.container.album.musicAlbum' 21 | }; 22 | 23 | const METADATA_URI_STARTERS = { 24 | song: '10032020ondemand_track%3a%3atra.', 25 | album: '100420ec' 26 | }; 27 | 28 | const PARENTS = { 29 | song: '100420ecexplore%3a', 30 | album: '100420ecexplore%3aalbum%3a' 31 | }; 32 | 33 | function napster(player, values) { 34 | const action = values[0]; 35 | const trackID = values[1].split(':')[1]; 36 | const type = values[1].split(':')[0]; 37 | var nextTrackNo = 0; 38 | 39 | const metadataID = METADATA_URI_STARTERS[type] + encodeURIComponent(trackID); 40 | const metadata = getMetadata(metadataID, PARENTS[type], CLASSES[type], ''); 41 | const uri = getUri(encodeURIComponent(trackID), type); 42 | 43 | if (action == 'queue') { 44 | return player.coordinator.addURIToQueue(uri, metadata); 45 | } else if (action == 'now') { 46 | nextTrackNo = player.coordinator.state.trackNo + 1; 47 | let promise = Promise.resolve(); 48 | if (player.coordinator.avTransportUri.startsWith('x-rincon-queue') === false) { 49 | promise = promise.then(() => player.coordinator.setAVTransport(`x-rincon-queue:${player.coordinator.uuid}#0`)); 50 | } 51 | 52 | return promise.then(() => { 53 | return player.coordinator.addURIToQueue(uri, metadata, true, nextTrackNo) 54 | .then((addToQueueStatus) => player.coordinator.trackSeek(addToQueueStatus.firsttracknumberenqueued)) 55 | .then(() => player.coordinator.play()); 56 | }); 57 | } else if (action == 'next') { 58 | nextTrackNo = player.coordinator.state.trackNo + 1; 59 | return player.coordinator.addURIToQueue(uri, metadata, true, nextTrackNo); 60 | } 61 | } 62 | 63 | module.exports = function (api) { 64 | api.registerAction('napster', napster); 65 | }; 66 | -------------------------------------------------------------------------------- /lib/actions/nextprevious.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | function next(player) { 3 | return player.coordinator.nextTrack(); 4 | } 5 | 6 | function previous(player) { 7 | return player.coordinator.previousTrack(); 8 | } 9 | 10 | module.exports = function (api) { 11 | api.registerAction('next', next); 12 | api.registerAction('previous', previous); 13 | } -------------------------------------------------------------------------------- /lib/actions/pandora.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const url = require('url'); 3 | const querystring = require('querystring'); 4 | const Anesidora = require('anesidora'); 5 | const Fuse = require('fuse.js'); 6 | const settings = require('../../settings'); 7 | 8 | function getPandoraMetadata(id, title, serviceType) { 9 | return ` 11 | ${title}object.item.audioItem.audioBroadcast.#station 12 | SA_RINCON${serviceType}_X_#Svc${serviceType}-0-Token`; 13 | } 14 | 15 | function getPandoraUri(id, title, albumart) { 16 | return `x-sonosapi-radio:ST%3a${id}?sid=236&flags=8300&sn=1`; 17 | } 18 | 19 | function parseQuerystring(uri) { 20 | const parsedUri = url.parse(uri); 21 | return querystring.parse(parsedUri.query); 22 | } 23 | 24 | function pandora(player, values) { 25 | const cmd = values[0]; 26 | 27 | function userLogin() { 28 | return new Promise(function(resolve, reject) { 29 | pAPI.login(function(err) { 30 | if (!err) { 31 | resolve(); 32 | } else { 33 | reject(err); 34 | } 35 | }); 36 | }); 37 | } 38 | 39 | function pandoraAPI(command, parameters) { 40 | return new Promise(function(resolve, reject) { 41 | pAPI.request(command, parameters, function(err, result) { 42 | if (!err) { 43 | resolve(result); 44 | } else { 45 | console.log("pandoraAPI " + command + " " + JSON.stringify(parameters)); 46 | console.log("ERROR: " + JSON.stringify(err)); 47 | reject(err); 48 | } 49 | }); 50 | }); 51 | } 52 | 53 | function playPandora(player, name) { 54 | var uri = ''; 55 | var metadata = ''; 56 | 57 | var sid = player.system.getServiceId('Pandora'); 58 | 59 | return userLogin() 60 | .then(() => pandoraAPI("user.getStationList", {"includeStationArtUrl" : true})) 61 | .then((stationList) => { 62 | return pandoraAPI("music.search", {"searchText": name}) 63 | .then((result) => { 64 | if (result.artists != undefined) { 65 | result.artists.map(function(artist) { 66 | if (artist.score > 90) { 67 | stationList.stations.push({"stationId":artist.musicToken,"stationName":artist.artistName,"type":"artist"}); 68 | } 69 | }); 70 | } 71 | if (result.songs != undefined) { 72 | result.songs.map(function(song) { 73 | if (song.score > 90) { 74 | stationList.stations.push({"stationId":song.musicToken,"stationName":song.songName,"type":"song"}); 75 | } 76 | }); 77 | } 78 | return pandoraAPI("station.getGenreStations", {}); 79 | }) 80 | .then((result) => { 81 | result.categories.map(function(category) { 82 | category.stations.map(function(genreStation) { 83 | stationList.stations.push({"stationId":genreStation.stationToken,"stationName":genreStation.stationName,"type":"song"}); 84 | }); 85 | }); 86 | var fuzzy = new Fuse(stationList.stations, { keys: ["stationName"] }); 87 | 88 | const results = fuzzy.search(name); 89 | if (results.length > 0) { 90 | const station = results[0]; 91 | if (station.type == undefined) { 92 | uri = getPandoraUri(station.item.stationId, station.item.stationName, station.item.artUrl); 93 | metadata = getPandoraMetadata(station.item.stationId, station.item.stationName, player.system.getServiceType('Pandora')); 94 | return Promise.resolve(); 95 | } else { 96 | return pandoraAPI("station.createStation", {"musicToken":station.item.stationId, "musicType":station.item.type}) 97 | .then((stationInfo) => { 98 | uri = getPandoraUri(stationInfo.stationId); 99 | metadata = getPandoraMetadata(stationInfo.stationId, stationInfo.stationName, player.system.getServiceType('Pandora')); 100 | return Promise.resolve(); 101 | }); 102 | } 103 | } else { 104 | return Promise.reject("No match was found"); 105 | } 106 | }) 107 | .then(() => player.coordinator.setAVTransport(uri, metadata)) 108 | .then(() => player.coordinator.play()); 109 | }); 110 | } 111 | 112 | if (settings && settings.pandora) { 113 | var pAPI = new Anesidora(settings.pandora.username, settings.pandora.password); 114 | 115 | if (cmd == 'play') { 116 | return playPandora(player, values[1]); 117 | } if ((cmd == 'thumbsup')||(cmd == 'thumbsdown')) { 118 | 119 | var sid = player.system.getServiceId('Pandora'); 120 | const uri = player.state.currentTrack.uri; 121 | 122 | const parameters = parseQuerystring(uri); 123 | 124 | if (uri.startsWith('x-sonosapi-radio') && parameters.sid == sid && player.state.currentTrack.trackUri) { 125 | const trackUri = player.state.currentTrack.trackUri; 126 | const trackToken = trackUri.substring(trackUri.search('x-sonos-http:') + 13, trackUri.search('%3a%3aST%3a')); 127 | const stationToken = trackUri.substring(trackUri.search('%3a%3aST%3a') + 11, trackUri.search('%3a%3aRINCON')); 128 | const up = (cmd == 'thumbsup'); 129 | 130 | return userLogin() 131 | .then(() => pandoraAPI("station.addFeedback", {"stationToken" : stationToken, "trackToken" : trackToken, "isPositive" : up})) 132 | .then(() => { 133 | if (cmd == 'thumbsdown') { 134 | return player.coordinator.nextTrack(); 135 | } 136 | }); 137 | } else { 138 | return Promise.reject('The music that is playing is not a Pandora station'); 139 | } 140 | } 141 | } else { 142 | console.log('Missing Pandora settings'); 143 | return Promise.reject('Missing Pandora settings'); 144 | } 145 | 146 | } 147 | 148 | 149 | module.exports = function (api) { 150 | api.registerAction('pandora', pandora); 151 | } 152 | 153 | -------------------------------------------------------------------------------- /lib/actions/pauseall.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const logger = require('sonos-discovery/lib/helpers/logger'); 3 | var pausedPlayers = []; 4 | 5 | function pauseAll(player, values) { 6 | logger.debug("pausing all players"); 7 | // save state for resume 8 | 9 | if (values[0] && values[0] > 0) { 10 | logger.debug("in", values[0], "minutes"); 11 | setTimeout(function () { 12 | doPauseAll(player.system); 13 | }, values[0] * 1000 * 60); 14 | return Promise.resolve(); 15 | } 16 | 17 | return doPauseAll(player.system); 18 | } 19 | 20 | function resumeAll(player, values) { 21 | logger.debug("resuming all players"); 22 | 23 | if (values[0] && values[0] > 0) { 24 | logger.debug("in", values[0], "minutes"); 25 | setTimeout(function () { 26 | doResumeAll(player.system); 27 | }, values[0] * 1000 * 60); 28 | return Promise.resolve(); 29 | } 30 | 31 | return doResumeAll(player.system); 32 | } 33 | 34 | function doPauseAll(system) { 35 | pausedPlayers = []; 36 | const promises = system.zones 37 | .filter(zone => { 38 | return zone.coordinator.state.playbackState === 'PLAYING' 39 | }) 40 | .map(zone => { 41 | pausedPlayers.push(zone.uuid); 42 | const player = system.getPlayerByUUID(zone.uuid); 43 | return player.pause(); 44 | }); 45 | return Promise.all(promises); 46 | } 47 | 48 | function doResumeAll(system) { 49 | 50 | const promises = pausedPlayers.map(uuid => { 51 | var player = system.getPlayerByUUID(uuid); 52 | return player.play(); 53 | }); 54 | 55 | // Clear the pauseState to prevent a second resume to raise hell 56 | pausedPlayers = []; 57 | 58 | return Promise.all(promises); 59 | } 60 | 61 | 62 | module.exports = function (api) { 63 | api.registerAction('pauseall', pauseAll); 64 | api.registerAction('resumeall', resumeAll); 65 | } 66 | -------------------------------------------------------------------------------- /lib/actions/playlist.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function playlist(player, values) { 4 | const playlistName = decodeURIComponent(values[0]); 5 | return player.coordinator 6 | .replaceWithPlaylist(playlistName) 7 | .then(() => player.coordinator.play()); 8 | } 9 | 10 | module.exports = function (api) { 11 | api.registerAction('playlist', playlist); 12 | }; 13 | -------------------------------------------------------------------------------- /lib/actions/playlists.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | function playlists(player, values) { 3 | 4 | return player.system.getPlaylists() 5 | .then((playlists) => { 6 | if (values[0] === 'detailed') { 7 | return playlists; 8 | } 9 | 10 | // only present relevant data 11 | var simplePlaylists = []; 12 | playlists.forEach(function (i) { 13 | simplePlaylists.push(i.title); 14 | }); 15 | 16 | return simplePlaylists; 17 | }); 18 | } 19 | 20 | module.exports = function (api) { 21 | api.registerAction('playlists', playlists); 22 | } 23 | -------------------------------------------------------------------------------- /lib/actions/playmode.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | function repeat(player, values) { 3 | let mode = values[0]; 4 | 5 | if (mode === "on") { 6 | mode = "all"; 7 | } else if (mode === "off") { 8 | mode = "none"; 9 | } else if (mode === "toggle") { 10 | switch (player.coordinator.state.playMode.repeat) { 11 | case 'all': mode = "one"; break; 12 | case 'one': mode = "off"; break; 13 | default: mode = "all"; 14 | } 15 | } 16 | 17 | return player.coordinator.repeat(mode).then((response) => { 18 | return { status: 'success', repeat: mode }; 19 | }); 20 | } 21 | 22 | function shuffle(player, values) { 23 | let enable = values[0] === "on"; 24 | if(values[0] == "toggle") enable = !player.coordinator.state.playMode.shuffle; 25 | return player.coordinator.shuffle(enable).then((response) => { 26 | return { status: 'success', shuffle: enable }; 27 | }); 28 | } 29 | 30 | function crossfade(player, values) { 31 | let enable = values[0] === "on"; 32 | if(values[0] == "toggle") enable = !player.coordinator.state.playMode.crossfade; 33 | return player.coordinator.crossfade(enable).then((response) => { 34 | return { status: 'success', crossfade: enable }; 35 | }); 36 | } 37 | 38 | module.exports = function (api) { 39 | api.registerAction('repeat', repeat); 40 | api.registerAction('shuffle', shuffle); 41 | api.registerAction('crossfade', crossfade); 42 | } 43 | -------------------------------------------------------------------------------- /lib/actions/playpause.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | function playpause(player) { 3 | let ret = { status: 'success', paused: false }; 4 | 5 | if(player.coordinator.state.playbackState === 'PLAYING') { 6 | ret.paused = true; 7 | return player.coordinator.pause().then((response) => { return ret; }); 8 | } 9 | 10 | return player.coordinator.play().then((response) => { return ret; }); 11 | } 12 | 13 | function play(player) { 14 | return player.coordinator.play(); 15 | } 16 | 17 | function pause(player) { 18 | return player.coordinator.pause(); 19 | } 20 | 21 | module.exports = function (api) { 22 | api.registerAction('playpause', playpause); 23 | api.registerAction('play', play); 24 | api.registerAction('pause', pause); 25 | } -------------------------------------------------------------------------------- /lib/actions/preset.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fs = require('fs'); 3 | const util = require('util'); 4 | const logger = require('sonos-discovery/lib/helpers/logger'); 5 | const presets = require('../presets-loader'); 6 | 7 | function presetsAction(player, values) { 8 | const value = decodeURIComponent(values[0]); 9 | let preset; 10 | if (value.startsWith('{')) { 11 | preset = JSON.parse(value); 12 | } else { 13 | preset = presets[value]; 14 | } 15 | 16 | if (preset) { 17 | return player.system.applyPreset(preset); 18 | } else { 19 | const simplePresets = Object.keys(presets); 20 | return Promise.resolve(simplePresets); 21 | } 22 | } 23 | 24 | module.exports = function (api) { 25 | api.registerAction('preset', presetsAction); 26 | }; -------------------------------------------------------------------------------- /lib/actions/queue.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function simplify(items) { 4 | return items 5 | .map(item => { 6 | return { 7 | title: item.title, 8 | artist: item.artist, 9 | album: item.album, 10 | albumArtUri: item.albumArtUri 11 | } 12 | }); 13 | } 14 | 15 | function queue(player, values) { 16 | const detailed = values[values.length - 1] === 'detailed'; 17 | let limit; 18 | let offset; 19 | 20 | if (/\d+/.test(values[0])) { 21 | limit = parseInt(values[0]); 22 | } 23 | 24 | if (/\d+/.test(values[1])) { 25 | offset = parseInt(values[1]); 26 | } 27 | 28 | const promise = player.coordinator.getQueue(limit, offset); 29 | 30 | if (detailed) { 31 | return promise; 32 | } 33 | 34 | return promise.then(simplify); 35 | } 36 | 37 | module.exports = function (api) { 38 | api.registerAction('queue', queue); 39 | } 40 | -------------------------------------------------------------------------------- /lib/actions/reindex.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | function reindex(player) { 3 | return player.system.refreshShareIndex(); 4 | } 5 | 6 | module.exports = function (api) { 7 | api.registerAction('reindex', reindex); 8 | } -------------------------------------------------------------------------------- /lib/actions/say.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const path = require('path'); 3 | const crypto = require('crypto'); 4 | const fs = require('fs'); 5 | const http = require('http'); 6 | const tryDownloadTTS = require('../helpers/try-download-tts'); 7 | const singlePlayerAnnouncement = require('../helpers/single-player-announcement'); 8 | const settings = require('../../settings'); 9 | 10 | let port; 11 | let system; 12 | 13 | function say(player, values) { 14 | let text; 15 | try { 16 | text = decodeURIComponent(values[0]); 17 | } catch (err) { 18 | if (err instanceof URIError) { 19 | err.message = `The encoded phrase ${values[0]} could not be URI decoded. Make sure your url encoded values (%xx) are within valid ranges. xx should be hexadecimal representations`; 20 | } 21 | return Promise.reject(err); 22 | } 23 | let announceVolume; 24 | let language; 25 | 26 | if (/^\d+$/i.test(values[1])) { 27 | // first parameter is volume 28 | announceVolume = values[1]; 29 | // language = 'en-gb'; 30 | } else { 31 | language = values[1]; 32 | announceVolume = values[2] || settings.announceVolume || 40; 33 | } 34 | 35 | return tryDownloadTTS(text, language) 36 | .then((result) => { 37 | return singlePlayerAnnouncement(player, `http://${system.localEndpoint}:${port}${result.uri}`, announceVolume, result.duration); 38 | }); 39 | } 40 | 41 | module.exports = function (api) { 42 | port = api.getPort(); 43 | api.registerAction('say', say); 44 | 45 | system = api.discovery; 46 | } 47 | -------------------------------------------------------------------------------- /lib/actions/sayall.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const tryDownloadTTS = require('../helpers/try-download-tts'); 3 | const allPlayerAnnouncement = require('../helpers/all-player-announcement'); 4 | const settings = require('../../settings'); 5 | 6 | let port; 7 | let system; 8 | 9 | function sayAll(player, values) { 10 | let text; 11 | try { 12 | text = decodeURIComponent(values[0]); 13 | } catch (err) { 14 | if (err instanceof URIError) { 15 | err.message = `The encoded phrase ${values[0]} could not be URI decoded. Make sure your url encoded values (%xx) are within valid ranges. xx should be hexadecimal representations`; 16 | } 17 | return Promise.reject(err); 18 | } 19 | let announceVolume; 20 | let language; 21 | 22 | if (/^\d+$/i.test(values[1])) { 23 | // first parameter is volume 24 | announceVolume = values[1]; 25 | // language = 'en-gb'; 26 | } else { 27 | language = values[1]; 28 | announceVolume = values[2] || settings.announceVolume || 40; 29 | } 30 | 31 | 32 | return tryDownloadTTS(text, language) 33 | .then((result) => { 34 | return allPlayerAnnouncement(player.system, `http://${player.system.localEndpoint}:${port}${result.uri}`, announceVolume, result.duration); 35 | }) 36 | 37 | } 38 | 39 | module.exports = function (api) { 40 | port = api.getPort(); 41 | api.registerAction('sayall', sayAll); 42 | }; 43 | -------------------------------------------------------------------------------- /lib/actions/saypreset.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const tryDownloadTTS = require('../helpers/try-download-tts'); 3 | const presetAnnouncement = require('../helpers/preset-announcement'); 4 | const presets = require('../presets-loader'); 5 | 6 | let port; 7 | let system; 8 | 9 | function sayPreset(player, values) { 10 | let text; 11 | const presetName = decodeURIComponent(values[0]); 12 | 13 | const preset = presets[presetName]; 14 | 15 | if (!preset) { 16 | return Promise.reject(new Error(`No preset named ${presetName} could be found`)); 17 | } 18 | 19 | try { 20 | text = decodeURIComponent(values[1]); 21 | } catch (err) { 22 | if (err instanceof URIError) { 23 | err.message = `The encoded phrase ${values[0]} could not be URI decoded. Make sure your url encoded values (%xx) are within valid ranges. xx should be hexadecimal representations`; 24 | } 25 | return Promise.reject(err); 26 | } 27 | 28 | const language = values[2]; 29 | 30 | return tryDownloadTTS(text, language) 31 | .then((result) => { 32 | return presetAnnouncement(player.system, `http://${player.system.localEndpoint}:${port}${result.uri}`, preset, result.duration); 33 | }) 34 | 35 | } 36 | 37 | module.exports = function (api) { 38 | port = api.getPort(); 39 | api.registerAction('saypreset', sayPreset); 40 | }; 41 | -------------------------------------------------------------------------------- /lib/actions/seek.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | function timeSeek(player, values) { 3 | return player.coordinator.timeSeek(values[0]); 4 | } 5 | 6 | function trackSeek(player, values) { 7 | return player.coordinator.trackSeek(values[0]*1); 8 | } 9 | 10 | module.exports = function (api) { 11 | api.registerAction('seek', timeSeek); // deprecated 12 | api.registerAction('timeseek', timeSeek); 13 | api.registerAction('trackseek', trackSeek); 14 | } -------------------------------------------------------------------------------- /lib/actions/services.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function services(player, values) { 4 | if (values[0] === 'all') { 5 | return Promise.resolve(player.system.availableServices); 6 | } 7 | 8 | return Promise.resolve(); 9 | } 10 | 11 | module.exports = (api) => { 12 | api.registerAction('services', services); 13 | }; -------------------------------------------------------------------------------- /lib/actions/setavtransporturi.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | function setAVTransportURI(player, values) { 3 | return player.setAVTransport(decodeURIComponent(values[0])); 4 | } 5 | 6 | module.exports = function (api) { 7 | api.registerAction('setavtransporturi', setAVTransportURI); 8 | } -------------------------------------------------------------------------------- /lib/actions/siriusXM.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const request = require('request-promise'); 3 | const Fuse = require('fuse.js'); 4 | const channels = require('../sirius-channels.json'); 5 | 6 | function getSiriusXmMetadata(id, parent, title) { 7 | return ` 9 | ${title}object.item.audioItem.audioBroadcast 10 | _`; 11 | } 12 | 13 | function getSiriusXmUri(id) { 14 | return `x-sonosapi-hls:r%3a${id}?sid=37&flags=8480&sn=11`; 15 | } 16 | 17 | const replaceArray = ['ñ|n','á|a','ó|o','è|e','ë|e','/| ','-| ','siriusxm|sirius XM','sxm|SXM','cnn|CNN','hln|HLN','msnbc|MSNBC','bbc|BBC', 18 | 'ici|ICI','prx|PRX','cbc|CBC','npr|NPR','espn|ESPN',' ny| NY','kiis|KIIS','&|and','ami|AMI','z1|Z1','2k|2K','bb |BB ']; 19 | 20 | function adjustStation(name) { 21 | name = name.toLowerCase(); 22 | for (var i=0;i < replaceArray.length;i++) 23 | name = name.replace(replaceArray[i].split('|')[0],replaceArray[i].split('|')[1]); 24 | 25 | return name; 26 | } 27 | 28 | function siriusXM(player, values) { 29 | var auth = ''; 30 | var results = []; 31 | 32 | // Used to generate channel data for the channels array. Results are sent to the console after loading Sonos Favorites with a number of SiriusXM Channels 33 | if (values[0] == 'data') { 34 | return player.system.getFavorites() 35 | .then((favorites) => { 36 | return favorites.reduce(function(promise, item) { 37 | if (item.uri.startsWith('x-sonosapi-hls:')) { 38 | var title = item.title.replace("'",''); 39 | 40 | console.log("{fullTitle:'" + title + 41 | "', channelNum:'" + title.substring(0,title.search(' - ')) + 42 | "', title:'" + title.substring(title.search(' - ')+3,title.length) + 43 | "', id:'" + item.uri.substring(item.uri.search('r%3a') + 4,item.uri.search('sid=')-1) + 44 | "', parentID:'" + item.metadata.substring(item.metadata.search('parentID=') + 10,item.metadata.search(' restricted')-1) + "'},"); 45 | } 46 | return promise; 47 | }, Promise.resolve("success")); 48 | }); 49 | } else 50 | // Used to send a list of channel numbers specified below in channels for input into an Alexa slot 51 | if (values[0] == 'channels') { 52 | var cList = channels.map(function(channel) { 53 | return channel.channelNum; 54 | }); 55 | cList.sort(function(a,b) {return a-b;}).map(function(channel) { 56 | console.log(channel); 57 | }); 58 | 59 | return Promise.resolve("success"); 60 | } else 61 | // Used to send a list of station titles specified below in channels for input into an Alexa slot 62 | if (values[0] == 'stations') { 63 | var sList = channels.map(function(channel){ 64 | console.log(adjustStation(channel.title)); 65 | }); 66 | return Promise.resolve("success"); 67 | } else { 68 | // Play the specified SiriusXM channel or station 69 | var searchVal = values[0]; 70 | var fuzzy = new Fuse(channels, { keys: ["channelNum", "title"] }); 71 | 72 | results = fuzzy.search(searchVal); 73 | if (results.length > 0) { 74 | const channel = results[0]; 75 | const uri = getSiriusXmUri(channel.item.id); 76 | const metadata = getSiriusXmMetadata(channel.item.id, channel.item.parentID, channel.item.fullTitle); 77 | 78 | return player.coordinator.setAVTransport(uri, metadata) 79 | .then(() => player.coordinator.play()); 80 | } 81 | } 82 | } 83 | 84 | module.exports = function (api) { 85 | api.registerAction('siriusxm', siriusXM); 86 | }; 87 | -------------------------------------------------------------------------------- /lib/actions/sleep.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function sleep(player, values) { 4 | let timestamp = 0; 5 | if (/^\d+$/.test(values[0])) { 6 | // only digits 7 | timestamp = values[0]; 8 | } else if (values[0].toLowerCase() != 'off') { 9 | // broken input 10 | return Promise.resolve(); 11 | } 12 | return player.coordinator.sleep(timestamp); 13 | } 14 | 15 | module.exports = function (api) { 16 | api.registerAction('sleep', sleep); 17 | } -------------------------------------------------------------------------------- /lib/actions/spotify.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function getSpotifyMetadata(uri, serviceType) { 4 | return ` 6 | object.item.audioItem.musicTrack 7 | SA_RINCON${serviceType}_X_#Svc${serviceType}-0-Token`; 8 | } 9 | 10 | function spotify(player, values) { 11 | const action = values[0]; 12 | const spotifyUri = values[1]; 13 | const encodedSpotifyUri = encodeURIComponent(spotifyUri); 14 | const sid = player.system.getServiceId('Spotify'); 15 | 16 | let uri; 17 | 18 | //check if current uri is either a track or a playlist/album 19 | if (spotifyUri.startsWith('spotify:track:')) { 20 | uri = `x-sonos-spotify:${encodedSpotifyUri}?sid=${sid}&flags=32&sn=1`; 21 | } else { 22 | uri = `x-rincon-cpcontainer:0006206c${encodedSpotifyUri}`; 23 | } 24 | 25 | var metadata = getSpotifyMetadata(encodedSpotifyUri, player.system.getServiceType('Spotify')); 26 | 27 | if (action == 'queue') { 28 | return player.coordinator.addURIToQueue(uri, metadata); 29 | } else if (action == 'now') { 30 | var nextTrackNo = player.coordinator.state.trackNo + 1; 31 | let promise = Promise.resolve(); 32 | return promise.then(() => player.coordinator.setAVTransport(`x-rincon-queue:${player.coordinator.uuid}#0`)) 33 | .then(() => player.coordinator.addURIToQueue(uri, metadata, true, nextTrackNo)) 34 | .then((addToQueueStatus) => player.coordinator.trackSeek(addToQueueStatus.firsttracknumberenqueued)) 35 | .then(() => player.coordinator.play()); 36 | 37 | } else if (action == 'next') { 38 | var nextTrackNo = player.coordinator.state.trackNo + 1; 39 | return player.coordinator.addURIToQueue(uri, metadata, true, nextTrackNo); 40 | } 41 | } 42 | 43 | module.exports = function (api) { 44 | api.registerAction('spotify', spotify); 45 | } 46 | -------------------------------------------------------------------------------- /lib/actions/state.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function state(player) { 4 | return Promise.resolve(player.state); 5 | } 6 | 7 | module.exports = function (api) { 8 | api.registerAction('state', state); 9 | } 10 | -------------------------------------------------------------------------------- /lib/actions/sub.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function sub(player, values) { 4 | if (!player.hasSub) { 5 | return Promise.reject(new Error('This zone doesn\'t have a SUB connected')); 6 | } 7 | 8 | const action = values[0]; 9 | const value = values[1]; 10 | 11 | switch (action) { 12 | case 'on': 13 | return player.subEnable(); 14 | case 'off': 15 | return player.subDisable(); 16 | case 'gain': 17 | return player.subGain(value); 18 | case 'crossover': 19 | return player.subCrossover(value); 20 | case 'polarity': 21 | return player.subPolarity(value); 22 | } 23 | 24 | return Promise.resolve({ 25 | message: 'Valid options are on, off, gain, crossover, polarity' 26 | }); 27 | } 28 | 29 | module.exports = function (api) { 30 | api.registerAction('sub', sub); 31 | } -------------------------------------------------------------------------------- /lib/actions/tunein.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function getTuneInMetadata(uri, serviceType) { 4 | return ` 6 | tuneinobject.item.audioItem.audioBroadcast 7 | SA_RINCON${serviceType}_`; 8 | } 9 | 10 | function tuneIn(player, values) { 11 | const action = values[0]; 12 | const tuneInUri = values[1]; 13 | const encodedTuneInUri = encodeURIComponent(tuneInUri); 14 | const sid = player.system.getServiceId('TuneIn'); 15 | const metadata = getTuneInMetadata(encodedTuneInUri, player.system.getServiceType('TuneIn')); 16 | const uri = `x-sonosapi-stream:s${encodedTuneInUri}?sid=${sid}&flags=8224&sn=0`; 17 | 18 | if (!tuneInUri) { 19 | return Promise.reject('Expected TuneIn station id'); 20 | } 21 | 22 | if (action == 'play') { 23 | return player.coordinator.setAVTransport(uri, metadata) 24 | .then(() => player.coordinator.play()); 25 | } 26 | if (action == 'set') { 27 | return player.coordinator.setAVTransport(uri, metadata); 28 | } 29 | 30 | return Promise.reject('TuneIn only handles the {play} & {set} action'); 31 | } 32 | 33 | module.exports = function (api) { 34 | api.registerAction('tunein', tuneIn); 35 | } 36 | -------------------------------------------------------------------------------- /lib/actions/volume.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | function volume(player, values) { 3 | var volume = values[0]; 4 | return player.setVolume(volume); 5 | } 6 | 7 | function groupVolume(player, values) { 8 | return player.coordinator.setGroupVolume(values[0]); 9 | } 10 | 11 | module.exports = function (api) { 12 | api.registerAction('volume', volume); 13 | api.registerAction('groupvolume', groupVolume); 14 | } -------------------------------------------------------------------------------- /lib/actions/zones.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function simplifyPlayer(player) { 4 | return { 5 | uuid: player.uuid, 6 | state: player.state, 7 | playMode: player.currentPlayMode, 8 | roomName: player.roomName, 9 | coordinator: player.coordinator.uuid, 10 | groupState: player.groupState 11 | }; 12 | } 13 | 14 | function simplifyZones(zones) { 15 | return zones.map((zone) => { 16 | return { 17 | uuid: zone.uuid, 18 | coordinator: simplifyPlayer(zone.coordinator), 19 | members: zone.members.map(simplifyPlayer) 20 | }; 21 | }); 22 | }; 23 | 24 | function zones(player) { 25 | return Promise.resolve(simplifyZones(player.system.zones)); 26 | } 27 | 28 | module.exports = function (api) { 29 | api.registerAction('zones', zones); 30 | } -------------------------------------------------------------------------------- /lib/helpers/all-player-announcement.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const logger = require('sonos-discovery/lib/helpers/logger'); 3 | const isRadioOrLineIn = require('../helpers/is-radio-or-line-in'); 4 | 5 | function saveAll(system) { 6 | const backupPresets = system.zones.map((zone) => { 7 | const coordinator = zone.coordinator; 8 | const state = coordinator.state; 9 | const preset = { 10 | players: [ 11 | { roomName: coordinator.roomName, volume: state.volume } 12 | ], 13 | state: state.playbackState, 14 | uri: coordinator.avTransportUri, 15 | metadata: coordinator.avTransportUriMetadata, 16 | playMode: { 17 | repeat: state.playMode.repeat 18 | } 19 | }; 20 | 21 | if (!isRadioOrLineIn(preset.uri)) { 22 | preset.trackNo = state.trackNo; 23 | preset.elapsedTime = state.elapsedTime; 24 | } 25 | 26 | zone.members.forEach(function (player) { 27 | if (coordinator.uuid != player.uuid) 28 | preset.players.push({ roomName: player.roomName, volume: player.state.volume }); 29 | }); 30 | 31 | return preset; 32 | 33 | }); 34 | 35 | logger.trace('backup presets', backupPresets); 36 | return backupPresets.sort((a,b) => { 37 | return a.players.length < b.players.length; 38 | }); 39 | } 40 | 41 | function announceAll(system, uri, volume, duration) { 42 | let abortTimer; 43 | 44 | // Save all players 45 | var backupPresets = saveAll(system); 46 | 47 | // find biggest group and all players 48 | const allPlayers = []; 49 | let biggestZone = {}; 50 | system.zones.forEach(function (zone) { 51 | if (!biggestZone.members || zone.members.length > biggestZone.members.length) { 52 | biggestZone = zone; 53 | } 54 | }); 55 | 56 | const coordinator = biggestZone.coordinator; 57 | 58 | allPlayers.push({ roomName: coordinator.roomName, volume }); 59 | 60 | system.players.forEach(player => { 61 | if (player.uuid == coordinator.uuid) return; 62 | allPlayers.push({ roomName: player.roomName, volume }); 63 | }); 64 | 65 | const preset = { 66 | uri, 67 | players: allPlayers, 68 | playMode: { 69 | repeat: false 70 | }, 71 | pauseOthers: true, 72 | state: 'STOPPED' 73 | }; 74 | 75 | const oneGroupPromise = new Promise((resolve) => { 76 | const onTopologyChanged = (topology) => { 77 | if (topology.length === 1) { 78 | return resolve(); 79 | } 80 | // Not one group yet, continue listening 81 | system.once('topology-change', onTopologyChanged); 82 | }; 83 | 84 | system.once('topology-change', onTopologyChanged); 85 | }); 86 | 87 | const restoreTimeout = duration + 2000; 88 | return system.applyPreset(preset) 89 | .then(() => { 90 | if (system.zones.length === 1) return; 91 | return oneGroupPromise; 92 | }) 93 | .then(() => { 94 | coordinator.play(); 95 | return new Promise((resolve) => { 96 | const transportChange = (state) => { 97 | logger.debug(`Player changed to state ${state.playbackState}`); 98 | if (state.playbackState === 'STOPPED') { 99 | return resolve(); 100 | } 101 | 102 | coordinator.once('transport-state', transportChange); 103 | }; 104 | setTimeout(() => { 105 | coordinator.once('transport-state', transportChange); 106 | }, duration / 2); 107 | 108 | logger.debug(`Setting restore timer for ${restoreTimeout} ms`); 109 | abortTimer = setTimeout(resolve, restoreTimeout); 110 | }); 111 | }) 112 | .then(() => { 113 | clearTimeout(abortTimer); 114 | }) 115 | .then(() => { 116 | return backupPresets.reduce((promise, preset) => { 117 | logger.trace('Restoring preset', preset); 118 | return promise.then(() => system.applyPreset(preset)); 119 | }, Promise.resolve()); 120 | }) 121 | .catch((err) => { 122 | logger.error(err.stack); 123 | throw err; 124 | }); 125 | 126 | } 127 | 128 | module.exports = announceAll; 129 | -------------------------------------------------------------------------------- /lib/helpers/file-duration.js: -------------------------------------------------------------------------------- 1 | const musicMeta = require('music-metadata'); 2 | 3 | function fileDuration(path) { 4 | return musicMeta.parseFile(path, { duration: true }) 5 | .then((info) => { 6 | return Math.ceil(info.format.duration * 1000); 7 | }) 8 | } 9 | 10 | module.exports = fileDuration; 11 | -------------------------------------------------------------------------------- /lib/helpers/http-event-server.js: -------------------------------------------------------------------------------- 1 | function HttpEventServer() { 2 | let clients = []; 3 | 4 | const removeClient = client => clients = clients.filter(value => value !== client); 5 | 6 | this.addClient = res => clients.push(new HttpEventSource(res, removeClient)); 7 | 8 | this.sendEvent = event => clients.forEach(client => client.sendEvent(event)) 9 | } 10 | 11 | function HttpEventSource(res, done) { 12 | this.sendEvent = event => res.write('data: ' + event + '\n\n') 13 | 14 | res.on('close', () => done(this)) 15 | 16 | res.setHeader('Content-Type', 'text/event-stream'); 17 | } 18 | 19 | module.exports = HttpEventServer; 20 | -------------------------------------------------------------------------------- /lib/helpers/is-radio-or-line-in.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function isRadioOrLineIn(uri) { 4 | return uri.startsWith('x-sonosapi-stream:') || 5 | uri.startsWith('x-sonosapi-radio:') || 6 | uri.startsWith('pndrradio:') || 7 | uri.startsWith('x-sonosapi-hls:') || 8 | uri.startsWith('x-rincon-stream:') || 9 | uri.startsWith('x-sonos-htastream:') || 10 | uri.startsWith('x-sonosprog-http:') || 11 | uri.startsWith('x-rincon-mp3radio:'); 12 | } 13 | 14 | module.exports = isRadioOrLineIn; 15 | -------------------------------------------------------------------------------- /lib/helpers/preset-announcement.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const logger = require('sonos-discovery/lib/helpers/logger'); 3 | const isRadioOrLineIn = require('../helpers/is-radio-or-line-in'); 4 | 5 | function saveAll(system) { 6 | const backupPresets = system.zones.map((zone) => { 7 | const coordinator = zone.coordinator; 8 | const state = coordinator.state; 9 | const preset = { 10 | players: [ 11 | { roomName: coordinator.roomName, volume: state.volume } 12 | ], 13 | state: state.playbackState, 14 | uri: coordinator.avTransportUri, 15 | metadata: coordinator.avTransportUriMetadata, 16 | playMode: { 17 | repeat: state.playMode.repeat 18 | } 19 | }; 20 | 21 | if (!isRadioOrLineIn(preset.uri)) { 22 | preset.trackNo = state.trackNo; 23 | preset.elapsedTime = state.elapsedTime; 24 | } 25 | 26 | zone.members.forEach(function (player) { 27 | if (coordinator.uuid != player.uuid) 28 | preset.players.push({ roomName: player.roomName, volume: player.state.volume }); 29 | }); 30 | 31 | return preset; 32 | 33 | }); 34 | 35 | logger.trace('backup presets', backupPresets); 36 | return backupPresets.sort((a, b) => { 37 | return a.players.length < b.players.length; 38 | }); 39 | } 40 | 41 | function announcePreset(system, uri, preset, duration) { 42 | let abortTimer; 43 | 44 | // Save all players 45 | var backupPresets = saveAll(system); 46 | 47 | const simplifiedPreset = { 48 | uri, 49 | players: preset.players, 50 | playMode: preset.playMode, 51 | pauseOthers: true, 52 | state: 'STOPPED' 53 | }; 54 | 55 | function hasReachedCorrectTopology(zones) { 56 | return zones.some(group => 57 | group.members.length === preset.players.length && 58 | group.coordinator.roomName === preset.players[0].roomName); 59 | } 60 | 61 | const oneGroupPromise = new Promise((resolve) => { 62 | const onTopologyChanged = (topology) => { 63 | if (hasReachedCorrectTopology(topology)) { 64 | return resolve(); 65 | } 66 | // Not one group yet, continue listening 67 | system.once('topology-change', onTopologyChanged); 68 | }; 69 | 70 | system.once('topology-change', onTopologyChanged); 71 | }); 72 | 73 | const restoreTimeout = duration + 2000; 74 | const coordinator = system.getPlayer(preset.players[0].roomName); 75 | return coordinator.pause() 76 | .then(() => system.applyPreset(simplifiedPreset)) 77 | .catch(() => system.applyPreset(simplifiedPreset)) 78 | .then(() => { 79 | if (hasReachedCorrectTopology(system.zones)) return; 80 | return oneGroupPromise; 81 | }) 82 | .then(() => { 83 | coordinator.play(); 84 | return new Promise((resolve) => { 85 | const transportChange = (state) => { 86 | logger.debug(`Player changed to state ${state.playbackState}`); 87 | if (state.playbackState === 'STOPPED') { 88 | return resolve(); 89 | } 90 | 91 | coordinator.once('transport-state', transportChange); 92 | }; 93 | setTimeout(() => { 94 | coordinator.once('transport-state', transportChange); 95 | }, duration / 2); 96 | logger.debug(`Setting restore timer for ${restoreTimeout} ms`); 97 | abortTimer = setTimeout(resolve, restoreTimeout); 98 | }); 99 | }) 100 | .then(() => { 101 | clearTimeout(abortTimer); 102 | }) 103 | .then(() => { 104 | return backupPresets.reduce((promise, preset) => { 105 | logger.trace('Restoring preset', preset); 106 | return promise.then(() => system.applyPreset(preset)); 107 | }, Promise.resolve()); 108 | }) 109 | .catch((err) => { 110 | logger.error(err.stack); 111 | throw err; 112 | }); 113 | 114 | } 115 | 116 | module.exports = announcePreset; -------------------------------------------------------------------------------- /lib/helpers/require-dir.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | module.exports = function (cwd, cb) { 6 | let files = fs.readdirSync(cwd); 7 | 8 | files.map((name) => { 9 | let fullPath = path.join(cwd, name); 10 | return { 11 | name, 12 | fullPath, 13 | stat: fs.statSync(fullPath) 14 | }; 15 | }).filter((file) => { 16 | return !file.stat.isDirectory() && !file.name.startsWith('.') && file.name.endsWith('.js'); 17 | }).forEach((file) => { 18 | cb(require(file.fullPath)); 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /lib/helpers/single-player-announcement.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const logger = require('sonos-discovery/lib/helpers/logger'); 3 | const isRadioOrLineIn = require('../helpers/is-radio-or-line-in'); 4 | const backupPresets = {}; 5 | 6 | function singlePlayerAnnouncement(player, uri, volume, duration) { 7 | // Create backup preset to restore this player 8 | const state = player.state; 9 | const system = player.system; 10 | 11 | let groupToRejoin; 12 | 13 | const backupPreset = { 14 | players: [ 15 | { roomName: player.roomName, volume: state.volume } 16 | ] 17 | }; 18 | 19 | if (player.coordinator.uuid == player.uuid) { 20 | // This one is coordinator, you will need to rejoin 21 | // remember which group you were part of. 22 | const group = system.zones.find(zone => zone.coordinator.uuid === player.coordinator.uuid); 23 | if (group.members.length > 1) { 24 | logger.debug('Think its coordinator, will find uri later'); 25 | groupToRejoin = group.id; 26 | backupPreset.group = group.id; 27 | } else { 28 | // was stand-alone, so keep state 29 | backupPreset.state = state.playbackState; 30 | backupPreset.uri = player.avTransportUri; 31 | backupPreset.metadata = player.avTransportUriMetadata; 32 | backupPreset.playMode = { 33 | repeat: state.playMode.repeat 34 | }; 35 | 36 | if (!isRadioOrLineIn(backupPreset.uri)) { 37 | backupPreset.trackNo = state.trackNo; 38 | backupPreset.elapsedTime = state.elapsedTime; 39 | } 40 | 41 | } 42 | } else { 43 | // Was grouped, so we use the group uri here directly. 44 | backupPreset.uri = `x-rincon:${player.coordinator.uuid}`; 45 | } 46 | 47 | logger.debug('backup state was', backupPreset); 48 | 49 | // Use the preset action to play the tts file 50 | var ttsPreset = { 51 | players: [ 52 | { roomName: player.roomName, volume } 53 | ], 54 | playMode: { 55 | repeat: false 56 | }, 57 | uri 58 | }; 59 | 60 | let abortTimer; 61 | 62 | if (!backupPresets[player.roomName]) { 63 | backupPresets[player.roomName] = []; 64 | } 65 | 66 | backupPresets[player.roomName].unshift(backupPreset); 67 | logger.debug('backup presets array', backupPresets[player.roomName]); 68 | 69 | const prepareBackupPreset = () => { 70 | if (backupPresets[player.roomName].length > 1) { 71 | backupPresets[player.roomName].shift(); 72 | logger.debug('more than 1 backup presets during prepare', backupPresets[player.roomName]); 73 | return Promise.resolve(); 74 | } 75 | 76 | if (backupPresets[player.roomName].length < 1) { 77 | return Promise.resolve(); 78 | } 79 | 80 | const relevantBackupPreset = backupPresets[player.roomName][0]; 81 | 82 | logger.debug('exactly 1 preset left', relevantBackupPreset); 83 | 84 | if (relevantBackupPreset.group) { 85 | const zone = system.zones.find(zone => zone.id === relevantBackupPreset.group); 86 | if (zone) { 87 | relevantBackupPreset.uri = `x-rincon:${zone.uuid}`; 88 | } 89 | } 90 | 91 | logger.debug('applying preset', relevantBackupPreset); 92 | return system.applyPreset(relevantBackupPreset) 93 | .then(() => { 94 | backupPresets[player.roomName].shift(); 95 | logger.debug('after backup preset applied', backupPresets[player.roomName]); 96 | }); 97 | } 98 | 99 | let timer; 100 | const restoreTimeout = duration + 2000; 101 | return system.applyPreset(ttsPreset) 102 | .then(() => { 103 | return new Promise((resolve) => { 104 | const transportChange = (state) => { 105 | logger.debug(`Player changed to state ${state.playbackState}`); 106 | if (state.playbackState === 'STOPPED') { 107 | return resolve(); 108 | } 109 | 110 | player.once('transport-state', transportChange); 111 | }; 112 | setTimeout(() => { 113 | player.once('transport-state', transportChange); 114 | }, duration / 2); 115 | 116 | logger.debug(`Setting restore timer for ${restoreTimeout} ms`); 117 | timer = Date.now(); 118 | abortTimer = setTimeout(resolve, restoreTimeout); 119 | }); 120 | }) 121 | .then(() => { 122 | const elapsed = Date.now() - timer; 123 | logger.debug(`${elapsed} elapsed with ${restoreTimeout - elapsed} to spare`); 124 | clearTimeout(abortTimer); 125 | }) 126 | .then(prepareBackupPreset) 127 | .catch((err) => { 128 | logger.error(err); 129 | return prepareBackupPreset() 130 | .then(() => { 131 | throw err; 132 | }); 133 | }); 134 | } 135 | 136 | module.exports = singlePlayerAnnouncement; 137 | -------------------------------------------------------------------------------- /lib/helpers/try-download-tts.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const path = require('path'); 3 | const requireDir = require('sonos-discovery/lib/helpers/require-dir'); 4 | const providers = []; 5 | 6 | requireDir(path.join(__dirname, '../tts-providers'), (provider) => { 7 | providers.push(provider); 8 | }); 9 | 10 | providers.push(require('../tts-providers/default/google')); 11 | 12 | function tryDownloadTTS(phrase, language) { 13 | let result; 14 | return providers.reduce((promise, provider) => { 15 | return promise.then(() => { 16 | if (result) return result; 17 | return provider(phrase, language) 18 | .then((_result) => { 19 | result = _result; 20 | return result; 21 | }); 22 | }); 23 | }, Promise.resolve()); 24 | } 25 | 26 | module.exports = tryDownloadTTS; -------------------------------------------------------------------------------- /lib/helpers/try-load-json.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const JSON5 = require('json5'); 3 | const logger = require('sonos-discovery/lib/helpers/logger'); 4 | 5 | function tryLoadJson(path) { 6 | try { 7 | const fileContent = fs.readFileSync(path); 8 | const parsedContent = JSON5.parse(fileContent); 9 | return parsedContent; 10 | } catch (e) { 11 | if (e.code === 'ENOENT') { 12 | logger.info(`Could not find file ${path}`); 13 | } else { 14 | logger.warn(`Could not read file ${path}, ignoring.`, e); 15 | } 16 | } 17 | return {}; 18 | } 19 | 20 | module.exports = tryLoadJson; -------------------------------------------------------------------------------- /lib/music_services/appleDef.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const appleDef = { 3 | country: '&country=', 4 | search: { 5 | album: 'https://itunes.apple.com/search?media=music&limit=1&entity=album&attribute=albumTerm&term=', 6 | song: 'https://itunes.apple.com/search?media=music&limit=50&entity=song&term=', 7 | station: 'https://itunes.apple.com/search?media=music&limit=50&entity=musicArtist&term=' 8 | }, 9 | metastart: { 10 | album: '0004206calbum%3a', 11 | song: '00032020song%3a', 12 | station: '000c206cradio%3ara.' 13 | }, 14 | parent: { 15 | album: '00020000album:', 16 | song: '00020000song:', 17 | station: '00020000radio:' 18 | }, 19 | object: { 20 | album: 'container.album.musicAlbum.#AlbumView', 21 | song: 'item.audioItem.musicTrack.#SongTitleWithArtistAndAlbum', 22 | station: 'item.audioItem.audioBroadcast' 23 | }, 24 | 25 | service: setService, 26 | term: getSearchTerm, 27 | tracks: loadTracks, 28 | empty: isEmpty, 29 | metadata: getMetadata, 30 | urimeta: getURIandMetadata, 31 | headers: getTokenHeaders, 32 | authenticate: authenticateService 33 | } 34 | 35 | function getTokenHeaders() { 36 | return null; 37 | }; 38 | 39 | function authenticateService() { 40 | return Promise.resolve(); 41 | } 42 | 43 | function getURI(type, id) { 44 | if (type == 'album') { 45 | return `x-rincon-cpcontainer:0004206calbum%3a${id}`; 46 | } else 47 | if (type == 'song') { 48 | return `x-sonos-http:song%3a${id}.mp4?sid=${sid}&flags=8224&sn=${accountSN}`; 49 | } else 50 | if (type == 'station') { 51 | return `x-sonosapi-radio:radio%3ara.${id}?sid=${sid}&flags=8300&sn=${accountSN}`; 52 | } 53 | } 54 | 55 | function getServiceToken() { 56 | return `SA_RINCON${serviceType}_X_#Svc${serviceType}-0-Token`; 57 | } 58 | 59 | 60 | var sid = ''; 61 | var serviceType = ''; 62 | var accountId = ''; 63 | var accountSN = ''; 64 | var country = ''; 65 | 66 | function setService(player, p_accountId, p_accountSN, p_country) 67 | { 68 | sid = player.system.getServiceId('Apple Music'); 69 | serviceType = player.system.getServiceType('Apple Music'); 70 | accountId = p_accountId; 71 | accountSN = p_accountSN; 72 | country = p_country; 73 | } 74 | 75 | function getSearchTerm(type, term, artist, album, track) { 76 | var newTerm = artist; 77 | 78 | if ((newTerm != '') && ((artist != '') || (track != ''))) { 79 | newTerm += ' '; 80 | } 81 | newTerm += (type == 'album')?album:track; 82 | newTerm = encodeURIComponent(newTerm); 83 | if (artist != '') { 84 | newTerm += '&attribute=artistTerm'; 85 | } 86 | if (track != '') { 87 | newTerm += '&attribute=songTerm'; 88 | } 89 | 90 | return newTerm; 91 | } 92 | 93 | function getMetadata(type, id, name, title) { 94 | const token = getServiceToken(); 95 | const parentUri = appleDef.parent[type] + name; 96 | const objectType = appleDef.object[type]; 97 | 98 | if (type == 'station') { 99 | title = title + ' Radio'; 100 | } else { 101 | title = ''; 102 | } 103 | 104 | return ` 106 | ${title}object.${objectType} 107 | ${token}`; 108 | } 109 | 110 | function getURIandMetadata(type, resList) 111 | { 112 | var Id = ''; 113 | var Title = ''; 114 | var Name = ''; 115 | var MetadataID = ''; 116 | var UaM = { 117 | uri: '', 118 | metadata: '' 119 | }; 120 | 121 | if (type=='album') { 122 | Id = resList.results[0].collectionId; 123 | Title = resList.results[0].collectionName; 124 | } else 125 | if (type=='station') { 126 | Id = resList.results[0].artistId; 127 | Title = resList.results[0].artistName; 128 | } else { 129 | Id = resList.results[0].id; 130 | Title = resList.results[0].name; 131 | } 132 | Name = Title.toLowerCase().replace(' radio','').replace('radio ','').replace("'","'"); 133 | MetadataID = appleDef.metastart[type] + encodeURIComponent(Id); 134 | 135 | UaM.metadata = getMetadata(type, MetadataID, Name, Title); 136 | UaM.uri = getURI(type, encodeURIComponent(Id)); 137 | 138 | return UaM; 139 | } 140 | 141 | function loadTracks(type, tracksJson) { 142 | var tracks = { count : 0, 143 | isArtist : false, 144 | queueTracks : [] 145 | }; 146 | 147 | if (tracksJson.resultCount > 0) { 148 | // Filtered list of tracks to play 149 | tracks.queueTracks = tracksJson.results.reduce(function(tracksArray, track) { 150 | if (track.isStreamable) { 151 | var skip = false; 152 | 153 | for (var j=0; (j < tracksArray.length) && !skip ; j++) { 154 | // Skip duplicate songs 155 | skip = (track.trackName == tracksArray[j].trackName); 156 | } 157 | 158 | if (!skip) { 159 | var metadataID = appleDef.metastart['song'] + encodeURIComponent(track.trackId); 160 | var metadata = getMetadata('song', metadataID, track.trackId, track.trackName); 161 | var uri = getURI('song', encodeURIComponent(track.trackId)); 162 | 163 | tracksArray.push({trackName:track.trackName, artistName:track.artistName, uri:uri, metadata:metadata}); 164 | tracks.count++; 165 | } 166 | } 167 | return tracksArray; 168 | }, []); 169 | } 170 | 171 | return tracks; 172 | } 173 | 174 | function isEmpty(type, resList) 175 | { 176 | return (resList.resultCount == 0); 177 | } 178 | 179 | module.exports = appleDef; 180 | 181 | -------------------------------------------------------------------------------- /lib/music_services/deezerDef.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const deezerDef = { 3 | country: '', 4 | search: { 5 | album: 'https://api.deezer.com/search?limit=1&q=album:', 6 | song: 'https://api.deezer.com/search?limit=50&q=', 7 | station: 'https://api.deezer.com/search?limit=1&q=artist:' 8 | }, 9 | metastart: { 10 | album: '0004006calbum-', 11 | song: '00032020tr%3a', 12 | station: '000c0068radio-artist-' 13 | }, 14 | parent: { 15 | album: '00020000search-album:', 16 | song: '00020000search-track:', 17 | station: '00050064artist-' 18 | }, 19 | object: { 20 | album: 'container.album.musicAlbum.#DEFAULT', 21 | song: 'item.audioItem.musicTrack.#DEFAULT', 22 | station: 'item.audioItem.audioBroadcast.#DEFAULT' 23 | }, 24 | 25 | init: function(flacOn) { this.song = (flacOn)?'00032020tr-flac%3a':'00032020tr%3a'; return this;}, 26 | service: setService, 27 | term: getSearchTerm, 28 | tracks: loadTracks, 29 | empty: isEmpty, 30 | metadata: getMetadata, 31 | urimeta: getURIandMetadata, 32 | headers: getTokenHeaders, 33 | authenticate: authenticateService 34 | } 35 | 36 | function getTokenHeaders() { 37 | return null; 38 | } 39 | 40 | function authenticateService() { 41 | return Promise.resolve(); 42 | } 43 | 44 | function getURI(type, id) { 45 | if (type == 'album') { 46 | return `x-rincon-cpcontainer:0004006calbum-${id}`; 47 | } else 48 | if (type == 'song') { 49 | return `x-sonos-http:tr%3a${id}.mp3?sid=${sid}&flags=8224&sn=${accountSN}`; 50 | } else 51 | if (type == 'station') { 52 | return `x-sonosapi-radio:radio-artist-${id}?sid=${sid}&flags=104&sn=${accountSN}`; 53 | } 54 | } 55 | 56 | function getServiceToken() { 57 | return `SA_RINCON${serviceType}_${accountId}`; 58 | } 59 | 60 | 61 | var sid = ''; 62 | var serviceType = ''; 63 | var accountId = ''; 64 | var accountSN = ''; 65 | var country = ''; 66 | 67 | function setService(player, p_accountId, p_accountSN, p_country) 68 | { 69 | sid = player.system.getServiceId('Deezer'); 70 | serviceType = player.system.getServiceType('Deezer'); 71 | accountId = p_accountId; 72 | accountSN = p_accountSN; 73 | country = p_country; 74 | } 75 | 76 | function getSearchTerm(type, term, artist, album, track) { 77 | var newTerm = ''; 78 | 79 | if (album != '') { 80 | newTerm = album + ' '; 81 | } 82 | if (artist != '') { 83 | newTerm += 'artist:' + artist + ((track != '')?' ':''); 84 | } 85 | if (track != '') { 86 | newTerm += 'track:' + track; 87 | } 88 | newTerm = encodeURIComponent(newTerm); 89 | 90 | return newTerm; 91 | } 92 | 93 | function getMetadata(type, id, name, title) { 94 | const token = getServiceToken(); 95 | const parentUri = deezerDef.parent[type] + name; 96 | const objectType = deezerDef.object[type]; 97 | 98 | if (type != 'station') { 99 | title = ''; 100 | } 101 | 102 | return ` 104 | ${title}object.${objectType} 105 | ${token}`; 106 | } 107 | 108 | function getURIandMetadata(type, resList) 109 | { 110 | var Id = ''; 111 | var Title = ''; 112 | var Name = ''; 113 | var MetadataID = ''; 114 | var UaM = { 115 | uri: '', 116 | metadata: '' 117 | }; 118 | 119 | Id = (type=='album')?resList.data[0].album.id:resList.data[0].artist.id; 120 | Title = (type=='album')?resList.data[0].album.title:(resList.data[0].artist.name + ' Radio'); 121 | Name = Title.toLowerCase().replace(' radio','').replace('radio ','').replace("'","'"); 122 | MetadataID = deezerDef.metastart[type] + encodeURIComponent(Id); 123 | 124 | UaM.metadata = getMetadata(type, MetadataID, Id, Title); 125 | UaM.uri = getURI(type, encodeURIComponent(Id)); 126 | 127 | return UaM; 128 | } 129 | 130 | function loadTracks(type, tracksJson) 131 | { 132 | var tracks = { count : 0, 133 | isArtist : false, 134 | queueTracks : [] 135 | }; 136 | 137 | // Load the tracks from the json results data 138 | if (tracksJson.data.length > 0) { 139 | // Filtered list of tracks to play 140 | tracks.queueTracks = tracksJson.data.reduce(function(tracksArray, track) { 141 | var skip = false; 142 | 143 | for (var j=0; (j < tracksArray.length) && !skip ; j++) { 144 | // Skip duplicate songs 145 | skip = (track.title == tracksArray[j].trackName); 146 | } 147 | 148 | if (!skip) { 149 | var metadataID = deezerDef.metastart['song'] + encodeURIComponent(track.id); 150 | var metadata = getMetadata('song', metadataID, track.title.toLowerCase(), track.title); 151 | var uri = getURI('song', encodeURIComponent(track.id)); 152 | 153 | tracksArray.push({trackName:track.title, artistName:track.artist.name, uri:uri, metadata:metadata}); 154 | tracks.count++; 155 | } 156 | return tracksArray; 157 | }, []); 158 | } 159 | 160 | return tracks; 161 | } 162 | 163 | function isEmpty(type, resList) 164 | { 165 | return (resList.data.length == 0); 166 | } 167 | 168 | module.exports = deezerDef; 169 | 170 | 171 | -------------------------------------------------------------------------------- /lib/music_services/libraryDef.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Fuse = require('fuse.js'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const settings = require('../../settings'); 6 | const libraryPath = path.join(settings.cacheDir, 'library.json'); 7 | const logger = require('sonos-discovery/lib/helpers/logger'); 8 | 9 | var randomQueueLimit = (settings.library && settings.library.randomQueueLimit !== undefined)?settings.library.randomQueueLimit:50; 10 | 11 | var musicLibrary = null; 12 | var currentLibVersion = 1.4; 13 | var fuzzyTracks = null; 14 | var fuzzyAlbums = null; 15 | 16 | var isLoading = false; 17 | 18 | 19 | const libraryDef = { 20 | country: '', 21 | search: { 22 | album: '', 23 | song: '', 24 | station: '' 25 | }, 26 | metastart: { 27 | album: 'S:', 28 | song: 'S:', 29 | station: '' 30 | }, 31 | parent: { 32 | album: 'A:ALBUMARTIST/', 33 | song: 'A:ALBUMARTIST/', 34 | station: '' 35 | }, 36 | object: { 37 | album: 'item.audioItem.musicTrack', 38 | song: 'item.audioItem.musicTrack', 39 | station: '' 40 | }, 41 | token: 'RINCON_AssociatedZPUDN', 42 | 43 | service: setService, 44 | term: getSearchTerm, 45 | tracks: loadTracks, 46 | nolib: libIsEmpty, 47 | read: readLibrary, 48 | load: loadLibrarySearch, 49 | searchlib: searchLibrary, 50 | empty: isEmpty, 51 | metadata: getMetadata, 52 | urimeta: getURIandMetadata, 53 | headers: getTokenHeaders, 54 | authenticate: authenticateService 55 | } 56 | 57 | function getTokenHeaders() { 58 | return null; 59 | } 60 | 61 | function authenticateService() { 62 | return Promise.resolve(); 63 | } 64 | 65 | function setService(player, p_accountId, p_accountSN, p_country) { 66 | } 67 | 68 | function getSearchTerm(type, term, artist, album, track) { 69 | var newTerm = artist; 70 | 71 | if ((newTerm != '') && ((artist != '') || (track != ''))) { 72 | newTerm += ' '; 73 | } 74 | newTerm += (type == 'album') ? album : track; 75 | 76 | return newTerm; 77 | } 78 | 79 | function getMetadata(type, id, name) { 80 | const token = libraryDef.token; 81 | const parentUri = libraryDef.parent[type] + name; 82 | const objectType = libraryDef.object[type]; 83 | 84 | return ` 86 | object.${objectType} 87 | ${token}`; 88 | } 89 | 90 | function getURIandMetadata(type, resList) { 91 | return { uri: resList[0].uri, metadata: resList[0].metadata }; 92 | } 93 | 94 | function loadTracks(type, tracksJson) { 95 | var tracks = { 96 | count: 0, 97 | isArtist: false, 98 | queueTracks: [] 99 | }; 100 | 101 | if (tracksJson.length > 0) { 102 | var albumName = tracksJson[0].item.albumName; 103 | 104 | // Filtered list of tracks to play 105 | tracks.queueTracks = tracksJson.reduce(function(tracksArray, track) { 106 | if (tracks.count < randomQueueLimit) { 107 | var skip = false; 108 | 109 | if (type == 'song') { 110 | for (var j = 0; (j < tracksArray.length) && !skip; j++) { 111 | // Skip duplicate songs 112 | skip = (track.item.trackName == tracksArray[j].trackName); 113 | } 114 | } else { 115 | skip = (track.item.albumName != albumName); 116 | } 117 | if (!skip) { 118 | tracksArray.push({ 119 | trackName: track.item.trackName, 120 | artistName: track.item.artistName, 121 | albumTrackNumber:track.item.albumTrackNumber, 122 | uri: track.item.uri, 123 | metadata: track.item.metadata 124 | }); 125 | tracks.count++; 126 | } 127 | } 128 | return tracksArray; 129 | }, []); 130 | } 131 | 132 | if (type == 'album') { 133 | tracks.queueTracks.sort(function(a,b) { 134 | if (a.artistName != b.artistName) { 135 | return (a.artistName > b.artistName)? 1 : -1; 136 | } else { 137 | return a.albumTrackNumber - b.albumTrackNumber; 138 | } 139 | }); 140 | } 141 | 142 | return tracks; 143 | } 144 | 145 | function libIsEmpty() { 146 | return (musicLibrary == null); 147 | } 148 | 149 | function loadFuse(items, fuzzyKeys) { 150 | return new Promise((resolve) => { 151 | return resolve(new Fuse(items, { keys: fuzzyKeys, threshold: 0.2, maxPatternLength: 100, ignoreLocation: true })); 152 | }); 153 | } 154 | 155 | function isFinished(chunk) { 156 | return chunk.startIndex + chunk.numberReturned >= chunk.totalMatches; 157 | } 158 | 159 | function loadLibrary(player) { 160 | 161 | if (isLoading) { 162 | return Promise.resolve('Loading'); 163 | } 164 | logger.info('Loading Library'); 165 | isLoading = true; 166 | 167 | let library = { 168 | version: currentLibVersion, 169 | tracks: { 170 | items: [], 171 | startIndex: 0, 172 | numberReturned: 0, 173 | totalMatches: 1 174 | } 175 | }; 176 | 177 | let result = library.tracks; 178 | 179 | let getChunk = (chunk) => { 180 | chunk.items.reduce(function (tracksArray, item) { 181 | if ((item.uri != undefined) && (item.artist != undefined) && (item.album != undefined)) { 182 | var metadataID = libraryDef.metastart['song'] + item.uri.substring(item.uri.indexOf(':') + 1); 183 | var metadata = getMetadata('song', metadataID, encodeURIComponent(item.artist) + '/' + encodeURIComponent(item.album)); 184 | result.items.push({ 185 | artistTrackSearch: item.artist + ' ' + item.title, 186 | artistAlbumSearch: item.artist + ' ' + item.album, 187 | trackName: item.title, 188 | artistName: item.artist, 189 | albumName: item.album, 190 | albumTrackNumber: item.albumTrackNumber, 191 | uri: item.uri, 192 | metadata: metadata 193 | }); 194 | } 195 | }, []); 196 | 197 | result.numberReturned += chunk.numberReturned; 198 | result.totalMatches = chunk.totalMatches; 199 | logger.info(`Tracks returned: ${result.numberReturned}, Total matches: ${result.totalMatches}`); 200 | 201 | if (isFinished(chunk)) { 202 | return new Promise((resolve, reject) => { 203 | fs.writeFile(libraryPath, JSON.stringify(library), (err) => { 204 | isLoading = false; 205 | if (err) { 206 | console.log("ERROR: " + JSON.stringify(err)); 207 | return reject(err); 208 | } else { 209 | return resolve(library); 210 | } 211 | }); 212 | }); 213 | } 214 | 215 | // Recursive promise chain 216 | return player.browse('A:TRACKS', chunk.startIndex + chunk.numberReturned, 0) 217 | .then(getChunk); 218 | } 219 | 220 | return Promise.resolve(result) 221 | .then(getChunk) 222 | .catch((err) => { 223 | logger.error('Error when recursively trying to load library using browse()', err); 224 | }); 225 | } 226 | 227 | function loadLibrarySearch(player, load) { 228 | if (load || (musicLibrary == null)) { 229 | return loadLibrary(player) 230 | .then((result) => { 231 | musicLibrary = result; 232 | }) 233 | .then(() => loadFuse(musicLibrary.tracks.items, ["artistTrackSearch", "artistName", "trackName"])) 234 | .then((result) => { 235 | fuzzyTracks = result; 236 | }) 237 | .then(() => loadFuse(musicLibrary.tracks.items, ["artistAlbumSearch", "albumName", "artistName"])) 238 | .then((result) => { 239 | fuzzyAlbums = result; 240 | return Promise.resolve("Library and search loaded"); 241 | }); 242 | } else { 243 | return loadFuse(musicLibrary.tracks.items, ["artistTrackSearch", "artistName", "trackName"]) 244 | .then((result) => { 245 | fuzzyTracks = result; 246 | }) 247 | .then(() => loadFuse(musicLibrary.tracks.items, ["artistAlbumSearch", "albumName", "artistName"])) 248 | .then((result) => { 249 | fuzzyAlbums = result; 250 | return Promise.resolve("Library search loaded"); 251 | }); 252 | } 253 | } 254 | 255 | Array.prototype.shuffle=function(){ 256 | var len = this.length,temp,i 257 | while(len){ 258 | i=Math.random()*len-- >>> 0; 259 | temp=this[len],this[len]=this[i],this[i]=temp; 260 | } 261 | return this; 262 | } 263 | 264 | function searchLibrary(type, term) { 265 | term = decodeURIComponent(term); 266 | 267 | return (type == 'album') ? fuzzyAlbums.search(term) : fuzzyTracks.search(term).shuffle().slice(0,randomQueueLimit); 268 | } 269 | 270 | function isEmpty(type, resList) { 271 | return (resList.length == 0); 272 | } 273 | 274 | function handleLibrary(err, data) { 275 | if (!err) { 276 | musicLibrary = JSON.parse(data); 277 | if ((musicLibrary.version == undefined) || (musicLibrary.version < currentLibVersion)) { // Ignore if older format 278 | musicLibrary = null; 279 | } 280 | if (musicLibrary != null) { 281 | loadLibrarySearch(null, false); 282 | } 283 | } 284 | } 285 | 286 | function readLibrary() { 287 | fs.readFile(libraryPath, handleLibrary); 288 | } 289 | 290 | module.exports = libraryDef; 291 | -------------------------------------------------------------------------------- /lib/music_services/spotifyDef.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var request = require('request-promise'); 4 | const settings = require('../../settings'); 5 | 6 | var clientId = ""; 7 | var clientSecret = ""; 8 | 9 | if (settings.spotify) { 10 | clientId = settings.spotify.clientId; 11 | clientSecret = settings.spotify.clientSecret; 12 | } 13 | 14 | var clientToken = null; 15 | 16 | const spotifyDef = { 17 | country: '&market=', 18 | search: { 19 | album: 'https://api.spotify.com/v1/search?type=album&limit=1&q=album:', 20 | song: 'https://api.spotify.com/v1/search?type=track&limit=50&q=', 21 | station: 'https://api.spotify.com/v1/search?type=artist&limit=1&q=', 22 | playlist: 'https://api.spotify.com/v1/search?type=playlist&q=' 23 | }, 24 | metastart: { 25 | album: '0004206cspotify%3aalbum%3a', 26 | song: '00032020spotify%3atrack%3a', 27 | station: '000c206cspotify:artistRadio%3a', 28 | playlist: '0004206cspotify%3aplaylist%3a' 29 | }, 30 | parent: { 31 | album: '00020000album:', 32 | song: '00020000track:', 33 | station: '00052064spotify%3aartist%3a', 34 | playlist:'00020000playlist:', 35 | }, 36 | object: { 37 | album: 'container.album.musicAlbum', 38 | song: 'item.audioItem.musicTrack', 39 | station: 'item.audioItem.audioBroadcast.#artistRadio', 40 | playlist:'container.playlistContainer', 41 | }, 42 | 43 | service: setService, 44 | term: getSearchTerm, 45 | tracks: loadTracks, 46 | empty: isEmpty, 47 | metadata: getMetadata, 48 | urimeta: getURIandMetadata, 49 | headers: getTokenHeaders, 50 | authenticate: authenticateService, 51 | } 52 | 53 | var toBase64 = (string) => Buffer.from(string).toString('base64'); 54 | 55 | const SPOTIFY_TOKEN_URL = 'https://accounts.spotify.com/api/token'; 56 | 57 | const mapResponse = (response) => ({ 58 | accessToken: response.access_token, 59 | tokenType: response.token_type, 60 | expiresIn: response.expires_in, 61 | }); 62 | 63 | const getHeaders = () => { 64 | console.log('spotify', clientId, clientSecret) 65 | if (!clientId || !clientSecret) { 66 | throw new Error('You are missing spotify clientId and secret in settings.json! Please read the README for instructions on how to generate and add them'); 67 | } 68 | const authString = `${clientId}:${clientSecret}`; 69 | return { 70 | Authorization: `Basic ${toBase64(authString)}`, 71 | 'Content-Type': 'application/x-www-form-urlencoded', 72 | }; 73 | }; 74 | 75 | const getOptions = (url) => { 76 | return { 77 | url, 78 | headers: getHeaders(), 79 | json: true, 80 | method: 'POST', 81 | form: { 82 | grant_type: 'client_credentials', 83 | }, 84 | }; 85 | }; 86 | 87 | const auth = () => { 88 | const options = getOptions(SPOTIFY_TOKEN_URL); 89 | return new Promise((resolve, reject) => { 90 | request(options).then((response) => { 91 | const responseMapped = mapResponse(response); 92 | resolve(responseMapped); 93 | }).catch((err) => { 94 | reject(new Error(`Unable to authenticate Spotify with client id: ${clientId}`)); 95 | }) 96 | }); 97 | }; 98 | 99 | function getTokenHeaders() { 100 | if (clientToken == null) { 101 | return null; 102 | } 103 | return { 104 | Authorization: `Bearer ${clientToken}` 105 | }; 106 | } 107 | 108 | function authenticateService() { 109 | return new Promise((resolve, reject) => { 110 | auth().then((response) => { 111 | const accessToken = response.accessToken; 112 | clientToken = accessToken; 113 | resolve(); 114 | }).catch(reject); 115 | }); 116 | } 117 | 118 | function getURI(type, id) { 119 | if (type == 'album') { 120 | return `x-rincon-cpcontainer:0004206c${id}`; 121 | } else 122 | if (type == 'song') { 123 | return `x-sonos-spotify:spotify%3atrack%3a${id}?sid=${sid}&flags=8224&sn=${accountSN}`; 124 | } else 125 | if (type == 'station') { 126 | return `x-sonosapi-radio:spotify%3aartistRadio%3a${id}?sid=${sid}&flags=8300&sn=${accountSN}`; 127 | } else 128 | if (type == 'playlist') { 129 | return `x-rincon-cpcontainer:0006206c${id}`; 130 | } 131 | } 132 | 133 | function getServiceToken() { 134 | return `SA_RINCON${serviceType}_X_#Svc${serviceType}-0-Token`; 135 | } 136 | 137 | 138 | var sid = ''; 139 | var serviceType = ''; 140 | var accountId = ''; 141 | var accountSN = ''; 142 | var country = ''; 143 | 144 | function setService(player, p_accountId, p_accountSN, p_country) 145 | { 146 | sid = player.system.getServiceId('Spotify'); 147 | serviceType = player.system.getServiceType('Spotify'); 148 | accountId = p_accountId; 149 | accountSN = 14; // GACALD: Hack to fix Spotify p_accountSN; 150 | country = p_country; 151 | } 152 | 153 | function getSearchTerm(type, term, artist, album, track) { 154 | var newTerm = ''; 155 | 156 | if (album != '') { 157 | newTerm = album + ' '; 158 | } 159 | if (artist != '') { 160 | newTerm += 'artist:' + artist + ((track != '')?' ':''); 161 | } 162 | if (track != '') { 163 | newTerm += 'track:' + track; 164 | } 165 | newTerm = encodeURIComponent(newTerm); 166 | 167 | return newTerm; 168 | } 169 | 170 | function getMetadata(type, id, name, title) { 171 | const token = getServiceToken(); 172 | const parentUri = spotifyDef.parent[type] + name; 173 | const objectType = spotifyDef.object[type]; 174 | 175 | if (type != 'station') { 176 | title = ''; 177 | } 178 | 179 | return ` 181 | ${title}object.${objectType} 182 | ${token}`; 183 | } 184 | 185 | function getURIandMetadata(type, resList) 186 | { 187 | var Id = ''; 188 | var Title = ''; 189 | var Name = ''; 190 | var MetadataID = ''; 191 | var UaM = { 192 | uri: '', 193 | metadata: '' 194 | }; 195 | 196 | var items = []; 197 | 198 | if (type == 'album') { 199 | items = resList.albums.items; 200 | } else 201 | if (type == 'station') { 202 | items = resList.artists.items; 203 | } else 204 | if (type == 'playlist') { 205 | items = resList.playlists.items; 206 | } 207 | 208 | Id = items[0].id; 209 | Title = items[0].name + ((type=='station')?' Radio':''); 210 | Name = Title.toLowerCase().replace(' radio','').replace('radio ',''); 211 | MetadataID = spotifyDef.metastart[type] + encodeURIComponent(Id); 212 | 213 | UaM.metadata = getMetadata(type, MetadataID, (type=='album' || type=='playlist')?Title.toLowerCase() : Id, Title); 214 | UaM.uri = getURI(type, encodeURIComponent((type=='station')?items[0].id:items[0].uri)); 215 | 216 | return UaM; 217 | } 218 | 219 | function loadTracks(type, tracksJson) 220 | { 221 | var tracks = { count : 0, 222 | isArtist : false, 223 | queueTracks : [] 224 | }; 225 | 226 | if (tracksJson.tracks.items.length > 0) { 227 | // Filtered list of tracks to play 228 | tracks.queueTracks = tracksJson.tracks.items.reduce(function(tracksArray, track) { 229 | if (track.available_markets == null || track.available_markets.indexOf(country) != -1) { 230 | var skip = false; 231 | 232 | for (var j=0; (j < tracksArray.length) && !skip ; j++) { 233 | // Skip duplicate songs 234 | skip = (track.name == tracksArray[j].trackName); 235 | } 236 | 237 | if (!skip) { 238 | var metadataID = spotifyDef.metastart['song'] + encodeURIComponent(track.id); 239 | var metadata = getMetadata('song', metadataID, track.id, track.name); 240 | var uri = getURI('song', encodeURIComponent(track.id)); 241 | 242 | tracksArray.push({trackName:track.name, artistName:(track.artists.length>0)?track.artists[0].name:'', uri:uri, metadata:metadata}); 243 | tracks.count++; 244 | } 245 | } 246 | return tracksArray; 247 | }, []); 248 | } 249 | 250 | return tracks; 251 | } 252 | 253 | function isEmpty(type, resList) 254 | { 255 | var count = 0; 256 | 257 | if (type == 'album') { 258 | count = resList.albums.items.length; 259 | } else 260 | if (type == 'song') { 261 | count = resList.tracks.items.length; 262 | } else 263 | if (type == 'station') { 264 | count = resList.artists.items.length; 265 | } else 266 | if (type == 'playlist') { 267 | count = resList.playlists.items.length; 268 | } 269 | 270 | return (count == 0); 271 | } 272 | 273 | module.exports = spotifyDef; 274 | 275 | -------------------------------------------------------------------------------- /lib/presets-loader.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fs = require('fs'); 3 | const util = require('util'); 4 | const path = require('path'); 5 | const logger = require('sonos-discovery/lib/helpers/logger'); 6 | const tryLoadJson = require('./helpers/try-load-json'); 7 | const settings = require('../settings'); 8 | 9 | const PRESETS_PATH = settings.presetDir; 10 | const PRESETS_FILENAME = `${__dirname}/../presets.json`; 11 | const presets = {}; 12 | 13 | function readPresetsFromDir(presets, presetPath) { 14 | let files; 15 | try { 16 | files = fs.readdirSync(presetPath); 17 | } catch (e) { 18 | logger.warn(`Could not find dir ${presetPath}, are you sure it exists?`); 19 | logger.warn(e.message); 20 | return; 21 | } 22 | 23 | files.map((name) => { 24 | let fullPath = path.join(presetPath, name); 25 | return { 26 | name, 27 | fullPath, 28 | stat: fs.statSync(fullPath) 29 | }; 30 | }).filter((file) => { 31 | return !file.stat.isDirectory() && !file.name.startsWith('.') && file.name.endsWith('.json'); 32 | }).forEach((file) => { 33 | const presetName = file.name.replace(/\.json/i, ''); 34 | const preset = tryLoadJson(file.fullPath); 35 | if (Object.keys(preset).length === 0) { 36 | logger.warn(`could not parse preset file ${file.name}, please make sure syntax conforms with JSON5.`); 37 | return; 38 | } 39 | 40 | presets[presetName] = preset; 41 | }); 42 | 43 | } 44 | 45 | function readPresetsFromFile(presets, filename) { 46 | try { 47 | const presetStat = fs.statSync(filename); 48 | if (!presetStat.isFile()) { 49 | return; 50 | } 51 | 52 | const filePresets = require(filename); 53 | Object.keys(filePresets).forEach(presetName => { 54 | presets[presetName] = filePresets[presetName]; 55 | }); 56 | 57 | logger.warn('You are using a presets.json file! ' + 58 | 'Consider migrating your presets into the presets/ ' + 59 | 'folder instead, and enjoy auto-reloading of presets when you change them'); 60 | } catch (err) { 61 | logger.debug(`no presets.json file exists, skipping`); 62 | } 63 | } 64 | 65 | function initPresets() { 66 | Object.keys(presets).forEach(presetName => { 67 | delete presets[presetName]; 68 | }); 69 | readPresetsFromFile(presets, PRESETS_FILENAME); 70 | readPresetsFromDir(presets, PRESETS_PATH); 71 | 72 | logger.info('Presets loaded:', util.inspect(presets, { depth: null })); 73 | 74 | } 75 | 76 | initPresets(); 77 | let watchTimeout; 78 | try { 79 | fs.watch(PRESETS_PATH, { persistent: false }, () => { 80 | clearTimeout(watchTimeout); 81 | watchTimeout = setTimeout(initPresets, 200); 82 | }); 83 | } catch (e) { 84 | logger.warn(`Could not start watching dir ${PRESETS_PATH}, will not auto reload any presets. Make sure the dir exists`); 85 | logger.warn(e.message); 86 | } 87 | 88 | module.exports = presets; 89 | -------------------------------------------------------------------------------- /lib/sonos-http-api.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const requireDir = require('./helpers/require-dir'); 3 | const path = require('path'); 4 | const request = require('sonos-discovery/lib/helpers/request'); 5 | const logger = require('sonos-discovery/lib/helpers/logger'); 6 | const HttpEventServer = require('./helpers/http-event-server'); 7 | 8 | function HttpAPI(discovery, settings) { 9 | 10 | const port = settings.port; 11 | const webroot = settings.webroot; 12 | const actions = {}; 13 | const events = new HttpEventServer(); 14 | 15 | this.getWebRoot = function () { 16 | return webroot; 17 | }; 18 | 19 | this.getPort = function () { 20 | return port; 21 | }; 22 | 23 | this.discovery = discovery; 24 | 25 | discovery.on('transport-state', function (player) { 26 | invokeWebhook('transport-state', player); 27 | }); 28 | 29 | discovery.on('topology-change', function (topology) { 30 | invokeWebhook('topology-change', topology); 31 | }); 32 | 33 | discovery.on('volume-change', function (volumeChange) { 34 | invokeWebhook('volume-change', volumeChange); 35 | }); 36 | 37 | discovery.on('mute-change', function (muteChange) { 38 | invokeWebhook('mute-change', muteChange); 39 | }); 40 | 41 | // this handles registering of all actions 42 | this.registerAction = function (action, handler) { 43 | actions[action] = handler; 44 | }; 45 | 46 | //load modularized actions 47 | requireDir(path.join(__dirname, './actions'), (registerAction) => { 48 | registerAction(this); 49 | }); 50 | 51 | this.requestHandler = function (req, res) { 52 | if (req.url === '/favicon.ico') { 53 | res.end(); 54 | return; 55 | } 56 | 57 | if (req.url === '/events') { 58 | events.addClient(res); 59 | return; 60 | } 61 | 62 | if (discovery.zones.length === 0) { 63 | const msg = 'No system has yet been discovered. Please see https://github.com/jishi/node-sonos-http-api/issues/77 if it doesn\'t resolve itself in a few seconds.'; 64 | logger.error(msg); 65 | sendResponse(500, { status: 'error', error: msg }); 66 | return; 67 | } 68 | 69 | const params = req.url.substring(1).split('/'); 70 | 71 | // parse decode player name considering decode errors 72 | let player; 73 | try { 74 | player = discovery.getPlayer(decodeURIComponent(params[0])); 75 | } catch (error) { 76 | logger.error(`Unable to parse supplied URI component (${params[0]})`, error); 77 | return sendResponse(500, { status: 'error', error: error.message, stack: error.stack }); 78 | } 79 | 80 | const opt = {}; 81 | 82 | if (player) { 83 | opt.action = (params[1] || '').toLowerCase(); 84 | opt.values = params.splice(2); 85 | } else { 86 | player = discovery.getAnyPlayer(); 87 | opt.action = (params[0] || '').toLowerCase(); 88 | opt.values = params.splice(1); 89 | } 90 | 91 | function sendResponse(code, body) { 92 | var jsonResponse = JSON.stringify(body); 93 | res.statusCode = code; 94 | res.setHeader('Content-Length', Buffer.byteLength(jsonResponse)); 95 | res.setHeader('Content-Type', 'application/json;charset=utf-8'); 96 | res.write(Buffer.from(jsonResponse)); 97 | res.end(); 98 | } 99 | 100 | opt.player = player; 101 | Promise.resolve(handleAction(opt)) 102 | .then((response) => { 103 | if (!response || response.constructor.name === 'IncomingMessage') { 104 | response = { status: 'success' }; 105 | } else if (Array.isArray(response) && response.length > 0 && response[0].constructor.name === 'IncomingMessage') { 106 | response = { status: 'success' }; 107 | } 108 | 109 | sendResponse(200, response); 110 | }).catch((error) => { 111 | logger.error(error); 112 | sendResponse(500, { status: 'error', error: error.message, stack: error.stack }); 113 | }); 114 | }; 115 | 116 | 117 | function handleAction(options) { 118 | var player = options.player; 119 | 120 | if (!actions[options.action]) { 121 | return Promise.reject({ error: 'action \'' + options.action + '\' not found' }); 122 | } 123 | 124 | return actions[options.action](player, options.values); 125 | 126 | 127 | } 128 | 129 | function invokeWebhook(type, data) { 130 | var typeName = "type"; 131 | var dataName = "data"; 132 | 133 | if (settings.webhookType) { typeName = settings.webhookType; } 134 | if (settings.webhookData) { dataName = settings.webhookData; } 135 | 136 | const jsonBody = JSON.stringify({ 137 | [typeName]: type, 138 | [dataName]: data 139 | }); 140 | 141 | events.sendEvent(jsonBody); 142 | 143 | if (!settings.webhook) return; 144 | 145 | const body = Buffer.from(jsonBody, 'utf8'); 146 | 147 | var headers = { 148 | 'Content-Type': 'application/json', 149 | 'Content-Length': body.length 150 | } 151 | if (settings.webhookHeaderName && settings.webhookHeaderContents) { 152 | headers[settings.webhookHeaderName] = settings.webhookHeaderContents; 153 | } 154 | 155 | request({ 156 | method: 'POST', 157 | uri: settings.webhook, 158 | headers: headers, 159 | body 160 | }) 161 | .catch(function (err) { 162 | logger.error('Could not reach webhook endpoint', settings.webhook, 'for some reason. Verify that the receiving end is up and running.'); 163 | logger.error(err); 164 | }) 165 | } 166 | 167 | } 168 | 169 | module.exports = HttpAPI; 170 | -------------------------------------------------------------------------------- /lib/tts-providers/aws-polly.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const crypto = require('crypto'); 3 | const fs = require('fs'); 4 | const http = require('http'); 5 | const path = require('path'); 6 | const AWS = require('aws-sdk'); 7 | const fileDuration = require('../helpers/file-duration'); 8 | const settings = require('../../settings'); 9 | const logger = require('sonos-discovery/lib/helpers/logger'); 10 | 11 | const DEFAULT_SETTINGS = { 12 | OutputFormat: 'mp3', 13 | VoiceId: 'Joanna', 14 | TextType: 'text' 15 | }; 16 | 17 | function polly(phrase, voiceName) { 18 | if (!settings.aws) { 19 | return Promise.resolve(); 20 | 21 | } 22 | 23 | // Construct a filesystem neutral filename 24 | const dynamicParameters = { Text: phrase }; 25 | const synthesizeParameters = Object.assign({}, DEFAULT_SETTINGS, dynamicParameters); 26 | if (settings.aws.name) { 27 | synthesizeParameters.VoiceId = settings.aws.name; 28 | } 29 | if (voiceName) { 30 | synthesizeParameters.VoiceId = voiceName; 31 | } 32 | if (synthesizeParameters.VoiceId.endsWith('Neural')) { 33 | synthesizeParameters.Engine = 'neural'; 34 | synthesizeParameters.VoiceId = synthesizeParameters.VoiceId.slice(0, -6); 35 | } 36 | 37 | const phraseHash = crypto.createHash('sha1').update(phrase).digest('hex'); 38 | const filename = `polly-${phraseHash}-${synthesizeParameters.VoiceId}.mp3`; 39 | const filepath = path.resolve(settings.webroot, 'tts', filename); 40 | 41 | const expectedUri = `/tts/${filename}`; 42 | try { 43 | fs.accessSync(filepath, fs.R_OK); 44 | return fileDuration(filepath) 45 | .then((duration) => { 46 | return { 47 | duration, 48 | uri: expectedUri 49 | }; 50 | }); 51 | } catch (err) { 52 | logger.info(`announce file for phrase "${phrase}" does not seem to exist, downloading`); 53 | } 54 | 55 | const constructorParameters = Object.assign({ apiVersion: '2016-06-10' }, settings.aws.credentials); 56 | 57 | const polly = new AWS.Polly(constructorParameters); 58 | 59 | return polly.synthesizeSpeech(synthesizeParameters) 60 | .promise() 61 | .then((data) => { 62 | fs.writeFileSync(filepath, data.AudioStream); 63 | return fileDuration(filepath); 64 | }) 65 | .then((duration) => { 66 | return { 67 | duration, 68 | uri: expectedUri 69 | }; 70 | }); 71 | } 72 | 73 | module.exports = polly; 74 | -------------------------------------------------------------------------------- /lib/tts-providers/default/google.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const crypto = require('crypto'); 3 | const fs = require('fs'); 4 | const http = require('http'); 5 | const path = require('path'); 6 | const fileDuration = require('../../helpers/file-duration'); 7 | const settings = require('../../../settings'); 8 | const logger = require('sonos-discovery/lib/helpers/logger'); 9 | 10 | function google(phrase, language) { 11 | if (!language) { 12 | language = 'en'; 13 | } 14 | 15 | // Use Google tts translation service to create a mp3 file 16 | const ttsRequestUrl = 'http://translate.google.com/translate_tts?client=tw-ob&tl=' + language + '&q=' + encodeURIComponent(phrase); 17 | 18 | // Construct a filesystem neutral filename 19 | const phraseHash = crypto.createHash('sha1').update(phrase).digest('hex'); 20 | const filename = `google-${phraseHash}-${language}.mp3`; 21 | const filepath = path.resolve(settings.webroot, 'tts', filename); 22 | 23 | const expectedUri = `/tts/${filename}`; 24 | try { 25 | fs.accessSync(filepath, fs.R_OK); 26 | return fileDuration(filepath) 27 | .then((duration) => { 28 | return { 29 | duration, 30 | uri: expectedUri 31 | }; 32 | }); 33 | } catch (err) { 34 | logger.info(`announce file for phrase "${phrase}" does not seem to exist, downloading`); 35 | } 36 | 37 | return new Promise((resolve, reject) => { 38 | const file = fs.createWriteStream(filepath); 39 | const options = { 40 | "headers": { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36" }, 41 | "host": "translate.google.com", 42 | "path": "/translate_tts?client=tw-ob&tl=" + language + "&q=" + encodeURIComponent(phrase) 43 | } 44 | const callback = function (response) { 45 | if (response.statusCode < 300 && response.statusCode >= 200) { 46 | response.pipe(file); 47 | file.on('finish', function () { 48 | file.end(); 49 | resolve(expectedUri); 50 | }); 51 | } else { 52 | reject(new Error(`Download from google TTS failed with status ${response.statusCode}, ${response.message}`)); 53 | 54 | } 55 | } 56 | 57 | http.request(options, callback).on('error', function (err) { 58 | reject(err); 59 | }).end(); 60 | }) 61 | .then(() => { 62 | return fileDuration(filepath); 63 | }) 64 | .then((duration) => { 65 | return { 66 | duration, 67 | uri: expectedUri 68 | }; 69 | }); 70 | } 71 | 72 | module.exports = google; 73 | -------------------------------------------------------------------------------- /lib/tts-providers/elevenlabs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const crypto = require('crypto'); 3 | const fs = require('fs'); 4 | const http = require('http'); 5 | const path = require('path'); 6 | const ElevenLabs = require('elevenlabs-node'); 7 | const fileDuration = require('../helpers/file-duration'); 8 | const settings = require('../../settings'); 9 | const logger = require('sonos-discovery/lib/helpers/logger'); 10 | 11 | const DEFAULT_SETTINGS = { 12 | stability: 0.5, 13 | similarityBoost: 0.5, 14 | speakerBoost: true, 15 | style: 1, 16 | modelId: "eleven_multilingual_v2" 17 | }; 18 | 19 | // Provider developed based on structure from aws-polly.js. 20 | // In this tts provider language argument from uri is used to inject custom voiceId 21 | function eleven(phrase, voiceId) { 22 | if (!settings.elevenlabs) { 23 | return Promise.resolve(); 24 | } 25 | 26 | // Construct a filesystem neutral filename 27 | const dynamicParameters = { textInput: phrase }; 28 | const synthesizeParameters = Object.assign({}, DEFAULT_SETTINGS, dynamicParameters, settings.elevenlabs.config); 29 | 30 | if (voiceId) { 31 | synthesizeParameters.voiceId = voiceId; 32 | } 33 | 34 | if (!synthesizeParameters.voiceId) { 35 | console.log('Voice ID not found neither in settings.elevenlabs.config nor in request!') 36 | return Promise.resolve(); 37 | } 38 | 39 | const phraseHash = crypto.createHash('sha1').update(phrase).digest('hex'); 40 | const filename = `elevenlabs-${phraseHash}-${synthesizeParameters.voiceId}.mp3`; 41 | const filepath = path.resolve(settings.webroot, 'tts', filename); 42 | 43 | synthesizeParameters.fileName = filepath; 44 | 45 | const expectedUri = `/tts/${filename}`; 46 | try { 47 | fs.accessSync(filepath, fs.R_OK); 48 | return fileDuration(filepath) 49 | .then((duration) => { 50 | return { 51 | duration, 52 | uri: expectedUri 53 | }; 54 | }); 55 | } catch (err) { 56 | logger.info(`announce file for phrase "${phrase}" does not seem to exist, downloading`); 57 | } 58 | 59 | const voice = new ElevenLabs( 60 | { 61 | apiKey: settings.elevenlabs.auth.apiKey 62 | } 63 | ); 64 | 65 | return voice.textToSpeech(synthesizeParameters) 66 | .then((res) => { 67 | console.log('Elevenlabs TTS generated new audio file.'); 68 | }) 69 | .then(() => { 70 | return fileDuration(filepath); 71 | }) 72 | .then((duration) => { 73 | return { 74 | duration, 75 | uri: expectedUri 76 | }; 77 | }); 78 | } 79 | 80 | module.exports = eleven; 81 | -------------------------------------------------------------------------------- /lib/tts-providers/mac-os.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const crypto = require('crypto'); 3 | const fs = require('fs'); 4 | const http = require('http'); 5 | const path = require('path'); 6 | const fileDuration = require('../helpers/file-duration'); 7 | const settings = require('../../settings'); 8 | const logger = require('sonos-discovery/lib/helpers/logger'); 9 | var exec = require('child_process').exec; 10 | 11 | function macSay(phrase, voice) { 12 | if (!settings.macSay) { 13 | return Promise.resolve(); 14 | } 15 | 16 | var selcetedRate = settings.macSay.rate; 17 | if( !selcetedRate ) { 18 | selcetedRate = "default"; 19 | } 20 | var selectedVoice = settings.macSay.voice; 21 | if( voice ) { 22 | selectedVoice = voice; 23 | } 24 | 25 | // Construct a filesystem neutral filename 26 | const phraseHash = crypto.createHash('sha1').update(phrase).digest('hex'); 27 | const filename = `macSay-${phraseHash}-${selcetedRate}-${selectedVoice}.m4a`; 28 | const filepath = path.resolve(settings.webroot, 'tts', filename); 29 | 30 | const expectedUri = `/tts/${filename}`; 31 | 32 | try { 33 | fs.accessSync(filepath, fs.R_OK); 34 | return fileDuration(filepath) 35 | .then((duration) => { 36 | return { 37 | duration, 38 | uri: expectedUri 39 | }; 40 | }); 41 | } catch (err) { 42 | logger.info(`announce file for phrase "${phrase}" does not seem to exist, downloading`); 43 | } 44 | 45 | return new Promise((resolve, reject) => { 46 | // 47 | // For more information on the "say" command, type "man say" in Terminal 48 | // or go to 49 | // https://developer.apple.com/legacy/library/documentation/Darwin/Reference/ManPages/man1/say.1.html 50 | // 51 | // The list of available voices can be configured in 52 | // System Preferences -> Accessibility -> Speech -> System Voice 53 | // 54 | 55 | var execCommand = `say "${phrase}" -o ${filepath}`; 56 | if( selectedVoice && selcetedRate != "default" ) { 57 | execCommand = `say -r ${selcetedRate} -v ${selectedVoice} "${phrase}" -o ${filepath}`; 58 | } else if ( selectedVoice ) { 59 | execCommand = `say -v ${selectedVoice} "${phrase}" -o ${filepath}`; 60 | } else if ( selcetedRate != "default" ) { 61 | execCommand = `say -r ${selcetedRate} "${phrase}" -o ${filepath}`; 62 | } 63 | 64 | exec(execCommand, 65 | function (error, stdout, stderr) { 66 | if (error !== null) { 67 | reject(error); 68 | } else { 69 | resolve(expectedUri); 70 | } 71 | }); 72 | 73 | }) 74 | .then(() => { 75 | return fileDuration(filepath); 76 | }) 77 | .then((duration) => { 78 | return { 79 | duration, 80 | uri: expectedUri 81 | }; 82 | }); 83 | } 84 | 85 | module.exports = macSay; 86 | -------------------------------------------------------------------------------- /lib/tts-providers/microsoft.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const crypto = require('crypto'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const fileDuration = require('../helpers/file-duration'); 6 | const request = require('sonos-discovery/lib/helpers/request'); 7 | const logger = require('sonos-discovery/lib/helpers/logger'); 8 | const globalSettings = require('../../settings'); 9 | const XmlEntities = require('html-entities').XmlEntities; 10 | 11 | const xmlEntities = new XmlEntities(); 12 | 13 | const APP_ID = '9aa44d9e6ec14da99231a9166fd50b0f'; 14 | const INSTANCE_ID = crypto.randomBytes(16).toString('hex'); 15 | const TOKEN_EXPIRATION = 590000; // 9:50 minutes in ms 16 | const DEFAULT_SETTINGS = { 17 | name: 'ZiraRUS' 18 | }; 19 | 20 | let bearerToken; 21 | let bearerExpires = Date.now(); 22 | 23 | function generateBearerToken(apiKey) { 24 | return request({ 25 | uri: 'https://api.cognitive.microsoft.com/sts/v1.0/issueToken', 26 | method: 'POST', 27 | type: 'raw', 28 | headers: { 29 | 'Ocp-Apim-Subscription-Key': apiKey, 30 | 'Content-Length': 0 31 | } 32 | }) 33 | .then((body) => { 34 | logger.debug(`Bearer token: body`); 35 | bearerToken = body; 36 | bearerExpires = Date.now() + TOKEN_EXPIRATION; 37 | }); 38 | } 39 | 40 | function format(lang, gender, name, text) { 41 | const escapedText = xmlEntities.encodeNonUTF(text); 42 | return `${escapedText}`; 43 | } 44 | 45 | function microsoft(phrase, voiceName) { 46 | if (!globalSettings.microsoft || !globalSettings.microsoft.key) { 47 | return Promise.resolve(); 48 | } 49 | 50 | const settings = Object.assign({}, DEFAULT_SETTINGS, globalSettings.microsoft); 51 | 52 | if (voiceName) { 53 | settings.name = voiceName; 54 | } 55 | 56 | const phraseHash = crypto.createHash('sha1').update(phrase).digest('hex'); 57 | const filename = `microsoft-${phraseHash}-${settings.name}.wav`; 58 | const filepath = path.resolve(globalSettings.webroot, 'tts', filename); 59 | 60 | const expectedUri = `/tts/${filename}`; 61 | try { 62 | fs.accessSync(filepath, fs.R_OK); 63 | return fileDuration(filepath) 64 | .then((duration) => { 65 | return { 66 | duration, 67 | uri: expectedUri 68 | }; 69 | }); 70 | 71 | } catch (err) { 72 | logger.info(`announce file for phrase "${phrase}" does not seem to exist, downloading`); 73 | } 74 | 75 | let promise = Promise.resolve(); 76 | if (bearerExpires < Date.now()) { 77 | // Refresh token 78 | promise = generateBearerToken(settings.key); 79 | } 80 | 81 | return promise.then(() => { 82 | const voice = VOICE[settings.name]; 83 | if (!voice) { 84 | throw new Error(`Voice name ${settings.name} could not be located in the list of valid voice names`); 85 | } 86 | 87 | const ssml = format(voice.language, voice.gender, voice.font, phrase); 88 | return request({ 89 | uri: 'https://speech.platform.bing.com/synthesize', 90 | method: 'POST', 91 | type: 'stream', 92 | headers: { 93 | Authorization: `Bearer ${bearerToken}`, 94 | 'Content-Type': 'application/ssml+xml', 95 | 'X-Microsoft-OutputFormat': 'riff-16khz-16bit-mono-pcm', 96 | 'X-Search-AppId': APP_ID, 97 | 'X-Search-ClientID': INSTANCE_ID, 98 | 'User-Agent': 'node-sonos-http-api', 99 | 'Content-Length': ssml.length 100 | }, 101 | body: ssml 102 | }) 103 | .then(res => { 104 | return new Promise((resolve) => { 105 | 106 | const file = fs.createWriteStream(filepath); 107 | res.pipe(file); 108 | 109 | file.on('close', () => { 110 | resolve(); 111 | }) 112 | }) 113 | 114 | }) 115 | .then(() => { 116 | return fileDuration(filepath); 117 | }) 118 | .then((duration) => { 119 | return { 120 | duration, 121 | uri: expectedUri 122 | }; 123 | }) 124 | .catch((err) => { 125 | logger.error(err); 126 | throw err; 127 | }); 128 | }); 129 | } 130 | 131 | const VOICE = { 132 | Hoda: { language: 'ar-EG', gender: 'Female', font: 'Microsoft Server Speech Text to Speech Voice (ar-EG, Hoda)' }, 133 | Naayf: { language: 'ar-SA', gender: 'Male', font: 'Microsoft Server Speech Text to Speech Voice (ar-SA, Naayf)' }, 134 | Ivan: { language: 'bg-BG', gender: 'Male', font: 'Microsoft Server Speech Text to Speech Voice (bg-BG, Ivan)' }, 135 | HerenaRUS: { language: 'ca-ES', gender: 'Female', font: 'Microsoft Server Speech Text to Speech Voice (ca-ES, HerenaRUS)' }, 136 | Jakub: { language: 'cs-CZ', gender: 'Male', font: 'Microsoft Server Speech Text to Speech Voice (cs-CZ, Jakub)' }, 137 | Vit: { language: 'cs-CZ', gender: 'Male', font: 'Microsoft Server Speech Text to Speech Voice (cs-CZ, Vit)' }, 138 | HelleRUS: { language: 'da-DK', gender: 'Female', font: 'Microsoft Server Speech Text to Speech Voice (da-DK, HelleRUS)' }, 139 | Michael: { language: 'de-AT', gender: 'Male', font: 'Microsoft Server Speech Text to Speech Voice (de-AT, Michael)' }, 140 | Karsten: { language: 'de-CH', gender: 'Male', font: 'Microsoft Server Speech Text to Speech Voice (de-CH, Karsten)' }, 141 | Hedda: { language: 'de-DE', gender: 'Female', font: 'Microsoft Server Speech Text to Speech Voice (de-DE, Hedda)' }, 142 | Stefan: { 143 | language: 'de-DE', 144 | gender: 'Male', 145 | font: 'Microsoft Server Speech Text to Speech Voice (de-DE, Stefan, Apollo)' 146 | }, 147 | Catherine: { 148 | language: 'en-AU', 149 | gender: 'Female', 150 | font: 'Microsoft Server Speech Text to Speech Voice (en-AU, Catherine)' 151 | }, 152 | Linda: { language: 'en-CA', gender: 'Female', font: 'Microsoft Server Speech Text to Speech Voice (en-CA, Linda)' }, 153 | Susan: { 154 | language: 'en-GB', 155 | gender: 'Female', 156 | font: 'Microsoft Server Speech Text to Speech Voice (en-GB, Susan, Apollo)' 157 | }, 158 | George: { 159 | language: 'en-GB', 160 | gender: 'Male', 161 | font: 'Microsoft Server Speech Text to Speech Voice (en-GB, George, Apollo)' 162 | }, 163 | Ravi: { 164 | language: 'en-IN', 165 | gender: 'Male', 166 | font: 'Microsoft Server Speech Text to Speech Voice (en-IN, Ravi, Apollo)' 167 | }, 168 | ZiraRUS: { 169 | language: 'en-US', 170 | gender: 'Female', 171 | font: 'Microsoft Server Speech Text to Speech Voice (en-US, ZiraRUS)' 172 | }, 173 | BenjaminRUS: { 174 | language: 'en-US', 175 | gender: 'Male', 176 | font: 'Microsoft Server Speech Text to Speech Voice (en-US, BenjaminRUS)' 177 | }, 178 | Laura: { 179 | language: 'es-ES', 180 | gender: 'Female', 181 | font: 'Microsoft Server Speech Text to Speech Voice (es-ES, Laura, Apollo)' 182 | }, 183 | Pablo: { 184 | language: 'es-ES', 185 | gender: 'Male', 186 | font: 'Microsoft Server Speech Text to Speech Voice (es-ES, Pablo, Apollo)' 187 | }, 188 | Raul: { 189 | language: 'es-MX', 190 | gender: 'Male', 191 | font: 'Microsoft Server Speech Text to Speech Voice (es-MX, Raul, Apollo)' 192 | }, 193 | Caroline: { 194 | language: 'fr-CA', 195 | gender: 'Female', 196 | font: 'Microsoft Server Speech Text to Speech Voice (fr-CA, Caroline)' 197 | }, 198 | Julie: { 199 | language: 'fr-FR', 200 | gender: 'Female', 201 | font: 'Microsoft Server Speech Text to Speech Voice (fr-FR, Julie, Apollo)' 202 | }, 203 | Paul: { 204 | language: 'fr-FR', 205 | gender: 'Male', 206 | font: 'Microsoft Server Speech Text to Speech Voice (fr-FR, Paul, Apollo)' 207 | }, 208 | Cosimo: { 209 | language: 'it-IT', 210 | gender: 'Male', 211 | font: 'Microsoft Server Speech Text to Speech Voice (it-IT, Cosimo, Apollo)' 212 | }, 213 | Ayumi: { 214 | language: 'ja-JP', 215 | gender: 'Female', 216 | font: 'Microsoft Server Speech Text to Speech Voice (ja-JP, Ayumi, Apollo)' 217 | }, 218 | Ichiro: { 219 | language: 'ja-JP', 220 | gender: 'Male', 221 | font: 'Microsoft Server Speech Text to Speech Voice (ja-JP, Ichiro, Apollo)' 222 | }, 223 | Daniel: { 224 | language: 'pt-BR', 225 | gender: 'Male', 226 | font: 'Microsoft Server Speech Text to Speech Voice (pt-BR, Daniel, Apollo)' 227 | }, 228 | Andrei: { 229 | language: 'ro-RO', 230 | gender: 'Male', 231 | font: 'Microsoft Server Speech Text to Speech Voice (ro-RO, Andrei)', 232 | }, 233 | Irina: { 234 | language: 'ru-RU', 235 | gender: 'Female', 236 | font: 'Microsoft Server Speech Text to Speech Voice (ru-RU, Irina, Apollo)' 237 | }, 238 | Pavel: { 239 | language: 'ru-RU', 240 | gender: 'Male', 241 | font: 'Microsoft Server Speech Text to Speech Voice (ru-RU, Pavel, Apollo)' 242 | }, 243 | HuihuiRUS: { 244 | language: 'zh-CN', 245 | gender: 'Female', 246 | font: 'Microsoft Server Speech Text to Speech Voice (zh-CN, HuihuiRUS)' 247 | }, 248 | Yaoyao: { 249 | language: 'zh-CN', 250 | gender: 'Female', 251 | font: 'Microsoft Server Speech Text to Speech Voice (zh-CN, Yaoyao, Apollo)' 252 | }, 253 | Kangkang: { 254 | language: 'zh-CN', 255 | gender: 'Male', 256 | font: 'Microsoft Server Speech Text to Speech Voice (zh-CN, Kangkang, Apollo)' 257 | }, 258 | Tracy: { 259 | language: 'zh-HK', 260 | gender: 'Female', 261 | font: 'Microsoft Server Speech Text to Speech Voice (zh-HK, Tracy, Apollo)' 262 | }, 263 | Danny: { 264 | language: 'zh-HK', 265 | gender: 'Male', 266 | font: 'Microsoft Server Speech Text to Speech Voice (zh-HK, Danny, Apollo)' 267 | }, 268 | Yating: { 269 | language: 'zh-TW', 270 | gender: 'Female', 271 | font: 'Microsoft Server Speech Text to Speech Voice (zh-TW, Yating, Apollo)' 272 | }, 273 | Zhiwei: { 274 | language: 'zh-TW', 275 | gender: 'Male', 276 | font: 'Microsoft Server Speech Text to Speech Voice (zh-TW, Zhiwei, Apollo)' 277 | }, 278 | JessaNeural: { 279 | language: 'en-US', 280 | gender: 'Female', 281 | font: 'Microsoft Server Speech Text to Speech Voice (en-US, JessaNeural)' 282 | } 283 | }; 284 | 285 | module.exports = microsoft; 286 | -------------------------------------------------------------------------------- /lib/tts-providers/voicerss.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const crypto = require('crypto'); 3 | const fs = require('fs'); 4 | const http = require('http'); 5 | const path = require('path'); 6 | const fileDuration = require('../helpers/file-duration'); 7 | const settings = require('../../settings'); 8 | 9 | function voicerss(phrase, language) { 10 | if (!settings.voicerss) { 11 | return Promise.resolve(); 12 | 13 | } 14 | 15 | if (!language) { 16 | language = 'en-gb'; 17 | } 18 | // Use voicerss tts translation service to create a mp3 file 19 | // Option "c=MP3" added. Otherwise a WAV file is created that won't play on Sonos. 20 | const ttsRequestUrl = `http://api.voicerss.org/?key=${settings.voicerss}&f=22khz_16bit_mono&hl=${language}&src=${encodeURIComponent(phrase)}&c=MP3`; 21 | 22 | // Construct a filesystem neutral filename 23 | const phraseHash = crypto.createHash('sha1').update(phrase).digest('hex'); 24 | const filename = `voicerss-${phraseHash}-${language}.mp3`; 25 | const filepath = path.resolve(settings.webroot, 'tts', filename); 26 | 27 | const expectedUri = `/tts/${filename}`; 28 | try { 29 | fs.accessSync(filepath, fs.R_OK); 30 | return fileDuration(filepath) 31 | .then((duration) => { 32 | return { 33 | duration, 34 | uri: expectedUri 35 | }; 36 | }); 37 | } catch (err) { 38 | console.log(`announce file for phrase "${phrase}" does not seem to exist, downloading`); 39 | } 40 | 41 | return new Promise((resolve, reject) => { 42 | var file = fs.createWriteStream(filepath); 43 | http.get(ttsRequestUrl, function (response) { 44 | if (response.statusCode < 300 && response.statusCode >= 200) { 45 | response.pipe(file); 46 | file.on('finish', function () { 47 | file.end(); 48 | resolve(expectedUri); 49 | }); 50 | } else { 51 | reject(new Error(`Download from voicerss failed with status ${response.statusCode}, ${response.message}`)); 52 | 53 | } 54 | }).on('error', function (err) { 55 | fs.unlink(dest); 56 | reject(err); 57 | }); 58 | }) 59 | .then(() => { 60 | return fileDuration(filepath); 61 | }) 62 | .then((duration) => { 63 | return { 64 | duration, 65 | uri: expectedUri 66 | }; 67 | }); 68 | } 69 | 70 | module.exports = voicerss; 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sonos-http-api", 3 | "version": "1.7.0", 4 | "description": "A simple node app for controlling a Sonos system with basic HTTP requests", 5 | "scripts": { 6 | "start": "node server.js", 7 | "lint": "eslint lib" 8 | }, 9 | "author": "Jimmy Shimizu ", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/jishi/node-sonos-http-api.git" 13 | }, 14 | "dependencies": { 15 | "anesidora": "^1.2.0", 16 | "aws-sdk": "^2.1299.0", 17 | "basic-auth": "~1.1.0", 18 | "fuse.js": "^6.4.1", 19 | "html-entities": "^1.2.1", 20 | "json5": "^0.5.1", 21 | "mime": "^1.4.1", 22 | "music-metadata": "^1.1.0", 23 | "serve-static": "^1.15.0", 24 | "request-promise": "~1.0.2", 25 | "sonos-discovery": "https://github.com/jishi/node-sonos-discovery/archive/v1.8.0.tar.gz", 26 | "wav-file-info": "0.0.8", 27 | "elevenlabs-node": "2.0.1" 28 | }, 29 | "engines": { 30 | "node": ">=4 <23" 31 | }, 32 | "main": "lib/sonos-http-api.js", 33 | "license": "MIT", 34 | "devDependencies": { 35 | "eslint": "^4.8.0", 36 | "eslint-config-airbnb-base": "^12.0.1", 37 | "eslint-plugin-import": "^2.7.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /presets/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "players": [ 3 | { 4 | "roomName": "Bathroom", 5 | "volume": 10 6 | }, 7 | { 8 | "roomName": "Kitchen", 9 | "volume": 10 10 | }, 11 | { 12 | "roomName": "Office", 13 | "volume": 10 14 | }, 15 | { 16 | "roomName": "Bedroom", 17 | "volume": 10 18 | }, 19 | { 20 | "roomName": "TV Room", 21 | "volume": 15 22 | } 23 | ], 24 | "playMode": { 25 | "shuffle": true, 26 | "repeat": "all", 27 | "crossfade": false 28 | }, 29 | "pauseOthers": false, 30 | 31 | } 32 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const http = require('http'); 3 | const https = require('https'); 4 | const fs = require('fs'); 5 | const auth = require('basic-auth'); 6 | const SonosSystem = require('sonos-discovery'); 7 | const logger = require('sonos-discovery/lib/helpers/logger'); 8 | const SonosHttpAPI = require('./lib/sonos-http-api.js'); 9 | const serveStatic = require('serve-static'); 10 | const settings = require('./settings'); 11 | 12 | const serve = new serveStatic(settings.webroot); 13 | const discovery = new SonosSystem(settings); 14 | const api = new SonosHttpAPI(discovery, settings); 15 | 16 | var requestHandler = function (req, res) { 17 | req.addListener('end', function () { 18 | serve(req, res, function (err) { 19 | 20 | // If error, route it. 21 | // This bypasses authentication on static files! 22 | //if (!err) { 23 | // return; 24 | //} 25 | 26 | if (settings.auth) { 27 | var credentials = auth(req); 28 | 29 | if (!credentials || credentials.name !== settings.auth.username || credentials.pass !== settings.auth.password) { 30 | res.statusCode = 401; 31 | res.setHeader('WWW-Authenticate', 'Basic realm="Access Denied"'); 32 | res.end('Access denied'); 33 | return; 34 | } 35 | } 36 | 37 | // Enable CORS requests 38 | res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS'); 39 | res.setHeader('Access-Control-Allow-Origin', '*'); 40 | if (req.headers['access-control-request-headers']) { 41 | res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers']); 42 | } 43 | 44 | if (req.method === 'OPTIONS') { 45 | res.end(); 46 | return; 47 | } 48 | 49 | if (req.method === 'GET') { 50 | api.requestHandler(req, res); 51 | } 52 | }); 53 | }).resume(); 54 | }; 55 | 56 | let server; 57 | 58 | if (settings.https) { 59 | var options = {}; 60 | if (settings.https.pfx) { 61 | options.pfx = fs.readFileSync(settings.https.pfx); 62 | options.passphrase = settings.https.passphrase; 63 | } else if (settings.https.key && settings.https.cert) { 64 | options.key = fs.readFileSync(settings.https.key); 65 | options.cert = fs.readFileSync(settings.https.cert); 66 | } else { 67 | logger.error("Insufficient configuration for https"); 68 | return; 69 | } 70 | 71 | const secureServer = https.createServer(options, requestHandler); 72 | secureServer.listen(settings.securePort, function () { 73 | logger.info('https server listening on port', settings.securePort); 74 | }); 75 | } 76 | 77 | server = http.createServer(requestHandler); 78 | 79 | process.on('unhandledRejection', (err) => { 80 | logger.error(err); 81 | }); 82 | 83 | let host = settings.ip; 84 | server.listen(settings.port, host, function () { 85 | logger.info('http server listening on', host, 'port', settings.port); 86 | }); 87 | 88 | server.on('error', (err) => { 89 | if (err.code && err.code === 'EADDRINUSE') { 90 | logger.error(`Port ${settings.port} seems to be in use already. Make sure the sonos-http-api isn't 91 | already running, or that no other server uses that port. You can specify an alternative http port 92 | with property "port" in settings.json`); 93 | } else { 94 | logger.error(err); 95 | } 96 | 97 | process.exit(1); 98 | }); 99 | 100 | 101 | -------------------------------------------------------------------------------- /settings.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const logger = require('sonos-discovery/lib/helpers/logger'); 5 | const tryLoadJson = require('./lib/helpers/try-load-json'); 6 | 7 | function merge(target, source) { 8 | Object.keys(source).forEach((key) => { 9 | if ((Object.getPrototypeOf(source[key]) === Object.prototype) && (target[key] !== undefined)) { 10 | merge(target[key], source[key]); 11 | } else { 12 | target[key] = source[key]; 13 | } 14 | }); 15 | } 16 | 17 | var settings = { 18 | port: 5005, 19 | ip: "0.0.0.0", 20 | securePort: 5006, 21 | cacheDir: path.resolve(__dirname, 'cache'), 22 | webroot: path.resolve(__dirname, 'static'), 23 | presetDir: path.resolve(__dirname, 'presets'), 24 | announceVolume: 40 25 | }; 26 | 27 | // load user settings 28 | const settingsFileFullPath = path.resolve(__dirname, 'settings.json'); 29 | const userSettings = tryLoadJson(settingsFileFullPath); 30 | merge(settings, userSettings); 31 | 32 | logger.debug(settings); 33 | 34 | if (!fs.existsSync(settings.webroot + '/tts/')) { 35 | fs.mkdirSync(settings.webroot + '/tts/'); 36 | } 37 | 38 | if (!fs.existsSync(settings.cacheDir)) { 39 | try { 40 | fs.mkdirSync(settings.cacheDir); 41 | } catch (err) { 42 | logger.warn(`Could not create cache directory ${settings.cacheDir}, please create it manually for all features to work.`); 43 | } 44 | } 45 | 46 | module.exports = settings; 47 | -------------------------------------------------------------------------------- /static/clips/sample_clip.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jishi/node-sonos-http-api/3776f0ee2261c924c7b7204de121a38100a08ca7/static/clips/sample_clip.mp3 -------------------------------------------------------------------------------- /static/docs/css/reset.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ v2.0 | 20110126 */ 2 | html, 3 | body, 4 | div, 5 | span, 6 | applet, 7 | object, 8 | iframe, 9 | h1, 10 | h2, 11 | h3, 12 | h4, 13 | h5, 14 | h6, 15 | p, 16 | blockquote, 17 | pre, 18 | a, 19 | abbr, 20 | acronym, 21 | address, 22 | big, 23 | cite, 24 | code, 25 | del, 26 | dfn, 27 | em, 28 | img, 29 | ins, 30 | kbd, 31 | q, 32 | s, 33 | samp, 34 | small, 35 | strike, 36 | strong, 37 | sub, 38 | sup, 39 | tt, 40 | var, 41 | b, 42 | u, 43 | i, 44 | center, 45 | dl, 46 | dt, 47 | dd, 48 | ol, 49 | ul, 50 | li, 51 | fieldset, 52 | form, 53 | label, 54 | legend, 55 | table, 56 | caption, 57 | tbody, 58 | tfoot, 59 | thead, 60 | tr, 61 | th, 62 | td, 63 | article, 64 | aside, 65 | canvas, 66 | details, 67 | embed, 68 | figure, 69 | figcaption, 70 | footer, 71 | header, 72 | hgroup, 73 | menu, 74 | nav, 75 | output, 76 | ruby, 77 | section, 78 | summary, 79 | time, 80 | mark, 81 | audio, 82 | video { 83 | margin: 0; 84 | padding: 0; 85 | border: 0; 86 | font-size: 100%; 87 | font: inherit; 88 | vertical-align: baseline; 89 | } 90 | /* HTML5 display-role reset for older browsers */ 91 | article, 92 | aside, 93 | details, 94 | figcaption, 95 | figure, 96 | footer, 97 | header, 98 | hgroup, 99 | menu, 100 | nav, 101 | section { 102 | display: block; 103 | } 104 | body { 105 | line-height: 1; 106 | } 107 | ol, 108 | ul { 109 | list-style: none; 110 | } 111 | blockquote, 112 | q { 113 | quotes: none; 114 | } 115 | blockquote:before, 116 | blockquote:after, 117 | q:before, 118 | q:after { 119 | content: ''; 120 | content: none; 121 | } 122 | table { 123 | border-collapse: collapse; 124 | border-spacing: 0; 125 | } 126 | -------------------------------------------------------------------------------- /static/docs/images/explorer_icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jishi/node-sonos-http-api/3776f0ee2261c924c7b7204de121a38100a08ca7/static/docs/images/explorer_icons.png -------------------------------------------------------------------------------- /static/docs/images/logo_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jishi/node-sonos-http-api/3776f0ee2261c924c7b7204de121a38100a08ca7/static/docs/images/logo_small.png -------------------------------------------------------------------------------- /static/docs/images/pet_store_api.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jishi/node-sonos-http-api/3776f0ee2261c924c7b7204de121a38100a08ca7/static/docs/images/pet_store_api.png -------------------------------------------------------------------------------- /static/docs/images/throbber.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jishi/node-sonos-http-api/3776f0ee2261c924c7b7204de121a38100a08ca7/static/docs/images/throbber.gif -------------------------------------------------------------------------------- /static/docs/images/wordnik_api.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jishi/node-sonos-http-api/3776f0ee2261c924c7b7204de121a38100a08ca7/static/docs/images/wordnik_api.png -------------------------------------------------------------------------------- /static/docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Swagger UI 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 69 | 70 | 71 | 72 | 88 | 89 |
 
90 |
91 | 92 | 93 | -------------------------------------------------------------------------------- /static/docs/lib/highlight.7.3.pack.js: -------------------------------------------------------------------------------- 1 | var hljs=new function(){function l(o){return o.replace(/&/gm,"&").replace(//gm,">")}function b(p){for(var o=p.firstChild;o;o=o.nextSibling){if(o.nodeName=="CODE"){return o}if(!(o.nodeType==3&&o.nodeValue.match(/\s+/))){break}}}function h(p,o){return Array.prototype.map.call(p.childNodes,function(q){if(q.nodeType==3){return o?q.nodeValue.replace(/\n/g,""):q.nodeValue}if(q.nodeName=="BR"){return"\n"}return h(q,o)}).join("")}function a(q){var p=(q.className+" "+q.parentNode.className).split(/\s+/);p=p.map(function(r){return r.replace(/^language-/,"")});for(var o=0;o"}while(x.length||v.length){var u=t().splice(0,1)[0];y+=l(w.substr(p,u.offset-p));p=u.offset;if(u.event=="start"){y+=s(u.node);r.push(u.node)}else{if(u.event=="stop"){var o,q=r.length;do{q--;o=r[q];y+=("")}while(o!=u.node);r.splice(q,1);while(q'+L[0]+""}else{r+=L[0]}N=A.lR.lastIndex;L=A.lR.exec(K)}return r+K.substr(N)}function z(){if(A.sL&&!e[A.sL]){return l(w)}var r=A.sL?d(A.sL,w):g(w);if(A.r>0){v+=r.keyword_count;B+=r.r}return''+r.value+""}function J(){return A.sL!==undefined?z():G()}function I(L,r){var K=L.cN?'':"";if(L.rB){x+=K;w=""}else{if(L.eB){x+=l(r)+K;w=""}else{x+=K;w=r}}A=Object.create(L,{parent:{value:A}});B+=L.r}function C(K,r){w+=K;if(r===undefined){x+=J();return 0}var L=o(r,A);if(L){x+=J();I(L,r);return L.rB?0:r.length}var M=s(A,r);if(M){if(!(M.rE||M.eE)){w+=r}x+=J();do{if(A.cN){x+=""}A=A.parent}while(A!=M.parent);if(M.eE){x+=l(r)}w="";if(M.starts){I(M.starts,"")}return M.rE?0:r.length}if(t(r,A)){throw"Illegal"}w+=r;return r.length||1}var F=e[D];f(F);var A=F;var w="";var B=0;var v=0;var x="";try{var u,q,p=0;while(true){A.t.lastIndex=p;u=A.t.exec(E);if(!u){break}q=C(E.substr(p,u.index-p),u[0]);p=u.index+q}C(E.substr(p));return{r:B,keyword_count:v,value:x,language:D}}catch(H){if(H=="Illegal"){return{r:0,keyword_count:0,value:l(E)}}else{throw H}}}function g(s){var o={keyword_count:0,r:0,value:l(s)};var q=o;for(var p in e){if(!e.hasOwnProperty(p)){continue}var r=d(p,s);r.language=p;if(r.keyword_count+r.r>q.keyword_count+q.r){q=r}if(r.keyword_count+r.r>o.keyword_count+o.r){q=o;o=r}}if(q.language){o.second_best=q}return o}function i(q,p,o){if(p){q=q.replace(/^((<[^>]+>|\t)+)/gm,function(r,v,u,t){return v.replace(/\t/g,p)})}if(o){q=q.replace(/\n/g,"
")}return q}function m(r,u,p){var v=h(r,p);var t=a(r);if(t=="no-highlight"){return}var w=t?d(t,v):g(v);t=w.language;var o=c(r);if(o.length){var q=document.createElement("pre");q.innerHTML=w.value;w.value=j(o,c(q),v)}w.value=i(w.value,u,p);var s=r.className;if(!s.match("(\\s|^)(language-)?"+t+"(\\s|$)")){s=s?(s+" "+t):t}r.innerHTML=w.value;r.className=s;r.result={language:t,kw:w.keyword_count,re:w.r};if(w.second_best){r.second_best={language:w.second_best.language,kw:w.second_best.keyword_count,re:w.second_best.r}}}function n(){if(n.called){return}n.called=true;Array.prototype.map.call(document.getElementsByTagName("pre"),b).filter(Boolean).forEach(function(o){m(o,hljs.tabReplace)})}function k(){window.addEventListener("DOMContentLoaded",n,false);window.addEventListener("load",n,false)}var e={};this.LANGUAGES=e;this.highlight=d;this.highlightAuto=g;this.fixMarkup=i;this.highlightBlock=m;this.initHighlighting=n;this.initHighlightingOnLoad=k;this.IR="[a-zA-Z][a-zA-Z0-9_]*";this.UIR="[a-zA-Z_][a-zA-Z0-9_]*";this.NR="\\b\\d+(\\.\\d+)?";this.CNR="(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)";this.BNR="\\b(0b[01]+)";this.RSR="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|\\.|-|-=|/|/=|:|;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~";this.BE={b:"\\\\[\\s\\S]",r:0};this.ASM={cN:"string",b:"'",e:"'",i:"\\n",c:[this.BE],r:0};this.QSM={cN:"string",b:'"',e:'"',i:"\\n",c:[this.BE],r:0};this.CLCM={cN:"comment",b:"//",e:"$"};this.CBLCLM={cN:"comment",b:"/\\*",e:"\\*/"};this.HCM={cN:"comment",b:"#",e:"$"};this.NM={cN:"number",b:this.NR,r:0};this.CNM={cN:"number",b:this.CNR,r:0};this.BNM={cN:"number",b:this.BNR,r:0};this.inherit=function(q,r){var o={};for(var p in q){o[p]=q[p]}if(r){for(var p in r){o[p]=r[p]}}return o}}();hljs.LANGUAGES.xml=function(a){var c="[A-Za-z0-9\\._:-]+";var b={eW:true,c:[{cN:"attribute",b:c,r:0},{b:'="',rB:true,e:'"',c:[{cN:"value",b:'"',eW:true}]},{b:"='",rB:true,e:"'",c:[{cN:"value",b:"'",eW:true}]},{b:"=",c:[{cN:"value",b:"[^\\s/>]+"}]}]};return{cI:true,c:[{cN:"pi",b:"<\\?",e:"\\?>",r:10},{cN:"doctype",b:"",r:10,c:[{b:"\\[",e:"\\]"}]},{cN:"comment",b:"",r:10},{cN:"cdata",b:"<\\!\\[CDATA\\[",e:"\\]\\]>",r:10},{cN:"tag",b:"|$)",e:">",k:{title:"style"},c:[b],starts:{e:"",rE:true,sL:"css"}},{cN:"tag",b:"|$)",e:">",k:{title:"script"},c:[b],starts:{e:"<\/script>",rE:true,sL:"javascript"}},{b:"<%",e:"%>",sL:"vbscript"},{cN:"tag",b:"",c:[{cN:"title",b:"[^ />]+"},b]}]}}(hljs);hljs.LANGUAGES.json=function(a){var e={literal:"true false null"};var d=[a.QSM,a.CNM];var c={cN:"value",e:",",eW:true,eE:true,c:d,k:e};var b={b:"{",e:"}",c:[{cN:"attribute",b:'\\s*"',e:'"\\s*:\\s*',eB:true,eE:true,c:[a.BE],i:"\\n",starts:c}],i:"\\S"};var f={b:"\\[",e:"\\]",c:[a.inherit(c,{cN:null})],i:"\\S"};d.splice(d.length,0,b,f);return{c:d,k:e,i:"\\S"}}(hljs); -------------------------------------------------------------------------------- /static/docs/lib/jquery.ba-bbq.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | * jQuery BBQ: Back Button & Query Library - v1.2.1 - 2/17/2010 3 | * http://benalman.com/projects/jquery-bbq-plugin/ 4 | * 5 | * Copyright (c) 2010 "Cowboy" Ben Alman 6 | * Dual licensed under the MIT and GPL licenses. 7 | * http://benalman.com/about/license/ 8 | */ 9 | (function($,p){var i,m=Array.prototype.slice,r=decodeURIComponent,a=$.param,c,l,v,b=$.bbq=$.bbq||{},q,u,j,e=$.event.special,d="hashchange",A="querystring",D="fragment",y="elemUrlAttr",g="location",k="href",t="src",x=/^.*\?|#.*$/g,w=/^.*\#/,h,C={};function E(F){return typeof F==="string"}function B(G){var F=m.call(arguments,1);return function(){return G.apply(this,F.concat(m.call(arguments)))}}function n(F){return F.replace(/^[^#]*#?(.*)$/,"$1")}function o(F){return F.replace(/(?:^[^?#]*\?([^#]*).*$)?.*/,"$1")}function f(H,M,F,I,G){var O,L,K,N,J;if(I!==i){K=F.match(H?/^([^#]*)\#?(.*)$/:/^([^#?]*)\??([^#]*)(#?.*)/);J=K[3]||"";if(G===2&&E(I)){L=I.replace(H?w:x,"")}else{N=l(K[2]);I=E(I)?l[H?D:A](I):I;L=G===2?I:G===1?$.extend({},I,N):$.extend({},N,I);L=a(L);if(H){L=L.replace(h,r)}}O=K[1]+(H?"#":L||!K[1]?"?":"")+L+J}else{O=M(F!==i?F:p[g][k])}return O}a[A]=B(f,0,o);a[D]=c=B(f,1,n);c.noEscape=function(G){G=G||"";var F=$.map(G.split(""),encodeURIComponent);h=new RegExp(F.join("|"),"g")};c.noEscape(",/");$.deparam=l=function(I,F){var H={},G={"true":!0,"false":!1,"null":null};$.each(I.replace(/\+/g," ").split("&"),function(L,Q){var K=Q.split("="),P=r(K[0]),J,O=H,M=0,R=P.split("]["),N=R.length-1;if(/\[/.test(R[0])&&/\]$/.test(R[N])){R[N]=R[N].replace(/\]$/,"");R=R.shift().split("[").concat(R);N=R.length-1}else{N=0}if(K.length===2){J=r(K[1]);if(F){J=J&&!isNaN(J)?+J:J==="undefined"?i:G[J]!==i?G[J]:J}if(N){for(;M<=N;M++){P=R[M]===""?O.length:R[M];O=O[P]=M').hide().insertAfter("body")[0].contentWindow;q=function(){return a(n.document[c][l])};o=function(u,s){if(u!==s){var t=n.document;t.open().close();t[c].hash="#"+u}};o(a())}}m.start=function(){if(r){return}var t=a();o||p();(function s(){var v=a(),u=q(t);if(v!==t){o(t=v,u);$(i).trigger(d)}else{if(u!==t){i[c][l]=i[c][l].replace(/#.*/,"")+"#"+u}}r=setTimeout(s,$[d+"Delay"])})()};m.stop=function(){if(!n){r&&clearTimeout(r);r=0}};return m})()})(jQuery,this); -------------------------------------------------------------------------------- /static/docs/lib/jquery.slideto.min.js: -------------------------------------------------------------------------------- 1 | (function(b){b.fn.slideto=function(a){a=b.extend({slide_duration:"slow",highlight_duration:3E3,highlight:true,highlight_color:"#FFFF99"},a);return this.each(function(){obj=b(this);b("body").animate({scrollTop:obj.offset().top},a.slide_duration,function(){a.highlight&&b.ui.version&&obj.effect("highlight",{color:a.highlight_color},a.highlight_duration)})})}})(jQuery); 2 | -------------------------------------------------------------------------------- /static/docs/lib/jquery.wiggle.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | jQuery Wiggle 3 | Author: WonderGroup, Jordan Thomas 4 | URL: http://labs.wondergroup.com/demos/mini-ui/index.html 5 | License: MIT (http://en.wikipedia.org/wiki/MIT_License) 6 | */ 7 | jQuery.fn.wiggle=function(o){var d={speed:50,wiggles:3,travel:5,callback:null};var o=jQuery.extend(d,o);return this.each(function(){var cache=this;var wrap=jQuery(this).wrap('
').css("position","relative");var calls=0;for(i=1;i<=o.wiggles;i++){jQuery(this).animate({left:"-="+o.travel},o.speed).animate({left:"+="+o.travel*2},o.speed*2).animate({left:"-="+o.travel},o.speed,function(){calls++;if(jQuery(cache).parent().hasClass('wiggle-wrap')){jQuery(cache).parent().replaceWith(cache);} 8 | if(calls==o.wiggles&&jQuery.isFunction(o.callback)){o.callback();}});}});}; -------------------------------------------------------------------------------- /static/docs/lib/shred/content.js: -------------------------------------------------------------------------------- 1 | 2 | // The purpose of the `Content` object is to abstract away the data conversions 3 | // to and from raw content entities as strings. For example, you want to be able 4 | // to pass in a Javascript object and have it be automatically converted into a 5 | // JSON string if the `content-type` is set to a JSON-based media type. 6 | // Conversely, you want to be able to transparently get back a Javascript object 7 | // in the response if the `content-type` is a JSON-based media-type. 8 | 9 | // One limitation of the current implementation is that it [assumes the `charset` is UTF-8](https://github.com/spire-io/shred/issues/5). 10 | 11 | // The `Content` constructor takes an options object, which *must* have either a 12 | // `body` or `data` property and *may* have a `type` property indicating the 13 | // media type. If there is no `type` attribute, a default will be inferred. 14 | var Content = function(options) { 15 | this.body = options.body; 16 | this.data = options.data; 17 | this.type = options.type; 18 | }; 19 | 20 | Content.prototype = { 21 | // Treat `toString()` as asking for the `content.body`. That is, the raw content entity. 22 | // 23 | // toString: function() { return this.body; } 24 | // 25 | // Commented out, but I've forgotten why. :/ 26 | }; 27 | 28 | 29 | // `Content` objects have the following attributes: 30 | Object.defineProperties(Content.prototype,{ 31 | 32 | // - **type**. Typically accessed as `content.type`, reflects the `content-type` 33 | // header associated with the request or response. If not passed as an options 34 | // to the constructor or set explicitly, it will infer the type the `data` 35 | // attribute, if possible, and, failing that, will default to `text/plain`. 36 | type: { 37 | get: function() { 38 | if (this._type) { 39 | return this._type; 40 | } else { 41 | if (this._data) { 42 | switch(typeof this._data) { 43 | case "string": return "text/plain"; 44 | case "object": return "application/json"; 45 | } 46 | } 47 | } 48 | return "text/plain"; 49 | }, 50 | set: function(value) { 51 | this._type = value; 52 | return this; 53 | }, 54 | enumerable: true 55 | }, 56 | 57 | // - **data**. Typically accessed as `content.data`, reflects the content entity 58 | // converted into Javascript data. This can be a string, if the `type` is, say, 59 | // `text/plain`, but can also be a Javascript object. The conversion applied is 60 | // based on the `processor` attribute. The `data` attribute can also be set 61 | // directly, in which case the conversion will be done the other way, to infer 62 | // the `body` attribute. 63 | data: { 64 | get: function() { 65 | if (this._body) { 66 | return this.processor.parser(this._body); 67 | } else { 68 | return this._data; 69 | } 70 | }, 71 | set: function(data) { 72 | if (this._body&&data) Errors.setDataWithBody(this); 73 | this._data = data; 74 | return this; 75 | }, 76 | enumerable: true 77 | }, 78 | 79 | // - **body**. Typically accessed as `content.body`, reflects the content entity 80 | // as a UTF-8 string. It is the mirror of the `data` attribute. If you set the 81 | // `data` attribute, the `body` attribute will be inferred and vice-versa. If 82 | // you attempt to set both, an exception is raised. 83 | body: { 84 | get: function() { 85 | if (this._data) { 86 | return this.processor.stringify(this._data); 87 | } else { 88 | return this._body.toString(); 89 | } 90 | }, 91 | set: function(body) { 92 | if (this._data&&body) Errors.setBodyWithData(this); 93 | this._body = body; 94 | return this; 95 | }, 96 | enumerable: true 97 | }, 98 | 99 | // - **processor**. The functions that will be used to convert to/from `data` and 100 | // `body` attributes. You can add processors. The two that are built-in are for 101 | // `text/plain`, which is basically an identity transformation and 102 | // `application/json` and other JSON-based media types (including custom media 103 | // types with `+json`). You can add your own processors. See below. 104 | processor: { 105 | get: function() { 106 | var processor = Content.processors[this.type]; 107 | if (processor) { 108 | return processor; 109 | } else { 110 | // Return the first processor that matches any part of the 111 | // content type. ex: application/vnd.foobar.baz+json will match json. 112 | var main = this.type.split(";")[0]; 113 | var parts = main.split(/\+|\//); 114 | for (var i=0, l=parts.length; i < l; i++) { 115 | processor = Content.processors[parts[i]] 116 | } 117 | return processor || {parser:identity,stringify:toString}; 118 | } 119 | }, 120 | enumerable: true 121 | }, 122 | 123 | // - **length**. Typically accessed as `content.length`, returns the length in 124 | // bytes of the raw content entity. 125 | length: { 126 | get: function() { 127 | if (typeof Buffer !== 'undefined') { 128 | return Buffer.byteLength(this.body); 129 | } 130 | return this.body.length; 131 | } 132 | } 133 | }); 134 | 135 | Content.processors = {}; 136 | 137 | // The `registerProcessor` function allows you to add your own processors to 138 | // convert content entities. Each processor consists of a Javascript object with 139 | // two properties: 140 | // - **parser**. The function used to parse a raw content entity and convert it 141 | // into a Javascript data type. 142 | // - **stringify**. The function used to convert a Javascript data type into a 143 | // raw content entity. 144 | Content.registerProcessor = function(types,processor) { 145 | 146 | // You can pass an array of types that will trigger this processor, or just one. 147 | // We determine the array via duck-typing here. 148 | if (types.forEach) { 149 | types.forEach(function(type) { 150 | Content.processors[type] = processor; 151 | }); 152 | } else { 153 | // If you didn't pass an array, we just use what you pass in. 154 | Content.processors[types] = processor; 155 | } 156 | }; 157 | 158 | // Register the identity processor, which is used for text-based media types. 159 | var identity = function(x) { return x; } 160 | , toString = function(x) { return x.toString(); } 161 | Content.registerProcessor( 162 | ["text/html","text/plain","text"], 163 | { parser: identity, stringify: toString }); 164 | 165 | // Register the JSON processor, which is used for JSON-based media types. 166 | Content.registerProcessor( 167 | ["application/json; charset=utf-8","application/json","json"], 168 | { 169 | parser: function(string) { 170 | return JSON.parse(string); 171 | }, 172 | stringify: function(data) { 173 | return JSON.stringify(data); }}); 174 | 175 | var qs = require('querystring'); 176 | // Register the post processor, which is used for JSON-based media types. 177 | Content.registerProcessor( 178 | ["application/x-www-form-urlencoded"], 179 | { parser : qs.parse, stringify : qs.stringify }); 180 | 181 | // Error functions are defined separately here in an attempt to make the code 182 | // easier to read. 183 | var Errors = { 184 | setDataWithBody: function(object) { 185 | throw new Error("Attempt to set data attribute of a content object " + 186 | "when the body attributes was already set."); 187 | }, 188 | setBodyWithData: function(object) { 189 | throw new Error("Attempt to set body attribute of a content object " + 190 | "when the data attributes was already set."); 191 | } 192 | } 193 | module.exports = Content; -------------------------------------------------------------------------------- /static/docs/lib/swagger-oauth.js: -------------------------------------------------------------------------------- 1 | var appName; 2 | var popupMask; 3 | var popupDialog; 4 | var clientId; 5 | var realm; 6 | 7 | function handleLogin() { 8 | var scopes = []; 9 | 10 | if(window.swaggerUi.api.authSchemes 11 | && window.swaggerUi.api.authSchemes.oauth2 12 | && window.swaggerUi.api.authSchemes.oauth2.scopes) { 13 | scopes = window.swaggerUi.api.authSchemes.oauth2.scopes; 14 | } 15 | 16 | if(window.swaggerUi.api 17 | && window.swaggerUi.api.info) { 18 | appName = window.swaggerUi.api.info.title; 19 | } 20 | 21 | if(popupDialog.length > 0) 22 | popupDialog = popupDialog.last(); 23 | else { 24 | popupDialog = $( 25 | [ 26 | '
', 27 | '
Select OAuth2.0 Scopes
', 28 | '
', 29 | '

Scopes are used to grant an application different levels of access to data on behalf of the end user. Each API may declare one or more scopes.', 30 | 'Learn how to use', 31 | '

', 32 | '

' + appName + ' API requires the following scopes. Select which ones you want to grant to Swagger UI.

', 33 | '
    ', 34 | '
', 35 | '

', 36 | '
', 37 | '
', 38 | '
'].join('')); 39 | $(document.body).append(popupDialog); 40 | 41 | popup = popupDialog.find('ul.api-popup-scopes').empty(); 42 | for (i = 0; i < scopes.length; i ++) { 43 | scope = scopes[i]; 44 | str = '
  • ' + '
  • '; 49 | popup.append(str); 50 | } 51 | } 52 | 53 | var $win = $(window), 54 | dw = $win.width(), 55 | dh = $win.height(), 56 | st = $win.scrollTop(), 57 | dlgWd = popupDialog.outerWidth(), 58 | dlgHt = popupDialog.outerHeight(), 59 | top = (dh -dlgHt)/2 + st, 60 | left = (dw - dlgWd)/2; 61 | 62 | popupDialog.css({ 63 | top: (top < 0? 0 : top) + 'px', 64 | left: (left < 0? 0 : left) + 'px' 65 | }); 66 | 67 | popupDialog.find('button.api-popup-cancel').click(function() { 68 | popupMask.hide(); 69 | popupDialog.hide(); 70 | }); 71 | popupDialog.find('button.api-popup-authbtn').click(function() { 72 | popupMask.hide(); 73 | popupDialog.hide(); 74 | 75 | var authSchemes = window.swaggerUi.api.authSchemes; 76 | var host = window.location; 77 | var redirectUrl = host.protocol + '//' + host.host + "/o2c.html"; 78 | var url = null; 79 | 80 | var p = window.swaggerUi.api.authSchemes; 81 | for (var key in p) { 82 | if (p.hasOwnProperty(key)) { 83 | var o = p[key].grantTypes; 84 | for(var t in o) { 85 | if(o.hasOwnProperty(t) && t === 'implicit') { 86 | var dets = o[t]; 87 | url = dets.loginEndpoint.url + "?response_type=token"; 88 | window.swaggerUi.tokenName = dets.tokenName; 89 | } 90 | } 91 | } 92 | } 93 | var scopes = [] 94 | var o = $('.api-popup-scopes').find('input:checked'); 95 | 96 | for(k =0; k < o.length; k++) { 97 | scopes.push($(o[k]).attr("scope")); 98 | } 99 | 100 | window.enabledScopes=scopes; 101 | 102 | url += '&redirect_uri=' + encodeURIComponent(redirectUrl); 103 | url += '&realm=' + encodeURIComponent(realm); 104 | url += '&client_id=' + encodeURIComponent(clientId); 105 | url += '&scope=' + encodeURIComponent(scopes); 106 | 107 | window.open(url); 108 | }); 109 | 110 | popupMask.show(); 111 | popupDialog.show(); 112 | return; 113 | } 114 | 115 | 116 | function handleLogout() { 117 | for(key in window.authorizations.authz){ 118 | window.authorizations.remove(key) 119 | } 120 | window.enabledScopes = null; 121 | $('.api-ic.ic-on').addClass('ic-off'); 122 | $('.api-ic.ic-on').removeClass('ic-on'); 123 | 124 | // set the info box 125 | $('.api-ic.ic-warning').addClass('ic-error'); 126 | $('.api-ic.ic-warning').removeClass('ic-warning'); 127 | } 128 | 129 | function initOAuth(opts) { 130 | var o = (opts||{}); 131 | var errors = []; 132 | 133 | appName = (o.appName||errors.push("missing appName")); 134 | popupMask = (o.popupMask||$('#api-common-mask')); 135 | popupDialog = (o.popupDialog||$('.api-popup-dialog')); 136 | clientId = (o.clientId||errors.push("missing client id")); 137 | realm = (o.realm||errors.push("missing realm")); 138 | 139 | if(errors.length > 0){ 140 | log("auth unable initialize oauth: " + errors); 141 | return; 142 | } 143 | 144 | $('pre code').each(function(i, e) {hljs.highlightBlock(e)}); 145 | $('.api-ic').click(function(s) { 146 | if($(s.target).hasClass('ic-off')) 147 | handleLogin(); 148 | else { 149 | handleLogout(); 150 | } 151 | false; 152 | }); 153 | } 154 | 155 | function onOAuthComplete(token) { 156 | if(token) { 157 | if(token.error) { 158 | var checkbox = $('input[type=checkbox],.secured') 159 | checkbox.each(function(pos){ 160 | checkbox[pos].checked = false; 161 | }); 162 | alert(token.error); 163 | } 164 | else { 165 | var b = token[window.swaggerUi.tokenName]; 166 | if(b){ 167 | // if all roles are satisfied 168 | var o = null; 169 | $.each($('.auth #api_information_panel'), function(k, v) { 170 | var children = v; 171 | if(children && children.childNodes) { 172 | var requiredScopes = []; 173 | $.each((children.childNodes), function (k1, v1){ 174 | var inner = v1.innerHTML; 175 | if(inner) 176 | requiredScopes.push(inner); 177 | }); 178 | var diff = []; 179 | for(var i=0; i < requiredScopes.length; i++) { 180 | var s = requiredScopes[i]; 181 | if(window.enabledScopes && window.enabledScopes.indexOf(s) == -1) { 182 | diff.push(s); 183 | } 184 | } 185 | if(diff.length > 0){ 186 | o = v.parentNode; 187 | $(o.parentNode).find('.api-ic.ic-on').addClass('ic-off'); 188 | $(o.parentNode).find('.api-ic.ic-on').removeClass('ic-on'); 189 | 190 | // sorry, not all scopes are satisfied 191 | $(o).find('.api-ic').addClass('ic-warning'); 192 | $(o).find('.api-ic').removeClass('ic-error'); 193 | } 194 | else { 195 | o = v.parentNode; 196 | $(o.parentNode).find('.api-ic.ic-off').addClass('ic-on'); 197 | $(o.parentNode).find('.api-ic.ic-off').removeClass('ic-off'); 198 | 199 | // all scopes are satisfied 200 | $(o).find('.api-ic').addClass('ic-info'); 201 | $(o).find('.api-ic').removeClass('ic-warning'); 202 | $(o).find('.api-ic').removeClass('ic-error'); 203 | } 204 | } 205 | }); 206 | 207 | window.authorizations.add("key", new ApiKeyAuthorization("Authorization", "Bearer " + b, "header")); 208 | } 209 | } 210 | } 211 | } -------------------------------------------------------------------------------- /static/docs/lib/underscore-min.js: -------------------------------------------------------------------------------- 1 | // Underscore.js 1.3.3 2 | // (c) 2009-2012 Jeremy Ashkenas, DocumentCloud Inc. 3 | // Underscore is freely distributable under the MIT license. 4 | // Portions of Underscore are inspired or borrowed from Prototype, 5 | // Oliver Steele's Functional, and John Resig's Micro-Templating. 6 | // For all details and documentation: 7 | // http://documentcloud.github.com/underscore 8 | (function(){function r(a,c,d){if(a===c)return 0!==a||1/a==1/c;if(null==a||null==c)return a===c;a._chain&&(a=a._wrapped);c._chain&&(c=c._wrapped);if(a.isEqual&&b.isFunction(a.isEqual))return a.isEqual(c);if(c.isEqual&&b.isFunction(c.isEqual))return c.isEqual(a);var e=l.call(a);if(e!=l.call(c))return!1;switch(e){case "[object String]":return a==""+c;case "[object Number]":return a!=+a?c!=+c:0==a?1/a==1/c:a==+c;case "[object Date]":case "[object Boolean]":return+a==+c;case "[object RegExp]":return a.source== 9 | c.source&&a.global==c.global&&a.multiline==c.multiline&&a.ignoreCase==c.ignoreCase}if("object"!=typeof a||"object"!=typeof c)return!1;for(var f=d.length;f--;)if(d[f]==a)return!0;d.push(a);var f=0,g=!0;if("[object Array]"==e){if(f=a.length,g=f==c.length)for(;f--&&(g=f in a==f in c&&r(a[f],c[f],d)););}else{if("constructor"in a!="constructor"in c||a.constructor!=c.constructor)return!1;for(var h in a)if(b.has(a,h)&&(f++,!(g=b.has(c,h)&&r(a[h],c[h],d))))break;if(g){for(h in c)if(b.has(c,h)&&!f--)break; 10 | g=!f}}d.pop();return g}var s=this,I=s._,o={},k=Array.prototype,p=Object.prototype,i=k.slice,J=k.unshift,l=p.toString,K=p.hasOwnProperty,y=k.forEach,z=k.map,A=k.reduce,B=k.reduceRight,C=k.filter,D=k.every,E=k.some,q=k.indexOf,F=k.lastIndexOf,p=Array.isArray,L=Object.keys,t=Function.prototype.bind,b=function(a){return new m(a)};"undefined"!==typeof exports?("undefined"!==typeof module&&module.exports&&(exports=module.exports=b),exports._=b):s._=b;b.VERSION="1.3.3";var j=b.each=b.forEach=function(a, 11 | c,d){if(a!=null)if(y&&a.forEach===y)a.forEach(c,d);else if(a.length===+a.length)for(var e=0,f=a.length;e2;a==null&&(a=[]);if(A&& 12 | a.reduce===A){e&&(c=b.bind(c,e));return f?a.reduce(c,d):a.reduce(c)}j(a,function(a,b,i){if(f)d=c.call(e,d,a,b,i);else{d=a;f=true}});if(!f)throw new TypeError("Reduce of empty array with no initial value");return d};b.reduceRight=b.foldr=function(a,c,d,e){var f=arguments.length>2;a==null&&(a=[]);if(B&&a.reduceRight===B){e&&(c=b.bind(c,e));return f?a.reduceRight(c,d):a.reduceRight(c)}var g=b.toArray(a).reverse();e&&!f&&(c=b.bind(c,e));return f?b.reduce(g,c,d,e):b.reduce(g,c)};b.find=b.detect=function(a, 13 | c,b){var e;G(a,function(a,g,h){if(c.call(b,a,g,h)){e=a;return true}});return e};b.filter=b.select=function(a,c,b){var e=[];if(a==null)return e;if(C&&a.filter===C)return a.filter(c,b);j(a,function(a,g,h){c.call(b,a,g,h)&&(e[e.length]=a)});return e};b.reject=function(a,c,b){var e=[];if(a==null)return e;j(a,function(a,g,h){c.call(b,a,g,h)||(e[e.length]=a)});return e};b.every=b.all=function(a,c,b){var e=true;if(a==null)return e;if(D&&a.every===D)return a.every(c,b);j(a,function(a,g,h){if(!(e=e&&c.call(b, 14 | a,g,h)))return o});return!!e};var G=b.some=b.any=function(a,c,d){c||(c=b.identity);var e=false;if(a==null)return e;if(E&&a.some===E)return a.some(c,d);j(a,function(a,b,h){if(e||(e=c.call(d,a,b,h)))return o});return!!e};b.include=b.contains=function(a,c){var b=false;if(a==null)return b;if(q&&a.indexOf===q)return a.indexOf(c)!=-1;return b=G(a,function(a){return a===c})};b.invoke=function(a,c){var d=i.call(arguments,2);return b.map(a,function(a){return(b.isFunction(c)?c||a:a[c]).apply(a,d)})};b.pluck= 15 | function(a,c){return b.map(a,function(a){return a[c]})};b.max=function(a,c,d){if(!c&&b.isArray(a)&&a[0]===+a[0])return Math.max.apply(Math,a);if(!c&&b.isEmpty(a))return-Infinity;var e={computed:-Infinity};j(a,function(a,b,h){b=c?c.call(d,a,b,h):a;b>=e.computed&&(e={value:a,computed:b})});return e.value};b.min=function(a,c,d){if(!c&&b.isArray(a)&&a[0]===+a[0])return Math.min.apply(Math,a);if(!c&&b.isEmpty(a))return Infinity;var e={computed:Infinity};j(a,function(a,b,h){b=c?c.call(d,a,b,h):a;bd?1:0}),"value")};b.groupBy=function(a,c){var d={},e=b.isFunction(c)?c:function(a){return a[c]}; 17 | j(a,function(a,b){var c=e(a,b);(d[c]||(d[c]=[])).push(a)});return d};b.sortedIndex=function(a,c,d){d||(d=b.identity);for(var e=0,f=a.length;e>1;d(a[g])=0})})};b.difference=function(a){var c=b.flatten(i.call(arguments,1),true);return b.filter(a,function(a){return!b.include(c,a)})};b.zip=function(){for(var a= 20 | i.call(arguments),c=b.max(b.pluck(a,"length")),d=Array(c),e=0;e=0;d--)b=[a[d].apply(this,b)];return b[0]}};b.after=function(a,b){return a<=0?b():function(){if(--a<1)return b.apply(this,arguments)}};b.keys=L||function(a){if(a!==Object(a))throw new TypeError("Invalid object");var c=[],d;for(d in a)b.has(a,d)&&(c[c.length]=d);return c};b.values=function(a){return b.map(a,b.identity)};b.functions=b.methods=function(a){var c=[],d;for(d in a)b.isFunction(a[d])&& 25 | c.push(d);return c.sort()};b.extend=function(a){j(i.call(arguments,1),function(b){for(var d in b)a[d]=b[d]});return a};b.pick=function(a){var c={};j(b.flatten(i.call(arguments,1)),function(b){b in a&&(c[b]=a[b])});return c};b.defaults=function(a){j(i.call(arguments,1),function(b){for(var d in b)a[d]==null&&(a[d]=b[d])});return a};b.clone=function(a){return!b.isObject(a)?a:b.isArray(a)?a.slice():b.extend({},a)};b.tap=function(a,b){b(a);return a};b.isEqual=function(a,b){return r(a,b,[])};b.isEmpty= 26 | function(a){if(a==null)return true;if(b.isArray(a)||b.isString(a))return a.length===0;for(var c in a)if(b.has(a,c))return false;return true};b.isElement=function(a){return!!(a&&a.nodeType==1)};b.isArray=p||function(a){return l.call(a)=="[object Array]"};b.isObject=function(a){return a===Object(a)};b.isArguments=function(a){return l.call(a)=="[object Arguments]"};b.isArguments(arguments)||(b.isArguments=function(a){return!(!a||!b.has(a,"callee"))});b.isFunction=function(a){return l.call(a)=="[object Function]"}; 27 | b.isString=function(a){return l.call(a)=="[object String]"};b.isNumber=function(a){return l.call(a)=="[object Number]"};b.isFinite=function(a){return b.isNumber(a)&&isFinite(a)};b.isNaN=function(a){return a!==a};b.isBoolean=function(a){return a===true||a===false||l.call(a)=="[object Boolean]"};b.isDate=function(a){return l.call(a)=="[object Date]"};b.isRegExp=function(a){return l.call(a)=="[object RegExp]"};b.isNull=function(a){return a===null};b.isUndefined=function(a){return a===void 0};b.has=function(a, 28 | b){return K.call(a,b)};b.noConflict=function(){s._=I;return this};b.identity=function(a){return a};b.times=function(a,b,d){for(var e=0;e/g,">").replace(/"/g,""").replace(/'/g,"'").replace(/\//g,"/")};b.result=function(a,c){if(a==null)return null;var d=a[c];return b.isFunction(d)?d.call(a):d};b.mixin=function(a){j(b.functions(a),function(c){M(c,b[c]=a[c])})};var N=0;b.uniqueId= 29 | function(a){var b=N++;return a?a+b:b};b.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};var u=/.^/,n={"\\":"\\","'":"'",r:"\r",n:"\n",t:"\t",u2028:"\u2028",u2029:"\u2029"},v;for(v in n)n[n[v]]=v;var O=/\\|'|\r|\n|\t|\u2028|\u2029/g,P=/\\(\\|'|r|n|t|u2028|u2029)/g,w=function(a){return a.replace(P,function(a,b){return n[b]})};b.template=function(a,c,d){d=b.defaults(d||{},b.templateSettings);a="__p+='"+a.replace(O,function(a){return"\\"+n[a]}).replace(d.escape|| 30 | u,function(a,b){return"'+\n_.escape("+w(b)+")+\n'"}).replace(d.interpolate||u,function(a,b){return"'+\n("+w(b)+")+\n'"}).replace(d.evaluate||u,function(a,b){return"';\n"+w(b)+"\n;__p+='"})+"';\n";d.variable||(a="with(obj||{}){\n"+a+"}\n");var a="var __p='';var print=function(){__p+=Array.prototype.join.call(arguments, '')};\n"+a+"return __p;\n",e=new Function(d.variable||"obj","_",a);if(c)return e(c,b);c=function(a){return e.call(this,a,b)};c.source="function("+(d.variable||"obj")+"){\n"+a+"}";return c}; 31 | b.chain=function(a){return b(a).chain()};var m=function(a){this._wrapped=a};b.prototype=m.prototype;var x=function(a,c){return c?b(a).chain():a},M=function(a,c){m.prototype[a]=function(){var a=i.call(arguments);J.call(a,this._wrapped);return x(c.apply(b,a),this._chain)}};b.mixin(b);j("pop,push,reverse,shift,sort,splice,unshift".split(","),function(a){var b=k[a];m.prototype[a]=function(){var d=this._wrapped;b.apply(d,arguments);var e=d.length;(a=="shift"||a=="splice")&&e===0&&delete d[0];return x(d, 32 | this._chain)}});j(["concat","join","slice"],function(a){var b=k[a];m.prototype[a]=function(){return x(b.apply(this._wrapped,arguments),this._chain)}});m.prototype.chain=function(){this._chain=true;return this};m.prototype.value=function(){return this._wrapped}}).call(this); 33 | -------------------------------------------------------------------------------- /static/docs/o2c.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/docs/spec.js: -------------------------------------------------------------------------------- 1 | var spec = 2 | { 3 | "swagger": "2.0", 4 | "info": { 5 | "description": "An interactive explorer for the Sonos music system from @jishi", 6 | "version": "1.0.0", 7 | "title": "Sonos API Explorer", 8 | "termsOfService": "https://github.com/jishi/node-sonos-http-api", 9 | "license": { 10 | "name": "MIT License", 11 | "url": "https://github.com/jishi/node-sonos-http-api/blob/master/LICENSE.md" 12 | } 13 | }, 14 | "basePath": "/", 15 | "schemes": [ 16 | "http" 17 | ], 18 | "paths": { 19 | "/lockvolumes": { 20 | "get": { 21 | "tags": [ 22 | "sonos" 23 | ], 24 | "summary": "experimental", 25 | "operationId": "lockVolumes", 26 | "responses": { 27 | "default": { 28 | "description": "success" 29 | } 30 | } 31 | } 32 | }, 33 | "/pauseall/{delayInMinutes}": { 34 | "get": { 35 | "tags": [ 36 | "sonos" 37 | ], 38 | "summary": "pauses all controllers", 39 | "operationId": "pauseAll", 40 | "parameters": [ 41 | { 42 | "in": "path", 43 | "name": "delayInMinutes", 44 | "required": true, 45 | "type": "integer", 46 | "format": "int32", 47 | "default": 0 48 | } 49 | ], 50 | "responses": { 51 | "default": { 52 | "description": "success" 53 | } 54 | } 55 | } 56 | }, 57 | "/preset/{jsonPreset}": { 58 | "get": { 59 | "tags": [ 60 | "sonos" 61 | ], 62 | "summary": "executes a preset list", 63 | "operationId": "executePreset", 64 | "parameters": [ 65 | { 66 | "in": "path", 67 | "name": "jsonPreset", 68 | "required": true, 69 | "type": "integer", 70 | "format": "int32" 71 | } 72 | ], 73 | "responses": { 74 | "default": { 75 | "description": "success" 76 | } 77 | } 78 | } 79 | }, 80 | "/resumeall/{delayInMinutes}": { 81 | "get": { 82 | "tags": [ 83 | "sonos" 84 | ], 85 | "summary": "resumes all controllers", 86 | "operationId": "resumeAll", 87 | "parameters": [ 88 | { 89 | "in": "path", 90 | "name": "delayInMinutes", 91 | "required": true, 92 | "type": "integer", 93 | "format": "int32" 94 | } 95 | ], 96 | "responses": { 97 | "default": { 98 | "description": "success" 99 | } 100 | } 101 | } 102 | }, 103 | "/unlockvolumes": { 104 | "get": { 105 | "tags": [ 106 | "sonos" 107 | ], 108 | "summary": "experimental", 109 | "operationId": "unlockVolumes", 110 | "responses": { 111 | "default": { 112 | "description": "success" 113 | } 114 | } 115 | } 116 | }, 117 | "/zones": { 118 | "get": { 119 | "tags": [ 120 | "sonos" 121 | ], 122 | "summary": "get zones in system", 123 | "operationId": "getZones", 124 | "responses": { 125 | "default": { 126 | "description": "success", 127 | "schema": { 128 | "type": "array", 129 | "items": { 130 | "$ref": "Zone" 131 | } 132 | } 133 | } 134 | } 135 | } 136 | }, 137 | "/{room}/{action}": { 138 | "get": { 139 | "tags": [ 140 | "sonos" 141 | ], 142 | "summary": "executes an action on a room", 143 | "operationId": "roomAction", 144 | "parameters": [ 145 | { 146 | "in": "path", 147 | "name": "room", 148 | "required": true, 149 | "type": "string" 150 | }, 151 | { 152 | "in": "path", 153 | "name": "action", 154 | "required": true, 155 | "type": "string" 156 | } 157 | ], 158 | "responses": { 159 | "default": { 160 | "description": "success" 161 | } 162 | } 163 | } 164 | }, 165 | "/{room}/{action}/{parameter}": { 166 | "get": { 167 | "tags": [ 168 | "sonos" 169 | ], 170 | "summary": "executes an action on a room with parameters", 171 | "operationId": "roomActionWithParameter", 172 | "parameters": [ 173 | { 174 | "in": "path", 175 | "name": "room", 176 | "required": true, 177 | "type": "string" 178 | }, 179 | { 180 | "in": "path", 181 | "name": "action", 182 | "required": true, 183 | "type": "string" 184 | }, 185 | { 186 | "in": "path", 187 | "name": "parameter", 188 | "description": "volume => abs or incremental, favorite => name, repeat => on/off", 189 | "required": true, 190 | "type": "string" 191 | } 192 | ], 193 | "responses": { 194 | "default": { 195 | "description": "success" 196 | } 197 | } 198 | } 199 | } 200 | }, 201 | "definitions": { 202 | "GroupState": { 203 | "properties": { 204 | "volume": { 205 | "type": "integer", 206 | "format": "int32" 207 | }, 208 | "mute": { 209 | "type": "boolean" 210 | } 211 | } 212 | }, 213 | "State": { 214 | "properties": { 215 | "currentTrack": { 216 | "$ref": "Track" 217 | }, 218 | "nextTrack": { 219 | "$ref": "Track" 220 | }, 221 | "volume": { 222 | "type": "integer", 223 | "format": "int32" 224 | }, 225 | "mute": { 226 | "type": "boolean" 227 | }, 228 | "trackNo": { 229 | "type": "integer", 230 | "format": "int32" 231 | }, 232 | "elapsedTime": { 233 | "type": "integer", 234 | "format": "int32" 235 | }, 236 | "elapsedTimeFormatted": { 237 | "type": "string" 238 | }, 239 | "zoneState": { 240 | "type": "string" 241 | }, 242 | "playerState": { 243 | "type": "string" 244 | } 245 | } 246 | }, 247 | "PlayMode": { 248 | "properties": { 249 | "suffle": { 250 | "type": "boolean" 251 | }, 252 | "repeat": { 253 | "type": "boolean" 254 | }, 255 | "crossfade": { 256 | "type": "string" 257 | } 258 | } 259 | }, 260 | "Zone": { 261 | "properties": { 262 | "uuid": { 263 | "type": "string", 264 | "description": "a unique identifier for the zone" 265 | }, 266 | "coordinator": { 267 | "$ref": "Coordinator" 268 | } 269 | }, 270 | "description": "A single sonos zone" 271 | }, 272 | "Track": { 273 | "properties": { 274 | "artist": { 275 | "type": "string" 276 | }, 277 | "title": { 278 | "type": "string" 279 | }, 280 | "album": { 281 | "type": "string" 282 | }, 283 | "albumArtURI": { 284 | "type": "string" 285 | }, 286 | "duration": { 287 | "type": "integer", 288 | "format": "int32" 289 | }, 290 | "uri": { 291 | "type": "string" 292 | } 293 | } 294 | }, 295 | "Coordinator": { 296 | "properties": { 297 | "uuid": { 298 | "type": "string", 299 | "description": "unique identifier for a coordinator" 300 | }, 301 | "state": { 302 | "$ref": "State" 303 | }, 304 | "playMode": { 305 | "$ref": "PlayMode" 306 | }, 307 | "roomName": { 308 | "type": "string" 309 | }, 310 | "coordinator": { 311 | "type": "string" 312 | }, 313 | "groupState": { 314 | "$ref": "#/definitions/GroupState" 315 | } 316 | } 317 | } 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sonos API 6 | 24 | 25 | 26 | 27 |
    28 |

    Sonos API

    29 | 30 |
    31 |

    Info

    32 |

    GET /zones

    33 |

    GET /favorites

    34 |

    GET /playlists

    35 |
    36 | 37 |
    38 |

    Global Control

    39 |

    GET /lockvolumes

    40 |

    GET /unlockvolumes

    41 |

    GET /pauseall/{timeout in minutes (optional)}

    42 |

    GET /resumeall/{timeout in minutes (optional)}

    43 |

    GET /reindex

    44 |

    GET /sleep/{timeout in seconds or timestamp HH:MM:SS or off}

    45 |

    GET /preset/{JSON preset}

    46 |

    GET /preset/{predefined preset name}

    47 |
    48 | 49 |
    50 |

    Zone Control

    51 |

    GET /{zone name}/{action}[/{parameter}]

    52 | 53 |

    Actions

    54 | 55 |

    Playback

    56 |
      57 |
    • play
    • 58 |
    • pause
    • 59 |
    • playpause toggles playing state
    • 60 |
    • trackseek/{seconds into song, i.e. 60 for 1:00, 120 for 2:00 etc.}
    • 61 |
    • next
    • 62 |
    • previous
    • 63 |
    64 | 65 |

    Volume

    66 |
      67 |
    • volume/{absolute volume}
    • 68 |
    • volume/{+ or -}{relative volume}
    • 69 |
    • groupVolume/{absolute volume}
    • 70 |
    • groupVolume/{+ or -}{relative volume}
    • 71 |
    • mute
    • 72 |
    • unmute
    • 73 |
    • groupMute
    • 74 |
    • groupUnmute
    • 75 |
    • togglemute
    • 76 |
    • lockvolumes
    • 77 |
    • unlockvolumes experimental enforce the volume that was selected when locking!
    • 78 |
    79 | 80 |

    Playback Settings

    81 |
      82 |
    • favorite
    • 83 |
    • playlist
    • 84 |
    • repeat/{on | off}
    • 85 |
    • shuffle/{on | off}
    • 86 |
    • crossfade/{on | off}
    • 87 |
    88 | 89 |

    Queue

    90 |
      91 |
    • queue
    • 92 |
    • clearqueue
    • 93 |
    • seek/{queue index}
    • 94 |
    95 | 96 |

    Room Grouping

    97 |
      98 |
    • add/{player name from existing zone, prefix with player you want to join}
    • 99 |
    • join/{player player to join in, prefix with player from current zone} (this is just the inverse of add)
    • 100 |
    • isolate
    • 101 |
    • ungroup (alias of isolate)
    • 102 |
    • leave (alias of isolate)
    • 103 |
    104 | 105 |

    Other

    106 |
      107 |
    • say
    • 108 |
    109 | 110 |

    Internals

    111 |
      112 |
    • state returns a json-representation of the current state of player
    • 113 |
    114 |
    115 | 116 | 120 |
    121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /static/missing_api_key.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jishi/node-sonos-http-api/3776f0ee2261c924c7b7204de121a38100a08ca7/static/missing_api_key.mp3 -------------------------------------------------------------------------------- /static/sonos-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jishi/node-sonos-http-api/3776f0ee2261c924c7b7204de121a38100a08ca7/static/sonos-icon.png -------------------------------------------------------------------------------- /test_endpoint.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const http = require('http'); 3 | 4 | let server = http.createServer((req, res) => { 5 | console.log(req.method, req.url); 6 | for (let header in req.headers) { 7 | console.log(header + ':', req.headers[header]); 8 | } 9 | 10 | console.log(''); 11 | 12 | const buffer = []; 13 | 14 | req.on('data', (data) => buffer.push(data.toString())); 15 | req.on('end', () => { 16 | res.end(); 17 | 18 | try { 19 | const json = JSON.parse(buffer.join('')); 20 | console.dir(json, { depth: 10 }); 21 | console.log(''); 22 | } catch (e) {} 23 | 24 | }); 25 | }); 26 | 27 | server.listen(5007); 28 | console.log('Listening on http://localhost:5007/'); --------------------------------------------------------------------------------