diff --git a/server/entity/Media.ts b/server/entity/Media.ts index f01bf3a6f..2fc2a741a 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -103,7 +103,7 @@ class Media { public status4k: MediaStatus; @OneToMany(() => MediaRequest, (request) => request.media, { - cascade: ['insert', 'remove'], + cascade: ['insert', 'update'], }) public requests: MediaRequest[]; diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index c5c28c03e..b14d0609a 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -1,6 +1,4 @@ import TheMovieDb from '@server/api/themoviedb'; -import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; -import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; import { MediaRequestStatus, MediaStatus, @@ -11,13 +9,10 @@ import OverrideRule from '@server/entity/OverrideRule'; import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces'; import notificationManager, { Notification } from '@server/lib/notifications'; import { Permission } from '@server/lib/permissions'; -import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; -import { DbAwareColumn } from '@server/utils/DbColumnHelper'; import { truncate } from 'lodash'; import { AfterInsert, - AfterLoad, AfterUpdate, Column, Entity, @@ -702,74 +697,6 @@ export class MediaRequest { } } - @AfterUpdate() - @AfterInsert() - public async updateParentStatus(): Promise { - const mediaRepository = getRepository(Media); - const media = await mediaRepository.findOne({ - where: { id: this.media.id }, - relations: { requests: true }, - }); - if (!media) { - logger.error('Media data not found', { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - }); - return; - } - const seasonRequestRepository = getRepository(SeasonRequest); - if ( - this.status === MediaRequestStatus.APPROVED && - // Do not update the status if the item is already partially available or available - media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE && - media[this.is4k ? 'status4k' : 'status'] !== - MediaStatus.PARTIALLY_AVAILABLE - ) { - media[this.is4k ? 'status4k' : 'status'] = MediaStatus.PROCESSING; - mediaRepository.save(media); - } - - if ( - media.mediaType === MediaType.MOVIE && - this.status === MediaRequestStatus.DECLINED && - media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED - ) { - media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; - mediaRepository.save(media); - } - - /** - * If the media type is TV, and we are declining a request, - * we must check if its the only pending request and that - * there the current media status is just pending (meaning no - * other requests have yet to be approved) - */ - if ( - media.mediaType === MediaType.TV && - this.status === MediaRequestStatus.DECLINED && - media.requests.filter( - (request) => request.status === MediaRequestStatus.PENDING - ).length === 0 && - media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING && - media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED - ) { - media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; - mediaRepository.save(media); - } - - // Approve child seasons if parent is approved - if ( - media.mediaType === MediaType.TV && - this.status === MediaRequestStatus.APPROVED - ) { - this.seasons.forEach((season) => { - season.status = MediaRequestStatus.APPROVED; - seasonRequestRepository.save(season); - }); - } - } - static async sendNotification( entity: MediaRequest, media: Media, diff --git a/server/subscriber/MediaRequestSubscriber.ts b/server/subscriber/MediaRequestSubscriber.ts index 2b293d97b..9ac6e9b44 100644 --- a/server/subscriber/MediaRequestSubscriber.ts +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -1,14 +1,32 @@ +import type { RadarrMovieOptions } from '@server/api/servarr/radarr'; +import RadarrAPI from '@server/api/servarr/radarr'; +import type { + AddSeriesOptions, + SonarrSeries, +} from '@server/api/servarr/sonarr'; +import SonarrAPI from '@server/api/servarr/sonarr'; import TheMovieDb from '@server/api/themoviedb'; +import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import { MediaRequestStatus, MediaStatus, MediaType, } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; +import SeasonRequest from '@server/entity/SeasonRequest'; import notificationManager, { Notification } from '@server/lib/notifications'; +import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; -import { truncate } from 'lodash'; -import type { EntitySubscriberInterface, UpdateEvent } from 'typeorm'; +import { isEqual, truncate } from 'lodash'; +import type { + EntityManager, + EntitySubscriberInterface, + InsertEvent, + RemoveEvent, + UpdateEvent, +} from 'typeorm'; import { EventSubscriber } from 'typeorm'; @EventSubscriber() @@ -110,21 +128,673 @@ export class MediaRequestSubscriber } } - public afterUpdate(event: UpdateEvent): void { + public async sendToRadarr(entity: MediaRequest): Promise { + if ( + entity.status === MediaRequestStatus.APPROVED && + entity.type === MediaType.MOVIE + ) { + try { + const mediaRepository = getRepository(Media); + const settings = getSettings(); + if (settings.radarr.length === 0 && !settings.radarr[0]) { + logger.info( + 'No Radarr server configured, skipping request processing', + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + return; + } + + let radarrSettings = settings.radarr.find( + (radarr) => radarr.isDefault && radarr.is4k === entity.is4k + ); + + if ( + entity.serverId !== null && + entity.serverId >= 0 && + radarrSettings?.id !== entity.serverId + ) { + radarrSettings = settings.radarr.find( + (radarr) => radarr.id === entity.serverId + ); + logger.info( + `Request has an override server: ${radarrSettings?.name}`, + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + } + + if (!radarrSettings) { + logger.warn( + `There is no default ${ + entity.is4k ? '4K ' : '' + }Radarr server configured. Did you set any of your ${ + entity.is4k ? '4K ' : '' + }Radarr servers as default?`, + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + return; + } + + let rootFolder = radarrSettings.activeDirectory; + let qualityProfile = radarrSettings.activeProfileId; + let tags = radarrSettings.tags ? [...radarrSettings.tags] : []; + + if ( + entity.rootFolder && + entity.rootFolder !== '' && + entity.rootFolder !== radarrSettings.activeDirectory + ) { + rootFolder = entity.rootFolder; + logger.info(`Request has an override root folder: ${rootFolder}`, { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + } + + if ( + entity.profileId && + entity.profileId !== radarrSettings.activeProfileId + ) { + qualityProfile = entity.profileId; + logger.info( + `Request has an override quality profile ID: ${qualityProfile}`, + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + } + + if (entity.tags && !isEqual(entity.tags, radarrSettings.tags)) { + tags = entity.tags; + logger.info(`Request has override tags`, { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + tagIds: tags, + }); + } + + const tmdb = new TheMovieDb(); + const radarr = new RadarrAPI({ + apiKey: radarrSettings.apiKey, + url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'), + }); + const movie = await tmdb.getMovie({ movieId: entity.media.tmdbId }); + + const media = await mediaRepository.findOne({ + where: { id: entity.media.id }, + }); + + if (!media) { + logger.error('Media data not found', { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + return; + } + + if (radarrSettings.tagRequests) { + let userTag = (await radarr.getTags()).find((v) => + v.label.startsWith(entity.requestedBy.id + ' - ') + ); + if (!userTag) { + logger.info(`Requester has no active tag. Creating new`, { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + userId: entity.requestedBy.id, + newTag: + entity.requestedBy.id + ' - ' + entity.requestedBy.displayName, + }); + userTag = await radarr.createTag({ + label: + entity.requestedBy.id + ' - ' + entity.requestedBy.displayName, + }); + } + if (userTag.id) { + if (!tags?.find((v) => v === userTag?.id)) { + tags?.push(userTag.id); + } + } else { + logger.warn(`Requester has no tag and failed to add one`, { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + userId: entity.requestedBy.id, + radarrServer: radarrSettings.hostname + ':' + radarrSettings.port, + }); + } + } + + if ( + media[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE + ) { + logger.warn('Media already exists, marking request as APPROVED', { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + + const requestRepository = getRepository(MediaRequest); + entity.status = MediaRequestStatus.APPROVED; + await requestRepository.save(entity); + return; + } + + const radarrMovieOptions: RadarrMovieOptions = { + profileId: qualityProfile, + qualityProfileId: qualityProfile, + rootFolderPath: rootFolder, + minimumAvailability: radarrSettings.minimumAvailability, + title: movie.title, + tmdbId: movie.id, + year: Number(movie.release_date.slice(0, 4)), + monitored: true, + tags, + searchNow: !radarrSettings.preventSearch, + }; + + // Run entity asynchronously so we don't wait for it on the UI side + radarr + .addMovie(radarrMovieOptions) + .then(async (radarrMovie) => { + // We grab media again here to make sure we have the latest version of it + const media = await mediaRepository.findOne({ + where: { id: entity.media.id }, + }); + + if (!media) { + throw new Error('Media data not found'); + } + + media[entity.is4k ? 'externalServiceId4k' : 'externalServiceId'] = + radarrMovie.id; + media[ + entity.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug' + ] = radarrMovie.titleSlug; + media[entity.is4k ? 'serviceId4k' : 'serviceId'] = + radarrSettings?.id; + await mediaRepository.save(media); + }) + .catch(async () => { + const requestRepository = getRepository(MediaRequest); + + entity.status = MediaRequestStatus.FAILED; + requestRepository.save(entity); + + logger.warn( + 'Something went wrong sending movie request to Radarr, marking status as FAILED', + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + radarrMovieOptions, + } + ); + + MediaRequest.sendNotification( + entity, + media, + Notification.MEDIA_FAILED + ); + }); + logger.info('Sent request to Radarr', { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + } catch (e) { + logger.error('Something went wrong sending request to Radarr', { + label: 'Media Request', + errorMessage: e.message, + requestId: entity.id, + mediaId: entity.media.id, + }); + throw new Error(e.message); + } + } + } + + public async sendToSonarr(entity: MediaRequest): Promise { + if ( + entity.status === MediaRequestStatus.APPROVED && + entity.type === MediaType.TV + ) { + try { + const mediaRepository = getRepository(Media); + const settings = getSettings(); + if (settings.sonarr.length === 0 && !settings.sonarr[0]) { + logger.warn( + 'No Sonarr server configured, skipping request processing', + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + return; + } + + let sonarrSettings = settings.sonarr.find( + (sonarr) => sonarr.isDefault && sonarr.is4k === entity.is4k + ); + + if ( + entity.serverId !== null && + entity.serverId >= 0 && + sonarrSettings?.id !== entity.serverId + ) { + sonarrSettings = settings.sonarr.find( + (sonarr) => sonarr.id === entity.serverId + ); + logger.info( + `Request has an override server: ${sonarrSettings?.name}`, + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + } + + if (!sonarrSettings) { + logger.warn( + `There is no default ${ + entity.is4k ? '4K ' : '' + }Sonarr server configured. Did you set any of your ${ + entity.is4k ? '4K ' : '' + }Sonarr servers as default?`, + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + return; + } + + const media = await mediaRepository.findOne({ + where: { id: entity.media.id }, + relations: { requests: true }, + }); + + if (!media) { + throw new Error('Media data not found'); + } + + if ( + media[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE + ) { + logger.warn('Media already exists, marking request as APPROVED', { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + + const requestRepository = getRepository(MediaRequest); + entity.status = MediaRequestStatus.APPROVED; + await requestRepository.save(entity); + return; + } + + const tmdb = new TheMovieDb(); + const sonarr = new SonarrAPI({ + apiKey: sonarrSettings.apiKey, + url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'), + }); + const series = await tmdb.getTvShow({ tvId: media.tmdbId }); + const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; + + if (!tvdbId) { + const requestRepository = getRepository(MediaRequest); + await mediaRepository.remove(media); + await requestRepository.remove(entity); + throw new Error('TVDB ID not found'); + } + + let seriesType: SonarrSeries['seriesType'] = 'standard'; + + // Change series type to anime if the anime keyword is present on tmdb + if ( + series.keywords.results.some( + (keyword) => keyword.id === ANIME_KEYWORD_ID + ) + ) { + seriesType = sonarrSettings.animeSeriesType ?? 'anime'; + } + + let rootFolder = + seriesType === 'anime' && sonarrSettings.activeAnimeDirectory + ? sonarrSettings.activeAnimeDirectory + : sonarrSettings.activeDirectory; + let qualityProfile = + seriesType === 'anime' && sonarrSettings.activeAnimeProfileId + ? sonarrSettings.activeAnimeProfileId + : sonarrSettings.activeProfileId; + let languageProfile = + seriesType === 'anime' && sonarrSettings.activeAnimeLanguageProfileId + ? sonarrSettings.activeAnimeLanguageProfileId + : sonarrSettings.activeLanguageProfileId; + let tags = + seriesType === 'anime' + ? sonarrSettings.animeTags + ? [...sonarrSettings.animeTags] + : [] + : sonarrSettings.tags + ? [...sonarrSettings.tags] + : []; + + if ( + entity.rootFolder && + entity.rootFolder !== '' && + entity.rootFolder !== rootFolder + ) { + rootFolder = entity.rootFolder; + logger.info(`Request has an override root folder: ${rootFolder}`, { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + } + + if (entity.profileId && entity.profileId !== qualityProfile) { + qualityProfile = entity.profileId; + logger.info( + `Request has an override quality profile ID: ${qualityProfile}`, + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + } + + if ( + entity.languageProfileId && + entity.languageProfileId !== languageProfile + ) { + languageProfile = entity.languageProfileId; + logger.info( + `Request has an override language profile ID: ${languageProfile}`, + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + } + + if (entity.tags && !isEqual(entity.tags, tags)) { + tags = entity.tags; + logger.info(`Request has override tags`, { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + tagIds: tags, + }); + } + + if (sonarrSettings.tagRequests) { + let userTag = (await sonarr.getTags()).find((v) => + v.label.startsWith(entity.requestedBy.id + ' - ') + ); + if (!userTag) { + logger.info(`Requester has no active tag. Creating new`, { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + userId: entity.requestedBy.id, + newTag: + entity.requestedBy.id + ' - ' + entity.requestedBy.displayName, + }); + userTag = await sonarr.createTag({ + label: + entity.requestedBy.id + ' - ' + entity.requestedBy.displayName, + }); + } + if (userTag.id) { + if (!tags?.find((v) => v === userTag?.id)) { + tags?.push(userTag.id); + } + } else { + logger.warn(`Requester has no tag and failed to add one`, { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + userId: entity.requestedBy.id, + sonarrServer: sonarrSettings.hostname + ':' + sonarrSettings.port, + }); + } + } + + const sonarrSeriesOptions: AddSeriesOptions = { + profileId: qualityProfile, + languageProfileId: languageProfile, + rootFolderPath: rootFolder, + title: series.name, + tvdbid: tvdbId, + seasons: entity.seasons.map((season) => season.seasonNumber), + seasonFolder: sonarrSettings.enableSeasonFolders, + seriesType, + tags, + monitored: true, + searchNow: !sonarrSettings.preventSearch, + }; + + // Run entity asynchronously so we don't wait for it on the UI side + sonarr + .addSeries(sonarrSeriesOptions) + .then(async (sonarrSeries) => { + // We grab media again here to make sure we have the latest version of it + const media = await mediaRepository.findOne({ + where: { id: entity.media.id }, + relations: { requests: true }, + }); + + if (!media) { + throw new Error('Media data not found'); + } + + media[entity.is4k ? 'externalServiceId4k' : 'externalServiceId'] = + sonarrSeries.id; + media[ + entity.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug' + ] = sonarrSeries.titleSlug; + media[entity.is4k ? 'serviceId4k' : 'serviceId'] = + sonarrSettings?.id; + await mediaRepository.save(media); + }) + .catch(async () => { + const requestRepository = getRepository(MediaRequest); + + entity.status = MediaRequestStatus.FAILED; + requestRepository.save(entity); + + logger.warn( + 'Something went wrong sending series request to Sonarr, marking status as FAILED', + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + sonarrSeriesOptions, + } + ); + + MediaRequest.sendNotification( + entity, + media, + Notification.MEDIA_FAILED + ); + }); + logger.info('Sent request to Sonarr', { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + } catch (e) { + logger.error('Something went wrong sending request to Sonarr', { + label: 'Media Request', + errorMessage: e.message, + requestId: entity.id, + mediaId: entity.media.id, + }); + throw new Error(e.message); + } + } + } + + public async updateParentStatus(entity: MediaRequest): Promise { + const mediaRepository = getRepository(Media); + const media = await mediaRepository.findOne({ + where: { id: entity.media.id }, + relations: { requests: true }, + }); + if (!media) { + logger.error('Media data not found', { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + return; + } + const seasonRequestRepository = getRepository(SeasonRequest); + if ( + entity.status === MediaRequestStatus.APPROVED && + // Do not update the status if the item is already partially available or available + media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE && + media[entity.is4k ? 'status4k' : 'status'] !== + MediaStatus.PARTIALLY_AVAILABLE && + media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.PROCESSING + ) { + media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.PROCESSING; + mediaRepository.save(media); + } + + if ( + media.mediaType === MediaType.MOVIE && + entity.status === MediaRequestStatus.DECLINED && + media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED + ) { + media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; + mediaRepository.save(media); + } + + /** + * If the media type is TV, and we are declining a request, + * we must check if its the only pending request and that + * there the current media status is just pending (meaning no + * other requests have yet to be approved) + */ + if ( + media.mediaType === MediaType.TV && + entity.status === MediaRequestStatus.DECLINED && + media.requests.filter( + (request) => request.status === MediaRequestStatus.PENDING + ).length === 0 && + media[entity.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING && + media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED + ) { + media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; + mediaRepository.save(media); + } + + // Approve child seasons if parent is approved + if ( + media.mediaType === MediaType.TV && + entity.status === MediaRequestStatus.APPROVED + ) { + entity.seasons.forEach((season) => { + season.status = MediaRequestStatus.APPROVED; + seasonRequestRepository.save(season); + }); + } + } + + public async handleRemoveParentUpdate( + manager: EntityManager, + entity: MediaRequest + ): Promise { + const fullMedia = await manager.findOneOrFail(Media, { + where: { id: entity.media.id }, + relations: { requests: true }, + }); + + if (!fullMedia) return; + + if ( + !fullMedia.requests.some((request) => !request.is4k) && + fullMedia.status !== MediaStatus.AVAILABLE + ) { + fullMedia.status = MediaStatus.UNKNOWN; + } + + if ( + !fullMedia.requests.some((request) => request.is4k) && + fullMedia.status4k !== MediaStatus.AVAILABLE + ) { + fullMedia.status4k = MediaStatus.UNKNOWN; + } + + await manager.save(fullMedia); + } + + public async afterUpdate(event: UpdateEvent): Promise { if (!event.entity) { return; } + await this.sendToRadarr(event.entity as MediaRequest); + await this.sendToSonarr(event.entity as MediaRequest); + + await this.updateParentStatus(event.entity as MediaRequest); + if (event.entity.status === MediaRequestStatus.COMPLETED) { if (event.entity.media.mediaType === MediaType.MOVIE) { - this.notifyAvailableMovie(event.entity as MediaRequest); + await this.notifyAvailableMovie(event.entity as MediaRequest); } if (event.entity.media.mediaType === MediaType.TV) { - this.notifyAvailableSeries(event.entity as MediaRequest); + await this.notifyAvailableSeries(event.entity as MediaRequest); } } } + public async afterInsert(event: InsertEvent): Promise { + if (!event.entity) { + return; + } + + await this.sendToRadarr(event.entity as MediaRequest); + await this.sendToSonarr(event.entity as MediaRequest); + + await this.updateParentStatus(event.entity as MediaRequest); + } + + public async afterRemove(event: RemoveEvent): Promise { + if (!event.entity) { + return; + } + + await this.handleRemoveParentUpdate( + event.manager as EntityManager, + event.entity as MediaRequest + ); + } + public listenTo(): typeof MediaRequest { return MediaRequest; }