Skip to content

Commit

Permalink
feat: availability sync job. fixes sct#377
Browse files Browse the repository at this point in the history
  • Loading branch information
jariz committed Aug 4, 2022
1 parent b33956e commit c9fafd7
Show file tree
Hide file tree
Showing 7 changed files with 343 additions and 2 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 '../../logger';
import ServarrBase from './base';

interface SonarrSeason {
export interface SonarrSeason {
seasonNumber: number;
monitored: boolean;
statistics?: {
Expand Down
17 changes: 17 additions & 0 deletions server/job/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { sonarrScanner } from '../lib/scanners/sonarr';
import type { JobId } from '../lib/settings';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import availabilitySync from '../lib/availabilitySync';

interface ScheduledJob {
id: JobId;
Expand Down Expand Up @@ -82,6 +83,22 @@ 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',
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 { getRepository } from 'typeorm';
import Media from '../entity/Media';
import { MediaStatus } from '../constants/media';
import { User } from '../entity/User';
import type { PlexMetadata } from '../api/plexapi';
import PlexAPI from '../api/plexapi';
import logger from '../logger';
import Season from '../entity/Season';
import RadarrAPI from '../api/servarr/radarr';
import type { RadarrSettings, SonarrSettings } from './settings';
import { getSettings } from './settings';
import type { SonarrSeason } from '../api/servarr/sonarr';
import SonarrAPI from '../api/servarr/sonarr';

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: [
{ media, status: MediaStatus.AVAILABLE },
{ media, status4k: MediaStatus.AVAILABLE },
],
});

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 @@ -246,7 +246,8 @@ export type JobId =
| 'radarr-scan'
| 'sonarr-scan'
| 'download-sync'
| 'download-sync-reset';
| 'download-sync-reset'
| 'availability-sync';

interface AllSettings {
clientId: string;
Expand Down Expand Up @@ -404,6 +405,9 @@ class Settings {
'sonarr-scan': {
schedule: '0 30 4 * * *',
},
'availability-sync': {
schedule: '0 0 5 * * *',
},
'download-sync': {
schedule: '0 * * * * *',
},
Expand Down
1 change: 1 addition & 0 deletions src/components/Settings/SettingsJobsCache/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
unknownJob: 'Unknown Job',
'plex-recently-added-scan': 'Plex Recently Added Scan',
'plex-full-scan': 'Plex Full Library Scan',
'availability-sync': 'Media availability sync',
'radarr-scan': 'Radarr Scan',
'sonarr-scan': 'Sonarr Scan',
'download-sync': 'Download Sync',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,7 @@
"components.Settings.SettingsAbout.totalrequests": "Total Requests",
"components.Settings.SettingsAbout.uptodate": "Up to Date",
"components.Settings.SettingsAbout.version": "Version",
"components.Settings.SettingsJobsCache.availability-sync": "Media availability sync",
"components.Settings.SettingsJobsCache.cache": "Cache",
"components.Settings.SettingsJobsCache.cacheDescription": "Overseerr caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls.",
"components.Settings.SettingsJobsCache.cacheflushed": "{cachename} cache flushed.",
Expand Down
Loading

0 comments on commit c9fafd7

Please sign in to comment.