Skip to content

Commit

Permalink
Add support for displaying cards in the Alexa app (josephschmitt#10)
Browse files Browse the repository at this point in the history
This requires providing an API key for TMDB, which is where we get the artwork from. You can do so in the config in the new `artwork` section:

```
  "server": {...},
  "movies": {...},
  "shows": {...},
  "artwork": {
    "tmdbApiKey": "abcdefghijklmnopqrstuvwxyz123456"
  }
```

Once configured correctly, a card will be displayed for Movies and Shows in the Alexa app (or in the Echo Show if you have one) after checking if a movie or show is already on your list, or after adding a new movie or show.
  • Loading branch information
josephschmitt authored Jul 1, 2017
1 parent 2c9bc28 commit 6f8fb5e
Show file tree
Hide file tree
Showing 16 changed files with 256 additions and 28 deletions.
5 changes: 4 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "alexa-libby",
"version": "1.1.8",
"version": "1.2.0",
"description": "A skill to ask Alexa about your Movie and TV Show library queues.",
"main": "dist/index.js",
"scripts": {
Expand Down Expand Up @@ -28,6 +28,7 @@
"couchpotato-api": "^0.1.0",
"node-sickbeard": "0.0.1",
"sonarr-api": "^0.2.0",
"themoviedbclient": "git+https://github.com/josephschmitt/TheMovieDBClient.git",
"url": "^0.11.0"
},
"devDependencies": {
Expand Down
4 changes: 3 additions & 1 deletion src/api/config.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import config from 'config';
import url from 'url';

import {PROVIDER_TYPE} from '~/api/getProvider.js';

/**
* Returns a config object for a given API provider.
*
* @param {String} providerType -- "movie" or "show"
* @returns {Object}
*/
export default function (providerType = 'movies') {
export default function (providerType = PROVIDER_TYPE.MOVIES) {
const baseConfig = config.get('alexa-libby');
const serverConfig = baseConfig.server || {};
const mediaConfig = baseConfig[providerType] || {};
Expand Down
6 changes: 3 additions & 3 deletions src/api/couchpotato.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import serverConfig from '~/api/config.js';
* @property {String} title
* @property {Number} year
* @property {String} tmdbId
* @property {String} imdb
* @property {String} imdbId
* @property {String} [status]
* @property {String} [quality]
*/
Expand Down Expand Up @@ -49,15 +49,15 @@ export async function search(query) {
* @returns {Object} -- Couch Potato response object
*/
export async function add(movie) {
return await couchpotato().get('movie.add', {title: movie.title, identifier: movie.imdb});
return await couchpotato().get('movie.add', {title: movie.title, identifier: movie.imdbId});
}

function formatMovieResult(movie) {
return {
title: movie.title || movie.original_title || movie.titles[0],
year: movie.year || movie.info.year,
tmdbId: movie.tmdb_id || movie.info.tmdb_id,
imdb: movie.imdb || movie.info.imdb,
imdbId: movie.imdb || movie.info.imdb,
status: movie.status || '',
quality: movie.releases && movie.releases.length ? movie.releases[0].quality : null
};
Expand Down
2 changes: 2 additions & 0 deletions src/api/radarr.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import serverConfig from '~/api/config.js';
* @property {String} slug
* @property {Number} year
* @property {String} tmdbId
* @property {String} imdbId
* @property {Array} images
* @property {String} [status]
* @property {String} [quality]
Expand Down Expand Up @@ -93,6 +94,7 @@ function mapToMediaResult(movie) {
slug: movie.titleSlug,
year: movie.year,
tmdbId: movie.tmdbId,
imdbId: movie.imdbId,
images: movie.images,
status: movie.status,
quality: quality ? quality.name : ''
Expand Down
8 changes: 4 additions & 4 deletions src/api/sickbeard.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import serverConfig from '~/api/config.js';
* @typedef {Object} MediaResult
* @property {String} title
* @property {Number} [year]
* @property {String} tvdbid
* @property {String} tvdbId
* @property {String} [status]
* @property {String} [quality]
*/
Expand Down Expand Up @@ -34,7 +34,7 @@ export async function list(title) {
return {
title: show.show_name,
year: show.year,
tvdbid: show.tvdbid,
tvdbId: show.tvdbid,
status: show.status.toLowerCase(),
quality: show.quality
};
Expand Down Expand Up @@ -63,7 +63,7 @@ export async function search(query) {
return {
title: show.name,
year: new Date(show.first_aired).getFullYear(),
tvdbid: show.tvdbid
tvdbId: show.tvdbid
};
}) : [];
}
Expand All @@ -75,5 +75,5 @@ export async function search(query) {
* @returns {Object} -- Sickbeard response object
*/
export async function add(show) {
return await sickbeard().cmd('show.addnew', {tvdbid: show.tvdbid, status: 'wanted'});
return await sickbeard().cmd('show.addnew', {tvdbid: show.tvdbId, status: 'wanted'});
}
2 changes: 2 additions & 0 deletions src/api/sonarr.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import serverConfig from '~/api/config.js';
* @property {String} slug
* @property {Number} year
* @property {String} tvdbId
* @property {String} imdbId
* @property {Array} images
* @property {String} [status]
* @property {String} [quality]
Expand Down Expand Up @@ -90,6 +91,7 @@ function mapToMediaResult(show) {
slug: show.titleSlug,
year: show.year,
tvdbId: show.tvdbId,
imdbId: show.imdbId,
images: show.images,
status: show.status,
quality: quality ? quality.name : ''
Expand Down
22 changes: 20 additions & 2 deletions src/handlers/general.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import getProvider from '~/api/getProvider.js';
import getProvider, {PROVIDER_TYPE} from '~/api/getProvider.js';

import buildCard from '~/lib/buildCard.js';
import buildReprompt from '~/lib/buildReprompt.js';
import getArtwork from '~/lib/getArtwork.js';

import {
CANCEL_RESPONSE,
Expand Down Expand Up @@ -31,7 +34,22 @@ export function handleYesIntent(req, resp) {
const [result] = promptData.searchResults;

return api.add(result).then(() => {
return Promise.resolve(resp.say(promptData.yesResponse));
if (!result) {
return null;
}

return getArtwork(result);
}).then((artwork) => {
if (artwork) {
let title = result.title;
if (promptData.providerType === PROVIDER_TYPE.MOVIES) {
title += ` (${result.year})`;
}

resp.card(buildCard(title, artwork, promptData.yesResponse));
}

return resp.say(promptData.yesResponse);
});
}

Expand Down
12 changes: 11 additions & 1 deletion src/handlers/movies.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import getProvider, {PROVIDER_TYPE} from '~/api/getProvider.js';

import buildCard from '~/lib/buildCard.js';
import buildReprompt from '~/lib/buildReprompt.js';
import getArtwork from '~/lib/getArtwork.js';
import parseDate from '~/lib/parseDate.js';

import {
Expand Down Expand Up @@ -28,6 +30,7 @@ export async function handleFindMovieIntent(req, resp) {
movies = await api.search(query);
if (movies && movies.length) {
const [topResult] = movies;

resp
.say(ADD_PROMPT(topResult.title, topResult.year))
.session('promptData', buildReprompt(movies, PROVIDER_TYPE.MOVIES))
Expand All @@ -38,7 +41,14 @@ export async function handleFindMovieIntent(req, resp) {
}

const [result] = movies;
return Promise.resolve(resp.say(ALREADY_WANTED(result.title, result.year)));
const responseText = ALREADY_WANTED(result.title, result.year);

const artwork = await getArtwork(result);
if (artwork) {
resp.card(buildCard(`${result.title} (${result.year})`, artwork, responseText));
}

return Promise.resolve(resp.say(responseText));
}

export async function handleAddMovieIntent(req, resp) {
Expand Down
13 changes: 10 additions & 3 deletions src/handlers/shows.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import getProvider, {PROVIDER_TYPE} from '~/api/getProvider.js';

import buildCard from '~/lib/buildCard.js';
import buildReprompt from '~/lib/buildReprompt.js';
import getArtwork from '~/lib/getArtwork.js';

import {
ADD_SHOW,
Expand Down Expand Up @@ -30,9 +32,14 @@ export async function handleFindShowIntent(req, resp) {
.shouldEndSession(false);
}
else {
resp
.say(ALREADY_WANTED(result.title.replace('\'s', 's')))
.send();
const responseText = ALREADY_WANTED(result.title.replace('\'s', 's'));

const artwork = await getArtwork(result);
if (artwork) {
resp.card(buildCard(result.title, artwork, responseText));
}

return resp.say(responseText);
}
}

Expand Down
8 changes: 8 additions & 0 deletions src/lib/buildCard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function buildCard(title, image, text) {
return {
type: 'Standard',
title,
text,
image
};
}
63 changes: 63 additions & 0 deletions src/lib/getArtwork.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import config from 'config';
import themoviedbclient from 'themoviedbclient';

/**
* @typedef {Object} ArtworkOptions
* @property {String} [tmdbId]
* @property {String} [tvdbId]
* @property {String} [imdbId]
*/

/**
* @typedef {Object} ArtworkImages
* @property {String} smallImageUrl
* @property {String} largeImageUrl
*/

/* Dumb workaround to make this easier to stub and test */
export const API = {
Client: themoviedbclient
};

/**
* Fetches TMDB for artwork for a MediaResult and returns a large and small image url for use in a
* skill card.
*
* @param {ArtworkOptions} options -- An object with a tmdb, tvdb, or imdb id on it. Usually a
* MediaResult from one of the API clients.
* @returns {PromiseLike<ArtworkImages>}
*/
export default async function getArtwork({tmdbId, tvdbId, imdbId}) {
if (!(tmdbId || tvdbId || imdbId)) {
throw new Error('You must provide a valid tmdbId, tvdbId, or imdbId to fetch artwork');
}

try {
const apiKey = config.get('alexa-libby.artwork.tmdbApiKey');
const tmdb = new API.Client(apiKey);
tmdb.configure({ssl: true});

let media;
if (tmdbId) {
media = await tmdb.call(`/movie/${tmdbId}`);
}
else {
const results = await tmdb.call(`/find/${tvdbId || imdbId}`, {
external_source: tvdbId ? 'tvdb_id' : 'imdb_id'
});
media = results.tv_results.length ? results.tv_results[0] : results.movie_results[0];
}

if (!media) {
return null;
}

return {
smallImageUrl: tmdb.getImageUrl(media.poster_path, 'w780'),
largeImageUrl: tmdb.getImageUrl(media.poster_path, 'w1280')
};
}
catch (e) {
return null;
}
}
10 changes: 5 additions & 5 deletions test/api/couchpotato.js
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ describe('api.couchpotato', () => {
it('should format the movie response to use standardized keys', async () => {
const movies = await couchpotato.list();
assert.deepEqual(Object.keys(movies[0]),
['title', 'year', 'tmdbId', 'imdb', 'status', 'quality']);
['title', 'year', 'tmdbId', 'imdbId', 'status', 'quality']);
});

it('should fill in the correct data in the correct fields', async () => {
Expand All @@ -496,7 +496,7 @@ describe('api.couchpotato', () => {
title: '10 Cloverfield Lane',
year: 2016,
tmdbId: 333371,
imdb: 'tt1179933',
imdbId: 'tt1179933',
status: 'done',
quality: '1080p'
});
Expand All @@ -519,7 +519,7 @@ describe('api.couchpotato', () => {
title: '10 Things I Hate About You',
year: 1999,
tmdbId: 4951,
imdb: 'tt0147800',
imdbId: 'tt0147800',
status: 'done',
quality: 'brrip'
});
Expand All @@ -534,12 +534,12 @@ describe('api.couchpotato', () => {
title: '12 Angry Men',
year: 1957,
tmdbId: 389,
imdb: 'tt0050083',
imdbId: 'tt0050083',
status: 'done',
quality: 'dvdr'
};

cpApiStub.withArgs('movie.add', {title: movie.title, identifier: movie.imdb})
cpApiStub.withArgs('movie.add', {title: movie.title, identifier: movie.imdbId})
.resolves(sampleAddMovieResponse);
});

Expand Down
10 changes: 5 additions & 5 deletions test/api/sickbeard.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ describe('api.sickbeard', () => {

it('should format the show response to use standardized keys', async () => {
const shows = await sickbeard.list();
assert.deepEqual(Object.keys(shows[0]), ['title', 'year', 'tvdbid', 'status', 'quality']);
assert.deepEqual(Object.keys(shows[0]), ['title', 'year', 'tvdbId', 'status', 'quality']);
});

it('should fill in the correct data in the correct fields', async () => {
Expand All @@ -149,7 +149,7 @@ describe('api.sickbeard', () => {
assert.deepEqual(show, {
title: 'Law & Order: Criminal Intent',
year: undefined,
tvdbid: 71489,
tvdbId: 71489,
status: 'ended',
quality: 'HD720p'
});
Expand All @@ -170,11 +170,11 @@ describe('api.sickbeard', () => {

assert.deepEqual(shows, [{
title: 'The Tonight Show with Jay Leno',
tvdbid: 70336,
tvdbId: 70336,
year: '1992'
}, {
title: 'The Jay Leno Show',
tvdbid: 113921,
tvdbId: 113921,
year: '2009'
}]);
});
Expand All @@ -186,7 +186,7 @@ describe('api.sickbeard', () => {
beforeEach(() => {
show = {
title: 'Conan (2010)',
tvdbid: 194751,
tvdbId: 194751,
year: '2011'
};

Expand Down
Loading

0 comments on commit 6f8fb5e

Please sign in to comment.