Spotify node-red interface


#1

I don’t know if it’s of interest but I’m currently working on Spotify interface using node-red. I’m actually building it in in HTML/JS because I needed integration with my matrix and I wanted a different “look”. I’m basically using node-red to ingest the Spotify api. I’ve got two of the authorization flows working, Authorization Code and Client Credentials, and I’m slowly but surely mapping the API (but really, once the authorization is working, stubbing out the services is really easy). Using the API it’s pretty easy to do things like pull one’s playlists and actually play it, no more hard-coding nonsensical Spotify URLs in yaml!

There are some notable caveats.

  1. I don’t think it’s possible to transfer playback to Sonos through the API. I know it wasn’t in the past. I’m using Chromecast as a transfer-to device and I’m using Sonos as a persistent device. That’s really about where the user begins interaction. If it’s on the phone using the app then I cast to Chromecast. If it’s on a panel then Sonos might be better.
  2. Transfer is a little wacky in general. Some devices appear to go to sleep and will disappear. I don’t know if it’s possible to persist their ID and transfer. Something I need to test.
  3. There’s no way to get the current queue. You can transport forward and back but you can’t get a queue.

Once I’m done with this I plan to integrate the Sonos API as well. Frankly, it’s much less friendly and I’ve used an intermediary in the past to talk to it. I’d actually posted about getting some help building an add-on for a Docker image I’d found but never got a response and I haven’t had time to play with it.

If anyone is interested in my flows let me know and I’ll sanitize them and post them here. You would really only need to change the user-specific tokens and you’d have to setup a valid callback but that’s easy enough if your HA is available outside your network.


#2

What about making them standalone nodes so anyone can import them? This seems like it would be the most portable for everyone’s use cases.


#3

Sorry for the delay. Here is my basic subflow for Spotify Authorization. You’ll need to put in your client_id, secret_id, and callback URI that are generated from the Spotify app that you’ve created. Spotify requires a callback which means the callback URI needs to be accessible to the internet. I’ve forwarded ports through my router for the time being.

[{"id":"bd53672a.f0dfc8","type":"subflow","name":"Spotify - Authorization Code","info":"","in":[{"x":33.75,"y":139.25,"wires":[{"id":"94e54c31.8dbf2"}]}],"out":[{"x":1049.75,"y":139.25,"wires":[{"id":"94e54c31.8dbf2","port":1}]}]},{"id":"94e54c31.8dbf2","type":"function","z":"bd53672a.f0dfc8","name":"isAuthenticated?","func":"var cookies = JSON.stringify(msg.req.cookies,null,4);\n\nvar access_token = msg.req.cookies['access_token'];\nvar refresh_token = msg.req.cookies['refresh_token'];\n\nnode.log(\"is this a refresh attempt: \" + msg.refresh_attempt);\n\nif (msg.refresh_attempt) {\n node.log(\"payload token value: \" + msg.payload.access_token);\n access_token = msg.payload.access_token;\n}\n\nif (access_token !== undefined) {\n node.log(\"Expected flow\");\n // This is the normal flow\n // msg.url need to be constructed in the \n // previous calling node.\n msg.headers = { 'Authorization': 'Bearer ' + access_token };\n msg.json = true;\n return [null, msg, null];\n} else if (refresh_token !== undefined) {\n node.log(\"Refresh flow\");\n // Token has expired. Use refresh token\n // to generate a new access token.\n msg.req.query.refresh_token = refresh_token;\n msg.refresh_attempt = true;\n return [null, null, msg];\n} else {\n node.log(\"No Tokens flow\");\n // No refresh or access token. Need to login.\n msg.url = 'https://beavners.duckdns.org:1880/spotify/login';\n return [msg, null, null];\n}\n","outputs":3,"noerr":0,"x":203.75,"y":139.25,"wires":[["1ba8d1cd.36e23e"],[],["529bda7c.88cc54"]]},{"id":"2d2d6e4f.3e54c2","type":"comment","z":"bd53672a.f0dfc8","name":"Authorization Code Authentication","info":"This is the main authorization flow for logging in,\ngetting access tokens, and refreshing tokens. This \nflow allows for scopes to be set. Currently, all \nscopes are wired in.\n","x":179.5,"y":34,"wires":[]},{"id":"529bda7c.88cc54","type":"function","z":"bd53672a.f0dfc8","name":"Spotify Authorization","func":"var querystring = global.get('querystring');\n\nvar client_id = '<your client id here';\nvar client_secret = '<your secret here>';\n\nvar refresh_token = msg.req.query.refresh_token;\n\nmsg.payload = {\n grant_type: 'refresh_token',\n refresh_token: refresh_token\n};\n\nmsg.headers = {\n 'Content-Type' : 'application/x-www-form-urlencoded', \n 'Authorization': 'Basic ' + (new Buffer(client_id + ':' + client_secret).toString('base64'))\n};\n \nmsg.json = true;\n\nreturn msg;","outputs":1,"noerr":0,"x":948,"y":258,"wires":[["cbbcd6d4.70f548"]]},{"id":"cbbcd6d4.70f548","type":"http request","z":"bd53672a.f0dfc8","name":"","method":"POST","ret":"obj","url":"https://accounts.spotify.com/api/token","tls":"","x":984.5,"y":302,"wires":[["f9c9d72d.fbf4d8"]]},{"id":"f9c9d72d.fbf4d8","type":"function","z":"bd53672a.f0dfc8","name":"Set access_token cookie","func":"if ( msg.statusCode === 200) {\nnode.log(\"Status code is 200\");\n var access_token = msg.payload.access_token,\n scope = msg.payload.scope,\n expires_in = msg.payload.expires_in;\n \n msg.cookies = {\n access_token: {\n value: access_token,\n maxAge: expires_in * 1000\n },\n scope: scope\n };\n \n return [msg, null];\n} else {\n node.log(\"Status code is \" + msg.statusCode);\n msg.url = \"error\";\n msg.payload = msg.statusCode;\n return [null, msg];\n}\n\n\n","outputs":2,"noerr":0,"x":786,"y":394,"wires":[["94e54c31.8dbf2"],["9e1c64ee.1146e8"]]},{"id":"9e1c64ee.1146e8","type":"http response","z":"bd53672a.f0dfc8","name":"Error","statusCode":"","headers":{},"x":987,"y":428,"wires":[]},{"id":"b6661045.4cbf7","type":"function","z":"bd53672a.f0dfc8","name":"Spotify Authorization","func":"var querystring = global.get('querystring');\n\nvar client_id = '<your client id>';\nvar client_secret = '<your secret>';\nvar redirect_uri = '<your redirect URI>';\n\n// your application requests authorization\nvar scope = 'playlist-modify-private playlist-read-private playlist-modify-public playlist-read-collaborative user-follow-read user-follow-modify user-read-private user-read-email user-read-birthdate user-top-read user-read-recently-played user-library-modify user-library-read user-read-playback-state user-read-currently-playing user-modify-playback-state streaming';\nmsg.url = '';\nmsg.statusCode = 302;\nmsg.headers = {\n location: 'https://accounts.spotify.com/authorize?' +\n querystring.stringify({\n response_type: 'code',\n client_id: client_id,\n scope: scope,\n redirect_uri: redirect_uri,\n state: msg.state\n })\n}\nmsg.payload = \"\";\nreturn msg;\n","outputs":1,"noerr":0,"x":973,"y":59,"wires":[[]]},{"id":"1ba8d1cd.36e23e","type":"function","z":"bd53672a.f0dfc8","name":"Generate state value","func":"/**\n * Generates a random string containing numbers and letters\n * @param {number} length The length of the string\n * @return {string} The generated string\n */\nvar generateRandomString = function(length) {\n var text = '';\n var possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n\n for (var i = 0; i < length; i++) {\n text += possible.charAt(Math.floor(Math.random() * possible.length));\n }\n return text;\n};\n\nvar state = generateRandomString(16);\nvar stateKey = 'spotify_auth_state';\nmsg.cookies = { };\nmsg.cookies[stateKey] = state;\n\nmsg.state = state;\n\nreturn msg;","outputs":1,"noerr":0,"x":745,"y":59,"wires":[["b6661045.4cbf7"]]},{"id":"11ce155c.9b739b","type":"function","z":"bd53672a.f0dfc8","name":"Callback","func":"var querystring = global.get('querystring');\n\nvar client_id = '<';\nvar client_secret = '<your secret>';\nvar redirect_uri = '<your redirect URI>';\n\n// your application requests refresh and access tokens\n// after checking the state parameter\nvar stateKey = 'spotify_auth_state';\n\nvar code = msg.req.query.code || null;\nnode.log(\"Code: \" + code);\nvar state = msg.req.query.state || null;\nnode.log(\"State: \" + state);\nvar storedState = msg.req.cookies ? msg.req.cookies[stateKey] : null;\nnode.log(\"StoredState: \" + storedState);\n\n//if (state === null || state !== storedState) {\nif (1 ==2 ) {\n msg.res.redirect('/error' +\n querystring.stringify({\n error: 'state_mismatch'\n }));\n} else {\n //msg.res.clearCookie(stateKey);\n\n msg.url = 'https://accounts.spotify.com/api/token';\n\n msg.payload = {\n \"code\": code,\n \"redirect_uri\": redirect_uri,\n \"grant_type\": 'authorization_code'\n };\n\n msg.headers = {\n 'Content-Type' : 'application/x-www-form-urlencoded', \n 'Authorization': 'Basic ' + (new Buffer(client_id + ':' + client_secret).toString('base64'))\n };\n \n msg.json = true;\n}\n\nreturn msg;","outputs":1,"noerr":0,"x":333,"y":617,"wires":[["1481e635.042cca"]]},{"id":"d4f5f45.8365408","type":"http response","z":"bd53672a.f0dfc8","name":"","statusCode":"","headers":{},"x":886,"y":617,"wires":[]},{"id":"1481e635.042cca","type":"http request","z":"bd53672a.f0dfc8","name":"","method":"POST","ret":"obj","url":"https://accounts.spotify.com/api/token","tls":"","x":492,"y":617,"wires":[["8c200d04.2eb72"]]},{"id":"8c200d04.2eb72","type":"function","z":"bd53672a.f0dfc8","name":"Set access_token cookie","func":"if ( msg.statusCode === 200) {\n var access_token = msg.payload.access_token,\n refresh_token = msg.payload.refresh_token,\n scope = msg.payload.scope,\n expires_in = msg.payload.expires_in;\n \n msg.cookies = {\n access_token: {\n value: access_token,\n maxAge: expires_in * 1000\n },\n refresh_token: refresh_token,\n scope: scope\n };\n}\n\nmsg.url = '';\nmsg.statusCode = 302;\nmsg.headers = {\n location: '/speakers.html'\n}\nreturn msg;\n","outputs":1,"noerr":0,"x":702,"y":617,"wires":[["d4f5f45.8365408"]]},{"id":"ad7e56ed.2500e8","type":"http in","z":"bd53672a.f0dfc8","name":"","url":"/spotify/auth/callback","method":"get","upload":false,"swaggerDoc":"","x":131,"y":617,"wires":[["11ce155c.9b739b"]]},{"id":"b57f76da.475638","type":"comment","z":"bd53672a.f0dfc8","name":"Spotify Callback","info":"The URI to redirect to after the user grants or\ndenies permission. This URI needs to have been \nentered in the Redirect URI whitelist that you \nspecified when you registered your application. \nThe value of redirect_uri here must exactly \nmatch one of the values you entered when you \nregistered your application, including upper or \nlowercase, terminating slashes, and such.","x":99,"y":557,"wires":[]},{"id":"68560cd9.08f144","type":"comment","z":"bd53672a.f0dfc8","name":"State Cookie","info":"Per the Spotify Documentation\nOptional, but strongly recommended. \nThe state can be useful for correlating requests \nand responses. Because your redirect_uri can be \nguessed, using a state value can increase your \nassurance that an incoming connection is the result \nof an authentication request. If you generate a \nrandom string, or encode the hash of some client \nstate, such as a cookie, in this state variable, \nyou can validate the response to additionally \nensure that both the request and response \noriginated in the same browser. This provides \nprotection against attacks such as cross-site \nrequest forgery. See RFC-6749.","x":715,"y":20,"wires":[]},{"id":"b2708426.f088b8","type":"comment","z":"bd53672a.f0dfc8","name":"Spotify Auth Redirect","info":"Need to pass:\n * client_id\n * client_secret\n * redirect_uri\n","x":971,"y":21,"wires":[]},{"id":"8c497eb1.bd6ed","type":"comment","z":"bd53672a.f0dfc8","name":"No Auth Cookie Flow","info":"If no access token or refresh token are present\nthen treat it as an initial login. This should\nhit the callback.","x":513,"y":64,"wires":[]},{"id":"9dbbc7e1.088cd8","type":"comment","z":"bd53672a.f0dfc8","name":"Refresh Token Flow","info":"Refresh token is present, generate a new \naccess token.","x":549,"y":199,"wires":[]},{"id":"f355a985.8df9d8","type":"comment","z":"bd53672a.f0dfc8","name":"Happy path","info":"Access token is present","x":656,"y":141,"wires":[]}]

