@@ -14,6 +14,7 @@ const googledrive = require('@googleapis/drive');
1414const googlesheets = require ( '@googleapis/sheets' ) ;
1515const validator = require ( 'validator' ) ;
1616const { 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 ( / ^ h t t p s ? : \/ \/ / , '' ) . 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 ( / ^ h t t p s ? : \/ \/ / , '' ) } ` ;
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 ( / ^ h t t p s ? : \/ \/ / , '' ) } ` ;
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 ( / ^ h t t p s ? : \/ \/ / , '' ) } ` ;
1338+ } else if ( movie . images . poster && Array . isArray ( movie . images . poster ) && movie . images . poster . length > 0 ) {
1339+ imgUrl = `https://${ movie . images . poster [ 0 ] . replace ( / ^ h t t p s ? : \/ \/ / , '' ) } ` ;
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 - z A - Z 0 - 9 _ - ] + ) / ) || movie . trailer . match ( / y o u t u \. b e \/ ( [ a - z A - Z 0 - 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+ } ;
0 commit comments