diff --git a/.env.example b/.env.example index e83c955..c30dcaf 100644 --- a/.env.example +++ b/.env.example @@ -42,10 +42,12 @@ WEBSERVER_PORT = 3200 # The URL to the Plex server PLEX_URL = https://plex.dlii.tech +# The hash of the Plex server +PLEX_SERVER_ID = + # The X-Plex-Token for the Plex PLEX_TOKEN_FILE = /run/secrets/plex_token - # Library Keys PLEX_LIBRARY_MOVIES_KEY = PLEX_LIBRARY_TV_KEY = diff --git a/package.json b/package.json index bcee839..ede7ba1 100644 --- a/package.json +++ b/package.json @@ -36,12 +36,14 @@ "vue-router": "^4.0.6", "vuedraggable": "^4.0.1", "vuex": "^4.0.0", - "websocket": "^1.0.33" + "websocket": "^1.0.33", + "xml2js": "^0.4.23" }, "devDependencies": { "@types/bcrypt": "^3.0.1", "@types/jsonwebtoken": "^8.5.1", "@types/node-ipc": "^9.1.3", + "@types/xml2js": "^0.4.8", "@vitejs/plugin-vue": "^1.2.1", "@vue/compiler-sfc": "^3.0.5", "@zerollup/ts-transform-paths": "^1.7.18", diff --git a/src/app/assets/plex_logo.svg b/src/app/assets/plex_logo.svg new file mode 100644 index 0000000..b499811 --- /dev/null +++ b/src/app/assets/plex_logo.svg @@ -0,0 +1 @@ +plex-logo diff --git a/src/app/components/modals/MovieModal.vue b/src/app/components/modals/MovieModal.vue index 866315f..390f2ea 100644 --- a/src/app/components/modals/MovieModal.vue +++ b/src/app/components/modals/MovieModal.vue @@ -24,9 +24,11 @@

{{movie?.overview}}

@@ -39,9 +41,11 @@

{{ movie.overview }}

