From ae3818304b2f75222d1bd223ece94f829a3b42d0 Mon Sep 17 00:00:00 2001 From: Brandon Cohen Date: Fri, 24 Feb 2023 00:28:22 -0500 Subject: [PATCH] feat: availability sync rework (#3219) * feat: add availability synchronization job fix #377 * fix: feedback on PR * perf: use pagination for Media Availability Synchronization job The original approach loaded all media items from the database at once. With large libraries, this could lead to performance issues. We're now using a paginated approach with a page size of 50. * feat: updated the availability sync to work with 4k * fix: corrected detection of media in plex * refactor: code cleanup and minimized unnecessary calls * fix: if media is not found, media check will continue * fix: if non-4k or 4k show media is updated, seasons and request is now properly updated * refactor: consolidated media updater into one function * fix: season requests are now removed if season has been deleted * refactor: removed joincolumn * fix: makes sure we will always check radarr/sonarr to see if media exists * fix: media will now only be updated to unavailable and deletion will be prevented * fix: changed types in Media entity * fix: prevent season deletion in preference of setting season to unknown --------- Co-authored-by: Jari Zwarts Co-authored-by: Sebastian Kappen --- server/api/servarr/sonarr.ts | 2 +- server/entity/Media.ts | 48 +- server/entity/MediaRequest.ts | 2 + server/entity/SeasonRequest.ts | 14 + server/job/schedule.ts | 18 + server/lib/availabilitySync.ts | 718 ++++++++++++++++++ server/lib/settings.ts | 6 +- .../Settings/SettingsJobsCache/index.tsx | 1 + src/i18n/locale/en.json | 1 + 9 files changed, 788 insertions(+), 22 deletions(-) create mode 100644 server/lib/availabilitySync.ts diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index eca0208c..2e423ef3 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -1,7 +1,7 @@ import logger from '@server/logger'; import ServarrBase from './base'; -interface SonarrSeason { +export interface SonarrSeason { seasonNumber: number; monitored: boolean; statistics?: { diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 6a681c47..2d169172 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -114,29 +114,29 @@ class Media { @Column({ type: 'datetime', nullable: true }) public mediaAddedAt: Date; - @Column({ nullable: true }) - public serviceId?: number; + @Column({ nullable: true, type: 'int' }) + public serviceId?: number | null; - @Column({ nullable: true }) - public serviceId4k?: number; + @Column({ nullable: true, type: 'int' }) + public serviceId4k?: number | null; - @Column({ nullable: true }) - public externalServiceId?: number; + @Column({ nullable: true, type: 'int' }) + public externalServiceId?: number | null; - @Column({ nullable: true }) - public externalServiceId4k?: number; + @Column({ nullable: true, type: 'int' }) + public externalServiceId4k?: number | null; - @Column({ nullable: true }) - public externalServiceSlug?: string; + @Column({ nullable: true, type: 'varchar' }) + public externalServiceSlug?: string | null; - @Column({ nullable: true }) - public externalServiceSlug4k?: string; + @Column({ nullable: true, type: 'varchar' }) + public externalServiceSlug4k?: string | null; - @Column({ nullable: true }) - public ratingKey?: string; + @Column({ nullable: true, type: 'varchar' }) + public ratingKey?: string | null; - @Column({ nullable: true }) - public ratingKey4k?: string; + @Column({ nullable: true, type: 'varchar' }) + public ratingKey4k?: string | null; public serviceUrl?: string; public serviceUrl4k?: string; @@ -260,7 +260,9 @@ class Media { if (this.mediaType === MediaType.MOVIE) { if ( this.externalServiceId !== undefined && - this.serviceId !== undefined + this.externalServiceId !== null && + this.serviceId !== undefined && + this.serviceId !== null ) { this.downloadStatus = downloadTracker.getMovieProgress( this.serviceId, @@ -270,7 +272,9 @@ class Media { if ( this.externalServiceId4k !== undefined && - this.serviceId4k !== undefined + this.externalServiceId4k !== null && + this.serviceId4k !== undefined && + this.serviceId4k !== null ) { this.downloadStatus4k = downloadTracker.getMovieProgress( this.serviceId4k, @@ -282,7 +286,9 @@ class Media { if (this.mediaType === MediaType.TV) { if ( this.externalServiceId !== undefined && - this.serviceId !== undefined + this.externalServiceId !== null && + this.serviceId !== undefined && + this.serviceId !== null ) { this.downloadStatus = downloadTracker.getSeriesProgress( this.serviceId, @@ -292,7 +298,9 @@ class Media { if ( this.externalServiceId4k !== undefined && - this.serviceId4k !== undefined + this.externalServiceId4k !== null && + this.serviceId4k !== undefined && + this.serviceId4k !== null ) { this.downloadStatus4k = downloadTracker.getSeriesProgress( this.serviceId4k, diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index fad97ef6..61122afc 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -1187,3 +1187,5 @@ export class MediaRequest { } } } + +export default MediaRequest; diff --git a/server/entity/SeasonRequest.ts b/server/entity/SeasonRequest.ts index f9eeef50..c55906eb 100644 --- a/server/entity/SeasonRequest.ts +++ b/server/entity/SeasonRequest.ts @@ -1,5 +1,7 @@ import { MediaRequestStatus } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; import { + AfterRemove, Column, CreateDateColumn, Entity, @@ -34,6 +36,18 @@ class SeasonRequest { constructor(init?: Partial) { Object.assign(this, init); } + + @AfterRemove() + public async handleRemoveParent(): Promise { + const mediaRequestRepository = getRepository(MediaRequest); + const requestToBeDeleted = await mediaRequestRepository.findOneOrFail({ + where: { id: this.request.id }, + }); + + if (requestToBeDeleted.seasons.length === 0) { + await mediaRequestRepository.delete({ id: this.request.id }); + } + } } export default SeasonRequest; diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 7925dd21..998abf1f 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -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'; @@ -104,6 +105,23 @@ export const startJobs = (): void => { cancelFn: () => sonarrScanner.cancel(), }); + // Checks if media is still available in plex/sonarr/radarr libs + scheduledJobs.push({ + id: 'availability-sync', + name: 'Media Availability Sync', + type: 'process', + interval: 'hours', + cronSchedule: jobs['availability-sync'].schedule, + job: schedule.scheduleJob(jobs['availability-sync'].schedule, () => { + logger.info('Starting scheduled job: Media Availability Sync', { + label: 'Jobs', + }); + availabilitySync.run(); + }), + running: () => availabilitySync.running, + cancelFn: () => availabilitySync.cancel(), + }); + // Run download sync every minute scheduledJobs.push({ id: 'download-sync', diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts new file mode 100644 index 00000000..d192259f --- /dev/null +++ b/server/lib/availabilitySync.ts @@ -0,0 +1,718 @@ +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.info( + `${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.info( + `${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.info( + `${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; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 86bcf144..8e66ebc5 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -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; @@ -409,6 +410,9 @@ class Settings { 'sonarr-scan': { schedule: '0 30 4 * * *', }, + 'availability-sync': { + schedule: '0 0 5 * * *', + }, 'download-sync': { schedule: '0 * * * * *', }, diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx index 8193a3bb..72bed7c4 100644 --- a/src/components/Settings/SettingsJobsCache/index.tsx +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -53,6 +53,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({ 'plex-recently-added-scan': 'Plex Recently Added Scan', 'plex-full-scan': 'Plex Full Library Scan', 'plex-watchlist-sync': 'Plex Watchlist Sync', + 'availability-sync': 'Media Availability Sync', 'radarr-scan': 'Radarr Scan', 'sonarr-scan': 'Sonarr Scan', 'download-sync': 'Download Sync', diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 0315916d..a762f951 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -721,6 +721,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.",