Here is a flow to get available devices. The above subflow needs to be inserted between the parse request function and the http request node. I didn’t include it because it’s above and it’s a lot of text to paste.

[{"id":"8edb3499.3d5bd8","type":"comment","z":"4f7b479d.f40f38","name":"Get Recently Played","info":"","x":108.5,"y":1243,"wires":[]},{"id":"ad9f9a51.7e51c8","type":"http in","z":"4f7b479d.f40f38","name":"","url":"/spotify/recent","method":"get","upload":false,"swaggerDoc":"","x":107.75,"y":1296.0556640625,"wires":[["707d6edc.b1bf1","7c698fa7.0ca4a"]]},{"id":"707d6edc.b1bf1","type":"function","z":"4f7b479d.f40f38","name":"Parse Request","func":"msg.url = 'https://api.spotify.com/v1/me/player/recently-played?limit=10';\nreturn msg;","outputs":1,"noerr":0,"x":326.50000762939453,"y":1295.4999866485596,"wires":[["1d59d43f.1af33c","98fbe371.e1c62"]]},{"id":"1a5ee470.6981cc","type":"http request","z":"4f7b479d.f40f38","name":"","method":"GET","ret":"obj","url":"","tls":"","x":743.7897338867188,"y":1294.9326171875,"wires":[["baea9e4f.fdf6","20d923f.33e22dc"]]},{"id":"baea9e4f.fdf6","type":"http response","z":"4f7b479d.f40f38","name":"","statusCode":"","headers":{},"x":895.3214721679688,"y":1294.9326171875,"wires":[]}]

Happy to answer specific questions. This is relatively quick & dirty. I need to go back through and refactor and put in better error handling. Is there a better way to attack node-red code?


#4

Hi I’m definitely interested, but I can’t get your code to work. I’m confused by a couple of things

  1. the redirect URI, can’t this be localhost:1880
  2. but more important, I get an error about ‘querystring’ being undefined which I believe is true, but I don’t know where, is part of the code missing?

I get:

7/21/2018, 10:14:29 PM Spotify Authorization
function : (error)
“TypeError: Cannot read property ‘stringify’ of undefined”


#5

Sorry, I’ve been away from this for awhile. I’m not sure if the redirect URI can be localhost. I’m assuming it needs to be available externally.

