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 MediaRequest from '@server/entity/MediaRequest'; import Season from '@server/entity/Season'; import SeasonRequest from '@server/entity/SeasonRequest'; 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 = {}; private sonarrSeasonsCache: Record = {}; 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.info(`Starting availability sync...`, { label: 'AvailabilitySync', }); const mediaRepository = getRepository(Media); const requestRepository = getRepository(MediaRequest); const seasonRepository = getRepository(Season); const seasonRequestRepository = getRepository(SeasonRequest); const pageSize = 50; try { for await (const media of this.loadAvailableMediaPaginated(pageSize)) { try { if (!this.running) { throw new Error('Job aborted'); } const mediaExists = await this.mediaExists(media); //We can not delete media so if both versions do not exist, we will change both columns to unknown or null if (!mediaExists) { if ( media.status !== MediaStatus.UNKNOWN || media.status4k !== MediaStatus.UNKNOWN ) { const request = await requestRepository.find({ relations: { media: true, }, where: { media: { id: media.id } }, }); logger.info( `${ media.mediaType === 'tv' ? media.tvdbId : media.tmdbId } does not exist in any of your media instances. We will change its status to unknown.`, { label: 'AvailabilitySync' } ); await mediaRepository.update(media.id, { status: MediaStatus.UNKNOWN, status4k: MediaStatus.UNKNOWN, serviceId: null, serviceId4k: null, externalServiceId: null, externalServiceId4k: null, externalServiceSlug: null, externalServiceSlug4k: null, ratingKey: null, ratingKey4k: null, }); await requestRepository.remove(request); } } 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 } }, { status: MediaStatus.PARTIALLY_AVAILABLE, media: { id: media.id }, }, { status4k: MediaStatus.AVAILABLE, media: { id: media.id } }, { status4k: MediaStatus.PARTIALLY_AVAILABLE, media: { id: media.id }, }, ], }); let didDeleteSeasons = false; for (const season of seasons) { if ( !mediaExists && (season.status !== MediaStatus.UNKNOWN || season.status4k !== MediaStatus.UNKNOWN) ) { await seasonRepository.update( { id: season.id }, { status: MediaStatus.UNKNOWN, status4k: MediaStatus.UNKNOWN, } ); } else { const seasonExists = await this.seasonExists(media, season); if (!seasonExists) { logger.info( `Removing season ${season.seasonNumber}, media id: ${media.tvdbId} because it does not exist in any of your media instances.`, { label: 'AvailabilitySync' } ); if ( season.status !== MediaStatus.UNKNOWN || season.status4k !== MediaStatus.UNKNOWN ) { await seasonRepository.update( { id: season.id }, { status: MediaStatus.UNKNOWN, status4k: MediaStatus.UNKNOWN, } ); } const seasonToBeDeleted = await seasonRequestRepository.findOne({ relations: { request: { media: true, }, }, where: { request: { media: { id: media.id, }, }, seasonNumber: season.seasonNumber, }, }); if (seasonToBeDeleted) { await seasonRequestRepository.remove(seasonToBeDeleted); } didDeleteSeasons = true; } } if (didDeleteSeasons) { if ( media.status === MediaStatus.AVAILABLE || media.status4k === MediaStatus.AVAILABLE ) { logger.info( `Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted some of its 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('Failure with media.', { errorMessage: ex.message, label: 'AvailabilitySync', }); } } } catch (ex) { logger.error('Failed to complete availability sync.', { errorMessage: ex.message, label: 'AvailabilitySync', }); } finally { logger.info(`Availability sync complete.`, { label: 'AvailabilitySync', }); this.running = false; } } public cancel() { this.running = false; } private async *loadAvailableMediaPaginated(pageSize: number) { let offset = 0; const mediaRepository = getRepository(Media); const whereOptions = [ { status: MediaStatus.AVAILABLE }, { status: MediaStatus.PARTIALLY_AVAILABLE }, { status4k: MediaStatus.AVAILABLE }, { status4k: MediaStatus.PARTIALLY_AVAILABLE }, ]; let mediaPage: Media[]; do { yield* (mediaPage = await mediaRepository.find({ where: whereOptions, skip: offset, take: pageSize, })); offset += pageSize; } while (mediaPage.length > 0); } private async mediaUpdater(media: Media, is4k: boolean): Promise { const mediaRepository = getRepository(Media); const requestRepository = getRepository(MediaRequest); const isTVType = media.mediaType === 'tv'; const request = await requestRepository.findOne({ relations: { media: true, }, where: { media: { id: media.id }, is4k: is4k ? true : false }, }); logger.info( `${media.tmdbId} does not exist in your ${is4k ? '4k' : 'non-4k'} ${ isTVType ? 'sonarr' : 'radarr' } and plex instance. We will change its status to unknown.`, { label: 'AvailabilitySync' } ); await mediaRepository.update( media.id, is4k ? { status4k: MediaStatus.UNKNOWN, serviceId4k: null, externalServiceId4k: null, externalServiceSlug4k: null, ratingKey4k: null, } : { status: MediaStatus.UNKNOWN, serviceId: null, externalServiceId: null, externalServiceSlug: null, ratingKey: null, } ); if (isTVType) { const seasonRepository = getRepository(Season); await seasonRepository?.update( { media: { id: media.id } }, is4k ? { status4k: MediaStatus.UNKNOWN } : { status: MediaStatus.UNKNOWN } ); } await requestRepository.delete({ id: request?.id }); } private async mediaExistsInRadarr( media: Media, existsInPlex: boolean, existsInPlex4k: boolean ): Promise { let existsInRadarr = true; let existsInRadarr4k = true; 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); //check if both exist or if a single non-4k or 4k exists //if both do not exist we will return false if (!server.is4k && !meta.id) { existsInRadarr = false; } if (server.is4k && !meta.id) { existsInRadarr4k = false; } } if (existsInRadarr && existsInRadarr4k) { return true; } if (!existsInRadarr && existsInPlex) { return true; } if (!existsInRadarr4k && existsInPlex4k) { return true; } //if only a single non-4k or 4k exists, then change entity columns accordingly //related media request will then be deleted if (!existsInRadarr && existsInRadarr4k && !existsInPlex) { if (media.status !== MediaStatus.UNKNOWN) { this.mediaUpdater(media, false); } } if (existsInRadarr && !existsInRadarr4k && !existsInPlex4k) { if (media.status4k !== MediaStatus.UNKNOWN) { this.mediaUpdater(media, true); } } if (existsInRadarr || existsInRadarr4k) { return true; } return false; } private async mediaExistsInSonarr( media: Media, existsInPlex: boolean, existsInPlex4k: boolean ): Promise { if (!media.tvdbId) { return false; } let existsInSonarr = true; let existsInSonarr4k = true; 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; //check if both exist or if a single non-4k or 4k exists //if both do not exist we will return false if (!server.is4k && !meta.id) { existsInSonarr = false; } if (server.is4k && !meta.id) { existsInSonarr4k = false; } } if (existsInSonarr && existsInSonarr4k) { return true; } if (!existsInSonarr && existsInPlex) { return true; } if (!existsInSonarr4k && existsInPlex4k) { return true; } //if only a single non-4k or 4k exists, then change entity columns accordingly //related media request will then be deleted if (!existsInSonarr && existsInSonarr4k && !existsInPlex) { if (media.status !== MediaStatus.UNKNOWN) { this.mediaUpdater(media, false); } } if (existsInSonarr && !existsInSonarr4k && !existsInPlex4k) { if (media.status4k !== MediaStatus.UNKNOWN) { this.mediaUpdater(media, true); } } if (existsInSonarr || existsInSonarr4k) { return true; } return false; } private async seasonExistsInSonarr( media: Media, season: Season, seasonExistsInPlex: boolean, seasonExistsInPlex4k: boolean ): Promise { if (!media.tvdbId) { return false; } let seasonExistsInSonarr = true; let seasonExistsInSonarr4k = true; const mediaRepository = getRepository(Media); const seasonRepository = getRepository(Season); const seasonRequestRepository = getRepository(SeasonRequest); 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 (!server.is4k && !hasMonitoredSeason) { seasonExistsInSonarr = false; } if (server.is4k && !hasMonitoredSeason) { seasonExistsInSonarr4k = false; } } if (seasonExistsInSonarr && seasonExistsInSonarr4k) { return true; } if (!seasonExistsInSonarr && seasonExistsInPlex) { return true; } if (!seasonExistsInSonarr4k && seasonExistsInPlex4k) { return true; } const seasonToBeDeleted = await seasonRequestRepository.findOne({ relations: { request: { media: true, }, }, where: { request: { is4k: seasonExistsInSonarr ? true : false, media: { id: media.id, }, }, seasonNumber: season.seasonNumber, }, }); //if season does not exist, we will change status to unknown and delete related season request //if parent media request is empty(all related seasons have been removed), parent is automatically deleted if ( !seasonExistsInSonarr && seasonExistsInSonarr4k && !seasonExistsInPlex ) { if (season.status !== MediaStatus.UNKNOWN) { logger.info( `${media.tvdbId}, season: ${season.seasonNumber} does not exist in your non-4k sonarr and plex instance. We will change its status to unknown.`, { label: 'AvailabilitySync' } ); await seasonRepository.update(season.id, { status: MediaStatus.UNKNOWN, }); if (seasonToBeDeleted) { await seasonRequestRepository.remove(seasonToBeDeleted); } if (media.status === MediaStatus.AVAILABLE) { logger.info( `Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted one of its seasons.`, { label: 'AvailabilitySync' } ); await mediaRepository.update(media.id, { status: MediaStatus.PARTIALLY_AVAILABLE, }); } } } if ( seasonExistsInSonarr && !seasonExistsInSonarr4k && !seasonExistsInPlex4k ) { if (season.status4k !== MediaStatus.UNKNOWN) { logger.info( `${media.tvdbId}, season: ${season.seasonNumber} does not exist in your 4k sonarr and plex instance. We will change its status to unknown.`, { label: 'AvailabilitySync' } ); await seasonRepository.update(season.id, { status4k: MediaStatus.UNKNOWN, }); if (seasonToBeDeleted) { await seasonRequestRepository.remove(seasonToBeDeleted); } if (media.status4k === MediaStatus.AVAILABLE) { logger.info( `Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted one of its seasons.`, { label: 'AvailabilitySync' } ); await mediaRepository.update(media.id, { status4k: MediaStatus.PARTIALLY_AVAILABLE, }); } } } if (seasonExistsInSonarr || seasonExistsInSonarr4k) { return true; } return false; } private async mediaExists(media: Media): Promise { const ratingKey = media.ratingKey; const ratingKey4k = media.ratingKey4k; let existsInPlex = false; let existsInPlex4k = false; //check each plex instance to see if media exists try { if (ratingKey) { const meta = await this.plexClient?.getMetadata(ratingKey); if (meta) { existsInPlex = true; } } if (ratingKey4k) { const meta4k = await this.plexClient?.getMetadata(ratingKey4k); if (meta4k) { existsInPlex4k = true; } } } 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; } } //base case for if both media versions exist in plex if (existsInPlex && existsInPlex4k) { return true; } //we then check radarr or sonarr has that specific media. If not, then we will move to delete //if a non-4k or 4k version exists in at least one of the instances, we will only update that specific version if (media.mediaType === 'movie') { const existsInRadarr = await this.mediaExistsInRadarr( media, existsInPlex, existsInPlex4k ); //if true, media exists in at least one radarr or plex instance. if (existsInRadarr) { logger.warn( `${media.tmdbId} exists in at least one radarr or plex instance. Media will be updated if set to available.`, { label: 'AvailabilitySync', } ); return true; } } if (media.mediaType === 'tv') { const existsInSonarr = await this.mediaExistsInSonarr( media, existsInPlex, existsInPlex4k ); //if true, media exists in at least one sonarr or plex instance. if (existsInSonarr) { logger.warn( `${media.tvdbId} exists in at least one sonarr or plex instance. Media will be updated if set to available.`, { label: 'AvailabilitySync', } ); return true; } } return false; } private async seasonExists(media: Media, season: Season) { const ratingKey = media.ratingKey; const ratingKey4k = media.ratingKey4k; let seasonExistsInPlex = false; let seasonExistsInPlex4k = false; if (ratingKey) { const children = this.plexSeasonsCache[ratingKey] ?? (await this.plexClient?.getChildrenMetadata(ratingKey)) ?? []; this.plexSeasonsCache[ratingKey] = children; const seasonMeta = children?.find( (child) => child.index === season.seasonNumber ); if (seasonMeta) { seasonExistsInPlex = true; } } if (ratingKey4k) { const children4k = this.plexSeasonsCache[ratingKey4k] ?? (await this.plexClient?.getChildrenMetadata(ratingKey4k)) ?? []; this.plexSeasonsCache[ratingKey4k] = children4k; const seasonMeta4k = children4k?.find( (child) => child.index === season.seasonNumber ); if (seasonMeta4k) { seasonExistsInPlex4k = true; } } //base case for if both season versions exist in plex if (seasonExistsInPlex && seasonExistsInPlex4k) { return true; } const existsInSonarr = await this.seasonExistsInSonarr( media, season, seasonExistsInPlex, seasonExistsInPlex4k ); if (existsInSonarr) { logger.warn( `${media.tvdbId}, season: ${season.seasonNumber} exists in at least one sonarr or plex instance. Media will be updated if set to available.`, { label: 'AvailabilitySync', } ); return true; } return false; } private async initPlexClient() { const userRepository = getRepository(User); const admin = await userRepository.findOne({ select: { id: true, plexToken: true }, where: { id: 1 }, }); if (!admin) { logger.warning('No admin configured. Availability sync skipped.'); return; } this.plexClient = new PlexAPI({ plexToken: admin.plexToken }); } } const availabilitySync = new AvailabilitySync(); export default availabilitySync;