diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index eca0208c72..2e423ef39f 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/job/schedule.ts b/server/job/schedule.ts index 725e67b577..d250fb91cb 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,21 @@ 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', + cronSchedule: jobs['availability-sync'].schedule, + 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', diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts new file mode 100644 index 0000000000..1a23af62ef --- /dev/null +++ b/server/lib/availabilitySync.ts @@ -0,0 +1,317 @@ +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 Season from '@server/entity/Season'; +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.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: [ + { status: MediaStatus.AVAILABLE, media: { id: media.id } }, + { status4k: MediaStatus.AVAILABLE, media: { id: media.id } }, + ], + }); + + 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 { + 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 { + 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 { + 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 { + 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 { + 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; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index cf475554ff..7065177aab 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 2600115bc2..e3884d6ac9 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', @@ -94,8 +95,8 @@ type JobModalState = { type JobModalAction = | { type: 'set'; hours?: number; minutes?: number } | { - type: 'close'; - } + type: 'close'; + } | { type: 'open'; job?: Job }; const jobModalReducer = ( @@ -385,7 +386,7 @@ const SettingsJobs = () => { value={Math.floor( (new Date(job.nextExecutionTime).getTime() - Date.now()) / - 1000 + 1000 )} updateIntervalInSeconds={1} numeric="auto" diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 4ffb110e1b..d7a5b063e1 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -628,6 +628,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.", diff --git a/src/i18n/locale/nl.json b/src/i18n/locale/nl.json index de695af034..419076efe8 100644 --- a/src/i18n/locale/nl.json +++ b/src/i18n/locale/nl.json @@ -558,6 +558,7 @@ "components.Settings.SettingsJobsCache.radarr-scan": "Radarr-scan", "components.Settings.SettingsJobsCache.plex-recently-added-scan": "Plex recent toegevoegde scan", "components.Settings.SettingsJobsCache.plex-full-scan": "Plex volledige bibliotheekscan", + "components.Settings.SettingsJobsCache.availability-sync": "Volledige beschikbaarheidsscan", "components.Settings.Notifications.validationUrl": "Je moet een geldige URL opgeven", "components.Settings.Notifications.botAvatarUrl": "URL bot-avatar", "components.RequestList.RequestItem.requested": "Aangevraagd",