@ -1,8 +1,8 @@ | |||
{ | |||
"watch": ["src/common", "src/server"], | |||
"watch": ["src"], | |||
"ext": "ts,json", | |||
"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": { | |||
"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 | |||
} |