thorpelawrence/alexa-spotify-connect

View on GitHub
connect.js

Summary

Maintainability
F
1 wk
Test Coverage
B
82%
File `connect.js` has 490 lines of code (exceeds 250 allowed). Consider refactoring.
const alexa = require('alexa-app');
const request = require('request-promise-native');
const express = require('express');
const nodecache = require('node-cache');
const i18n = require('i18n');
 
const rq = require('./request-helper');
const { findDeviceByName, requestDevices } = require('./device-helper');
 
// Create instance of express
var express_app = express();
// Create a 1 hour cache for storing user devices
var cache = new nodecache({ stdTTL: 3600, checkperiod: 120 });
// Create instance of alexa-app
var app = new alexa.app('connect');
// Bind alexa-app to express instance
app.express({ expressApp: express_app });
 
const successSound = "<audio src='soundbank://soundlibrary/ui/gameshow/amzn_ui_sfx_gameshow_neutral_response_02'/>";
const connectDeviceCard = (context) => ({
type: "Simple",
title: context.__("Connecting to a device using Spotify Connect"),
content: context.__("To add a device to Spotify Connect,"
+ " log in to your Spotify account on a supported device"
+ " such as an Echo, phone, or computer"
+ "\nhttps://support.spotify.com/uk/article/spotify-connect/")
});
const applicationId = require('./package.json').alexa.applicationId;
 
// Run every time the skill is accessed
app.pre = function (req, res, _type) {
i18n.configure({
directory: __dirname + '/locales',
defaultLocale: 'en-GB',
register: req.context,
});
 
if (req.data.request.locale) {
req.context.setLocale(req.data.request.locale);
}
// Error if the application ID of the request is not for this skill
if (req.applicationId != applicationId &&
req.getSession().details.application.applicationId != applicationId) {
throw "Invalid applicationId";
}
// Check that the user has an access token, if they have linked their account
if (!(req.context.System.user.accessToken || req.getSession().details.user.accessToken)) {
res.say(req.context.__("You have not linked your Spotify account, check your Alexa app to link the account"));
res.linkAccount();
}
};
 
// Run after every request
app.post = function (req, res, _type, exception) {
if (exception) {
return res.clear().say(req.context.__("An error occured: ") + exception).send();
}
};
 
// Function for when skill is invoked without intent
app.launch(function (req, res) {
res.say(req.context.__("I can control your Spotify Connect devices, to start, ask me to list your devices"))
.reprompt(req.context.__("To start, ask me to list your devices"));
// Keep session open
res.shouldEndSession(false);
});
 
// Handle default Amazon help intent
// No slots or utterances required
app.intent("AMAZON.HelpIntent", {
"slots": {},
"utterances": []
}, function (req, res) {
res.say(req.context.__("You can ask me to list your connect devices and then control them. "))
.say(req.context.__("For example, tell me to play on a device after listing devices"))
.reprompt(req.context.__("What would you like to do?"));
// Keep session open
res.shouldEndSession(false);
});
 
// Handle default Amazon stop intent
// No slots or utterances required
app.intent("AMAZON.StopIntent", {
"slots": {},
"utterances": []
}, function (_req, _res) {
return;
});
 
// Handle default Amazon cancel intent
// No slots or utterances required
app.intent("AMAZON.CancelIntent", {
"slots": {},
"utterances": []
}, function (_req, _res) {
return;
});
 
// Handle play intent
// No slots required
app.intent('PlayIntent', {
"utterances": [
"play",
"resume",
"continue"
]
},
function (req, res) {
// PUT to Spotify REST API
return rq.put("https://api.spotify.com/v1/me/player/play", req.getSession().details.user.accessToken)
.then((r) => {
req.getSession().set("statusCode", r.statusCode);
res.say(successSound);
}).catch((err) => {
if (err.statusCode === 403) res.say(req.context.__("Make sure your Spotify account is premium"));
if (err.statusCode === 404) {
res.say(req.context.__("I couldn't find any connect devices, check your Alexa app for information on connecting a device"));
res.card(connectDeviceCard(req.context));
}
});
}
);
 
// Handle pause intent
// No slots required
app.intent('PauseIntent', {
"utterances": [
"pause"
]
},
Similar blocks of code found in 3 locations. Consider refactoring.
function (req, res) {
// PUT to Spotify REST API
return rq.put("https://api.spotify.com/v1/me/player/pause", req.getSession().details.user.accessToken)
.then((r) => {
req.getSession().set("statusCode", r.statusCode);
res.say(successSound);
}).catch((err) => {
if (err.statusCode === 403) res.say(req.context.__("Make sure your Spotify account is premium"));
if (err.statusCode === 404) {
res.say(req.context.__("I couldn't find any connect devices, check your Alexa app for information on connecting a device"));
res.card(connectDeviceCard(req));
}
});
}
);
 
