@ -0,0 +1,29 @@ | |||||
import { LibraryType, IRawLibrariesResponse } from "./schema"; | |||||
import ApiRequestManager from "./request"; | |||||
import { readFileSync } from "fs"; | |||||
/** | |||||
* An interface to the a Plex server | |||||
*/ | |||||
export default class Plex | |||||
{ | |||||
/** | |||||
* The API request manager instance | |||||
*/ | |||||
protected requestManager: ApiRequestManager; | |||||
/** | |||||
* Create a new instance of the Plex interface | |||||
*/ | |||||
public constructor(plexToken: string) { | |||||
this.requestManager = new ApiRequestManager(plexToken); | |||||
} | |||||
/** | |||||
* Fetch the IMDb ID set from the Plex movies library | |||||
*/ | |||||
public async movies(key: string|number) { | |||||
let response = await this.requestManager.get(`/library/sections/${key}/all`); | |||||
return new Set(response.match(/(?<=guid="com\.plexapp\.agents\.imdb\:\/\/)tt\d+/ig)); | |||||
} | |||||
} |
@ -0,0 +1,112 @@ | |||||
import https, { RequestOptions } from "https"; | |||||
/** | |||||
* The URL to the Plex server | |||||
*/ | |||||
const API_URL = <string>process.env["PLEX_URL"]; | |||||
/** | |||||
* A status error is used to indicate responses with non-200 status codes | |||||
*/ | |||||
export class StatusError<T = any> extends Error | |||||
{ | |||||
/** | |||||
* The resulting body of a response | |||||
*/ | |||||
public readonly response: T; | |||||
/** | |||||
* The resulting status code of a response | |||||
*/ | |||||
public readonly statusCode?: number; | |||||
/** | |||||
* Create a new error indicating non-200 status | |||||
*/ | |||||
public constructor(response: T, statusCode: number) { | |||||
super(); | |||||
Object.setPrototypeOf(this, StatusError.prototype); | |||||
this.response = response; | |||||
this.statusCode = statusCode; | |||||
} | |||||
} | |||||
/** | |||||
* A request manager with atomic/persistent request options | |||||
*/ | |||||
export default class ApiRequestManager | |||||
{ | |||||
/** | |||||
* The authorization token | |||||
*/ | |||||
private __plexToken: string; | |||||
/** | |||||
* Store additional request options | |||||
*/ | |||||
protected options: RequestOptions; | |||||
/** | |||||
* Create a new API request manager | |||||
* | |||||
* @param options Additional request options | |||||
*/ | |||||
public constructor(plexToken: string, options: RequestOptions = {}) { | |||||
this.__plexToken = plexToken; | |||||
this.options = options; | |||||
} | |||||
/** | |||||
* Perform a generic HTTPS request | |||||
* | |||||
* @param method The HTTP method | |||||
* @param url The URL to request | |||||
* @param apiKey An optional bearer token | |||||
* @param params Optional parameters | |||||
* @param body Optional body | |||||
*/ | |||||
protected request(method: string, url: string, params?: any) | |||||
{ | |||||
return new Promise<string>((resolve, reject) => { | |||||
// Create request options | |||||
let options = Object.assign({ method, headers: {} }, this.options); | |||||
// Add search parameters if necessary | |||||
let requestUrl = new URL(url); | |||||
requestUrl.searchParams.set("X-Plex-Token", this.__plexToken); | |||||
if (params) { | |||||
Object.keys(params).forEach((key) => { | |||||
if (params[key] !== undefined) { | |||||
requestUrl.searchParams.set(key, params[key]); | |||||
} | |||||
}); | |||||
} | |||||
// Create the request | |||||
let request = https.request(<any>requestUrl, options, (res) => { | |||||
let rawData: string = ""; | |||||
res.setEncoding("utf8"); | |||||
res.on("data", chunk => {rawData += chunk}); | |||||
res.on("error", reject); | |||||
res.on("end", async () => { | |||||
rawData; | |||||
if (res.statusCode == 200) { | |||||
resolve(rawData) | |||||
} else { | |||||
reject(new StatusError(rawData, <number>res.statusCode)); | |||||
} | |||||
}); | |||||
}) | |||||
.on("error", reject) | |||||
.on("timeout", () => reject("timeout")); | |||||
request.end(); | |||||
}); | |||||
} | |||||
/** | |||||
* Perform a generic GET request | |||||
*/ | |||||
public async get(path: string, params?: any) { | |||||
return await this.request("GET", `${API_URL}${path}`, params); | |||||
} | |||||
} |
@ -0,0 +1,34 @@ | |||||
/** | |||||
* The supported library types | |||||
*/ | |||||
export enum LibraryType { | |||||
Movie = "movie", | |||||
TvShow = "show" | |||||
} | |||||
/** | |||||
* Library directory informaiton | |||||
*/ | |||||
export interface IDirectory { | |||||
$: { | |||||
key : string, | |||||
type: LibraryType | |||||
} | |||||
} | |||||
/** | |||||
* The response generated from a library listing request | |||||
*/ | |||||
export interface IRawLibrariesResponse { | |||||
MediaContainer: { | |||||
Directory: IDirectory[] | |||||
} | |||||
} | |||||
/** | |||||
* The parsed library type | |||||
*/ | |||||
export interface ILibrary { | |||||
key : string, | |||||
type: LibraryType | |||||
} |
@ -0,0 +1,47 @@ | |||||
import Plex from "@lib/plex"; | |||||
import { BaseEntity, Column, Entity, In, PrimaryColumn } from "typeorm"; | |||||
@Entity() | |||||
export class PlexMovie extends BaseEntity | |||||
{ | |||||
@PrimaryColumn({ length: 27 }) | |||||
imdbId!: string; | |||||
@Column({ type: "int", nullable: true, unique: true }) | |||||
tmdbId!: number|null; | |||||
/** | |||||
* Check if a movie is on Plex given its TMDb ID | |||||
*/ | |||||
public static async isOnPlex(tmdbId: number|string) { | |||||
return await PlexMovie.count({ where: { tmdbId } }) > 0; | |||||
} | |||||
/** | |||||
* Get the set of IMDb IDs stored in the library | |||||
*/ | |||||
public static async imdbSet() { | |||||
let rows = await PlexMovie.createQueryBuilder("plex_movie") | |||||
.select("imdbId") | |||||
.getRawMany(); | |||||
return new Set(rows.map(row => <string>row.imdbId)); | |||||
} | |||||
/** | |||||
* Insert a set of IMDb IDs into the database | |||||
*/ | |||||
public static async insertImdbSet(imdbIds: Set<string>) { | |||||
await PlexMovie.createQueryBuilder() | |||||
.insert() | |||||
.into("plex_movie") | |||||
.values([...imdbIds].map(imdbId => ({ imdbId, tmdbId: null }))) | |||||
.execute(); | |||||
} | |||||
/** | |||||
* Remove the given set of IMDb IDs from the library | |||||
*/ | |||||
public static async removeImdbSet(imdbIds: Set<string>) { | |||||
await PlexMovie.delete({ imdbId: In([...imdbIds]) }); | |||||
} | |||||
} |
@ -0,0 +1,103 @@ | |||||
import Plex from "@lib/plex"; | |||||
import Application from "@server/Application"; | |||||
import { PlexMovie } from "@server/database/entities"; | |||||
import { env, secret, sleep } from "@server/util"; | |||||
import MovieSearch from "./MovieSearch"; | |||||
import Service from "./Service"; | |||||
/** | |||||
* Throttle requests when updating the movie database | |||||
*/ | |||||
const API_DATABASE_THROTTLE = 500; | |||||
/** | |||||
* A service for maintaining an internal representation of the Plex library | |||||
*/ | |||||
export default class PlexLibrary extends Service | |||||
{ | |||||
/** | |||||
* A reference to the Plex library | |||||
*/ | |||||
protected plex!: Plex; | |||||
/** | |||||
* The key for the movies library | |||||
*/ | |||||
protected moviesKey!: string; | |||||
/** | |||||
* The key for the TV shows library | |||||
*/ | |||||
protected tvKey!: string; | |||||
/** | |||||
* Indicate if the plex library is currently being updated | |||||
*/ | |||||
protected isUpdating: boolean; | |||||
/** | |||||
* Create a new Plex library service instance | |||||
*/ | |||||
public constructor(app: Application) { | |||||
super("Plex Library", app); | |||||
this.isUpdating = false; | |||||
} | |||||
/** | |||||
* Boot the Plex library service | |||||
*/ | |||||
public async boot() { | |||||
let token = await secret(env("PLEX_TOKEN_FILE")); | |||||
this.moviesKey = env("PLEX_LIBRARY_MOVIES_KEY"); | |||||
this.tvKey = env("PLEX_LIBRARY_TV_KEY"); | |||||
this.plex = new Plex(token); | |||||
} | |||||
public start() { | |||||
this.updateMovies(); | |||||
} | |||||
/** | |||||
* Shutdown the Plex library service | |||||
*/ | |||||
public async shutdown() {} | |||||
/** | |||||
* Update the movie catalog | |||||
*/ | |||||
public async updateMovies() { | |||||
if (this.isUpdating) { | |||||
return; | |||||
} | |||||
this.isUpdating = true; | |||||
let start = Date.now(); | |||||
this.log("Updating movies..."); | |||||
// Fetch the current and new sets of IMDb IDs | |||||
let [currentIds, newIds] = await Promise.all([ | |||||
PlexMovie.imdbSet(), | |||||
this.plex.movies(this.moviesKey), | |||||
]); | |||||
// Calculate the updates | |||||
let toInsert = new Set([...newIds].filter(id => !currentIds.has(id))); | |||||
let toRemove = new Set([...currentIds].filter(id => !newIds.has(id))); | |||||
// Remove ald Ids and insert new ones | |||||
await PlexMovie.removeImdbSet(toRemove); | |||||
await PlexMovie.insertImdbSet(toInsert); | |||||
// Update TMDb IDs | |||||
for (let movie of await PlexMovie.find({ where: { tmdbId: null } })) { | |||||
let result = await this.app.service<MovieSearch>("Movie Search").findImdb(movie.imdbId); | |||||
if (result !== null) { | |||||
movie.tmdbId = result.id; | |||||
await movie.save(); | |||||
} | |||||
await sleep(API_DATABASE_THROTTLE); | |||||
} | |||||
this.log("Movies updated. Took", (Date.now() - start)/1000, "seconds"); | |||||
this.isUpdating = false; | |||||
} | |||||
} |