Files
seasonedShows/src/plex/plex.js
Kevin 628ed52012 Fix: Tests lint and src folder (#138)
* Automaticly fixable eslint issues, mostly 3 -> 2 space indentation

* fix: updated plex_userid to camelcase

* Linted and some consistency refactor on middleware

* eslint uses ecmaversion 2020 & allow empty catch rule

* Started linting source files

* Fixed eslint errors & improved a lot of error handling

* Set 2 eslint rules as warning temporarly

* Updated all import statements to be relative

* Updated mocha & nyc, resolved all lint issues in tests/

* Updated mocha & nyc. Removed production config. Updated gitignore

* Updated test commands to omit system tests, no exit code

* Updated test configuration w/ missing keys

* Chai modules defined in package.json & resolved linting errors

* Dockerfile copies development.example -> production.json. Simplified commands

* All api calls from tests use same chaiHttp implementation

Removes a list of fetch alternatives after being replaced by chaiHttp:
 - request
 - request-promise
 - supertest
 - supertest-as-promised

* Tests should use redis (mock) cache, not tmdb sqlite cache

* Disabled test asADeveloperIWantTheServerToStart

* Re-enable tests/system

* Use chaiHttp in asAUserIWantToRequestAMovie.

* Fixed redis expire & mock implmentation

* Replaced all fetch alternatives from source code and package.json

* Pass error from tmdb api back to client as errorMessage

* Updated authentication middleware to handle checks consitenctly

* Prevent assert error when checking request status, returns success 200

* Resolved merge conflicts

* Only build and publish docker container when branch master
2022-08-20 17:41:46 +02:00

231 lines
6.4 KiB
JavaScript

const convertPlexToMovie = require("./convertPlexToMovie");
const convertPlexToShow = require("./convertPlexToShow");
const convertPlexToEpisode = require("./convertPlexToEpisode");
const redisCache = require("../cache/redis");
class PlexRequestTimeoutError extends Error {
constructor() {
const message = "Timeout: Plex did not respond.";
super(message);
this.statusCode = 408;
}
}
class PlexUnexpectedError extends Error {
constructor(plexError = null) {
const message = "Unexpected plex error occured.";
super(message);
this.statusCode = 500;
this.plexError = plexError;
}
}
const sanitize = string => string.toLowerCase().replace(/[^\w]/gi, "");
const matchingTitleAndYear = (plex, tmdb) => {
let matchingTitle;
let matchingYear;
if (plex?.title && tmdb?.title) {
const plexTitle = sanitize(plex.title);
const tmdbTitle = sanitize(tmdb.title);
matchingTitle = plexTitle === tmdbTitle;
matchingTitle = matchingTitle || plexTitle.startsWith(tmdbTitle);
} else matchingTitle = false;
if (plex?.year && tmdb?.year) matchingYear = plex.year === tmdb.year;
else matchingYear = false;
return matchingTitle && matchingYear;
};
function fixedEncodeURIComponent(str) {
return encodeURIComponent(str).replace(/[!'()*]/g, c => {
return `%${c.charCodeAt(0).toString(16).toUpperCase()}`;
});
}
function matchTmdbAndPlexMedia(plex, tmdb) {
let match;
if (plex === null || tmdb === null) return false;
if (plex instanceof Array) {
const possibleMatches = plex.map(plexItem =>
matchingTitleAndYear(plexItem, tmdb)
);
match = possibleMatches.includes(true);
} else {
match = matchingTitleAndYear(plex, tmdb);
}
return match;
}
const successfullResponse = response => {
const { status, statusText } = response;
if (status !== 200) {
throw new PlexUnexpectedError(statusText);
}
if (response?.MediaContainer) return response;
return response.json();
};
function mapResults(response) {
if (response?.MediaContainer?.Hub === null) {
return [];
}
return response.MediaContainer.Hub.filter(category => category.size > 0)
.map(category => {
if (category.type === "movie") {
return category.Metadata.map(convertPlexToMovie);
}
if (category.type === "show") {
return category.Metadata.map(convertPlexToShow);
}
if (category.type === "episode") {
return category.Metadata.map(convertPlexToEpisode);
}
return null;
})
.filter(result => result !== null);
}
class Plex {
constructor(ip, port = 32400, cache = null) {
this.plexIP = ip;
this.plexPort = port;
this.cache = cache || redisCache;
this.cacheTags = {
machineInfo: "plex/mi",
search: "plex/s"
};
}
fetchMachineIdentifier() {
const cacheKey = `${this.cacheTags.machineInfo}`;
const url = `http://${this.plexIP}:${this.plexPort}/`;
const options = {
timeout: 20000,
headers: { Accept: "application/json" }
};
return new Promise((resolve, reject) =>
this.cache
.get(cacheKey)
.then(machineInfo => resolve(machineInfo?.machineIdentifier))
.catch(() => fetch(url, options))
.then(response => response.json())
.then(machineInfo =>
this.cache.set(cacheKey, machineInfo.MediaContainer, 2628000)
)
.then(machineInfo => resolve(machineInfo?.machineIdentifier))
.catch(error => {
if (error?.type === "request-timeout") {
reject(new PlexRequestTimeoutError());
}
reject(new PlexUnexpectedError());
})
);
}
async existsInPlex(tmdb) {
const plexMatch = await this.findPlexItemByTitleAndYear(
tmdb.title,
tmdb.year
);
return !!plexMatch;
}
findPlexItemByTitleAndYear(title, year) {
const query = { title, year };
return this.search(title).then(plexResults => {
const matchesInPlex = plexResults.map(plex =>
matchTmdbAndPlexMedia(plex, query)
);
const matchesIndex = matchesInPlex.findIndex(el => el === true);
return matchesInPlex !== -1 ? plexResults[matchesIndex] : null;
});
}
getDirectLinkByTitleAndYear(title, year) {
const machineIdentifierPromise = this.fetchMachineIdentifier();
const matchingObjectInPlexPromise = this.findPlexItemByTitleAndYear(
title,
year
);
return Promise.all([
machineIdentifierPromise,
matchingObjectInPlexPromise
]).then(([machineIdentifier, matchingObjectInPlex]) => {
if (
matchingObjectInPlex === false ||
matchingObjectInPlex === null ||
matchingObjectInPlex.key === null ||
machineIdentifier === null
)
return false;
const keyUriComponent = fixedEncodeURIComponent(matchingObjectInPlex.key);
return `https://app.plex.tv/desktop#!/server/${machineIdentifier}/details?key=${keyUriComponent}`;
});
}
search(query) {
const cacheKey = `${this.cacheTags.search}:${query}`;
const url = `http://${this.plexIP}:${
this.plexPort
}/hubs/search?query=${fixedEncodeURIComponent(query)}`;
const options = {
timeout: 20000,
headers: { Accept: "application/json" }
};
return new Promise((resolve, reject) =>
this.cache
.get(cacheKey)
.catch(() => fetch(url, options)) // else fetch fresh data
.then(successfullResponse)
.then(results => this.cache.set(cacheKey, results, 21600)) // 6 hours
.then(mapResults)
.then(resolve)
.catch(error => {
if (error?.type === "request-timeout") {
reject(new PlexRequestTimeoutError());
}
reject(new PlexUnexpectedError());
})
);
}
// this is not guarenteed to work, but if we see a movie or
// show has been imported, this function can be helpfull to call
// in order to try bust the cache preventing movieInfo and
// showInfo from seeing updates through existsInPlex.
bustSearchCacheWithTitle(title) {
const query = title;
const cacheKey = `${this.cacheTags.search}/${query}*`;
this.cache.del(cacheKey, (error, response) => {
// TODO improve cache key matching by lowercasing it on the backend.
// what do we actually need to check for if the key was deleted or not
// it might be an error or another response code.
console.log("Unable to delete, key might not exists");
return response === 1;
});
}
}
module.exports = Plex;