// Handle skip next intent
// No slots required
app.intent('SkipNextIntent', {
"utterances": [
"skip",
"next",
"forwards"
]
},
Similar blocks of code found in 3 locations. Consider refactoring.
function (req, res) {
// POST to Spotify REST API
return rq.post("https://api.spotify.com/v1/me/player/next", req.getSession().details.user.accessToken)
.then((r) => {
req.getSession().set("statusCode", r.statusCode);
res.say(successSound);
}).catch((err) => {
if (err.statusCode === 403) res.say(req.context.__("Make sure your Spotify account is premium"));
if (err.statusCode === 404) {
res.say(req.context.__("I couldn't find any connect devices, check your Alexa app for information on connecting a device"));
res.card(connectDeviceCard(res));
}
});
}
);
 
// Handle skip previous intent
// No slots required
app.intent('SkipPreviousIntent', {
"utterances": [
"previous",
"last",
"back",
"backwards"
]
},
Similar blocks of code found in 3 locations. Consider refactoring.
function (req, res) {
// POST to Spotify REST API
return rq.post("https://api.spotify.com/v1/me/player/previous", req.getSession().details.user.accessToken)
.then((r) => {
req.getSession().set("statusCode", r.statusCode);
res.say(successSound);
}).catch((err) => {
if (err.statusCode === 403) res.say(req.context.__("Make sure your Spotify account is premium"));
if (err.statusCode === 404) {
res.say(req.context.__("I couldn't find any connect devices, check your Alexa app for information on connecting a device"));
res.card(connectDeviceCard(res));
}
});
}
);
 
// PUT to Spotify REST API
const setVolume = (volumePercent, req, res) => {
return request.put({
// Send new volume * 10 (convert to percentage)
url: "https://api.spotify.com/v1/me/player/volume?volume_percent=" + volumePercent,
// Send access token as bearer auth
auth: {
"bearer": req.getSession().details.user.accessToken
},
// Handle sending as JSON
json: true
}).catch((err) => {
if (err.statusCode === 403) res.say(req.context.__("Make sure your Spotify account is premium"));
if (err.statusCode === 404) {
res.say(req.context.__("I couldn't find any connect devices, check your Alexa app for information on connecting a device"));
res.card(connectDeviceCard(res));
}
});
};
 
Function `getAndValidateVolumePercentFromSlot` has a Cognitive Complexity of 28 (exceeds 5 allowed). Consider refactoring.
Function `getAndValidateVolumePercentFromSlot` has 33 lines of code (exceeds 25 allowed). Consider refactoring.
const getAndValidateVolumePercentFromSlot = (req, res, isPercentIntent) => {
const slotName = isPercentIntent ? "VOLUMEPERCENT" : "VOLUMELEVEL"
// Check that the slot has a value
if (req.slot(slotName)) {
// Check if the slot is a number
if (!isNaN(req.slot(slotName))) {
var volumeValue = req.slot(slotName);
// Check that the volume is valid
if (volumeValue >= 0 && volumeValue <= (isPercentIntent ? 100 : 10)) {
return (isPercentIntent ? 1 : 10) * volumeValue;
}
else {
// If not valid volume
res.say(req.context.__(isPercentIntent
? "You can only set the volume percent between 0 and 100"
: "You can only set the volume between 0 and 10"));
// Keep session open
res.shouldEndSession(false);
return null;
}
}
else {
// Not a number
res.say(req.context.__(isPercentIntent
? "Try setting a volume percent between 0 and 100"
: "Try setting a volume between 0 and 10"))
.reprompt(req.context.__("What would you like to do?"));
// Keep session open
res.shouldEndSession(false);
return null;
}
}
else {
// No slot value
res.say(req.context.__("I couldn't work out the volume to use."))
.say(req.context.__(isPercentIntent
? "Try setting a volume percent between 0 and 100"
: "Try setting a volume between 0 and 10"))
.reprompt(req.context.__("What would you like to do?"));
// Keep session open
res.shouldEndSession(false);
return null;
}
};
 
// Handle 0-10 volume level intent
// Slot for new volume
Similar blocks of code found in 2 locations. Consider refactoring.
app.intent('VolumeLevelIntent', {
"slots": {
"VOLUMELEVEL": "AMAZON.NUMBER"
},
"utterances": [
"{set the|set|} volume {level|} {to|} {-|VOLUMELEVEL}"
]
},
function (req, res) {
// Check that request contains session
if (req.hasSession()) {
const volumePercent = getAndValidateVolumePercentFromSlot(req, res, false);
if (volumePercent !== null) {
return setVolume(volumePercent, req, res);
}
}
}
);
 
