feat(api): plex tv sync and recently added sync

This commit is contained in:
sct
2020-11-11 09:02:28 +00:00
parent 16221a46a7
commit 1390cc1f13
19 changed files with 554 additions and 76 deletions

View File

@@ -3,9 +3,11 @@ import { getSettings } from '../lib/settings';
export interface PlexLibraryItem {
ratingKey: string;
parentRatingKey?: string;
title: string;
guid: string;
type: 'movie' | 'show';
parentGuid?: string;
type: 'movie' | 'show' | 'season';
}
interface PlexLibraryResponse {
@@ -28,12 +30,21 @@ interface PlexLibrariesResponse {
export interface PlexMetadata {
ratingKey: string;
parentRatingKey?: string;
guid: string;
type: 'movie' | 'show';
type: 'movie' | 'show' | 'season';
title: string;
Guid: {
id: string;
}[];
Children?: {
size: 12;
Metadata: PlexMetadata[];
};
index: number;
parentIndex?: number;
leafCount: number;
viewedLeafCount: number;
}
interface PlexMetadataResponse {
@@ -63,6 +74,9 @@ class PlexAPI {
cb(undefined, plexToken);
},
},
// requestOptions: {
// includeChildren: 1,
// },
options: {
identifier: settings.clientId,
product: 'Overseerr',
@@ -92,18 +106,25 @@ class PlexAPI {
return response.MediaContainer.Metadata;
}
public async getMetadata(key: string): Promise<PlexMetadata> {
public async getMetadata(
key: string,
options: { includeChildren?: boolean } = {}
): Promise<PlexMetadata> {
const response = await this.plexClient.query<PlexMetadataResponse>(
`/library/metadata/${key}`
`/library/metadata/${key}${
options.includeChildren ? '?includeChildren=1' : ''
}`
);
return response.MediaContainer.Metadata[0];
}
public async getRecentlyAdded() {
const response = await this.plexClient.query('/library/recentlyAdded');
public async getRecentlyAdded(): Promise<PlexLibraryItem[]> {
const response = await this.plexClient.query<PlexLibraryResponse>(
'/library/recentlyAdded'
);
return response;
return response.MediaContainer.Metadata;
}
}

View File

@@ -649,6 +649,38 @@ class TheMovieDb {
);
}
}
public async getShowByTvdbId({
tvdbId,
language = 'en-US',
}: {
tvdbId: number;
language?: string;
}): Promise<TmdbTvDetails> {
try {
const extResponse = await this.getByExternalId({
externalId: tvdbId,
type: 'tvdb',
});
if (extResponse.tv_results[0]) {
const tvshow = await this.getTvShow({
tvId: extResponse.tv_results[0].id,
language,
});
return tvshow;
}
throw new Error(
`[TMDB] Failed to find a tv show with the provided TVDB id: ${tvdbId}`
);
} catch (e) {
throw new Error(
`[TMDB] Failed to get tv show by external tvdb ID: ${e.message}`
);
}
}
}
export default TheMovieDb;

View File

@@ -12,6 +12,7 @@ import {
import { MediaRequest } from './MediaRequest';
import { MediaStatus, MediaType } from '../constants/media';
import logger from '../logger';
import Season from './Season';
@Entity()
class Media {
@@ -79,6 +80,12 @@ class Media {
@OneToMany(() => MediaRequest, (request) => request.media, { cascade: true })
public requests: MediaRequest[];
@OneToMany(() => Season, (season) => season.media, {
cascade: true,
eager: true,
})
public seasons: Season[];
@CreateDateColumn()
public createdAt: Date;

37
server/entity/Season.ts Normal file
View File

@@ -0,0 +1,37 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { MediaStatus } from '../constants/media';
import Media from './Media';
@Entity()
class Season {
@PrimaryGeneratedColumn()
public id: number;
@Column()
public seasonNumber: number;
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
public status: MediaStatus;
@ManyToOne(() => Media, (media) => media.seasons)
public media: Media;
@CreateDateColumn()
public createdAt: Date;
@UpdateDateColumn()
public updatedAt: Date;
constructor(init?: Partial<Season>) {
Object.assign(this, init);
}
}
export default Season;

View File

@@ -1,16 +1,19 @@
import { getRepository } from 'typeorm';
import { User } from '../entity/User';
import PlexAPI, { PlexLibraryItem } from '../api/plexapi';
import TheMovieDb from '../api/themoviedb';
import Media from '../entity/Media';
import { MediaStatus, MediaType } from '../constants/media';
import logger from '../logger';
import { getSettings, Library } from '../lib/settings';
import { User } from '../../entity/User';
import PlexAPI, { PlexLibraryItem } from '../../api/plexapi';
import TheMovieDb, { TmdbTvDetails } from '../../api/themoviedb';
import Media from '../../entity/Media';
import { MediaStatus, MediaType } from '../../constants/media';
import logger from '../../logger';
import { getSettings, Library } from '../../lib/settings';
import Season from '../../entity/Season';
const BUNDLE_SIZE = 10;
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/);
const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
const plexRegex = new RegExp(/plex:\/\//);
interface SyncStatus {
@@ -29,9 +32,11 @@ class JobPlexSync {
private libraries: Library[];
private currentLibrary: Library;
private running = false;
private isRecentOnly = false;
constructor() {
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
this.tmdb = new TheMovieDb();
this.isRecentOnly = isRecentOnly ?? false;
}
private async getExisting(tmdbId: number) {
@@ -107,11 +112,116 @@ class JobPlexSync {
}
}
private async processShow(plexitem: PlexLibraryItem) {
const mediaRepository = getRepository(Media);
let tvShow: TmdbTvDetails | null = null;
try {
const metadata = await this.plexClient.getMetadata(
plexitem.parentRatingKey ?? plexitem.ratingKey,
{ includeChildren: true }
);
if (metadata.guid.match(tvdbRegex)) {
const matchedtvdb = metadata.guid.match(tvdbRegex);
// If we can find a tvdb Id, use it to get the full tmdb show details
if (matchedtvdb?.[1]) {
tvShow = await this.tmdb.getShowByTvdbId({
tvdbId: Number(matchedtvdb[1]),
});
}
} else if (metadata.guid.match(tmdbShowRegex)) {
const matchedtmdb = metadata.guid.match(tmdbShowRegex);
if (matchedtmdb?.[1]) {
tvShow = await this.tmdb.getTvShow({ tvId: Number(matchedtmdb[1]) });
}
}
if (tvShow && metadata) {
// Lets get the available seasons from plex
const seasons = tvShow.seasons;
const media = await mediaRepository.findOne({
where: { tmdbId: tvShow.id, mediaType: MediaType.TV },
});
const availableSeasons: Season[] = [];
seasons.forEach((season) => {
const matchedPlexSeason = metadata.Children?.Metadata.find(
(md) => Number(md.index) === season.season_number
);
// Check if we found the matching season and it has all the available episodes
if (
matchedPlexSeason &&
Number(matchedPlexSeason.leafCount) === season.episode_count
) {
availableSeasons.push(
new Season({
seasonNumber: season.season_number,
status: MediaStatus.AVAILABLE,
})
);
} else if (matchedPlexSeason) {
availableSeasons.push(
new Season({
seasonNumber: season.season_number,
status: MediaStatus.PARTIALLY_AVAILABLE,
})
);
}
});
// Remove extras season. We dont count it for determining availability
const filteredSeasons = tvShow.seasons.filter(
(season) => season.season_number !== 0
);
const isAllSeasons = availableSeasons.length >= filteredSeasons.length;
if (media) {
// Update existing
media.seasons = availableSeasons;
media.status = isAllSeasons
? MediaStatus.AVAILABLE
: MediaStatus.PARTIALLY_AVAILABLE;
await mediaRepository.save(media);
this.log(`Updating existing title: ${tvShow.name}`);
} else {
const newMedia = new Media({
mediaType: MediaType.TV,
seasons: availableSeasons,
tmdbId: tvShow.id,
tvdbId: tvShow.external_ids.tvdb_id,
status: isAllSeasons
? MediaStatus.AVAILABLE
: MediaStatus.PARTIALLY_AVAILABLE,
});
await mediaRepository.save(newMedia);
this.log(`Saved ${tvShow.name}`);
}
} else {
this.log(`failed show: ${plexitem.guid}`);
}
} catch (e) {
this.log(
`Failed to process plex item. ratingKey: ${
plexitem.parentRatingKey ?? plexitem.ratingKey
}`,
'error'
);
}
}
private async processItems(slicedItems: PlexLibraryItem[]) {
await Promise.all(
slicedItems.map(async (plexitem) => {
if (plexitem.type === 'movie') {
await this.processMovie(plexitem);
} else if (plexitem.type === 'show') {
await this.processShow(plexitem);
}
})
);
@@ -159,15 +269,26 @@ class JobPlexSync {
});
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
this.libraries = settings.plex.libraries.filter(
(library) => library.enabled
);
for (const library of this.libraries) {
this.currentLibrary = library;
this.log(`Beginning to process library: ${library.name}`, 'info');
this.items = await this.plexClient.getLibraryContents(library.id);
if (this.isRecentOnly) {
this.currentLibrary = {
id: '0',
name: 'Recently Added',
enabled: true,
};
this.log(`Beginning to process recently added`, 'info');
this.items = await this.plexClient.getRecentlyAdded();
await this.loop();
} else {
this.libraries = settings.plex.libraries.filter(
(library) => library.enabled
);
for (const library of this.libraries) {
this.currentLibrary = library;
this.log(`Beginning to process library: ${library.name}`, 'info');
this.items = await this.plexClient.getLibraryContents(library.id);
await this.loop();
}
}
this.running = false;
this.log('complete');
@@ -189,6 +310,5 @@ class JobPlexSync {
}
}
const jobPlexSync = new JobPlexSync();
export default jobPlexSync;
export const jobPlexFullSync = new JobPlexSync();
export const jobPlexRecentSync = new JobPlexSync({ isRecentOnly: true });

View File

@@ -1,14 +1,32 @@
import schedule from 'node-schedule';
import jobPlexSync from './plexsync';
import { jobPlexFullSync, jobPlexRecentSync } from './plexsync';
import logger from '../logger';
export const scheduledJobs: Record<string, schedule.Job> = {};
interface ScheduledJob {
job: schedule.Job;
name: string;
}
export const scheduledJobs: ScheduledJob[] = [];
export const startJobs = (): void => {
// Run recently added plex sync every 5 minutes
scheduledJobs.push({
name: 'Plex Recently Added Sync',
job: schedule.scheduleJob('0 */10 * * * *', () => {
logger.info('Starting scheduled job: Plex Recently Added Sync', {
label: 'Jobs',
});
jobPlexRecentSync.run();
}),
});
// Run full plex sync every 6 hours
scheduledJobs.plexFullSync = schedule.scheduleJob('* */6 * * *', () => {
logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' });
jobPlexSync.run();
scheduledJobs.push({
name: 'Plex Full Library Sync',
job: schedule.scheduleJob('* * */6 * * *', () => {
logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' });
jobPlexFullSync.run();
}),
});
logger.info('Scheduled jobs loaded', { label: 'Jobs' });

View File

@@ -8,10 +8,11 @@ import {
import { getRepository } from 'typeorm';
import { User } from '../entity/User';
import PlexAPI, { PlexLibrary } from '../api/plexapi';
import jobPlexSync from '../job/plexsync';
import { jobPlexFullSync } from '../job/plexsync';
import SonarrAPI from '../api/sonarr';
import RadarrAPI from '../api/radarr';
import logger from '../logger';
import { scheduledJobs } from '../job/schedule';
const settingsRoutes = Router();
@@ -108,12 +109,12 @@ settingsRoutes.get('/plex/library', async (req, res) => {
settingsRoutes.get('/plex/sync', (req, res) => {
if (req.query.cancel) {
jobPlexSync.cancel();
jobPlexFullSync.cancel();
} else if (req.query.start) {
jobPlexSync.run();
jobPlexFullSync.run();
}
return res.status(200).json(jobPlexSync.status());
return res.status(200).json(jobPlexFullSync.status());
});
settingsRoutes.get('/radarr', (req, res) => {
@@ -324,4 +325,13 @@ settingsRoutes.delete<{ id: string }>('/sonarr/:id', (req, res) => {
return res.status(200).json(removed[0]);
});
settingsRoutes.get('/jobs', (req, res) => {
return res.status(200).json(
scheduledJobs.map((job) => ({
name: job.name,
nextExecutionTime: job.job.nextInvocation(),
}))
);
});
export default settingsRoutes;

View File

@@ -16,6 +16,7 @@ declare module 'plex-api' {
deviceName: string;
platform: string;
};
requestOptions?: Record<string, string | number>;
});
query: <T extends Record<string, any>>(endpoint: string) => Promise<T>;