What’s the exact error you’re getting? Where exactly are you getting it? I had to sanitize some of the code I pasted and I don’t think it’s exactly right.


#6

it’s a good thing that you were away, because I had to figure it out myself :slight_smile:
I was missing query-string in my ~/.node-red/setting.js.

I also added a http response node to the spotify authorization node and setup a web response page that the redirect URI would go to, and yes it can be an internal IP as long as it’s in your local network.

it now works :slight_smile:

The only thing that I haven’t figure out yet is how I would get a node-red flow to interact with the authorization. How to ‘fake’ the cookie and store the token and refresh token.


#7

I’ve actually moved away from storing tokens in cookies. I’m now storing the values in global variables.

global.set("spotify_access_token", access_token);

My main goal was to build a service layer that I could call from a web front end so a single end-point looks something like below. I can’t promise it’s working because my entire instance is down and being rebuilt from scratch. I’m planning to get back to it as soon as I get my instance back up.

[{"id":"bd53672a.f0dfc8","type":"subflow","name":"Spotify - Authorization Code","info":"","in":[{"x":33.75,"y":139.25,"wires":[{"id":"94e54c31.8dbf2"}]}],"out":[{"x":1049.75,"y":139.25,"wires":[{"id":"94e54c31.8dbf2","port":1}]}]},{"id":"94e54c31.8dbf2","type":"function","z":"bd53672a.f0dfc8","name":"isAuthenticated?","func":"node.log(\"beginning spotify authentication\");\n\nnode.log(\"getting access_token\");\nvar access_token = global.get(\"spotify_access_token\")\nnode.log(\"received access token\");\nnode.log(\"spotify_access_token: \" + access_token);\nvar refresh_token = global.get(\"spotify_refresh_token\");\nnode.log(\"is this a refresh attempt: \" + msg.refresh_attempt !== undefined ? 'yes' : 'no');\n\nif (flow.get('refresh') && access_token === undefined) {\n throw 'refresh attempt without access token';\n}\n\nif (msg.refresh_attempt) {\n node.log(\"payload token value: \" + msg.payload.access_token);\n access_token = msg.payload.access_token;\n}\n\nif (access_token !== undefined) {\n node.log(\"Expected flow\");\n // This is the normal flow\n // msg.url need to be constructed in the \n // previous calling node.\n msg.headers = { 'Authorization': 'Bearer ' + access_token };\n msg.json = true;\n return [null, msg, null];\n} else if (refresh_token !== undefined) {\n node.log(\"Refresh flow\");\n flow.set('refresh', true);\n // Token has expired. Use refresh token\n // to generate a new access token.\n msg.req.query.refresh_token = refresh_token;\n msg.refresh_attempt = true;\n return [null, null, msg];\n} else {\n node.log(\"No Tokens flow\");\n // No refresh or access token. Need to login.\n msg.url = '/spotify/login';\n return [msg, null, null];\n}\n","outputs":3,"noerr":0,"x":203.75,"y":139.25,"wires":[["1ba8d1cd.36e23e","1cd125d2.a770ba"],[],["529bda7c.88cc54","7d8c818a.aadcb"]]},{"id":"2d2d6e4f.3e54c2","type":"comment","z":"bd53672a.f0dfc8","name":"Authorization Code Authentication","info":"This is the main authorization flow for logging in,\ngetting access tokens, and refreshing tokens. This \nflow allows for scopes to be set. Currently, all \nscopes are wired in.\n","x":179.5,"y":34,"wires":[]},{"id":"529bda7c.88cc54","type":"function","z":"bd53672a.f0dfc8","name":"Spotify Authorization","func":"var querystring = global.get('querystring');\n\nvar client_id = '<client_id>';\nvar client_secret = '<client_secret>';\n\nvar refresh_token = msg.req.query.refresh_token;\nglobal.set(\"spotify_refresh_token\", refresh_token);\nmsg.payload = {\n grant_type: 'refresh_token',\n refresh_token: refresh_token,\n refresh_attempt: true\n};\n\nmsg.headers = {\n 'Content-Type' : 'application/x-www-form-urlencoded', \n 'Authorization': 'Basic ' + (new Buffer(client_id + ':' + client_secret).toString('base64'))\n};\n \nmsg.json = true;\n\nreturn msg;","outputs":1,"noerr":0,"x":948,"y":258,"wires":[["cbbcd6d4.70f548"]]},{"id":"cbbcd6d4.70f548","type":"http request","z":"bd53672a.f0dfc8","name":"","method":"POST","ret":"obj","url":"https://accounts.spotify.com/api/token","tls":"","x":984.5,"y":302,"wires":[["f9c9d72d.fbf4d8"]]},{"id":"f9c9d72d.fbf4d8","type":"function","z":"bd53672a.f0dfc8","name":"Set access_token variables","func":"if ( msg.statusCode === 200) {\nnode.log(\"Status code is 200\");\n var access_token = msg.payload.access_token,\n scope = msg.payload.scope,\n expires_in = msg.payload.expires_in;\n \n global.set(\"spotify_access_token\", access_token);\n global.set(\"spotify_scope\", scope);\n \n return [msg, null];\n} else {\n node.log(\"Status code is \" + msg.statusCode);\n msg.url = \"error\";\n msg.payload = msg.statusCode;\n return [null, msg];\n}\n","outputs":2,"noerr":0,"x":796,"y":394,"wires":[["94e54c31.8dbf2"],["9e1c64ee.1146e8"]]},{"id":"9e1c64ee.1146e8","type":"http response","z":"bd53672a.f0dfc8","name":"Error","statusCode":"","headers":{},"x":987,"y":428,"wires":[]},{"id":"b6661045.4cbf7","type":"function","z":"bd53672a.f0dfc8","name":"Spotify Authorization","func":"node.log(\"Spotify building request to callback\");\n\nvar querystring = global.get('querystring');\n\nvar client_id = '<client_id>';\nvar client_secret = '<client_secret>';\nvar redirect_uri = 'http://192.168.3.5:1880/spotify/auth/callback';\n\n// your application requests authorization\nvar scope = 'playlist-modify-private playlist-read-private playlist-modify-public playlist-read-collaborative user-follow-read user-follow-modify user-read-private user-read-email user-read-birthdate user-top-read user-read-recently-played user-library-modify user-library-read user-read-playback-state user-read-currently-playing user-modify-playback-state streaming';\nconsole.log(scope);\nmsg.res.redirect('https://accounts.spotify.com/authorize?' +\n querystring.stringify({\n response_type: 'code',\n client_id: client_id,\n scope: scope,\n redirect_uri: redirect_uri,\n state: msg.state\n }));\n\nnode.log(\"Spotify redirecting to callback\");\n\n\n\n","outputs":1,"noerr":0,"x":973,"y":59,"wires":[["c13a40ee.f3ba3"]]},{"id":"1ba8d1cd.36e23e","type":"function","z":"bd53672a.f0dfc8","name":"Generate state value","func":"/**\n * Generates a random string containing numbers and letters\n * @param {number} length The length of the string\n * @return {string} The generated string\n */\nvar generateRandomString = function(length) {\n var text = '';\n var possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n\n for (var i = 0; i < length; i++) {\n text += possible.charAt(Math.floor(Math.random() * possible.length));\n }\n return text;\n};\n\nvar state = generateRandomString(16);\nvar stateKey = 'spotify_auth_state';\nmsg.cookies = { };\nmsg.cookies[stateKey] = state;\n\nmsg.state = state;\nnode.log(\"spotify state: \" + state);\nreturn msg;","outputs":1,"noerr":0,"x":745,"y":59,"wires":[["b6661045.4cbf7","85528716.162578"]]},{"id":"68560cd9.08f144","type":"comment","z":"bd53672a.f0dfc8","name":"State","info":"Per the Spotify Documentation\nOptional, but strongly recommended. \nThe state can be useful for correlating requests \nand responses. Because your redirect_uri can be \nguessed, using a state value can increase your \nassurance that an incoming connection is the result \nof an authentication request. If you generate a \nrandom string, or encode the hash of some client \nstate, such as a cookie, in this state variable, \nyou can validate the response to additionally \nensure that both the request and response \noriginated in the same browser. This provides \nprotection against attacks such as cross-site \nrequest forgery. See RFC-6749.","x":695,"y":20,"wires":[]},{"id":"b2708426.f088b8","type":"comment","z":"bd53672a.f0dfc8","name":"Spotify Auth Redirect","info":"Need to pass:\n * client_id\n * client_secret\n * redirect_uri\n","x":971,"y":21,"wires":[]},{"id":"8c497eb1.bd6ed","type":"comment","z":"bd53672a.f0dfc8","name":"No Auth Cookie Flow","info":"If no access token or refresh token are present\nthen treat it as an initial login. This should\nhit the callback.","x":513,"y":64,"wires":[]},{"id":"9dbbc7e1.088cd8","type":"comment","z":"bd53672a.f0dfc8","name":"Refresh Token Flow","info":"Refresh token is present, generate a new \naccess token.","x":549,"y":199,"wires":[]},{"id":"f355a985.8df9d8","type":"comment","z":"bd53672a.f0dfc8","name":"Happy path","info":"Access token is present","x":656,"y":141,"wires":[]},{"id":"7d8c818a.aadcb","type":"debug","z":"bd53672a.f0dfc8","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":527.5,"y":238,"wires":[]},{"id":"c13a40ee.f3ba3","type":"debug","z":"bd53672a.f0dfc8","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":1165,"y":103,"wires":[]},{"id":"85528716.162578","type":"debug","z":"bd53672a.f0dfc8","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":906,"y":101,"wires":[]},{"id":"1cd125d2.a770ba","type":"debug","z":"bd53672a.f0dfc8","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":466,"y":27,"wires":[]},{"id":"6b7c0514.91f0ac","type":"catch","z":"bd53672a.f0dfc8","name":"","scope":null,"x":820,"y":436,"wires":[["9e1c64ee.1146e8"]]},{"id":"496e235d.3f5f0c","type":"http request","z":"4f7b479d.f40f38","name":"","method":"GET","ret":"obj","url":"","tls":"","x":749.0397338867188,"y":1891,"wires":[["27b07ef8.9612a2","d1e553.9dc60ab"]]},{"id":"f58eb4cb.eb16d8","type":"http in","z":"4f7b479d.f40f38","name":"","url":"/spotify/playlists","method":"get","upload":false,"swaggerDoc":"","x":113,"y":1892.123046875,"wires":[["bf0fecec.d7838","d64831e.d4bf3d"]]},{"id":"27b07ef8.9612a2","type":"http response","z":"4f7b479d.f40f38","name":"","statusCode":"","headers":{},"x":900.5714721679688,"y":1891,"wires":[]},{"id":"bf0fecec.d7838","type":"function","z":"4f7b479d.f40f38","name":"Parse Request","func":"msg.url = 'https://api.spotify.com/v1/me/playlists';\nreturn msg;","outputs":1,"noerr":0,"x":331.75000762939453,"y":1891.5673694610596,"wires":[["2c4f604a.05202","544358ce.4e12b8"]]},{"id":"544358ce.4e12b8","type":"subflow:bd53672a.f0dfc8","z":"4f7b479d.f40f38","name":"Spotify Authorization","x":540.125,"y":1891.4423828125,"wires":[["496e235d.3f5f0c","c2e1a76e.bdd328"]]}]


