Skip to content

Commit

Permalink
feat: add availability synchronization job
Browse files Browse the repository at this point in the history
fix #377
  • Loading branch information
jariz authored and OwsleyJr committed Feb 12, 2023
1 parent 33e7691 commit b029eef
Show file tree
Hide file tree
Showing 7 changed files with 345 additions and 5 deletions.
2 changes: 1 addition & 1 deletion server/api/servarr/sonarr.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logger from '@server/logger';
import ServarrBase from './base';

interface SonarrSeason {
export interface SonarrSeason {
seasonNumber: number;
monitored: boolean;
statistics?: {
Expand Down
16 changes: 16 additions & 0 deletions server/job/schedule.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import availabilitySync from '@server/lib/availabilitySync';
import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy';
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
Expand Down Expand Up @@ -104,6 +105,21 @@ export const startJobs = (): void => {
cancelFn: () => sonarrScanner.cancel(),
});

// Checks if media is still available in plex/sonarr/radarr libs
scheduledJobs.push({
id: 'availability-sync',
name: 'Update availability',
type: 'process',
interval: 'long',
cronSchedule: jobs['availability-sync'].schedule,
job: schedule.scheduleJob(jobs['availability-sync'].schedule, () => {
logger.info('Starting scheduled job: Update availability', { label: 'Jobs' });
availabilitySync.run();
}),
running: () => availabilitySync.running,
cancelFn: () => availabilitySync.cancel(),
});

// Run download sync every minute
scheduledJobs.push({
id: 'download-sync',
Expand Down
317 changes: 317 additions & 0 deletions server/lib/availabilitySync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
import type { PlexMetadata } from '@server/api/plexapi';
import PlexAPI from '@server/api/plexapi';
import RadarrAPI from '@server/api/servarr/radarr';
import type { SonarrSeason } from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import Season from '@server/entity/Season';
import { User } from '@server/entity/User';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';

class AvailabilitySync {
public running = false;
private plexClient: PlexAPI;
private plexSeasonsCache: Record<string, PlexMetadata[]> = {};
private sonarrSeasonsCache: Record<string, SonarrSeason[]> = {};
private radarrServers: RadarrSettings[];
private sonarrServers: SonarrSettings[];

async run() {
const settings = getSettings();
this.running = true;
this.plexSeasonsCache = {};
this.sonarrSeasonsCache = {};
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
await this.initPlexClient();

if (!this.plexClient) {
return;
}

logger.debug(`Starting availability sync...`, {
label: 'AvailabilitySync',
});

try {
const mediaRepository = getRepository(Media);
const seasonRepository = getRepository(Season);
const availableMedia = await mediaRepository.find({
where: [
{
status: MediaStatus.AVAILABLE,
},
{
status: MediaStatus.PARTIALLY_AVAILABLE,
},
{
status4k: MediaStatus.AVAILABLE,
},
{
status4k: MediaStatus.PARTIALLY_AVAILABLE,
},
],
});

for (const media of availableMedia) {
if (!this.running) {
throw new Error('Job aborted');
}

const mediaExists = await this.mediaExists(media);

if (!mediaExists) {
logger.debug(
`Removing media id: ${media.tmdbId} because it doesn't appear in any of the libraries anymore`,
{
label: 'AvailabilitySync',
}
);
await mediaRepository.delete(media.id);
continue;
}

if (media.mediaType === 'tv') {
// ok, the show itself exists, but do all it's seasons?

const seasons = await seasonRepository.find({
where: [
{ status: MediaStatus.AVAILABLE, media: { id: media.id } },
{ status4k: MediaStatus.AVAILABLE, media: { id: media.id } },
],
});

let didDeleteSeasons = false;
for (const season of seasons) {
const seasonExists = await this.seasonExists(media, season);

if (!seasonExists) {
logger.debug(
`Removing ${season.seasonNumber} for media id: ${media.tmdbId} because it doesn't appear in any of the libraries anymore`,
{
label: 'AvailabilitySync',
}
);
await seasonRepository.delete(season.id);
didDeleteSeasons = true;
}
}

if (didDeleteSeasons) {
if (
media.status === MediaStatus.AVAILABLE ||
media.status4k === MediaStatus.AVAILABLE
) {
logger.debug(
`Marking media id: ${media.tmdbId} as PARTIALLY_AVAILABLE because we deleted some of it's seasons`,
{
label: 'AvailabilitySync',
}
);
if (media.status === MediaStatus.AVAILABLE) {
await mediaRepository.update(media.id, {
status: MediaStatus.PARTIALLY_AVAILABLE,
});
}
if (media.status4k === MediaStatus.AVAILABLE) {
await mediaRepository.update(media.id, {
status4k: MediaStatus.PARTIALLY_AVAILABLE,
});
}
}
}
}
}
} catch (ex) {
logger.error('Failed to complete availability sync', {
errorMessage: ex.message,
label: 'AvailabilitySync',
});
} finally {
logger.debug(`Availability sync complete`, {
label: 'AvailabilitySync',
});
this.running = false;
}
}

private async mediaExists(media: Media): Promise<boolean> {
if (await this.mediaExistsInPlex(media)) {
return true;
}

if (media.mediaType === 'movie') {
const existsInRadarr = await this.mediaExistsInRadarr(media);
if (existsInRadarr) {
logger.warn(
`${media.tmdbId} exists in radarr (${media.serviceUrl}) but is missing from plex, so we'll assume it's still supposed to exist.`,
{
label: 'AvailabilitySync',
}
);
return true;
}
}

if (media.mediaType === 'tv') {
const existsInSonarr = await this.mediaExistsInSonarr(media);
if (existsInSonarr) {
logger.warn(
`${media.tvdbId} exists in sonarr (${media.serviceUrl}) but is missing from plex, so we'll assume it's still supposed to exist.`,
{
label: 'AvailabilitySync',
}
);
return true;
}
}

return false;
}

private async seasonExists(media: Media, season: Season): Promise<boolean> {
if (await this.seasonExistsInPlex(media, season)) {
return true;
}

const existsInSonarr = await this.seasonExistsInSonarr(media, season);
if (existsInSonarr) {
logger.warn(
`${media.tvdbId}, season: ${season.seasonNumber} exists in sonarr (${media.serviceUrl}) but is missing from plex, so we'll assume it's still supposed to exist.`,
{
label: 'AvailabilitySync',
}
);
return true;
}
return false;
}

private async mediaExistsInRadarr(media: Media): Promise<boolean> {
for (const server of this.radarrServers) {
const api = new RadarrAPI({
apiKey: server.apiKey,
url: RadarrAPI.buildUrl(server, '/api/v3'),
});
const meta = await api.getMovieByTmdbId(media.tmdbId);
if (meta.id) {
return true;
}
}
return false;
}

private async mediaExistsInSonarr(media: Media): Promise<boolean> {
if (!media.tvdbId) {
return false;
}

for (const server of this.sonarrServers) {
const api = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildUrl(server, '/api/v3'),
});
const meta = await api.getSeriesByTvdbId(media.tvdbId);
this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] = meta.seasons;
if (meta.id && meta.monitored) {
return true;
}
}
return false;
}

private async mediaExistsInPlex(media: Media): Promise<boolean> {
const ratingKey = media.ratingKey ?? media.ratingKey4k;

if (!ratingKey) {
return false;
}

try {
const meta = await this.plexClient?.getMetadata(ratingKey);
return !!meta;
} catch (ex) {
// TODO: oof, not the nicest way of handling this, but plex-api does not leave us with any other options...
if (!ex.message.includes('response code: 404')) {
throw ex;
}

return false;
}
}

private async seasonExistsInPlex(media: Media, season: Season) {
const ratingKey = media.ratingKey ?? media.ratingKey4k;

if (!ratingKey) {
return false;
}

const children =
this.plexSeasonsCache[ratingKey] ??
(await this.plexClient?.getChildrenMetadata(ratingKey)) ??
[];
this.plexSeasonsCache[ratingKey] = children;

const seasonMeta = children.find(
(child) => child.index === season.seasonNumber
);

return !!seasonMeta;
}

private async seasonExistsInSonarr(media: Media, season: Season) {
if (!media.tvdbId) {
return false;
}

for (const server of this.sonarrServers) {
const api = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildUrl(server, '/api/v3'),
});
const seasons =
this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] ??
(await api.getSeriesByTvdbId(media.tvdbId)).seasons;
this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] = seasons;

const hasMonitoredSeason = seasons.find(
({ monitored, seasonNumber }) =>
monitored && season.seasonNumber === seasonNumber
);
if (hasMonitoredSeason) {
return true;
}
}

return false;
}

private async initPlexClient() {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});

if (!admin) {
logger.warning(
'No plex admin configured. Availability sync will not be ran'
);
return;
}

this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
}

public cancel() {
this.running = false;
}
}

const availabilitySync = new AvailabilitySync();
export default availabilitySync;
6 changes: 5 additions & 1 deletion server/lib/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,8 @@ export type JobId =
| 'sonarr-scan'
| 'download-sync'
| 'download-sync-reset'
| 'image-cache-cleanup';
| 'image-cache-cleanup'
| 'availability-sync';

interface AllSettings {
clientId: string;
Expand Down Expand Up @@ -409,6 +410,9 @@ class Settings {
'sonarr-scan': {
schedule: '0 30 4 * * *',
},
'availability-sync': {
schedule: '0 0 5 * * *',
},
'download-sync': {
schedule: '0 * * * * *',
},
Expand Down
Loading

0 comments on commit b029eef

Please sign in to comment.