feat: Radarr & Sonarr Sync (#734)

This commit is contained in:
sct
2021-01-27 23:52:37 +09:00
committed by GitHub
parent 86efcd82c3
commit ec5fb83678
32 changed files with 2394 additions and 425 deletions

View File

@@ -442,7 +442,11 @@ class JobPlexSync {
);
// Total episodes that are in standard definition (not 4k)
const totalStandard = episodes.filter((episode) =>
episode.Media.some((media) => media.videoResolution !== '4k')
!this.enable4kShow
? true
: episode.Media.some(
(media) => media.videoResolution !== '4k'
)
).length;
// Total episodes that are in 4k
@@ -461,9 +465,9 @@ class JobPlexSync {
? MediaStatus.PARTIALLY_AVAILABLE
: existingSeason.status;
existingSeason.status4k =
total4k === season.episode_count
this.enable4kShow && total4k === season.episode_count
? MediaStatus.AVAILABLE
: total4k > 0
: this.enable4kShow && total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: existingSeason.status4k;
} else {
@@ -479,9 +483,9 @@ class JobPlexSync {
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k:
total4k === season.episode_count
this.enable4kShow && total4k === season.episode_count
? MediaStatus.AVAILABLE
: total4k > 0
: this.enable4kShow && total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
})
@@ -563,13 +567,15 @@ class JobPlexSync {
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
media.status4k = isAll4kSeasons
? MediaStatus.AVAILABLE
: media.seasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
media.status4k =
isAll4kSeasons && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
media.seasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
await mediaRepository.save(media);
this.log(`Updating existing title: ${tvShow.name}`);
} else {
@@ -586,13 +592,15 @@ class JobPlexSync {
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k: isAll4kSeasons
? MediaStatus.AVAILABLE
: newSeasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k:
isAll4kSeasons && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
newSeasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
});
await mediaRepository.save(newMedia);
this.log(`Saved ${tvShow.name}`);
@@ -772,7 +780,8 @@ class JobPlexSync {
this.log(
this.isRecentOnly
? 'Recently Added Scan Complete'
: 'Full Scan Complete'
: 'Full Scan Complete',
'info'
);
} catch (e) {
logger.error('Sync interrupted', {

View File

@@ -0,0 +1,248 @@
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import { v4 as uuid } from 'uuid';
import RadarrAPI, { RadarrMovie } from '../../api/radarr';
import { MediaStatus, MediaType } from '../../constants/media';
import Media from '../../entity/Media';
import { getSettings, RadarrSettings } from '../../lib/settings';
import logger from '../../logger';
const BUNDLE_SIZE = 50;
const UPDATE_RATE = 4 * 1000;
interface SyncStatus {
running: boolean;
progress: number;
total: number;
currentServer: RadarrSettings;
servers: RadarrSettings[];
}
class JobRadarrSync {
private running = false;
private progress = 0;
private enable4k = false;
private sessionId: string;
private servers: RadarrSettings[];
private currentServer: RadarrSettings;
private radarrApi: RadarrAPI;
private items: RadarrMovie[] = [];
public async run() {
const settings = getSettings();
const sessionId = uuid();
this.sessionId = sessionId;
this.log('Radarr sync starting', 'info', { sessionId });
try {
this.running = true;
// Remove any duplicate Radarr servers and assign them to the servers field
this.servers = uniqWith(settings.radarr, (radarrA, radarrB) => {
return (
radarrA.hostname === radarrB.hostname &&
radarrA.port === radarrB.port &&
radarrA.baseUrl === radarrB.baseUrl
);
});
this.enable4k = settings.radarr.some((radarr) => radarr.is4k);
if (this.enable4k) {
this.log(
'At least one 4K Radarr server was detected. 4K movie detection is now enabled.',
'info'
);
}
for (const server of this.servers) {
this.currentServer = server;
if (server.syncEnabled) {
this.log(
`Beginning to process Radarr server: ${server.name}`,
'info'
);
this.radarrApi = new RadarrAPI({
apiKey: server.apiKey,
url: RadarrAPI.buildRadarrUrl(server, '/api/v3'),
});
this.items = await this.radarrApi.getMovies();
await this.loop({ sessionId });
} else {
this.log(`Sync not enabled. Skipping Radarr server: ${server.name}`);
}
}
this.log('Radarr sync complete', 'info');
} catch (e) {
this.log('Something went wrong.', 'error', { errorMessage: e.message });
} finally {
// If a new scanning session hasnt started, set running back to false
if (this.sessionId === sessionId) {
this.running = false;
}
}
}
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
total: this.items.length,
currentServer: this.currentServer,
servers: this.servers,
};
}
public cancel(): void {
this.running = false;
}
private async processRadarrMovie(radarrMovie: RadarrMovie) {
const mediaRepository = getRepository(Media);
const server4k = this.enable4k && this.currentServer.is4k;
const media = await mediaRepository.findOne({
where: { tmdbId: radarrMovie.tmdbId },
});
if (media) {
let isChanged = false;
if (media.status === MediaStatus.AVAILABLE) {
this.log(`Movie already available: ${radarrMovie.title}`);
} else {
media[server4k ? 'status4k' : 'status'] = radarrMovie.downloaded
? MediaStatus.AVAILABLE
: MediaStatus.PROCESSING;
this.log(
`Updated existing ${server4k ? '4K ' : ''}movie ${
radarrMovie.title
} to status ${MediaStatus[media[server4k ? 'status4k' : 'status']]}`
);
isChanged = true;
}
if (
media[server4k ? 'serviceId4k' : 'serviceId'] !== this.currentServer.id
) {
media[server4k ? 'serviceId4k' : 'serviceId'] = this.currentServer.id;
this.log(`Updated service ID for media entity: ${radarrMovie.title}`);
isChanged = true;
}
if (
media[server4k ? 'externalServiceId4k' : 'externalServiceId'] !==
radarrMovie.id
) {
media[server4k ? 'externalServiceId4k' : 'externalServiceId'] =
radarrMovie.id;
this.log(
`Updated external service ID for media entity: ${radarrMovie.title}`
);
isChanged = true;
}
if (
media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
radarrMovie.titleSlug
) {
media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
radarrMovie.titleSlug;
this.log(
`Updated external service slug for media entity: ${radarrMovie.title}`
);
isChanged = true;
}
if (isChanged) {
await mediaRepository.save(media);
}
} else {
const newMedia = new Media({
tmdbId: radarrMovie.tmdbId,
imdbId: radarrMovie.imdbId,
mediaType: MediaType.MOVIE,
serviceId: !server4k ? this.currentServer.id : undefined,
serviceId4k: server4k ? this.currentServer.id : undefined,
externalServiceId: !server4k ? radarrMovie.id : undefined,
externalServiceId4k: server4k ? radarrMovie.id : undefined,
status:
!server4k && radarrMovie.downloaded
? MediaStatus.AVAILABLE
: !server4k
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
server4k && radarrMovie.downloaded
? MediaStatus.AVAILABLE
: server4k
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
});
this.log(
`Added media for movie ${radarrMovie.title} and set status to ${
MediaStatus[newMedia[server4k ? 'status4k' : 'status']]
}`
);
await mediaRepository.save(newMedia);
}
}
private async processItems(items: RadarrMovie[]) {
await Promise.all(
items.map(async (radarrMovie) => {
await this.processRadarrMovie(radarrMovie);
})
);
}
private async loop({
start = 0,
end = BUNDLE_SIZE,
sessionId,
}: {
start?: number;
end?: number;
sessionId?: string;
} = {}) {
const slicedItems = this.items.slice(start, end);
if (!this.running) {
throw new Error('Sync was aborted.');
}
if (this.sessionId !== sessionId) {
throw new Error('New session was started. Old session aborted.');
}
if (start < this.items.length) {
this.progress = start;
await this.processItems(slicedItems);
await new Promise<void>((resolve, reject) =>
setTimeout(() => {
this.loop({
start: start + BUNDLE_SIZE,
end: end + BUNDLE_SIZE,
sessionId,
})
.then(() => resolve())
.catch((e) => reject(new Error(e.message)));
}, UPDATE_RATE)
);
}
}
private log(
message: string,
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
optional?: Record<string, unknown>
): void {
logger[level](message, { label: 'Radarr Sync', ...optional });
}
}
export const jobRadarrSync = new JobRadarrSync();

View File

@@ -1,10 +1,17 @@
import schedule from 'node-schedule';
import { jobPlexFullSync, jobPlexRecentSync } from './plexsync';
import logger from '../logger';
import { jobRadarrSync } from './radarrsync';
import { jobSonarrSync } from './sonarrsync';
import downloadTracker from '../lib/downloadtracker';
interface ScheduledJob {
id: string;
job: schedule.Job;
name: string;
type: 'process' | 'command';
running?: () => boolean;
cancelFn?: () => void;
}
export const scheduledJobs: ScheduledJob[] = [];
@@ -12,21 +19,80 @@ export const scheduledJobs: ScheduledJob[] = [];
export const startJobs = (): void => {
// Run recently added plex sync every 5 minutes
scheduledJobs.push({
id: 'plex-recently-added-sync',
name: 'Plex Recently Added Sync',
type: 'process',
job: schedule.scheduleJob('0 */5 * * * *', () => {
logger.info('Starting scheduled job: Plex Recently Added Sync', {
label: 'Jobs',
});
jobPlexRecentSync.run();
}),
running: () => jobPlexRecentSync.status().running,
cancelFn: () => jobPlexRecentSync.cancel(),
});
// Run full plex sync every 24 hours
scheduledJobs.push({
id: 'plex-full-sync',
name: 'Plex Full Library Sync',
type: 'process',
job: schedule.scheduleJob('0 0 3 * * *', () => {
logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' });
jobPlexFullSync.run();
}),
running: () => jobPlexFullSync.status().running,
cancelFn: () => jobPlexFullSync.cancel(),
});
// Run full radarr sync every 24 hours
scheduledJobs.push({
id: 'radarr-sync',
name: 'Radarr Sync',
type: 'process',
job: schedule.scheduleJob('0 0 4 * * *', () => {
logger.info('Starting scheduled job: Radarr Sync', { label: 'Jobs' });
jobRadarrSync.run();
}),
running: () => jobRadarrSync.status().running,
cancelFn: () => jobRadarrSync.cancel(),
});
// Run full sonarr sync every 24 hours
scheduledJobs.push({
id: 'sonarr-sync',
name: 'Sonarr Sync',
type: 'process',
job: schedule.scheduleJob('0 30 4 * * *', () => {
logger.info('Starting scheduled job: Sonarr Sync', { label: 'Jobs' });
jobSonarrSync.run();
}),
running: () => jobSonarrSync.status().running,
cancelFn: () => jobSonarrSync.cancel(),
});
// Run download sync
scheduledJobs.push({
id: 'download-sync',
name: 'Download Sync',
type: 'command',
job: schedule.scheduleJob('0 * * * * *', () => {
logger.debug('Starting scheduled job: Download Sync', { label: 'Jobs' });
downloadTracker.updateDownloads();
}),
});
// Reset download sync
scheduledJobs.push({
id: 'download-sync-reset',
name: 'Download Sync Reset',
type: 'command',
job: schedule.scheduleJob('0 0 1 * * *', () => {
logger.info('Starting scheduled job: Download Sync Reset', {
label: 'Jobs',
});
downloadTracker.resetDownloadTracker();
}),
});
logger.info('Scheduled jobs loaded', { label: 'Jobs' });

View File

@@ -0,0 +1,358 @@
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import { v4 as uuid } from 'uuid';
import SonarrAPI, { SonarrSeries } from '../../api/sonarr';
import TheMovieDb, { TmdbTvDetails } from '../../api/themoviedb';
import { MediaStatus, MediaType } from '../../constants/media';
import Media from '../../entity/Media';
import Season from '../../entity/Season';
import { getSettings, SonarrSettings } from '../../lib/settings';
import logger from '../../logger';
const BUNDLE_SIZE = 50;
const UPDATE_RATE = 4 * 1000;
interface SyncStatus {
running: boolean;
progress: number;
total: number;
currentServer: SonarrSettings;
servers: SonarrSettings[];
}
class JobSonarrSync {
private running = false;
private progress = 0;
private enable4k = false;
private sessionId: string;
private servers: SonarrSettings[];
private currentServer: SonarrSettings;
private sonarrApi: SonarrAPI;
private items: SonarrSeries[] = [];
public async run() {
const settings = getSettings();
const sessionId = uuid();
this.sessionId = sessionId;
this.log('Sonarr sync starting', 'info', { sessionId });
try {
this.running = true;
// Remove any duplicate Sonarr servers and assign them to the servers field
this.servers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => {
return (
sonarrA.hostname === sonarrB.hostname &&
sonarrA.port === sonarrB.port &&
sonarrA.baseUrl === sonarrB.baseUrl
);
});
this.enable4k = settings.sonarr.some((sonarr) => sonarr.is4k);
if (this.enable4k) {
this.log(
'At least one 4K Sonarr server was detected. 4K movie detection is now enabled.',
'info'
);
}
for (const server of this.servers) {
this.currentServer = server;
if (server.syncEnabled) {
this.log(
`Beginning to process Sonarr server: ${server.name}`,
'info'
);
this.sonarrApi = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildSonarrUrl(server, '/api/v3'),
});
this.items = await this.sonarrApi.getSeries();
await this.loop({ sessionId });
} else {
this.log(`Sync not enabled. Skipping Sonarr server: ${server.name}`);
}
}
this.log('Sonarr sync complete', 'info');
} catch (e) {
this.log('Something went wrong.', 'error', { errorMessage: e.message });
} finally {
// If a new scanning session hasnt started, set running back to false
if (this.sessionId === sessionId) {
this.running = false;
}
}
}
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
total: this.items.length,
currentServer: this.currentServer,
servers: this.servers,
};
}
public cancel(): void {
this.running = false;
}
private async processSonarrSeries(sonarrSeries: SonarrSeries) {
const mediaRepository = getRepository(Media);
const server4k = this.enable4k && this.currentServer.is4k;
const media = await mediaRepository.findOne({
where: { tvdbId: sonarrSeries.tvdbId },
});
const currentSeasonsAvailable = (media?.seasons ?? []).filter(
(season) =>
season[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
).length;
const newSeasons: Season[] = [];
for (const season of sonarrSeries.seasons) {
const existingSeason = media?.seasons.find(
(es) => es.seasonNumber === season.seasonNumber
);
// We are already tracking this season so we can work on it directly
if (existingSeason) {
if (
existingSeason[server4k ? 'status4k' : 'status'] !==
MediaStatus.AVAILABLE &&
season.statistics
) {
existingSeason[server4k ? 'status4k' : 'status'] =
season.statistics.episodeFileCount ===
season.statistics.totalEpisodeCount
? MediaStatus.AVAILABLE
: season.statistics.episodeFileCount > 0
? MediaStatus.PARTIALLY_AVAILABLE
: season.monitored
? MediaStatus.PROCESSING
: existingSeason[server4k ? 'status4k' : 'status'];
}
} else {
if (season.statistics && season.seasonNumber !== 0) {
const allEpisodes =
season.statistics.episodeFileCount ===
season.statistics.totalEpisodeCount;
newSeasons.push(
new Season({
seasonNumber: season.seasonNumber,
status:
!server4k && allEpisodes
? MediaStatus.AVAILABLE
: !server4k && season.statistics.episodeFileCount > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !server4k && season.monitored
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
server4k && allEpisodes
? MediaStatus.AVAILABLE
: server4k && season.statistics.episodeFileCount > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !server4k && season.monitored
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
})
);
}
}
}
const filteredSeasons = sonarrSeries.seasons.filter(
(s) => s.seasonNumber !== 0
);
const isAllSeasons =
(media?.seasons ?? []).filter(
(s) => s[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
).length +
newSeasons.filter(
(s) => s[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
).length >=
filteredSeasons.length;
if (media) {
media.seasons = [...media.seasons, ...newSeasons];
const newSeasonsAvailable = (media?.seasons ?? []).filter(
(season) =>
season[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
).length;
if (newSeasonsAvailable > currentSeasonsAvailable) {
this.log(
`Detected ${newSeasonsAvailable - currentSeasonsAvailable} new ${
server4k ? '4K ' : ''
}season(s) for ${sonarrSeries.title}`,
'debug'
);
media.lastSeasonChange = new Date();
}
if (
media[server4k ? 'serviceId4k' : 'serviceId'] !== this.currentServer.id
) {
media[server4k ? 'serviceId4k' : 'serviceId'] = this.currentServer.id;
this.log(`Updated service ID for media entity: ${sonarrSeries.title}`);
}
if (
media[server4k ? 'externalServiceId4k' : 'externalServiceId'] !==
sonarrSeries.id
) {
media[server4k ? 'externalServiceId4k' : 'externalServiceId'] =
sonarrSeries.id;
this.log(
`Updated external service ID for media entity: ${sonarrSeries.title}`
);
}
if (
media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
sonarrSeries.titleSlug
) {
media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
sonarrSeries.titleSlug;
this.log(
`Updated external service slug for media entity: ${sonarrSeries.title}`
);
}
media[server4k ? 'status4k' : 'status'] = isAllSeasons
? MediaStatus.AVAILABLE
: media.seasons.some((season) => season.status !== MediaStatus.UNKNOWN)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
await mediaRepository.save(media);
} else {
const tmdb = new TheMovieDb();
let tvShow: TmdbTvDetails;
try {
tvShow = await tmdb.getShowByTvdbId({
tvdbId: sonarrSeries.tvdbId,
});
} catch (e) {
this.log(
'Failed to create new media item during sync. TVDB ID is missing from TMDB?',
'warn',
{ sonarrSeries, errorMessage: e.message }
);
return;
}
const newMedia = new Media({
tmdbId: tvShow.id,
tvdbId: sonarrSeries.tvdbId,
mediaType: MediaType.TV,
serviceId: !server4k ? this.currentServer.id : undefined,
serviceId4k: server4k ? this.currentServer.id : undefined,
externalServiceId: !server4k ? sonarrSeries.id : undefined,
externalServiceId4k: server4k ? sonarrSeries.id : undefined,
externalServiceSlug: !server4k ? sonarrSeries.titleSlug : undefined,
externalServiceSlug4k: server4k ? sonarrSeries.titleSlug : undefined,
seasons: newSeasons,
status:
!server4k && isAllSeasons
? MediaStatus.AVAILABLE
: !server4k &&
newSeasons.some(
(s) =>
s.status === MediaStatus.PARTIALLY_AVAILABLE ||
s.status === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: !server4k
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
server4k && isAllSeasons
? MediaStatus.AVAILABLE
: server4k &&
newSeasons.some(
(s) =>
s.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
s.status4k === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: server4k
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
});
this.log(
`Added media for series ${sonarrSeries.title} and set status to ${
MediaStatus[newMedia[server4k ? 'status4k' : 'status']]
}`
);
await mediaRepository.save(newMedia);
}
}
private async processItems(items: SonarrSeries[]) {
await Promise.all(
items.map(async (sonarrSeries) => {
await this.processSonarrSeries(sonarrSeries);
})
);
}
private async loop({
start = 0,
end = BUNDLE_SIZE,
sessionId,
}: {
start?: number;
end?: number;
sessionId?: string;
} = {}) {
const slicedItems = this.items.slice(start, end);
if (!this.running) {
throw new Error('Sync was aborted.');
}
if (this.sessionId !== sessionId) {
throw new Error('New session was started. Old session aborted.');
}
if (start < this.items.length) {
this.progress = start;
await this.processItems(slicedItems);
await new Promise<void>((resolve, reject) =>
setTimeout(() => {
this.loop({
start: start + BUNDLE_SIZE,
end: end + BUNDLE_SIZE,
sessionId,
})
.then(() => resolve())
.catch((e) => reject(new Error(e.message)));
}, UPDATE_RATE)
);
}
}
private log(
message: string,
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
optional?: Record<string, unknown>
): void {
logger[level](message, { label: 'Sonarr Sync', ...optional });
}
}
export const jobSonarrSync = new JobSonarrSync();