@ -1,8 +1,8 @@ | |||||
{ | { | ||||
"watch": ["src/common", "src/server"], | |||||
"watch": ["src"], | |||||
"ext": "ts,json", | "ext": "ts,json", | ||||
"ignore": ["src/**/*.spec.ts"], | "ignore": ["src/**/*.spec.ts"], | ||||
"exec": "node --inspect=0.0.0.0:9229 -r tsconfig-paths/register -r ts-node/register src/server/index.ts", | |||||
"exec": "node --inspect=0.0.0.0:9229 -r tsconfig-paths/register -r ts-node/register src/index.ts", | |||||
"events": { | "events": { | ||||
"start": "clear" | "start": "clear" | ||||
} | } | ||||
@ -1,90 +0,0 @@ | |||||
import { IMovieDetails, IPaginatedResponse } from "@lib/tmdb/schema"; | |||||
/** | |||||
* Basic user information schema | |||||
*/ | |||||
export interface IUser { | |||||
id : number, | |||||
name : string, | |||||
isAdmin: boolean | |||||
} | |||||
/** | |||||
* The JWT auth token structure | |||||
*/ | |||||
export interface ITokenSchema extends IUser { | |||||
iat : number, | |||||
exp : number | |||||
} | |||||
/** | |||||
* The general API response structure | |||||
*/ | |||||
export interface IApiResponse { | |||||
status: string | |||||
} | |||||
/** | |||||
* A generic data response from the API | |||||
*/ | |||||
export interface IApiDataResponse<T> extends IApiResponse { | |||||
data: T | |||||
} | |||||
export interface IApiPaginatedResponse<T> { | |||||
page : number, | |||||
results : T[], | |||||
totalPages : number, | |||||
totalResults: number | |||||
}; | |||||
/** | |||||
* A movie listing returned from the API | |||||
*/ | |||||
export interface IApiMovie { | |||||
plexLink : string | null, | |||||
posterPath : string | null, | |||||
releaseDate: string | null, | |||||
ticketId : number | null, | |||||
title : string, | |||||
tmdbId : number | |||||
} | |||||
/** | |||||
* Movie details returned from the API | |||||
*/ | |||||
export interface IApiMovieDetails extends IApiMovie { | |||||
backdropPath: string | null, | |||||
imdbId : string | null, | |||||
overview : string | null, | |||||
runtime : number | null, | |||||
requestedBy : IUser | null | |||||
} | |||||
/** | |||||
* Movie detail data | |||||
*/ | |||||
// export interface IApiMovieDetails extends Pick<IMovieDetails, "title" | "overview" | "runtime" | | |||||
// "release_date" | "imdb_id" | "backdrop_path" | "poster_path"> | |||||
// { | |||||
// is_requested: boolean | |||||
// } | |||||
/** | |||||
* | |||||
*/ | |||||
// export type IApiMovieDetailsResponse = IApiDataResponse<IApiMovieDetails>; | |||||
// export interface IApiMovieTicket { | |||||
// id : number, | |||||
// tmdbId : number, | |||||
// imdbId : number, | |||||
// title : string, | |||||
// overview : string, | |||||
// posterPath : string, | |||||
// backdropPath: string, | |||||
// runtime : number, | |||||
// releaseDate : string, | |||||
// } | |||||
// export type IApiMovieTicketResponse = IApiDataResponse<IApiMovieTicket[]>; |
@ -1,90 +0,0 @@ | |||||
export const constraints = { | |||||
api: { | |||||
movie: { | |||||
search: { | |||||
query: { | |||||
presence: { | |||||
allowEmpty: false, | |||||
message: "The query cannot be blank" | |||||
} | |||||
}, | |||||
year: { | |||||
numericality: { | |||||
onlyInteger: true, | |||||
greaterThan: 0, | |||||
notGreaterThan: "Invalid year", | |||||
notValid: "Invalid year", | |||||
notInteger: "Invalid year" | |||||
} | |||||
} | |||||
} | |||||
} | |||||
}, | |||||
login: { | |||||
email: { | |||||
presence: { | |||||
allowEmpty: false, | |||||
message: "An email address is required" | |||||
} | |||||
}, | |||||
password: { | |||||
presence: { | |||||
allowEmpty: false, | |||||
message: "A password is required" | |||||
} | |||||
} | |||||
}, | |||||
register: { | |||||
token: { | |||||
presence: { | |||||
message: "A valid token is required to register" | |||||
}, | |||||
token: { | |||||
message: "A valid token is required to register" | |||||
} | |||||
}, | |||||
name: { | |||||
presence: { | |||||
allowEmpty: false, | |||||
message: "Your name is required" | |||||
}, | |||||
length: { | |||||
maximum: 50, | |||||
tooLong: "Your name cannot exceed 50 characters" | |||||
} | |||||
}, | |||||
email: { | |||||
presence: { | |||||
allowEmpty: false, | |||||
message: "Your email is required" | |||||
}, | |||||
length: { | |||||
maximum: 255, | |||||
tooLong: "An email address cannot exceed 255 characters" | |||||
}, | |||||
email: { | |||||
message: "A valid email address is required" | |||||
} | |||||
}, | |||||
password: { | |||||
presence: { | |||||
allowEmpty: false, | |||||
message: "A password is required" | |||||
}, | |||||
length: { | |||||
minimum: 8, | |||||
tooShort: "Password should be at least 8 characters" | |||||
} | |||||
}, | |||||
retypePassword: { | |||||
presence: { | |||||
allowEmpty: false, | |||||
message: "Re-type your password to confirm it" | |||||
}, | |||||
equality: { | |||||
attribute: "password", | |||||
message: "Passwords must match" | |||||
} | |||||
} | |||||
} | |||||
}; |
@ -1,48 +0,0 @@ | |||||
import { parseStringPromise } from "xml2js"; | |||||
import { readFileSync } from "fs"; | |||||
import ApiRequestManager from "./request"; | |||||
import { ILibraryContents } from "./schema"; | |||||
/** | |||||
* Map a media ID such as an IMDb ID to a Plex movie key | |||||
*/ | |||||
interface IMediaMap { | |||||
[id: string]: number | |||||
} | |||||
/** | |||||
* 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`); | |||||
let library = <ILibraryContents>await parseStringPromise(response); | |||||
let movies: IMediaMap = {}; | |||||
for (let video of library.MediaContainer.Video) { | |||||
let imdbMatch = video.$.guid.match(/(?<=imdb\:\/\/)tt\d+/i); | |||||
if (imdbMatch === null) { | |||||
continue; | |||||
} | |||||
let imdbId = imdbMatch[0]; | |||||
let plexKey = parseInt(video.$.ratingKey); | |||||
movies[imdbId] = plexKey; | |||||
} | |||||
return movies; | |||||
} | |||||
} |
@ -1,112 +0,0 @@ | |||||
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); | |||||
} | |||||
} |
@ -1,12 +0,0 @@ | |||||
export interface IVideo { | |||||
$: { | |||||
ratingKey: string, | |||||
guid: string | |||||
} | |||||
} | |||||
export interface ILibraryContents { | |||||
MediaContainer: { | |||||
Video: IVideo[] | |||||
} | |||||
} |
@ -1,38 +0,0 @@ | |||||
import ApiRequestManager from "./request" | |||||
import * as Schema from "./schema"; | |||||
export enum ExternalSource { | |||||
Facebook = "facebook_id", | |||||
Freebase = "freebase_id", | |||||
FreebaseM = "freebase_mid", | |||||
Imdb = "imdb_id", | |||||
Instagram = "instagram_id", | |||||
Tvdb = "tvdb_id", | |||||
TvRage = "tvrage_id", | |||||
Twitter = "twitter_id" | |||||
} | |||||
export default class TheMovieDb | |||||
{ | |||||
protected requestManager!: ApiRequestManager; | |||||
public constructor(apiKey: string) { | |||||
this.requestManager = new ApiRequestManager(apiKey); | |||||
} | |||||
public async configuration() { | |||||
return await this.requestManager.get("/configuration"); | |||||
} | |||||
public async searchMovie(query: string, year?: number, page?: number) { | |||||
return await this.requestManager.get<Schema.IPaginatedResponse<Schema.IMovieSearchResult>>("/search/movie", { query, year }); | |||||
} | |||||
public async movie(id: number) { | |||||
return await this.requestManager.get<Schema.IMovieDetails>(`/movie/${id}`); | |||||
} | |||||
public async findMovie(id: string, externalSource: ExternalSource) { | |||||
return await this.requestManager.get<Schema.IFindResult>(`/find/${id}`, { external_source: externalSource }); | |||||
} | |||||
} |
@ -1,126 +0,0 @@ | |||||
import https, { RequestOptions } from "https"; | |||||
/** | |||||
* The API URL | |||||
*/ | |||||
const API_URL = "https://api.themoviedb.org/3"; | |||||
/** | |||||
* 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 | |||||
{ | |||||
private __api_key: string; | |||||
/** | |||||
* Store additional request options | |||||
*/ | |||||
protected options: RequestOptions; | |||||
/** | |||||
* Create a new API request manager | |||||
* | |||||
* @param options Additional request options | |||||
*/ | |||||
public constructor(apiKey: string, options: RequestOptions = {}) { | |||||
this.__api_key = apiKey; | |||||
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<T>(method: string, url: string, params?: any, body?: string) | |||||
{ | |||||
return new Promise<T>((resolve, reject) => { | |||||
// Create request options | |||||
let options = Object.assign({ method, headers: {} }, this.options); | |||||
if (body) { | |||||
options.headers["Content-Type"] = "application/json"; | |||||
options.headers["Content-Length"] = body.length; | |||||
} | |||||
// Add search parameters if necessary | |||||
let requestUrl = new URL(url); | |||||
requestUrl.searchParams.set("api_key", this.__api_key); | |||||
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", () => { | |||||
let response: T = JSON.parse(rawData); | |||||
if (res.statusCode == 200) { | |||||
resolve(response) | |||||
} else { | |||||
reject(new StatusError(response, <number>res.statusCode)); | |||||
} | |||||
}); | |||||
}) | |||||
.on("error", reject) | |||||
.on("timeout", () => reject("timeout")); | |||||
if (body) { | |||||
request.write(body); | |||||
} | |||||
request.end(); | |||||
}); | |||||
} | |||||
/** | |||||
* Perform a generic GET request | |||||
*/ | |||||
public async get<T = any>(path: string, params?: any) { | |||||
return await this.request<T>("GET", `${API_URL}${path}`, params); | |||||
} | |||||
/** | |||||
* Perform a generic POST request | |||||
*/ | |||||
public async post<T = any>(path: string, params?: any, body?: any) { | |||||
if (body !== undefined) { | |||||
body = JSON.stringify(body); | |||||
} | |||||
return await this.request<T>("POST", `${API_URL}${path}`, params, body); | |||||
} | |||||
} |
@ -1,89 +0,0 @@ | |||||
export enum Status { | |||||
Rumored = "Rumored", | |||||
Planned = "Planned", | |||||
InProduction = "InProduction", | |||||
PostProduction = "PostProduction", | |||||
Released = "Released", | |||||
Canceled = "Canceled" | |||||
} | |||||
export interface IGenre { | |||||
id : number, | |||||
name: string | |||||
} | |||||
export interface ILanguage { | |||||
iso_639_1: string, | |||||
name: string | |||||
} | |||||
export interface IFindResult { | |||||
movie_results : IMovieSearchResult[], | |||||
person_results : unknown, | |||||
tv_results : unknown, | |||||
tv_episode_results: unknown, | |||||
tv_season_results : unknown | |||||
} | |||||
export interface IMovieSearchResult { | |||||
adult : boolean, | |||||
backdrop_path : string | null, | |||||
genre_ids : number[], | |||||
id : number, | |||||
original_string: string, | |||||
original_title : string, | |||||
overview : string, | |||||
popularity : number, | |||||
poster_path : string | null, | |||||
release_date : string, | |||||
title : string, | |||||
video : boolean, | |||||
vote_average : number | |||||
vote_count : number, | |||||
} | |||||
export interface IMovieDetails { | |||||
adult : boolean, | |||||
backdrop_path : string | null, | |||||
belongs_to_collection: any, | |||||
budget : number, | |||||
genres : IGenre[] | |||||
imdb_id : string | null, | |||||
original_language : string, | |||||
original_title : string, | |||||
overview : string | null, | |||||
popularity : number, | |||||
poster_path : string | null, | |||||
production_companies : IProductionCompany[], | |||||
production_countries : IProductionCountry[], | |||||
release_date : string, | |||||
revenue : number, | |||||
runtime : number | null, | |||||
spoken_languages : ILanguage[], | |||||
status : Status, | |||||
tagline : string | null, | |||||
title : string, | |||||
video : boolean, | |||||
vote_average : number, | |||||
vote_count : number | |||||
} | |||||
export interface IProductionCompany { | |||||
name : string, | |||||
id : number, | |||||
logo_path : string | null, | |||||
origin_country: string | |||||
} | |||||
export interface IProductionCountry { | |||||
release_date: string, | |||||
revenue : number, | |||||
runtime : number | null | |||||
} | |||||
export interface IPaginatedResponse<T> { | |||||
page : number, | |||||
results : T[], | |||||
total_results: number, | |||||
total_pages : number | |||||
} |
@ -1,176 +0,0 @@ | |||||
import TheMovieDb, { ExternalSource } from "@lib/tmdb"; | |||||
import { env, plexMediaUrl, secret } from "@autoplex/utils"; | |||||
import { request } from "https"; | |||||
import { InternalService } from "@autoplex/microservice"; | |||||
import TvDb from "./TvDb"; | |||||
import { IApiMovie, IApiMovieDetails, IApiPaginatedResponse } from "@common/api_schema"; | |||||
import { MovieTicket, PlexMovie } from "@autoplex/database"; | |||||
import { IMovieSearchResult } from "@lib/tmdb/schema"; | |||||
import Application from "@server/Application"; | |||||
const CACHE_CLEAR_INTERVAL = 1000*60; // 60 seconds | |||||
export default class MovieSearch extends InternalService<Application> | |||||
{ | |||||
/** | |||||
* A reference to The Movie DB API | |||||
*/ | |||||
protected tmdb!: TheMovieDb; | |||||
/** | |||||
* The instance of TVDB | |||||
*/ | |||||
protected tvdb!: TvDb; | |||||
/** | |||||
* Hold a cache of recently fetched movies to speed up request times | |||||
*/ | |||||
protected movieCache: { [tmdbId: number]: { timestamp: number, movie: IApiMovieDetails } } = {}; | |||||
/** | |||||
* Hold the clear cache interval reference | |||||
*/ | |||||
private __clearCacheInterval: NodeJS.Timeout|null = null; | |||||
/** | |||||
* The name of the service | |||||
*/ | |||||
public readonly NAME = "Movie Search"; | |||||
/** | |||||
* Start the service | |||||
*/ | |||||
public start() { | |||||
this.tvdb = this.app.service<TvDb>("TVDB"); | |||||
} | |||||
/** | |||||
* Boot the service | |||||
*/ | |||||
public async boot() { | |||||
let apiKey = await secret(env("TMDB_KEY_FILE")); | |||||
this.tmdb = new TheMovieDb(apiKey); | |||||
} | |||||
/** | |||||
* Store an IMDb ID in cache | |||||
*/ | |||||
protected cacheMovie(tmdbId: number, movie: IApiMovieDetails) { | |||||
this.movieCache[tmdbId] = { movie, timestamp: Date.now() + CACHE_CLEAR_INTERVAL }; | |||||
if (this.__clearCacheInterval === null) { | |||||
this.__clearCacheInterval = setInterval(() => this.cleanMovieCache(), CACHE_CLEAR_INTERVAL); | |||||
} | |||||
return this.movieCache[tmdbId]; | |||||
} | |||||
/** | |||||
* Clean the IMDb cache | |||||
*/ | |||||
protected cleanMovieCache() { | |||||
let now = Date.now(); | |||||
let remaining = 0; | |||||
for (let key in this.movieCache) { | |||||
if (now > this.movieCache[key].timestamp) { | |||||
delete this.movieCache[key]; | |||||
} else { | |||||
remaining++; | |||||
} | |||||
} | |||||
if (remaining == 0) { | |||||
clearInterval(<NodeJS.Timeout>this.__clearCacheInterval); | |||||
this.__clearCacheInterval = null; | |||||
} | |||||
} | |||||
// Interface ----------------------------------------------------------------------------------- | |||||
/** | |||||
* Verify the IMDb ID exists | |||||
*/ | |||||
public verifyImdbId(id: string) { | |||||
return new Promise<boolean>((resolve, reject) => { | |||||
let req = request({ method: "HEAD", host: "www.imdb.com", path: `/title/${id}/` }, (response) => { | |||||
response.resume(); | |||||
if (response.statusCode == undefined) { | |||||
reject(); | |||||
return; | |||||
} | |||||
resolve(response.statusCode === 200); | |||||
response.destroy(); | |||||
}); | |||||
req.end(); | |||||
}); | |||||
} | |||||
/** | |||||
* Get the details of a movie | |||||
*/ | |||||
public async details(id: number) { | |||||
if (id in this.movieCache) { | |||||
if (this.movieCache[id].movie.ticketId == null) { | |||||
let ticket = await MovieTicket.findOne({ where: { tmdbId: id, isCanceled: false }, relations: ["user"] }); | |||||
this.movieCache[id].movie.ticketId = ticket?.id ?? null; | |||||
this.movieCache[id].movie.requestedBy = ticket ? { | |||||
id : ticket.user.id, | |||||
isAdmin: ticket.user.isAdmin, | |||||
name : ticket.user.name | |||||
} : null; | |||||
} | |||||
return this.movieCache[id].movie; | |||||
} | |||||
let fetchMovieRequest = this.tmdb.movie(id); | |||||
let ticket = await MovieTicket.findOne({ where: { tmdbId: id, isCanceled: false }, relations: ["user"] }); | |||||
let movie = await fetchMovieRequest; | |||||
let plexKey = await PlexMovie.findPlexKey(id); | |||||
let result: IApiMovieDetails = { | |||||
tmdbId : id, | |||||
backdropPath: movie.backdrop_path, | |||||
imdbId : movie.imdb_id, | |||||
overview : movie.overview, | |||||
plexLink : plexKey !== null ? plexMediaUrl(plexKey) : null, | |||||
posterPath : movie.poster_path, | |||||
releaseDate : movie.release_date, | |||||
runtime : movie.runtime, | |||||
title : movie.title, | |||||
ticketId : ticket?.id ?? null, | |||||
requestedBy : (ticket ? { | |||||
id : ticket.user.id, | |||||
isAdmin: ticket.user.isAdmin, | |||||
name : ticket.user.name | |||||
} : null) | |||||
}; | |||||
return this.cacheMovie(id, result).movie; | |||||
} | |||||
/** | |||||
* Find a movie by its IMDb ID | |||||
*/ | |||||
public async findImdb(imdbId: string): Promise<IMovieSearchResult|null> { | |||||
return (await this.tmdb.findMovie(imdbId, ExternalSource.Imdb)).movie_results[0] ?? null; | |||||
} | |||||
/** | |||||
* Search for a movie | |||||
*/ | |||||
public async search(query: string, year?: number) { | |||||
let movieFetchRequest = this.tmdb.searchMovie(query, year); | |||||
let activeTickets = await MovieTicket.activeTicketMap(); | |||||
let results = await movieFetchRequest; | |||||
let plexKeys = await Promise.all(results.results.map( | |||||
movie => PlexMovie.findPlexKey(movie.id) | |||||
)); | |||||
return <IApiPaginatedResponse<IApiMovie>>{ | |||||
page: results.page, | |||||
results: results.results.map((movie, index) => <IApiMovie>{ | |||||
plexLink : plexKeys[index] !== null ? plexMediaUrl(<number>plexKeys[index]) : null, | |||||
posterPath : movie.poster_path, | |||||
releaseDate: movie.release_date, | |||||
ticketId : activeTickets[movie.id] ?? null, | |||||
title : movie.title, | |||||
tmdbId : movie.id | |||||
}), | |||||
totalPages: results.total_pages, | |||||
totalResults: results.total_results | |||||
}; | |||||
} | |||||
} |
@ -1,64 +0,0 @@ | |||||
import Plex from "@lib/plex"; | |||||
import { PlexMovie } from "@autoplex/database"; | |||||
import { env, secret, sleep } from "@autoplex/utils"; | |||||
import MovieSearch from "./MovieSearch"; | |||||
import { InternalService } from "@autoplex/microservice"; | |||||
import Application from "@server/Application"; | |||||
/** | |||||
* 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 InternalService<Application> | |||||
{ | |||||
/** | |||||
* A reference to the Plex library | |||||
*/ | |||||
protected plex!: Plex; | |||||
/** | |||||
* The key for the movies library | |||||
*/ | |||||
protected readonly KEY_MOVIES: string = env("PLEX_LIBRARY_MOVIES_KEY"); | |||||
/** | |||||
* The key for the TV shows library | |||||
*/ | |||||
protected readonly KEY_TV: string = env("PLEX_LIBRARY_TV_KEY"); | |||||
/** | |||||
* Indicate if the plex library is currently being updated | |||||
*/ | |||||
protected isUpdating: boolean = false; | |||||
/** | |||||
* The service name | |||||
*/ | |||||
public readonly NAME = "Plex Library"; | |||||
/** | |||||
* Boot the Plex library service | |||||
*/ | |||||
public async boot() { | |||||
let token = await secret(env("PLEX_TOKEN_FILE")); | |||||
this.plex = new Plex(token); | |||||
} | |||||
/** | |||||
* Update the movies after boot | |||||
*/ | |||||
public start() { | |||||
this.updateMovies(); | |||||
} | |||||
/** | |||||
* Update the movie catalog | |||||
*/ | |||||
public async updateMovies() { | |||||
} | |||||
} |
@ -1,58 +0,0 @@ | |||||
import TVDB from "tvdb-v4"; | |||||
import { InternalService } from "@autoplex/microservice"; | |||||
import { env, secret } from "@autoplex/utils"; | |||||
import Application from "@server/Application"; | |||||
/** | |||||
* The token refresh period in milliseconds | |||||
*/ | |||||
const TOKEN_REFRESH_PERIOD = 1000*60*parseInt(env("TVDB_REFRESH_PERIOD")); | |||||
export default class TvDb extends InternalService<Application> | |||||
{ | |||||
/** | |||||
* The active TVDB instance | |||||
*/ | |||||
protected tvdb!: TVDB; | |||||
/** | |||||
* Store the next timestamp when a token refresh is needed | |||||
*/ | |||||
protected nextTokenRefreshTimestamp: number = 0; | |||||
/** | |||||
* The service name | |||||
*/ | |||||
public readonly NAME = "TVDB"; | |||||
/** | |||||
* Boot the service | |||||
*/ | |||||
public async boot() { | |||||
let apiKey = await secret(env("TVDB_KEY_FILE")); | |||||
this.tvdb = new TVDB(apiKey); | |||||
await this.refreshLogin(); | |||||
} | |||||
/** | |||||
* Refresh the login token if necessary | |||||
*/ | |||||
protected async refreshLogin() { | |||||
if (Date.now() < this.nextTokenRefreshTimestamp) { | |||||
return; | |||||
} | |||||
this.log("Refreshing login token..."); | |||||
let pin = await secret(env("TVDB_PIN_FILE")); | |||||
let timestamp = Date.now() + TOKEN_REFRESH_PERIOD; // Save the time before the request | |||||
await this.tvdb.login(pin); | |||||
this.nextTokenRefreshTimestamp = timestamp | |||||
} | |||||
/** | |||||
* Search for a movie | |||||
*/ | |||||
public async searchMovie(query: string, year?: number) { | |||||
await this.refreshLogin(); | |||||
return await this.tvdb.search(query, "movie", year); | |||||
} | |||||
} |
@ -1,16 +0,0 @@ | |||||
export { IpcClient } from "@autoplex-api/seeker"; | |||||
export { DatabaseService } from "@autoplex/database"; | |||||
import DiscordBot from "./DiscordBot"; | |||||
import MovieSearch from "./MovieSearch"; | |||||
import PlexLibrary from "./PlexLibrary"; | |||||
import TvDb from "./TvDb"; | |||||
import WebServer from "./WebServer/WebServer"; | |||||
export { | |||||
// DiscordBot, | |||||
MovieSearch, | |||||
PlexLibrary, | |||||
// TorrentIpcClient, | |||||
TvDb, | |||||
WebServer | |||||
} |
@ -0,0 +1,86 @@ | |||||
import { MovieTicket } from "@autoplex/database"; | |||||
import { IpcClient as PlexIpc } from "@autoplex-api/plex"; | |||||
import { IMovie, IMovieDetails, IPaginatedResponse } from "@autoplex-api/request"; | |||||
import { IpcClient as SearchIpc } from "@autoplex-api/search"; | |||||
import { InternalService } from "@autoplex/microservice"; | |||||
import Application from "../Application"; | |||||
export default class MovieSearch extends InternalService<Application> | |||||
{ | |||||
/** | |||||
* The name of the service | |||||
*/ | |||||
public readonly NAME = "Movie Search"; | |||||
/** | |||||
* The Plex IPC interface | |||||
*/ | |||||
protected plex!: PlexIpc; | |||||
/** | |||||
* A reference to The Movie DB API | |||||
*/ | |||||
protected searchIpc!: SearchIpc; | |||||
/** | |||||
* Start the service | |||||
*/ | |||||
public start() { | |||||
this.plex = this.app.service<PlexIpc>("Plex"); | |||||
this.searchIpc = this.app.service<SearchIpc>("Search"); | |||||
} | |||||
// Interface ----------------------------------------------------------------------------------- | |||||
/** | |||||
* Get the details of a movie | |||||
*/ | |||||
public async details(tmdbId: number) { | |||||
let [ movie, plexLinks, ticket ] = await Promise.all([ | |||||
this.searchIpc.movieDetails(tmdbId), | |||||
this.plex.movieLinks([tmdbId]), | |||||
MovieTicket.findOne({ | |||||
where: { tmdbId: tmdbId, isCanceled: false }, | |||||
relations: ["user"] | |||||
}) | |||||
]); | |||||
return <IMovieDetails>Object.assign(movie, { | |||||
plexLink: plexLinks[tmdbId], | |||||
ticketId : ticket?.id ?? null, | |||||
requestedBy : (ticket ? { | |||||
id : ticket.user.id, | |||||
isAdmin: ticket.user.isAdmin, | |||||
name : ticket.user.name | |||||
} : null) | |||||
}); | |||||
} | |||||
/** | |||||
* Find a movie by its IMDb ID | |||||
*/ | |||||
public async findImdb(imdbId: string) { | |||||
return await this.searchIpc.findMovieFromImdb(imdbId); | |||||
} | |||||
/** | |||||
* Search for a movie | |||||
*/ | |||||
public async search(query: string, year?: number) { | |||||
this.log("Searching for John Wick..."); | |||||
let [ movies, ticketMap ] = await Promise.all([ | |||||
this.searchIpc.searchMovie(query, year), | |||||
MovieTicket.activeTicketMap() | |||||
]); | |||||
let plexLinks = await this.plex.movieLinks(movies.results.map(movie => movie.tmdbId)); | |||||
return <IPaginatedResponse<IMovie>>{ | |||||
page: movies.page, | |||||
results: movies.results.map(movie => <IMovie>{ | |||||
...movie, | |||||
plexLink: plexLinks[movie.tmdbId], | |||||
ticketId : ticketMap[movie.tmdbId] ?? null | |||||
}), | |||||
totalPages: movies.totalPages, | |||||
totalResults: movies.totalPages | |||||
}; | |||||
} | |||||
} |
@ -0,0 +1,13 @@ | |||||
export { IpcClient as PlexIpc } from "@autoplex-api/plex"; | |||||
export { IpcClient as SeekerIpc } from "@autoplex-api/seeker"; | |||||
export { IpcClient as SearchIpc } from "@autoplex-api/search"; | |||||
export { DatabaseService } from "@autoplex/database"; | |||||
import DiscordBot from "./DiscordBot"; | |||||
import MovieSearch from "./MovieSearch"; | |||||
import WebServer from "./WebServer/WebServer"; | |||||
export { | |||||
// DiscordBot, | |||||
MovieSearch, | |||||
WebServer | |||||
} |