// Handle volume percent intent
// Slot for new volume
Similar blocks of code found in 2 locations. Consider refactoring.
app.intent('VolumePercentIntent', {
"slots": {
"VOLUMEPERCENT": "AMAZON.NUMBER"
},
"utterances": [
"{set the|set|} volume {level|} {to|} {-|VOLUMEPERCENT} percent"
]
},
function (req, res) {
// Check that request contains session
if (req.hasSession()) {
const volumePercent = getAndValidateVolumePercentFromSlot(req, res, true);
if (volumePercent !== null) {
return setVolume(volumePercent, req, res);
}
}
}
);
 
// Handle get devices intent
// No slots required
app.intent('GetDevicesIntent', {
"utterances": [
"devices",
"list",
"search",
"find"
]
},
function (req, res) {
// GET from Spotify REST API
return requestDevices(req, cache)
.then(function (devices) {
var deviceNames = devices.map(d => d.name);
// Check if user has devices
if (devices.length > 0) {
// Comma separated list of device names
res.say(req.context.__("I found these connect devices: "))
.say([deviceNames.slice(0, -1).join(', '), deviceNames.slice(-1)[0]].join(deviceNames.length < 2 ? '' : ',' + req.context.__(' and ')) + ". ")
.say(req.context.__("What would you like to do with these devices?")).reprompt(req.context.__("What would you like to do?"));
// Keep session open
res.shouldEndSession(false);
}
else {
// No devices found
res.say(req.context.__("I couldn't find any connect devices, check your Alexa app for information on connecting a device"));
res.card(connectDeviceCard(res));
}
})
// Handle errors
.catch(function (err) {
req.getSession().set("statusCode", err.statusCode);
});
}
);
 
// Handle device play intent
// Slot for device name
app.intent('DevicePlayIntent', {
"slots": {
"DEVICE": "AMAZON.SearchQuery"
},
"utterances": [
"play on {my|device|} {-|DEVICE}"
]
},
function (req, res) {
// Check that request contains session
if (req.hasSession()) {
// Check that the slot has a value
var DEVICE = req.slot("DEVICE");
Similar blocks of code found in 2 locations. Consider refactoring.
if (DEVICE) {
return findDeviceByName(req, cache, DEVICE).then(async (device) => {
// Check that the device was found
Identical blocks of code found in 2 locations. Consider refactoring.
if (device.id) {
// PUT to Spotify REST API
await request.put({
url: "https://api.spotify.com/v1/me/player",
// Send access token as bearer auth
auth: {
"bearer": req.getSession().details.user.accessToken
},
body: {
// Send device ID
"device_ids": [
device.id
],
// Make sure that music plays
"play": true
},
// Handle sending as JSON
json: true
}).then((_r) => {
res.say(req.context.__("Playing on device {{deviceName}}", { deviceName: device.name }));
}).catch((err) => {
if (err.statusCode === 403) res.say(req.context.__("Make sure your Spotify account is premium"));
if (err.statusCode === 404) {
res.say(req.context.__("I couldn't find any connect devices, check your Alexa app for information on connecting a device"));
res.card(connectDeviceCard(res));
}
});
}
else {
// If device not found
res.say(req.context.__("I couldn't find a device named {{DEVICE}}.", { DEVICE }))
.say(req.context.__("Try asking me to list devices first"));
// Keep session open
res.shouldEndSession(false);
}
});
}
else {
// No slot value
res.say(req.context.__("I couldn't work out which device to play on."))
.say(req.context.__("Try asking me to list devices first"))
.reprompt(req.context.__("What would you like to do?"));
// Keep session open
res.shouldEndSession(false);
}
}
}
);
 
