UNPKG

@egeria/trakt-plugin

Version:

Egeria | trakt.tv plugin

415 lines (391 loc) 15 kB
/* .--. .-'. .--. .--. .--. .--. .`-. .--. :::::.\::::::::.\::::::::.\::::::::.\::::::::.\::::::::.\::::::::.\::::::::.\ ' `--' `.-' `--' `--' `--' `-.' `--' ` Egeria - She bestows Knowledge and Wisdom Copyright (C) 2016-2019 MySidesTheyAreGone <mysidestheyaregone@protonmail.com> This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. .--. .-'. .--. .--. .--. .--. .`-. .--. :::::.\::::::::.\::::::::.\::::::::.\::::::::.\::::::::.\::::::::.\::::::::.\ ' `--' `.-' `--' `--' `--' `-.' `--' ` */ const R = require('ramda') const T = require('@egeria/tools') const moment = require('moment-timezone') moment.tz.setDefault(moment.tz.guess()) const TV = require('trakt.tv') const userAgent = 'Node.js/' + process.version + ' @egeria/trakt-plugin/v' + require('../package.json').version const hour = 3600 const minute = 60 const short = 15 * minute const long = 12 * hour const week = 24 * hour * 7 const convertTraktDate = (d) => moment(d).toDate() const rethrow = (prefix, action, e, data) => { throw T.err(prefix, action, e, data) } async function getClient (identity) { let trakt = new TV({ client_id: identity.client_id, client_secret: identity.client_secret, useragent: userAgent }) trakt.import_token({ access_token: R.defaultTo(identity.access_token, identity.token), expires: identity.expires, refresh_token: identity.refresh_token }) return { trakt, identity } } async function getIdentity (client) { return R.merge(client.identity, client.trakt.export_token()) } async function getLatestActivities (api, prefix) { let action = 'While getting the latest account activity from Trakt.tv' let activities try { activities = await api.cache.run({ key: 'trakt:system:lastActivities', ttl: short, fn: () => api.enqueue(() => api.client.trakt.sync.last_activities()) }) } catch (e) { rethrow(prefix, action, e) } return activities } async function getShowCollection (api, prefix, showId, showTitle) { let idName = api.client.identity.name let lastUpdatedRemote = moment() let collection = R.defaultTo({ shows: {}, movies: {} }, await api.cache.get(`trakt:system:collections:${idName}`)) if (!T.isMissing(R.path([showId, 'lastUpdated'], collection.shows))) { let show = collection.shows[showId] let activities = await getLatestActivities(api, prefix) lastUpdatedRemote = moment(R.path(['episodes', 'collected_at'], activities)).add(5, 'minutes') // workaround if (lastUpdatedRemote.isAfter(show.lastUpdated)) { collection.shows[showId] = null await api.cache.set(`trakt:system:collections:${idName}`, collection) } } if (T.isMissing(collection.shows[showId])) { let showRemote = await api.cache.run({ key: `trakt:system:collections:${idName}:${showId}`, ttl: week, fn: () => api.enqueue(() => api.client.trakt.shows.progress.collection({ hidden: false, specials: false, extended: 'full', id: showId })) }) // see if we need to append unaired episodes to the collection if (!R.isNil(showRemote.next_episode)) { let nextSeasonEpisodes = await api.enqueue(() => api.client.trakt.seasons.season({ id: showId, season: showRemote.next_episode.season })) let found = false for (let season of showRemote.seasons) { if (season.number !== showRemote.next_episode.season) { continue } else { found = true } let lastEpInCollection = R.reduce(R.max, -Infinity, R.pluck('number', season.episodes)) for (let episode of nextSeasonEpisodes) { if (episode.number > lastEpInCollection) { season.episodes.push(episode) } } } if (!found) { let s = { number: showRemote.next_episode.season, episodes: nextSeasonEpisodes } showRemote.seasons.push(s) } } collection.shows[showId] = showRemote collection.shows[showId].lastUpdated = lastUpdatedRemote await api.cache.set(`trakt:system:collections:${idName}`, collection) } return collection.shows[showId] } async function getMovieCollection (api, prefix) { let idName = api.client.identity.name let lastUpdatedRemote = moment() let collection = R.defaultTo({ shows: {}, movies: {} }, await api.cache.get(`trakt:system:collections:${idName}`)) if (!T.isMissing(R.path(['movies', 'lastUpdated'], collection))) { let activities = getLatestActivities(api, prefix) lastUpdatedRemote = moment(R.path(['movies', 'collected_at'], activities)) if (lastUpdatedRemote.isAfter(collection.movies.lastUpdated)) { collection.movies = null await api.cache.set(`trakt:system:collections:${idName}`, collection) } } if (T.isMissing(collection.movies.list) || T.isMissing(collection.movies.lastUpdated)) { let action = 'While listing your collection of movies' let moviesRemote try { moviesRemote = await api.enqueue(() => api.client.trakt.sync.collection.get({ type: 'movies' })) } catch (e) { rethrow(prefix, action, e) } collection.movies = { list: moviesRemote, lastUpdated: lastUpdatedRemote } await api.cache.set(`trakt:system:collections:${idName}`, collection) } return collection.movies.list } async function getListItems (api, prefix, listName) { let lists = await api.enqueue(() => api.client.trakt.users.lists.get({ username: 'me' })) let listId for (let list of lists) { if (R.toUpper(list.name) === R.toUpper(listName)) { listId = list.ids.trakt break } } if (T.isMissing(listId)) { throw new Error('The list name you set in your configuration file, "' + listName + '", doesn\'t match any list on your Trakt account. This plugin won\'t work.') } let action = 'While downloading user list ' + listName let listedItems try { listedItems = await api.enqueue(() => api.client.trakt.users.list.items.get({ username: 'me', id: listId })) } catch (e) { rethrow(prefix, action, e) } return { name: listName, id: listId, items: listedItems } } async function traktTrendingMoviesAction (api, cfg) { let prefix = 'PLUGIN traktTrendingMovies |' let trendingMovies try { trendingMovies = await api.enqueue(() => api.client.trakt.movies.trending({ limit: cfg.limit })) } catch (e) { rethrow(prefix, 'While fetching the list of trending movies from Trakt.tv', e) } for (let data of trendingMovies) { if (cfg.onlyMissing) { let collection = await getMovieCollection(api, prefix) let collectedMovieIds = R.map(R.path(['movie', 'ids', 'trakt']), collection) if (R.contains(data.movie.ids.trakt, collectedMovieIds)) { continue } } let movieInfo try { movieInfo = await api.cache.run({ key: 'trakt:movie:' + data.movie.ids.trakt, ttl: long, fn: () => api.enqueue(() => api.client.trakt.movies.summary({ id: data.movie.ids.trakt, extended: 'full' })) }) } catch (e) { rethrow(prefix, 'While fetching information about a movie from Trakt', e, { id: data.movie.ids.trakt }) } movieInfo.watchers = data.watchers movieInfo.available_translations = R.join(', ', R.defaultTo([], movieInfo.available_translations)) movieInfo.genres = R.join(', ', R.defaultTo([], movieInfo.genres)) movieInfo.key = 'trakt:movie:' + movieInfo.ids.trakt movieInfo = T.collapse('trakt:', movieInfo) movieInfo.key = movieInfo['trakt:key'] movieInfo.origin = 'traktTrendingMovies' api.announce(movieInfo) } } async function traktEpisodesAction (api, cfg) { let prefix = 'PLUGIN traktEpisodes |' let list = await getListItems(api, prefix, cfg.list) let listedShows = R.pipe( R.map(R.pipe(T.collapse(''), R.assoc('list:name', list.name), R.assoc('list:id', list.id))), R.reject(R.pipe(R.prop('show:ids:trakt'), R.isNil)) )(list.items) for (let show of listedShows) { let showCollection = await getShowCollection(api, prefix, show['show:ids:trakt'], show['show:title']) for (let season of showCollection.seasons) { if (R.isNil(season.episodes)) { season.episodes = [] } for (let episode of season.episodes) { if (cfg.onlyMissing && !T.isMissing(episode.collected_at)) { continue } episode.season = season.number episode = R.merge(show, T.collapse('episode:', episode)) let action = 'While fetching information about an episode' let parms = { extended: 'full', id: episode['show:ids:trakt'], season: episode['episode:season'], episode: episode['episode:number'] } // let hrepisode = '"' + show['show:title'] + '" S' + episode['episode:season'] + 'E' + episode['episode:number'] let extraInfo try { extraInfo = await api.cache.run({ key: `trakt:${episode['show:ids:trakt']}:${episode['episode:season']}:${episode['episode:number']}`, ttl: long, fn: () => api.enqueue(() => api.client.trakt.episodes.summary(parms)) }) } catch (e) { rethrow(prefix, action, e, parms) } extraInfo.available_translations = R.join(', ', extraInfo.available_translations) episode = R.merge(episode, T.collapse('episode:', extraInfo)) episode['episode:link'] = 'http://trakt.tv/search/trakt/' + episode['episode:ids:trakt'] episode['show:link'] = 'http://trakt.tv/shows/' + episode['show:ids:slug'] episode['episode:season:num'] = episode['episode:season'] episode['episode:number:num'] = episode['episode:number'] episode['episode:season'] = T.pad(0, 2, episode['episode:season']) episode['episode:number'] = T.pad(0, 2, episode['episode:number']) episode.key = R.join(':', ['trakt', episode['show:ids:slug'], episode['episode:season'], episode['episode:number']]) // trakt:breaking-bad:02:01 for (let prop of ['listed_at', 'episode:first_aired', 'episode:updated_at']) { if (!T.isMissing(episode[prop])) { episode[prop] = convertTraktDate(episode[prop]) } } let metadata = T.collapse('trakt:', episode) metadata.key = metadata['trakt:key'] metadata.origin = 'traktEpisodes' api.announce(metadata) } } } } async function traktMoviesAction (api, cfg) { let prefix = 'PLUGIN traktMovies |' let list = await getListItems(api, prefix, cfg.list) let listedMovies = R.pipe( R.map(R.pipe(T.collapse(''), R.assoc('list:name', list.name), R.assoc('list:id', list.id))), R.reject(R.pipe(R.prop('movie:ids:trakt'), R.isNil)) )(list.items) let collection = await getMovieCollection(api, prefix) let collectedMovieIds = R.map(R.path(['movie', 'ids', 'trakt']), collection) for (let listedMovie of listedMovies) { let id = listedMovie['movie:ids:trakt'] if (cfg.onlyMissing && R.contains(id, collectedMovieIds)) { continue } let movie try { movie = await api.cache.run({ key: 'trakt:movie:' + listedMovie['movie:ids:slug'], ttl: long, fn: () => api.enqueue(() => api.client.trakt.movies.summary({ id, extended: 'full' })) }) } catch (e) { rethrow(prefix, 'While fetching information about a movie', e, { id }) } movie.available_translations = R.join(', ', R.defaultTo([], movie.available_translations)) movie.genres = R.join(', ', R.defaultTo([], movie.genres)) movie = T.collapse('trakt:movie:', movie) movie['trakt:list:name'] = list.name movie['trakt:list:id'] = list.id movie.key = movie['trakt:key'] = 'trakt:' + movie['trakt:movie:ids:slug'] movie.origin = 'traktMovies' api.announce(movie) } } async function traktCollectAction (api, cfg, fact) { let prefix = 'PLUGIN traktCollect |' let id = fact.applyTemplate(cfg.id) let params = {} params[cfg.type] = [{ 'ids': { 'trakt': id } }] try { await api.enqueue(() => api.client.trakt.sync.collection.add(params)) } catch (e) { rethrow(prefix, 'While trying to collect ' + cfg.type + ' (' + id + ')', e, { id: cfg.id + ' => ' + id }) } await api.cache.invalidate('trakt:system:lastActivities') return fact } const limits = { upstream: 'trakt.tv', concurrency: 2, delay: 1, timeout: 120 } const plugins = { traktTrendingMovies: { type: 'input', requires: ['identity', 'schedule', 'cache'], sanity: { type: 'object', required: ['identity'], properties: { limit: { type: 'number' } } }, limits, getClient, getIdentity, act: traktTrendingMoviesAction }, traktEpisodes: { type: 'input', requires: ['identity', 'schedule', 'cache'], sanity: { type: 'object', required: ['identity', 'list'], properties: { list: { type: 'string' }, onlyMissing: { type: 'boolean' } } }, limits, getClient, getIdentity, act: traktEpisodesAction }, traktMovies: { type: 'input', requires: ['identity', 'schedule', 'cache'], sanity: { type: 'object', required: ['identity', 'list'], properties: { list: { type: 'string' }, onlyMissing: { type: 'boolean' } } }, limits, getClient, getIdentity, act: traktMoviesAction }, traktCollect: { type: 'output', requires: ['identity', 'cache'], sanity: { type: 'object', required: ['identity', 'type', 'id'], properties: { type: { type: 'string', enum: ['movies', 'episodes'] }, id: { type: 'string' } } }, limits, getClient, getIdentity, act: traktCollectAction } } module.exports = plugins