Skip to content

Commit 58ac430

Browse files
committed
feat: Add trakt.tv API example
- Add trakt.tv API example and a basic limited local image cache to avoid hot-linking trakt images.
1 parent 03229c2 commit 58ac430

File tree

10 files changed

+465
-18
lines changed

10 files changed

+465
-18
lines changed

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ STEAM_KEY=D1240DEF4D41D416FD291D0075B6ED3F
6969
STRIPE_SKEY=sk_test_BQokikJOvBiI2HlWgH4olfQ2
7070
STRIPE_PKEY=pk_test_6pRNASCoBOKtIshFeQd4XMUh
7171

72+
TRAKT_ID=trakt-client-id
73+
TRAKT_SECRET=trackt-client-secret
74+
7275
TUMBLR_KEY=FaXbGf5gkhswzDqSMYI42QCPYoHsu5MIDciAhTyYjehotQpJvM
7376
TUMBLR_SECRET=QpCTs5IMMCsCImwdvFiqyGtIZwowF5o3UXonjPoNp4HVtJAL4o
7477

README.md

Lines changed: 32 additions & 18 deletions
Large diffs are not rendered by default.

app.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ app.use('/js/lib', express.static(path.join(__dirname, 'node_modules/@popperjs/c
166166
app.use('/js/lib', express.static(path.join(__dirname, 'node_modules/bootstrap/dist/js'), { maxAge: 31557600000 }));
167167
app.use('/js/lib', express.static(path.join(__dirname, 'node_modules/jquery/dist'), { maxAge: 31557600000 }));
168168
app.use('/webfonts', express.static(path.join(__dirname, 'node_modules/@fortawesome/fontawesome-free/webfonts'), { maxAge: 31557600000 }));
169+
app.use('/image-cache', express.static(path.join(__dirname, 'tmp/image-cache'), { maxAge: 31557600000 }));
169170

170171
/**
171172
* Analytics IDs needed thru layout.pug; set as express local so we don't have to pass them with each render call
@@ -228,6 +229,7 @@ app.get('/api/google/drive', passportConfig.isAuthenticated, passportConfig.isAu
228229
app.get('/api/chart', apiController.getChart);
229230
app.get('/api/google/sheets', passportConfig.isAuthenticated, passportConfig.isAuthorized, apiController.getGoogleSheets);
230231
app.get('/api/quickbooks', passportConfig.isAuthenticated, passportConfig.isAuthorized, apiController.getQuickbooks);
232+
app.get('/api/trakt', apiController.getTrakt);
231233

232234
/**
233235
* OAuth authentication routes. (Sign in)
@@ -268,6 +270,10 @@ app.get('/auth/steam', passport.authorize('steam-openid'));
268270
app.get('/auth/steam/callback', passport.authorize('steam-openid', { failureRedirect: '/api' }), (req, res) => {
269271
res.redirect(req.session.returnTo);
270272
});
273+
app.get('/auth/trakt', passport.authorize('trakt'));
274+
app.get('/auth/trakt/callback', passport.authorize('trakt', { failureRedirect: '/login' }), (req, res) => {
275+
res.redirect(req.session.returnTo);
276+
});
271277
app.get('/auth/quickbooks', passport.authorize('quickbooks'));
272278
app.get('/auth/quickbooks/callback', passport.authorize('quickbooks', { failureRedirect: '/login' }), (req, res) => {
273279
res.redirect(req.session.returnTo);

config/passport.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,49 @@ const quickbooksStrategyConfig = new OAuth2Strategy(
721721
passport.use('quickbooks', quickbooksStrategyConfig);
722722
refresh.use('quickbooks', quickbooksStrategyConfig);
723723

724+
/**
725+
* trakt.tv API OAuth.
726+
*/
727+
const traktStrategyConfig = new OAuth2Strategy(
728+
{
729+
authorizationURL: 'https://api.trakt.tv/oauth/authorize',
730+
tokenURL: 'https://api.trakt.tv/oauth/token',
731+
clientID: process.env.TRAKT_ID,
732+
clientSecret: process.env.TRAKT_SECRET,
733+
callbackURL: `${process.env.BASE_URL}/auth/trakt/callback`,
734+
state: generateState(),
735+
passReqToCallback: true,
736+
},
737+
async (req, accessToken, refreshToken, params, profile, done) => {
738+
try {
739+
const response = await fetch('https://api.trakt.tv/users/me?extended=full', {
740+
method: 'GET',
741+
headers: {
742+
Authorization: `Bearer ${accessToken}`,
743+
'trakt-api-version': 2,
744+
'trakt-api-key': process.env.TRAKT_ID,
745+
'Content-Type': 'application/json',
746+
},
747+
});
748+
if (!response.ok) {
749+
throw new Error(`HTTP error! status: ${response.status}`);
750+
}
751+
const data = await response.json();
752+
const user = await saveOAuth2UserTokens(req, accessToken, refreshToken, params.expires_in, params.x_refresh_token_expires_in, 'trakt', {
753+
trakt: data.ids.slug,
754+
});
755+
user.profile.name = user.profile.name || data.name;
756+
user.profile.location = user.profile.location || data.location;
757+
await user.save();
758+
return done(null, user);
759+
} catch (err) {
760+
return done(err);
761+
}
762+
},
763+
);
764+
passport.use('trakt', traktStrategyConfig);
765+
refresh.use('trakt', traktStrategyConfig);
766+
724767
/**
725768
* Login Required middleware.
726769
*/

controllers/api.js

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const googledrive = require('@googleapis/drive');
1414
const googlesheets = require('@googleapis/sheets');
1515
const validator = require('validator');
1616
const { Configuration: LobConfiguration, LetterEditable, LettersApi, ZipEditable, ZipLookupsApi } = require('@lob/lob-typescript-sdk');
17+
const fs = require('fs');
1718

1819
/**
1920
* GET /api
@@ -1188,3 +1189,241 @@ exports.getGoogleSheets = (req, res) => {
11881189
},
11891190
);
11901191
};
1192+
1193+
/**
1194+
* Trakt.tv API Helpers
1195+
*/
1196+
const formatDate = (isoString) => {
1197+
if (!isoString) return '';
1198+
const date = new Date(isoString);
1199+
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
1200+
};
1201+
1202+
/* Trakt does not permit hotlinking of images, so we would need to get the image
1203+
* from them and serve it ourselves. Use an edge CDN/Caching service like Cloudflare
1204+
* or Fastly in front of your server to cache the images in production.
1205+
* This is a simple implementation of an image cache from trakt as a trusted source
1206+
* - Uses a simple in-memory cache, with a limit on the number of images stored
1207+
* - Uses a static path for the image cache, which is sufficient for this use-case
1208+
* - Uses a helper function to convert a Trakt image URL to a filename
1209+
* - Uses a helper function to fetch and cache an image, returning the static path for <img src="">
1210+
*/
1211+
1212+
/*
1213+
* Helper function and variables for file name generation and traking of cached images
1214+
*/
1215+
const traktImageCache = [];
1216+
const TRAKT_IMAGE_CACHE_LIMIT = 20;
1217+
function traktUrlToFilename(url) {
1218+
if (!url) return null;
1219+
const a = url.replace(/^https?:\/\//, '').replace(/\//g, '-');
1220+
return a;
1221+
}
1222+
1223+
/*
1224+
* Helper function to fetch and cache Trakt image
1225+
* Fetch and cache Trakt image, return the static path for <img src="">
1226+
*/
1227+
async function fetchAndCacheTraktImage(imageUrl) {
1228+
const imageCacheDir = 'tmp/image-cache';
1229+
if (!imageUrl) return null;
1230+
const filename = traktUrlToFilename(imageUrl);
1231+
if (!filename) return null;
1232+
1233+
// Check if already cached
1234+
const found = traktImageCache.find(entry => entry.url === imageUrl);
1235+
if (found) {
1236+
return `${process.env.BASE_URL}/image-cache/${found.filename}`;
1237+
}
1238+
1239+
if (!fs.existsSync(imageCacheDir)) {
1240+
fs.mkdirSync(imageCacheDir, { recursive: true }); // Ensures that parent directories are created
1241+
}
1242+
1243+
// Download and save
1244+
try {
1245+
const response = await fetch(imageUrl);
1246+
if (!response.ok) return null;
1247+
const buffer = Buffer.from(await response.arrayBuffer());
1248+
const absPath = `${imageCacheDir}/${filename}`;
1249+
try {
1250+
fs.writeFileSync(absPath, buffer);
1251+
} catch (writeErr) {
1252+
console.error('Failed to write image to disk:', absPath, writeErr);
1253+
return null;
1254+
}
1255+
1256+
// Add to cache, delete the oldest file if we have hit our cache limit
1257+
traktImageCache.push({ url: imageUrl, filename });
1258+
while (traktImageCache.length > TRAKT_IMAGE_CACHE_LIMIT) {
1259+
const removed = traktImageCache.shift();
1260+
const oldPath = `${imageCacheDir}/${removed.filename}`;
1261+
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
1262+
}
1263+
1264+
return `${process.env.BASE_URL}/image-cache/${filename}`;
1265+
} catch (err) {
1266+
console.log('Trakt image cache error:', err);
1267+
return null;
1268+
}
1269+
}
1270+
1271+
async function fetchTraktUserProfile(traktToken) {
1272+
const res = await fetch('https://api.trakt.tv/users/me?extended=full', {
1273+
method: 'GET',
1274+
headers: {
1275+
Authorization: `Bearer ${traktToken}`,
1276+
'trakt-api-version': 2,
1277+
'trakt-api-key': process.env.TRAKT_ID,
1278+
'Content-Type': 'application/json',
1279+
},
1280+
});
1281+
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
1282+
return res.json();
1283+
}
1284+
1285+
async function fetchTraktUserHistory(traktToken, limit) {
1286+
const res = await fetch(`https://api.trakt.tv/users/me/history?limit=${limit}`, {
1287+
headers: {
1288+
Authorization: `Bearer ${traktToken}`,
1289+
'trakt-api-version': 2,
1290+
'trakt-api-key': process.env.TRAKT_ID,
1291+
'Content-Type': 'application/json',
1292+
},
1293+
});
1294+
if (!res.ok) return [];
1295+
return res.json();
1296+
}
1297+
1298+
async function fetchTraktTrendingMovies(limit) {
1299+
const res = await fetch(`https://api.trakt.tv/movies/trending?limit=${limit}&extended=images`, {
1300+
headers: {
1301+
'trakt-api-version': 2,
1302+
'trakt-api-key': process.env.TRAKT_ID,
1303+
'Content-Type': 'application/json',
1304+
},
1305+
});
1306+
if (!res.ok) return [];
1307+
const trending = await res.json();
1308+
return Promise.all(
1309+
trending.map(async (item) => {
1310+
let imgUrl = null;
1311+
if (item.movie && item.movie.images) {
1312+
if (item.movie.images.fanart && Array.isArray(item.movie.images.fanart) && item.movie.images.fanart.length > 0) {
1313+
imgUrl = `https://${item.movie.images.fanart[0].replace(/^https?:\/\//, '')}`;
1314+
} else if (item.movie.images.poster && Array.isArray(item.movie.images.poster) && item.movie.images.poster.length > 0) {
1315+
imgUrl = `https://${item.movie.images.poster[0].replace(/^https?:\/\//, '')}`;
1316+
}
1317+
}
1318+
item.movie.largeImageUrl = await fetchAndCacheTraktImage(imgUrl);
1319+
return item;
1320+
}),
1321+
);
1322+
}
1323+
1324+
async function fetchMovieDetails(slug, watchers) {
1325+
const res = await fetch(`https://api.trakt.tv/movies/${slug}?extended=full,images`, {
1326+
headers: {
1327+
'trakt-api-version': 2,
1328+
'trakt-api-key': process.env.TRAKT_ID,
1329+
'Content-Type': 'application/json',
1330+
},
1331+
});
1332+
if (!res.ok) return null;
1333+
const movie = await res.json();
1334+
let imgUrl = null;
1335+
if (movie.images) {
1336+
if (movie.images.fanart && Array.isArray(movie.images.fanart) && movie.images.fanart.length > 0) {
1337+
imgUrl = `https://${movie.images.fanart[0].replace(/^https?:\/\//, '')}`;
1338+
} else if (movie.images.poster && Array.isArray(movie.images.poster) && movie.images.poster.length > 0) {
1339+
imgUrl = `https://${movie.images.poster[0].replace(/^https?:\/\//, '')}`;
1340+
}
1341+
}
1342+
movie.largeImageUrl = await fetchAndCacheTraktImage(imgUrl);
1343+
if (typeof movie.rating === 'number') {
1344+
movie.ratingFormatted = `${movie.rating.toFixed(2)} / 10`;
1345+
} else {
1346+
movie.ratingFormatted = '';
1347+
}
1348+
movie.languages = movie.languages || [];
1349+
movie.genres = movie.genres || [];
1350+
movie.certification = movie.certification || '';
1351+
movie.watchers = watchers;
1352+
// Trailer (YouTube embed)
1353+
movie.trailerEmbed = null;
1354+
if (movie.trailer && (movie.trailer.startsWith('https://youtube.com/') || movie.trailer.startsWith('http://youtu.be/'))) {
1355+
const match = movie.trailer.match(/v=([a-zA-Z0-9_-]+)/) || movie.trailer.match(/youtu\.be\/([a-zA-Z0-9_-]+)/);
1356+
if (match && match[1]) {
1357+
movie.trailerEmbed = `https://www.youtube.com/embed/${match[1]}`;
1358+
}
1359+
}
1360+
return movie;
1361+
}
1362+
1363+
/*
1364+
* GET /api/trakt
1365+
* Trakt.tv API Example.
1366+
* - Always show public trending movies, even if not logged in.
1367+
* - Show user profile/history only if user is logged in AND has linked Trakt.
1368+
*/
1369+
exports.getTrakt = async (req, res, next) => {
1370+
const limit = 10;
1371+
let authFailure = null;
1372+
let userInfo = null;
1373+
let userHistory = [];
1374+
let trending = [];
1375+
let trendingTop = null;
1376+
1377+
// Determine Trakt token if user is logged in and has linked Trakt
1378+
let traktToken = null;
1379+
if (req.user && req.user.tokens) {
1380+
const tokenObj = req.user.tokens.find((token) => token.kind === 'trakt');
1381+
if (tokenObj) {
1382+
traktToken = tokenObj.accessToken;
1383+
}
1384+
}
1385+
1386+
// Only fetch user info/history if logged in and linked Trakt
1387+
if (req.user) {
1388+
if (!traktToken) {
1389+
authFailure = 'NotTraktAuthorized';
1390+
}
1391+
} else {
1392+
authFailure = 'NotLoggedIn';
1393+
}
1394+
1395+
try {
1396+
if (traktToken) {
1397+
userInfo = await fetchTraktUserProfile(traktToken);
1398+
userHistory = await fetchTraktUserHistory(traktToken, limit);
1399+
}
1400+
trending = await fetchTraktTrendingMovies(6);
1401+
if (trending.length > 0) {
1402+
const top = trending[0];
1403+
const slug = top.movie && top.movie.ids && top.movie.ids.slug;
1404+
if (slug) {
1405+
trendingTop = await fetchMovieDetails(slug, top.watchers);
1406+
}
1407+
}
1408+
} catch (error) {
1409+
console.log('Trakt API Error:', error);
1410+
trending = [];
1411+
trendingTop = null;
1412+
}
1413+
1414+
try {
1415+
res.render('api/trakt', {
1416+
title: 'Trakt.tv API',
1417+
userInfo,
1418+
userHistory,
1419+
limit,
1420+
authFailure,
1421+
formatDate,
1422+
trending,
1423+
trendingTop,
1424+
trendingTopTrailer: trendingTop && trendingTop.trailerEmbed,
1425+
});
1426+
} catch (error) {
1427+
next(error);
1428+
}
1429+
};

models/User.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const userSchema = new mongoose.Schema(
2929
twitch: String,
3030
quickbooks: String,
3131
tumblr: String,
32+
trakt: String,
3233
tokens: Array,
3334

3435
profile: {

test/.env.test

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ STEAM_KEY=test_steam_key
5757
STRIPE_SKEY=sk_test_testkey1234567890
5858
STRIPE_PKEY=pk_test_testkey1234567890
5959

60+
TRAKT_ID=test_trakt_id
61+
TRAKT_SECRET=test_trakt_secret
62+
6063
TUMBLR_KEY=test_tumblr_key
6164
TUMBLR_SECRET=test_tumblr_secret
6265

views/account/profile.pug

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,8 @@ block content
146146
p.mb-1: a.text-danger(href='/account/unlink/tumblr') Unlink your Tumblr account
147147
else
148148
p.mb-1: a(href='/auth/tumblr') Link your Tumblr account
149+
.offset-sm-3.col-md-7.pl-2
150+
if user.trakt
151+
p.mb-1: a.text-danger(href='/account/unlink/trakt') Unlink your Trakt account
152+
else
153+
p.mb-1: a(href='/auth/trakt') Link your Trakt account

views/api/index.pug

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,9 @@ block content
125125
.card-body
126126
img(src='https://www.gstatic.com/images/icons/material/product/1x/sheets_64dp.png', height=40, style='padding: 0px 10px 0px 0px')
127127
| Google Sheets
128+
.col-md-4
129+
a(href='/api/trakt', style='color: #fff')
130+
.card.text-white.mb-3(style='background-color: rgb(0, 0, 0)')
131+
.card-body
132+
img(src='https://i.imgur.com/Adtl9qg.png', height=40, style='padding: 0px 10px 0px 0px')
133+
| trakt.tv

0 commit comments

Comments
 (0)