// Handle track queue intent
// Slot for track name
app.intent(
"QueueTrackIntent",
{
slots: {
TRACKNAME: "AMAZON.MusicRecording",
},
utterances: [
"queue {the song|} {-|TRACKNAME}",
"add {the song|} {-|TRACKNAME} to {my|the} queue",
],
},
function (req, res) {
// Check that request contains session
if (!req.hasSession()) {
return;
}
 
// Check that the slot has a value
if (!req.slot("TRACKNAME")) {
// No slot value
res
.say(req.context.__("I couldn't work out which song you want to queue."))
.reprompt(req.context.__("What would you like to do?"));
// Keep session open
res.shouldEndSession(false);
}
 
var trackSearchQuery = req.slot("TRACKNAME");
Identical blocks of code found in 2 locations. Consider refactoring.
return request.get({
url: `https://api.spotify.com/v1/search`,
// Send access token as bearer auth
auth: {
bearer: req.getSession().details.user.accessToken,
},
qs: {
q: trackSearchQuery,
type: "track",
offset: "0",
},
// Handle sending as JSON
json: true,
})
.then((body) => {
 
if (!body.tracks || !body.tracks.items[0] || !body.tracks.items[0].uri || !body.tracks.items[0].name) {
throw "bad response";
}
 
var trackId = body.tracks.items[0].uri;
var trackName = body.tracks.items[0].name;
 
Identical blocks of code found in 2 locations. Consider refactoring.
return request.post({
url: "https://api.spotify.com/v1/me/player/queue",
auth: {
bearer: req.getSession().details.user.accessToken,
},
qs: {
// Send track ID
uri: trackId,
},
// Handle sending as JSON
json: true,
})
.then((response) => {
res.say(
req.context.__("Queued track {{trackName}}", {
trackName,
})
);
})
.catch((err) => {
res
.say(req.context.__("Sorry, I couldn't queue that song."))
.reprompt(req.context.__("What would you like to do?"));
});
})
.catch((err) => {
res
.say(req.context.__("Sorry, I couldn't queue that song."))
.reprompt(req.context.__("What would you like to do?"));
});
}
);
// Handle device transfer intent
// Slot for device name
app.intent('DeviceTransferIntent', {
"slots": {
"DEVICE": "AMAZON.SearchQuery"
},
"utterances": [
"{transfer|switch|swap|move} to {my|device|} {-|DEVICE}",
"use {my|device|} {-|DEVICE}"
]
},
function (req, res) {
// Check that request contains session
if (req.hasSession()) {
// Check that the slot has a value
var DEVICE = req.slot("DEVICE");
Similar blocks of code found in 2 locations. Consider refactoring.
if (DEVICE) {
return findDeviceByName(req, cache, DEVICE).then(async (device) => {
// Check that the device was found
Identical blocks of code found in 2 locations. Consider refactoring.
if (device.id) {
// PUT to Spotify REST API
await request.put({
url: "https://api.spotify.com/v1/me/player",
// Send access token as bearer auth
auth: {
"bearer": req.getSession().details.user.accessToken
},
body: {
// Send device ID
"device_ids": [
device.id
]
},
// Handle sending as JSON
json: true
}).then((_r) => {
res.say(req.context.__("Transferring to {{deviceName}}", {deviceName: device.name }));
}).catch((err) => {
if (err.statusCode === 403) res.say(req.context.__("Make sure your Spotify account is premium"));
if (err.statusCode === 404) {
res.say(req.context.__("I couldn't find any connect devices, check your Alexa app for information on connecting a device"));
res.card(connectDeviceCard(res));
}
});
}
else {
// If device not found
res.say(req.context.__("I couldn't find a device named {{DEVICE}}.", { DEVICE }))
.say(req.context.__("Try asking me to list devices first"));
// Keep session open
res.shouldEndSession(false);
}
});
}
else {
// No slot value
res.say(req.context.__("I couldn't work out which device to transfer to."))
.say(req.context.__("Try asking me to list devices first"))
.reprompt(req.context.__("What would you like to do?"));
// Keep session open
res.shouldEndSession(false);
}
}
}
);
 
// Handle get track intent
// No slots required
app.intent('GetTrackIntent', {
"utterances": [
"{what is|what's} {playing|this song}",
"what {song|track|} is this"
]
},
function (req, res) {
// GET from Spotify REST API
Similar blocks of code found in 2 locations. Consider refactoring.
return request.get({
url: "https://api.spotify.com/v1/me/player/currently-playing",
// Send access token as bearer auth
auth: {
"bearer": req.getSession().details.user.accessToken
},
// Parse results as JSON
json: true
})
.then(function (body) {
if (body.is_playing) {
Similar blocks of code found in 2 locations. Consider refactoring.
res.say(req.context.__("This is {{name}} by {{artist}}", { name: body.item.name, artist: body.item.artists[0].name }));
}
else {
if (body.item.name) {
// If not playing but last track known
Similar blocks of code found in 2 locations. Consider refactoring.
res.say(req.context.__("That was {{name}} by {{artist}}", { name: body.item.name, artist: body.item.artists[0].name }));
}
else {
// If unknown
res.say(req.context.__("Nothing is playing"));
}
}
})
// Handle errors
.catch(function (err) {
req.getSession().set("statusCode", err.statusCode);
});
}
);
 
// Set up redirect to project page
express_app.use(express.static(__dirname));
 
/* istanbul ignore next */
express_app.get('/', function (_, res) {
res.redirect('https://github.com/thorpelawrence/alexa-spotify-connect');
});
 
/* istanbul ignore if */
// Only listen if run directly, not if required as a module
if (require.main === module) {
var port = process.env.PORT || 8888;
console.log("Listening on port " + port);
express_app.listen(port);
}
 
/* istanbul ignore next */
express_app.get('/skill', function (_, res) {
const skill = require('./skill/skill.js');
skill.forEach(locale => {
res.write(locale.name + '\n');
res.write(JSON.stringify(locale.data, null, 2) + '\n');
res.write('='.repeat(80) + '\n');
});
res.end();
});
 
// Export alexa-app instance for skill.js
module.exports = app;