@egeria/trakt-plugin
Version:
Egeria | trakt.tv plugin
415 lines (391 loc) • 15 kB
JavaScript
/*
.--. .-'. .--. .--. .--. .--. .`-. .--.
:::::.\::::::::.\::::::::.\::::::::.\::::::::.\::::::::.\::::::::.\::::::::.\
' `--' `.-' `--' `--' `--' `-.' `--' `
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