- - +
diff --git a/src/common/api_schema.ts b/src/common/api_schema.ts index 5d5ced1..3598772 100644 --- a/src/common/api_schema.ts +++ b/src/common/api_schema.ts @@ -42,7 +42,7 @@ export interface IApiPaginatedResponse { * A movie listing returned from the API */ export interface IApiMovie { - isOnPlex : boolean, + plexLink : string | null, posterPath : string | null, releaseDate: string | null, ticketId : number | null, diff --git a/src/lib/plex/index.ts b/src/lib/plex/index.ts index 3cc9016..97eefb9 100644 --- a/src/lib/plex/index.ts +++ b/src/lib/plex/index.ts @@ -1,6 +1,14 @@ -import { LibraryType, IRawLibrariesResponse } from "./schema"; -import ApiRequestManager from "./request"; +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 @@ -24,6 +32,17 @@ export default class Plex */ 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)); + let library = 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; } } diff --git a/src/lib/plex/schema.ts b/src/lib/plex/schema.ts index 934c95e..26a41ae 100644 --- a/src/lib/plex/schema.ts +++ b/src/lib/plex/schema.ts @@ -1,34 +1,12 @@ -/** - * The supported library types - */ -export enum LibraryType { - Movie = "movie", - TvShow = "show" -} - -/** - * Library directory informaiton - */ -export interface IDirectory { +export interface IVideo { $: { - key : string, - type: LibraryType + ratingKey: string, + guid: string } } -/** - * The response generated from a library listing request - */ -export interface IRawLibrariesResponse { +export interface ILibraryContents { MediaContainer: { - Directory: IDirectory[] + Video: IVideo[] } } - -/** - * The parsed library type - */ -export interface ILibrary { - key : string, - type: LibraryType -} diff --git a/src/server/database/entities/PlexMovie.ts b/src/server/database/entities/PlexMovie.ts index 52c1bd5..28b924e 100644 --- a/src/server/database/entities/PlexMovie.ts +++ b/src/server/database/entities/PlexMovie.ts @@ -10,11 +10,14 @@ export class PlexMovie extends BaseEntity @Column({ type: "int", nullable: true, unique: true }) tmdbId!: number|null; + @Column() + plexKey!: number; + /** * 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; + public static async findPlexKey(tmdbId: number|string) { + return (await PlexMovie.findOne({ where: { tmdbId } }))?.plexKey ?? null; } /** @@ -30,11 +33,11 @@ export class PlexMovie extends BaseEntity /** * Insert a set of IMDb IDs into the database */ - public static async insertImdbSet(imdbIds: Set) { + public static async insertMovies(movies: { imdbId: string, plexKey: number }[]) { await PlexMovie.createQueryBuilder() .insert() .into("plex_movie") - .values([...imdbIds].map(imdbId => ({ imdbId, tmdbId: null }))) + .values([...movies].map(({imdbId, plexKey}) => ({ imdbId, tmdbId: null, plexKey }))) .execute(); } diff --git a/src/server/database/entities/User.ts b/src/server/database/entities/User.ts index 1910bf1..8a5007d 100644 --- a/src/server/database/entities/User.ts +++ b/src/server/database/entities/User.ts @@ -102,7 +102,7 @@ export class User extends BaseEntity relations: ["info"] }); return tickets.map(ticket => ({ - isOnPlex : false, + plexLink : null, posterPath : ticket.info?.posterPath, releaseDate: ticket.info?.releaseDate, ticketId : ticket.id, diff --git a/src/server/services/MovieSearch.ts b/src/server/services/MovieSearch.ts index ebf7a4e..b494c4f 100644 --- a/src/server/services/MovieSearch.ts +++ b/src/server/services/MovieSearch.ts @@ -1,6 +1,6 @@ import Application from "@server/Application"; import TheMovieDb, { ExternalSource } from "@lib/tmdb"; -import { env, secret } from "@server/util"; +import { env, plexMediaUrl, secret } from "@server/util"; import { request } from "https"; import Service from "./Service"; import TvDb from "./TvDb"; @@ -129,17 +129,18 @@ export default class MovieSearch extends Service 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, - isOnPlex : await PlexMovie.isOnPlex(id), requestedBy : (ticket ? { id : ticket.user.id, isAdmin: ticket.user.isAdmin, @@ -163,13 +164,13 @@ export default class MovieSearch extends Service let movieFetchRequest = this.tmdb.searchMovie(query, year); let activeTickets = await MovieTicket.activeTicketMap(); let results = await movieFetchRequest; - let isOnPlex = await Promise.all(results.results.map( - movie => PlexMovie.isOnPlex(movie.id) + let plexKeys = await Promise.all(results.results.map( + movie => PlexMovie.findPlexKey(movie.id) )); return >{ page: results.page, results: results.results.map((movie, index) => { - isOnPlex : isOnPlex[index], + plexLink : plexKeys[index] !== null ? plexMediaUrl(plexKeys[index]) : null, posterPath : movie.poster_path, releaseDate: movie.release_date, ticketId : activeTickets[movie.id] ?? null, diff --git a/src/server/services/PlexLibrary.ts b/src/server/services/PlexLibrary.ts index aa202a8..6301fb9 100644 --- a/src/server/services/PlexLibrary.ts +++ b/src/server/services/PlexLibrary.ts @@ -80,12 +80,12 @@ export default class PlexLibrary extends Service ]); // Calculate the updates - let toInsert = new Set([...newIds].filter(id => !currentIds.has(id))); - let toRemove = new Set([...currentIds].filter(id => !newIds.has(id))); + let toInsert = new Set(Object.keys(newIds).filter(id => !currentIds.has(id))); + let toRemove = new Set([...currentIds].filter(id => !(id in newIds))); // Remove ald Ids and insert new ones await PlexMovie.removeImdbSet(toRemove); - await PlexMovie.insertImdbSet(toInsert); + await PlexMovie.insertMovies([...toInsert].map(imdbId => ({imdbId, plexKey: newIds[imdbId]}))); // Update TMDb IDs for (let movie of await PlexMovie.find({ where: { tmdbId: null } })) { diff --git a/src/server/util.ts b/src/server/util.ts index dcb2483..f32b0e4 100644 --- a/src/server/util.ts +++ b/src/server/util.ts @@ -57,3 +57,11 @@ export function generateToken(size: number = 48) { }); }); } + +/** + * Generate a URL to a movie/TV show on Plex + */ +export function plexMediaUrl(plexKey: number) { + return `${env("PLEX_URL")}/web/index.html#!/server/${env("PLEX_SERVER_ID")}/` + + `details?key=%2Flibrary%2Fmetadata%2F${plexKey}&context=library%3Acontent.library;`; +} diff --git a/yarn.lock b/yarn.lock index 803be50..6e32f83 100644 --- a/yarn.lock +++ b/yarn.lock @@ -142,6 +142,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.37.tgz#a3dd8da4eb84a996c36e331df98d82abd76b516e" integrity sha512-XYmBiy+ohOR4Lh5jE379fV2IU+6Jn4g5qASinhitfyO71b/sCo6MKsMLF5tc7Zf2CE8hViVQyYSobJNke8OvUw== +"@types/xml2js@^0.4.8": + version "0.4.8" + resolved "https://registry.yarnpkg.com/@types/xml2js/-/xml2js-0.4.8.tgz#84c120c864a5976d0b5cf2f930a75d850fc2b03a" + integrity sha512-EyvT83ezOdec7BhDaEcsklWy7RSIdi6CNe95tmOAK0yx/Lm30C9K75snT3fYayK59ApC2oyW+rcHErdG05FHJA== + dependencies: + "@types/node" "*" + "@types/zen-observable@^0.8.2": version "0.8.2" resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.2.tgz#808c9fa7e4517274ed555fa158f2de4b4f468e71"