import type { PlexMetadata } from '@server/api/plexapi'; import PlexAPI from '@server/api/plexapi'; import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr'; import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import MediaRequest from '@server/entity/MediaRequest'; import type 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); try { logger.info(`Starting availability sync...`, { label: 'AvailabilitySync', }); const pageSize = 50; const userRepository = getRepository(User); const admin = await userRepository.findOne({ select: { id: true, plexToken: true }, where: { id: 1 }, }); if (admin) { this.plexClient = new PlexAPI({ plexToken: admin.plexToken }); } else { logger.error('An admin is not configured.'); } for await (const media of this.loadAvailableMediaPaginated(pageSize)) { if (!this.running) { throw new Error('Job aborted'); } // Check plex, radarr, and sonarr for that specific media and // if unavailable, then we change the status accordingly. // 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') { let movieExists = false; let movieExists4k = false; const { existsInPlex } = await this.mediaExistsInPlex(media, false); const { existsInPlex: existsInPlex4k } = await this.mediaExistsInPlex( media, true ); const existsInRadarr = await this.mediaExistsInRadarr(media, false); const existsInRadarr4k = await this.mediaExistsInRadarr(media, true); if (existsInPlex || existsInRadarr) { movieExists = true; logger.info( `The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, { label: 'AvailabilitySync', } ); } if (existsInPlex4k || existsInRadarr4k) { movieExists4k = true; logger.info( `The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, { label: 'AvailabilitySync', } ); } if (!movieExists && media.status === MediaStatus.AVAILABLE) { await this.mediaUpdater(media, false); } if (!movieExists4k && media.status4k === MediaStatus.AVAILABLE) { await this.mediaUpdater(media, true); } } // If both versions still exist in plex, we still need // to check through sonarr to verify season availability if (media.mediaType === 'tv') { let showExists = false; let showExists4k = false; const { existsInPlex, seasonsMap: plexSeasonsMap = new Map() } = await this.mediaExistsInPlex(media, false); const { existsInPlex: existsInPlex4k, seasonsMap: plexSeasonsMap4k = new Map(), } = await this.mediaExistsInPlex(media, true); const { existsInSonarr, seasonsMap: sonarrSeasonsMap } = await this.mediaExistsInSonarr(media, false); const { existsInSonarr: existsInSonarr4k, seasonsMap: sonarrSeasonsMap4k, } = await this.mediaExistsInSonarr(media, true); if (existsInPlex || existsInSonarr) { showExists = true; logger.info( `The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, { label: 'AvailabilitySync', } ); } if (existsInPlex4k || existsInSonarr4k) { showExists4k = true; logger.info( `The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, { label: 'AvailabilitySync', } ); } // Here we will create a final map that will cross compare // with plex and sonarr. Filtered seasons will go through // each season and assume the season does not exist. If Plex or // Sonarr finds that season, we will change the final seasons value // to true. const filteredSeasonsMap: Map = new Map(); media.seasons .filter( (season) => season.status === MediaStatus.AVAILABLE || season.status === MediaStatus.PARTIALLY_AVAILABLE ) .forEach((season) => filteredSeasonsMap.set(season.seasonNumber, false) ); const finalSeasons = new Map([ ...filteredSeasonsMap, ...plexSeasonsMap, ...sonarrSeasonsMap, ]); const filteredSeasonsMap4k: Map = new Map(); media.seasons .filter( (season) => season.status4k === MediaStatus.AVAILABLE || season.status4k === MediaStatus.PARTIALLY_AVAILABLE ) .forEach((season) => filteredSeasonsMap4k.set(season.seasonNumber, false) ); const finalSeasons4k = new Map([ ...filteredSeasonsMap4k, ...plexSeasonsMap4k, ...sonarrSeasonsMap4k, ]); if ([...finalSeasons.values()].includes(false)) { await this.seasonUpdater(media, finalSeasons, false); } if ([...finalSeasons4k.values()].includes(false)) { await this.seasonUpdater(media, finalSeasons4k, true); } if ( !showExists && (media.status === MediaStatus.AVAILABLE || media.status === MediaStatus.PARTIALLY_AVAILABLE) ) { await this.mediaUpdater(media, false); } if ( !showExists4k && (media.status4k === MediaStatus.AVAILABLE || media.status4k === MediaStatus.PARTIALLY_AVAILABLE) ) { await this.mediaUpdater(media, true); } } } } 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 findMediaStatus( requests: MediaRequest[], is4k: boolean ): MediaStatus { const filteredRequests = requests.filter( (request) => request.is4k === is4k ); let mediaStatus: MediaStatus; if ( filteredRequests.some( (request) => request.status === MediaRequestStatus.APPROVED ) ) { mediaStatus = MediaStatus.PROCESSING; } else if ( filteredRequests.some( (request) => request.status === MediaRequestStatus.PENDING ) ) { mediaStatus = MediaStatus.PENDING; } else { mediaStatus = MediaStatus.UNKNOWN; } return mediaStatus; } private async mediaUpdater(media: Media, is4k: boolean): Promise { const mediaRepository = getRepository(Media); const requestRepository = getRepository(MediaRequest); try { // Find all related requests only if // the related media has an available status const requests = await requestRepository .createQueryBuilder('request') .leftJoinAndSelect('request.media', 'media') .where('(media.id = :id)', { id: media.id, }) .andWhere( ['show', 'movie'].includes(media.mediaType) ? `(request.is4k = :is4k AND media.${ is4k ? 'status4k' : 'status' } IN (:...mediaStatus))` : '', ['show', 'movie'].includes(media.mediaType) ? { mediaStatus: [ MediaStatus.AVAILABLE, MediaStatus.PARTIALLY_AVAILABLE, ], is4k: is4k, } : {} ) .getMany(); // Check if a season is processing or pending to // make sure we set the media to the correct status let mediaStatus = MediaStatus.UNKNOWN; if (media.mediaType === 'tv') { mediaStatus = this.findMediaStatus(requests, is4k); } media[is4k ? 'status4k' : 'status'] = mediaStatus; media[is4k ? 'serviceId4k' : 'serviceId'] = mediaStatus === MediaStatus.PROCESSING ? media[is4k ? 'serviceId4k' : 'serviceId'] : null; media[is4k ? 'externalServiceId4k' : 'externalServiceId'] = mediaStatus === MediaStatus.PROCESSING ? media[is4k ? 'externalServiceId4k' : 'externalServiceId'] : null; media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = mediaStatus === MediaStatus.PROCESSING ? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] : null; media[is4k ? 'ratingKey4k' : 'ratingKey'] = mediaStatus === MediaStatus.PROCESSING ? media[is4k ? 'ratingKey4k' : 'ratingKey'] : null; logger.info( `The ${is4k ? '4K' : 'non-4K'} ${ media.mediaType === 'movie' ? 'movie' : 'show' } [TMDB ID ${media.tmdbId}] was not found in any ${ media.mediaType === 'movie' ? 'Radarr' : 'Sonarr' } and Plex instance. Status will be changed to unknown.`, { label: 'AvailabilitySync' } ); await mediaRepository.save({ media, ...media }); // Only delete media request if type is movie. // Type tv request deletion is handled // in the season request entity if (requests.length > 0 && media.mediaType === 'movie') { await requestRepository.remove(requests); } } catch (ex) { logger.debug( `Failure updating the ${is4k ? '4K' : 'non-4K'} ${ media.mediaType === 'tv' ? 'show' : 'movie' } [TMDB ID ${media.tmdbId}].`, { errorMessage: ex.message, label: 'AvailabilitySync', } ); } } private async seasonUpdater( media: Media, seasons: Map, is4k: boolean ): Promise { const mediaRepository = getRepository(Media); const seasonRequestRepository = getRepository(SeasonRequest); const seasonsPendingRemoval = new Map( // Disabled linter as only the value is needed from the filter // eslint-disable-next-line @typescript-eslint/no-unused-vars [...seasons].filter(([_, exists]) => !exists) ); const seasonKeys = [...seasonsPendingRemoval.keys()]; try { // Need to check and see if there are any related season // requests. If they are, we will need to delete them. const seasonRequests = await seasonRequestRepository .createQueryBuilder('seasonRequest') .leftJoinAndSelect('seasonRequest.request', 'request') .leftJoinAndSelect('request.media', 'media') .where('(media.id = :id)', { id: media.id }) .andWhere( '(request.is4k = :is4k AND seasonRequest.seasonNumber IN (:...seasonNumbers))', { seasonNumbers: seasonKeys, is4k: is4k, } ) .getMany(); for (const mediaSeason of media.seasons) { if (seasonsPendingRemoval.has(mediaSeason.seasonNumber)) { mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; } } if (media.status === MediaStatus.AVAILABLE) { media.status = MediaStatus.PARTIALLY_AVAILABLE; logger.info( `Marking the non-4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`, { label: 'AvailabilitySync' } ); } if (media.status4k === MediaStatus.AVAILABLE) { media.status4k = MediaStatus.PARTIALLY_AVAILABLE; logger.info( `Marking the 4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`, { label: 'AvailabilitySync' } ); } await mediaRepository.save({ media, ...media }); if (seasonRequests.length > 0) { await seasonRequestRepository.remove(seasonRequests); } logger.info( `The ${is4k ? '4K' : 'non-4K'} season(s) [${seasonKeys}] [TMDB ID ${ media.tmdbId }] was not found in any ${ media.mediaType === 'tv' ? 'Sonarr' : 'Radarr' } and Plex instance. Status will be changed to unknown.`, { label: 'AvailabilitySync' } ); } catch (ex) { logger.debug( `Failure updating the ${ is4k ? '4K' : 'non-4K' } season(s) [${seasonKeys}], TMDB ID ${media.tmdbId}.`, { errorMessage: ex.message, label: 'AvailabilitySync', } ); } } private async mediaExistsInRadarr( media: Media, is4k: boolean ): Promise { let existsInRadarr = false; // Check for availability in all of the available radarr servers // If any find the media, we will assume the media exists for (const server of this.radarrServers) { const radarrAPI = new RadarrAPI({ apiKey: server.apiKey, url: RadarrAPI.buildUrl(server, '/api/v3'), }); try { let radarr: RadarrMovie | undefined; if (!server.is4k && media.externalServiceId && !is4k) { radarr = await radarrAPI.getMovie({ id: media.externalServiceId, }); } if (server.is4k && media.externalServiceId4k && is4k) { radarr = await radarrAPI.getMovie({ id: media.externalServiceId4k, }); } if (radarr && radarr.hasFile) { existsInRadarr = true; } } catch (ex) { if (!ex.message.includes('404')) { existsInRadarr = true; logger.debug( `Failure retrieving the ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${ media.tmdbId }] from Radarr.`, { errorMessage: ex.message, label: 'AvailabilitySync', } ); } } } return existsInRadarr; } private async mediaExistsInSonarr( media: Media, is4k: boolean ): Promise<{ existsInSonarr: boolean; seasonsMap: Map }> { let existsInSonarr = false; let preventSeasonSearch = false; // Check for availability in all of the available sonarr servers // If any find the media, we will assume the media exists for (const server of this.sonarrServers) { const sonarrAPI = new SonarrAPI({ apiKey: server.apiKey, url: SonarrAPI.buildUrl(server, '/api/v3'), }); try { let sonarr: SonarrSeries | undefined; if (!server.is4k && media.externalServiceId && !is4k) { sonarr = await sonarrAPI.getSeriesById(media.externalServiceId); this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] = sonarr.seasons; } if (server.is4k && media.externalServiceId4k && is4k) { sonarr = await sonarrAPI.getSeriesById(media.externalServiceId4k); this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] = sonarr.seasons; } if (sonarr && sonarr.statistics.episodeFileCount > 0) { existsInSonarr = true; } } catch (ex) { if (!ex.message.includes('404')) { existsInSonarr = true; preventSeasonSearch = true; logger.debug( `Failure retrieving the ${is4k ? '4K' : 'non-4K'} show [TMDB ID ${ media.tmdbId }] from Sonarr.`, { errorMessage: ex.message, label: 'AvailabilitySync', } ); } } } // Here we check each season for availability // If the API returns an error other than a 404, // we will have to prevent the season check from happening const seasonsMap: Map = new Map(); if (!preventSeasonSearch) { const filteredSeasons = media.seasons.filter( (season) => season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE || season[is4k ? 'status4k' : 'status'] === MediaStatus.PARTIALLY_AVAILABLE ); for (const season of filteredSeasons) { const seasonExists = await this.seasonExistsInSonarr( media, season, is4k ); if (seasonExists) { seasonsMap.set(season.seasonNumber, true); } } } return { existsInSonarr, seasonsMap }; } private async seasonExistsInSonarr( media: Media, season: Season, is4k: boolean ): Promise { let seasonExists = false; // Check each sonarr instance to see if the media still exists // If found, we will assume the media exists and prevent removal // We can use the cache we built when we fetched the series with mediaExistsInSonarr for (const server of this.sonarrServers) { let sonarrSeasons: SonarrSeason[] | undefined; if (media.externalServiceId && !is4k) { sonarrSeasons = this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`]; } if (media.externalServiceId4k && is4k) { sonarrSeasons = this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`]; } const seasonIsAvailable = sonarrSeasons?.find( ({ seasonNumber, statistics }) => season.seasonNumber === seasonNumber && statistics?.episodeFileCount && statistics?.episodeFileCount > 0 ); if (seasonIsAvailable && sonarrSeasons) { seasonExists = true; } } return seasonExists; } private async mediaExistsInPlex( media: Media, is4k: boolean ): Promise<{ existsInPlex: boolean; seasonsMap?: Map }> { const ratingKey = media.ratingKey; const ratingKey4k = media.ratingKey4k; let existsInPlex = false; let preventSeasonSearch = false; // Check each plex instance to see if the media still exists // If found, we will assume the media exists and prevent removal // We can use the cache we built when we fetched the series with mediaExistsInPlex try { let plexMedia: PlexMetadata | undefined; if (ratingKey && !is4k) { plexMedia = await this.plexClient?.getMetadata(ratingKey); if (media.mediaType === 'tv') { this.plexSeasonsCache[ratingKey] = await this.plexClient?.getChildrenMetadata(ratingKey); } } if (ratingKey4k && is4k) { plexMedia = await this.plexClient?.getMetadata(ratingKey4k); if (media.mediaType === 'tv') { this.plexSeasonsCache[ratingKey4k] = await this.plexClient?.getChildrenMetadata(ratingKey4k); } } if (plexMedia) { existsInPlex = true; } } catch (ex) { if (!ex.message.includes('404')) { existsInPlex = true; preventSeasonSearch = true; logger.debug( `Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${ media.mediaType === 'tv' ? 'show' : 'movie' } [TMDB ID ${media.tmdbId}] from Plex.`, { errorMessage: ex.message, label: 'AvailabilitySync', } ); } } // Here we check each season in plex for availability // If the API returns an error other than a 404, // we will have to prevent the season check from happening if (media.mediaType === 'tv') { const seasonsMap: Map = new Map(); if (!preventSeasonSearch) { const filteredSeasons = media.seasons.filter( (season) => season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE || season[is4k ? 'status4k' : 'status'] === MediaStatus.PARTIALLY_AVAILABLE ); for (const season of filteredSeasons) { const seasonExists = await this.seasonExistsInPlex( media, season, is4k ); if (seasonExists) { seasonsMap.set(season.seasonNumber, true); } } } return { existsInPlex, seasonsMap }; } return { existsInPlex }; } private async seasonExistsInPlex( media: Media, season: Season, is4k: boolean ): Promise { const ratingKey = media.ratingKey; const ratingKey4k = media.ratingKey4k; let seasonExistsInPlex = false; // Check each plex instance to see if the season exists let plexSeasons: PlexMetadata[] | undefined; if (ratingKey && !is4k) { plexSeasons = this.plexSeasonsCache[ratingKey]; } if (ratingKey4k && is4k) { plexSeasons = this.plexSeasonsCache[ratingKey4k]; } const seasonIsAvailable = plexSeasons?.find( (plexSeason) => plexSeason.index === season.seasonNumber ); if (seasonIsAvailable) { seasonExistsInPlex = true; } return seasonExistsInPlex; } } const availabilitySync = new AvailabilitySync(); export default availabilitySync;