#8

thank you, storing the tokens as global vars make sense, but I’m having trouble setting it up. What does your callback look like?


#9

Here’s my callback flow

[{“id”:“ebb9aeff.06ecf”,“type”:“debug”,“z”:“4f7b479d.f40f38”,“name”:"",“active”:true,“tosidebar”:true,“console”:false,“tostatus”:false,“complete”:“true”,“x”:310.4999694824219,“y”:43.245795249938965,“wires”:[]},{“id”:“2c974944.d97516”,“type”:“function”,“z”:“4f7b479d.f40f38”,“name”:“Callback”,“func”:“node.log(“reached callback”);\nvar querystring = global.get(‘querystring’);\n\nvar client_id = ‘<client_id>’;\nvar client_secret = ‘<client_secret>’;\nvar redirect_uri = ‘http://192.168.3.5:1880/spotify/auth/callback’;\n\n// your application requests refresh and access tokens\n// after checking the state parameter\nvar stateKey = ‘spotify_auth_state’;\n\nvar code = msg.req.query.code || null;\nnode.log(“Code: " + code);\nvar state = msg.req.query.state || null;\nnode.log(“State: " + state);\nvar storedState = msg.req.cookies ? msg.req.cookies[stateKey] : null;\nnode.log(“StoredState: " + storedState);\n\n//if (state === null || state !== storedState) {\nif (1 ==2 ) {\n msg.res.redirect(’/error’ +\n querystring.stringify({\n error: ‘state_mismatch’\n }));\n} else {\n //msg.res.clearCookie(stateKey);\n\n msg.url = ‘https://accounts.spotify.com/api/token’;\n\n msg.payload = {\n “code”: code,\n “redirect_uri”: redirect_uri,\n “grant_type”: ‘authorization_code’\n };\n\n msg.headers = {\n ‘Content-Type’ : ‘application/x-www-form-urlencoded’, \n ‘Authorization’: 'Basic ’ + (new Buffer(client_id + ‘:’ + client_secret).toString(‘base64’))\n };\n \n msg.json = true;\n}\n\nreturn msg;”,“outputs”:1,“noerr”:0,“x”:324.7142791748047,“y”:81.42856979370117,“wires”:[[“c3498d4c.4601c”,“6b3c44a7.af9cfc”]]},{“id”:“940b279e.fe2528”,“type”:“http response”,“z”:“4f7b479d.f40f38”,“name”:””,“statusCode”:”",“headers”:{},“x”:877.7142791748047,“y”:81.42856979370117,“wires”:[]},{“id”:“c3498d4c.4601c”,“type”:“http request”,“z”:“4f7b479d.f40f38”,“name”:"",“method”:“POST”,“ret”:“obj”,“url”:“https://accounts.spotify.com/api/token",“tls”:"",“x”:483.7142791748047,“y”:81.42856979370117,“wires”:[[“b39e38fa.74dba8”,“24682428.7ae98c”]]},{“id”:“b39e38fa.74dba8”,“type”:“function”,“z”:“4f7b479d.f40f38”,“name”:"Set access_token variable”,“func”:“if ( msg.statusCode === 200) {\n var access_token = msg.payload.access_token,\n refresh_token = msg.payload.refresh_token,\n scope = msg.payload.scope,\n expires_in = msg.payload.expires_in;\n \n global.set(“spotify_access_token”, access_token);\n global.set(“spotify_refresh_token”, refresh_token);\n global.set(“spotify_scope”, scope);\n}\n\n//msg.url = ‘’;\n//msg.statusCode = 302;\n//msg.headers = {\n// location: ‘/speakers.html’\n//}\nreturn msg;\n”,“outputs”:1,“noerr”:0,“x”:693.7142791748047,“y”:81.42856979370117,“wires”:[[“940b279e.fe2528”,“319702f0.1d1f2e”]]},{“id”:“ee942049.7307a”,“type”:“http in”,“z”:“4f7b479d.f40f38”,“name”:"",“url”:"/spotify/auth/callback",“method”:“get”,“upload”:false,“swaggerDoc”:"",“x”:122.71427917480469,“y”:81.42856979370117,“wires”:[[“2c974944.d97516”,“ebb9aeff.06ecf”]]},{“id”:“f27bfc68.78c13”,“type”:“comment”,“z”:“4f7b479d.f40f38”,“name”:“Spotify Callback”,“info”:“The URI to redirect to after the user grants or\ndenies permission. This URI needs to have been \nentered in the Redirect URI whitelist that you \nspecified when you registered your application. \nThe value of redirect_uri here must exactly \nmatch one of the values you entered when you \nregistered your application, including upper or \nlowercase, terminating slashes, and such.”,“x”:94.99999237060547,“y”:34.28571128845215,“wires”:[]},{“id”:“6b3c44a7.af9cfc”,“type”:“debug”,“z”:“4f7b479d.f40f38”,“name”:"",“active”:true,“tosidebar”:true,“console”:false,“tostatus”:false,“complete”:“true”,“x”:464.28565979003906,“y”:42.85719299316406,“wires”:[]},{“id”:“24682428.7ae98c”,“type”:“debug”,“z”:“4f7b479d.f40f38”,“name”:"",“active”:true,“tosidebar”:true,“console”:false,“tostatus”:false,“complete”:“true”,“x”:631.4285469055176,“y”:45.714293479919434,“wires”:[]},{“id”:“319702f0.1d1f2e”,“type”:“debug”,“z”:“4f7b479d.f40f38”,“name”:"",“active”:true,“tosidebar”:true,“console”:false,“tostatus”:false,“complete”:“true”,“x”:875.7143115997314,“y”:45.71426582336426,“wires”:[]}]


#10

your pasted code has some quirky characters and line endings, and prevents me from importing the flow, can you insert it as a code block? thank!


#12

Quick question: what’s the advantage of doing it this way compared with the Spotify component?


#13

I have a monoprice 6 zone controller that HA can control. It has six audio zones and supports up to six input devices. Right now I have to turn on a speaker and select its input and then go to Spotify, play music, and use Spotify Connect to get it playing on the source device. It’s more than my wife or guests would be able to handle.

I created some automations where if I tell Google to play music it knows which speakers to activate and will play but I want a visually appealing interface so I decided to wrap the Spotify API and build my own web interface. I’ve made it more complicated for myself because I have different types of sources that can play music (chromecast, sonos, etc) and some of those have additional speakers and then there are weird challenges with each source that I’m still trying to work my way through.

End of the day, HA is great for what it is but pretty it ain’t and a lot of the components are fairly rudimentary.


#14

What Controller are you using?


#15

It’s this https://www.monoprice.com/product?c_id=109&cp_id=10918&cs_id=1091801&p_id=10761. They run 20% off it fairly regularly. Trying to control this unit was the entire reason I started with HA.


#16

Hey Jay, I’m really impressed with this, but somewhat new to node-red.

How does it all come together?

Say I’ve got a spotify “application” set up with an ID and a secret.

How do I authorize the node-red application and do something simple like trigger a playlist on a specific device?


#17

Once you have your application with your Client ID and Secret you’ll need to perform a pretty standard Oauth validation to interact with Spotify. Oauth requires a request that makes a callback to your system at which point you can get your tokens. A good way to visualize this is to use the Spotify console. Here’s the Transfer user playback endpoint > https://developer.spotify.com/console/put-user-player/. The Get Token button is making the Oauth call to get a valid token. You’ll notice when you click it, it asks for Scopes. Scopes are the permissions you are giving the token. You could actually use the token Spotify generates to make calls but it will timeout pretty fast.

The toughest part of using the Spotify API is the authentication. The flows above should help you with that part but you’ll need to change them to put in your own info (ID, ClientSecret, URL’s, etc). Once you have your access token it’s as simple as using an HTTP request node and making a call.

I’ll be honest, life has kind of gotten in the way and I haven’t messed with this in months. It’s on my list but other things keep popping up. My goal was to make something reusable, I just need to find the time. Assuming my wife doesn’t go into labor I could probably whip something together in the next day or so.


#18

Ok, here are three quick and dirty nodes that will fetch and playback the first playlist.

First we have to get our auth and refresh tokens. This first flow handles that. It’s important to note that this flow expects global variables. You can set those variables with the code below. The node-red IP does not have to be externally routable. Mine is 192.168.3.5.

global.set("spotify_client_id", "<client id>");
global.set("spotify_client_secret", "<client secret>");
global.set("spotify_redirect_uri", "http://<node-red IP>:1880/spotify/auth/callback");

The next flow is the meat of the authentication. You will need to call this before every API call. Conversely you could do something with error handling and call as needed but it’s probably easiest to call it every time. Basically it checks for the auth token. If it’s already present it returns it, if it doesn’t exist and there’s no refresh token then it initiates a call to get auth and refresh tokens. If there’s a refresh token then it uses that to get the auth token. As I think about it, there’s a problem here in that it doesn’t know when the auth token expires. I used to store the tokens in cookies with expiry dates but that’s not really best practice. I should probably keep a context-less cookie with an expiry that I could check and then know my access code is no longer valid. Something for another day.

You should make this a subflow so it can be reused as needed.

[{"id":"bd53672a.f0dfc8","type":"subflow","name":"Spotify - Authorization Code","info":"","in":[{"x":60,"y":220,"wires":[{"id":"94e54c31.8dbf2"}]}],"out":[{"x":1116,"y":220,"wires":[{"id":"94e54c31.8dbf2","port":1}]}]},{"id":"94e54c31.8dbf2","type":"function","z":"bd53672a.f0dfc8","name":"isAuthenticated?","func":"node.log(\"beginning spotify authentication\");\n\nnode.log(\"getting access_token\");\nvar access_token = global.get(\"spotify_access_token\")\nnode.log(\"received access token\");\nnode.log(\"spotify_access_token: \" + access_token);\nvar refresh_token = global.get(\"spotify_refresh_token\");\nnode.log(\"is this a refresh attempt: \" + msg.refresh_attempt !== undefined ? 'yes' : 'no');\n\nif (flow.get('refresh') && access_token === undefined) {\n    throw 'refresh attempt without access token';\n}\n\nif (msg.refresh_attempt) {\n    node.log(\"payload token value: \" + msg.payload.access_token);\n    access_token = msg.payload.access_token;\n}\n\nif (access_token !== undefined) {\n    node.log(\"Expected flow\");\n    // This is the normal flow\n    // msg.url need to be constructed in the \n    // previous calling node.\n    msg.headers = { 'Authorization': 'Bearer ' + access_token };\n    msg.json = true;\n    return [null, msg, null];\n} else if (refresh_token !== undefined) {\n    node.log(\"Refresh flow\");\n    flow.set('refresh', true);\n    // Token has expired. Use refresh token\n    // to generate a new access token.\n    msg.req.query.refresh_token = refresh_token;\n    msg.refresh_attempt = true;\n    return [null, null, msg];\n} else {\n    node.log(\"No Tokens flow\");\n    // No refresh or access token. Need to login.\n    msg.url = '/spotify/login';\n    return [msg, null, null];\n}\n","outputs":3,"noerr":0,"x":210,"y":220,"wires":[["1ba8d1cd.36e23e","1cd125d2.a770ba"],[],["529bda7c.88cc54","7d8c818a.aadcb"]]},{"id":"2d2d6e4f.3e54c2","type":"comment","z":"bd53672a.f0dfc8","name":"Authorization Code Authentication","info":"This is the main authorization flow for logging in,\ngetting access tokens, and refreshing tokens. This \nflow allows for scopes to be set. Currently, all \nscopes are wired in.\n","x":160,"y":40,"wires":[]},{"id":"529bda7c.88cc54","type":"function","z":"bd53672a.f0dfc8","name":"Spotify Authorization","func":"var querystring = global.get('querystring');\n\nvar client_id = global.get(\"spotify_client_id\");\nvar client_secret = global.get(\"spotify_client_secret\");\n\nvar refresh_token = msg.req.query.refresh_token;\nglobal.set(\"spotify_refresh_token\", refresh_token);\nmsg.payload = {\n    grant_type: 'refresh_token',\n    refresh_token: refresh_token,\n    refresh_attempt: true\n};\n\nmsg.headers = {\n    'Content-Type' : 'application/x-www-form-urlencoded', \n    'Authorization': 'Basic ' + (new Buffer(client_id + ':' + client_secret).toString('base64'))\n};\n    \nmsg.json = true;\n\nreturn msg;","outputs":1,"noerr":0,"x":500,"y":320,"wires":[["cbbcd6d4.70f548"]]},{"id":"cbbcd6d4.70f548","type":"http request","z":"bd53672a.f0dfc8","name":"","method":"POST","ret":"obj","url":"https://accounts.spotify.com/api/token","tls":"","x":690,"y":320,"wires":[["f9c9d72d.fbf4d8"]]},{"id":"f9c9d72d.fbf4d8","type":"function","z":"bd53672a.f0dfc8","name":"Set access_token variables","func":"if ( msg.statusCode === 200) {\nnode.log(\"Status code is 200\");\n    var access_token = msg.payload.access_token,\n        scope = msg.payload.scope,\n        expires_in = msg.payload.expires_in;\n    \n    global.set(\"spotify_access_token\", access_token);\n    global.set(\"spotify_scope\", scope);\n    \n    return [msg, null];\n} else {\n    node.log(\"Status code is \" + msg.statusCode);\n    msg.url = \"error\";\n    msg.payload = msg.statusCode;\n    return [null, msg];\n}\n","outputs":2,"noerr":0,"x":900,"y":320,"wires":[["94e54c31.8dbf2"],["9e1c64ee.1146e8"]]},{"id":"9e1c64ee.1146e8","type":"http response","z":"bd53672a.f0dfc8","name":"Error","statusCode":"","headers":{},"x":1090,"y":380,"wires":[]},{"id":"b6661045.4cbf7","type":"function","z":"bd53672a.f0dfc8","name":"Spotify Authorization","func":"node.log(\"Spotify building request to callback\");\n\nvar querystring = global.get('querystring');\n\nvar client_id = global.get(\"spotify_client_id\");\nvar client_secret = global.get(\"spotify_client_secret\");\nvar redirect_uri = global.get(\"redirect_uri\");\n\n// your application requests authorization\nvar scope = 'playlist-modify-private playlist-read-private playlist-modify-public playlist-read-collaborative user-follow-read user-follow-modify user-read-private user-read-email user-read-birthdate user-top-read user-read-recently-played user-library-modify user-library-read user-read-playback-state user-read-currently-playing user-modify-playback-state streaming';\nconsole.log(scope);\nmsg.res.redirect('https://accounts.spotify.com/authorize?' +\n    querystring.stringify({\n        response_type: 'code',\n        client_id: client_id,\n        scope: scope,\n        redirect_uri: redirect_uri,\n        state: msg.state\n    }));\n\nnode.log(\"Spotify redirecting to callback\");\n\n","outputs":1,"noerr":0,"x":740,"y":140,"wires":[["c13a40ee.f3ba3"]]},{"id":"1ba8d1cd.36e23e","type":"function","z":"bd53672a.f0dfc8","name":"Generate state value","func":"/**\n * Generates a random string containing numbers and letters\n * @param  {number} length The length of the string\n * @return {string} The generated string\n */\nvar generateRandomString = function(length) {\n  var text = '';\n  var possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n\n  for (var i = 0; i < length; i++) {\n    text += possible.charAt(Math.floor(Math.random() * possible.length));\n  }\n  return text;\n};\n\nvar state = generateRandomString(16);\nvar stateKey = 'spotify_auth_state';\nmsg.cookies = { };\nmsg.cookies[stateKey] = state;\n\nmsg.state = state;\nnode.log(\"spotify state: \" + state);\nreturn msg;","outputs":1,"noerr":0,"x":500,"y":140,"wires":[["b6661045.4cbf7","85528716.162578"]]},{"id":"68560cd9.08f144","type":"comment","z":"bd53672a.f0dfc8","name":"State","info":"Per the Spotify Documentation\nOptional, but strongly recommended. \nThe state can be useful for correlating requests \nand responses. Because your redirect_uri can be \nguessed, using a state value can increase your \nassurance that an incoming connection is the result \nof an authentication request. If you generate a \nrandom string, or encode the hash of some client \nstate, such as a cookie, in this state variable, \nyou can validate the response to additionally \nensure that both the request and response \noriginated in the same browser. This provides \nprotection against attacks such as cross-site \nrequest forgery. See RFC-6749.","x":450,"y":100,"wires":[]},{"id":"b2708426.f088b8","type":"comment","z":"bd53672a.f0dfc8","name":"Spotify Auth Redirect","info":"Need to pass:\n * client_id\n * client_secret\n * redirect_uri\n","x":740,"y":100,"wires":[]},{"id":"8c497eb1.bd6ed","type":"comment","z":"bd53672a.f0dfc8","name":"No Auth Token Flow","info":"If no access token or refresh token are present\nthen treat it as an initial login. This should\nhit the callback.","x":1250,"y":140,"wires":[]},{"id":"9dbbc7e1.088cd8","type":"comment","z":"bd53672a.f0dfc8","name":"Refresh Token Flow","info":"Refresh token is present, generate a new \naccess token.","x":1250,"y":320,"wires":[]},{"id":"f355a985.8df9d8","type":"comment","z":"bd53672a.f0dfc8","name":"Happy path","info":"Access token is present","x":1230,"y":220,"wires":[]},{"id":"7d8c818a.aadcb","type":"debug","z":"bd53672a.f0dfc8","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":290,"y":340,"wires":[]},{"id":"c13a40ee.f3ba3","type":"debug","z":"bd53672a.f0dfc8","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":930,"y":140,"wires":[]},{"id":"85528716.162578","type":"debug","z":"bd53672a.f0dfc8","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":690,"y":180,"wires":[]},{"id":"1cd125d2.a770ba","type":"debug","z":"bd53672a.f0dfc8","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":290,"y":140,"wires":[]},{"id":"6b7c0514.91f0ac","type":"catch","z":"bd53672a.f0dfc8","name":"","scope":null,"x":940,"y":380,"wires":[["9e1c64ee.1146e8"]]}]

The next flow is the callback. This listens on the node-red IP path from the global above. You can put this flow anywhere.

[{"id":"ebb9aeff.06ecf","type":"debug","z":"4f7b479d.f40f38","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":310,"y":140,"wires":[]},{"id":"2c974944.d97516","type":"function","z":"4f7b479d.f40f38","name":"Callback","func":"node.log(\"reached callback\");\nvar querystring = global.get('querystring');\n\nvar client_id = global.get(\"spotify_client_id\");\nvar client_secret = global.get(\"spotify_client_secret\");\nvar redirect_uri = global.get(\"redirect_uri\");\n\n// your application requests refresh and access tokens\n// after checking the state parameter\nvar stateKey = 'spotify_auth_state';\n\nvar code = msg.req.query.code || null;\nnode.log(\"Code: \" + code);\nvar state = msg.req.query.state || null;\nnode.log(\"State: \" + state);\nvar storedState = msg.req.cookies ? msg.req.cookies[stateKey] : null;\nnode.log(\"StoredState: \" + storedState);\n\n// I don't really care about state.\n//if (state === null || state !== storedState) {\n//    msg.res.redirect('/error' +\n//      querystring.stringify({\n//        error: 'state_mismatch'\n//      }));\n//    return;\n//}\n\nmsg.url = 'https://accounts.spotify.com/api/token';\n\nmsg.payload = {\n    \"code\": code,\n    \"redirect_uri\": redirect_uri,\n    \"grant_type\": 'authorization_code'\n};\n\nmsg.headers = {\n    'Content-Type' : 'application/x-www-form-urlencoded', \n    'Authorization': 'Basic ' + (new Buffer(client_id + ':' + client_secret).toString('base64'))\n};\n\nmsg.json = true;\n\nreturn msg;","outputs":1,"noerr":0,"x":324.2143096923828,"y":178.1827745437622,"wires":[["c3498d4c.4601c","6b3c44a7.af9cfc"]]},{"id":"940b279e.fe2528","type":"http response","z":"4f7b479d.f40f38","name":"","statusCode":"","headers":{},"x":877.2143096923828,"y":178.1827745437622,"wires":[]},{"id":"c3498d4c.4601c","type":"http request","z":"4f7b479d.f40f38","name":"","method":"POST","ret":"obj","url":"https://accounts.spotify.com/api/token","tls":"","x":483.2143096923828,"y":178.1827745437622,"wires":[["b39e38fa.74dba8","24682428.7ae98c"]]},{"id":"b39e38fa.74dba8","type":"function","z":"4f7b479d.f40f38","name":"Set access_token variable","func":"if ( msg.statusCode === 200) {\n    var access_token = msg.payload.access_token,\n        refresh_token = msg.payload.refresh_token,\n        scope = msg.payload.scope,\n        expires_in = msg.payload.expires_in;\n    \n    global.set(\"spotify_access_token\", access_token);\n    global.set(\"spotify_refresh_token\", refresh_token);\n    global.set(\"spotify_scope\", scope);\n}\n\n// Could use some error handling if the response code is not 200\nreturn msg;\n","outputs":1,"noerr":0,"x":693.2143096923828,"y":178.1827745437622,"wires":[["940b279e.fe2528","319702f0.1d1f2e"]]},{"id":"ee942049.7307a","type":"http in","z":"4f7b479d.f40f38","name":"","url":"/spotify/auth/callback","method":"get","upload":false,"swaggerDoc":"","x":122.21430969238281,"y":178.1827745437622,"wires":[["2c974944.d97516","ebb9aeff.06ecf"]]},{"id":"f27bfc68.78c13","type":"comment","z":"4f7b479d.f40f38","name":"Spotify Callback","info":"The URI to redirect to after the user grants or\ndenies permission. This URI needs to have been \nentered in the Redirect URI whitelist that you \nspecified when you registered your application. \nThe value of redirect_uri here must exactly \nmatch one of the values you entered when you \nregistered your application, including upper or \nlowercase, terminating slashes, and such.","x":94.5000228881836,"y":131.03991603851318,"wires":[]},{"id":"6b3c44a7.af9cfc","type":"debug","z":"4f7b479d.f40f38","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":463.7856903076172,"y":139.6113977432251,"wires":[]},{"id":"24682428.7ae98c","type":"debug","z":"4f7b479d.f40f38","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":630.9285774230957,"y":142.46849822998047,"wires":[]},{"id":"319702f0.1d1f2e","type":"debug","z":"4f7b479d.f40f38","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":875.2143421173096,"y":142.4684705734253,"wires":[]}]

Ok, it’s taken to this point just to get our access token but now that we have it, we can do whatever we want. Here’s a simple flow to get your first playlist and play it on the active device.

[{"id":"bd53672a.f0dfc8","type":"subflow","name":"Spotify - Authorization Code","info":"","in":[{"x":60,"y":220,"wires":[{"id":"94e54c31.8dbf2"}]}],"out":[{"x":1116,"y":220,"wires":[{"id":"94e54c31.8dbf2","port":1}]}]},{"id":"94e54c31.8dbf2","type":"function","z":"bd53672a.f0dfc8","name":"isAuthenticated?","func":"node.log(\"beginning spotify authentication\");\n\nnode.log(\"getting access_token\");\nvar access_token = global.get(\"spotify_access_token\")\nnode.log(\"received access token\");\nnode.log(\"spotify_access_token: \" + access_token);\nvar refresh_token = global.get(\"spotify_refresh_token\");\nnode.log(\"is this a refresh attempt: \" + msg.refresh_attempt !== undefined ? 'yes' : 'no');\n\nif (flow.get('refresh') && access_token === undefined) {\n    throw 'refresh attempt without access token';\n}\n\nif (msg.refresh_attempt) {\n    node.log(\"payload token value: \" + msg.payload.access_token);\n    access_token = msg.payload.access_token;\n}\n\nif (access_token !== undefined) {\n    node.log(\"Expected flow\");\n    // This is the normal flow\n    // msg.url need to be constructed in the \n    // previous calling node.\n    msg.headers = { 'Authorization': 'Bearer ' + access_token };\n    msg.json = true;\n    return [null, msg, null];\n} else if (refresh_token !== undefined) {\n    node.log(\"Refresh flow\");\n    flow.set('refresh', true);\n    // Token has expired. Use refresh token\n    // to generate a new access token.\n    msg.req.query.refresh_token = refresh_token;\n    msg.refresh_attempt = true;\n    return [null, null, msg];\n} else {\n    node.log(\"No Tokens flow\");\n    // No refresh or access token. Need to login.\n    msg.url = '/spotify/login';\n    return [msg, null, null];\n}\n","outputs":3,"noerr":0,"x":210,"y":220,"wires":[["1ba8d1cd.36e23e","1cd125d2.a770ba"],[],["529bda7c.88cc54","7d8c818a.aadcb"]]},{"id":"2d2d6e4f.3e54c2","type":"comment","z":"bd53672a.f0dfc8","name":"Authorization Code Authentication","info":"This is the main authorization flow for logging in,\ngetting access tokens, and refreshing tokens. This \nflow allows for scopes to be set. Currently, all \nscopes are wired in.\n","x":160,"y":40,"wires":[]},{"id":"529bda7c.88cc54","type":"function","z":"bd53672a.f0dfc8","name":"Spotify Authorization","func":"var querystring = global.get('querystring');\n\nvar client_id = global.get(\"spotify_client_id\");\nvar client_secret = global.get(\"spotify_client_secret\");\n\nvar refresh_token = msg.req.query.refresh_token;\nglobal.set(\"spotify_refresh_token\", refresh_token);\nmsg.payload = {\n    grant_type: 'refresh_token',\n    refresh_token: refresh_token,\n    refresh_attempt: true\n};\n\nmsg.headers = {\n    'Content-Type' : 'application/x-www-form-urlencoded', \n    'Authorization': 'Basic ' + (new Buffer(client_id + ':' + client_secret).toString('base64'))\n};\n    \nmsg.json = true;\n\nreturn msg;","outputs":1,"noerr":0,"x":500,"y":320,"wires":[["cbbcd6d4.70f548"]]},{"id":"cbbcd6d4.70f548","type":"http request","z":"bd53672a.f0dfc8","name":"","method":"POST","ret":"obj","url":"https://accounts.spotify.com/api/token","tls":"","x":690,"y":320,"wires":[["f9c9d72d.fbf4d8"]]},{"id":"f9c9d72d.fbf4d8","type":"function","z":"bd53672a.f0dfc8","name":"Set access_token variables","func":"if ( msg.statusCode === 200) {\nnode.log(\"Status code is 200\");\n    var access_token = msg.payload.access_token,\n        scope = msg.payload.scope,\n        expires_in = msg.payload.expires_in;\n    \n    global.set(\"spotify_access_token\", access_token);\n    global.set(\"spotify_scope\", scope);\n    \n    return [msg, null];\n} else {\n    node.log(\"Status code is \" + msg.statusCode);\n    msg.url = \"error\";\n    msg.payload = msg.statusCode;\n    return [null, msg];\n}\n","outputs":2,"noerr":0,"x":900,"y":320,"wires":[["94e54c31.8dbf2"],["9e1c64ee.1146e8"]]},{"id":"9e1c64ee.1146e8","type":"http response","z":"bd53672a.f0dfc8","name":"Error","statusCode":"","headers":{},"x":1090,"y":380,"wires":[]},{"id":"b6661045.4cbf7","type":"function","z":"bd53672a.f0dfc8","name":"Spotify Authorization","func":"node.log(\"Spotify building request to callback\");\n\nvar querystring = global.get('querystring');\n\nvar client_id = global.get(\"spotify_client_id\");\nvar client_secret = global.get(\"spotify_client_secret\");\nvar redirect_uri = global.get(\"redirect_uri\");\n\n// your application requests authorization\nvar scope = 'playlist-modify-private playlist-read-private playlist-modify-public playlist-read-collaborative user-follow-read user-follow-modify user-read-private user-read-email user-read-birthdate user-top-read user-read-recently-played user-library-modify user-library-read user-read-playback-state user-read-currently-playing user-modify-playback-state streaming';\nconsole.log(scope);\nmsg.res.redirect('https://accounts.spotify.com/authorize?' +\n    querystring.stringify({\n        response_type: 'code',\n        client_id: client_id,\n        scope: scope,\n        redirect_uri: redirect_uri,\n        state: msg.state\n    }));\n\nnode.log(\"Spotify redirecting to callback\");\n\n","outputs":1,"noerr":0,"x":740,"y":140,"wires":[["c13a40ee.f3ba3"]]},{"id":"1ba8d1cd.36e23e","type":"function","z":"bd53672a.f0dfc8","name":"Generate state value","func":"/**\n * Generates a random string containing numbers and letters\n * @param  {number} length The length of the string\n * @return {string} The generated string\n */\nvar generateRandomString = function(length) {\n  var text = '';\n  var possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n\n  for (var i = 0; i < length; i++) {\n    text += possible.charAt(Math.floor(Math.random() * possible.length));\n  }\n  return text;\n};\n\nvar state = generateRandomString(16);\nvar stateKey = 'spotify_auth_state';\nmsg.cookies = { };\nmsg.cookies[stateKey] = state;\n\nmsg.state = state;\nnode.log(\"spotify state: \" + state);\nreturn msg;","outputs":1,"noerr":0,"x":500,"y":140,"wires":[["b6661045.4cbf7","85528716.162578"]]},{"id":"68560cd9.08f144","type":"comment","z":"bd53672a.f0dfc8","name":"State","info":"Per the Spotify Documentation\nOptional, but strongly recommended. \nThe state can be useful for correlating requests \nand responses. Because your redirect_uri can be \nguessed, using a state value can increase your \nassurance that an incoming connection is the result \nof an authentication request. If you generate a \nrandom string, or encode the hash of some client \nstate, such as a cookie, in this state variable, \nyou can validate the response to additionally \nensure that both the request and response \noriginated in the same browser. This provides \nprotection against attacks such as cross-site \nrequest forgery. See RFC-6749.","x":450,"y":100,"wires":[]},{"id":"b2708426.f088b8","type":"comment","z":"bd53672a.f0dfc8","name":"Spotify Auth Redirect","info":"Need to pass:\n * client_id\n * client_secret\n * redirect_uri\n","x":740,"y":100,"wires":[]},{"id":"8c497eb1.bd6ed","type":"comment","z":"bd53672a.f0dfc8","name":"No Auth Token Flow","info":"If no access token or refresh token are present\nthen treat it as an initial login. This should\nhit the callback.","x":1250,"y":140,"wires":[]},{"id":"9dbbc7e1.088cd8","type":"comment","z":"bd53672a.f0dfc8","name":"Refresh Token Flow","info":"Refresh token is present, generate a new \naccess token.","x":1250,"y":320,"wires":[]},{"id":"f355a985.8df9d8","type":"comment","z":"bd53672a.f0dfc8","name":"Happy path","info":"Access token is present","x":1230,"y":220,"wires":[]},{"id":"7d8c818a.aadcb","type":"debug","z":"bd53672a.f0dfc8","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":290,"y":340,"wires":[]},{"id":"c13a40ee.f3ba3","type":"debug","z":"bd53672a.f0dfc8","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":930,"y":140,"wires":[]},{"id":"85528716.162578","type":"debug","z":"bd53672a.f0dfc8","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":690,"y":180,"wires":[]},{"id":"1cd125d2.a770ba","type":"debug","z":"bd53672a.f0dfc8","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":290,"y":140,"wires":[]},{"id":"6b7c0514.91f0ac","type":"catch","z":"bd53672a.f0dfc8","name":"","scope":null,"x":940,"y":380,"wires":[["9e1c64ee.1146e8"]]},{"id":"b9adfaca.a59688","type":"inject","z":"4f7b479d.f40f38","name":"","topic":"","payload":"","payloadType":"date","repeat":"","crontab":"","once":false,"onceDelay":0.1,"x":1160,"y":1400,"wires":[["9302d18a.7f1a4"]]},{"id":"9302d18a.7f1a4","type":"subflow:bd53672a.f0dfc8","z":"4f7b479d.f40f38","name":"Spotify Authorization","x":1200,"y":1440,"wires":[["9fa4b1b6.95aef"]]},{"id":"946bd874.2c7bd8","type":"http request","z":"4f7b479d.f40f38","name":"Get Playlists from Spotify","method":"GET","ret":"obj","url":"https://api.spotify.com/v1/me/playlists","tls":"","x":1210,"y":1520,"wires":[["e8747d2d.f7dd5"]]},{"id":"21cd1eaf.a4dc92","type":"http request","z":"4f7b479d.f40f38","name":"Play Playlist","method":"PUT","ret":"obj","url":"https://api.spotify.com/v1/me/player/play","tls":"","x":1170,"y":1600,"wires":[[]]},{"id":"e8747d2d.f7dd5","type":"function","z":"4f7b479d.f40f38","name":"Build response with first playlist","func":"msg.payload = {\n    \"context_uri\":\"spotify:playlist:\" + msg.payload.items[0].id \n};\n// The http nodes overwrite the header so we need to set it again with our auth token\nmsg.headers = msg.auth;\nreturn msg;","outputs":1,"noerr":0,"x":1230,"y":1560,"wires":[["21cd1eaf.a4dc92"]]},{"id":"9fa4b1b6.95aef","type":"function","z":"4f7b479d.f40f38","name":"Set aside the auth header for later use","func":"msg.auth = msg.headers;\nreturn msg;","outputs":1,"noerr":0,"x":1250,"y":1480,"wires":[["946bd874.2c7bd8"]]}]

There are a lot of debuggers and node.log statements in the code to help you see what it’s doing. Dump these into node-red and let me know what it does. As you’re building functionality, the Spotify console is your friend https://developer.spotify.com/console/.


#19

What a great work Jay_Heavner!

When I try to run your auth flow, I get

“TypeError: Cannot read property ‘cookies’ of undefined”

Any ideas?


#20

It’s possible that I have some extra modules installed. I don’t have it in front of me right now and it’s been awhile. If this were just node and npm you could npm install cookies and it would probably be fine. Node-red does it a little differently. I’ll try and take a look when I get home to see how that’s set up.


#21

So you have a couple of choices depending on how you have node-red installed. I have it running in a docker instance so I can simply ssh to my instance and npm install cookie. If you’re running Hassio and you have node-red installed under hassio then you’ll need to Google how to install in that scenario. I’ve done it before but it’s been long enough that I can’t remember and I don’t have an instance to play with to replicate it. I’m also using a querystring package that needs to be installed.