diff --git a/CHANGELOG.md b/CHANGELOG.md index fc45fac..1182484 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### 0.3.1 (Next) +* [#56](https://github.com/artsy/elderfield/pull/56): Play and summarize Artsy podcasts - [@dblock](https://github.com/dblock). * [#54](https://github.com/artsy/elderfield/pull/54): Added test code coverage - [@dblock](https://github.com/dblock). * Your contribution here. diff --git a/README.md b/README.md index 4fa2daf..d9c5b66 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,15 @@ This skill adds a card with more information. ![](images/card-edelman-arts.png) +#### Ask Artsy to play the latest podcast + +* Alexa, ask Artsy to play podcast number 13. +* Alexa, ask Artsy to play the latest podcast. +* Alexa, ask Artsy for a summary of podcast 13. +* Alexa, ask Artsy for a summary of the latest podcast. + +Plays and summarizes one of [Artsy podcasts](https://soundcloud.com/artsypodcast). + ### Deploying to AWS Lambda See [DEPLOYMENT](DEPLOYMENT.md). diff --git a/functions/artsy/index.js b/functions/artsy/index.js index e636502..8a02593 100644 --- a/functions/artsy/index.js +++ b/functions/artsy/index.js @@ -2,6 +2,7 @@ var alexa = require('alexa-app'); var app = new alexa.app('artsy'); var xapp = require('./models/xapp'); var api = require('./models/api'); +var podcast = require('./models/podcast'); var _ = require('underscore'); var removeMd = require('remove-markdown'); var nodeGeocoder = require('node-geocoder'); @@ -52,6 +53,28 @@ app.intent('AMAZON.CancelIntent', { } ); +app.intent('AMAZON.PauseIntent', {}, + function(req, res) { + console.log('app.AMAZON.PauseIntent'); + res.audioPlayerStop(); + res.send(); + } +); + +app.intent('AMAZON.ResumeIntent', {}, + function(req, res) { + console.log('app.AMAZON.ResumeIntent'); + if (req.context.AudioPlayer.offsetInMilliseconds > 0 && req.context.AudioPlayer.playerActivity === 'STOPPED') { + res.audioPlayerPlayStream('REPLACE_ALL', { + token: req.context.AudioPlayer.token, + url: req.context.AudioPlayer.token, // hack: use token to remember the URL of the stream + offsetInMilliseconds: req.context.AudioPlayer.offsetInMilliseconds + }); + } + res.send(); + } +); + app.intent('AMAZON.HelpIntent', { "slots": {}, "utterances": [ @@ -150,7 +173,7 @@ app.intent('AboutIntent', { res.send(); }).fail(function(error) { - console.log(`app.AboutIntent: couldn't find an artist ${value}.`); + console.error(`app.AboutIntent: couldn't find an artist ${value}.`); res.say(`Sorry, I couldn't find an artist ${value}. Try again?`); res.shouldEndSession(false); res.send(); @@ -246,19 +269,19 @@ app.intent('ShowsIntent', { res.say(messageText); res.shouldEndSession(true); } else { - console.log(`app.ShowsIntent: couldn't find any shows in '${city}'.`); + console.error(`app.ShowsIntent: couldn't find any shows in '${city}'.`); res.say(`Sorry, I couldn't find any shows in ${city}. Try again?`); res.shouldEndSession(false); } res.send(); }).fail(function(error) { - console.log(`app.ShowsIntent: couldn't find any shows in '${city}', ${error}.`); + console.error(`app.ShowsIntent: couldn't find any shows in '${city}', ${error}.`); res.say(`Sorry, I couldn't find any shows in ${city}. Try again?`); res.shouldEndSession(false); res.send(); }); }).fail(function(error) { - console.log(`app.ShowsIntent: ${error}.`); + console.error(`app.ShowsIntent: ${error}.`); res.say("Sorry, I couldn't connect to Artsy. Try again?"); res.shouldEndSession(false); res.send(); @@ -266,7 +289,7 @@ app.intent('ShowsIntent', { } }) .catch(function(error) { - console.log(`app.ShowsIntent: ${error}.`); + console.error(`app.ShowsIntent: ${error}.`); res.say(`Sorry, I couldn't find ${city}. Try again?`); res.shouldEndSession(false); res.send(); @@ -276,6 +299,83 @@ app.intent('ShowsIntent', { } ); +app.intent('PodcastIntent', { + "slots": { + "NUMBER": "AMAZON.NUMBER" + }, + "utterances": [ + "{|to play} {|latest} podcast {|number|episode|episode number} {-|NUMBER}" + ] + }, + function(req, res) { + var podcastNumber = req.slot('NUMBER'); + var podcastStream = podcast.instance(); + console.log(`app.PodcastIntent: ${podcastNumber || 'latest'}`); + if (podcastNumber && podcastNumber !== "") { + podcastStream = podcastStream.getEpisodeStreamById(podcastNumber); + } else { + podcastStream = podcastStream.getLatestEpisodeStream(); + } + + podcastStream.then(function(audioMpegEnclosure) { + var streamUrl = audioMpegEnclosure.url.replace('http://', 'https://'); // SSL required by Amazon, available on SoundCloud + var stream = { + url: streamUrl, + token: streamUrl, + offsetInMilliseconds: 0 + } + res.audioPlayerPlayStream('REPLACE_ALL', stream); + console.log(`app.PodcastIntent: ${podcastNumber || 'latest'}, streaming ${stream.url}.`); + res.send(); + }).catch(function(error) { + console.error(`app.PodcastIntent: ${podcastNumber || 'latest'}, ${error}.`); + if (podcastNumber) { + res.say(`Sorry, I couldn't find Artsy podcast number ${podcastNumber}. Try again?`); + } else { + res.say(`Sorry, I couldn't find the latest Artsy podcast. Try again?`); + } + res.shouldEndSession(false); + res.send(); + }); + + return false; + } +); + +app.intent('PodcastSummaryIntent', { + "slots": { + "NUMBER": "AMAZON.NUMBER" + }, + "utterances": [ + "for the summary of podcast {|number|episode|episode number} {-|NUMBER}", + "for the summary of the latest podcast" + ] + }, + function(req, res) { + var podcastNumber = req.slot('NUMBER'); + var podcastInfo = podcast.instance(); + console.log(`app.PodcastSummaryIntent: ${podcastNumber || 'latest'}`); + if (podcastNumber && podcastNumber !== "") { + podcastInfo = podcastInfo.getEpisodeById(podcastNumber); + } else { + podcastInfo = podcastInfo.getLatestEpisode(); + } + + podcastInfo.then(function(episode) { + res.say(`Artsy podcast episode ${episode.title.replace('No. ', '')}. ${episode.description}`); + console.log(`app.PodcastSummaryIntent: ${podcastNumber}, ${episode.title}.`); + res.send(); + }).catch(function(error) { + console.error(`app.PodcastSummaryIntent: ${podcastNumber}, ${error}.`); + res.say(`Sorry, I couldn't find podcast number ${podcastNumber}. Try again?`); + res.shouldEndSession(false); + res.send(); + }); + + return false; + } +); + if (process.env['ENV'] == 'lambda') { console.log("Starting Artsy Alexa on AWS lambda.") exports.handle = app.lambda(); diff --git a/functions/artsy/models/podcast.js b/functions/artsy/models/podcast.js new file mode 100644 index 0000000..d89596c --- /dev/null +++ b/functions/artsy/models/podcast.js @@ -0,0 +1,88 @@ +var Q = require('q'); +var feedparser = require('feedparser-promised'); +var _ = require('underscore'); +_.mixin(require('underscore.string').exports()); + +function Podcast() { + this.rssUrl = 'https://feeds.soundcloud.com/users/soundcloud:users:211089382/sounds.rss'; + this.parsed = feedparser.parse(this.rssUrl); +} + +Podcast.instance = function() { + return new Podcast(); +} + +Podcast.prototype.fetchItems = function() { + var deferred = Q.defer(); + if (this.items) { + deferred.resolve(this.items); + } else { + var deferred = Q.defer(); + this.parsed.then(function(items) { + this.items = items; + deferred.resolve(this.items); + }); + } + return deferred.promise; +} + +Podcast.prototype.getEpisodeById = function(id) { + var deferred = Q.defer(); + this.fetchItems().then(function(items) { + var podcastTitlePrefix = `No. ${id}:`; + var episode = _.find(items, function(item) { + return _.startsWith(item.title, podcastTitlePrefix); + }); + if (episode) { + deferred.resolve(episode); + } else { + deferred.reject(`podcast ${id} not found`); + } + }).fail(function(error) { + deferred.reject(error); + }); + return deferred.promise; +} + +Podcast.prototype.getLatestEpisode = function() { + var deferred = Q.defer(); + this.fetchItems().then(function(items) { + if (items.length > 0) { + deferred.resolve(items[0]); + } else { + deferred.reject(`no podcast episodes found`); + } + }).fail(function(error) { + deferred.reject(error); + }); + return deferred.promise; +} + +Podcast.prototype.getEpisodeStream = function(getEpisodeFunction) { + var deferred = Q.defer(); + getEpisodeFunction.then(function(episode) { + if (episode.enclosures) { + var audioMpegEnclosure = _.findWhere(episode.enclosures, { type: 'audio/mpeg' }); + if (audioMpegEnclosure) { + deferred.resolve(audioMpegEnclosure); + } else { + deferred.reject(`podcast ${episode.guid} audio stream not found`); + } + } else { + deferred.reject(`podcast ${episode.guid} has no audio streams`); + } + }).fail(function(error) { + deferred.reject(error); + }); + return deferred.promise; +} + +Podcast.prototype.getEpisodeStreamById = function(id) { + return this.getEpisodeStream(this.getEpisodeById(id)); +} + +Podcast.prototype.getLatestEpisodeStream = function(id) { + return this.getEpisodeStream(this.getLatestEpisode()); +} + +module.exports = Podcast; diff --git a/functions/artsy/package.json b/functions/artsy/package.json index b58e94e..a1f18e2 100644 --- a/functions/artsy/package.json +++ b/functions/artsy/package.json @@ -26,7 +26,9 @@ "alexa-app": "^3.0.0", "superagent": "2.3.0", "underscore": "1.8.3", + "underscore.string": "2.3.0", "remove-markdown": "0.1.0", - "node-geocoder": "^3.15.2" + "node-geocoder": "^3.15.2", + "feedparser-promised": "1.1.1" } } diff --git a/functions/artsy/schema.json b/functions/artsy/schema.json index 84ce1ba..057df77 100644 --- a/functions/artsy/schema.json +++ b/functions/artsy/schema.json @@ -8,6 +8,14 @@ "intent": "AMAZON.CancelIntent", "slots": [] }, + { + "intent": "AMAZON.PauseIntent", + "slots": [] + }, + { + "intent": "AMAZON.ResumeIntent", + "slots": [] + }, { "intent": "AMAZON.HelpIntent", "slots": [] @@ -29,6 +37,24 @@ "type": "AMAZON.US_CITY" } ] + }, + { + "intent": "PodcastIntent", + "slots": [ + { + "name": "NUMBER", + "type": "AMAZON.NUMBER" + } + ] + }, + { + "intent": "PodcastSummaryIntent", + "slots": [ + { + "name": "NUMBER", + "type": "AMAZON.NUMBER" + } + ] } ] } \ No newline at end of file diff --git a/functions/artsy/utterances.txt b/functions/artsy/utterances.txt index 0583e42..770811b 100644 --- a/functions/artsy/utterances.txt +++ b/functions/artsy/utterances.txt @@ -20,3 +20,24 @@ ShowsIntent to recommend a show around {CITY} ShowsIntent current show around {CITY} ShowsIntent for current show around {CITY} ShowsIntent to recommend current show around {CITY} +PodcastIntent podcast {NUMBER} +PodcastIntent to play podcast {NUMBER} +PodcastIntent latest podcast {NUMBER} +PodcastIntent to play latest podcast {NUMBER} +PodcastIntent podcast number {NUMBER} +PodcastIntent to play podcast number {NUMBER} +PodcastIntent latest podcast number {NUMBER} +PodcastIntent to play latest podcast number {NUMBER} +PodcastIntent podcast episode {NUMBER} +PodcastIntent to play podcast episode {NUMBER} +PodcastIntent latest podcast episode {NUMBER} +PodcastIntent to play latest podcast episode {NUMBER} +PodcastIntent podcast episode number {NUMBER} +PodcastIntent to play podcast episode number {NUMBER} +PodcastIntent latest podcast episode number {NUMBER} +PodcastIntent to play latest podcast episode number {NUMBER} +PodcastSummaryIntent for the summary of podcast {NUMBER} +PodcastSummaryIntent for the summary of podcast number {NUMBER} +PodcastSummaryIntent for the summary of podcast episode {NUMBER} +PodcastSummaryIntent for the summary of podcast episode number {NUMBER} +PodcastSummaryIntent for the summary of the latest podcast diff --git a/test/functions/artsy/fixtures/PauseIntentRequest.json b/test/functions/artsy/fixtures/PauseIntentRequest.json new file mode 100644 index 0000000..1c25a51 --- /dev/null +++ b/test/functions/artsy/fixtures/PauseIntentRequest.json @@ -0,0 +1,22 @@ +{ + "version": "1.0", + "session": { + "new": false, + "sessionId": "amzn1.echo-api.session.abeee1a7-aee0-41e6-8192-e6faaed9f5ef", + "application": { + "applicationId": "amzn1.echo-sdk-ams.app.999999-9999-9999-9999-999999999999" + }, + "attributes": {}, + "user": { + "userId": "amzn1.account.AM3B227HF3FAM1B261HK7FFM3A2" + } + }, + "request": { + "type": "IntentRequest", + "requestId": "amzn1.echo-api.request.6919844a-733e-4e89-893a-fdcb77e2ef0d", + "timestamp": "2015-05-13T12:34:56Z", + "intent": { + "name": "AMAZON.PauseIntent" + } + } +} diff --git a/test/functions/artsy/fixtures/PodcastIntentRequest.json b/test/functions/artsy/fixtures/PodcastIntentRequest.json new file mode 100644 index 0000000..b9d4317 --- /dev/null +++ b/test/functions/artsy/fixtures/PodcastIntentRequest.json @@ -0,0 +1,29 @@ +{ + "session": { + "sessionId": "SessionId.ca05ce5f-3b70-49be-84b5-bdc3086533fb", + "application": { + "applicationId": "amzn1.ask.skill.42004f60-1c66-4c61-8c5d-9006303e6ec4" + }, + "attributes": {}, + "user": { + "userId": "amzn1.ask.account.xyz" + }, + "new": true + }, + "request": { + "type": "IntentRequest", + "requestId": "EdwRequestId.624219fd-68b1-41ba-b02b-a1bf54dfe180", + "locale": "en-US", + "timestamp": "2016-11-12T22:03:54Z", + "intent": { + "name": "PodcastIntent", + "slots": { + "NUMBER": { + "name": "NUMBER", + "value": 6666 + } + } + } + }, + "version": "1.0" +} diff --git a/test/functions/artsy/fixtures/PodcastSummaryIntentRequest.json b/test/functions/artsy/fixtures/PodcastSummaryIntentRequest.json new file mode 100644 index 0000000..fe46e3c --- /dev/null +++ b/test/functions/artsy/fixtures/PodcastSummaryIntentRequest.json @@ -0,0 +1,29 @@ +{ + "session": { + "sessionId": "SessionId.ca05ce5f-3b70-49be-84b5-bdc3086533fb", + "application": { + "applicationId": "amzn1.ask.skill.42004f60-1c66-4c61-8c5d-9006303e6ec4" + }, + "attributes": {}, + "user": { + "userId": "amzn1.ask.account.xyz" + }, + "new": true + }, + "request": { + "type": "IntentRequest", + "requestId": "EdwRequestId.624219fd-68b1-41ba-b02b-a1bf54dfe180", + "locale": "en-US", + "timestamp": "2016-11-12T22:03:54Z", + "intent": { + "name": "PodcastSummaryIntent", + "slots": { + "NUMBER": { + "name": "NUMBER", + "value": 6666 + } + } + } + }, + "version": "1.0" +} diff --git a/test/functions/artsy/fixtures/ResumeIntentRequest.json b/test/functions/artsy/fixtures/ResumeIntentRequest.json new file mode 100644 index 0000000..c45f639 --- /dev/null +++ b/test/functions/artsy/fixtures/ResumeIntentRequest.json @@ -0,0 +1,23 @@ +{ + "version": "1.0", + "session": { + "new": false, + "sessionId": "amzn1.echo-api.session.abeee1a7-aee0-41e6-8192-e6faaed9f5ef", + "application": { + "applicationId": "amzn1.echo-sdk-ams.app.999999-9999-9999-9999-999999999999" + }, + "attributes": {}, + "user": { + "userId": "amzn1.account.AM3B227HF3FAM1B261HK7FFM3A2" + } + }, + "request": { + "type": "IntentRequest", + "requestId": "amzn1.echo-api.request.6919844a-733e-4e89-893a-fdcb77e2ef0d", + "timestamp": "2015-05-13T12:34:56Z", + "intent": { + "name": "AMAZON.PlaybackResumed", + "slots": {} + } + } +} diff --git a/test/functions/artsy/test-podcast-summary.js b/test/functions/artsy/test-podcast-summary.js new file mode 100644 index 0000000..91cea6c --- /dev/null +++ b/test/functions/artsy/test-podcast-summary.js @@ -0,0 +1,42 @@ +require('../../setup'); + +describe('artsy alexa', function() { + podcastSummaryIntentRequest = function(podcastSummaryNumber, cb) { + var podcastSummaryIntentRequest = require('./fixtures/PodcastSummaryIntentRequest.json'); + podcastSummaryIntentRequest.request.intent.slots.NUMBER.value = podcastSummaryNumber; + chai.request(server) + .post('/alexa/artsy') + .send(podcastSummaryIntentRequest) + .end(function(err, res) { + expect(res.status).to.equal(200); + var data = JSON.parse(res.text); + cb(data.response); + }); + } + + it('describes podcastSummary by number', function(done) { + podcastSummaryIntentRequest(13, function(response) { + expect(response.outputSpeech.ssml).to.startWith('Artsy podcast episode 13: We Still Need All-Female Group Shows. With the number of all-female group'); + expect(response.shouldEndSession).to.equal(true); + done(); + }); + }); + + it('describes the latest podcast', function(done) { + podcastSummaryIntentRequest("", function(response) { + expect(response.outputSpeech.ssml).to.not.startWith('Artsy podcast episode 13'); + expect(response.outputSpeech.ssml).to.startWith('Artsy podcast episode'); + expect(response.shouldEndSession).to.equal(true); + done(); + }); + }); + + it('shows error when the podcastSummary cannot be found', function(done) { + podcastSummaryIntentRequest(6666, function(response) { + expect(response.outputSpeech.type).to.equal('SSML'); + expect(response.outputSpeech.ssml).to.equal("Sorry, I couldn't find podcast number 6666. Try again?"); + expect(response.shouldEndSession).to.equal(false); + done(); + }); + }); +}); diff --git a/test/functions/artsy/test-podcast.js b/test/functions/artsy/test-podcast.js new file mode 100644 index 0000000..ac6224e --- /dev/null +++ b/test/functions/artsy/test-podcast.js @@ -0,0 +1,72 @@ +require('../../setup'); + +describe('artsy alexa', function() { + podcastIntentRequest = function(podcastNumber, cb) { + var podcastIntentRequest = require('./fixtures/PodcastIntentRequest.json'); + podcastIntentRequest.request.intent.slots.NUMBER.value = podcastNumber; + chai.request(server) + .post('/alexa/artsy') + .send(podcastIntentRequest) + .end(function(err, res) { + expect(res.status).to.equal(200); + var data = JSON.parse(res.text); + cb(data.response); + }); + } + + it('plays podcast by number', function(done) { + podcastIntentRequest(23, function(response) { + expect(response).to.eql({ + directives: [{ + type: 'AudioPlayer.Play', + playBehavior: 'REPLACE_ALL', + audioItem: { + stream: { + offsetInMilliseconds: 0, + token: "https://feeds.soundcloud.com/stream/304688136-artsypodcast-no-23-what-does-it-mean-to-curate-gifs.mp3", + url: "https://feeds.soundcloud.com/stream/304688136-artsypodcast-no-23-what-does-it-mean-to-curate-gifs.mp3" + } + } + }], + shouldEndSession: true + }) + expect(response.shouldEndSession).to.equal(true); + done(); + }); + }); + + it('plays latest podcast', function(done) { + podcastIntentRequest("", function(response) { + var directive = response.directives[0]; + expect(directive.type).to.equal('AudioPlayer.Play'); + expect(directive.playBehavior).to.equal('REPLACE_ALL'); + expect(directive.audioItem.stream.token).to.not.include('-23-'); + expect(response.shouldEndSession).to.equal(true); + done(); + }); + }); + + it('shows error when the podcast cannot be found', function(done) { + podcastIntentRequest(6666, function(response) { + expect(response.outputSpeech.type).to.equal('SSML'); + expect(response.outputSpeech.ssml).to.equal("Sorry, I couldn't find Artsy podcast number 6666. Try again?"); + expect(response.shouldEndSession).to.equal(false); + done(); + }); + }); + + it('pauses podcast', function(done) { + var pauseIntentRequest = require('./fixtures/PauseIntentRequest.json'); + chai.request(server) + .post('/alexa/artsy') + .send(pauseIntentRequest) + .end(function(err, res) { + expect(res.status).to.equal(200); + var data = JSON.parse(res.text); + var response = data.response; + expect(response.directives[0].type).to.equal('AudioPlayer.Stop'); + expect(response.shouldEndSession).to.equal(true); + done(); + }); + }); +});