Browse Source

Add Plex intergration and movie detection/movie linking

master
David Ludwig 4 years ago
parent
commit
65068f2244
13 changed files with 77 additions and 52 deletions
  1. +3
    -1
      .env.example
  2. +3
    -1
      package.json
  3. +1
    -0
      src/app/assets/plex_logo.svg
  4. +10
    -6
      src/app/components/modals/MovieModal.vue
  5. +1
    -1
      src/common/api_schema.ts
  6. +22
    -3
      src/lib/plex/index.ts
  7. +5
    -27
      src/lib/plex/schema.ts
  8. +7
    -4
      src/server/database/entities/PlexMovie.ts
  9. +1
    -1
      src/server/database/entities/User.ts
  10. +6
    -5
      src/server/services/MovieSearch.ts
  11. +3
    -3
      src/server/services/PlexLibrary.ts
  12. +8
    -0
      src/server/util.ts
  13. +7
    -0
      yarn.lock

+ 3
- 1
.env.example View File

@ -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 =

+ 3
- 1
package.json View File

@ -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",


+ 1
- 0
src/app/assets/plex_logo.svg View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 320.03 103.61"><defs><style>.cls-1{fill:#fff;}.cls-2{fill:url(#radial-gradient);}.cls-3{fill:#e5a00d;}</style><radialGradient id="radial-gradient" cx="258.33" cy="51.76" r="42.95" gradientUnits="userSpaceOnUse"><stop offset="0.17" stop-color="#f9be03"/><stop offset="0.51" stop-color="#e8a50b"/><stop offset="1" stop-color="#cc7c19"/></radialGradient></defs><title>plex-logo</title><polygon id="X" class="cls-1" points="320.03 -0.09 289.96 -0.09 259.88 51.76 289.96 103.61 320.01 103.61 289.96 51.79 320.03 -0.09"/><g id="chevron"><polygon class="cls-2" points="226.7 -0.09 256.78 -0.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76 226.7 -0.09"/><polygon class="cls-3" points="226.7 -0.09 256.78 -0.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76 226.7 -0.09"/></g><path id="E" class="cls-1" d="M216.32,103.61H156.49V-.09h59.83v18h-37.8V40.69H213.7v18H178.52V85.45h37.8Z"/><path id="L" class="cls-1" d="M82.07,103.61V-.09h22V85.45h42.07v18.16Z"/><path id="P" class="cls-1" d="M71.66,32.25Q71.66,49,61.2,57.87T31.44,66.73H22v36.88H0V-.09H33.14Q52-.09,61.83,8T71.66,32.25ZM22,48.71h7.24q10.15,0,15.18-4c3.37-2.66,5-6.56,5-11.67s-1.41-9-4.22-11.42S38,17.93,32,17.93H22Z"/></svg>

+ 10
- 6
src/app/components/modals/MovieModal.vue View File

@ -24,9 +24,11 @@
<p class="">{{movie?.overview}}</p>
</div>
<div class="mt-4 hidden md:block">
<button class="py-2 px-4 rounded-full shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-default focus:outline-none ring-0 transition-transform transform hover:scale-105" :class="{ 'bg-white text-black': isDark, 'bg-black text-white': !isDark }"
v-if="movie.ticketId !== null">View Request Status</button>
<button v-else class="py-2 w-36 rounded-full shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-default focus:outline-none ring-0 transition-transform transform hover:scale-105" :class="{ 'bg-white text-black': isDark, 'bg-black text-white': !isDark }"
<a class="inline-block py-2 px-6 rounded-full shadow-md hover:shadow-lg focus:outline-none ring-0 transition-transform transform hover:scale-105" :class="{ 'bg-white text-black': isDark, 'bg-black text-white': !isDark }"
v-if="movie.plexLink !== null" :href="movie.plexLink" target="_blank">Watch Now</a>
<button class="inline-block py-2 px-4 rounded-full shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-default focus:outline-none ring-0 transition-transform transform hover:scale-105" :class="{ 'bg-white text-black': isDark, 'bg-black text-white': !isDark }"
v-else-if="movie.ticketId !== null">View Request Status</button>
<button v-else class="inline-block py-2 w-36 rounded-full shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-default focus:outline-none ring-0 transition-transform transform hover:scale-105" :class="{ 'bg-white text-black': isDark, 'bg-black text-white': !isDark }"
:disabled="isRequesting" @click="request">{{isRequesting ? "Requesting..." : "Request"}}</button>
</div>
</div>
@ -39,9 +41,11 @@
<p>{{ movie.overview }}</p>
</div>
<div class="text-center">
<button class="py-2 px-4 rounded-full bg-red-500 text-white disabled:opacity-50"
v-if="movie.ticketId !== null">View Request Status</button>
<button v-else class="py-2 px-8 rounded-full shadow-sm bg-red-500 hover:bg-black transition-colors text-white disabled:opacity-50 disabled:cursor-default focus:outline-none ring-0 disabled:bg-black"
<a class="inline-block py-2 px-8 rounded-full shadow-sm bg-red-500 hover:bg-black transition-colors text-white focus:outline-none ring-0"
v-if="movie.plexLink !== null" :href="movie.plexLink">Watch Now</a>
<button class="inline-block py-2 px-4 rounded-full bg-red-500 text-white disabled:opacity-50"
v-else-if="movie.ticketId !== null">View Request Status</button>
<button v-else class="inline-block py-2 px-8 rounded-full shadow-sm bg-red-500 hover:bg-black transition-colors text-white disabled:opacity-50 disabled:cursor-default focus:outline-none ring-0 disabled:bg-black"
:disabled="isRequesting" @click="request">{{isRequesting ? "Requesting..." : "Request"}}</button>
</div>
</div>


+ 1
- 1
src/common/api_schema.ts View File

@ -42,7 +42,7 @@ export interface IApiPaginatedResponse<T> {
* A movie listing returned from the API
*/
export interface IApiMovie {
isOnPlex : boolean,
plexLink : string | null,
posterPath : string | null,
releaseDate: string | null,
ticketId : number | null,


+ 22
- 3
src/lib/plex/index.ts View File

@ -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 = <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;
}
}

+ 5
- 27
src/lib/plex/schema.ts View File

@ -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
}

+ 7
- 4
src/server/database/entities/PlexMovie.ts View File

@ -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<string>) {
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();
}


+ 1
- 1
src/server/database/entities/User.ts View File

@ -102,7 +102,7 @@ export class User extends BaseEntity
relations: ["info"]
});
return tickets.map(ticket => <IApiMovie>({
isOnPlex : false,
plexLink : null,
posterPath : ticket.info?.posterPath,
releaseDate: ticket.info?.releaseDate,
ticketId : ticket.id,


+ 6
- 5
src/server/services/MovieSearch.ts View File

@ -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 <IApiPaginatedResponse<IApiMovie>>{
page: results.page,
results: results.results.map((movie, index) => <IApiMovie>{
isOnPlex : isOnPlex[index],
plexLink : plexKeys[index] !== null ? plexMediaUrl(<number>plexKeys[index]) : null,
posterPath : movie.poster_path,
releaseDate: movie.release_date,
ticketId : activeTickets[movie.id] ?? null,


+ 3
- 3
src/server/services/PlexLibrary.ts View File

@ -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 } })) {


+ 8
- 0
src/server/util.ts View File

@ -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;`;
}

+ 7
- 0
yarn.lock View File

@ -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"


Loading…
Cancel
Save